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
+989
View File
@@ -0,0 +1,989 @@
#include "mungal4.h"
#pragma hdrstop
//###########################################################################
// L4JOY -- generic-joystick reader (DirectInput 8) for the glass cockpit.
// Design + device model: L4JOY.h. Consumed by the PadRIO poll through the
// joydev/joyaxis/joybutton/joyhat bindings (L4PADBINDINGS / L4PADRIO).
//###########################################################################
#include "l4joy.h"
#define DIRECTINPUT_VERSION 0x0800
#include <windows.h>
#include <dinput.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
//###########################################################################
// State
//###########################################################################
struct JoyDevice
{
IDirectInputDevice8A *device;
BTJoyDeviceState state;
//
// Per-axis calibrated range (DIPROP_RANGE is set to +-32767 at open, but
// a driver may refuse; normalization uses what the device reports).
//
LONG axisMin[BTJoyAxisCount];
LONG axisMax[BTJoyAxisCount];
};
static IDirectInput8A *sDirectInput = NULL;
static JoyDevice sDevices[BTJoyMaxDevices];
static int sDeviceCount = 0;
static int sInitialized = 0;
static unsigned long sLastProbeMilliseconds = 0;
static int
JoyLogEnabled()
{
static int s_log = -1;
if (s_log < 0)
{
const char *value = getenv("BT_JOY_LOG");
s_log = (value != NULL && *value != '0') ? 1 : 0;
}
return s_log;
}
//###########################################################################
// XInput-device exclusion.
//
// The documented wbem-free method: every XInput-capable device's RawInput
// device path carries the "IG_" marker; collect the VID/PID of each such
// path once per enumeration pass, and skip any DirectInput device whose
// guidProduct carries a matching VID/PID (DI packs VID in the low word,
// PID in the high word of guidProduct.Data1).
//###########################################################################
enum { XInputVidPidMax = 16 };
static unsigned long sXInputVidPid[XInputVidPidMax];
static int sXInputVidPidCount = 0;
static void
CollectXInputVidPids()
{
sXInputVidPidCount = 0;
UINT device_count = 0;
if (GetRawInputDeviceList(NULL, &device_count, sizeof(RAWINPUTDEVICELIST))
!= 0 || device_count == 0)
{
return;
}
RAWINPUTDEVICELIST *list = (RAWINPUTDEVICELIST *)
malloc(device_count * sizeof(RAWINPUTDEVICELIST));
if (list == NULL)
{
return;
}
device_count = GetRawInputDeviceList(list, &device_count,
sizeof(RAWINPUTDEVICELIST));
if (device_count == (UINT)-1)
{
free(list);
return;
}
for (UINT i = 0; i < device_count; ++i)
{
if (list[i].dwType != RIM_TYPEHID)
{
continue;
}
char path[256];
UINT size = sizeof(path);
if (GetRawInputDeviceInfoA(list[i].hDevice, RIDI_DEVICENAME,
path, &size) == (UINT)-1)
{
continue;
}
path[sizeof(path) - 1] = '\0';
if (strstr(path, "IG_") == NULL && strstr(path, "ig_") == NULL)
{
continue;
}
//
// Parse "...VID_045E&PID_028E..." (case varies by driver).
//
unsigned vid = 0, pid = 0;
const char *v = strstr(path, "VID_");
if (v == NULL) v = strstr(path, "vid_");
const char *p = strstr(path, "PID_");
if (p == NULL) p = strstr(path, "pid_");
if (v == NULL || p == NULL)
{
continue;
}
vid = (unsigned)strtoul(v + 4, NULL, 16);
pid = (unsigned)strtoul(p + 4, NULL, 16);
if (sXInputVidPidCount < XInputVidPidMax)
{
sXInputVidPid[sXInputVidPidCount++] =
((unsigned long)pid << 16) | vid;
}
}
free(list);
}
static int
IsXInputProduct(const GUID &guid_product)
{
//
// DirectInput packs VID low-word / PID high-word into Data1.
//
for (int i = 0; i < sXInputVidPidCount; ++i)
{
if (sXInputVidPid[i] == (unsigned long)guid_product.Data1)
{
return 1;
}
}
return 0;
}
//###########################################################################
// Device open
//###########################################################################
//
// A window handle of THIS process for SetCooperativeLevel. BACKGROUND +
// NONEXCLUSIVE polling matches the XInput model (device readable while the
// game runs; PadRIO's focus rules stay the arbiter of what acts on it).
//
static BOOL CALLBACK
FindProcessWindowCallback(HWND hwnd, LPARAM lparam)
{
DWORD process_id = 0;
GetWindowThreadProcessId(hwnd, &process_id);
if (process_id == GetCurrentProcessId())
{
*(HWND *)lparam = hwnd;
return FALSE;
}
return TRUE;
}
static HWND
FindProcessWindow()
{
HWND hwnd = NULL;
EnumWindows(FindProcessWindowCallback, (LPARAM)&hwnd);
return hwnd != NULL ? hwnd : GetDesktopWindow();
}
static BOOL CALLBACK
SetAxisRangeCallback(LPCDIDEVICEOBJECTINSTANCEA object, LPVOID context)
{
IDirectInputDevice8A *device = (IDirectInputDevice8A *)context;
DIPROPRANGE range;
range.diph.dwSize = sizeof(DIPROPRANGE);
range.diph.dwHeaderSize = sizeof(DIPROPHEADER);
range.diph.dwHow = DIPH_BYID;
range.diph.dwObj = object->dwType;
range.lMin = -32767;
range.lMax = 32767;
device->SetProperty(DIPROP_RANGE, &range.diph); // best-effort; a driver
// may refuse -- read back
return DIENUM_CONTINUE;
}
struct EnumPassContext
{
HWND hwnd;
};
static BOOL CALLBACK
EnumDevicesCallback(const DIDEVICEINSTANCEA *instance, VOID *context)
{
EnumPassContext *pass = (EnumPassContext *)context;
if (sDeviceCount >= BTJoyMaxDevices)
{
return DIENUM_STOP;
}
if (IsXInputProduct(instance->guidProduct))
{
if (JoyLogEnabled())
{
DEBUG_STREAM << "[joy] skipping XInput-class device \""
<< instance->tszProductName << "\"\n" << std::flush;
}
return DIENUM_CONTINUE;
}
IDirectInputDevice8A *device = NULL;
if (FAILED(sDirectInput->CreateDevice(instance->guidInstance,
&device, NULL)) || device == NULL)
{
return DIENUM_CONTINUE;
}
if (FAILED(device->SetDataFormat(&c_dfDIJoystick2)))
{
device->Release();
return DIENUM_CONTINUE;
}
//
// Best-effort: some environments reject a cooperative level on the
// desktop window; the default (nonexclusive) still polls.
//
device->SetCooperativeLevel(pass->hwnd,
DISCL_BACKGROUND | DISCL_NONEXCLUSIVE);
device->EnumObjects(SetAxisRangeCallback, device, DIDFT_AXIS);
device->Acquire();
JoyDevice &slot = sDevices[sDeviceCount];
memset(&slot, 0, sizeof(slot));
slot.device = device;
slot.state.attached = 1;
strncpy(slot.state.name, instance->tszProductName,
sizeof(slot.state.name) - 1);
for (int a = 0; a < BTJoyAxisCount; ++a)
{
slot.axisMin[a] = -32767;
slot.axisMax[a] = 32767;
}
for (int h = 0; h < BTJoyHatCount; ++h)
{
slot.state.hat[h] = -1;
}
++sDeviceCount;
DEBUG_STREAM << "[joy] device " << (sDeviceCount - 1) << ": \""
<< slot.state.name << "\" attached\n" << std::flush;
return DIENUM_CONTINUE;
}
static void
ReleaseAllDevices()
{
for (int i = 0; i < sDeviceCount; ++i)
{
if (sDevices[i].device != NULL)
{
sDevices[i].device->Unacquire();
sDevices[i].device->Release();
sDevices[i].device = NULL;
}
sDevices[i].state.attached = 0;
}
sDeviceCount = 0;
}
static void
Enumerate()
{
ReleaseAllDevices();
CollectXInputVidPids();
EnumPassContext pass;
pass.hwnd = FindProcessWindow();
sDirectInput->EnumDevices(DI8DEVCLASS_GAMECTRL, EnumDevicesCallback,
&pass, DIEDFL_ATTACHEDONLY);
}
//###########################################################################
// Public surface
//###########################################################################
int
BTJoyInit(void)
{
if (sInitialized)
{
return sDeviceCount;
}
sInitialized = 1;
if (FAILED(DirectInput8Create(GetModuleHandle(NULL), DIRECTINPUT_VERSION,
IID_IDirectInput8A, (void **)&sDirectInput, NULL))
|| sDirectInput == NULL)
{
sDirectInput = NULL;
DEBUG_STREAM << "[joy] DirectInput8Create failed -- generic "
<< "joysticks unavailable\n" << std::flush;
return 0;
}
Enumerate();
return sDeviceCount;
}
void
BTJoyShutdown(void)
{
ReleaseAllDevices();
if (sDirectInput != NULL)
{
sDirectInput->Release();
sDirectInput = NULL;
}
sInitialized = 0;
}
static float
NormalizeAxis(LONG value, LONG range_min, LONG range_max)
{
if (range_max <= range_min)
{
return 0.0f;
}
float normalized =
((float)(value - range_min) / (float)(range_max - range_min))
* 2.0f - 1.0f;
if (normalized < -1.0f) normalized = -1.0f;
if (normalized > 1.0f) normalized = 1.0f;
return normalized;
}
void
BTJoyPoll(void)
{
if (!sInitialized)
{
BTJoyInit();
}
if (sDirectInput == NULL)
{
return;
}
//
// Hot-plug: when nothing is attached (or a device died), re-enumerate
// on the PadRIO probe cadence (~3 s) -- enumeration is too heavy for
// every frame.
//
int any_attached = 0;
for (int i = 0; i < sDeviceCount; ++i)
{
if (sDevices[i].state.attached)
{
any_attached = 1;
break;
}
}
if (!any_attached)
{
unsigned long now = timeGetTime();
if (sLastProbeMilliseconds != 0 &&
now - sLastProbeMilliseconds < 3000)
{
return;
}
sLastProbeMilliseconds = now;
Enumerate();
}
for (int i = 0; i < sDeviceCount; ++i)
{
JoyDevice &slot = sDevices[i];
if (slot.device == NULL || !slot.state.attached)
{
continue;
}
HRESULT result = slot.device->Poll();
if (FAILED(result))
{
result = slot.device->Acquire();
if (SUCCEEDED(result))
{
result = slot.device->Poll();
}
}
DIJOYSTATE2 joystate;
if (SUCCEEDED(result) || result == DI_NOEFFECT)
{
result = slot.device->GetDeviceState(sizeof(joystate), &joystate);
}
if (FAILED(result))
{
//
// Unplugged (or the driver died). Mark detached; the binding
// layer sees zeroed state and releases everything it held (the
// issue-#24 stuck-button rule), and the probe above will try to
// re-enumerate.
//
DEBUG_STREAM << "[joy] device " << i << " (\"" << slot.state.name
<< "\") lost\n" << std::flush;
slot.state.attached = 0;
memset(slot.state.axis, 0, sizeof(slot.state.axis));
slot.state.buttons = 0;
for (int h = 0; h < BTJoyHatCount; ++h)
{
slot.state.hat[h] = -1;
}
continue;
}
LONG raw[BTJoyAxisCount];
raw[0] = joystate.lX;
raw[1] = joystate.lY;
raw[2] = joystate.lZ;
raw[3] = joystate.lRx;
raw[4] = joystate.lRy;
raw[5] = joystate.lRz;
raw[6] = joystate.rglSlider[0];
raw[7] = joystate.rglSlider[1];
for (int a = 0; a < BTJoyAxisCount; ++a)
{
slot.state.axis[a] =
NormalizeAxis(raw[a], slot.axisMin[a], slot.axisMax[a]);
}
slot.state.buttons = 0;
for (int b = 0; b < BTJoyButtonCount; ++b)
{
if (joystate.rgbButtons[b] & 0x80)
{
slot.state.buttons |= (1u << b);
}
}
for (int h = 0; h < BTJoyHatCount; ++h)
{
DWORD pov = joystate.rgdwPOV[h];
//
// Centered reads as -1 (0xFFFF in LOWORD per the DI contract;
// some drivers return the full 0xFFFFFFFF).
//
slot.state.hat[h] =
(LOWORD(pov) == 0xFFFF) ? -1 : (int)pov;
}
}
}
int
BTJoyDeviceCount(void)
{
return sDeviceCount;
}
const BTJoyDeviceState *
BTJoyDevice(int index)
{
if (index < 0 || index >= sDeviceCount ||
!sDevices[index].state.attached)
{
return NULL;
}
return &sDevices[index].state;
}
int
BTJoyFindDevice(const char *name_substring)
{
if (name_substring == NULL || *name_substring == '\0')
{
return -1;
}
char want[64];
strncpy(want, name_substring, sizeof(want) - 1);
want[sizeof(want) - 1] = '\0';
_strlwr(want);
for (int i = 0; i < sDeviceCount; ++i)
{
if (!sDevices[i].state.attached)
{
continue;
}
char have[64];
strncpy(have, sDevices[i].state.name, sizeof(have) - 1);
have[sizeof(have) - 1] = '\0';
_strlwr(have);
if (strstr(have, want) != NULL)
{
return i;
}
}
return -1;
}
//###########################################################################
// BT_JOYCONFIG -- the interactive capture wizard.
//
// Console UI (AllocConsole; the game is a GUI app). Detects which device/
// axis the player moves for each pod control, derives the sign convention
// from the direction of the move, then writes the joystick section of
// bindings.txt between marker lines (replacing any previous generated
// section; the rest of the file is preserved byte-for-byte).
//###########################################################################
#include <conio.h>
#include "l4padbindings.h"
namespace
{
struct WizardCapture
{
int used;
int device;
int axis; // axis steps (-1 for buttons)
int button; // button steps (-1 for axes)
int invert;
float deadzone;
char line[128];
};
const char *joyAxisToken(int axis)
{
static const char *names[BTJoyAxisCount] =
{ "X", "Y", "Z", "RX", "RY", "RZ", "SL0", "SL1" };
return (axis >= 0 && axis < BTJoyAxisCount) ? names[axis] : "?";
}
void WizardBaseline(float baseline[BTJoyMaxDevices][BTJoyAxisCount])
{
//
// ~600 ms of samples -> the at-rest position of every axis (a HOTAS
// throttle rests wherever its lever is; never assume 0).
//
for (int pass = 0; pass < 20; ++pass)
{
BTJoyPoll();
Sleep(30);
}
for (int d = 0; d < BTJoyMaxDevices; ++d)
{
const BTJoyDeviceState *state = BTJoyDevice(d);
for (int a = 0; a < BTJoyAxisCount; ++a)
{
baseline[d][a] = state != 0 ? state->axis[a] : 0.0f;
}
}
}
//
// Wait for a decisive axis move. Returns 1 with (device, axis, delta,
// final) filled, 0 on skip (SPACE), -1 on abort (ESC) / timeout.
//
int WizardCaptureAxis(
const float baseline[BTJoyMaxDevices][BTJoyAxisCount],
const WizardCapture *taken, int taken_count,
int allow_skip,
int *out_device, int *out_axis, float *out_delta, float *out_final)
{
unsigned long deadline = timeGetTime() + 30000;
unsigned long hold_since = 0;
int cand_device = -1, cand_axis = -1;
while (timeGetTime() < deadline)
{
while (_kbhit())
{
int key = _getch();
if (key == 27)
{
return -1;
}
if (key == ' ' && allow_skip)
{
return 0;
}
}
BTJoyPoll();
int best_device = -1, best_axis = -1;
float best_mag = 0.0f, best_delta = 0.0f;
for (int d = 0; d < BTJoyMaxDevices; ++d)
{
const BTJoyDeviceState *state = BTJoyDevice(d);
if (state == 0)
{
continue;
}
for (int a = 0; a < BTJoyAxisCount; ++a)
{
int already = 0;
for (int t = 0; t < taken_count; ++t)
{
if (taken[t].used && taken[t].axis >= 0 &&
taken[t].device == d && taken[t].axis == a)
{
already = 1;
}
}
if (already)
{
continue;
}
float delta = state->axis[a] - baseline[d][a];
float magnitude = delta < 0.0f ? -delta : delta;
if (magnitude > best_mag)
{
best_mag = magnitude;
best_delta = delta;
best_device = d;
best_axis = a;
}
}
}
if (best_mag > 0.45f)
{
if (cand_device != best_device || cand_axis != best_axis)
{
cand_device = best_device;
cand_axis = best_axis;
hold_since = timeGetTime();
}
else if (timeGetTime() - hold_since > 250)
{
const BTJoyDeviceState *state = BTJoyDevice(best_device);
*out_device = best_device;
*out_axis = best_axis;
*out_delta = best_delta;
*out_final = state != 0 ? state->axis[best_axis] : best_delta;
return 1;
}
}
else
{
cand_device = cand_axis = -1;
}
Sleep(15);
}
return -1;
}
//
// Wait for a fresh button press. Returns 1 with (device, button), 0 on
// skip, -1 on abort/timeout.
//
int WizardCaptureButton(
int allow_skip, int *out_device, int *out_button)
{
unsigned baseline[BTJoyMaxDevices];
BTJoyPoll();
for (int d = 0; d < BTJoyMaxDevices; ++d)
{
const BTJoyDeviceState *state = BTJoyDevice(d);
baseline[d] = state != 0 ? state->buttons : 0;
}
unsigned long deadline = timeGetTime() + 30000;
while (timeGetTime() < deadline)
{
while (_kbhit())
{
int key = _getch();
if (key == 27)
{
return -1;
}
if (key == ' ' && allow_skip)
{
return 0;
}
}
BTJoyPoll();
for (int d = 0; d < BTJoyMaxDevices; ++d)
{
const BTJoyDeviceState *state = BTJoyDevice(d);
if (state == 0)
{
continue;
}
unsigned fresh = state->buttons & ~baseline[d];
if (fresh != 0)
{
int button = 0;
while ((fresh & 1u) == 0)
{
fresh >>= 1;
++button;
}
*out_device = d;
*out_button = button;
return 1;
}
baseline[d] &= state->buttons; // releases refresh the baseline
}
Sleep(15);
}
return -1;
}
} // namespace
int
BTJoyConfigWizard(void)
{
//
// Console (the game is a GUI subsystem app).
//
if (GetConsoleWindow() == NULL)
{
AllocConsole();
}
FILE *io;
freopen_s(&io, "CONOUT$", "w", stdout);
freopen_s(&io, "CONIN$", "r", stdin);
printf("\n=== BattleTech joystick setup (BT_JOYCONFIG) ===\n\n");
//
// Make sure a bindings.txt exists (the wizard APPENDS its section; the
// keyboard/pad sections come from the default write).
//
{
PadBindingProfile ensure_default;
ensure_default.Load();
}
if (BTJoyInit() == 0)
{
printf("No generic (non-Xbox) game devices found.\n");
printf("Plug in the stick/throttle/pedals and run joyconfig again.\n");
printf("(Xbox-class controllers already work -- no setup needed.)\n\n");
printf("Press any key to exit.\n");
_getch();
return 1;
}
printf("Detected devices:\n");
for (int d = 0; d < BTJoyDeviceCount(); ++d)
{
const BTJoyDeviceState *state = BTJoyDevice(d);
if (state != 0)
{
printf(" [%d] %s\n", d, state->name);
}
}
printf("\nFor each prompt, MOVE the control you want, or press SPACE to\n"
"skip that control, ESC to abort. Keep everything else still.\n\n");
WizardCapture captures[16];
memset(captures, 0, sizeof(captures));
int capture_count = 0;
struct AxisStep
{
const char *prompt;
const char *channel;
int invert_when_positive; // the asked move should read NEGATIVE
int throttle; // full-travel lever (invert from FINAL)
int allow_skip;
};
static const AxisStep axisSteps[] =
{
{ "AIM: push the STICK fully RIGHT", "JoystickX", 0, 0, 0 },
{ "AIM: push the STICK fully FORWARD\n"
" (forward aims DOWN, flight-style; add/remove the word\n"
" invert on that line in bindings.txt to flip it later)",
"JoystickY", 1, 0, 0 },
{ "TURN: TWIST the stick / push the rudder RIGHT (SPACE if none)",
"Turn", 0, 0, 1 },
{ "THROTTLE: move the throttle lever to FULL (SPACE if none)",
"Throttle", 0, 1, 1 },
};
float baseline[BTJoyMaxDevices][BTJoyAxisCount];
for (int s = 0; s < (int)(sizeof(axisSteps)/sizeof(axisSteps[0])); ++s)
{
printf("%s ...\n", axisSteps[s].prompt);
WizardBaseline(baseline);
int device, axis;
float delta, final_value;
int got = WizardCaptureAxis(baseline, captures, capture_count,
axisSteps[s].allow_skip, &device, &axis, &delta, &final_value);
if (got < 0)
{
printf("\nAborted -- nothing written.\n");
return 1;
}
if (got == 0)
{
printf(" skipped.\n\n");
continue;
}
WizardCapture &capture = captures[capture_count++];
capture.used = 1;
capture.device = device;
capture.axis = axis;
capture.button = -1;
if (axisSteps[s].throttle)
{
capture.invert = (final_value < 0.0f);
capture.deadzone = 0.0f;
sprintf(capture.line, "joyaxis %s axis %s%s deadzone 0",
joyAxisToken(axis), axisSteps[s].channel,
capture.invert ? " invert" : "");
}
else
{
int negative = (delta < 0.0f);
capture.invert = axisSteps[s].invert_when_positive
? !negative : negative;
capture.deadzone = 0.08f;
sprintf(capture.line, "joyaxis %s axis %s%s",
joyAxisToken(axis), axisSteps[s].channel,
capture.invert ? " invert" : "");
}
printf(" -> device %d (%s) axis %s%s\n\n", device,
BTJoyDevice(device) ? BTJoyDevice(device)->name : "?",
joyAxisToken(axis), capture.invert ? " (inverted)" : "");
Sleep(800); // let the control come back to rest
}
struct ButtonStep
{
const char *prompt;
int address;
int allow_skip;
};
static const ButtonStep buttonSteps[] =
{
{ "FIRE 1: press the TRIGGER", 0x40, 0 },
{ "FIRE 2: press your second fire button", 0x46, 1 },
{ "FIRE 3: press your third fire button", 0x47, 1 },
{ "FIRE 4: press your fourth fire button", 0x45, 1 },
{ "REVERSE: press the button for reverse thrust", 0x3F, 1 },
};
for (int s = 0; s < (int)(sizeof(buttonSteps)/sizeof(buttonSteps[0])); ++s)
{
printf("%s ... %s\n", buttonSteps[s].prompt,
buttonSteps[s].allow_skip ? "(SPACE to skip)" : "");
int device, button;
int got = WizardCaptureButton(buttonSteps[s].allow_skip,
&device, &button);
if (got < 0)
{
printf("\nAborted -- nothing written.\n");
return 1;
}
if (got == 0)
{
printf(" skipped.\n\n");
continue;
}
WizardCapture &capture = captures[capture_count++];
capture.used = 1;
capture.device = device;
capture.axis = -1;
capture.button = button;
sprintf(capture.line, "joybutton %d button 0x%X",
button, buttonSteps[s].address);
printf(" -> device %d button %d\n\n", device, button);
Sleep(400);
}
if (capture_count == 0)
{
printf("Nothing captured -- nothing written.\n");
return 1;
}
//
// Group by device -> joydev slots in order of first use. The hat on
// the primary (stick) device gets the pod look cluster automatically
// (hats are standardized; no capture needed).
//
int slot_of_device[BTJoyMaxDevices];
int slot_count = 0;
for (int d = 0; d < BTJoyMaxDevices; ++d)
{
slot_of_device[d] = -1;
}
for (int i = 0; i < capture_count; ++i)
{
if (captures[i].used && slot_of_device[captures[i].device] < 0)
{
slot_of_device[captures[i].device] = slot_count++;
}
}
//
// Rewrite bindings.txt: preserve everything outside the marker pair.
//
static const char *beginMarker =
"# >>> BT_JOYCONFIG generated -- do not edit between markers";
static const char *endMarker = "# <<< BT_JOYCONFIG end";
static char kept[32768];
kept[0] = '\0';
{
FILE *f = fopen("bindings.txt", "rt");
if (f != NULL)
{
char line[512];
int inside = 0;
size_t used = 0;
while (fgets(line, sizeof(line), f))
{
if (strstr(line, beginMarker) != NULL)
{
inside = 1;
continue;
}
if (strstr(line, endMarker) != NULL)
{
inside = 0;
continue;
}
if (!inside && used + strlen(line) < sizeof(kept) - 1)
{
strcpy(kept + used, line);
used += strlen(line);
}
}
fclose(f);
}
}
FILE *f = fopen("bindings.txt", "wt");
if (f == NULL)
{
printf("ERROR: cannot write bindings.txt (working directory?)\n");
_getch();
return 1;
}
fputs(kept, f);
if (kept[0] != '\0' && kept[strlen(kept) - 1] != '\n')
{
fputs("\n", f);
}
fprintf(f, "%s\n", beginMarker);
int hat_done = 0;
for (int d = 0; d < BTJoyMaxDevices; ++d)
{
if (slot_of_device[d] < 0)
{
continue;
}
const BTJoyDeviceState *state = BTJoyDevice(d);
fprintf(f, "joydev %d %s\n", slot_of_device[d],
state != 0 ? state->name : "");
for (int i = 0; i < capture_count; ++i)
{
if (captures[i].used && captures[i].device == d)
{
fprintf(f, "%s\n", captures[i].line);
}
}
if (!hat_done)
{
hat_done = 1;
fprintf(f, "joyhat 0 up button 0x42\n");
fprintf(f, "joyhat 0 down button 0x41\n");
fprintf(f, "joyhat 0 left button 0x44\n");
fprintf(f, "joyhat 0 right button 0x43\n");
}
}
fprintf(f, "%s\n", endMarker);
fclose(f);
printf("bindings.txt written (%d controls + hat looks).\n", capture_count);
printf("The game uses them from the next launch -- have fun.\n\n");
printf("Press any key to continue into the game.\n");
_getch();
return 0;
}
+69
View File
@@ -0,0 +1,69 @@
#pragma once
//###########################################################################
//
// L4JOY -- generic-joystick reader (DirectInput 8) for the glass cockpit.
//
// PadRIO reads XInput; this layer adds every OTHER game device Windows
// knows -- flight sticks, HOTAS throttles, twist-sticks, rudder pedals --
// through DirectInput 8 (the standard generic-HID game API). It exposes
// up to BTJoyMaxDevices attached devices as normalized state blocks; the
// PadRIO poll maps them onto the pod's control channels through the
// joydev/joyaxis/joybutton/joyhat rows of bindings.txt (L4PADBINDINGS.h)
// -- the same binding machinery the XInput pad uses.
//
// XInput-class devices are EXCLUDED (they'd double-feed through both
// APIs): a DirectInput device whose VID/PID appears in a RawInput device
// path containing the "IG_" marker is an XInput device (the documented
// wbem-free detection).
//
// This is DISTINCT from the legacy L4DINPUT.cpp DIJoystick (the 1995-era
// single-device `Joystick` engine interface, reachable only through the
// old L4CONTROLS=DIJOYSTICK profile) -- that path is left untouched.
//
// BT_JOYCONFIG=1 runs the interactive capture wizard at boot (console
// prompts; writes the joystick section of bindings.txt). BT_JOY_LOG=1
// logs device attach/detach and the resolved bindings.
//
//###########################################################################
enum
{
BTJoyMaxDevices = 4,
BTJoyAxisCount = 8, // X Y Z RX RY RZ SL0 SL1 (DIJOYSTATE2 order)
BTJoyButtonCount = 32, // buttons exposed to bindings (DI carries 128)
BTJoyHatCount = 4,
};
struct BTJoyDeviceState
{
int attached;
float axis[BTJoyAxisCount]; // normalized -1..1 (raw; deadzones
// are the binding layer's business)
unsigned buttons; // bit n = button n held
int hat[BTJoyHatCount]; // POV in centidegrees; -1 = centered
char name[64]; // product name ("T.16000M", ...)
};
//
// Lifecycle. Init is lazy-safe (Poll calls it); returns the attached
// non-XInput device count. Re-enumeration (hot-plug) happens inside Poll
// on a ~3 s cadence whenever nothing is attached or a device was lost.
//
int BTJoyInit(void);
void BTJoyShutdown(void);
void BTJoyPoll(void);
int BTJoyDeviceCount(void);
const BTJoyDeviceState *BTJoyDevice(int index); // NULL out of range/detached
//
// Case-insensitive product-name substring match -> device index, -1 none.
//
int BTJoyFindDevice(const char *name_substring);
//
// The BT_JOYCONFIG capture wizard (console UI; called from btl4main before
// the frontend). Returns 0 if it wrote a config, nonzero on abort/no-device.
//
int BTJoyConfigWizard(void);
+192 -4
View File
@@ -101,6 +101,26 @@ static const NamedValue channelNames[] =
{ "Turn", PadBindingProfile::ChannelTurn }, // issue #25 composite
};
//
// Generic-joystick axis names (the DIJOYSTATE2 layout -- L4JOY.h order).
//
static const NamedValue joyAxisNames[] =
{
{ "X", PadBindingProfile::JoyAxisX },
{ "Y", PadBindingProfile::JoyAxisY },
{ "Z", PadBindingProfile::JoyAxisZ },
{ "RX", PadBindingProfile::JoyAxisRX },
{ "RY", PadBindingProfile::JoyAxisRY },
{ "RZ", PadBindingProfile::JoyAxisRZ },
{ "SL0", PadBindingProfile::JoyAxisSL0 },
{ "SL1", PadBindingProfile::JoyAxisSL1 },
};
static const NamedValue hatDirectionNames[] =
{
{ "up", 0 }, { "right", 1 }, { "down", 2 }, { "left", 3 },
};
static int
LookupName(const NamedValue *table, int count, const char *name)
{
@@ -252,7 +272,28 @@ static const char *defaultProfileText =
"pad DPAD_UP button 0x42\n"
"pad DPAD_DOWN button 0x41\n"
"pad DPAD_LEFT button 0x44\n"
"pad DPAD_RIGHT button 0x43\n";
"pad DPAD_RIGHT button 0x43\n"
"\n"
"# --- generic joysticks (flight sticks / HOTAS / pedals; DirectInput) ---\n"
"# EASIEST: run the game once with BT_JOYCONFIG=1 (joyconfig.bat) -- an\n"
"# interactive wizard detects your controls and writes this section for you.\n"
"# Manual grammar (rows attach to the last joydev slot; slot 0 if none):\n"
"# joydev <slot 0-3> [product-name substring]\n"
"# joyaxis <X|Y|Z|RX|RY|RZ|SL0|SL1> axis <channel> [invert] [slew <r>] [deadzone <f>]\n"
"# joybutton <0-31> button <addr>\n"
"# joyhat <0-3> <up|down|left|right> button <addr>\n"
"# Typical flight stick (twist = RZ, throttle wheel = SL0):\n"
"# joydev 0\n"
"# joyaxis X axis JoystickX\n"
"# joyaxis Y axis JoystickY invert\n"
"# joyaxis RZ axis Turn\n"
"# joyaxis SL0 axis Throttle invert deadzone 0\n"
"# joybutton 0 button 0x40\n"
"# joybutton 1 button 0x46\n"
"# joyhat 0 up button 0x42\n"
"# joyhat 0 down button 0x41\n"
"# joyhat 0 left button 0x44\n"
"# joyhat 0 right button 0x43\n";
//###########################################################################
// PadBindingProfile
@@ -261,8 +302,13 @@ static const char *defaultProfileText =
PadBindingProfile::PadBindingProfile():
keyBindingCount(0),
padButtonBindingCount(0),
padAxisBindingCount(0)
padAxisBindingCount(0),
joyAxisBindingCount(0),
joyButtonBindingCount(0),
joyHatBindingCount(0),
parseJoyDevice(0)
{
memset(joyDeviceMatch, 0, sizeof(joyDeviceMatch));
}
PadBindingProfile::~PadBindingProfile()
@@ -368,7 +414,97 @@ int
}
//
// key/pad rows: parse the action clause.
// joydev <slot> [name substring...] -- select the device slot for the
// joy rows that follow; optional product-name match (joined verbatim).
//
if (_stricmp(tokens[0], "joydev") == 0)
{
int slot = (int)strtol(tokens[1], NULL, 0);
if (slot < 0 || slot >= JoyDeviceSlots)
{
return -1;
}
parseJoyDevice = slot;
joyDeviceMatch[slot][0] = '\0';
for (int t = 2; t < tokenCount; ++t)
{
if (t > 2)
{
strncat(joyDeviceMatch[slot], " ",
sizeof(joyDeviceMatch[slot]) - strlen(joyDeviceMatch[slot]) - 1);
}
strncat(joyDeviceMatch[slot], tokens[t],
sizeof(joyDeviceMatch[slot]) - strlen(joyDeviceMatch[slot]) - 1);
}
return 0;
}
//
// joyaxis <AXIS> axis <channel> [invert] [slew <rate>] [deadzone <f>]
//
if (_stricmp(tokens[0], "joyaxis") == 0)
{
if (tokenCount < 4 || _stricmp(tokens[2], "axis") != 0)
{
return -1;
}
int axis = LookupName(joyAxisNames,
sizeof(joyAxisNames)/sizeof(joyAxisNames[0]), tokens[1]);
int channel = LookupName(channelNames,
sizeof(channelNames)/sizeof(channelNames[0]), tokens[3]);
if (axis < 0 || channel < 0 ||
joyAxisBindingCount >= (int)(sizeof(joyAxisBindings)/sizeof(joyAxisBindings[0])))
{
return -1;
}
JoyAxisBinding &b = joyAxisBindings[joyAxisBindingCount];
b.device = parseJoyDevice;
b.axis = axis;
b.channel = channel;
b.invert = 0;
b.slew = 0;
b.slewRate = 0.0f;
b.deadzone = 0.08f; // stick default; levers set deadzone 0
for (int t = 4; t < tokenCount; ++t)
{
if (_stricmp(tokens[t], "invert") == 0)
{
b.invert = 1;
}
else if (_stricmp(tokens[t], "slew") == 0 && t+1 < tokenCount)
{
b.slew = 1;
b.slewRate = (float)atof(tokens[++t]);
}
else if (_stricmp(tokens[t], "deadzone") == 0 && t+1 < tokenCount)
{
b.deadzone = (float)atof(tokens[++t]);
if (b.deadzone < 0.0f) b.deadzone = 0.0f;
if (b.deadzone > 0.9f) b.deadzone = 0.9f;
}
else
{
return -1;
}
}
++joyAxisBindingCount;
return 0;
}
//
// joyhat's action clause starts one token later (joyhat <n> <dir> ...).
//
if (_stricmp(tokens[0], "joyhat") == 0)
{
actionAt = 3;
if (tokenCount < 5)
{
return -1;
}
}
//
// key/pad/joybutton/joyhat rows: parse the action clause.
//
if (_stricmp(tokens[actionAt], "button") == 0)
{
@@ -486,6 +622,45 @@ int
++padButtonBindingCount;
return 0;
}
else if (_stricmp(tokens[0], "joybutton") == 0)
{
if (action.kind != ActionButton && action.kind != ActionKeypad)
{
return -1; // joystick buttons cannot drive axes; use joyaxis
}
int button = (int)strtol(tokens[1], NULL, 0);
if (button < 0 || button >= 32 ||
joyButtonBindingCount >= (int)(sizeof(joyButtonBindings)/sizeof(joyButtonBindings[0])))
{
return -1;
}
joyButtonBindings[joyButtonBindingCount].device = parseJoyDevice;
joyButtonBindings[joyButtonBindingCount].button = button;
joyButtonBindings[joyButtonBindingCount].action = action;
++joyButtonBindingCount;
return 0;
}
else if (_stricmp(tokens[0], "joyhat") == 0)
{
if (action.kind != ActionButton)
{
return -1; // hat directions map to RIO buttons only
}
int hat = (int)strtol(tokens[1], NULL, 0);
int direction = LookupName(hatDirectionNames,
sizeof(hatDirectionNames)/sizeof(hatDirectionNames[0]), tokens[2]);
if (hat < 0 || hat >= 4 || direction < 0 ||
joyHatBindingCount >= (int)(sizeof(joyHatBindings)/sizeof(joyHatBindings[0])))
{
return -1;
}
joyHatBindings[joyHatBindingCount].device = parseJoyDevice;
joyHatBindings[joyHatBindingCount].hat = hat;
joyHatBindings[joyHatBindingCount].direction = direction;
joyHatBindings[joyHatBindingCount].action = action;
++joyHatBindingCount;
return 0;
}
return -1;
}
@@ -495,6 +670,11 @@ void
keyBindingCount = 0;
padButtonBindingCount = 0;
padAxisBindingCount = 0;
joyAxisBindingCount = 0;
joyButtonBindingCount = 0;
joyHatBindingCount = 0;
parseJoyDevice = 0;
memset(joyDeviceMatch, 0, sizeof(joyDeviceMatch));
//
// Parse the built-in default text line by line (one code path for
@@ -540,6 +720,11 @@ void
keyBindingCount = 0;
padButtonBindingCount = 0;
padAxisBindingCount = 0;
joyAxisBindingCount = 0;
joyButtonBindingCount = 0;
joyHatBindingCount = 0;
parseJoyDevice = 0;
memset(joyDeviceMatch, 0, sizeof(joyDeviceMatch));
char line[256];
int line_number = 0;
@@ -576,7 +761,10 @@ void
}
DEBUG_STREAM << "[padrio] bindings loaded: " << keyBindingCount
<< " keys, " << padButtonBindingCount << " pad buttons, "
<< padAxisBindingCount << " pad axes"
<< padAxisBindingCount << " pad axes, "
<< joyAxisBindingCount << " joy axes, "
<< joyButtonBindingCount << " joy buttons, "
<< joyHatBindingCount << " joy hats"
<< (rejected ? " (rejected lines logged above)" : "")
<< "\n" << std::flush;
}
+66
View File
@@ -18,6 +18,11 @@
// key <KEYNAME> axis <channel> set <value>
// pad <PADBTN> button <addr>
// padaxis <PADAXIS> axis <channel> [invert] [slew <rate>]
// joydev <slot 0-3> [product-name substring...]
// joyaxis <JOYAXIS> axis <channel> [invert] [slew <rate>] [deadzone <f>]
// joybutton <n> button <addr>
// joybutton <n> keypad <unit> <key>
// joyhat <n> <up|down|left|right> button <addr>
//
// deflect = held drives the channel toward +/-1 at <rate>/s, auto-centers
// on release (the spring-centered stick model).
@@ -25,6 +30,17 @@
// lever model).
// set = press jumps the channel to <value> (all-stop detent).
//
// joy* rows (generic DirectInput joysticks -- flight sticks, HOTAS,
// pedals; L4JOY.h) attach to the most recent joydev slot (slot 0 if none
// given). A slot with a name substring binds THAT device; without one it
// binds the Nth attached non-XInput device. JOYAXIS names follow the
// DirectInput layout: X Y Z RX RY RZ SL0 SL1 (twist is usually RZ, a
// HOTAS throttle usually Z or SL0). A direct (non-slew) joyaxis on the
// Throttle channel maps the full axis travel onto the 0..1 lever (a
// physical lever OWNS the channel); other channels follow the pad model
// (outside the deadzone owns the channel). BT_JOYCONFIG=1 generates
// these rows interactively.
//
//###########################################################################
class PadBindingProfile
@@ -88,6 +104,46 @@ public:
float slewRate; // full-deflection slew speed (units/s)
};
//
// Generic-joystick bindings (DirectInput devices -- L4JOY.h). device
// is a joydev SLOT (0-3), resolved at poll time by the slot's name
// substring (or ordinal when the slot has none).
//
enum { JoyDeviceSlots = 4 };
enum JoyAxis {
JoyAxisX = 0, JoyAxisY, JoyAxisZ,
JoyAxisRX, JoyAxisRY, JoyAxisRZ,
JoyAxisSL0, JoyAxisSL1,
JoyAxisCount
};
struct JoyAxisBinding
{
int device; // joydev slot
int axis; // JoyAxis
int channel; // Channel
int invert;
int slew;
float slewRate;
float deadzone; // 0..1 fraction (rescaled past it)
};
struct JoyButtonBinding
{
int device; // joydev slot
int button; // 0-31
Action action; // ActionButton / ActionKeypad only
};
struct JoyHatBinding
{
int device; // joydev slot
int hat; // 0-3
int direction; // 0 up / 1 right / 2 down / 3 left
Action action; // ActionButton only
};
PadBindingProfile();
~PadBindingProfile();
@@ -107,7 +163,17 @@ public:
int padAxisBindingCount;
PadAxisBinding padAxisBindings[12];
int joyAxisBindingCount;
JoyAxisBinding joyAxisBindings[24];
int joyButtonBindingCount;
JoyButtonBinding joyButtonBindings[48];
int joyHatBindingCount;
JoyHatBinding joyHatBindings[16];
char joyDeviceMatch[JoyDeviceSlots][64]; // "" = ordinal slot
protected:
int parseJoyDevice; // joydev slot for subsequent joy rows
void
LoadDefaults();
void
+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
+10
View File
@@ -109,6 +109,16 @@ protected:
previousPadButtons;
unsigned char
previousKeyHeld[192]; // per key BINDING (parallel to the profile)
//
// Generic-joystick edge state, per BINDING (parallel to the profile's
// joy tables -- the previousKeyHeld pattern). A device detach reads
// everything as released, so held buttons let go automatically (the
// issue-#24 rule).
//
unsigned char
previousJoyButtonHeld[48];
unsigned char
previousJoyHatHeld[16];
float
channelValue[PadBindingProfile::ChannelCount];
float