diff --git a/docs/PLAN.md b/docs/PLAN.md index 861c5fd..6033d76 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -106,7 +106,7 @@ tray menu is always available. (tray app); `driver/` placeholder for the WDK project. - This plan + [PROTOCOL.md](PROTOCOL.md). -### Phase 1 — Virtual HID driver — compiling ✅ (sign/install pending) +### Phase 1 — Virtual HID driver — test-signed, installed, verified ✅ Implemented in [`driver/RioGamepad/`](../driver/RioGamepad/); builds to `RioGamepad.sys` against the EWDK (KMDF 1.15 + VHF, x64, warnings-as-errors). - KMDF + VHF virtual HID gamepad: report descriptor = 6×16-bit axes, 1 hat, @@ -116,10 +116,18 @@ Implemented in [`driver/RioGamepad/`](../driver/RioGamepad/); builds to [`Public.h`](../driver/RioGamepad/Public.h). - C# side of the contract: `RioJoy.Core.Hid.RioHidReport` packs axes/hat/buttons into that exact report (unit-tested). Replaces the throwaway test harness idea. -- ⏳ **Remaining (Phase 6 / on-cabinet):** test-sign + `pnputil` install + verify - in `joy.cpl`; wire the real `DeviceIoControl` feeder sink (replacing - `NullJoystickSink`). The EWDK's in-build catalog/sign tasks are bypassed; the - `.cat` is made with `inf2cat`/`signtool` at install time. +- Test-signed + `pnputil`-installed on the cabinet; INF declares `vhf` as a + **lower filter** (`LowerFilters` AddReg) — without it `VhfCreate` fails and the + device shows Code 31. The EWDK's in-build catalog/sign tasks are bypassed; the + `.cat` is made with `inf2cat`/`signtool` at install time (`driver/*.ps1`, + [`driver/README.md`](../driver/README.md)). +- **End-to-end verified:** the real `HidFeederJoystickSink` opens the device and + submits reports; axes (min/mid/max), buttons, and the POV hat all read back + correctly through `winmm joyGetPosEx` / `joy.cpl`. The controller's `joy.cpl` + name is set via the DirectInput `OEMName` registry value at install + (VHF can't supply a HID product string). +- ⏳ **Remaining:** none for the driver itself; redistribution off owned cabinets + would need attestation signing (Phase 6). ### Phase 2 — Serial + RIO protocol core (`RioJoy.Core`) — code-complete ✅ Implemented in `src/RioJoy.Core/Protocol` + `Serial`, covered by @@ -151,11 +159,12 @@ Implemented in `src/RioJoy.Core/Mapping` + `Output`, covered by the - Output is split behind sink interfaces (`IInputSink`, `IJoystickSink`, `ILampSink`, `IRioCommandSink`) so routing is pure and unit-tested; `SendInputSink` is the real `SendInput` keyboard/mouse adapter. -- ⏳ **Remaining:** the joystick sink's real adapter is the **HID feeder → - RioGamepad driver via `DeviceIoControl`**, which is blocked on the Phase 1 - driver. Routing already targets `IJoystickSink`; wire the IOCTL adapter once the - driver exists. The legacy default map / `RIO.ini` becomes an importable profile - (Phase 5/7). +- The joystick sink's real adapter — the **HID feeder → RioGamepad driver via + `DeviceIoControl`** (`Output/HidFeederJoystickSink`) — is implemented, wired in + (`RioCoordinator` selects it when the driver is present, else `NullJoystickSink`), + and verified end-to-end against the installed driver (Phase 1). +- ⏳ **Remaining:** the legacy default map / `RIO.ini` becomes an importable + profile (Phase 5/7). ### Phase 4 — Axis calibration + plasma display — code-complete ✅ Implemented in `src/RioJoy.Core/Calibration` + `Plasma` (105 xUnit tests total): @@ -189,9 +198,11 @@ Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tr + "start with Windows"; `RioCoordinator` owns the serial acquire/release tied to the watcher (native-game COM-port yield); OS adapters (`ForegroundProcessProvider`, `AutoStartManager`). -- ⏳ **Remaining:** the joystick output is a `NullJoystickSink` placeholder until - the Phase 1 HID feeder exists; full on-cabinet verification of the auto-switch + - acquire/release lifecycle. +- Joystick output now uses the real `HidFeederJoystickSink` when the driver is + present (verified end-to-end); `NullJoystickSink` remains only as the + no-driver fallback. +- ⏳ **Remaining:** full on-cabinet verification of the auto-switch + + acquire/release lifecycle against real RIO hardware. ### Phase 6 — Packaging / signing / deploy - Driver install via `pnputil`; app installer; test-signing script. diff --git a/driver/README.md b/driver/README.md index 7f36a6f..1cd136a 100644 --- a/driver/README.md +++ b/driver/README.md @@ -29,8 +29,11 @@ Test-signed, installed, and verified on the cabinet: the devnode starts clean (`CM_PROB_NONE`), `vhf` attaches as a lower filter, the VHF HID child enumerates, and the controller registers under `VID_1209&PID_5249` in `joy.cpl`. -Not yet done: wiring the real `DeviceIoControl` feeder sink (replacing the C# -side's `NullJoystickSink`). +The user-mode feeder (`RioJoy.Core.Output.HidFeederJoystickSink`) is wired in and +**verified end-to-end**: driving it moves the six axes, 96 buttons, and POV hat, +read back through `winmm joyGetPosEx` / `joy.cpl` exactly as a game would see +them. The tray app selects it automatically when the driver is present and falls +back to a no-op sink when it is not. ## Building diff --git a/tools/RioJoySmokeTest/Program.cs b/tools/RioJoySmokeTest/Program.cs new file mode 100644 index 0000000..9b66137 --- /dev/null +++ b/tools/RioJoySmokeTest/Program.cs @@ -0,0 +1,133 @@ +using System.Runtime.InteropServices; +using RioJoy.Core.Calibration; +using RioJoy.Core.Mapping; +using RioJoy.Core.Output; + +// On-cabinet smoke test: drive the real HidFeederJoystickSink (which opens the +// RioGamepad driver and submits reports via IOCTL_RIO_SUBMIT_REPORT) and read +// the virtual gamepad back through winmm joyGetPosEx, confirming fed values +// actually surface to the OS. Run after installing the driver: +// dotnet run --project tools/RioJoySmokeTest -c Release +// Exit code: 0 = all passed, 1 = a check failed, 2 = driver/joystick not found. + +const uint JOY_RETURNALL = 0x000000FF; +int failures = 0; +string lastName = ""; +void Check(string what, bool ok) +{ + Console.WriteLine($" [{(ok ? "PASS" : "FAIL")}] {what}"); + if (!ok) failures++; +} + +Console.WriteLine("== RioJoy feeder -> driver smoke test =="); + +if (!HidFeederJoystickSink.TryCreate(out var sink) || sink is null) +{ + Console.WriteLine(" [FAIL] HidFeederJoystickSink.TryCreate returned false (driver not present?)."); + return 2; +} +using (sink) +{ + Console.WriteLine(" [PASS] Opened RioGamepad device interface."); + + int joyId = FindJoyId(); + if (joyId < 0) + { + Console.WriteLine(" [FAIL] No winmm joystick found to read back from."); + return 2; + } + Console.WriteLine($" Reading back via winmm joystick id {joyId} (\"{lastName}\")."); + + // 1. X axis: min / mid / max (RioHidReport logical max = 32767 -> joyGetPosEx ~0..65535) + sink.SetAxis(JoyAxis.X, 0); var lo = Read(joyId); + sink.SetAxis(JoyAxis.X, 16384); var mid = Read(joyId); + sink.SetAxis(JoyAxis.X, 32767); var hi = Read(joyId); + Console.WriteLine($" X(min)={lo.X} X(mid)={mid.X} X(max)={hi.X}"); + Check("X axis tracks min 61000); + + // reset X to centre so it doesn't confuse later reads + sink.SetAxis(JoyAxis.X, 16384); + + // 2. Z axis independent move + sink.SetAxis(JoyAxis.Z, 32767); var z = Read(joyId); + Console.WriteLine($" Z(max)={z.Z}"); + Check("Z axis reaches max", z.Z > 61000); + sink.SetAxis(JoyAxis.Z, 16384); + + // 3. Buttons: 1 and 5 + sink.SetButton(1, true); var b1 = Read(joyId); + Check("button 1 down -> bit0 set", (b1.Buttons & 0x1) != 0); + sink.SetButton(5, true); var b5 = Read(joyId); + Check("button 5 down -> bit4 set", (b5.Buttons & 0x10) != 0); + sink.SetButton(1, false); + sink.SetButton(5, false); var b0 = Read(joyId); + Check("buttons released -> none set (low 8)", (b0.Buttons & 0xFF) == 0); + + // 4. Hat / POV + sink.SetHat(RioHat.Up); var hUp = Read(joyId); + Check("hat Up -> POV 0", hUp.Pov == 0); + sink.SetHat(RioHat.Right); var hR = Read(joyId); + Check("hat Right -> POV 9000", hR.Pov == 9000); + sink.SetHat(RioHat.Centered); var hC = Read(joyId); + Check("hat Centered -> POV centered (0xFFFF)", hC.Pov == 0xFFFF); +} + +Console.WriteLine(failures == 0 + ? "== ALL CHECKS PASSED ==" + : $"== {failures} CHECK(S) FAILED =="); +return failures == 0 ? 0 : 1; + + +// ---- winmm read-back helpers ------------------------------------------------ + +static (uint X, uint Y, uint Z, uint Buttons, uint Pov) Read(int id) +{ + Thread.Sleep(120); // let the report propagate through the HID stack + var info = new JOYINFOEX { dwSize = (uint)Marshal.SizeOf(), dwFlags = JOY_RETURNALL }; + uint rc = joyGetPosEx(id, ref info); + if (rc != 0) throw new InvalidOperationException($"joyGetPosEx failed: {rc}"); + return (info.dwXpos, info.dwYpos, info.dwZpos, info.dwButtons, info.dwPOV); +} + +int FindJoyId() +{ + int n = (int)joyGetNumDevs(); + int firstPresent = -1; + for (int id = 0; id < n; id++) + { + var caps = new JOYCAPS(); + if (joyGetDevCapsW(id, ref caps, (uint)Marshal.SizeOf()) != 0) continue; + var info = new JOYINFOEX { dwSize = (uint)Marshal.SizeOf(), dwFlags = JOY_RETURNALL }; + if (joyGetPosEx(id, ref info) != 0) continue; // not present + string name = caps.szPname ?? ""; + if (firstPresent < 0) { firstPresent = id; lastName = name; } + // RioGamepad identity (Public.h: RIO_VENDOR_ID 0x1209 / RIO_PRODUCT_ID 0x5249). + if (caps.wMid == 0x1209 && caps.wPid == 0x5249) { lastName = name; return id; } + if (name.Contains("RIOJoy", StringComparison.OrdinalIgnoreCase)) { lastName = name; return id; } + } + return firstPresent; +} + +[DllImport("winmm.dll")] static extern uint joyGetNumDevs(); +[DllImport("winmm.dll")] static extern uint joyGetPosEx(int uJoyID, ref JOYINFOEX pji); +[DllImport("winmm.dll", CharSet = CharSet.Unicode)] static extern uint joyGetDevCapsW(int uJoyID, ref JOYCAPS pjc, uint cbjc); + +[StructLayout(LayoutKind.Sequential)] +struct JOYINFOEX +{ + public uint dwSize, dwFlags, dwXpos, dwYpos, dwZpos, dwRpos, dwUpos, dwVpos, + dwButtons, dwButtonNumber, dwPOV, dwReserved1, dwReserved2; +} + +[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] +struct JOYCAPS +{ + public ushort wMid, wPid; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string szPname; + public uint wXmin, wXmax, wYmin, wYmax, wZmin, wZmax, wNumButtons, wPeriodMin, wPeriodMax, + wRmin, wRmax, wUmin, wUmax, wVmin, wVmax, wCaps, wMaxAxes, wNumAxes, wMaxButtons; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string szRegKey; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szOEMVxD; +} diff --git a/tools/RioJoySmokeTest/README.md b/tools/RioJoySmokeTest/README.md new file mode 100644 index 0000000..7128537 --- /dev/null +++ b/tools/RioJoySmokeTest/README.md @@ -0,0 +1,20 @@ +# RioJoySmokeTest + +On-cabinet verification that the **virtual gamepad path works end to end**: it +drives the real [`HidFeederJoystickSink`](../../src/RioJoy.Core/Output/HidFeederJoystickSink.cs) +(open the RioGamepad device → submit reports via `IOCTL_RIO_SUBMIT_REPORT`) and +reads the gamepad back through `winmm joyGetPosEx`, the same data `joy.cpl` and a +game would see. It checks the six axes (min/mid/max), buttons, and the POV hat. + +This is **not** part of `RioJoy.sln` and not a unit test — it needs the signed +driver actually installed (see [`driver/README.md`](../../driver/README.md)), so +it only passes on a machine where the RioGamepad device is present. + +## Run + +```cmd +dotnet run --project tools/RioJoySmokeTest -c Release +``` + +Exit codes: `0` all checks passed · `1` a check failed · `2` driver or joystick +not found (driver not installed, or device not started — check Device Manager). diff --git a/tools/RioJoySmokeTest/RioJoySmokeTest.csproj b/tools/RioJoySmokeTest/RioJoySmokeTest.csproj new file mode 100644 index 0000000..dc791e8 --- /dev/null +++ b/tools/RioJoySmokeTest/RioJoySmokeTest.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0-windows + enable + enable + x64 + x64 + RioJoySmokeTest + + + + + +