Make FFB direction encoding for emulated G27 configurable (#18776)

This adds a dropdown in the emulated G27 settings dialog ("Force
feedback direction encoding") with three options: Steering Axis
(default), Cartesian, Polar. Default is Steering Axis so existing users
see no change.

RPCS3 right now hardcodes SDL_HAPTIC_STEERING_AXIS for the spring and
constant-force effects it sends to the host wheel. That's SDL's
recommended encoding for wheels and works on the hardware the code was
written against. Some Windows DirectInput drivers (like my MOZA R3) do
not react to these affects at all. This now allows changing it to an
encoding that works.
This commit is contained in:
Nico Franke 2026-06-05 20:09:35 +02:00 committed by GitHub
parent 8112c7cc19
commit 8d9abd396b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 76 additions and 19 deletions

View File

@ -266,12 +266,28 @@ static const std::map<logitech_personality,
}
};
// ref: https://github.com/libsdl-org/SDL/issues/7941, need to use SDL_HAPTIC_STEERING_AXIS for some windows drivers
static constexpr SDL_HapticDirection STEERING_DIRECTION =
// Configurable because some Windows DirectInput drivers silently ignore
// SDL_HAPTIC_STEERING_AXIS; see https://github.com/libsdl-org/SDL/issues/7941 .
SDL_HapticDirection usb_device_logitech_g27::make_steering_direction() const
{
.type = SDL_HAPTIC_STEERING_AXIS,
.dir = {0, 0, 0}
};
SDL_HapticDirection dir {};
switch (g_cfg_logitech_g27.ffb_direction_type.get())
{
case g27_ffb_direction_type::steering_axis:
dir.type = SDL_HAPTIC_STEERING_AXIS;
dir.dir[0] = 0;
break;
case g27_ffb_direction_type::cartesian:
dir.type = SDL_HAPTIC_CARTESIAN;
dir.dir[0] = 1;
break;
case g27_ffb_direction_type::polar:
dir.type = SDL_HAPTIC_POLAR;
dir.dir[0] = 0;
break;
}
return dir;
}
void usb_device_logitech_g27::set_personality(logitech_personality personality, bool reconnect)
{
@ -298,8 +314,10 @@ usb_device_logitech_g27::usb_device_logitech_g27(u32 controller_index, const std
{
set_personality(logitech_personality::driving_force_ex);
g_cfg_logitech_g27.load();
m_default_spring_effect.type = SDL_HAPTIC_SPRING;
m_default_spring_effect.condition.direction = STEERING_DIRECTION;
m_default_spring_effect.condition.direction = make_steering_direction();
m_default_spring_effect.condition.length = SDL_HAPTIC_INFINITY;
for (int i = 0; i < 1 /*3*/; i++)
{
@ -309,8 +327,6 @@ usb_device_logitech_g27::usb_device_logitech_g27(u32 controller_index, const std
m_default_spring_effect.condition.left_coeff[i] = 0x7FFF;
}
g_cfg_logitech_g27.load();
m_enabled = g_cfg_logitech_g27.enabled.get() && sdl_instance::get_instance().initialize();
if (!m_enabled)
@ -1344,7 +1360,7 @@ void usb_device_logitech_g27::interrupt_transfer(u32 buf_size, u8* buf, u32 endp
{
// Constant force
new_effect.type = SDL_HAPTIC_CONSTANT;
new_effect.constant.direction = STEERING_DIRECTION;
new_effect.constant.direction = make_steering_direction();
new_effect.constant.length = SDL_HAPTIC_INFINITY;
new_effect.constant.level = logitech_g27_force_to_level(buf[2 + i], m_reverse_effects);
break;
@ -1354,7 +1370,7 @@ void usb_device_logitech_g27::interrupt_transfer(u32 buf_size, u8* buf, u32 endp
{
// Spring/High resolution spring
new_effect.type = SDL_HAPTIC_SPRING;
new_effect.condition.direction = STEERING_DIRECTION;
new_effect.condition.direction = make_steering_direction();
new_effect.condition.length = SDL_HAPTIC_INFINITY;
const u8 s1 = buf[5] & 1;
const u8 s2 = (buf[5] >> 4) & 1;
@ -1405,7 +1421,7 @@ void usb_device_logitech_g27::interrupt_transfer(u32 buf_size, u8* buf, u32 endp
{
// Damper/High resolution damper
new_effect.type = SDL_HAPTIC_DAMPER;
new_effect.condition.direction = STEERING_DIRECTION;
new_effect.condition.direction = make_steering_direction();
new_effect.condition.length = SDL_HAPTIC_INFINITY;
const u8 s1 = buf[3] & 1;
const u8 s2 = buf[5] & 1;
@ -1445,7 +1461,7 @@ void usb_device_logitech_g27::interrupt_transfer(u32 buf_size, u8* buf, u32 endp
{
// Friction
new_effect.type = SDL_HAPTIC_FRICTION;
new_effect.condition.direction = STEERING_DIRECTION;
new_effect.condition.direction = make_steering_direction();
new_effect.condition.length = SDL_HAPTIC_INFINITY;
const u8 k1 = buf[2];
const u8 k2 = buf[3];
@ -1472,7 +1488,7 @@ void usb_device_logitech_g27::interrupt_transfer(u32 buf_size, u8* buf, u32 endp
{
// Auto center spring/High resolution auto center spring
new_effect.type = SDL_HAPTIC_SPRING;
new_effect.condition.direction = STEERING_DIRECTION;
new_effect.condition.direction = make_steering_direction();
new_effect.condition.length = SDL_HAPTIC_INFINITY;
const u16 saturation = logitech_g27_clip_to_saturation(buf[4]);
constexpr u16 deadband = 2 * 0xFFFF / 255;
@ -1518,7 +1534,7 @@ void usb_device_logitech_g27::interrupt_transfer(u32 buf_size, u8* buf, u32 endp
else
new_effect.type = m_reverse_effects ? SDL_HAPTIC_SAWTOOTHUP : SDL_HAPTIC_SAWTOOTHDOWN;
new_effect.type = buf[1] == 0x04 ? SDL_HAPTIC_SAWTOOTHUP : SDL_HAPTIC_SAWTOOTHDOWN;
new_effect.periodic.direction = STEERING_DIRECTION;
new_effect.periodic.direction = make_steering_direction();
new_effect.periodic.length = SDL_HAPTIC_INFINITY;
const u8 l1 = buf[2];
const u8 l2 = buf[3];
@ -1558,7 +1574,7 @@ void usb_device_logitech_g27::interrupt_transfer(u32 buf_size, u8* buf, u32 endp
{
// Trapezoid, convert to SDL_HAPTIC_SQUARE or SDL_HAPTIC_TRIANGLE
// TODO full accuracy will need some kind of rendering thread, cannot be represented with a single effect
new_effect.periodic.direction = STEERING_DIRECTION;
new_effect.periodic.direction = make_steering_direction();
new_effect.periodic.length = SDL_HAPTIC_INFINITY;
const u8 l1 = buf[2];
const u8 l2 = buf[3];
@ -1587,7 +1603,7 @@ void usb_device_logitech_g27::interrupt_transfer(u32 buf_size, u8* buf, u32 endp
// Rectangle, convert to SDL_HAPTIC_SQUARE
// TODO full accuracy will need some kind of rendering thread, cannot be represented with a single effect
new_effect.type = SDL_HAPTIC_SQUARE;
new_effect.periodic.direction = STEERING_DIRECTION;
new_effect.periodic.direction = make_steering_direction();
new_effect.periodic.length = SDL_HAPTIC_INFINITY;
const u8 l1 = buf[2];
const u8 l2 = buf[3];
@ -1630,7 +1646,7 @@ void usb_device_logitech_g27::interrupt_transfer(u32 buf_size, u8* buf, u32 endp
const u8 d = i == 0 ? d1 : d2;
const u8 l = i == 0 ? l1 : l2;
new_effect.constant.length = SDL_HAPTIC_INFINITY;
new_effect.constant.direction = STEERING_DIRECTION;
new_effect.constant.direction = make_steering_direction();
if (s == 0 || t == 0)
{
// gran turismo 6 does this, gives a variable force with no step so it just behaves as constant force
@ -1655,7 +1671,7 @@ void usb_device_logitech_g27::interrupt_transfer(u32 buf_size, u8* buf, u32 endp
else
{
new_effect.type = SDL_HAPTIC_RAMP;
new_effect.ramp.direction = STEERING_DIRECTION;
new_effect.ramp.direction = make_steering_direction();
if (l2 > l1)
logitech_g27_log.error("min force is larger than max force in ramp effect, l1 %u l2 %u", l1, l2);
const s16 l1_converted = logitech_g27_force_to_level(l1, m_reverse_effects);
@ -1674,7 +1690,7 @@ void usb_device_logitech_g27::interrupt_transfer(u32 buf_size, u8* buf, u32 endp
{
// Square
new_effect.type = SDL_HAPTIC_SQUARE;
new_effect.periodic.direction = STEERING_DIRECTION;
new_effect.periodic.direction = make_steering_direction();
const u8 a = buf[2];
const u8 tl = buf[3];
const u8 th = buf[4];

View File

@ -126,6 +126,7 @@ private:
void transfer_dfgt(u32 buf_size, u8* buf, UsbTransfer* transfer) const;
void transfer_g25(u32 buf_size, u8* buf, UsbTransfer* transfer) const;
void transfer_g27(u32 buf_size, u8* buf, UsbTransfer* transfer) const;
SDL_HapticDirection make_steering_direction() const;
u32 m_controller_index = 0;

View File

@ -39,6 +39,22 @@ void fmt_class_string<hat_component>::format(std::string& out, u64 arg)
});
}
template <>
void fmt_class_string<g27_ffb_direction_type>::format(std::string& out, u64 arg)
{
format_enum(out, arg, [](g27_ffb_direction_type value)
{
switch (value)
{
case g27_ffb_direction_type::steering_axis: return "steering_axis";
case g27_ffb_direction_type::cartesian: return "cartesian";
case g27_ffb_direction_type::polar: return "polar";
}
return unknown;
});
}
emulated_logitech_g27_config g_cfg_logitech_g27;
LOG_CHANNEL(cfg_log, "CFG");

View File

@ -20,6 +20,13 @@ enum class hat_component
right
};
enum class g27_ffb_direction_type
{
steering_axis = 0,
cartesian = 1,
polar = 2,
};
// this was a bitfield, but juggling at least 3 compilers and OSes means no bitfield
// num_buttons:10 << 52 | num_hats:10 << 42 | num_axes:10 << 32 | vendor_id:16 << 16 | product_id:16
struct emulated_g27_device_type_id
@ -111,6 +118,7 @@ public:
emulated_logitech_g27_mapping shifter_r{this, "shifter_r", 0, sdl_mapping_type::hat, 0, hat_component::left, false};
cfg::_bool reverse_effects{this, "reverse_effects", false};
cfg::_enum<g27_ffb_direction_type> ffb_direction_type{this, "ffb_direction_type", g27_ffb_direction_type::steering_axis};
cfg::uint<0, 4> compatibility_limit{this, "compatibility_limit", 4};
cfg::uint<0, 0xFFFFFFFFFFFFFFFF> ffb_device_type_id{this, "ffb_device_type_id", 0};
cfg::uint<0, 0xFFFFFFFFFFFFFFFF> led_device_type_id{this, "led_device_type_id", 0};

View File

@ -547,6 +547,7 @@ void emulated_logitech_g27_settings_dialog::save_ui_state_to_config()
g_cfg_logitech_g27.enabled.set(m_enabled->isChecked());
g_cfg_logitech_g27.reverse_effects.set(m_reverse_effects->isChecked());
g_cfg_logitech_g27.ffb_direction_type.set(static_cast<g27_ffb_direction_type>(m_ffb_direction_type->currentData().toInt()));
g_cfg_logitech_g27.compatibility_limit.set(m_compatibility_limit->currentData().toInt());
if (m_ffb_device->get_device_choice() == mapping_device::NONE)
@ -597,6 +598,7 @@ void emulated_logitech_g27_settings_dialog::load_ui_state_from_config()
m_enabled->setChecked(g_cfg_logitech_g27.enabled.get());
m_reverse_effects->setChecked(g_cfg_logitech_g27.reverse_effects.get());
m_ffb_direction_type->setCurrentIndex(m_ffb_direction_type->findData(static_cast<int>(g_cfg_logitech_g27.ffb_direction_type.get())));
m_compatibility_limit->setCurrentIndex(4 - g_cfg_logitech_g27.compatibility_limit.get());
}
@ -658,6 +660,18 @@ emulated_logitech_g27_settings_dialog::emulated_logitech_g27_settings_dialog(QWi
m_reverse_effects = new QCheckBox(tr("Reverse force feedback effects"), this);
v_layout->addWidget(m_reverse_effects);
QHBoxLayout* ffb_dir_layout = new QHBoxLayout(this);
ffb_dir_layout->setContentsMargins(0, 0, 0, 0);
QLabel* ffb_dir_label = new QLabel(tr("Force feedback direction encoding:"), this);
ffb_dir_layout->addWidget(ffb_dir_label);
m_ffb_direction_type = new QComboBox(this);
m_ffb_direction_type->addItem(tr("Steering Axis (Default)"), static_cast<int>(g27_ffb_direction_type::steering_axis));
m_ffb_direction_type->addItem(tr("Cartesian"), static_cast<int>(g27_ffb_direction_type::cartesian));
m_ffb_direction_type->addItem(tr("Polar"), static_cast<int>(g27_ffb_direction_type::polar));
m_ffb_direction_type->setToolTip(tr("Selects the direction encoding used for force feedback effects on the host wheel.\nSteering Axis is SDL's recommended encoding for steering wheels and the safest default.\nSwitch to Cartesian or Polar only if your wheel reports no force feedback with the default."));
ffb_dir_layout->addWidget(m_ffb_direction_type);
v_layout->addLayout(ffb_dir_layout);
QHBoxLayout* compat_layout = new QHBoxLayout(this);
compat_layout->setContentsMargins(0, 0, 0, 0);
QLabel* compatibility_label = new QLabel(tr("Compatibility limit:"), this);
@ -880,6 +894,7 @@ void emulated_logitech_g27_settings_dialog::set_enable(bool enable)
m_enabled->setEnabled(enable);
m_reverse_effects->setEnabled(enable);
m_ffb_direction_type->setEnabled(enable);
m_ffb_device->set_enable(enable);
m_led_device->set_enable(enable);

View File

@ -58,6 +58,7 @@ private:
QCheckBox* m_enabled = nullptr;
QCheckBox* m_reverse_effects = nullptr;
QComboBox* m_ffb_direction_type = nullptr;
QComboBox* m_compatibility_limit = nullptr;
std::map<mapping_device, Mapping*> m_mappings;