diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ea3300..ab98ab5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -263,6 +263,7 @@ add_library(munga_engine STATIC target_sources(munga_engine PRIVATE "engine/MUNGA_L4/L4PADRIO.cpp" "engine/MUNGA_L4/L4PADBINDINGS.cpp" + "engine/MUNGA_L4/L4JOY.cpp" "engine/MUNGA_L4/L4PADPANEL.cpp" "engine/MUNGA_L4/L4GLASSWIN.cpp" "engine/MUNGA_L4/L4PLASMAWIN.cpp" diff --git a/context/glass-cockpit.md b/context/glass-cockpit.md index 89793ab..dc2b388 100644 --- a/context/glass-cockpit.md +++ b/context/glass-cockpit.md @@ -88,6 +88,31 @@ recovery; keyboard-only run clean). Fix: on the connected->disconnected transit `EmitButton(address, 0)` for every binding whose mask was held, THEN forget. Not reproducible headless (needs real hardware unplug) -- logic fix, awaiting the reporter's controller. +## Generic joysticks / HOTAS / pedals -- DirectInput 8 layer (2026-07-23) [T2 rig-verified; awaiting stick hardware] +Tester ask: configure non-Xbox controllers. **`L4JOY.cpp/.h`** (all builds, lazy-init): DI8 +enumeration of every non-XInput game device (up to 4), `c_dfDIJoystick2` polling with +`DIPROP_RANGE` ±32767 + reacquire-on-loss + 3 s hot-plug re-enum, normalized +`BTJoyDeviceState` {8 axes -1..1, 32 buttons, 4 POV hats}. **XInput exclusion** = the +documented wbem-free RawInput check (device path contains `IG_` → VID/PID blacklist vs +`guidProduct.Data1`) — LIVE-verified skipping a real XBOX360 pad. **Bindings** +(L4PADBINDINGS): `joydev [name-substr]` + `joyaxis axis + [invert] [slew r] [deadzone f]` + `joybutton <0-31>` + `joyhat <0-3> ` — rows +attach to the last joydev slot; unnamed slot binds attached device #slot. **PadRIO poll** +runs them through the SAME channel/event machinery (per-binding prev arrays → detach releases +automatically, the #24 rule); a direct joyaxis on Throttle maps full travel [-1,1]→[0,1] and +writes every frame (a lever OWNS the channel; slewHeld stays clear so the GAIT DETENT still +snaps a lever parked in the walk/run dead band). **Wizard** `BT_JOYCONFIG=1` +(`BTJoyConfigWizard`, console UI, `joyconfig.bat`): baselines all axes (a HOTAS lever rests +anywhere), captures the dominant mover per prompt (0.45 threshold, 250 ms hold, already- +assigned axes excluded), derives invert from the move direction (throttle from FINAL +position), auto-adds the hat look cluster, and rewrites bindings.txt between +`# >>> BT_JOYCONFIG` markers (rest of the file preserved). Sign conventions: JoystickX + = +twist right, JoystickY + = aim up (wizard binds flight-style forward=down via invert), Turn + += right. Legacy `L4DINPUT.cpp DIJoystick` (L4CONTROLS=DIJOYSTICK, 1995 `Joystick` iface) +left untouched. Verified: grammar accept/reject by line, no-device + XInput-only polling +clean 30 s in-mission, zero DI cost with no joy rows. NOT yet verified: real stick axes +(no hardware here) — needs a tester with a stick. + ## Plan of record (2026-07-17) Working branch `glass-cockpit` (from master `e2c21c4`). Steps: (1) gates+scaffolding [THIS], diff --git a/engine/MUNGA_L4/L4JOY.cpp b/engine/MUNGA_L4/L4JOY.cpp new file mode 100644 index 0000000..45d0136 --- /dev/null +++ b/engine/MUNGA_L4/L4JOY.cpp @@ -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 +#include +#include +#include +#include + +//########################################################################### +// 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 +#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; +} diff --git a/engine/MUNGA_L4/L4JOY.h b/engine/MUNGA_L4/L4JOY.h new file mode 100644 index 0000000..06361fd --- /dev/null +++ b/engine/MUNGA_L4/L4JOY.h @@ -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); diff --git a/engine/MUNGA_L4/L4PADBINDINGS.cpp b/engine/MUNGA_L4/L4PADBINDINGS.cpp index c11602e..6827a15 100644 --- a/engine/MUNGA_L4/L4PADBINDINGS.cpp +++ b/engine/MUNGA_L4/L4PADBINDINGS.cpp @@ -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 [product-name substring]\n" +"# joyaxis axis [invert] [slew ] [deadzone ]\n" +"# joybutton <0-31> button \n" +"# joyhat <0-3> button \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 [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 [invert] [slew ] [deadzone ] + // + 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 ...). + // + 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; } diff --git a/engine/MUNGA_L4/L4PADBINDINGS.h b/engine/MUNGA_L4/L4PADBINDINGS.h index 2b7814a..a0f5d78 100644 --- a/engine/MUNGA_L4/L4PADBINDINGS.h +++ b/engine/MUNGA_L4/L4PADBINDINGS.h @@ -18,6 +18,11 @@ // key axis set // pad button // padaxis axis [invert] [slew ] +// joydev [product-name substring...] +// joyaxis axis [invert] [slew ] [deadzone ] +// joybutton button +// joybutton keypad +// joyhat button // // deflect = held drives the channel toward +/-1 at /s, auto-centers // on release (the spring-centered stick model). @@ -25,6 +30,17 @@ // lever model). // set = press jumps the channel to (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 diff --git a/engine/MUNGA_L4/L4PADRIO.cpp b/engine/MUNGA_L4/L4PADRIO.cpp index b3d48fd..ab80179 100644 --- a/engine/MUNGA_L4/L4PADRIO.cpp +++ b/engine/MUNGA_L4/L4PADRIO.cpp @@ -11,6 +11,7 @@ #include "l4padpanel.h" #include "l4glasswin.h" #include "l4ctrl.h" +#include "l4joy.h" #include #include @@ -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 diff --git a/engine/MUNGA_L4/L4PADRIO.h b/engine/MUNGA_L4/L4PADRIO.h index 04dde3b..bdf644e 100644 --- a/engine/MUNGA_L4/L4PADRIO.h +++ b/engine/MUNGA_L4/L4PADRIO.h @@ -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 diff --git a/game/btl4main.cpp b/game/btl4main.cpp index a5a6844..8f40bcd 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -197,6 +197,17 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine ? (std::ios::out | std::ios::app) : std::ios::out); std::cout.rdbuf(logfile.rdbuf()); + // BT_JOYCONFIG=1: the generic-joystick capture wizard (flight sticks / + // HOTAS / pedals -- L4JOY.h). Console prompts detect which device/axis + // the player moves for each pod control and write the joystick section + // of content\bindings.txt, then the boot continues into the game with + // the new bindings live (PadRIO loads the file after this point). + if (getenv("BT_JOYCONFIG") != NULL && *getenv("BT_JOYCONFIG") != '0') + { + extern int BTJoyConfigWizard(void); + BTJoyConfigWizard(); + } + // MP wire-format probe (env BT_NET_PROBE=1): print the exact packet constants // the console emulator (tools/btconsole.py) must speak, computed from OUR build // (message IDs chain across class enums at compile time -- never hand-compute). diff --git a/players/README.txt b/players/README.txt index c2813c8..7ae61c2 100644 --- a/players/README.txt +++ b/players/README.txt @@ -53,7 +53,10 @@ CONTROLS (keyboard): weapon panel's red PROGRAM button with the mouse and tap a fire key; the joystick diagram on the panel shows the binding live. -An Xbox controller works out of the box. Rebind keys in +An Xbox controller works out of the box. FLIGHT STICKS / HOTAS / +pedals: run joyconfig.bat ONCE -- a wizard asks you to move each +control (stick, twist, throttle, fire buttons), writes the bindings, +and drops you into the solo menu to try them. Rebind keys in content\bindings.txt (regenerates with defaults if deleted). This build: {VERSION}. Private -- do not redistribute. diff --git a/players/joyconfig.bat b/players/joyconfig.bat new file mode 100644 index 0000000..f104838 --- /dev/null +++ b/players/joyconfig.bat @@ -0,0 +1,34 @@ +@echo off +rem BT411 -- one-time JOYSTICK / HOTAS / pedals setup. +rem A console wizard asks you to move each control (stick, twist/rudder, +rem throttle, fire buttons); it detects what you moved and writes the +rem joystick section of content\bindings.txt. When it finishes the game +rem continues into the solo menu so you can try the bindings immediately. +rem Xbox-class controllers need NO setup -- they work out of the box. +rem Re-run this any time to redo the bindings. +cd /d %~dp0 +set BT_PLATFORM=glass +if not exist "build\Release\btl4.exe" goto badpath +if not exist "content\OPERATOR.EGG" goto badpath +set BT_JOYCONFIG=1 +set BT_START_INSIDE=1 +set BT_DEV_GAUGES=1 +set BT_LOG=join.log +set BT_FE_SOLO=1 +cd content +..\build\Release\btl4.exe +exit /b +:badpath +echo. +echo ================================================== +echo ERROR: this file is not in the right place. +echo. +echo It must sit in the EXTRACTED game folder, next to +echo the 'content' and 'build' folders. +echo. +echo Fix: right-click the .zip - Extract All, open the +echo extracted folder, and run this file from THERE. +echo (Do NOT run it from inside the zip.) +echo ================================================== +echo. +pause diff --git a/tools/mkdist.py b/tools/mkdist.py index 25cfdea..d02eb21 100644 --- a/tools/mkdist.py +++ b/tools/mkdist.py @@ -75,7 +75,8 @@ def main(): zpath = os.path.join(outdir, name + ".zip") exes = ["build/Release/btl4.exe"] - bats = ["players/play_solo.bat", "players/join.bat", "players/join_lan.bat"] + bats = ["players/play_solo.bat", "players/join.bat", "players/join_lan.bat", + "players/joyconfig.bat"] if steam_on: bats.append("players/play_steam.bat") for p in exes + bats: