Generic joystick / HOTAS / pedals support: DirectInput 8 layer + capture wizard

Tester ask: configure non-Xbox controllers.  New L4JOY layer (DI8):
enumerates every non-XInput game device (up to 4), DIJOYSTATE2 polling
with range normalization, reacquire-on-loss, 3s hot-plug re-enum.
XInput devices are excluded via the documented RawInput IG_ VID/PID
check (live-verified skipping a real XBOX360 pad -- no double-feed).

bindings.txt grows joydev/joyaxis/joybutton/joyhat rows (device slots
by name substring or ordinal; axes X..RZ/SL0/SL1 with invert/slew/
deadzone; hats as 4-direction buttons with diagonal chords).  PadRIO
runs them through the existing channel/event machinery -- per-binding
edge arrays release automatically on detach (the #24 rule), and a
direct throttle axis maps full lever travel onto the 0..1 channel
while the gait detent still snaps a lever parked in the walk/run dead
band.

BT_JOYCONFIG=1 (joyconfig.bat) runs a console capture wizard: move
each control when prompted (stick/twist/throttle/fire), invert derived
from the move direction, hat look cluster auto-added, output written
between markers in bindings.txt with the rest of the file preserved.

Legacy L4DINPUT DIJoystick (L4CONTROLS=DIJOYSTICK) untouched.
Verified: grammar accept/reject per line, XInput-exclusion live,
30s in-mission no-device polling clean.  Awaiting a tester with real
stick hardware for axis verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-23 16:36:32 -05:00
co-authored by Claude Opus 4.8
parent 86e387b6c7
commit c2ee7299b2
12 changed files with 1531 additions and 6 deletions
+128
View File
@@ -11,6 +11,7 @@
#include "l4padpanel.h"
#include "l4glasswin.h"
#include "l4ctrl.h"
#include "l4joy.h"
#include <windows.h>
#include <xinput.h>
@@ -127,6 +128,8 @@ PadRIO::PadRIO():
previousPadButtons(0)
{
memset(previousKeyHeld, 0, sizeof(previousKeyHeld));
memset(previousJoyButtonHeld, 0, sizeof(previousJoyButtonHeld));
memset(previousJoyHatHeld, 0, sizeof(previousJoyHatHeld));
memset(channelValue, 0, sizeof(channelValue));
memset(lampState, 0, sizeof(lampState));
@@ -672,6 +675,131 @@ void
}
}
//
//-----------------------------------------------------------------
// Generic joysticks (DirectInput -- L4JOY): resolve each joydev slot
// to an attached device (name-substring match, else attached device
// #slot), then run the joy bindings through the same channel/event
// machinery the pad uses. Zero DirectInput cost when the profile has
// no joy rows. A detached device reads as all-released, so held
// buttons let go automatically (the issue-#24 rule).
//-----------------------------------------------------------------
//
if (bindings.joyAxisBindingCount > 0
|| bindings.joyButtonBindingCount > 0
|| bindings.joyHatBindingCount > 0)
{
BTJoyPoll();
const BTJoyDeviceState *slotState[PadBindingProfile::JoyDeviceSlots];
for (int s = 0; s < PadBindingProfile::JoyDeviceSlots; ++s)
{
slotState[s] = (bindings.joyDeviceMatch[s][0] != '\0')
? BTJoyDevice(BTJoyFindDevice(bindings.joyDeviceMatch[s]))
: BTJoyDevice(s);
}
for (int b = 0; b < bindings.joyButtonBindingCount; ++b)
{
const PadBindingProfile::JoyButtonBinding &binding =
bindings.joyButtonBindings[b];
const BTJoyDeviceState *state = slotState[binding.device];
int held = state != 0
&& (state->buttons & (1u << binding.button)) != 0;
int was_held = previousJoyButtonHeld[b];
previousJoyButtonHeld[b] = (unsigned char)held;
if (held == was_held)
{
continue;
}
if (binding.action.kind == PadBindingProfile::ActionButton)
{
EmitButton(binding.action.address, held);
}
else if (binding.action.kind == PadBindingProfile::ActionKeypad
&& held)
{
EmitKeypad(binding.action.address, binding.action.key);
}
}
for (int hb = 0; hb < bindings.joyHatBindingCount; ++hb)
{
const PadBindingProfile::JoyHatBinding &binding =
bindings.joyHatBindings[hb];
const BTJoyDeviceState *state = slotState[binding.device];
int held = 0;
if (state != 0 && state->hat[binding.hat] >= 0)
{
//
// Held when the POV is within 60 degrees of the bound
// direction -- a 45-degree diagonal drives BOTH adjacent
// directions (the d-pad chord model).
//
int diff = state->hat[binding.hat] - binding.direction * 9000;
if (diff < 0) diff = -diff;
if (diff > 18000) diff = 36000 - diff;
held = (diff <= 6000);
}
int was_held = previousJoyHatHeld[hb];
previousJoyHatHeld[hb] = (unsigned char)held;
if (held != was_held)
{
EmitButton(binding.action.address, held);
}
}
for (int a = 0; a < bindings.joyAxisBindingCount; ++a)
{
const PadBindingProfile::JoyAxisBinding &binding =
bindings.joyAxisBindings[a];
const BTJoyDeviceState *state = slotState[binding.device];
if (state == 0)
{
continue; // detached: leave the channel alone
}
float raw = state->axis[binding.axis];
if (binding.deadzone > 0.0f)
{
float magnitude = raw < 0.0f ? -raw : raw;
raw = (magnitude <= binding.deadzone)
? 0.0f
: (raw < 0.0f ? -1.0f : 1.0f)
* (magnitude - binding.deadzone)
/ (1.0f - binding.deadzone);
}
if (binding.invert)
{
raw = -raw;
}
if (binding.slew)
{
if (raw != 0.0f)
{
slewHeld[binding.channel] = 1;
}
channelValue[binding.channel] += raw * binding.slewRate * dt;
}
else if (binding.channel == PadBindingProfile::ChannelThrottle)
{
//
// A physical lever OWNS the 0..1 throttle: full axis travel
// maps [-1,1] -> [0,1] and writes EVERY frame (the lever's
// rest position is wherever it sits, not "centered").
// slewHeld stays clear so the GAIT DETENT below still snaps
// a lever PARKED in the walk/run dead band to the nearer
// edge -- the published value never hunts even though the
// physical lever has no notches.
//
channelValue[binding.channel] = (raw + 1.0f) * 0.5f;
}
else if (raw != 0.0f)
{
channelValue[binding.channel] = raw;
}
}
}
//
//-----------------------------------------------------------------
// GAIT DETENT (keyboard/pad accommodation, ported from the pod-build