Author SHA1 Message Date
CydandClaude Fable 5 d13d434e88 editor: RIO port/pipe picker on the profile edit panel
The right-hand panel gains a "RIO port" row under Triggers: an editable
combo offering the app default (shown with its value), the machine's COM
ports, and pipe:vrio, with free text for anything else. Save stores the
endpoint in RioProfile.RioComPort (blank or the default entry = null =
follow DefaultRioComPort).

Because the editor session holds the endpoint it opened with, a saved
port change now re-arms the session live: the tray host unhooks the old
runtime's editor wiring, re-activates on the new endpoint, re-hooks, and
restores the output-gate state - so the live RIO commands and button
echo follow the new port/pipe without closing the editor.

Verified with the offline DrawToBitmap harness (layout, both default and
pipe:vrio states) plus a scripted save round-trip (default->null,
pipe:vrio->stored, blank->null, reopen shows stored COM7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:39:52 -05:00
CydandClaude Fable 5 9cca7c77bd serial: pipe:vrio named-pipe transport, no com0com needed
NamedPipeTransport connects as a client to vRIO's \\.\pipe\vrio (the
DOSBox-X fork's role) and speaks the shared typed-frame contract
(PipeFraming: 0x00 data / 0x01 modem lines, null-modem crossed). The COM
path's DTR reset pulse is replayed in-band on connect. Peer disconnects
and framing violations surface as the 0-byte transport-closed read the
link already understands.

RioTransportFactory routes endpoint strings — pipe:name (vRIO's own
picker syntax) to the pipe transport, everything else to
SerialPortTransport — and is wired into RioCoordinator and all three
RioSerialMonitor modes, so profiles (RioComPort/DefaultRioComPort) and
the bench tools take pipe endpoints anywhere a COM name went.

Gotcha baked into the design: named pipes here have 0-byte buffers, so a
write blocks until the peer reads it, and vRIO also writes its lines
frame before reading — the on-connect pulse frames are therefore queued
as overlapped writes (pipe writes drain in issue order, preserving the
edge positions) instead of blocking the constructor into a mutual
write-first deadlock.

Verified end-to-end against the real VRioDevice + VRioPipeService over
\\.\pipe\vrio: version 4.2 + check replies, 137 analog polls, lamp
commands ACKed, zero framing errors. 322 tests green, both flavors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 10:14:13 -05:00
CydandClaude Fable 5 3512c89dca tray: --import-profile merges a profile document into the user config
RioJoy.Tray --import-profile <profile.json> merges a single-profile
document (a repo's profiles/*.json) into %APPDATA%\RIOJoy\config.json
via ConfigStore.ImportProfile: same-name profiles are replaced in place
(FindProfile's case-insensitive convention), everything else appended,
all other config content preserved. Refuses (exit 3) while a tray
instance is running - a live tray rewrites the config from memory and
would silently discard the import. Documents that a Name must be stated
in the file itself: RioProfile defaults Name to 'Unnamed', so the import
checks the raw JSON, not the deserialized object. This is the profile
install story for bench machines and pods (previously: hand-paste into
the Profiles array).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 09:33:25 -05:00
CydandClaude Fable 5 6967e66837 calibration: throttle reads rest (0), not full, at the detent
Faithful port of a legacy quirk made harmful by axis routing: _throttleLast
initialized to Center and the lT==0 hold path meant the first poll at rest
compounded 16383*32 -> clamped 32766 = FULL throttle until the lever moved
past the deadzone (under the old routing this held LeftTrigger at 255 -
fire-secondary - from power-on). lT==0 now zeroes like the deadzone branch
and _throttleLast initializes to 0, the sanctioned rest value ResetAll/
ResetThrottle already used. Three regression tests replace the one that
asserted the old behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 09:00:09 -05:00
CydandClaude Fable 5 2f2438717a core: per-profile ViGEm axis routing with unipolar output mode
RioProfile gains a nullable AxisRouting section mapping each calibrated
axis (X/Y/Z/Rx/Ry/Rz) to a pad target (thumbs, triggers, or None) with a
Centered or UnipolarPositive conversion; the default reproduces the old
hardcoded routing exactly, so existing profiles are untouched. Routing
resolution lives in a pure, ViGEm-free AxisRouter for testability; the
sink neutralizes the pad on routing change so stale trigger state cannot
leak across profile switches.

Motivation: Descent reads the pad via SDL GameController, where the
triggers are its stock fire axis-buttons - the old fixed routing put
throttle on LeftTrigger (fires) and detent would have read as full
reverse. descent-d1x.json now routes Z->RightThumbY (UnipolarPositive,
detent = center) and Rz->RightThumbX, triggers untargeted; guarded by
tests that parse the shipped JSON through the real deserializer and
byte-compare the dxx-rebirth reference copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 09:00:09 -05:00
CydandClaude Fable 5 23dec8901b build: repair net40 Tray build and test-host binding redirects
Environment drift since ~7/26 broke a pristine checkout: (1) the SDK's
AutoGenerateBindingRedirects made RAR drop RioJoy.Core.dll for the net40
Tray exe (Bcl facade 1.5.11.0 vs 2.6.8.0 mismatch) - disabled for net40
with a hand-authored app.net40.config carrying the 2.6.8.0 redirects,
which XP's CLR 4.0 does not unify on its own; (2) 25 serial-link tests
failed on System.Runtime.CompilerServices.Unsafe load - Channels 8.0's
net462 binary references Unsafe 6.0.0.0 without declaring the dependency;
pinned the package and added the redirect app.config the earlier good
builds had auto-generated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:59:51 -05:00
CydandClaude Fable 5 d904f739d0 profiles: add canonical Descent (D1X-Rebirth) pod profile
First profile for a non-native game shipped in-repo (profiles/ is new -
profiles previously lived only inside config.json's Profiles array).
Matches d1x-rebirth; buttons map to synthesized DXX default keys with the
kiosk never-map list honored; ViGEm pad carries stick/throttle/pedals and
twitch actions. Worksheet + rationale: dxx-rebirth repo
docs/pod-input-map.md. Known work items before Phase-2 exit there: fixed
axis routing sends throttle/rudder to the trigger axes, and
AxisCalibrationConfig has no detent-to-center field yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 22:43:13 -05:00
CydandClaude Fable 5 25e3aa2ef5 PLAN: note wallpaper restore-on-dormant bench-verified on Win11 (incl empty case)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:39:19 -05:00
CydandClaude Fable 5 46961d0cd1 Wallpaper: restore the user's desktop when going dormant
The wallpaper maker/runtime overrides the desktop with the cockpit
wallpaper on profile activation but never put the user's own back.
Now RioCoordinator captures the current wallpaper (SPI_GETDESKWALLPAPER,
via new WallpaperApplier.GetCurrent) the first time it overrides it, and
restores it (WallpaperApplier.Restore) on every GoDormant and on Dispose
— so going idle, a native game taking the port, or app exit returns the
desktop to what the user had. Capture is once-per-override so switching
between cockpit profiles keeps the real previous wallpaper; only engages
when OverlayTemplatePath is set. Builds clean on net48 + net40.

Known gap (documented in PLAN.md): a hard crash between apply and restore
leaves the cockpit wallpaper, since SPIF_UPDATEINIFILE persists it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:15:20 -05:00
CydandClaude Fable 5 30c1a85445 Move RIO hardware + firmware archive to the TeslaRel410 repo
The physical RIO board docs (photos, schematics, GAL decode) and the
board firmware (dumps, disassembly, make_patch.py, RIO 4.3 + FastRIO
images, analysis, testlogs) describe hardware shared across all Tesla
cockpits with a lifecycle independent of this Windows app — a
native-game-only cabinet runs the firmware and never touches RIOjoy.
They now live in TeslaRel410/restoration/{rio-hardware,rio-firmware}
with full history preserved (git subtree).

Kept here: docs/PROTOCOL.md (this app's interface contract) and the
RioSerialMonitor bench harness (--mash/--e0test, C# on RioJoy.Core).
README + code comments now point at the TeslaRel410 archive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:50:46 -05:00
CydandClaude Fable 5 adfcee4bac Add pod-owner ELI5 of the wedge bug (wedge-explained.md)
Plain-language explanation of the reply-latch wedge for non-technical
pod owners, linked from the firmware README top. Frames the bug as a
'busy' sticky note the board forgets to take down on the give-up path,
and how RIO 4.3 fixes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:11:02 -05:00
CydandClaude Fable 5 cef6609ebd FastRIO 4.3: RIOv4_3_fastrio.bin (31250 + widened ACK-wait + all 4.3 edits)
Built with the full 4.3 flag set plus --baud31250 --widen-ackwait;
71 bytes vs stock, differing from RIOv4_3.bin in exactly two bytes
($D62B baud, $D9E7 ACK-wait) — disassembly-verified. Production table
updated; acceptance ladder on the FastRIO cockpit still required
before deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:28:27 -05:00
CydandClaude Fable 5 a76437ae16 Edit 7: RIOv4_3 reports firmware version 4.3
The VersionReply builder at $C6EA hardcoded 4.2; --reportversion=4.3
patches the minor operand byte ($C6FF: 02->03). Verified no host
software gates on the value (legacy prints it, RIOJoy parses it, native
games ignore it — Cyd). Rebuilt RIOv4_3.bin: 69 bytes vs stock, sha
6d67a2fc7713...; docs updated. Chips burned before this edit still
announce 4.2 — re-burn to pick up the number; all other bytes match
the certified image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:20:40 -05:00
CydandClaude Fable 5 8697f6c909 Christen RIO 4.3: RIOv4_3.bin is the production firmware
Final gate passed: 120s mash at 549 presses/min sustained (heaviest
stress run of the campaign) — zero wedges, counters flat, NAK 0, all
247 resends healed. RIOv4_3rc1 renamed to RIOv4_3.bin (same bytes,
sha dc59bd51cae3...); README/ANALYSIS promote it, production table
updated (native-game cabinets -> RIOv4_3.bin; FastRIO 4.3 variant is
build-on-demand with the documented flag set + acceptance ladder).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:53:02 -05:00
CydandClaude Fable 5 b0d245d056 RC1 acceptance PASS: E0000305 after check — edit 6 verified on both paths
Over-threshold check exchange repaints and re-renders a live E0 readout
(not the stale 04000000). The +2 drift in $3184 among 65 status frames
refines the counter model: $3184 = started timeout-retry sequences
(give-ups are the exhausted subset); timeout-recovered cycles exit via
the success teardown, NAK-recovered ones via $DA2F ($3185). All
observations from both bench sessions fit. Mash spot-check remains
before christening RIO 4.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:46:53 -05:00
CydandClaude Fable 5 83341f1ec0 e0test: check-exchange acceptance stage for edit 6; record RC1 first-light
After the flip leaves counters over threshold, the tool now sends a
CheckRequest (ACKing every status frame) — edit-6 chips must repaint
and re-render the E0 readout; 04000000 marks a pre-rc1 chip. Analysis
records the stock-chip archaeology (stock also leaves 04000000, but the
ungated E0 readout papered over it in period — our fixes exposed it)
and RC1's below-threshold first-light PASS (version+check -> F0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:29:16 -05:00
CydandClaude Fable 5 2ef632a1c2 RIOv4_3rc1 candidate: wedge fix + E0 threshold + check-display repaint; archive prior images
New edit 6 (--checkrepaint, requires --e0thresh): hooks the CheckRequest
handler's final notify ($C5E4) through a 10-byte cave at $E020 that
repaints F0000000 after the self-test and re-renders the E0 readout only
when a counter is at/over threshold — no more stale 04000000 after
status checks. Candidate = 9600 native-game-compatible base + edits
1-2/5/6, 68 bytes vs stock, disassembly-verified, awaiting burn; on
verification it will be christened RIO 4.3.

All prior patched generations (wedge-only, 31250/31250v2, 62500/125000
science builds, e0t5 pair) moved to rio-firmware/archive/ with their
disassemblies; RIOv4_2.bin stays at top level as the pristine patch
source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:13:13 -05:00
CydandClaude Fable 5 64cd933bb1 Correct 04000000 finding: CheckRequest self-test leftover, not test mode
Bench refutation (display is static under axis movement; version-only
exchanges leave it alone) kills the A-9-E chord theory. Real mechanism:
the CheckRequest handler at $C5A6 runs a full self-test — sets the
test-display flag $2421, brackets itself with TestModeChange 0x8C,
lamp pattern, pod scan, five-channel encoder sweep — and returns
without repainting, leaving the channel-4 frame (04000000) as a stale
cosmetic snapshot. Board fully healthy; no reset needed. PROTOCOL.md
now warns hosts that 0x8C fires around every check. Candidate firmware
fix (repaint cave off $C5E4) noted for a future burn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:04:11 -05:00
CydandClaude Fable 5 fd4dd3bb12 e0t5 mash regression: PASS (0 wedges, counters flat) + live test-mode incident
120s lamps-on mash on the burned 9600 e0t5 chip: 236/236 presses, 88.0%
analog fill, NAK 0, zero wedges, board counters flat — normal operation
unaffected by the E0-threshold patch. Bonus field data: the keypad
A-9-E chord fired mid-mash and dropped the board into axis test 4
(display 04000000), confirming that button scanning halts in test mode
while ISR-driven serial keeps running — visible in the log as presses
freezing at 236 while analog continued to 1919.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:48:30 -05:00
CydandClaude Fable 5 4ac688dfa5 Docs: record E0-threshold bench PASS (E0000105) + corrected counter semantics
The display observation recalibrates the counters: $3184 = give-up
cycles (once per exhausted retry sequence, not per retransmit), $3185 =
teardowns of any reply cycle needing at least one retransmission — the
counter behind a lone E0000001. Also recorded in the error-handling
inventory: 5 retransmits per cycle, reply-await arms only after the
complete frame, NAK forces exactly one counted retransmit, late ACKs
cannot rescue a cycle. (Previous commit's doc edits had silently missed
their anchors; applied properly this time.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:40:57 -05:00
CydandClaude Fable 5 b5e2ad35af E0-threshold patch bench-verified: PASS at E0000105; counter semantics corrected
Display observation (Cyd, on-cabinet): held F0000000 through the clean
handshake and four NAK-then-ACK sub-threshold cycles, flipped exactly at
the 5th teardown to E0000105. That reading recalibrates the counters:
$3184 counts give-up cycles (not per-retransmit) and $3185 counts
teardowns of any imperfect reply cycle — the counter behind the original
lone E0000001. Also recorded: 5 retransmits per cycle (not 4), reply-
await arms only after the complete frame, NAK forces exactly one counted
retransmit, and a late ACK cannot rescue a cycle once retries begin.
Tool predictor updated to the confirmed semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:39:03 -05:00
CydandClaude Fable 5 6b02eb4d1b RioSerialMonitor: --e0test mode — on-hardware E0-threshold verification
Two-phase test against a DTR-reset board: phase A forces sub-threshold
retransmits (NAK the completed reply frame -> exactly one counted
retransmit, then ACK the retry), where the display must hold F0000000;
phase B withholds ACKs for a full give-up cycle to cross the threshold,
with the expected E0 readout predicted from observed traffic.

Bench findings while building it (9600, e0t5 chip): the reply-retry
machine sends 5 retransmits per cycle (not 4); responses are honored
only after the complete reply frame (mid-frame ACK/NAK/RESTART is
ignored); once the first retry fires the cycle runs blind to give-up;
a host NAK triggers an immediate counted retransmit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:32:21 -05:00
CydandClaude Fable 5 2e727a4c1c Firmware README: complete the patched-image inventory (speed ladder, e0t5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:14:14 -05:00
CydandClaude Fable 5 398ad1739d Firmware: --e0thresh patch — gate the E0 error display (default N=5)
Stock $D5F2 repaints the cockpit display to the E0 counter readout on
the FIRST increment of $3187/$3184/$3185, so one benign give-up shows
E0000001 forever. New opt-in make_patch.py edit 5 hijacks the render's
LDX #$2038 into a 30-byte cave at $E000: all three counters below N ->
exit via the routine's own epilogue (registers restored, display
untouched); any >= N -> resume the render. Counters still accumulate.

Built + disassembly-verified (not yet burned): RIOv4_2_patched_e0t5.bin
(9600) and RIOv4_2_patched_31250v2_e0t5.bin (FastRIO). Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:02:22 -05:00
CydandClaude Fable 5 c6bec4ec2a Error-handling inventory: E0 diagnostics, dEAd crash screen, no watchdog
The display has a third use: serial error annunciator. $D5F2 renders
'E0' + three live counters (TX-ring overflow $3187, reply retries
$3184, give-up teardowns $3185); $DAB8/$DAB2 is a terminal crash
screen spelling 'dEAd' when the TX dispatcher meets an unknown command
byte. Also recorded: RX overrun is unhandled (checksum NAKs are the only
symptom), unused vectors hang in BRA-self stubs, FAULT LED = MAX690 PFO,
manual/remote reset works by pulling PFI low, RUN LED = buffered AS*,
and the MAX690 watchdog is unconnected/unserviced — software wedges
persist until reset, matching bench observations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:47:01 -05:00
CydandClaude Fable 5 9819949d65 Display board 1408: full output definition + firmware display content
New display-board-1408.md: pin-level definition of the board's outputs
(8 multiplexed hex digits via ICM7228B, isolated LPT LED monitor block,
no readback path) and, from the firmware disassembly, everything the RIO
puts on it: F0 boot banner, F1 test-mode banner (keypad sequence A-9-E
enters, D exits), and the sub-test displays — five live encoder-count
readouts, button test with input index, two lamp patterns, keypad test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:35:12 -05:00
CydandClaude Fable 5 38defb5246 FastRIO certified: 31250v2 final runs clean; config plumbing for baud/poll
Operator-verified zero lamp misses/hangs through the slow-chord test
(62500's killer) and 392/min mash. Final run: NAK=0, 25 timeout resends
silently healing inbound response loss, counters flat, zero wedges.
--poll 20 delivered ~31Hz analog (1.7x legacy); the shortfall vs 50Hz
is additive host pacing (delay after awaited exchange), not board
saturation - noted as a future host tweak.

AppConfig gains RioBaudRate (default 9600) + AnalogPollMs (default 55),
plumbed through RioCoordinator; existing configs unchanged. Production
matrix recorded in ANALYSIS.md: 9600 patched chip for native-game
cabinets, 31250v2 + poll 20-25 + FTDI for RIOJoy cockpits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:19:30 -05:00
CydandClaude Fable 5 e8e4522ed4 Hardware docs: Quad Amplifier Board (1413) photos
Four photos of the 1413 Rev. 1: component side with the bolt-on Pyramid
PB-150P, solder side (etch NT 1-0A 3695), and both faces of the Pyramid
module (inputs/level pots, speaker terminals/fuse/power). Photo index
updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:12:49 -05:00
CydandClaude Fable 5 e793af1bb7 Hardware docs: 1413 is the Quad Amplifier Board — a bolt-on Pyramid PB-150P
Photos of the physical board (Rev. 1) show the amplification is a stock
Pyramid PB-150P Pro Plus 4-channel car amp mounted as a module; the VWE
board is passive: 12V/remote power distribution, per-corner 3-way
crossovers (Dale IHB-3 820uH/180uH, MMP film caps, CP-5 2R pads), 6-pin
woofer/mid/tweeter corner connectors, and the LED output-check circuit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:02:25 -05:00
CydandClaude Fable 5 d4c2615cc5 GAL U7 decoded: full RIO memory map recovered from the fuse dump
galdecode.py applies the GAL20V8 complex-mode fuse geometry (tables from
MAME jedutil.cpp) to GAL20v8a_5764.JED with pin names from schematic
sheet 1. Result triple-checks: schematic net names, firmware $A0xx
accesses, and the chip's UES signature — which VWE programmed as 'U7'.

Memory map: $2000-$9FFF SRAM (E-qualified), $A000 display write port,
$A010 pod-bus latch, $A020-$A03F HCTL-2016 counters (A3 byte select
via NOT_A3), $C000-$FFFF EPROM (explains the FF-padded dump), with
OE* = E&R/W as the shared read enable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:57:44 -05:00
CydandClaude Fable 5 a0f49c8a7c Mash tool: --poll flag for FastRIO bandwidth harvesting
Overrides the legacy 55ms analog poll interval (e.g. --poll 20 at
31250 -> ~50Hz analog). Summary's expected-poll-slots math follows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:42:16 -05:00
CydandClaude Fable 5 61e7d0f600 62500 SHELVED: chord-driven RX overrun is host-unfixable; 31250v2 is FastRIO
Slow chorded presses still glitch lamps at 62500 after three rounds of
host-side hardening (NAK-race resend -> stop-and-wait -> typed
resolution): a chord maximizes board ISR latency exactly when lamp
replies arrive, and a 2-byte RX buffer loses bytes no protocol can
recover. Full post-mortem in ANALYSIS.md; all bench evidence archived.
Final ladder: 9600 production / 31250v2 FastRIO / 62500 shelved /
125000 not viable. Stop-and-wait stays on everywhere as cheap delivery
insurance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:40:48 -05:00
CydandClaude Fable 5 5dd7f78bc2 RioSerialLink: type-aware resolution closes the straggler-reply hole
Bench round 3 (testlogs/riomash-patched-62500-sw): lamp glitches
persisted because reply-resolution was type-blind — an analog reply
still crossing USB from the previous poll could falsely confirm the
NEXT command (usually a lamp write) before the board judged it. Now a
reply resolves only its MATCHING pending request (analog/version/
check); ACK/NAK stay type-blind, which is safe because the board's TX
ISR prioritizes ACK/NAK ahead of reply data, so a command's ACK cannot
trail into its successor's window. Budget-exhausted drops settle 10ms
before releasing the gate so late stragglers land on an empty pending.

New test: a stray AnalogReply must not resolve a pending lamp command.
283 green; selftest regression unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:34:12 -05:00
CydandClaude Fable 5 f752e2131a Remove root duplicates of hardware source material
A concurrent commit (47df14c) picked up the untracked photo/PDF/JED files
at the repo root just as they were being organized into docs/hardware/.
The content is identical (same blobs); this completes the move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:28:32 -05:00
CydandClaude Fable 5 edbf3f8340 Hardware docs: board photos, schematic scan, GAL fuse dump
Organize the restoration source material under docs/hardware/:
- 9 board photos (renamed from Signal timestamps, index in README)
- Scans_018-014.pdf: 7-sheet VWE schematic set (RIO_1407 CPU/power/IO,
  PBE_1401 buttons, KEY_1402 keypad, DSP_1408 display, AMP_PLT 1413 amp)
- GAL20v8a_5764.JED: fuse map of the 1407's U7 memory decoder

README documents the board family, the 68HC11 memory map decode path,
the pod-bus signal set and jumper addressing, and cross-links PROTOCOL.md
and rio-firmware/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:27:36 -05:00
CydandClaude Fable 5 47df14cc61 RioSerialLink: stop-and-wait command delivery (supersedes NAK-race resend)
Bench falsified the v1 retransmit (testlogs/riomash-patched-62500-retx):
resends tracked NAKs 1:1 (321/321) yet lamps still stuck/missed — under
mash bursts the NAK arrives after a newer command is already "latest",
so the wrong packet was resent; and total shreds never NAK at all.

Now commands are stop-and-wait: ONE in flight (_commandGate), resolved
by ACK, NAK, or AckTimeout (50ms); NAK/timeout retransmits THE SAME
packet up to CommandRetransmitLimit (2), then drops (idempotent - the
next state update supersedes). Attribution is exact by construction and
timeouts catch silent shreds. Control-byte replies bypass the gate so
board traffic is never delayed; a request's own reply (analog/version/
check) also resolves the wait, so request/reply exchanges never burn
the timeout even if the board sends no explicit ACK.

7 tests (same-packet resend, timeout retry+drop, ACK completion,
serialization, reply-resolves-request, stray-NAK no-op, disable);
282 green. Mash summary now reports NAK/timeout resends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:20:30 -05:00
CydandClaude Fable 5 1943b7f8eb RioSerialLink: NAK-driven retransmit of the last command packet
The board ACK/NAKs every inbound packet; we were fire-and-forget, so
any corrupt arrival became a permanent state error (stuck-bright /
missed lamps at 62500, where ~6.5% of 4-byte lamp commands lose bytes
to RX-ISR overrun). Now a NAK control byte triggers a resend of the
most recent command packet, bounded by NakRetransmitLimit (default 2;
0 restores fire-and-forget).

Design notes: the wire has no sequence numbers, but every PC->RIO
command is idempotent (lamp state, analog request, reset), so resending
the latest command is safe even in the rare race where the NAK belonged
to an earlier packet. Single control-byte replies (our ACKs) never
participate. NakRetransmits counter surfaced in the mash summary.

6 new tests (retransmit, limit, budget reset, ACK no-op, control-byte
exclusion, disable switch); 281 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:58:27 -05:00
CydandClaude Fable 5 894521206a 62500 bench: analog clean, lamps lose ~6.5% — RIO speed ladder complete
NAK=69/~1060 lamp commands explains the observed stuck-bright/missed
lamps (fire-and-forget writes turn each corrupt 4-byte burst into a
visible state error) while 2-byte traffic is pristine (analog 88.1%,
counters flat, zero wedges, worst gap 0.14s). The HC11 RX ISR's worst
path sits right at ~320 E-cycles. Ladder: 9600 production, 31250v2
CLEAN (FastRIO), 62500 needs host NAK-retransmit, 125000 CPU-walled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:08:46 -05:00
CydandClaude Fable 5 f7c16f579d 125000 bench: RX overrun confirmed (NAK=1082, lamps dead) — CPU is the wall
Board->PC near-perfect at 125k (framing 5) while PC->board shredded by
packet length (2-byte polls 60% delivered, 4-byte lamp cmds ~2/3 NAKed
as checksum-corrupt): the 2MHz HC11 RX ISR can't drain back-to-back
bytes at 80us. MAX232 acquitted; zero wedges even at 40% inbound loss.
62500 image built as the sweet-spot candidate (RIOv4_2_patched_62500.bin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 12:57:51 -05:00
CydandClaude Fable 5 d7535f2a43 Firmware: RIOv4_2_patched_125000.bin — max-speed science build (13x)
make_patch.py generalized: baud flags 31250/62500/125000, each holding
the byte-clocked ACK-wait at ~12.8ms wall time (40/80/160 ticks; the
widen is implied for 62500/125000 since stock 4 ticks would be 0.6/0.3ms
there). All historical hashes reproduce unchanged; the 125000 image
(sha256 d22c8d85..., 25 bytes) diffs from 31250v2 by exactly the BAUD
and CMPA operands.

Predictions on record for the bench run: FTDI side exact (3M/24); the
MAX232-class level shifter is AT its 120kbps rating so edge-rounding
may surface as noise flags; and the 2MHz HC11 has ~160 E-cycles/byte -
back-to-back inbound bytes likely outrun the RX ISR (the firmware sets
its overrun flag $3186 but never reads it), so expect dropped bytes ->
NAK/retry churn and a dirtier profile than v2's. Wedges should stay 0.
62500 is one flag away as the fallback sweet spot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:20:30 -05:00
CydandClaude Fable 5 61778ec3f1 FastRIO v2 bench: CLEAN — ACK-wait widening eliminates the retry storm
192s mash at 31250 with the $D9E7 wait fix: framing 5655 -> 54
(-99.4%), AbandonCount 65 -> +0, duplicate replies gone (215% -> 88.6%
of poll slots — identical to the healthy 9600 profile, the structural
$D758 drop-gate ratio), zero wedges, counters perfectly flat. The
byte-clocked ACK-wait theory is confirmed end to end: disassembly
predicted the mechanism, one operand byte fixed it, the wire went
quiet. Evidence log archived; results table in ANALYSIS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:13:38 -05:00
CydandClaude Fable 5 9a12b779bb Firmware 31250 v2: widen the byte-scaled reply ACK-wait ($D9E7: 04->28)
Disassembly confirmed the bench theory: the no-ACK wait loop at $D9E0
self-clocks in byte times (each tick transmits an IDLE keep-alive and
re-enters on its TX-complete interrupt), so raising the baud silently
shrank the ACK grace from ~5.2ms to ~1.6ms - under USB turnaround,
hence the v1 retry storm. --widen-ackwait (requires --baud31250) sets
the limit to 40 ticks (~12.8ms at 31250). v2 image: 25 bytes changed,
sha256 420d4cfc...; re-disasm diff vs v1 is exactly the CMPA operand.
v1/classic hashes reproduce unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 00:45:40 -05:00
CydandClaude Fable 5 56d951cee9 31250 bench run: wedge patch holds under retry storm; window is byte-scaled
127s mash at 31250: zero wedges — but 5655 framing resyncs, duplicate
replies (215% of poll slots) and AbandonCount=65 reveal the board's
reply ACK-wait scales with byte time: ~5+ms at 9600 (USB beats it,
zero framing) vs ~1.6ms at 31250 (USB cannot). All 65 retry
exhaustions self-recovered through the patched give-up path — the
strongest field proof of the latch fix yet; unpatched firmware would
have wedged on the first. Verdict recorded in ANALYSIS.md: cabinets
run the 9600 patched chip; 31250 stays a bench branch until the retry
window is widened in firmware.

Tool: before-snapshot now retries once (can lose the post-DTR boot
race, as this run showed); evidence log archived.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 00:36:23 -05:00
CydandClaude Fable 5 4bbdaff5ef Mash tool: Ctrl+C ends the run early with a full summary
Stopping early is a real operator workflow (finger fatigue); previously
an interrupt died summary-less and could orphan a process holding the
COM port. CancelKeyPress now breaks the run loop, snapshots the after-
counters, and prints the summary with the actual elapsed duration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 00:23:48 -05:00
CydandClaude Fable 5 af9ae439a5 Archive the bench A/B evidence logs (force past the riomash ignore)
testlogs/ carries the raw records behind the CONFIRMED verdict:
baseline wedged at 0.62s and stayed dead 300s; patched ran clean
through 152s of heavier mash, worst gap 0.58s self-recovered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 00:22:55 -05:00
CydandClaude Fable 5 3c04077317 Firmware wedge patch CONFIRMED on real hardware — bench A/B results
Baseline (original chip): reply path wedged 0.62s into the run — the
tool's own version/check exchange colliding with analog polling was
enough, no mash required — and stayed dead for all 300s despite 951
button presses (no button revival; power cycle only). Patched chip:
survived the same startup collision, 2419 analog replies at a steady
~16/s over 152s under heavier mash (peak 635/min), zero wedges, worst
gap 0.58s self-recovered — the $DFF0 latch-clear stub doing its job.

Results table + evidence logs committed (rio-firmware/testlogs/).
Remaining: cabinet soak + native-game session; 31250 variant A/B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 00:21:38 -05:00
CydandClaude Fable 5 1156eb8fe9 Mash tool: dispose transport before awaiting the link on shutdown
On a wedged (fully silent) board the receive loop sits in a pending
net48 serial read that ignores cancellation; awaiting the link before
closing the port hangs the tool at run end and loses the summary -
exactly what happened on the first real baseline run (the board wedged
at 0.62s and stayed dead, so no byte ever completed the read). Close
the port first (same order RioCoordinator.Teardown uses); applies to
both monitor and --mash modes. Selftest unaffected (its fake transport
honors cancellation, which is why it never caught this).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 00:11:26 -05:00
CydandClaude Fable 5 a8ec285b8e Firmware: RIOv4_2_patched_31250.bin — wedge fix + SCI retuned to 31250 baud
make_patch.py gains --baud31250: one byte beyond the wedge patch — the SCI
init operand at $D62B ($30 -> $02). BAUD $30 = /13 prescale, /1 divider
(2MHz E / 208 = 9615); $02 = /1, /4 -> 31250 exactly, 3.3x faster. The
init write also confirms the 8MHz crystal, which is why 19200/38400 are
unreachable and why 62500/125k are left alone pending an ISR cycle count.

- 24 bytes changed vs original (sha256 9f866cf3...); re-disassembly diff
  vs the classic patched image shows exactly the one operand line, and
  the classic build still reproduces 3fc8170c... (script regression-safe).
- PC side: SerialPortTransport takes an optional baudRate (default 9600,
  runtime untouched); RioSerialMonitor + --mash accept --baud 31250.
- Caveats documented in README/ANALYSIS: non-standard rate (FTDI-class
  adapters only, no 16550s); native games still speak 9600 so this chip
  is bench/RIOJoy-only; validate the wedge patch at 9600 first.

275 tests green; mash --selftest regression unchanged (FAIL/exit-1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:25:00 -05:00
CydandClaude Fable 5 fcf26cfd15 Firmware validation harness: instrumented RIO_TAP mash test in RioSerialMonitor
New --mash mode (tools/RioSerialMonitor/MashTest.cs) mechanizes the
wedge-patch validation plan from RIOv4_2-ANALYSIS.md:
- Runs the live link with the app's >5s reset-recovery DISABLED so a
  board wedge stays observable, and echoes lamps on every press
  (lamp/reply collisions are the wedge trigger).
- Gap timing uses ANY AnalogReply packet (0xFE sentinels included -
  a sentinel still proves the reply path is alive); logs a gap
  histogram + top-10 longest gaps with timestamps.
- WEDGE detector: analog silent past the threshold (default 2s) ->
  beep + banner; on resume, classifies self-recovered (patched
  expectation) vs button-revived (button event within 300ms of resume,
  the unpatched signature) vs unresolved at run end.
- Board self-reported RestartCount/AbandonCount/FullBufferCount
  snapshotted before/after via CheckRequest, delta printed
  (7-bit wrap-aware).
- Fixed-layout summary teed to riomash-<label>-<stamp>.log so
  baseline-vs-patched runs diff directly. Exit 0 = no wedge, 1 = wedge.

--mash --selftest drives the whole instrument against a scripted
in-memory board (SelftestTransport) that goes silent at t=4.0s and
revives 200ms after a button at t=6.5s: verified end-to-end - alarm at
6.0s, wedge classified button-revived (2.75s), counter delta +4/+0/+1,
verdict FAIL, exit 1. Use it to sanity-check the alarm at the cabinet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:35:27 -05:00
CydandClaude Fable 5 52712f8409 Phase 8B: RioGamepadXP.sys — WDM HID minidriver for Windows XP
The XP flavor of our own virtual joystick (no third-party driver), built
with WDK 7.1.0 to x86/subsystem-5.01. Exposes the identical Public.h
contract as the modern KMDF+VHF driver — same GUID, IOCTL_RIO_SUBMIT_REPORT,
25-byte report, VID/PID — so HidFeederJoystickSink drives both.

Design (driver/RioGamepadXP/rioxp.c):
- WDM HID minidriver via HidRegisterMinidriver presenting the 6-axis/hat/
  96-button joystick. POLLED mode (DevicesArePolled=TRUE): hidclass paces
  IOCTL_HID_READ_REPORT and we complete each synchronously from a cached
  report — no pending-IRP or cancel-routine machinery (the usual crash
  surface in a virtual HID driver).
- Named sideband control device (\Device\RioGamepadXP + \DosDevices symlink)
  takes IOCTL_RIO_SUBMIT_REPORT and updates the cache under a spinlock.
- hidclass overwrites our CREATE/CLOSE/DEVICE_CONTROL during registration;
  we save its pointers and reinstall wrappers that route the control device's
  IRPs to us and forward the HID FDO's to hidclass.

Packaging/install:
- Root-enumerated, so install uses devcon (built from the same WDK) —
  InstallHinfSection can't create the devnode. install-core.bat runs
  "devcon install RioGamepadXP.inf root\RioGamepadXP"; unsigned is fine
  (XP x86 enforces no kernel signing). Bundled into vendor\xp\.
- net40 feeder opens the driver by name (\.\RioGamepadXP); XP can't put a
  device interface on a bare control device (no PDO) — the one nuance.

Static build only: compiles clean but runtime bring-up (joy.cpl enumeration,
report flow) needs a real XP target — that's 8E. 275 tests still green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:01:16 -05:00
55 changed files with 3533 additions and 26981 deletions
+9
View File
@@ -372,11 +372,20 @@ FodyWeavers.xsd
# WDK / driver build outputs
[Dd]river/**/[Xx]64/
[Dd]river/package/
# WDK build.exe output trees + logs (RioGamepadXP / any ddkbuild sample)
[Dd]river/**/objfre_*/
[Dd]river/**/objchk_*/
[Dd]river/**/buildfre_*.log
[Dd]river/**/buildchk_*.log
[Dd]river/**/build.err
[Dd]river/**/build.wrn
*.cer
*.cat
*.pvk
# Local run-time config that shouldn't be versioned
*.local.json
# Firmware mash-test session logs (tools/RioSerialMonitor --mash)
riomash-*.log
# Generated overlay/editor previews (regenerable, not committed)
docs/reference/customBackground/riojoy-preview.png
+14
View File
@@ -22,6 +22,7 @@ Red Planet — talk to the RIO directly and do not use this app.)
| [`tools/XcfRegionExtract`](tools/XcfRegionExtract/) | Extracts cockpit label regions from `riojoy.xcf``regions.json` |
| [`docs/PLAN.md`](docs/PLAN.md) | Full modernization plan (7 phases) |
| [`docs/PROTOCOL.md`](docs/PROTOCOL.md) | RIO wire format + `iRIO` input-map reference |
| _RIO board hardware & firmware_ | Moved to the [TeslaRel410 `restoration/`](https://gitea.mysticmachines.com/VWE/TeslaRel410/src/branch/main/restoration) archive — board photos, schematics, GAL decode (`restoration/rio-hardware`) and the RIO 4.3 board firmware (`restoration/rio-firmware`) |
| [`docs/reference/`](docs/reference/) | Cockpit overlay art & the legacy labeling pipeline |
| [`legacy/`](legacy/) | Original C++/vJoy implementation, kept as reference |
@@ -51,3 +52,16 @@ auto-switch), and the HID report packer that matches the driver's wire format.
Remaining work is **on-cabinet** (real RIO serial/axis/plasma/auto-switch
verification) plus packaging (Phase 6) and the profile editor + overlay
generator (Phase 7). See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
## Testing without hardware: vRIO over a named pipe
The [vRIO](https://gitea.mysticmachines.com/VWE/VRIO) device emulator can stand
in for the real board with no com0com pair: anywhere a COM port name is
configured — a profile's `RioComPort`, the app-wide `DefaultRioComPort`, or the
`RioSerialMonitor` `[port]` argument — the endpoint `pipe:vrio` connects to
vRIO's `\\.\pipe\vrio` instead (vRIO must have its pipe endpoint open). Serial
bytes and modem lines (including the DTR reset pulse on open) travel as typed
frames over the pipe; the contract lives in
[`src/RioJoy.Core/Serial/PipeFraming.cs`](src/RioJoy.Core/Serial/PipeFraming.cs)
on this side and vRIO's `PipeFraming.cs` / the DOSBox-X fork's
`serialnamedpipe.h` on the others.
+2 -1
View File
@@ -102,7 +102,8 @@ try {
@{ Name = 'dotNetFx40_Full_x86_x64.exe'; What = '.NET Framework 4.0 offline installer' },
@{ Name = 'NDP40-KB2468871-v2-x86.exe'; What = '.NET 4.0 update KB2468871 (required by Bcl.Async)' },
@{ Name = 'RioGamepadXP.inf'; What = 'RioGamepadXP driver INF (Phase 8B)' },
@{ Name = 'RioGamepadXP.sys'; What = 'RioGamepadXP driver binary (Phase 8B)' }
@{ Name = 'RioGamepadXP.sys'; What = 'RioGamepadXP driver binary (Phase 8B)' },
@{ Name = 'devcon.exe'; What = 'devcon (creates the root-enumerated driver devnode on XP)' }
)
foreach ($item in $xpWanted) {
$src = Join-Path $vendorXpSrc $item.Name
+11 -4
View File
@@ -55,12 +55,19 @@ if exist "%PKGROOT%vendor\xp\NDP40-KB2468871-v2-x86.exe" (
echo will not run without KB2468871. Install it before first use.
)
rem RioGamepadXP virtual-joystick driver (bundled from Phase 8B onward).
rem RioGamepadXP virtual-joystick driver. Root-enumerated, so it must be
rem created with devcon (rundll32/InstallHinfSection would not make the
rem devnode). devcon is idempotent: re-running "install" updates in place.
if exist "%PKGROOT%vendor\xp\RioGamepadXP.inf" (
echo Installing the RioGamepadXP virtual joystick driver...
rundll32 setupapi.dll,InstallHinfSection DefaultInstall 132 %PKGROOT%vendor\xp\RioGamepadXP.inf
if exist "%PKGROOT%vendor\xp\devcon.exe" (
echo Installing the RioGamepadXP virtual joystick driver...
"%PKGROOT%vendor\xp\devcon.exe" install "%PKGROOT%vendor\xp\RioGamepadXP.inf" root\RioGamepadXP
if errorlevel 1 echo WARNING: driver install returned an error - check Device Manager.
) else (
echo WARNING: vendor\xp\devcon.exe missing - cannot create the driver devnode.
)
) else (
echo Note: RioGamepadXP driver not bundled yet - joystick output is
echo Note: RioGamepadXP driver not bundled - joystick output is
echo unavailable on XP; keyboard/mouse, lamps and plasma still work.
)
+51 -22
View File
@@ -206,6 +206,21 @@ Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tr
- Joystick output now uses the real `HidFeederJoystickSink` when the driver is
present (verified end-to-end); `NullJoystickSink` remains only as the
no-driver fallback.
- **Per-profile ViGEm axis routing — code-complete ✅.** `RioProfile.AxisRouting`
(`Output/AxisRoutingConfig`: one route per calibrated axis = pad target —
four thumbs, two triggers, or None — + output mode; shared types with no
ViGEm references, so net40 keeps compiling) re-routes the six axes on the
Xbox 360 pad. Modes: `Centered` (legacy bipolar) and `UnipolarPositive`
(calibrated 0 → thumb center — for the ratcheted unipolar throttle). Null/
absent = the historical fixed routing, so existing profiles are untouched.
Resolution + conversion is the pure unit-tested `AxisRouter`;
`ViGEmJoystickSink.SetRouting` applies it thread-safely and neutralizes all
thumbs/triggers each profile switch (no stale axes); wired in
`RioCoordinator.Activate` beside the calibrator config. First consumer:
`profiles/descent-d1x.json` (throttle→RightThumbY unipolar, rudder→
RightThumbX, triggers untouched — DXX fires on the LT/RT axis-buttons).
The HID feeder (native 6-axis report) intentionally ignores routing.
⏳ Remaining: on-cabinet throttle/rudder feel check (first Descent flight).
-**Remaining:** full on-cabinet verification of the auto-switch +
acquire/release lifecycle against real RIO hardware.
@@ -254,8 +269,18 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
generates + applies the wallpaper on profile activation when
`AppConfig.OverlayTemplatePath` is set (best-effort, off by default, never breaks
activation). The live `SystemParametersInfo` apply changes a user setting, so it
is gated behind config and not exercised by tests. ⏳ Optional: restore the prior
wallpaper when going dormant.
is gated behind config and not exercised by tests. **Restore-on-dormant — done ✅:**
`RioCoordinator` captures the user's own wallpaper (`WallpaperApplier.GetCurrent`)
the first time it overrides it, and puts it back (`WallpaperApplier.Restore`) on
every `GoDormant` and on `Dispose` — so idle/native-game/exit return the desktop
to what the user had. Capture is once-per-override (switching between cockpit
profiles never records a cockpit wallpaper as the "previous"); only engages when
`OverlayTemplatePath` is set. Verified 2026-07-19 on Win11 by a capture→apply→
restore round-trip against the compiled `WallpaperApplier` — including the
empty-wallpaper case (`GetCurrent``''`, apply, `Restore("")` clears back to the
original solid-color desktop). Known gap: a hard crash between apply and restore
leaves the cockpit wallpaper (SPIF_UPDATEINIFILE persists it) — a future
crash-recovery could persist the saved path to config.
- **Wallpaper maker — done ✅.** `RioJoy.Tray/Editor/WallpaperMakerForm` (tray →
"Wallpaper maker") is the interactive replacement for the Sheet → GIMP pipeline:
it renders the profile's wallpaper live on the template base image, outlines all
@@ -322,26 +347,30 @@ XP consumes pre-rendered wallpapers.
(`ViGEmJoystickSink`, `HidFeederJoystickSink`, `Hid/`).
*Risk fallback:* if Bcl.Async misbehaves on real XP, the receive loop
reverts to a dedicated thread (the legacy `CommWatchProc` shape) for net40.
- **8B — RioGamepadXP.sys, the XP flavor of our own driver.** Third-party
virtual-joystick drivers are ruled out (decided: **no vJoy** — the project
is unmaintained; PPJoy likewise). Instead, rebuild the thin driver side of
our existing split for XP: a **WDM HID minidriver** in the shape of the
DDK `vhidmini` sample (`HidRegisterMinidriver`, x86), exposing the **same
`Public.h` contract** as the modern driver — identical
`IOCTL_RIO_SUBMIT_REPORT`, identical 25-byte report, same descriptor
(6×16-bit axes, hat, 96 buttons) — so the existing `HidFeederJoystickSink`
drives it unchanged (only the device path differs). Precedent: the
original FASA `tasgame.sys` was exactly an XP HID minidriver
(docs/Win32RIO/, analyzed); ours stays thin with serial in user mode.
Toolchain: **WDK 7.1.0** (last XP-capable kit) under `driver/RioGamepadXP/`;
XP x86 enforces no kernel signing, so install is just the INF — none of
the Phase 1 test-signing/Secure Boot friction exists there.
Acquisition order (decided): **the Xbox 360 pad (ViGEm) stays preferred
on 10/11** — net48: ViGEm → RioGamepad → Null (unchanged);
net40: RioGamepadXP → Null.
*Staging:* the XP app is useful before the driver lands — milestone 1
ships keyboard/mouse + lamps + plasma (joystick = Null sink), the driver
follows as milestone 2.
- **8B — RioGamepadXP.sys — built ✅ (static; XP bring-up pending in 8E).**
Source in [`driver/RioGamepadXP/`](../driver/RioGamepadXP/): a WDM HID
minidriver (`HidRegisterMinidriver`, **polled mode** so there's no pending-
IRP/cancel machinery) presenting the 6-axis/hat/96-button joystick, plus a
named sideband control device (`\\.\RioGamepadXP`) that takes
`IOCTL_RIO_SUBMIT_REPORT` — the **identical `Public.h` contract** as the
modern driver (same 25-byte report, VID/PID). A dispatch wrapper routes the
control device's CREATE/CLOSE/DEVICE_CONTROL to us and forwards the HID FDO's
to hidclass. Builds with WDK 7.1.0 (`build.cmd``setenv … fre x86 WXP
no_oacr`) to `RioGamepadXP.sys` (x86, subsystem 5.01). XP enforces no kernel
signing, so install is unsigned; the device is root-enumerated so it needs
**devcon** (`devcon install RioGamepadXP.inf root\RioGamepadXP`, also built
from the WDK) rather than InstallHinfSection. The net40 feeder opens the
driver by name (`#if NET40` branch in `HidFeederJoystickSink`); XP can't
register a device interface on a bare control device (no PDO), the one
contract nuance. Bundled into the package's `vendor\xp\`. Precedent: the
original FASA `tasgame.sys` was itself an XP HID minidriver (docs/Win32RIO/,
analyzed); ours stays thin with serial in user mode. No third-party virtual
joystick (vJoy/PPJoy unmaintained). Acquisition order: net48 ViGEm →
RioGamepad → Null (unchanged); net40 RioGamepadXP → Null. ⏳ **8E:** the
driver compiles clean but is **unverified at runtime** — needs an XP target
to confirm joy.cpl enumeration + report flow. *Staging:* the app is useful
before the driver — milestone 1 ships keyboard/mouse + lamps + plasma
(joystick = Null sink) if the driver isn't present.
- **8C — Tray on net40/x86 — done ✅:** multi-target `RioJoy.Tray` (net40 drops the
ViGEm + RioJoy.Overlay references); gate `WallpaperMakerForm` + overlay
generation; `WallpaperApplier` converts PNG→BMP via GDI+ before
+8
View File
@@ -91,6 +91,14 @@ from [`g_baRIOLengthsA`](../legacy/riovjoy2.cpp#L171).
| 0x8B | KeyReleased | RIO→PC | 2 | `pad`, `index` |
| 0x8C | TestModeChange | RIO→PC | 1 | `mode` (0 = exit) |
> **TestModeChange fires on every CheckRequest too** (firmware v4.2,
> handler `$C5A6`): the board's check self-test brackets itself with
> `0x8C` mode≠0 / mode=0, flashes the lamps, and leaves a stale
> `04000000` frame on the 8-digit diagnostic display (cosmetic; see
> [hardware/display-board-1408.md](hardware/display-board-1408.md)).
> Hosts should treat `0x8C` around a check as routine, not as the
> operator entering keypad test mode.
### Reset targets (ResetRequest payload)
`0` = general/all, `1` = throttle, `2` = left pedal, `3` = right pedal,
+56
View File
@@ -0,0 +1,56 @@
/*++
RioGamepadXP — shared definitions for the Windows XP virtual HID gamepad driver
and its user-mode client (the RIOJoy net40 tray app's HID feeder).
This is the XP-flavor sibling of driver/RioGamepad/Public.h. The contract is
IDENTICAL — same interface GUID, same IOCTL_RIO_SUBMIT_REPORT, same 25-byte
report layout, same VID/PID — so RioJoy.Core's HidFeederJoystickSink drives
either driver with only the device-open path differing.
The one XP difference: XP-era hidclass minidrivers cannot register a device
interface on a bare control device (IoRegisterDeviceInterface needs a PDO), so
the sideband control device is reachable by a fixed symbolic-link name instead
of by interface GUID. The net40 feeder opens RIO_XP_USERMODE_PATH directly; the
net48 feeder keeps using SetupDi + GUID_DEVINTERFACE_RIOGAMEPAD.
The input report is fixed-size and has no report ID:
bytes 0..11 : 6 axes (X,Y,Z,Rx,Ry,Rz), each unsigned 16-bit little-endian
byte 12 : low nibble = hat (0..3; 0x0F = centered/null), high nibble pad
bytes 13..24 : 96 button bits (button N at byte 13 + (N-1)/8, bit (N-1)%8)
--*/
#pragma once
//
// Device interface GUID (same as the modern driver; kept for parity, and used
// if a future XP build ever exposes an interface). {b6a3f1c2-...}
//
DEFINE_GUID(GUID_DEVINTERFACE_RIOGAMEPAD,
0xb6a3f1c2, 0x7e84, 0x4d2a, 0x9c, 0x1f, 0x2a, 0x5e, 0x8d, 0x3b, 0x60, 0x71);
//
// Custom IOCTL: submit one input report (input buffer = RIO_REPORT_SIZE bytes).
// Identical code to the modern driver.
//
#define IOCTL_RIO_SUBMIT_REPORT \
CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_WRITE_ACCESS)
//
// HID input report size in bytes: 6*2 (axes) + 1 (hat+pad) + 12 (96 buttons).
//
#define RIO_REPORT_SIZE 25
//
// Virtual device identity (matches the modern driver).
//
#define RIO_VENDOR_ID 0x1209 // pid.codes test/community VID
#define RIO_PRODUCT_ID 0x5249 // 'R','I'
#define RIO_VERSION 0x0100
//
// Sideband control-device names. The kernel object and its DOS-device symlink;
// the user-mode client opens RIO_XP_USERMODE_PATH.
//
#define RIO_XP_DEVICE_NAME L"\\Device\\RioGamepadXP"
#define RIO_XP_SYMLINK_NAME L"\\DosDevices\\RioGamepadXP"
#define RIO_XP_USERMODE_PATH "\\\\.\\RioGamepadXP"
+56
View File
@@ -0,0 +1,56 @@
/*++
RioGamepad — HID report descriptor: a joystick with 6 16-bit axes (X,Y,Z,Rx,
Ry,Rz), one 4-direction hat with null state, and 96 buttons. This mirrors the
fidelity the legacy app drove through vJoy. The resulting input report is
RIO_REPORT_SIZE (25) bytes; see Public.h for the byte layout.
--*/
#pragma once
static const UCHAR g_RioReportDescriptor[] =
{
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x04, // Usage (Joystick)
0xA1, 0x01, // Collection (Application)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x09, 0x32, // Usage (Z)
0x09, 0x33, // Usage (Rx)
0x09, 0x34, // Usage (Ry)
0x09, 0x35, // Usage (Rz)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x7F, // Logical Maximum (32767)
0x75, 0x10, // Report Size (16)
0x95, 0x06, // Report Count (6)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0, // End Collection (Physical)
0x09, 0x39, // Usage (Hat switch)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x03, // Logical Maximum (3)
0x35, 0x00, // Physical Minimum (0)
0x46, 0x0E, 0x01, // Physical Maximum (270)
0x65, 0x14, // Unit (Degrees)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x81, 0x42, // Input (Data,Var,Abs,Null)
0x65, 0x00, // Unit (None)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x81, 0x03, // Input (Const,Var,Abs) ; 4-bit padding
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (Button 1)
0x29, 0x60, // Usage Maximum (Button 96)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x60, // Report Count (96)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0 // End Collection (Application)
};
+53
View File
@@ -0,0 +1,53 @@
;
; RioGamepadXP.inf — RIOJoy virtual HID gamepad for Windows XP (WDM HID
; minidriver, root-enumerated). No catalog / signing: 32-bit XP does not
; enforce kernel-mode driver signing, so this installs unsigned.
;
; Install (see deploy\install-core.bat):
; devcon.exe install RioGamepadXP.inf root\RioGamepadXP
;
[Version]
Signature = "$WINDOWS NT$"
Class = HIDClass
ClassGuid = {745a17a0-74d3-11d0-b6fe-00a0c90f57da}
Provider = %ManufacturerName%
DriverVer = 07/11/2026,1.0.0.0
[DestinationDirs]
DefaultDestDir = 12 ; %SystemRoot%\System32\drivers
RioGamepadXP_CopyFiles = 12
[SourceDisksNames]
1 = %DiskName%
[SourceDisksFiles]
RioGamepadXP.sys = 1
[Manufacturer]
%ManufacturerName% = Standard, NTx86
[Standard.NTx86]
%DeviceName% = RioGamepadXP_Device, root\RioGamepadXP
[RioGamepadXP_Device.NT]
CopyFiles = RioGamepadXP_CopyFiles
[RioGamepadXP_CopyFiles]
RioGamepadXP.sys
[RioGamepadXP_Device.NT.Services]
AddService = RioGamepadXP, 0x00000002, RioGamepadXP_Service
[RioGamepadXP_Service]
DisplayName = %ServiceName%
ServiceType = 1 ; SERVICE_KERNEL_DRIVER
StartType = 3 ; SERVICE_DEMAND_START
ErrorControl = 1 ; SERVICE_ERROR_NORMAL
ServiceBinary = %12%\RioGamepadXP.sys
[Strings]
ManufacturerName = "VWE"
DiskName = "RioGamepadXP Installation Disk"
DeviceName = "RIOJoy Virtual Gamepad (XP)"
ServiceName = "RIOJoy Virtual Gamepad Service (XP)"
+7
View File
@@ -0,0 +1,7 @@
@echo off
rem Build RioGamepadXP.sys for Windows XP (x86, free). Requires WDK 7.1.0.
rem Override WDK if installed elsewhere: set WDK=<path> before calling.
if "%WDK%"=="" set WDK=C:\WinDDK\WinDDK\7600.16385.win7_wdk.100208-1538
call %WDK%\bin\setenv.bat %WDK% fre x86 WXP no_oacr
cd /d %~dp0
build -cZ
+1
View File
@@ -0,0 +1 @@
!INCLUDE $(NTMAKEENV)\makefile.def
+381
View File
@@ -0,0 +1,381 @@
/*++
RioGamepadXP — a WDM HID minidriver for Windows XP that presents a virtual
joystick (6 axes, hat, 96 buttons) and accepts input reports from the RIOJoy
tray app over a sideband control device. The XP-era replacement for the modern
KMDF+VHF RioGamepad driver, exposing the same 25-byte report contract
(see Public.h) so the user-mode feeder is unchanged apart from the open path.
Architecture
------------
There is exactly one virtual device, so state is global (guarded by a spinlock):
* HID side: we register as a HID minidriver (HidRegisterMinidriver). hidclass
creates and owns the HID FDO and drives PnP/Power; it calls our
IRP_MJ_INTERNAL_DEVICE_CONTROL handler to fetch the HID/report descriptors,
device attributes, and input reports. We run in POLLED mode
(DevicesArePolled = TRUE): hidclass paces IOCTL_HID_READ_REPORT and we
complete each synchronously with the current cached report. This avoids
pending-IRP + cancel-routine machinery entirely (the usual crash surface in
a virtual HID driver).
* Sideband: a raw control device (\Device\RioGamepadXP + a \DosDevices symlink)
that the app opens by name. On IOCTL_RIO_SUBMIT_REPORT we copy the 25 report
bytes into the cache. hidclass, on its next poll, hands them to the OS.
Dispatch ownership: HidRegisterMinidriver replaces our driver object's
CREATE/CLOSE/DEVICE_CONTROL (and PnP/Power/etc.) entries with hidclass thunks
that assume a HID FDO. Our control device shares the same driver object, so we
save hidclass's pointers and reinstall thin wrappers that route the control
device's IRPs to us and forward everything else to hidclass.
--*/
#include <ntddk.h>
#include <hidport.h>
#include <initguid.h> // realize DEFINE_GUID storage from Public.h
#include "Public.h"
#include "ReportDescriptor.h"
//
// Global single-instance state.
//
static PDEVICE_OBJECT g_ControlDevice = NULL;
static KSPIN_LOCK g_ReportLock;
static UCHAR g_Report[RIO_REPORT_SIZE];
// hidclass-installed dispatch pointers we wrap.
static PDRIVER_DISPATCH g_HidCreate = NULL;
static PDRIVER_DISPATCH g_HidClose = NULL;
static PDRIVER_DISPATCH g_HidDeviceControl = NULL;
static UNICODE_STRING g_SymlinkName;
//
// The HID descriptor that advertises our single report descriptor. bcdHID 1.11.
//
#include <pshpack1.h>
typedef struct _RIO_HID_DESCRIPTOR {
UCHAR bLength;
UCHAR bDescriptorType;
USHORT bcdHID;
UCHAR bCountry;
UCHAR bNumDescriptors;
UCHAR bReportType;
USHORT wReportLength;
} RIO_HID_DESCRIPTOR;
#include <poppack.h>
static const RIO_HID_DESCRIPTOR g_HidDescriptor =
{
sizeof(RIO_HID_DESCRIPTOR),
HID_HID_DESCRIPTOR_TYPE, // 0x21
0x0111, // HID 1.11
0x00, // not localized
1, // one report descriptor
HID_REPORT_DESCRIPTOR_TYPE, // 0x22
sizeof(g_RioReportDescriptor)
};
//
// Reset the cached report to the neutral rest state: axes centered (16383 =
// 0x3FFF little-endian), hat null (0x0F), all buttons released.
//
static VOID
RioResetReport(VOID)
{
ULONG i;
KIRQL irql;
KeAcquireSpinLock(&g_ReportLock, &irql);
RtlZeroMemory(g_Report, sizeof(g_Report));
for (i = 0; i < 6; i++) // 6 axes, 2 bytes each, centered
{
g_Report[i * 2] = 0xFF;
g_Report[i * 2 + 1] = 0x3F;
}
g_Report[12] = 0x0F; // hat centered/null
KeReleaseSpinLock(&g_ReportLock, irql);
}
//
// Complete an IRP with a status and information count.
//
static NTSTATUS
RioComplete(PIRP Irp, NTSTATUS Status, ULONG_PTR Information)
{
Irp->IoStatus.Status = Status;
Irp->IoStatus.Information = Information;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return Status;
}
//
// IRP_MJ_INTERNAL_DEVICE_CONTROL on the HID FDO: hidclass asks us for the
// descriptors / attributes / input reports. These IOCTLs are METHOD_NEITHER;
// hidclass supplies the output buffer at Irp->UserBuffer with the length in
// Parameters.DeviceIoControl.OutputBufferLength.
//
static NTSTATUS
RioInternalDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
NTSTATUS status = STATUS_SUCCESS;
ULONG_PTR info = 0;
ULONG outLen = stack->Parameters.DeviceIoControl.OutputBufferLength;
PVOID out = Irp->UserBuffer;
UNREFERENCED_PARAMETER(DeviceObject);
switch (stack->Parameters.DeviceIoControl.IoControlCode)
{
case IOCTL_HID_GET_DEVICE_DESCRIPTOR:
if (outLen < sizeof(g_HidDescriptor)) { status = STATUS_BUFFER_TOO_SMALL; break; }
RtlCopyMemory(out, &g_HidDescriptor, sizeof(g_HidDescriptor));
info = sizeof(g_HidDescriptor);
break;
case IOCTL_HID_GET_REPORT_DESCRIPTOR:
if (outLen < sizeof(g_RioReportDescriptor)) { status = STATUS_BUFFER_TOO_SMALL; break; }
RtlCopyMemory(out, g_RioReportDescriptor, sizeof(g_RioReportDescriptor));
info = sizeof(g_RioReportDescriptor);
break;
case IOCTL_HID_GET_DEVICE_ATTRIBUTES:
{
PHID_DEVICE_ATTRIBUTES attr = (PHID_DEVICE_ATTRIBUTES)out;
if (outLen < sizeof(HID_DEVICE_ATTRIBUTES)) { status = STATUS_BUFFER_TOO_SMALL; break; }
RtlZeroMemory(attr, sizeof(HID_DEVICE_ATTRIBUTES));
attr->Size = sizeof(HID_DEVICE_ATTRIBUTES);
attr->VendorID = RIO_VENDOR_ID;
attr->ProductID = RIO_PRODUCT_ID;
attr->VersionNumber = RIO_VERSION;
info = sizeof(HID_DEVICE_ATTRIBUTES);
break;
}
case IOCTL_HID_READ_REPORT:
{
KIRQL irql;
if (outLen < RIO_REPORT_SIZE) { status = STATUS_BUFFER_TOO_SMALL; break; }
KeAcquireSpinLock(&g_ReportLock, &irql);
RtlCopyMemory(out, g_Report, RIO_REPORT_SIZE);
KeReleaseSpinLock(&g_ReportLock, irql);
info = RIO_REPORT_SIZE;
break;
}
case IOCTL_HID_WRITE_REPORT:
case IOCTL_HID_SET_FEATURE:
case IOCTL_HID_GET_FEATURE:
// No output reports / feature reports on this device.
status = STATUS_NOT_SUPPORTED;
break;
case IOCTL_HID_GET_STRING:
// DirectInput takes the friendly name from the registry OEMName value
// (set at install), not from a HID string, so an empty success is fine.
status = STATUS_SUCCESS;
info = 0;
break;
default:
status = STATUS_NOT_SUPPORTED;
break;
}
return RioComplete(Irp, status, info);
}
//
// hidclass calls this (our saved AddDevice) after it has created the HID FDO.
// Nothing to attach — hidclass owns the stack — so just succeed.
//
static NTSTATUS
RioAddDevice(PDRIVER_OBJECT DriverObject, PDEVICE_OBJECT FunctionalDeviceObject)
{
UNREFERENCED_PARAMETER(DriverObject);
UNREFERENCED_PARAMETER(FunctionalDeviceObject);
return STATUS_SUCCESS;
}
//
// Sideband handlers (control device only). CREATE/CLOSE just succeed.
//
static NTSTATUS
RioControlCreateClose(PIRP Irp)
{
return RioComplete(Irp, STATUS_SUCCESS, 0);
}
//
// IOCTL_RIO_SUBMIT_REPORT (METHOD_BUFFERED): copy the 25 report bytes into the
// cache; hidclass hands them out on its next poll.
//
static NTSTATUS
RioControlDeviceControl(PIRP Irp)
{
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
NTSTATUS status;
if (stack->Parameters.DeviceIoControl.IoControlCode == IOCTL_RIO_SUBMIT_REPORT)
{
if (stack->Parameters.DeviceIoControl.InputBufferLength != RIO_REPORT_SIZE)
{
status = STATUS_INVALID_BUFFER_SIZE;
}
else
{
KIRQL irql;
KeAcquireSpinLock(&g_ReportLock, &irql);
RtlCopyMemory(g_Report, Irp->AssociatedIrp.SystemBuffer, RIO_REPORT_SIZE);
KeReleaseSpinLock(&g_ReportLock, irql);
status = STATUS_SUCCESS;
}
}
else
{
status = STATUS_INVALID_DEVICE_REQUEST;
}
return RioComplete(Irp, status, 0);
}
//
// Dispatch wrappers: route the control device's IRPs to us, forward the HID
// FDO's IRPs to the saved hidclass handlers.
//
static NTSTATUS
RioDispatchCreate(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
if (DeviceObject == g_ControlDevice)
return RioControlCreateClose(Irp);
return g_HidCreate(DeviceObject, Irp);
}
static NTSTATUS
RioDispatchClose(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
if (DeviceObject == g_ControlDevice)
return RioControlCreateClose(Irp);
return g_HidClose(DeviceObject, Irp);
}
static NTSTATUS
RioDispatchDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
if (DeviceObject == g_ControlDevice)
return RioControlDeviceControl(Irp);
return g_HidDeviceControl(DeviceObject, Irp);
}
//
// Unload: drop the symlink and control device. hidclass tears down the HID FDO.
//
static VOID
RioUnload(PDRIVER_OBJECT DriverObject)
{
UNREFERENCED_PARAMETER(DriverObject);
if (g_SymlinkName.Buffer != NULL)
IoDeleteSymbolicLink(&g_SymlinkName);
if (g_ControlDevice != NULL)
IoDeleteDevice(g_ControlDevice);
}
//
// Create the sideband control device and its DOS-device symbolic link.
//
static NTSTATUS
RioCreateControlDevice(PDRIVER_OBJECT DriverObject)
{
NTSTATUS status;
UNICODE_STRING deviceName;
RtlInitUnicodeString(&deviceName, RIO_XP_DEVICE_NAME);
RtlInitUnicodeString(&g_SymlinkName, RIO_XP_SYMLINK_NAME);
status = IoCreateDevice(
DriverObject,
0, // no device extension; state is global
&deviceName,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE, // not exclusive
&g_ControlDevice);
if (!NT_SUCCESS(status))
return status;
g_ControlDevice->Flags |= DO_BUFFERED_IO; // IOCTL_RIO_SUBMIT_REPORT is METHOD_BUFFERED
status = IoCreateSymbolicLink(&g_SymlinkName, &deviceName);
if (!NT_SUCCESS(status))
{
IoDeleteDevice(g_ControlDevice);
g_ControlDevice = NULL;
g_SymlinkName.Buffer = NULL;
return status;
}
g_ControlDevice->Flags &= ~DO_DEVICE_INITIALIZING;
return STATUS_SUCCESS;
}
NTSTATUS
DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
NTSTATUS status;
HID_MINIDRIVER_REGISTRATION reg;
KeInitializeSpinLock(&g_ReportLock);
RioResetReport();
//
// Our own dispatch entries. hidclass replaces the HID-related ones during
// registration; we re-wrap CREATE/CLOSE/DEVICE_CONTROL afterwards.
//
DriverObject->MajorFunction[IRP_MJ_INTERNAL_DEVICE_CONTROL] = RioInternalDeviceControl;
DriverObject->MajorFunction[IRP_MJ_CREATE] = RioDispatchCreate;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = RioDispatchClose;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = RioDispatchDeviceControl;
DriverObject->DriverExtension->AddDevice = RioAddDevice;
DriverObject->DriverUnload = RioUnload;
RtlZeroMemory(&reg, sizeof(reg));
reg.Revision = HID_REVISION;
reg.DriverObject = DriverObject;
reg.RegistryPath = RegistryPath;
reg.DeviceExtensionSize = 0; // per-device state is global (single instance)
reg.DevicesArePolled = TRUE; // polled reads → no pending-IRP/cancel machinery
status = HidRegisterMinidriver(&reg);
if (!NT_SUCCESS(status))
return status;
//
// hidclass has now overwritten CREATE/CLOSE/DEVICE_CONTROL (and PnP/Power/
// etc.) with its own thunks. Save the ones we need to forward, then reinstall
// our wrappers so the control device's IRPs come back to us.
//
g_HidCreate = DriverObject->MajorFunction[IRP_MJ_CREATE];
g_HidClose = DriverObject->MajorFunction[IRP_MJ_CLOSE];
g_HidDeviceControl = DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL];
DriverObject->MajorFunction[IRP_MJ_CREATE] = RioDispatchCreate;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = RioDispatchClose;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = RioDispatchDeviceControl;
//
// hidclass also replaces DriverUnload; chain our teardown ahead of nothing
// (hidclass's unload runs via its own bookkeeping). Reassert ours.
//
DriverObject->DriverUnload = RioUnload;
status = RioCreateControlDevice(DriverObject);
if (!NT_SUCCESS(status))
{
// The HID device can still work without the sideband, but the app can't
// feed it, so fail registration cleanly.
return status;
}
return STATUS_SUCCESS;
}
+7
View File
@@ -0,0 +1,7 @@
TARGETNAME=RioGamepadXP
TARGETTYPE=DRIVER
# Link against hidclass's minidriver import library (HidRegisterMinidriver).
TARGETLIBS=$(DDK_LIB_PATH)\hidclass.lib
SOURCES=rioxp.c
+100
View File
@@ -0,0 +1,100 @@
{
"Name": "Descent",
"MatchExecutables": [
"d1x-rebirth"
],
"PlasmaGreeting": "DESCENT",
"WallpaperPath": null,
"OverlayLabels": {},
"Calibration": {
"InvertX": false,
"InvertY": false,
"InvertZ": false,
"InvertXR": false,
"InvertYR": false,
"InvertZR": false,
"EnableZR": true
},
"AxisRouting": {
"X": {
"Target": "LeftThumbX",
"Mode": "Centered"
},
"Y": {
"Target": "LeftThumbY",
"Mode": "Centered"
},
"Z": {
"Target": "RightThumbY",
"Mode": "UnipolarPositive"
},
"Rx": {
"Target": "None",
"Mode": "Centered"
},
"Ry": {
"Target": "None",
"Mode": "Centered"
},
"Rz": {
"Target": "RightThumbX",
"Mode": "Centered"
}
},
"Buttons": {
"16": 32822,
"17": 32823,
"18": 32824,
"19": 32825,
"20": 32816,
"21": 32777,
"24": 32781,
"28": 34991,
"29": 34990,
"32": 32817,
"33": 32818,
"34": 32819,
"35": 32820,
"36": 32821,
"37": 32850,
"56": 67,
"57": 68,
"58": 88,
"59": 65,
"60": 4101,
"62": 4102,
"63": 83,
"64": 4097,
"65": 8194,
"66": 8192,
"67": 8193,
"68": 8195,
"69": 188,
"70": 190,
"71": 4099,
"80": 48,
"81": 49,
"82": 50,
"83": 51,
"84": 52,
"85": 53,
"86": 54,
"87": 55,
"88": 56,
"89": 57,
"90": 9,
"91": 66,
"92": 82,
"95": 70,
"96": 28672,
"97": 28673,
"98": 28674,
"99": 28675,
"100": 28676,
"101": 28677,
"102": 28678,
"103": 28679,
"104": 28680,
"105": 28681
}
}
-42
View File
@@ -1,42 +0,0 @@
# RIO board firmware
- **`RIOv4_2.bin`** — RIO cockpit I/O board firmware **v4.2**, dumped
2026-07-04 from one of our own boards' EPROM: an **AMD AM27C512-150**
(64K x 8 UV EPROM, 150ns — the image fills it exactly).
sha256 `60a88718835c654b6135dbec7721c40ef99dca07df2ad4b57eedeb24037a5f73`.
For the eventual patched burn: a pin-compatible Winbond W27C512
(electrically erasable, TL866-friendly) drops straight into the socket;
the original AMD chip gets labeled and preserved unmodified.
## First-look analysis (from the image alone, confirmed on hardware)
- MCU: **Toshiba TMP68HC11** (read off the chip; the code fingerprint
agrees — 6800-family opcodes with writes into the 68HC11 internal
register block at `$10xx`).
- Memory map: image is FF up to **0xC000**; 16KB of code occupies
`$C000-$FFFF` (EPROM mapped at the top of the HC11 address space).
- Startup at `$C000`: `SEI; LDS #$8000; STAA $1024 (TMSK2);
STAA $1022 (TMSK1); ...` then a long `JSR` init chain — textbook HC11
bring-up.
- Vector table (`$FFD6-$FFFF`, big-endian):
- `$FFFE` RESET → `$C000`
- **`$FFD6` SCI (serial) → `$D630`** — the entry point of the board's
receive/protocol interrupt handler. The suspected board-side
DISABLE_AND_DIE-style wedge (see RIO-NOTES.md: the board mirrors the
game's PCSPAK state machine, and mash-stress leaves the reply path
dead while the button/event path stays alive) is reachable from here.
- `$FFE4` → `$C1B2`, `$FFE6` → `$C18E` (timer output-compares); most
other vectors → `$DB07..$DB3D` stubs.
## Why this exists
The remaining RIO reliability issue is board-side: under button-mash
stress the board's reply/analog state machine wedges (RX dead, TX alive;
a button press or power cycle revives it), reproduced identically on two
different USB serial adapters. The game-side half of the protocol was
binary-patched for tolerance (BTL4OPT patches v2-v4); the board firmware
is the other half. Plan (RIO-NOTES.md "Board firmware patch plan"):
disassemble as 68HC11 from `$C000` with the vector entries as roots, find
the SCI state machine (protocol constants FC=ACK FD=NAK FE=RESTART
FF=IDLE, idle-reload-4 patterns), patch the early-ACK/error wedge path or
widen its window, burn a new EPROM, keep this original safe.
-175
View File
@@ -1,175 +0,0 @@
# RIO v4.2 firmware — protocol wedge analysis
Reverse-engineering of `RIOv4_2.bin` (Toshiba TMP68HC11, AM27C512) to find
the board-side cause of the "reply path wedges under stress, button-press
revives it" fault. Disassembly by `disasm_6811.py`
`RIOv4_2.disasm.asm`. **Research only** — the fix below is proposed, not
yet burned or tested (no spare EPROM on hand). Validate on hardware with
the `RIO_TAP` mash test before trusting.
Addresses are CPU = file offset (EPROM at `$C000-$FFFF`; reset `$FFFE`
`$C000`). RAM lives at `$20xx-$31xx`.
## How the serial protocol is structured
- **SCI interrupt** (`$FFD6``$D630`): `JSR $D634; RTI`. `$D634` runs BOTH
workers every interrupt: `JSR $D6EA` (RX) then `JSR $D887` (TX). So the
transmitter is poked after every received byte, not only on TX-empty
interrupts.
- **RX ISR** `$D6EA`: reads SCSR/SCDR, stores the byte at `$3172`, then
`LDX $292F; JMP $00,X` — dispatches through a **state-handler pointer**
at `$292F`. Handlers classify bytes (`$D717`: `FE`=RESTART, `FF`=IDLE,
`FC`/`FD`=game ACK/NAK, `$82`=analog request, table lookup at `$3144`),
accumulate the body + checksum (`AND $7F`), and on a complete packet run
the ACK/NAK decision at `$D81F`.
- **TX ISR** `$D887`: if TDRE, send a pending ACK (`$316F``$FC`) or NAK
(`$3170``$FD`), else dispatch through the TX state pointer `$2D3B`
(`$D8C2` ring-drain → `$D90E` reply/retry machine). When idle it disarms
the TX interrupt (SCCR2 `#$2C`, TIE off) at `$D918`; the enqueue routine
`$D63B` re-arms it (SCCR2 `#$AC`, TIE on) at `$D664`.
## The wedge: an orphaned "reply-in-progress" latch (`$2521`)
`$2521` = "an analog reply is in progress." The analog-request handler
gates on it:
```
D74F CMPB #$82 ; analog request from the game
D753 LDAA #$01
D755 STAA $2520 ; arm reply generation
D758 TST $2521 ; already replying?
D75B BNE $D77A ; YES -> D77A: CLR $2520, drop this request
```
So while `$2521` is set, **every analog request is silently dropped**.
The latch is set when a reply is generated:
```
D847 JSR $C5EC ; build the analog reply
D84C STAA $2521 ; reply-in-progress = 1
```
and is cleared in only three places: power-on init (`$C0A3`), a host
reset/init command handler (`$C686`), and the reply **success** teardown
(`$DA00`). The success teardown is reached at `$D9C1` when the game ACKs
the reply, and clears the latch — but only conditionally:
```
DA21 LDAA $2522 ; did the $87 reply byte actually start sending?
DA24 CMPA #$01
DA26 BNE $DA2E ; if not, skip the clears <-- fragile
DA28 CLR $2521
DA2B CLR $2522
```
`$2521` is set the instant the reply is *generated* (`$D84C`), but `$2522`
is set only once the `$87` command byte *starts transmitting* (`$D8FD`).
**The leak** is the retry-exhausted give-up path, which is *separate* from
the success teardown. When the game fails to ACK a reply, `$D90E`/`$D9BE`
retries up to 4 times, then gives up:
```
D9D5 LDAB #$FE ; give up: send RESTART
D9D7 STAB $102F ; SCDR
D9DA INC $317A
D9DD JMP $DA2F ; teardown -- but DA2F never touches $2521
```
`$DA2F` resets the TX pointers and calls `$D5F2` (a debug-counter
formatter that does *not* clear the latch), then returns. **`$2521` is
left set forever.** From then on every `$82` analog request is dropped at
`$D758` → the board is mute to analog while its RX/event path stays fully
alive.
### Why a button press / new game revives it
The only mid-run code that clears `$2521` is the host command handler at
`$C669-$C689` (it clears `$2520`/`$2521`/`$2522` plus a raft of state).
That runs for a host-level reset/init command — exactly what the game
sends at game-start / on the player's opening button actions. Mid-mission
button-mashing sends no such command, so the leaked latch stays stuck
until the next game-start reset. This matches the field observation
precisely: the board goes mute under stress and only a new-game/button
resync brings analog back.
### Why mash stress triggers it
Button-event traffic floods the link while the board is mid-analog-reply;
the reply's ACKs collide/drop, the 4-retry budget exhausts, and the
give-up path (`$DA2F`) fires — leaking the latch. Light traffic rarely
exhausts the retries, so it's a stress-only fault. Two different USB
adapters showed the identical stall because the defect is in the board,
not the transport — consistent with this being firmware, not timing.
## Proposed fix (minimal, in-place; UNTESTED)
Clear `$2521` on *every* reply teardown, not just the `$2522`-gated
success path. Two edits, no code-size change, 8 KB of free ROM exists at
`$DFF0-$FFBF` for the stub:
1. **Give-up path** — redirect its teardown through a stub that clears the
latch first. At `$D9DD` change `JMP $DA2F` (`7E DA 2F`) →
`JMP $DFF0` (`7E DF F0`), and place at `$DFF0`:
```
DFF0 7F 25 21 CLR $2521
DFF3 7F 25 22 CLR $2522
DFF6 7E DA 2F JMP $DA2F
```
2. **Success path** — make the clear unconditional (belt-and-suspenders,
covers an abort before `$87` is sent). Replace `$DA21-$DA2D` (13 bytes)
in place:
```
DA21 7F 25 21 CLR $2521
DA24 7F 25 22 CLR $2522
DA27 01 01 01 01 01 01 01 (NOP x7)
DA2E 39 RTS (unchanged)
```
Rationale: `$2521` means "a reply is in progress"; any path that tears
down reply state must release it. There is no case where you reset the
reply machine yet want the latch to stay set, so unconditional clearing is
safe. This is the board-side analogue of the game-side "make collisions
harmless" patches (BTL4OPT v2-v4) — instead of widening a timing window it
removes the latch leak entirely.
### Patched binary — built & statically verified (2026-07-04)
`make_patch.py` applies both edits to `RIOv4_2.bin` (asserting the exact
original bytes at each site first) → **`RIOv4_2_patched.bin`**
(sha256 `3fc8170caf60e2580641724ff995176c93c4f2e706f31487beded8233142493f`,
23 bytes changed). Re-disassembling it (`RIOv4_2_patched.disasm.asm`) and
diffing against the original confirms the change is confined to exactly
three regions with no downstream desync:
- `$D9DD` `JMP $DA2F` → `JMP $DFF0`
- `$DFF0` new stub: `CLR $2521 ; CLR $2522 ; JMP $DA2F`
- `$DA21` `CLR $2521 ; CLR $2522 ; NOP×7` (RTS at `$DA2E` intact)
Flash `RIOv4_2_patched.bin` directly to the W27C512 (DIP-28). This is
static verification only; dynamic proof still needs the burned chip.
### Validation plan (when a chip is available)
Burn the two edits to a W27C512, socket it (preserve the original AMD
chip), then run the `RIO_TAP` two-handed 8-button mash test. Expect: no
permanent analog mute; any collision self-recovers without a game-start
reset. Compare dropout counts to the 2026-07-03/04 baseline taps.
## Firmware memory map (as decoded so far)
| addr | meaning |
|---|---|
| `$292F` | RX state-handler pointer (`JMP $00,X` dispatch) |
| `$2D3B` | TX state-handler pointer |
| `$2D34/$36/$38` | TX ring read/write/aux pointers (ring `$2932-$2D31`) |
| `$2520` | reply gate (analog request pending) |
| `$2521` | **reply-in-progress latch — the wedge** |
| `$2522` | `$87` analog-reply-byte-sent flag |
| `$316C/$6D` | game ACK / NAK received |
| `$316E` | unknown-command seen |
| `$316F/$70` | ACK / NAK pending to send (→ TX ISR) |
| `$3172` | last received byte |
| `$3173/$74/$75` | ACK/NAK/wait retry counters (limit 4) |
| `$317A/$7B` | RESTART / IDLE keep-alive counters |
| `$3184/$85` | give-up / error diagnostic counters |
| `$3186` | RX overrun flag (set at `$D701`, **never read** — not the cause) |
| `$102D/$2E/$2F` | SCCR2 / SCSR / SCDR (HC11 SCI) |
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-236
View File
@@ -1,236 +0,0 @@
#!/usr/bin/env python3
"""Recursive-descent 68HC11 disassembler for the RIO board firmware.
The RIO board (Toshiba TMP68HC11 + AM27C512) speaks the PCSPAK serial
protocol to the game. This tool disassembles RIOv4_2.bin to help locate the
receive/reply state machine and the wedge where the reply path dies under
stress. Address == file offset for this image (EPROM occupies $C000-$FFFF,
reset vector $FFFE -> $C000 confirms).
Usage: python disasm_6811.py [RIOv4_2.bin] > RIOv4_2.disasm.asm
"""
import sys
# ---- HC11 internal register block ($1000-$103F) annotations --------------
REG = {
0x00:"PORTA",0x02:"PIOC",0x03:"PORTC",0x04:"PORTB",0x05:"PORTCL",
0x07:"DDRC",0x08:"PORTD",0x09:"DDRD",0x0A:"PORTE",0x0B:"CFORC",
0x0C:"OC1M",0x0D:"OC1D",0x0E:"TCNT",0x10:"TIC1",0x12:"TIC2",
0x14:"TIC3",0x16:"TOC1",0x18:"TOC2",0x1A:"TOC3",0x1C:"TOC4",
0x1E:"TI4O5",0x20:"TCTL1",0x21:"TCTL2",0x22:"TMSK1",0x23:"TFLG1",
0x24:"TMSK2",0x25:"TFLG2",0x26:"PACTL",0x27:"PACNT",0x28:"SPCR",
0x29:"SPSR",0x2A:"SPDR",0x2B:"BAUD",0x2C:"SCCR1",0x2D:"SCCR2",
0x2E:"SCSR",0x2F:"SCDR",0x30:"ADCTL",0x31:"ADR1",0x32:"ADR2",
0x33:"ADR3",0x34:"ADR4",0x39:"OPTION",0x3A:"COPRST",0x3B:"PPROG",
0x3C:"HPRIO",0x3D:"INIT",0x3E:"TEST1",0x3F:"CONFIG",
}
def reg_ann(addr):
if 0x1000 <= addr <= 0x103F and (addr-0x1000) in REG:
return " ; "+REG[addr-0x1000]
return ""
# addressing modes and their extra operand byte counts
INH,IMM8,IMM16,DIR,EXT,IDX,IDY,REL = range(8)
BSET_DIR,BCLR_DIR,BRSET_DIR,BRCLR_DIR = range(8,12)
BSET_IDX,BCLR_IDX,BRSET_IDX,BRCLR_IDX = range(12,16)
MODELEN = {INH:0,IMM8:1,IMM16:2,DIR:1,EXT:2,IDX:1,IDY:1,REL:1,
BSET_DIR:2,BCLR_DIR:2,BRSET_DIR:3,BRCLR_DIR:3,
BSET_IDX:2,BCLR_IDX:2,BRSET_IDX:3,BRCLR_IDX:3}
# base page opcode table: opcode -> (mnemonic, mode)
OP = {
0x00:("TEST",INH),0x01:("NOP",INH),0x02:("IDIV",INH),0x03:("FDIV",INH),
0x04:("LSRD",INH),0x05:("LSLD",INH),0x06:("TAP",INH),0x07:("TPA",INH),
0x08:("INX",INH),0x09:("DEX",INH),0x0A:("CLV",INH),0x0B:("SEV",INH),
0x0C:("CLC",INH),0x0D:("SEC",INH),0x0E:("CLI",INH),0x0F:("SEI",INH),
0x10:("SBA",INH),0x11:("CBA",INH),0x12:("BRSET",BRSET_DIR),0x13:("BRCLR",BRCLR_DIR),
0x14:("BSET",BSET_DIR),0x15:("BCLR",BCLR_DIR),0x16:("TAB",INH),0x17:("TBA",INH),
0x19:("DAA",INH),0x1B:("ABA",INH),
0x1C:("BSET",BSET_IDX),0x1D:("BCLR",BCLR_IDX),0x1E:("BRSET",BRSET_IDX),0x1F:("BRCLR",BRCLR_IDX),
0x20:("BRA",REL),0x21:("BRN",REL),0x22:("BHI",REL),0x23:("BLS",REL),
0x24:("BCC",REL),0x25:("BCS",REL),0x26:("BNE",REL),0x27:("BEQ",REL),
0x28:("BVC",REL),0x29:("BVS",REL),0x2A:("BPL",REL),0x2B:("BMI",REL),
0x2C:("BGE",REL),0x2D:("BLT",REL),0x2E:("BGT",REL),0x2F:("BLE",REL),
0x30:("TSX",INH),0x31:("INS",INH),0x32:("PULA",INH),0x33:("PULB",INH),
0x34:("DES",INH),0x35:("TXS",INH),0x36:("PSHA",INH),0x37:("PSHB",INH),
0x38:("PULX",INH),0x39:("RTS",INH),0x3A:("ABX",INH),0x3B:("RTI",INH),
0x3C:("PSHX",INH),0x3D:("MUL",INH),0x3E:("WAI",INH),0x3F:("SWI",INH),
0x40:("NEGA",INH),0x43:("COMA",INH),0x44:("LSRA",INH),0x46:("RORA",INH),
0x47:("ASRA",INH),0x48:("LSLA",INH),0x49:("ROLA",INH),0x4A:("DECA",INH),
0x4C:("INCA",INH),0x4D:("TSTA",INH),0x4F:("CLRA",INH),
0x50:("NEGB",INH),0x53:("COMB",INH),0x54:("LSRB",INH),0x56:("RORB",INH),
0x57:("ASRB",INH),0x58:("LSLB",INH),0x59:("ROLB",INH),0x5A:("DECB",INH),
0x5C:("INCB",INH),0x5D:("TSTB",INH),0x5F:("CLRB",INH),
0x60:("NEG",IDX),0x63:("COM",IDX),0x64:("LSR",IDX),0x66:("ROR",IDX),
0x67:("ASR",IDX),0x68:("LSL",IDX),0x69:("ROL",IDX),0x6A:("DEC",IDX),
0x6C:("INC",IDX),0x6D:("TST",IDX),0x6E:("JMP",IDX),0x6F:("CLR",IDX),
0x70:("NEG",EXT),0x73:("COM",EXT),0x74:("LSR",EXT),0x76:("ROR",EXT),
0x77:("ASR",EXT),0x78:("LSL",EXT),0x79:("ROL",EXT),0x7A:("DEC",EXT),
0x7C:("INC",EXT),0x7D:("TST",EXT),0x7E:("JMP",EXT),0x7F:("CLR",EXT),
0x80:("SUBA",IMM8),0x81:("CMPA",IMM8),0x82:("SBCA",IMM8),0x83:("SUBD",IMM16),
0x84:("ANDA",IMM8),0x85:("BITA",IMM8),0x86:("LDAA",IMM8),0x88:("EORA",IMM8),
0x89:("ADCA",IMM8),0x8A:("ORAA",IMM8),0x8B:("ADDA",IMM8),0x8C:("CPX",IMM16),
0x8D:("BSR",REL),0x8E:("LDS",IMM16),0x8F:("XGDX",INH),
0x90:("SUBA",DIR),0x91:("CMPA",DIR),0x92:("SBCA",DIR),0x93:("SUBD",DIR),
0x94:("ANDA",DIR),0x95:("BITA",DIR),0x96:("LDAA",DIR),0x97:("STAA",DIR),
0x98:("EORA",DIR),0x99:("ADCA",DIR),0x9A:("ORAA",DIR),0x9B:("ADDA",DIR),
0x9C:("CPX",DIR),0x9D:("JSR",DIR),0x9E:("LDS",DIR),0x9F:("STS",DIR),
0xA0:("SUBA",IDX),0xA1:("CMPA",IDX),0xA2:("SBCA",IDX),0xA3:("SUBD",IDX),
0xA4:("ANDA",IDX),0xA5:("BITA",IDX),0xA6:("LDAA",IDX),0xA7:("STAA",IDX),
0xA8:("EORA",IDX),0xA9:("ADCA",IDX),0xAA:("ORAA",IDX),0xAB:("ADDA",IDX),
0xAC:("CPX",IDX),0xAD:("JSR",IDX),0xAE:("LDS",IDX),0xAF:("STS",IDX),
0xB0:("SUBA",EXT),0xB1:("CMPA",EXT),0xB2:("SBCA",EXT),0xB3:("SUBD",EXT),
0xB4:("ANDA",EXT),0xB5:("BITA",EXT),0xB6:("LDAA",EXT),0xB7:("STAA",EXT),
0xB8:("EORA",EXT),0xB9:("ADCA",EXT),0xBA:("ORAA",EXT),0xBB:("ADDA",EXT),
0xBC:("CPX",EXT),0xBD:("JSR",EXT),0xBE:("LDS",EXT),0xBF:("STS",EXT),
0xC0:("SUBB",IMM8),0xC1:("CMPB",IMM8),0xC2:("SBCB",IMM8),0xC3:("ADDD",IMM16),
0xC4:("ANDB",IMM8),0xC5:("BITB",IMM8),0xC6:("LDAB",IMM8),0xC8:("EORB",IMM8),
0xC9:("ADCB",IMM8),0xCA:("ORAB",IMM8),0xCB:("ADDB",IMM8),0xCC:("LDD",IMM16),
0xCE:("LDX",IMM16),0xCF:("STOP",INH),
0xD0:("SUBB",DIR),0xD1:("CMPB",DIR),0xD2:("SBCB",DIR),0xD3:("ADDD",DIR),
0xD4:("ANDB",DIR),0xD5:("BITB",DIR),0xD6:("LDAB",DIR),0xD7:("STAB",DIR),
0xD8:("EORB",DIR),0xD9:("ADCB",DIR),0xDA:("ORAB",DIR),0xDB:("ADDB",DIR),
0xDC:("LDD",DIR),0xDD:("STD",DIR),0xDE:("LDX",DIR),0xDF:("STX",DIR),
0xE0:("SUBB",IDX),0xE1:("CMPB",IDX),0xE2:("SBCB",IDX),0xE3:("ADDD",IDX),
0xE4:("ANDB",IDX),0xE5:("BITB",IDX),0xE6:("LDAB",IDX),0xE7:("STAB",IDX),
0xE8:("EORB",IDX),0xE9:("ADCB",IDX),0xEA:("ORAB",IDX),0xEB:("ADDB",IDX),
0xEC:("LDD",IDX),0xED:("STD",IDX),0xEE:("LDX",IDX),0xEF:("STX",IDX),
0xF0:("SUBB",EXT),0xF1:("CMPB",EXT),0xF2:("SBCB",EXT),0xF3:("ADDD",EXT),
0xF4:("ANDB",EXT),0xF5:("BITB",EXT),0xF6:("LDAB",EXT),0xF7:("STAB",EXT),
0xF8:("EORB",EXT),0xF9:("ADCB",EXT),0xFA:("ORAB",EXT),0xFB:("ADDB",EXT),
0xFC:("LDD",EXT),0xFD:("STD",EXT),0xFE:("LDX",EXT),0xFF:("STX",EXT),
}
# page 2 ($18): Y-register/Y-indexed forms
OP18 = {
0x08:("INY",INH),0x09:("DEY",INH),0x1C:("BSET",BSET_IDX),0x1D:("BCLR",BCLR_IDX),
0x1E:("BRSET",BRSET_IDX),0x1F:("BRCLR",BRCLR_IDX),0x30:("TSY",INH),0x35:("TYS",INH),
0x38:("PULY",INH),0x3A:("ABY",INH),0x3C:("PSHY",INH),0x60:("NEG",IDY),
0x63:("COM",IDY),0x64:("LSR",IDY),0x66:("ROR",IDY),0x67:("ASR",IDY),
0x68:("LSL",IDY),0x69:("ROL",IDY),0x6A:("DEC",IDY),0x6C:("INC",IDY),
0x6D:("TST",IDY),0x6E:("JMP",IDY),0x6F:("CLR",IDY),0x8C:("CPY",IMM16),
0x8F:("XGDY",INH),0x9C:("CPY",DIR),0xA0:("SUBA",IDY),0xA1:("CMPA",IDY),
0xA2:("SBCA",IDY),0xA3:("SUBD",IDY),0xA4:("ANDA",IDY),0xA5:("BITA",IDY),
0xA6:("LDAA",IDY),0xA7:("STAA",IDY),0xA8:("EORA",IDY),0xA9:("ADCA",IDY),
0xAA:("ORAA",IDY),0xAB:("ADDA",IDY),0xAC:("CPY",IDY),0xAD:("JSR",IDY),
0xAE:("LDS",IDY),0xAF:("STS",IDY),0xBC:("CPY",EXT),0xCE:("LDY",IMM16),
0xDE:("LDY",DIR),0xDF:("STY",DIR),0xE0:("SUBB",IDY),0xE1:("CMPB",IDY),
0xE2:("SBCB",IDY),0xE3:("ADDD",IDY),0xE4:("ANDB",IDY),0xE5:("BITB",IDY),
0xE6:("LDAB",IDY),0xE7:("STAB",IDY),0xE8:("EORB",IDY),0xE9:("ADCB",IDY),
0xEA:("ORAB",IDY),0xEB:("ADDB",IDY),0xEC:("LDD",IDY),0xED:("STD",IDY),
0xEE:("LDY",IDY),0xEF:("STY",IDY),0xBE:("LDY",EXT),0xBF:("STY",EXT),
}
OP1A = {0x83:("CPD",IMM16),0x93:("CPD",DIR),0xA3:("CPD",IDX),0xB3:("CPD",EXT),
0xAC:("CPY",IDX),0xEE:("LDY",IDX),0xEF:("STY",IDX)}
OPCD = {0xA3:("CPD",IDY),0xAC:("CPX",IDY),0xEE:("LDX",IDY),0xEF:("STX",IDY)}
def u16(d,a): return (d[a]<<8)|d[a+1]
class Insn:
__slots__=("addr","end","mnem","mode","opbytes","txt","target","flow")
def __init__(s,**k):
for n,v in k.items(): setattr(s,n,v)
def decode(d,a):
"""Decode one instruction at address a. Returns Insn or None (illegal)."""
start=a; op=d[a]; a+=1; pfx=None; table=OP
if op==0x18: pfx=0x18; table=OP18; op=d[a]; a+=1
elif op==0x1A: pfx=0x1A; table=OP1A; op=d[a]; a+=1
elif op==0xCD: pfx=0xCD; table=OPCD; op=d[a]; a+=1
ent=table.get(op)
if ent is None: return None
mnem,mode=ent; n=MODELEN[mode]; ops=d[a:a+n]; a+=n
idxreg="Y" if (mode in (IDY,) or pfx in (0x18,) and mode in (IDX,)) else "X"
if pfx==0xCD: idxreg="Y"
if pfx==0x1A and mode==IDY: idxreg="Y"
tgt=None; flow="seq"; ann=""
if mode==INH: txt=mnem
elif mode==IMM8: txt=f"{mnem} #${ops[0]:02X}"
elif mode==IMM16:
v=(ops[0]<<8)|ops[1]; txt=f"{mnem} #${v:04X}"
elif mode==DIR:
txt=f"{mnem} ${ops[0]:02X}"; ann=reg_ann(ops[0])
elif mode==EXT:
v=(ops[0]<<8)|ops[1]; txt=f"{mnem} ${v:04X}"; ann=reg_ann(v)
if mnem=="JSR": tgt=v; flow="call"
elif mnem=="JMP": tgt=v; flow="jump"
elif mode==IDX or mode==IDY:
txt=f"{mnem} ${ops[0]:02X},{idxreg}"
elif mode==REL:
rel=ops[0]-256 if ops[0]>127 else ops[0]; tgt=a+rel
txt=f"{mnem} ${tgt:04X}"
if mnem=="BRA": flow="jump"
elif mnem=="BSR": flow="call"
else: flow="branch"
elif mode in (BSET_DIR,BCLR_DIR):
txt=f"{mnem} ${ops[0]:02X},#${ops[1]:02X}"; ann=reg_ann(ops[0])
elif mode in (BSET_IDX,BCLR_IDX):
txt=f"{mnem} ${ops[0]:02X},{idxreg},#${ops[1]:02X}"
elif mode in (BRSET_DIR,BRCLR_DIR):
rel=ops[2]-256 if ops[2]>127 else ops[2]; tgt=a+rel
txt=f"{mnem} ${ops[0]:02X},#${ops[1]:02X},${tgt:04X}"; ann=reg_ann(ops[0]); flow="branch"
elif mode in (BRSET_IDX,BRCLR_IDX):
rel=ops[2]-256 if ops[2]>127 else ops[2]; tgt=a+rel
txt=f"{mnem} ${ops[0]:02X},{idxreg},#${ops[1]:02X},${tgt:04X}"; flow="branch"
else: txt=mnem
if mnem in ("RTS","RTI","JMP","BRA","STOP","WAI") and flow not in ("call","branch"):
if mnem in ("RTS","RTI","STOP"): flow="end"
ob=bytes([d[i] for i in range(start,a)])
return Insn(addr=start,end=a,mnem=mnem,mode=mode,opbytes=ob,
txt=txt+ann,target=tgt,flow=flow)
def main():
path=sys.argv[1] if len(sys.argv)>1 else "RIOv4_2.bin"
d=open(path,"rb").read()
LO,HI=0xC000,0x10000
# entry points: reset + all IRQ vectors
entries=set()
for va in range(0xFFD6,0x10000,2):
entries.add(u16(d,va))
# The RX/TX protocol runs as a pointer state machine: handlers are stored
# into dispatch-pointer RAM vars and reached via `JMP $00,X`, which a
# recursive tracer can't follow. Seed every handler by scanning for
# `LDX #imm16 ; STX <dispatchvar>` (CE iw FF pp) and taking imm16.
DISPATCH={0x292F,0x2927,0x2929,0x292B,0x2D3B,0x2D34,0x2D36,0x2D38}
for a in range(LO,HI-5):
if d[a]==0xCE and d[a+3]==0xFF:
iw=(d[a+1]<<8)|d[a+2]; pp=(d[a+4]<<8)|d[a+5]
if pp in DISPATCH and LO<=iw<HI:
entries.add(iw)
insns={}; labels=set(); calls=set()
work=[e for e in entries if LO<=e<HI]
for e in work: labels.add(e)
seen=set()
while work:
a=work.pop()
while LO<=a<HI and a not in insns:
ins=decode(d,a)
if ins is None: break
insns[a]=ins
if ins.target is not None and LO<=ins.target<HI:
labels.add(ins.target)
if ins.flow in ("call",): calls.add(ins.target)
if ins.flow in ("call","branch","jump"):
if ins.target not in insns: work.append(ins.target)
if ins.flow in ("end","jump"): break
a=ins.end
# emit listing
out=[]
a=LO
while a<HI:
if a in insns:
ins=insns[a]
lbl=f"L{a:04X}:" if a in labels else ""
mark=" <<<CALLED" if a in calls else ""
hexb=" ".join(f"{b:02X}" for b in ins.opbytes)
out.append(f"{a:04X} {hexb:<20} {lbl:<8}{ins.txt}{mark}")
a=ins.end
else:
# data byte
out.append(f"{a:04X} {d[a]:02X} .byte ${d[a]:02X}")
a+=1
sys.stdout.write("\n".join(out)+"\n")
sys.stderr.write(f"decoded {len(insns)} insns, {len(labels)} labels, "
f"{len(calls)} call targets\n")
if __name__=="__main__":
main()
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env python3
"""Apply the RIO v4.2 reply-wedge fix to RIOv4_2.bin -> RIOv4_2_patched.bin.
Fix (see RIOv4_2-ANALYSIS.md): clear the reply-in-progress latch $2521 on
EVERY reply teardown, not just the $2522-gated success path.
1. Give-up path: redirect $D9DD `JMP $DA2F` to a stub at free ROM $DFF0
that clears $2521/$2522 then continues to $DA2F.
2. Success path: make $DA00's clear of $2521/$2522 unconditional.
Each edit asserts the exact original bytes first, so a wrong assumption
aborts instead of corrupting the image. Address == file offset.
"""
import sys, hashlib
SRC = sys.argv[1] if len(sys.argv) > 1 else "RIOv4_2.bin"
DST = sys.argv[2] if len(sys.argv) > 2 else "RIOv4_2_patched.bin"
d = bytearray(open(SRC, "rb").read())
assert len(d) == 0x10000, f"expected 64KB image, got {len(d)}"
orig_sha = hashlib.sha256(d).hexdigest()
assert orig_sha == "60a88718835c654b6135dbec7721c40ef99dca07df2ad4b57eedeb24037a5f73", \
f"unexpected source image {orig_sha}"
def patch(addr, expect, new):
got = bytes(d[addr:addr+len(expect)])
assert got == bytes(expect), (
f"@${addr:04X}: expected {got.hex()} to be {bytes(expect).hex()}")
assert len(new) == len(expect), "length mismatch"
d[addr:addr+len(new)] = bytes(new)
# --- edit 1: give-up path redirect ---------------------------------------
# $D9DD 7E DA 2F JMP $DA2F -> 7E DF F0 JMP $DFF0
patch(0xD9DD, [0x7E, 0xDA, 0x2F], [0x7E, 0xDF, 0xF0])
# stub at $DFF0 (was erased $FF): CLR $2521; CLR $2522; JMP $DA2F
patch(0xDFF0, [0xFF]*8,
[0x7F, 0x25, 0x21, # CLR $2521
0x7F, 0x25, 0x22, # CLR $2522
0x7E, 0xDA]) # JMP $DA2F (hi + first target byte)
patch(0xDFF8, [0xFF], [0x2F]) # JMP low byte
# --- edit 2: success teardown, unconditional clear -----------------------
# $DA21 B6 25 22 LDAA $2522
# $DA24 81 01 CMPA #$01
# $DA26 26 06 BNE $DA2E
# $DA28 7F 25 21 CLR $2521
# $DA2B 7F 25 22 CLR $2522 (13 bytes $DA21-$DA2D; $DA2E RTS untouched)
# -> CLR $2521 ; CLR $2522 ; NOP x7
patch(0xDA21,
[0xB6,0x25,0x22, 0x81,0x01, 0x26,0x06, 0x7F,0x25,0x21, 0x7F,0x25,0x22],
[0x7F,0x25,0x21, 0x7F,0x25,0x22, 0x01,0x01,0x01,0x01,0x01,0x01,0x01])
assert d[0xDA2E] == 0x39, "RTS at $DA2E must be intact"
open(DST, "wb").write(d)
new_sha = hashlib.sha256(d).hexdigest()
# byte-diff report
diffs = [(a, orig, d[a]) for a, orig in
enumerate(open(SRC,"rb").read()) if d[a] != orig]
print(f"source : {SRC} sha256 {orig_sha}")
print(f"patched: {DST} sha256 {new_sha}")
print(f"{len(diffs)} bytes changed:")
for a, o, n in diffs:
print(f" ${a:04X}: {o:02X} -> {n:02X}")
+25 -15
View File
@@ -10,8 +10,12 @@ namespace RioJoy.Core.Calibration;
/// across <see cref="Update"/> calls exactly as the legacy globals did.
///
/// <para>Final outputs are clamped to <c>0..<see cref="AxisOutputs.Max"/></c> — the
/// documented axis range — which also guards a legacy quirk where a value pinned
/// at its observed extreme could compound across polls (see ⚠️ below).</para>
/// documented axis range. One legacy quirk is deliberately fixed rather than
/// ported: with the lever exactly at the tracked start (the detent), the legacy
/// held the previous throttle value, which the ×32 rescale compounded into a
/// full-throttle pin — on the very first poll at rest, and for a poll when a
/// release landed exactly on the start. The detent now zeroes like the rest of
/// the deadzone (see the note in <see cref="Throttle"/>).</para>
/// </summary>
public sealed class AxisCalibrator
{
@@ -28,8 +32,11 @@ public sealed class AxisCalibrator
private int _leftPedalStart = int.MaxValue;
private int _rightPedalStart = int.MaxValue;
// Last computed (pre-clamp) outputs — persist across calls like the legacy globals.
private int _throttleLast = AxisOutputs.Center;
// Last computed (pre-clamp) outputs — persist across calls like the legacy
// globals. The throttle starts at 0, its calibrated rest value (the same value
// ResetThrottle/ResetAll restore): the legacy Center init fed the detent-hold
// quirk (see Throttle) and pinned Z at full on the first poll at rest.
private int _throttleLast;
private int _leftPedalLast = AxisOutputs.Center;
private int _rightPedalLast = AxisOutputs.Center;
private int _joystickXLast = AxisOutputs.Center;
@@ -98,17 +105,20 @@ public sealed class AxisCalibrator
if (lT > 800)
lT = 800;
if (lT != 0)
{
lT = lT * 1000 / 800;
if (lT is > -DeadzoneThrottle and < DeadzoneThrottle)
_throttleLast = 0;
else if (_throttleResult > 0) // back
_throttleLast = 900 + (lT * _throttleResult / 10);
else // front
_throttleLast = lT * _throttleResult;
}
// lT == 0 leaves _throttleLast unchanged (legacy behavior).
// Deliberate divergence from the legacy port: lT == 0 (lever exactly at
// the tracked start, i.e. the detent — including samples that raise the
// running max) zeroes the output like the rest of the ±50 deadzone.
// The legacy left _throttleLast unchanged here, and the ×32 rescale
// below then compounded the held value — pinning Z at full throttle on
// the first poll at rest and spiking full for a poll when a release
// landed exactly on the start.
lT = lT * 1000 / 800;
if (lT is > -DeadzoneThrottle and < DeadzoneThrottle)
_throttleLast = 0;
else if (_throttleResult > 0) // back
_throttleLast = 900 + (lT * _throttleResult / 10);
else // front
_throttleLast = lT * _throttleResult;
}
_throttleLast = Math.Abs(_throttleLast * 32);
+67
View File
@@ -0,0 +1,67 @@
using RioJoy.Core.Calibration;
namespace RioJoy.Core.Output;
/// <summary>
/// A resolved axis write: which pad control to drive and the converted value.
/// <see cref="Value"/> is in the thumb range (<c>-32768..32767</c>) for thumb
/// targets, the trigger range (<c>0..255</c>) for trigger targets, and 0 for
/// <see cref="PadTarget.None"/> (nothing is written).
/// </summary>
public readonly struct ResolvedAxis
{
public PadTarget Target { get; }
public int Value { get; }
public ResolvedAxis(PadTarget target, int value)
{
Target = target;
Value = value;
}
public override string ToString() => $"{Target}:{Value}";
}
/// <summary>
/// Pure route-resolution + value-conversion for the ViGEm sink — no ViGEm types,
/// so the decision logic is unit-testable without the bus driver.
/// <see cref="ViGEmJoystickSink"/> translates the result into ViGEm calls.
/// </summary>
public static class AxisRouter
{
/// <summary>
/// Resolve where the calibrated <paramref name="value"/> (<c>0..32766</c>,
/// center 16383) of <paramref name="axis"/> lands under
/// <paramref name="config"/>, and convert it for that target.
/// </summary>
public static ResolvedAxis Resolve(AxisRoutingConfig config, JoyAxis axis, int value)
{
if (config is null) throw new ArgumentNullException(nameof(config));
AxisRoute route = config.RouteFor(axis);
return route.Target switch
{
PadTarget.None => new ResolvedAxis(PadTarget.None, 0),
PadTarget.LeftTrigger or PadTarget.RightTrigger =>
new ResolvedAxis(route.Target, ToTrigger(value)),
_ => new ResolvedAxis(
route.Target,
route.Mode == AxisOutputMode.UnipolarPositive
? ToUnipolarThumb(value)
: ToCenteredThumb(value)),
};
}
// RIO axis 0..32766 (centre 16383) -> Xbox thumb short -32768..32767 (legacy math).
private static int ToCenteredThumb(int value) =>
Compat.Net48Math.Clamp((value - AxisOutputs.Center) * 2, (int)short.MinValue, short.MaxValue);
// Unipolar RIO axis (rest = 0) -> upper thumb half: 0 -> center 0, 32766 -> max.
private static int ToUnipolarThumb(int value) =>
Compat.Net48Math.Clamp(value, 0, (int)short.MaxValue);
// RIO axis 0..32766 -> Xbox trigger byte 0..255 (legacy math).
private static int ToTrigger(int value) =>
Compat.Net48Math.Clamp(value * 255 / AxisOutputs.Max, 0, 255);
}
@@ -0,0 +1,82 @@
using RioJoy.Core.Calibration;
namespace RioJoy.Core.Output;
/// <summary>
/// Where a calibrated axis lands on the virtual Xbox 360 pad. Our own enum (no
/// ViGEm types — these are shared config types, serialized into profiles and
/// compiled for net40 too); <see cref="ViGEmJoystickSink"/> translates to the
/// ViGEm equivalents. <see cref="None"/> = the axis is not emitted at all.
/// </summary>
public enum PadTarget
{
LeftThumbX,
LeftThumbY,
RightThumbX,
RightThumbY,
LeftTrigger,
RightTrigger,
None,
}
/// <summary>
/// How a calibrated axis value (<c>0..32766</c>, center 16383) converts to a
/// thumb-stick value. Meaningless for trigger targets (triggers always use the
/// legacy <c>value*255/32766</c> byte conversion).
/// </summary>
public enum AxisOutputMode
{
/// <summary>Legacy bipolar mapping: <c>(value - 16383) * 2</c> → center 16383 = thumb 0.</summary>
Centered,
/// <summary>
/// Unipolar mapping for axes whose calibrated rest is 0 (the ratcheted
/// throttle): <c>clamp(value, 0, 32767)</c> → calibrated 0 = thumb center 0,
/// 32766 = thumb max. Only the upper half of the thumb range is used.
/// </summary>
UnipolarPositive,
}
/// <summary>One axis route: which pad control it drives, and how the value converts.</summary>
public sealed record AxisRoute
{
public PadTarget Target { get; init; } = PadTarget.None;
public AxisOutputMode Mode { get; init; } = AxisOutputMode.Centered;
}
/// <summary>
/// Per-profile routing of the six calibrated axes onto the ViGEm Xbox 360 pad
/// (<see cref="RioJoy.Core.Profiles.RioProfile.AxisRouting"/>; null there = this
/// default). The defaults reproduce the historical hardcoded sink routing
/// exactly — X→LeftThumbX, Y→LeftThumbY, Rx→RightThumbX, Ry→RightThumbY,
/// Z→LeftTrigger, Rz→RightTrigger, all <see cref="AxisOutputMode.Centered"/> —
/// so existing profiles behave identically. Two axes routed to the same target
/// are not arbitrated: the last <c>SetAxis</c> write wins.
/// </summary>
public sealed record AxisRoutingConfig
{
public AxisRoute X { get; init; } = new() { Target = PadTarget.LeftThumbX };
public AxisRoute Y { get; init; } = new() { Target = PadTarget.LeftThumbY };
public AxisRoute Z { get; init; } = new() { Target = PadTarget.LeftTrigger };
public AxisRoute Rx { get; init; } = new() { Target = PadTarget.RightThumbX };
public AxisRoute Ry { get; init; } = new() { Target = PadTarget.RightThumbY };
public AxisRoute Rz { get; init; } = new() { Target = PadTarget.RightTrigger };
/// <summary>The route for <paramref name="axis"/>.</summary>
public AxisRoute RouteFor(JoyAxis axis) => axis switch
{
JoyAxis.X => X,
JoyAxis.Y => Y,
JoyAxis.Z => Z,
JoyAxis.Rx => Rx,
JoyAxis.Ry => Ry,
JoyAxis.Rz => Rz,
_ => new AxisRoute(), // unknown axis → None (not emitted)
};
}
@@ -36,7 +36,15 @@ public sealed class HidFeederJoystickSink : IJoystickSink, IDisposable
public static bool TryCreate(out HidFeederJoystickSink? sink)
{
sink = null;
#if NET40
// Windows XP: RioGamepadXP is a HID minidriver whose sideband control
// device can't carry a device interface (no PDO), so it's opened by its
// fixed symbolic-link name rather than via SetupDi + interface GUID.
// Contract is otherwise identical (see driver/RioGamepadXP/Public.h).
string? path = @"\\.\RioGamepadXP";
#else
string? path = FindDevicePath();
#endif
if (path is null)
return false;
+34 -16
View File
@@ -38,6 +38,7 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
private readonly ViGEmClient _client;
private readonly IXbox360Controller _pad;
private readonly object _gate = new();
private AxisRoutingConfig _routing = new();
private ViGEmJoystickSink(ViGEmClient client, IXbox360Controller pad)
{
@@ -45,6 +46,28 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
_pad = pad;
}
/// <summary>
/// Apply a per-profile axis routing (<see langword="null"/> = the default
/// legacy routing) and neutralize the pad's axis state — all four thumb axes
/// to 0 and both triggers to 0 in one report — so values written under the
/// previous routing cannot persist across a profile switch. Thread-safe.
/// Two axes routed to the same target are not arbitrated: last writer wins.
/// </summary>
public void SetRouting(AxisRoutingConfig? routing)
{
lock (_gate)
{
_routing = routing ?? new AxisRoutingConfig();
_pad.SetAxisValue(Xbox360Axis.LeftThumbX, 0);
_pad.SetAxisValue(Xbox360Axis.LeftThumbY, 0);
_pad.SetAxisValue(Xbox360Axis.RightThumbX, 0);
_pad.SetAxisValue(Xbox360Axis.RightThumbY, 0);
_pad.SetSliderValue(Xbox360Slider.LeftTrigger, 0);
_pad.SetSliderValue(Xbox360Slider.RightTrigger, 0);
_pad.SubmitReport();
}
}
/// <summary>
/// Try to connect to ViGEmBus and create a virtual Xbox 360 controller. Returns
/// <see langword="false"/> if ViGEmBus is not installed, so callers can fall back
@@ -94,32 +117,27 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
}
}
// Routing + conversion live in the pure AxisRouter (unit-tested without the
// bus); this method only translates the resolved target into ViGEm calls.
public void SetAxis(JoyAxis axis, int value)
{
lock (_gate)
{
switch (axis)
ResolvedAxis resolved = AxisRouter.Resolve(_routing, axis, value);
switch (resolved.Target)
{
case JoyAxis.X: _pad.SetAxisValue(Xbox360Axis.LeftThumbX, ToThumb(value)); break;
case JoyAxis.Y: _pad.SetAxisValue(Xbox360Axis.LeftThumbY, ToThumb(value)); break;
case JoyAxis.Rx: _pad.SetAxisValue(Xbox360Axis.RightThumbX, ToThumb(value)); break;
case JoyAxis.Ry: _pad.SetAxisValue(Xbox360Axis.RightThumbY, ToThumb(value)); break;
case JoyAxis.Z: _pad.SetSliderValue(Xbox360Slider.LeftTrigger, ToTrigger(value)); break;
case JoyAxis.Rz: _pad.SetSliderValue(Xbox360Slider.RightTrigger, ToTrigger(value)); break;
default: return;
case PadTarget.LeftThumbX: _pad.SetAxisValue(Xbox360Axis.LeftThumbX, (short)resolved.Value); break;
case PadTarget.LeftThumbY: _pad.SetAxisValue(Xbox360Axis.LeftThumbY, (short)resolved.Value); break;
case PadTarget.RightThumbX: _pad.SetAxisValue(Xbox360Axis.RightThumbX, (short)resolved.Value); break;
case PadTarget.RightThumbY: _pad.SetAxisValue(Xbox360Axis.RightThumbY, (short)resolved.Value); break;
case PadTarget.LeftTrigger: _pad.SetSliderValue(Xbox360Slider.LeftTrigger, (byte)resolved.Value); break;
case PadTarget.RightTrigger: _pad.SetSliderValue(Xbox360Slider.RightTrigger, (byte)resolved.Value); break;
default: return; // PadTarget.None — the axis is not emitted
}
_pad.SubmitReport();
}
}
// RIO axis 0..32766 (centre 16383) -> Xbox thumb short -32768..32767.
private static short ToThumb(int value) =>
(short)RioJoy.Core.Compat.Net48Math.Clamp((value - AxisOutputs.Center) * 2, short.MinValue, short.MaxValue);
// RIO axis 0..32766 -> Xbox trigger byte 0..255.
private static byte ToTrigger(int value) =>
(byte)RioJoy.Core.Compat.Net48Math.Clamp(value * 255 / AxisOutputs.Max, 0, 255);
public void Dispose()
{
try { _pad.Disconnect(); } catch { /* already disconnected / bus gone */ }
+20 -1
View File
@@ -7,9 +7,28 @@ namespace RioJoy.Core.Profiles;
/// </summary>
public sealed class AppConfig
{
/// <summary>Default RIO COM port when a profile doesn't specify one.</summary>
/// <summary>
/// Default RIO endpoint when a profile doesn't specify one: a COM port
/// name ("COM1"), or "pipe:vrio" to reach the vRIO emulator over its
/// named pipe (no com0com pair; see RioTransportFactory).
/// </summary>
public string DefaultRioComPort { get; set; } = "COM1";
/// <summary>
/// RIO link baud rate. 9600 = stock/wedge-patched firmware (native-game
/// compatible). 31250 = the FastRIO chip (TeslaRel410
/// restoration/rio-firmware/RIOv4_3_fastrio.bin) — needs an FTDI-class
/// adapter; classic 16550 UARTs cannot make this rate.
/// </summary>
public int RioBaudRate { get; set; } = 9600;
/// <summary>
/// Analog poll interval in milliseconds (legacy cadence 55 → ~18 Hz).
/// The FastRIO chip sustains ~20-25 ms (~30+ Hz effective; bench
/// 2026-07-19). Leave at 55 for stock-speed boards.
/// </summary>
public int AnalogPollMs { get; set; } = 55;
/// <summary>Default plasma COM port when a profile doesn't specify one.</summary>
public string? DefaultPlasmaComPort { get; set; } = "COM2";
+44
View File
@@ -52,4 +52,48 @@ public static class ConfigStore
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(path));
return File.Exists(path) ? Deserialize(File.ReadAllText(path)) : new AppConfig();
}
/// <summary>
/// Deserialize a single-profile document (the shape shipped in a repo's
/// <c>profiles/*.json</c>) with the same serializer settings as the config.
/// </summary>
public static RioProfile DeserializeProfile(string json)
{
if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
return JsonConvert.DeserializeObject<RioProfile>(json, Options)
?? throw new JsonSerializationException("Profile JSON deserialized to null.");
}
/// <summary>
/// Merge the single-profile file at <paramref name="profilePath"/> into the
/// config at <paramref name="configPath"/> (created with defaults if absent).
/// A profile with the same name (case-insensitive, the
/// <see cref="AppConfig.FindProfile"/> convention) is replaced in place;
/// otherwise the profile is appended. All other config content is preserved.
/// </summary>
public static ProfileImportResult ImportProfile(string configPath, string profilePath)
{
string profileJson = File.ReadAllText(profilePath);
RioProfile profile = DeserializeProfile(profileJson);
// Name keys the merge, and RioProfile defaults it to "Unnamed" — so a
// document that never states one must be rejected, not merged under the
// class default.
if (Newtonsoft.Json.Linq.JObject.Parse(profileJson).Value<string>("Name") is not { Length: > 0 })
throw new JsonSerializationException($"Profile file '{profilePath}' has no Name.");
AppConfig config = Load(configPath);
int existing = config.Profiles.FindIndex(
p => string.Equals(p.Name, profile.Name, StringComparison.OrdinalIgnoreCase));
bool replaced = existing >= 0;
if (replaced)
config.Profiles[existing] = profile;
else
config.Profiles.Add(profile);
Save(config, configPath);
return new ProfileImportResult(profile.Name!, replaced);
}
}
/// <summary>Outcome of <see cref="ConfigStore.ImportProfile"/>.</summary>
public sealed record ProfileImportResult(string Name, bool Replaced);
+10 -1
View File
@@ -1,5 +1,6 @@
using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping;
using RioJoy.Core.Output;
namespace RioJoy.Core.Profiles;
@@ -14,7 +15,7 @@ public sealed class RioProfile
/// <summary>Display name (unique within a library).</summary>
public string Name { get; set; } = "Unnamed";
/// <summary>RIO serial port (e.g. "COM3"); null = use the app default.</summary>
/// <summary>RIO endpoint ("COM3", or "pipe:vrio" for the emulator); null = use the app default.</summary>
public string? RioComPort { get; set; }
/// <summary>Plasma/VFD serial port; null = use the app default or none.</summary>
@@ -29,6 +30,14 @@ public sealed class RioProfile
/// <summary>Axis calibration / invert options.</summary>
public AxisCalibrationConfig Calibration { get; set; } = new();
/// <summary>
/// How the six calibrated axes route onto the ViGEm Xbox 360 pad; null =
/// the default <see cref="AxisRoutingConfig"/> (the historical fixed
/// routing). Ignored by the RioGamepad HID feeder, whose native 6-axis
/// report needs no routing.
/// </summary>
public AxisRoutingConfig? AxisRouting { get; set; }
/// <summary>Plasma greeting text shown on load (null = leave display as-is).</summary>
public string? PlasmaGreeting { get; set; }
@@ -0,0 +1,174 @@
using System.IO.Pipes;
namespace RioJoy.Core.Serial;
/// <summary>
/// <see cref="IRioTransport"/> over a local named pipe, for driving the vRIO
/// device emulator without com0com. vRIO serves <c>\\.\pipe\vrio</c> for the
/// whole app lifetime (the device is always present); we connect as the
/// client — the same role the DOSBox-X fork's <c>namedpipe</c> serial backend
/// plays. Framing per <see cref="PipeFraming"/>: writes wrap in data frames,
/// reads unwrap them, and modem lines travel in-band as lines frames.
///
/// <para>On connect this replays <see cref="SerialPortTransport"/>'s board
/// reset over the pipe: a lines frame asserting DTR, the
/// <see cref="SerialPortTransport.DtrPulse"/> hold, then a lines frame
/// releasing it (RTS stays low throughout, matching the COM path's
/// RtsEnable default). The in-band frames keep the pulse's exact position
/// in the byte stream, which is why the contract multiplexes control onto
/// the data pipe instead of using a second one.</para>
///
/// <para>The peer's lines frames (vRIO asserts DTR+RTS on connect — "board
/// present") are decoded and tracked but nothing consumes them: the COM path
/// runs Handshake.None and never reads DSR/CTS either. A peer disconnect or
/// framing violation surfaces as a 0-byte read — the transport-closed signal
/// <see cref="RioSerialLink"/> already understands, same as a yanked
/// adapter.</para>
///
/// <para>Writes are not paced: host→RIO traffic is tiny stop-and-wait
/// commands, and the com0com path never emulated baud timing on this
/// direction either. (vRIO paces its own RIO→host TX, where the analog
/// stream would otherwise burst.)</para>
/// </summary>
public sealed class NamedPipeTransport : IRioTransport
{
/// <summary>How long to wait for the pipe server before giving up.</summary>
public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(2);
private readonly NamedPipeClientStream _pipe;
private readonly string _pipeName;
private readonly PipeFrameDecoder _decoder = new();
// Decoded data bytes not yet handed to a reader (a pipe read may carry
// more payload than the caller's buffer holds). Only the receive loop
// touches these — IRioTransport has a single reader by contract.
private readonly Queue<byte> _decoded = new();
private readonly byte[] _raw = new byte[512];
private volatile bool _closed;
private byte _peerLines;
/// <param name="pipeName">Pipe name without the <c>\\.\pipe\</c> prefix, e.g. "vrio".</param>
/// <param name="connectTimeout">Server wait; default <see cref="DefaultConnectTimeout"/>.</param>
public NamedPipeTransport(string pipeName, TimeSpan? connectTimeout = null)
{
if (string.IsNullOrWhiteSpace(pipeName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(pipeName));
_pipeName = pipeName;
_decoder.Data += (buffer, count) =>
{
for (int i = 0; i < count; i++)
_decoded.Enqueue(buffer[i]);
};
_decoder.Lines += lines => _peerLines = lines;
_pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
try
{
_pipe.Connect((int)(connectTimeout ?? DefaultConnectTimeout).TotalMilliseconds);
}
catch (TimeoutException)
{
_pipe.Dispose();
throw new TimeoutException($@"No pipe server at \\.\pipe\{pipeName} — is vRIO running?");
}
catch
{
_pipe.Dispose();
throw;
}
// DTR reset pulse, in-band: assert, hold, release (port of the COM
// path's SETDTR/CLRDTR — see SerialPortTransport and PROTOCOL.md §1).
//
// Queued as overlapped writes, never awaited here: the pipe's buffers
// default to 0 bytes, so a synchronous write blocks until the peer
// reads it — and vRIO's own on-connect lines frame is written before
// it starts reading, a write-first/write-first deadlock if we blocked
// too. Pipe writes complete in issue order, so the assert→release
// edge keeps its exact position in the byte stream; both frames
// drain once the two readers are up. (When vRIO is already reading —
// the steady case — the 50 ms hold arrives in real time too; during
// the startup rendezvous the device may see a shorter pulse, which
// still carries both edges.)
try
{
byte[] assert = PipeFraming.EncodeLines(PipeFraming.LineDtr);
Observe(_pipe.WriteAsync(assert, 0, assert.Length, CancellationToken.None));
Thread.Sleep(SerialPortTransport.DtrPulse);
byte[] release = PipeFraming.EncodeLines(0);
Observe(_pipe.WriteAsync(release, 0, release.Length, CancellationToken.None));
}
catch
{
_pipe.Dispose();
throw;
}
}
// Swallow a queued write's eventual fault (e.g. the peer vanished before
// draining it) — on net40 an unobserved task exception kills the process.
private static void Observe(Task task) =>
task.ContinueWith(t => { _ = t.Exception; }, TaskContinuationOptions.ExecuteSynchronously);
public string Description => $@"\\.\pipe\{_pipeName}";
/// <summary>The peer's last lines frame (bit0 its DTR → our DSR, bit1 its RTS → our CTS).</summary>
public byte PeerLines => _peerLines;
/// <summary>Why the peer's stream was rejected, or null (diagnostic; see <see cref="PipeFrameDecoder.Violation"/>).</summary>
public string? Violation => _decoder.Violation;
public async Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken)
{
while (_decoded.Count == 0)
{
if (_closed)
return 0;
int n;
try
{
n = await _pipe.ReadAsync(_raw, 0, _raw.Length, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (_closed && ex is IOException or ObjectDisposedException)
{
return 0; // Dispose broke the pending read — normal teardown
}
if (n == 0)
{
_closed = true; // server went away — unplugged cable
return 0;
}
if (!_decoder.Feed(_raw, n))
{
_closed = true; // framing violation: not line noise on a pipe, drop the link
return 0;
}
}
int count = Math.Min(buffer.Length, _decoded.Count);
for (int i = 0; i < count; i++)
buffer[i] = _decoded.Dequeue();
return count;
}
public Task WriteAsync(byte[] data, CancellationToken cancellationToken)
{
// Empty input frames to an empty array; the 0-byte write is a no-op.
byte[] framed = PipeFraming.EncodeData(data);
return _pipe.WriteAsync(framed, 0, framed.Length, cancellationToken);
}
public void Dispose()
{
// Closing the handle aborts a pending overlapped read (the receive
// loop's read ignores cancellation, same as the COM path — teardown
// relies on this). Pipes have no FlushFileBuffers hang to guard
// against, so no grace-period dance like SerialPortTransport's.
_closed = true;
try { _pipe.Dispose(); }
catch (IOException) { }
}
}
+160
View File
@@ -0,0 +1,160 @@
namespace RioJoy.Core.Serial;
/// <summary>
/// The typed-frame contract for serial-over-named-pipe, shared with vRIO
/// (<c>VRio.Core/Device/PipeFraming.cs</c>) and the DOSBox-X fork's
/// <c>namedpipe</c> serial backend (<c>serialnamedpipe.h</c> is the contract's
/// source of truth on that side). A pipe is a plain byte stream, so serial
/// data and modem control lines are multiplexed as typed frames:
///
/// <code>
/// 0x00 &lt;len:u8&gt; &lt;len bytes&gt; serial data, len ≥ 1 (batching allowed)
/// 0x01 &lt;lines:u8&gt; the sender's OWN output lines (bit0 DTR,
/// bit1 RTS); the receiver applies the
/// null-modem cross: peer DTR → local DSR,
/// peer RTS → local CTS
/// </code>
///
/// Each side sends one lines frame immediately on connect; until it arrives
/// the peer's lines are assumed low, and a disconnect drops them low again.
/// Any other frame type is a protocol bug, not line noise — pipes don't drop
/// bytes — so the receiver drops the connection instead of trying to resync.
/// </summary>
public static class PipeFraming
{
public const byte DataType = 0x00;
public const byte LinesType = 0x01;
/// <summary>Lines-frame bit: the sender's DTR output.</summary>
public const byte LineDtr = 0x01;
/// <summary>Lines-frame bit: the sender's RTS output.</summary>
public const byte LineRts = 0x02;
/// <summary>Largest payload one data frame can carry (u8 length).</summary>
public const int MaxDataPayload = byte.MaxValue;
/// <summary>Build a lines frame carrying <paramref name="lines"/>.</summary>
public static byte[] EncodeLines(byte lines) => new[] { LinesType, lines };
/// <summary>
/// Wrap <paramref name="data"/> in data frames, chunking payloads longer
/// than <see cref="MaxDataPayload"/>. Empty input yields an empty array
/// (the contract forbids zero-length data frames).
/// </summary>
public static byte[] EncodeData(byte[] data)
{
if (data is null) throw new ArgumentNullException(nameof(data));
if (data.Length == 0)
return new byte[0]; // net40 has no Array.Empty
int chunks = (data.Length + MaxDataPayload - 1) / MaxDataPayload;
var framed = new byte[data.Length + chunks * 2];
int src = 0, dst = 0;
while (src < data.Length)
{
int len = Math.Min(MaxDataPayload, data.Length - src);
framed[dst++] = DataType;
framed[dst++] = (byte)len;
Array.Copy(data, src, framed, dst, len);
src += len;
dst += len;
}
return framed;
}
}
/// <summary>
/// Incremental decoder for <see cref="PipeFraming"/>: feed it raw pipe reads,
/// get one <see cref="Data"/> event per complete data frame and one
/// <see cref="Lines"/> event per lines frame. Frames may split across reads
/// at any byte boundary. A malformed stream (unknown type, zero-length data
/// frame) poisons the decoder: <see cref="Feed"/> returns false and
/// <see cref="Violation"/> says why — drop the connection and
/// <see cref="Reset"/> before the next one.
/// </summary>
public sealed class PipeFrameDecoder
{
private enum State { Type, Length, Payload, Lines }
private readonly byte[] _payload = new byte[byte.MaxValue];
private State _state;
private int _fill, _length;
/// <summary>
/// A complete data frame's payload as (buffer, count). The buffer is
/// reused across frames — consume it synchronously.
/// </summary>
public event Action<byte[], int>? Data;
/// <summary>A lines frame's bits (see <see cref="PipeFraming.LineDtr"/>).</summary>
public event Action<byte>? Lines;
/// <summary>Why the stream was rejected, or null while it is healthy.</summary>
public string? Violation { get; private set; }
/// <summary>Forget any partial frame and clear a violation (new connection).</summary>
public void Reset()
{
_state = State.Type;
_fill = _length = 0;
Violation = null;
}
/// <summary>
/// Consume <paramref name="count"/> received bytes from
/// <paramref name="buffer"/>. Returns false when the stream violates the
/// framing contract (see <see cref="Violation"/>); a poisoned decoder
/// keeps returning false until <see cref="Reset"/>.
/// </summary>
public bool Feed(byte[] buffer, int count)
{
if (Violation is not null)
return false;
for (int i = 0; i < count; i++)
{
byte b = buffer[i];
switch (_state)
{
case State.Type when b == PipeFraming.DataType:
_state = State.Length;
break;
case State.Type when b == PipeFraming.LinesType:
_state = State.Lines;
break;
case State.Type:
Violation = $"unknown frame type 0x{b:X2}";
return false;
case State.Length when b == 0:
Violation = "zero-length data frame";
return false;
case State.Length:
_length = b;
_fill = 0;
_state = State.Payload;
break;
case State.Payload:
_payload[_fill++] = b;
if (_fill == _length)
{
_state = State.Type;
Data?.Invoke(_payload, _length);
}
break;
case State.Lines:
_state = State.Type;
Lines?.Invoke(b);
break;
}
}
return true;
}
}
+113 -1
View File
@@ -23,6 +23,26 @@ public sealed class RioSerialLink
// Time since the last accepted AnalogReply, for the recovery watchdog.
private readonly Stopwatch _sinceAnalog = new();
// Stop-and-wait command state: one command in flight at a time
// (_commandGate), resolved by the board's ACK/NAK or by AckTimeout.
// Control-byte replies (our ACKs) bypass the gate — the board never
// ACK/NAKs those, and they must not queue behind a pending command.
private readonly SemaphoreSlim _commandGate = new(1, 1);
private readonly object _pendingGate = new();
private PendingCommand? _pending;
private long _retransmits;
// The in-flight command: its command byte gates which inbound events may
// resolve it (a reply only resolves the MATCHING request — an analog reply
// still in USB transit from the previous poll must never "confirm" a lamp
// command that follows it; bench 2026-07-19).
private sealed class PendingCommand
{
public PendingCommand(byte command) => Command = command;
public byte Command { get; }
public TaskCompletionSource<bool> Tcs { get; } = new();
}
public RioSerialLink(IRioTransport transport, RioSerialLinkOptions? options = null)
{
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
@@ -50,6 +70,9 @@ public sealed class RioSerialLink
/// <summary>The transport's description, surfaced for status/logging.</summary>
public string Description => _transport.Description;
/// <summary>Total command retransmits (NAK- or timeout-triggered) since creation.</summary>
public long Retransmits => Interlocked.Read(ref _retransmits);
/// <summary>
/// Run the receive loop and (if enabled) the analog poll loop until
/// <paramref name="cancellationToken"/> fires or the transport closes.
@@ -78,8 +101,67 @@ public sealed class RioSerialLink
}
}
/// <summary>Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.</summary>
/// <summary>
/// Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.
/// Command packets use stop-and-wait: the call completes once the board
/// ACKs, or after <see cref="RioSerialLinkOptions.CommandRetransmitLimit"/>
/// retransmits (NAK- or timeout-triggered) go unacknowledged — the command
/// is then dropped (all commands are idempotent state-setters, and the
/// caller's next update supersedes it). Control bytes bypass the wait.
/// </summary>
public async Task SendAsync(byte[] packet, CancellationToken cancellationToken = default)
{
if (packet is null) throw new ArgumentNullException(nameof(packet));
// Control-byte replies (len 1) are never ACK/NAK'd by the board, and a
// limit of 0 selects the legacy fire-and-forget behavior.
if (packet.Length <= 1 || _options.CommandRetransmitLimit <= 0)
{
await WriteAsync(packet, cancellationToken).ConfigureAwait(false);
return;
}
await Compat.TaskCompat.WaitAsync(_commandGate, cancellationToken).ConfigureAwait(false);
try
{
for (int attempt = 0; ; attempt++)
{
var pending = new PendingCommand(packet[0]);
lock (_pendingGate) _pending = pending;
await WriteAsync(packet, cancellationToken).ConfigureAwait(false);
Task winner = await Compat.TaskCompat.WhenAny(new[]
{
pending.Tcs.Task,
Compat.TaskCompat.Delay(_options.AckTimeout, cancellationToken),
}).ConfigureAwait(false);
if (winner == pending.Tcs.Task && pending.Tcs.Task.Result)
return; // delivered (ACK, or the request's own reply)
// NAK'd or timed out (a shred so complete the board never NAK'd).
if (attempt >= _options.CommandRetransmitLimit)
{
// Budget spent — drop (idempotent; the next update supersedes).
// Brief settle so a late straggler response can't land on the
// NEXT command's wait (strays with nothing pending are ignored).
lock (_pendingGate) _pending = null;
await Compat.TaskCompat.Delay(TimeSpan.FromMilliseconds(10), cancellationToken)
.ConfigureAwait(false);
return;
}
Interlocked.Increment(ref _retransmits);
}
}
finally
{
lock (_pendingGate) _pending = null;
_commandGate.Release();
}
}
private async Task WriteAsync(byte[] packet, CancellationToken cancellationToken)
{
await Compat.TaskCompat.WaitAsync(_writeLock, cancellationToken).ConfigureAwait(false);
try
@@ -141,6 +223,17 @@ public sealed class RioSerialLink
break;
case RioRxEventKind.ControlByte:
if (ev.Byte is (byte)RioControl.Ack or (byte)RioControl.Nak)
{
// Resolve the in-flight command (stop-and-wait). Safe to be
// type-blind here: the board's TX ISR sends pending ACK/NAK
// ahead of reply data, so a command's ACK cannot arrive
// after its successor starts (unlike replies, see
// DispatchTyped).
PendingCommand? pending;
lock (_pendingGate) pending = _pending;
pending?.Tcs.TrySetResult(ev.Byte == (byte)RioControl.Ack);
}
ControlReceived?.Invoke(ev.Byte);
break;
@@ -152,6 +245,25 @@ public sealed class RioSerialLink
private void DispatchTyped(RioPacket packet)
{
// A reply proves the MATCHING request landed, whether or not the board
// also sent an explicit ACK — but only the matching one: a reply still
// in transit from an earlier poll must never confirm an unrelated
// command (that type-blindness was the residual lamp-glitch hole).
RioCommand? resolves = packet.Command switch
{
RioCommand.AnalogReply => RioCommand.AnalogRequest,
RioCommand.VersionReply => RioCommand.VersionRequest,
RioCommand.CheckReply => RioCommand.CheckRequest,
_ => null,
};
if (resolves is not null)
{
PendingCommand? pending;
lock (_pendingGate) pending = _pending;
if (pending is not null && pending.Command == (byte)resolves.Value)
pending.Tcs.TrySetResult(true);
}
switch (packet.Command)
{
case RioCommand.AnalogReply:
@@ -29,4 +29,25 @@ public sealed record RioSerialLinkOptions
/// <summary>Read buffer size for the receive loop.</summary>
public int ReadBufferSize { get; init; } = 256;
/// <summary>
/// How many times a command packet is retransmitted when the board NAKs it
/// or the ACK never arrives (0 = fire-and-forget, the pre-2026-07 behavior).
/// Commands use stop-and-wait: ONE command in flight, resolved by
/// ACK / NAK / <see cref="AckTimeout"/> before the next is written. The
/// first (NAK-race) design resent "the most recent" packet and misfired
/// under mash bursts — by the time a NAK crossed USB, a newer command was
/// already the latest, so the corrupted write stayed lost (bench
/// 2026-07-19); with one-in-flight the attribution is exact, and the
/// timeout also catches corruptions so complete the board never NAKs.
/// </summary>
public int CommandRetransmitLimit { get; init; } = 2;
/// <summary>
/// How long to wait for the board's ACK/NAK of a command before treating it
/// as lost (stop-and-wait; see <see cref="CommandRetransmitLimit"/>). Board
/// turnaround is ~1-4 ms; 50 ms is generous without stalling the 55 ms
/// analog poll cadence on a healthy link.
/// </summary>
public TimeSpan AckTimeout { get; init; } = TimeSpan.FromMilliseconds(50);
}
@@ -0,0 +1,33 @@
namespace RioJoy.Core.Serial;
/// <summary>
/// Opens the right <see cref="IRioTransport"/> for an endpoint string. A COM
/// port name ("COM3") opens a <see cref="SerialPortTransport"/>; a
/// <c>pipe:name</c> endpoint ("pipe:vrio") opens a
/// <see cref="NamedPipeTransport"/> to <c>\\.\pipe\name</c> — the same
/// endpoint syntax vRIO's own connection picker uses, so a profile can point
/// at the emulator with <c>RioComPort = "pipe:vrio"</c> and no com0com pair.
/// </summary>
public static class RioTransportFactory
{
/// <summary>Endpoint prefix selecting the named-pipe transport.</summary>
public const string PipeScheme = "pipe:";
/// <summary>True when <paramref name="endpoint"/> names a pipe rather than a COM port.</summary>
public static bool IsPipe(string endpoint) =>
endpoint is not null
&& endpoint.StartsWith(PipeScheme, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Open <paramref name="endpoint"/>. <paramref name="baudRate"/> applies
/// to COM ports only — a pipe has no wire rate (the peer paces itself).
/// </summary>
public static IRioTransport Open(string endpoint, int baudRate = SerialPortTransport.BaudRate)
{
if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(endpoint));
return IsPipe(endpoint)
? new NamedPipeTransport(endpoint.Substring(PipeScheme.Length))
: (IRioTransport)new SerialPortTransport(endpoint, baudRate);
}
}
+11 -4
View File
@@ -11,7 +11,7 @@ namespace RioJoy.Core.Serial;
/// </summary>
public sealed class SerialPortTransport : IRioTransport
{
/// <summary>RIO link bit rate.</summary>
/// <summary>Default RIO link bit rate (stock v4.2 firmware).</summary>
public const int BaudRate = 9600;
/// <summary>DTR reset-pulse hold time on open.</summary>
@@ -23,11 +23,18 @@ public sealed class SerialPortTransport : IRioTransport
private readonly SerialPort _port;
private readonly Stream _stream;
public SerialPortTransport(string portName)
/// <param name="portName">COM port name, e.g. "COM1".</param>
/// <param name="baudRate">
/// Link rate; default 9600 (stock firmware). The 31250-baud FastRIO
/// firmware (TeslaRel410 restoration/rio-firmware) needs an adapter that can make
/// non-standard rates exactly — FTDI-class USB serial can, classic
/// 16550 UARTs cannot.
/// </param>
public SerialPortTransport(string portName, int baudRate = BaudRate)
{
if (string.IsNullOrWhiteSpace(portName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(portName));
_port = new SerialPort(portName, BaudRate, Parity.None, 8, StopBits.One)
_port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
{
Handshake = Handshake.None,
// The framing state machine handles timing; keep reads non-throwing.
@@ -45,7 +52,7 @@ public sealed class SerialPortTransport : IRioTransport
_stream = _port.BaseStream;
}
public string Description => $"{_port.PortName} @ {BaudRate} 8N1";
public string Description => $"{_port.PortName} @ {_port.BaudRate} 8N1";
public Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken) =>
_stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
+61 -22
View File
@@ -5,6 +5,7 @@ using RioJoy.Core.Output;
using RioJoy.Core.Mapping;
using RioJoy.Core.Profiles;
using RioJoy.Core.Protocol;
using RioJoy.Core.Serial;
namespace RioJoy.Tray.Editor;
@@ -30,26 +31,32 @@ public sealed class ProfileEditorForm : Form
private readonly TextBox _nameBox = new() { Location = new Point(66, 12), Width = 234 };
private readonly TextBox _matchBox = new() { Location = new Point(66, 40), Width = 234 };
private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 68), MaximumSize = new Size(310, 0) };
private readonly TextBox _labelBox = new() { Location = new Point(70, 100), Width = 230 };
private readonly ComboBox _kindBox = new() { Location = new Point(70, 134), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 171), AutoSize = true };
private readonly ComboBox _valueCombo = new() { Location = new Point(70, 168), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 200), AutoSize = true };
private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 200), AutoSize = true };
private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 200), AutoSize = true };
private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 200), AutoSize = true };
private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 228), AutoSize = true };
private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 262), Width = 110 };
private readonly Button _unassign = new() { Text = "Unassign", Location = new Point(190, 262), Width = 110 };
private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 296), Width = 110 };
private readonly Button _close = new() { Text = "Close", Location = new Point(190, 296), Width = 80 };
private readonly CheckBox _outputToggle = new() { Text = "Send button output to the PC", Location = new Point(12, 328), AutoSize = true };
// RIO endpoint: editable so any COM name or pipe:name goes; the drop-down
// offers the app default, the machine's COM ports, and the vRIO pipe.
private readonly ComboBox _portBox = new() { Location = new Point(66, 68), Width = 234, DropDownStyle = ComboBoxStyle.DropDown };
private readonly string _defaultPortItem;
private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 96), MaximumSize = new Size(310, 0) };
private readonly TextBox _labelBox = new() { Location = new Point(70, 128), Width = 230 };
private readonly ComboBox _kindBox = new() { Location = new Point(70, 162), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 199), AutoSize = true };
private readonly ComboBox _valueCombo = new() { Location = new Point(70, 196), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 228), AutoSize = true };
private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 228), AutoSize = true };
private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 228), AutoSize = true };
private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 228), AutoSize = true };
private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 256), AutoSize = true };
private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 290), Width = 110 };
private readonly Button _unassign = new() { Text = "Unassign", Location = new Point(190, 290), Width = 110 };
private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 324), Width = 110 };
private readonly Button _close = new() { Text = "Close", Location = new Point(190, 324), Width = 80 };
private readonly CheckBox _outputToggle = new() { Text = "Send button output to the PC", Location = new Point(12, 356), AutoSize = true };
private readonly TextBox _statusBox = new()
{
Location = new Point(12, 646),
Size = new Size(306, 145),
Location = new Point(12, 674),
Size = new Size(306, 140),
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
@@ -104,13 +111,38 @@ public sealed class ProfileEditorForm : Form
/// <summary>Raised when the "send output to the PC" toggle changes (true = send).</summary>
public event Action<bool>? OutputsEnabledChanged;
public ProfileEditorForm(RioProfile profile)
/// <param name="profile">The profile to edit (mutated in place; Save persists).</param>
/// <param name="defaultEndpoint">
/// The app-wide RIO endpoint (<see cref="AppConfig.DefaultRioComPort"/>), shown
/// on the port picker's "(app default)" entry. Null just hides the value.
/// </param>
public ProfileEditorForm(RioProfile profile, string? defaultEndpoint = null)
{
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
Text = $"RIOJoy — Edit profile: {profile.Name}";
_nameBox.Text = profile.Name;
_matchBox.Text = string.Join(", ", profile.MatchExecutables);
// Endpoint suggestions: app default, the machine's COM ports, the vRIO
// pipe. Free text stays allowed — any COM name or pipe:name works.
_defaultPortItem = defaultEndpoint is null ? "(app default)" : $"(app default: {defaultEndpoint})";
_portBox.Items.Add(_defaultPortItem);
try
{
foreach (string port in System.IO.Ports.SerialPort.GetPortNames()
.Distinct().OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
_portBox.Items.Add(port);
}
catch (Exception)
{
// Enumerating ports is best-effort (registry read) — typing still works.
}
_portBox.Items.Add(RioTransportFactory.PipeScheme + "vrio");
if (string.IsNullOrWhiteSpace(profile.RioComPort))
_portBox.SelectedIndex = 0;
else
_portBox.Text = profile.RioComPort;
ClientSize = new Size(1320, 820);
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(900, 500);
@@ -162,16 +194,18 @@ public sealed class ProfileEditorForm : Form
panel.Controls.Add(_nameBox);
panel.Controls.Add(new Label { Text = "Triggers:", Location = new Point(12, 43), AutoSize = true });
panel.Controls.Add(_matchBox);
panel.Controls.Add(new Label { Text = "RIO port:", Location = new Point(12, 71), AutoSize = true });
panel.Controls.Add(_portBox);
panel.Controls.Add(_info);
panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 103), AutoSize = true });
panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 131), AutoSize = true });
panel.Controls.Add(_labelBox);
panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 137), AutoSize = true });
panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 165), AutoSize = true });
panel.Controls.Add(_kindBox);
panel.Controls.Add(_valueLabel);
panel.Controls.Add(_valueCombo);
panel.Controls.AddRange(new Control[] { _shift, _ctrl, _alt, _ext, _lit, _apply, _unassign, _save, _close, _outputToggle });
panel.Controls.Add(BuildCommandGroup());
panel.Controls.Add(new Label { Text = "RIO reply:", Location = new Point(12, 628), AutoSize = true });
panel.Controls.Add(new Label { Text = "RIO reply:", Location = new Point(12, 656), AutoSize = true });
panel.Controls.Add(_statusBox);
return panel;
@@ -180,7 +214,7 @@ public sealed class ProfileEditorForm : Form
// A button per RIO device command, fired against the live RIO via CommandRequested.
private GroupBox BuildCommandGroup()
{
var group = new GroupBox { Text = "RIO commands (live)", Location = new Point(12, 358), Size = new Size(306, 262) };
var group = new GroupBox { Text = "RIO commands (live)", Location = new Point(12, 386), Size = new Size(306, 262) };
int y = 24;
foreach ((string label, RioCommandCode code) in RioCommands)
@@ -359,6 +393,11 @@ public sealed class ProfileEditorForm : Form
.Where(s => s.Length > 0)
.ToList();
// RIO endpoint: a COM name or pipe:name (e.g. pipe:vrio); blank or the
// "(app default)" entry stores null = follow DefaultRioComPort.
string port = _portBox.Text.Trim();
_profile.RioComPort = port.Length == 0 || port == _defaultPortItem ? null : port;
ApplyToCell();
try
{
+49 -2
View File
@@ -1,3 +1,5 @@
using RioJoy.Core.Profiles;
namespace RioJoy.Tray;
internal static class Program
@@ -12,18 +14,63 @@ internal static class Program
/// Entry point. RIOJoy runs as a background tray application with no main
/// window: an ApplicationContext owns the NotifyIcon and the runtime, so the
/// message loop stays alive while the only UI is the tray icon and its menu.
///
/// <para><c>--import-profile &lt;profile.json&gt;</c> merges a single-profile
/// document (a repo's <c>profiles/*.json</c>) into this user's config and
/// exits without starting the tray. Output goes to stdout/stderr, which a
/// GUI-subsystem exe only delivers when redirected — check the exit code
/// (0 ok, 1 failed, 2 usage, 3 tray running) when scripting it.</para>
/// </summary>
[STAThread]
private static void Main()
private static int Main(string[] args)
{
if (args.Length >= 1 && string.Equals(args[0], "--import-profile", StringComparison.OrdinalIgnoreCase))
return ImportProfile(args);
using var instance = new Mutex(initiallyOwned: true, SingleInstanceMutex, out bool createdNew);
if (!createdNew)
return; // another RIOJoy is already running in this session
return 0; // another RIOJoy is already running in this session
// net48 has no source-generated ApplicationConfiguration.Initialize();
// do the equivalent setup directly.
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new TrayApplicationContext());
return 0;
}
private static int ImportProfile(string[] args)
{
if (args.Length != 2)
{
Console.Error.WriteLine("usage: RioJoy.Tray --import-profile <profile.json>");
return 2;
}
// A running tray holds the config in memory and rewrites it on its own
// saves, which would silently discard this import — refuse instead.
// (OpenExisting + catch rather than TryOpenExisting: net40 lacks the Try form.)
try
{
using Mutex running = Mutex.OpenExisting(SingleInstanceMutex);
Console.Error.WriteLine("RIOJoy is running in this session - quit it from the tray menu, import, then relaunch.");
return 3;
}
catch (WaitHandleCannotBeOpenedException)
{
// not running — proceed
}
try
{
ProfileImportResult result = ConfigStore.ImportProfile(TrayApplicationContext.ConfigPath, args[1]);
Console.WriteLine($"{(result.Replaced ? "Replaced" : "Added")} profile '{result.Name}' in {TrayApplicationContext.ConfigPath}");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"import failed: {ex.Message}");
return 1;
}
}
}
+53 -3
View File
@@ -44,10 +44,16 @@ public sealed class RioCoordinator : IDisposable
private IDisposable? _joystick;
private string? _activeProfileName;
// The user's own desktop wallpaper, captured the first time we override it with
// a cockpit wallpaper. null = we are not currently overriding (nothing to
// restore). "" is a valid captured value (the user had no wallpaper).
private string? _savedWallpaper;
public RioCoordinator(Func<AppConfig> config, Func<string, IRioTransport>? transportFactory = null)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_transportFactory = transportFactory ?? (port => new SerialPortTransport(port));
_transportFactory = transportFactory
?? (port => RioTransportFactory.Open(port, _config().RioBaudRate));
}
/// <summary>Raised (with a short status string) whenever the active state changes.</summary>
@@ -193,6 +199,13 @@ public sealed class RioCoordinator : IDisposable
IJoystickSink joystick;
string note;
IJoystickSink realJoystick = CreateJoystickSink(out string joystickNote);
#if !NET40
// Per-profile ViGEm axis routing, applied the same way the profile's
// Calibration reaches the AxisCalibrator below (null = default legacy
// routing). Also neutralizes the pad so nothing persists across a switch.
if (realJoystick is ViGEmJoystickSink vigemPad)
vigemPad.SetRouting(profile.AxisRouting);
#endif
if (!routeInput)
{
// Keyboard/mouse + joystick are gated off while editing so the RIO can
@@ -211,7 +224,11 @@ public sealed class RioCoordinator : IDisposable
}
_transport = _transportFactory(port);
_link = new RioSerialLink(_transport);
_link = new RioSerialLink(_transport, new RioSerialLinkOptions
{
AnalogPollInterval = TimeSpan.FromMilliseconds(
Math.Max(10, config.AnalogPollMs)), // floor guards a typo'd config
});
_runtime = new RioRuntime(
_link,
profile.ToInputMap(),
@@ -252,6 +269,7 @@ public sealed class RioCoordinator : IDisposable
return;
try
{
CaptureWallpaperOnce();
WallpaperApplier.Apply(profile.WallpaperPath!);
}
catch (Exception ex)
@@ -275,6 +293,7 @@ public sealed class RioCoordinator : IDisposable
new ProfileWallpaperGenerator().Generate(template, templateDir, profile.OverlayLabels, outPath);
profile.WallpaperPath = outPath;
CaptureWallpaperOnce();
WallpaperApplier.Apply(outPath);
}
catch (Exception ex)
@@ -284,6 +303,32 @@ public sealed class RioCoordinator : IDisposable
#endif
}
/// <summary>
/// Remember the user's current desktop wallpaper the first time we override it,
/// so it can be restored when we go dormant. No-op once captured (so switching
/// between cockpit profiles never records a cockpit wallpaper as the "previous").
/// </summary>
private void CaptureWallpaperOnce()
{
if (_savedWallpaper != null)
return;
try { _savedWallpaper = WallpaperApplier.GetCurrent(); }
catch { _savedWallpaper = string.Empty; } // best-effort; empty just clears on restore
}
/// <summary>
/// Put the user's pre-activation wallpaper back, if we overrode it. Called when
/// going dormant and on shutdown; best-effort and idempotent.
/// </summary>
private void RestoreWallpaper()
{
if (_savedWallpaper == null)
return; // never overrode — leave the desktop alone
try { WallpaperApplier.Restore(_savedWallpaper); }
catch { /* best-effort — never block going dormant */ }
_savedWallpaper = null;
}
private static string SafeFileName(string name)
{
foreach (char c in Path.GetInvalidFileNameChars())
@@ -294,6 +339,7 @@ public sealed class RioCoordinator : IDisposable
private void GoDormant(string status)
{
Teardown();
RestoreWallpaper(); // put the user's own desktop back
SetStatus(status);
}
@@ -325,5 +371,9 @@ public sealed class RioCoordinator : IDisposable
StatusChanged?.Invoke(status);
}
public void Dispose() => Teardown();
public void Dispose()
{
Teardown();
RestoreWallpaper(); // clean exit shouldn't leave a cockpit wallpaper behind
}
}
+10
View File
@@ -21,6 +21,16 @@
<PropertyGroup Condition="'$(TargetFramework)' == 'net40'">
<PlatformTarget>x86</PlatformTarget>
<!-- The SDK defaults AutoGenerateBindingRedirects=true for .NET Framework
exes, which makes RAR walk the dependency closure of the NuGet-resolved
Microsoft.Bcl.Async assemblies; those were compiled against Microsoft.Bcl
facades v1.5.11.0 while the graph ships v2.6.8.0, and RAR classifies the
missing 1.5.11.0 facades as unresolvable framework assemblies and drops
the references (MSB3268 → CS0234). Turning generation off skips that
walk; the redirects the XP runtime still needs are hand-authored in
app.net40.config instead. -->
<AutoGenerateBindingRedirects>false</AutoGenerateBindingRedirects>
<AppConfig>app.net40.config</AppConfig>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net40'">
+49 -16
View File
@@ -17,7 +17,8 @@ namespace RioJoy.Tray;
/// </summary>
internal sealed class TrayApplicationContext : ApplicationContext
{
private static readonly string ConfigPath =
// Internal so Program's --import-profile writes the same store the tray reads.
internal static readonly string ConfigPath =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RIOJoy", "config.json");
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
@@ -230,33 +231,65 @@ internal sealed class TrayApplicationContext : ApplicationContext
// is suppressed (no keystrokes); the editor only shows which button is pressed.
_coordinator.BeginEditorSession(profile);
var editor = new ProfileEditorForm(profile);
var editor = new ProfileEditorForm(profile, _config.DefaultRioComPort);
editor.IsNameAvailable = name => !_config.Profiles.Any(
p => !ReferenceEquals(p, profile) && string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
editor.Saved += _ => ConfigStore.Save(_config, ConfigPath);
editor.CommandRequested += cmd => _coordinator.Runtime?.Trigger(cmd);
editor.OutputsEnabledChanged += enabled => _coordinator.SetEditorOutputs(enabled);
RioRuntime? runtime = _coordinator.Runtime;
Action<int, bool>? activity = null;
if (runtime is not null)
// The editor's live wiring targets the CURRENT runtime, which is replaced
// when the session re-arms (endpoint change below) — so hook/unhook by pair.
Action? unhook = null;
void HookRuntime()
{
activity = editor.ShowLiveActivity;
runtime.ButtonActivity += activity;
RioRuntime? runtime = _coordinator.Runtime;
if (runtime is null)
{
unhook = null;
return;
}
runtime.ButtonActivity += editor.ShowLiveActivity;
runtime.AxesUpdated += editor.ShowAxes;
runtime.VersionReceived += editor.ShowVersion;
runtime.CheckReceived += editor.AddCheckStatus;
}
editor.FormClosed += (_, _) =>
{
if (runtime is not null && activity is not null)
unhook = () =>
{
runtime.ButtonActivity -= activity;
runtime.ButtonActivity -= editor.ShowLiveActivity;
runtime.AxesUpdated -= editor.ShowAxes;
runtime.VersionReceived -= editor.ShowVersion;
runtime.CheckReceived -= editor.AddCheckStatus;
};
}
bool outputsOn = false;
string armedEndpoint = profile.RioComPort ?? _config.DefaultRioComPort;
editor.Saved += _ =>
{
ConfigStore.Save(_config, ConfigPath);
// A saved port/pipe change takes effect immediately: re-arm the held
// session on the new endpoint so the live RIO buttons follow it.
string endpoint = profile.RioComPort ?? _config.DefaultRioComPort;
if (!string.Equals(endpoint, armedEndpoint, StringComparison.OrdinalIgnoreCase))
{
unhook?.Invoke();
_coordinator.BeginEditorSession(profile);
HookRuntime();
_coordinator.SetEditorOutputs(outputsOn); // new gates start closed
armedEndpoint = endpoint;
}
};
editor.CommandRequested += cmd => _coordinator.Runtime?.Trigger(cmd);
editor.OutputsEnabledChanged += enabled =>
{
outputsOn = enabled;
_coordinator.SetEditorOutputs(enabled);
};
HookRuntime();
editor.FormClosed += (_, _) =>
{
unhook?.Invoke();
_coordinator.EndEditorSession();
_watcher.Reset(); // re-sync with the foreground app (may re-activate a game)
_watcher.Poll();
+32
View File
@@ -1,4 +1,5 @@
using System.Runtime.InteropServices;
using System.Text;
namespace RioJoy.Tray;
@@ -16,8 +17,10 @@ namespace RioJoy.Tray;
public static class WallpaperApplier
{
private const int SPI_SETDESKWALLPAPER = 0x0014;
private const int SPI_GETDESKWALLPAPER = 0x0073;
private const int SPIF_UPDATEINIFILE = 0x01; // persist across logon
private const int SPIF_SENDCHANGE = 0x02; // broadcast WM_SETTINGCHANGE
private const int MaxPath = 260;
/// <summary>
/// Apply <paramref name="imagePath"/> as the desktop wallpaper. Returns true on
@@ -50,7 +53,36 @@ public static class WallpaperApplier
SPI_SETDESKWALLPAPER, 0, full, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE);
}
/// <summary>
/// Read the desktop wallpaper Windows currently has set (empty string when none
/// is set — a solid-color desktop). Capture this before <see cref="Apply"/>
/// overrides it, so <see cref="Restore"/> can put it back when going dormant.
/// </summary>
public static string GetCurrent()
{
var buffer = new StringBuilder(MaxPath);
bool ok = SystemParametersInfo(SPI_GETDESKWALLPAPER, buffer.Capacity, buffer, 0);
return ok ? buffer.ToString() : string.Empty;
}
/// <summary>
/// Restore a wallpaper previously read by <see cref="GetCurrent"/>. Unlike
/// <see cref="Apply"/> this does not require the file to exist and accepts an
/// empty path (which clears the wallpaper to the solid desktop color) — the
/// value came straight from Windows, so it is passed back verbatim. Returns the
/// API result; callers treat it as best-effort.
/// </summary>
public static bool Restore(string? previous)
{
return SystemParametersInfo(
SPI_SETDESKWALLPAPER, 0, previous ?? string.Empty, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE);
}
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni);
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SystemParametersInfo(int uAction, int uParam, StringBuilder lpvParam, int fuWinIni);
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- XP (net40) flavor only, selected via <AppConfig> in the csproj. The
Microsoft.Bcl.Async assemblies were compiled against the Microsoft.Bcl
facades v1.5.11.0, but the package graph ships v2.6.8.0; XP's CLR 4.0 has
no framework unification for these (4.5+ does), so without the redirects
the first TaskEx call throws FileLoadException. Build-time redirect
generation is off (see AutoGenerateBindingRedirects in the csproj), so the
redirects live here statically. The SDK adds the <startup> section. -->
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-2.6.8.0" newVersion="2.6.8.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Threading.Tasks" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-2.6.8.0" newVersion="2.6.8.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.IO" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-2.6.8.0" newVersion="2.6.8.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
@@ -128,11 +128,39 @@ public class AxisCalibratorTests
Assert.Equal(AxisOutputs.Center, cal.Update(Report(x: 0)).X);
}
// --- Detent regression (legacy quirk deliberately fixed) -----------------
[Fact]
public void Outputs_AreClampedToAxisRange()
public void Throttle_FirstPollAtRest_IsZero_NotPinned()
{
// The init-state throttle quirk yields a value far over range on the first
// poll at rest; the documented 0..Max clamp guards it.
Assert.Equal(AxisOutputs.Max, new AxisCalibrator().Update(Report(throttle: 0)).Z);
// Regression: the legacy Center init + detent-hold compounded through the
// ×32 rescale and pinned Z at Max on the very first poll at rest — full
// reverse on a Centered thumb route, full thrust on a UnipolarPositive one
// (Descent). Rest must read 0 with no reset and stay there.
var cal = new AxisCalibrator();
Assert.Equal(0, cal.Update(Report(throttle: 0)).Z);
Assert.Equal(0, cal.Update(Report(throttle: 0)).Z);
}
[Fact]
public void Throttle_ReleaseExactlyOntoStart_ReturnsToZero_NoSpike()
{
// Regression: a release landing exactly on the tracked start (lT == 0)
// used to hold the last moving value, which the ×32 rescale turned into a
// one-poll full-throttle spike until jitter broke it.
var cal = new AxisCalibrator();
Assert.Equal(0, cal.Update(Report(throttle: 0)).Z); // seed start at rest
Assert.Equal(16000, cal.Update(Report(throttle: -400)).Z); // pushed halfway
Assert.Equal(0, cal.Update(Report(throttle: 0)).Z); // release onto start
}
[Fact]
public void Throttle_SampleAboveRunningMax_ReadsAsRest()
{
// Samples above the tracked start re-base the auto-range (lT == 0 again)
// and must read as rest, not re-enter the legacy hold path.
var cal = new AxisCalibrator();
cal.Update(Report(throttle: -400)); // power-on with lever pushed: becomes the start
Assert.Equal(0, cal.Update(Report(throttle: -100)).Z); // above max → re-based, rest
}
}
@@ -0,0 +1,130 @@
using RioJoy.Core.Calibration;
using RioJoy.Core.Output;
using Xunit;
namespace RioJoy.Core.Tests.Output;
public class AxisRouterTests
{
// --- Default routing reproduces the legacy hardcoded sink exactly --------
[Theory]
[InlineData(JoyAxis.X, PadTarget.LeftThumbX)]
[InlineData(JoyAxis.Y, PadTarget.LeftThumbY)]
[InlineData(JoyAxis.Rx, PadTarget.RightThumbX)]
[InlineData(JoyAxis.Ry, PadTarget.RightThumbY)]
public void Default_ThumbAxes_UseLegacyCenteredMath(JoyAxis axis, PadTarget expected)
{
var config = new AxisRoutingConfig();
// Legacy ToThumb: (value - 16383) * 2, clamped to the short range.
ResolvedAxis min = AxisRouter.Resolve(config, axis, 0);
Assert.Equal(expected, min.Target);
Assert.Equal(-32766, min.Value);
Assert.Equal(0, AxisRouter.Resolve(config, axis, AxisOutputs.Center).Value);
Assert.Equal(32766, AxisRouter.Resolve(config, axis, AxisOutputs.Max).Value);
}
[Theory]
[InlineData(JoyAxis.Z, PadTarget.LeftTrigger)]
[InlineData(JoyAxis.Rz, PadTarget.RightTrigger)]
public void Default_TriggerAxes_UseLegacyTriggerMath(JoyAxis axis, PadTarget expected)
{
var config = new AxisRoutingConfig();
// Legacy ToTrigger: value * 255 / 32766, clamped to the byte range.
ResolvedAxis rest = AxisRouter.Resolve(config, axis, 0);
Assert.Equal(expected, rest.Target);
Assert.Equal(0, rest.Value);
Assert.Equal(127, AxisRouter.Resolve(config, axis, AxisOutputs.Center).Value);
Assert.Equal(255, AxisRouter.Resolve(config, axis, AxisOutputs.Max).Value);
}
[Fact]
public void Default_CenteredThumb_ClampsToShortRange()
{
var config = new AxisRoutingConfig();
// (40000 - 16383) * 2 = 47234 → clamped to short.MaxValue.
Assert.Equal(32767, AxisRouter.Resolve(config, JoyAxis.X, 40000).Value);
// (-2000 - 16383) * 2 = -36766 → clamped to short.MinValue.
Assert.Equal(-32768, AxisRouter.Resolve(config, JoyAxis.X, -2000).Value);
}
// --- UnipolarPositive: clamp(value, 0, 32767) ----------------------------
[Fact]
public void UnipolarPositive_MapsCalibratedZeroToThumbCenter()
{
var config = new AxisRoutingConfig
{
Z = new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive },
};
Assert.Equal(0, AxisRouter.Resolve(config, JoyAxis.Z, 0).Value); // detent → center
Assert.Equal(16383, AxisRouter.Resolve(config, JoyAxis.Z, 16383).Value); // half → half up
Assert.Equal(32766, AxisRouter.Resolve(config, JoyAxis.Z, AxisOutputs.Max).Value); // full → max (32766 of 32767)
Assert.Equal(0, AxisRouter.Resolve(config, JoyAxis.Z, -5).Value); // clamps below
Assert.Equal(32767, AxisRouter.Resolve(config, JoyAxis.Z, 40000).Value); // clamps above
}
[Fact]
public void UnipolarPositive_OnTriggerTarget_IsIgnored_TriggerMathApplies()
{
// Mode is meaningless for trigger targets — they keep the trigger byte math.
var config = new AxisRoutingConfig
{
Z = new AxisRoute { Target = PadTarget.LeftTrigger, Mode = AxisOutputMode.UnipolarPositive },
};
Assert.Equal(255, AxisRouter.Resolve(config, JoyAxis.Z, AxisOutputs.Max).Value);
}
// --- None: the axis is not emitted ---------------------------------------
[Fact]
public void NoneTarget_EmitsNothing()
{
var config = new AxisRoutingConfig
{
Rx = new AxisRoute { Target = PadTarget.None },
};
ResolvedAxis r = AxisRouter.Resolve(config, JoyAxis.Rx, 12345);
Assert.Equal(PadTarget.None, r.Target);
Assert.Equal(0, r.Value);
}
// --- The Descent shape ----------------------------------------------------
[Fact]
public void DescentConfig_ThrottleToRightThumbY_TriggersUntargeted()
{
// profiles/descent-d1x.json: DXX's stock GameController defaults fire on
// the LT/RT axis-buttons, so no axis may land on a trigger.
var config = new AxisRoutingConfig
{
Z = new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive },
Rz = new AxisRoute { Target = PadTarget.RightThumbX },
Rx = new AxisRoute { Target = PadTarget.None },
Ry = new AxisRoute { Target = PadTarget.None },
};
ResolvedAxis z = AxisRouter.Resolve(config, JoyAxis.Z, 16383);
Assert.Equal(PadTarget.RightThumbY, z.Target);
Assert.Equal(16383, z.Value); // unipolar: half throttle → half deflection up
ResolvedAxis rz = AxisRouter.Resolve(config, JoyAxis.Rz, AxisOutputs.Center);
Assert.Equal(PadTarget.RightThumbX, rz.Target);
Assert.Equal(0, rz.Value); // rudder stays bipolar: center → thumb 0
foreach (JoyAxis axis in new[] { JoyAxis.X, JoyAxis.Y, JoyAxis.Z, JoyAxis.Rx, JoyAxis.Ry, JoyAxis.Rz })
{
PadTarget target = AxisRouter.Resolve(config, axis, AxisOutputs.Center).Target;
Assert.NotEqual(PadTarget.LeftTrigger, target);
Assert.NotEqual(PadTarget.RightTrigger, target);
}
}
}
@@ -1,4 +1,5 @@
using RioJoy.Core.Calibration;
using RioJoy.Core.Output;
using RioJoy.Core.Profiles;
using Xunit;
@@ -45,6 +46,179 @@ public class ConfigStoreTests
Assert.Equal(new[] { "doom.exe" }, p.MatchExecutables);
}
[Fact]
public void RoundTrips_AxisRouting_WithStringEnums()
{
var config = new AppConfig
{
Profiles =
{
new RioProfile
{
Name = "Descent",
AxisRouting = new AxisRoutingConfig
{
Z = new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive },
Rz = new AxisRoute { Target = PadTarget.RightThumbX },
Rx = new AxisRoute { Target = PadTarget.None },
Ry = new AxisRoute { Target = PadTarget.None },
},
},
},
};
string json = ConfigStore.Serialize(config);
Assert.Contains("\"RightThumbY\"", json); // enums serialize as strings
Assert.Contains("\"UnipolarPositive\"", json); // (house StringEnumConverter convention)
RioProfile back = Assert.Single(ConfigStore.Deserialize(json).Profiles);
Assert.Equal(config.Profiles[0].AxisRouting, back.AxisRouting); // record value equality
Assert.Equal(PadTarget.LeftThumbX, back.AxisRouting!.X.Target); // untouched routes keep defaults
}
[Fact]
public void AxisRouting_Unset_StaysNull_AndOffJson()
{
// null = default routing; NullValueHandling.Ignore keeps it out of the JSON,
// so pre-existing profiles on disk are byte-compatible.
string json = ConfigStore.Serialize(new AppConfig { Profiles = { new RioProfile { Name = "P" } } });
Assert.DoesNotContain("AxisRouting", json);
Assert.Null(Assert.Single(ConfigStore.Deserialize(json).Profiles).AxisRouting);
}
[Fact]
public void ShippedDescentProfile_ParsesWithDescentRouting_NoTriggerTargets()
{
// Guards the shipped fixture itself: Newtonsoft's default
// MissingMemberHandling.Ignore silently drops a misspelled property or axis
// name, which would fall back to the default routing and put the throttle
// back on LeftTrigger — the stock fire button in Descent's SDL
// GameController mapping. So parse the real file, not a C#-built config.
string json = File.ReadAllText(Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json"));
// The shipped file is a single-profile document users paste into the
// config's Profiles list; wrap it so it flows through the exact
// ConfigStore serializer settings.
RioProfile p = Assert.Single(ConfigStore.Deserialize($"{{\"Profiles\":[{json}]}}").Profiles);
Assert.Equal("Descent", p.Name);
Assert.True(p.Calibration.EnableZR); // pedal-differential rudder feeds Rz
Assert.NotNull(p.AxisRouting);
AxisRoutingConfig routing = p.AxisRouting!;
Assert.Equal(new AxisRoute { Target = PadTarget.LeftThumbX }, routing.X);
Assert.Equal(new AxisRoute { Target = PadTarget.LeftThumbY }, routing.Y);
Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive }, routing.Z);
Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbX }, routing.Rz);
Assert.Equal(new AxisRoute { Target = PadTarget.None }, routing.Rx);
Assert.Equal(new AxisRoute { Target = PadTarget.None }, routing.Ry);
// No axis may resolve onto a trigger — triggers are Descent's fire buttons.
foreach (JoyAxis axis in new[] { JoyAxis.X, JoyAxis.Y, JoyAxis.Z, JoyAxis.Rx, JoyAxis.Ry, JoyAxis.Rz })
{
PadTarget target = AxisRouter.Resolve(routing, axis, AxisOutputs.Center).Target;
Assert.NotEqual(PadTarget.LeftTrigger, target);
Assert.NotEqual(PadTarget.RightTrigger, target);
}
}
[Fact]
public void ShippedDescentProfile_MatchesDxxRebirthReferenceCopy()
{
// The dxx-rebirth docs keep a reference copy of the Descent profile that
// must stay byte-identical to ours. The sibling checkout is a cabinet-
// machine convention, not a repo guarantee, so no-op quietly when the
// second repo is not checked out next to this one.
string reference = Path.GetFullPath(Path.Combine(
TestRepo.Root(), "..", "dxx-rebirth", "docs", "reference", "descent-riojoy-profile.json"));
if (!File.Exists(reference))
return;
string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json");
Assert.Equal(File.ReadAllBytes(shipped), File.ReadAllBytes(reference));
}
[Fact]
public void ImportProfile_ShippedDescentFile_IntoFreshConfig()
{
string configPath = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}", "config.json");
try
{
string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json");
ProfileImportResult result = ConfigStore.ImportProfile(configPath, shipped);
Assert.Equal("Descent", result.Name);
Assert.False(result.Replaced);
RioProfile p = Assert.Single(ConfigStore.Load(configPath).Profiles);
Assert.Equal("Descent", p.Name);
// The routing must survive the import round-trip intact — this is the
// exact payload a cabinet/bench install writes.
Assert.NotNull(p.AxisRouting);
Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive }, p.AxisRouting!.Z);
}
finally
{
string? dir = Path.GetDirectoryName(configPath);
if (dir is not null && Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ImportProfile_ReplacesSameName_PreservesEverythingElse()
{
string configPath = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}", "config.json");
try
{
ConfigStore.Save(new AppConfig
{
DefaultRioComPort = "COM7",
NeutralProfileName = "Desktop",
Profiles =
{
new RioProfile { Name = "Desktop" },
new RioProfile { Name = "descent", PlasmaGreeting = "STALE" }, // case-insensitive match
},
}, configPath);
string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json");
ProfileImportResult result = ConfigStore.ImportProfile(configPath, shipped);
Assert.True(result.Replaced);
AppConfig back = ConfigStore.Load(configPath);
Assert.Equal("COM7", back.DefaultRioComPort); // untouched settings survive
Assert.Equal("Desktop", back.NeutralProfileName);
Assert.Equal(2, back.Profiles.Count); // replaced in place, not appended
RioProfile descent = Assert.IsType<RioProfile>(back.FindProfile("Descent"));
Assert.Equal("DESCENT", descent.PlasmaGreeting); // stale profile fully overwritten
}
finally
{
string? dir = Path.GetDirectoryName(configPath);
if (dir is not null && Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ImportProfile_NamelessProfile_Throws()
{
string dir = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
string profilePath = Path.Combine(dir, "nameless.json");
File.WriteAllText(profilePath, "{\"PlasmaGreeting\":\"X\"}");
Assert.Throws<Newtonsoft.Json.JsonSerializationException>(
() => ConfigStore.ImportProfile(Path.Combine(dir, "config.json"), profilePath));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void Load_MissingFile_ReturnsDefaults()
{
@@ -15,6 +15,12 @@
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<!-- Channels 8.0's net462 binary references Unsafe 6.0.0.0 but doesn't
declare it, so the graph would otherwise resolve 4.5.3 (assembly
4.0.4.1) and the test host dies with FileLoadException. Pin the
assembly Channels was built against; app.config redirects the older
requests (System.Memory / Tasks.Extensions) up to it. -->
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.0.0" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -0,0 +1,275 @@
using System.IO.Pipes;
using RioJoy.Core.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Serial;
public class PipeFramingTests
{
[Fact]
public void EncodeData_WrapsInOneFrame()
{
byte[] framed = PipeFraming.EncodeData(new byte[] { 0x81, 0x01, 0xFC });
Assert.Equal(new byte[] { 0x00, 0x03, 0x81, 0x01, 0xFC }, framed);
}
[Fact]
public void EncodeData_ChunksPayloadsOver255()
{
var data = new byte[300];
for (int i = 0; i < data.Length; i++) data[i] = (byte)i;
byte[] framed = PipeFraming.EncodeData(data);
// 255-byte frame + 45-byte frame, payloads contiguous.
Assert.Equal(300 + 4, framed.Length);
Assert.Equal(PipeFraming.DataType, framed[0]);
Assert.Equal(255, framed[1]);
Assert.Equal(PipeFraming.DataType, framed[2 + 255]);
Assert.Equal(45, framed[2 + 255 + 1]);
Assert.Equal(data.Take(255), framed.Skip(2).Take(255));
Assert.Equal(data.Skip(255), framed.Skip(2 + 255 + 2));
}
[Fact]
public void EncodeData_EmptyYieldsNoFrames()
{
Assert.Empty(PipeFraming.EncodeData(new byte[0]));
}
[Fact]
public void Decoder_SurvivesAnySplitAcrossReads()
{
var decoder = new PipeFrameDecoder();
var data = new List<byte>();
var lines = new List<byte>();
decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
decoder.Lines += lines.Add;
// A lines frame, a 2-byte data frame, a 1-byte data frame — fed one byte at a time.
byte[] stream = { 0x01, 0x03, 0x00, 0x02, 0x81, 0x01, 0x00, 0x01, 0xFC };
foreach (byte b in stream)
Assert.True(decoder.Feed(new[] { b }, 1));
Assert.Equal(new byte[] { 0x03 }, lines);
Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, data);
}
[Fact]
public void Decoder_UnknownFrameType_Poisons()
{
var decoder = new PipeFrameDecoder();
Assert.False(decoder.Feed(new byte[] { 0xFF }, 1));
Assert.Contains("0xFF", decoder.Violation);
Assert.False(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3)); // stays poisoned
decoder.Reset();
Assert.True(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
Assert.Null(decoder.Violation);
}
[Fact]
public void Decoder_ZeroLengthDataFrame_Poisons()
{
var decoder = new PipeFrameDecoder();
Assert.False(decoder.Feed(new byte[] { 0x00, 0x00 }, 2));
Assert.Contains("zero-length", decoder.Violation);
}
}
public class RioTransportFactoryTests
{
[Theory]
[InlineData("pipe:vrio", true)]
[InlineData("PIPE:vrio", true)]
[InlineData("COM3", false)]
[InlineData("com1", false)]
public void IsPipe_RecognizesTheScheme(string endpoint, bool expected)
{
Assert.Equal(expected, RioTransportFactory.IsPipe(endpoint));
}
}
/// <summary>
/// Integration tests against a real in-process <see cref="NamedPipeServerStream"/>
/// standing in for vRIO (which serves \\.\pipe\vrio the same way).
/// </summary>
public class NamedPipeTransportTests : IDisposable
{
private readonly string _pipeName = $"riojoy-test-{Guid.NewGuid():N}";
private readonly NamedPipeServerStream _server;
public NamedPipeTransportTests()
{
_server = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
}
public void Dispose()
{
try { _server.Dispose(); }
catch (IOException) { }
}
/// <summary>Accept the client and construct the transport concurrently (the ctor blocks through the DTR pulse).</summary>
private async Task<NamedPipeTransport> ConnectAsync()
{
Task accept = _server.WaitForConnectionAsync();
Task<NamedPipeTransport> client = Task.Run(() => new NamedPipeTransport(_pipeName));
await accept.WithTimeout();
return await client.WithTimeout();
}
private async Task<byte[]> ServerReadAsync(int count)
{
var buffer = new byte[count];
int fill = 0;
while (fill < count)
{
int n = await _server.ReadAsync(buffer, fill, count - fill).WithTimeout();
Assert.True(n > 0, "server: pipe closed before the expected bytes arrived");
fill += n;
}
return buffer;
}
[Fact]
public async Task Connect_SendsTheDtrResetPulse()
{
using NamedPipeTransport transport = await ConnectAsync();
// Assert (DTR high), hold, release — the in-band SETDTR/CLRDTR port.
Assert.Equal(new byte[] { 0x01, PipeFraming.LineDtr }, await ServerReadAsync(2));
Assert.Equal(new byte[] { 0x01, 0x00 }, await ServerReadAsync(2));
}
[Fact]
public async Task ReadAsync_UnwrapsDataFrames_AndSwallowsLinesFrames()
{
using NamedPipeTransport transport = await ConnectAsync();
// vRIO's on-connect lines frame (board present), then a data frame —
// split at an awkward boundary to exercise the incremental decoder.
// Issued unawaited: the pipe's 0-byte buffers make a write complete
// only when the peer reads it (see the transport's ctor comment).
Task w1 = _server.WriteAsync(new byte[] { 0x01, 0x03, 0x00, 0x03, 0x81 }, 0, 5);
Task w2 = _server.WriteAsync(new byte[] { 0x01, 0xFC }, 0, 2);
var buffer = new byte[16];
var got = new List<byte>();
while (got.Count < 3)
{
int n = await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout();
Assert.True(n > 0);
got.AddRange(buffer.Take(n));
}
Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, got);
Assert.Equal((byte)(PipeFraming.LineDtr | PipeFraming.LineRts), transport.PeerLines);
await w1.WithTimeout();
await w2.WithTimeout();
}
[Fact]
public async Task ReadAsync_SmallBuffer_DrainsAcrossCalls()
{
using NamedPipeTransport transport = await ConnectAsync();
Task write = _server.WriteAsync(new byte[] { 0x00, 0x04, 0x10, 0x20, 0x30, 0x40 }, 0, 6);
var buffer = new byte[3];
Assert.Equal(3, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
Assert.Equal(new byte[] { 0x10, 0x20, 0x30 }, buffer);
Assert.Equal(1, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
Assert.Equal(0x40, buffer[0]);
await write.WithTimeout();
}
[Fact]
public async Task WriteAsync_WrapsInADataFrame()
{
using NamedPipeTransport transport = await ConnectAsync();
await ServerReadAsync(4); // discard the DTR pulse frames
// Unawaited until the server drains it (0-byte pipe buffers).
Task write = transport.WriteAsync(new byte[] { 0x81, 0x01 }, CancellationToken.None);
Assert.Equal(new byte[] { 0x00, 0x02, 0x81, 0x01 }, await ServerReadAsync(4));
await write.WithTimeout();
}
[Fact]
public async Task ServerGone_ReadReturnsZero()
{
using NamedPipeTransport transport = await ConnectAsync();
_server.Dispose();
var buffer = new byte[16];
Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
// And it keeps saying closed rather than reading a dead pipe.
Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
}
[Fact]
public async Task ProtocolViolation_ReadReturnsZero()
{
using NamedPipeTransport transport = await ConnectAsync();
Task write = _server.WriteAsync(new byte[] { 0xFF }, 0, 1);
var buffer = new byte[16];
Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
Assert.Contains("0xFF", transport.Violation);
await write.WithTimeout();
}
[Fact]
public async Task Dispose_UnblocksAPendingRead()
{
NamedPipeTransport transport = await ConnectAsync();
Task<int> pending = transport.ReadAsync(new byte[16], CancellationToken.None);
Assert.False(pending.IsCompleted);
transport.Dispose();
Assert.Equal(0, await pending.WithTimeout());
}
[Fact]
public void NoServer_ConstructorTimesOutWithAClearMessage()
{
var ex = Assert.Throws<TimeoutException>(() =>
new NamedPipeTransport($"riojoy-nobody-{Guid.NewGuid():N}", TimeSpan.FromMilliseconds(200)));
Assert.Contains("vRIO", ex.Message);
}
[Fact]
public async Task Factory_OpensPipeEndpoints()
{
Task accept = _server.WaitForConnectionAsync();
Task<IRioTransport> client = Task.Run(() => RioTransportFactory.Open($"pipe:{_pipeName}"));
await accept.WithTimeout();
using IRioTransport transport = await client.WithTimeout();
Assert.Equal($@"\\.\pipe\{_pipeName}", transport.Description);
}
}
internal static class TaskTimeoutExtensions
{
/// <summary>Await with a test-failure deadline, so a hung pipe fails fast instead of stalling the run.</summary>
public static async Task<T> WithTimeout<T>(this Task<T> task, int seconds = 5)
{
Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(seconds))));
return await task;
}
public static async Task WithTimeout(this Task task, int seconds = 5)
{
Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(seconds))));
await task;
}
}
@@ -141,4 +141,211 @@ public class RioSerialLinkTests
cts.Cancel();
await run;
}
// --- stop-and-wait command retransmit (CommandRetransmitLimit) -----------
private static RioSerialLinkOptions StopAndWait(int limit = 2, int timeoutMs = 150) => new()
{
AutoPollAnalog = false,
CommandRetransmitLimit = limit,
AckTimeout = TimeSpan.FromMilliseconds(timeoutMs),
};
[Fact]
public async Task Nak_RetransmitsTheSamePacket()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait());
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
Task send = link.SendAsync(lamp);
Assert.Equal(lamp, await fake.NextWriteAsync());
fake.Enqueue((byte)RioControl.Nak); // board: "that arrived corrupt"
Assert.Equal(lamp, await fake.NextWriteAsync()); // exact same packet again
fake.Enqueue((byte)RioControl.Ack); // second copy lands
await send.WaitAsync(Timeout);
Assert.Equal(1, link.Retransmits);
cts.Cancel();
await run;
}
[Fact]
public async Task Timeout_RetransmitsWhenBoardNeverResponds()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait(limit: 2, timeoutMs: 80));
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
Task send = link.SendAsync(lamp);
// Original + 2 timeout-driven retries, then the command is dropped.
Assert.Equal(lamp, await fake.NextWriteAsync());
Assert.Equal(lamp, await fake.NextWriteAsync());
Assert.Equal(lamp, await fake.NextWriteAsync());
await send.WaitAsync(Timeout); // completes (dropped), doesn't hang
Assert.Equal(2, link.Retransmits);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
cts.Cancel();
await run;
}
[Fact]
public async Task Ack_CompletesWithoutRetransmit()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait());
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
await fake.NextWriteAsync();
fake.Enqueue((byte)RioControl.Ack);
await send.WaitAsync(Timeout);
Assert.Equal(0, link.Retransmits);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
cts.Cancel();
await run;
}
[Fact]
public async Task Commands_AreSerialized_SecondWaitsForFirst()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait());
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
byte[] first = PacketBuilder.LampRequest(0x05, 0x02);
byte[] second = PacketBuilder.LampRequest(0x06, 0x01);
Task sendA = link.SendAsync(first);
Task sendB = link.SendAsync(second);
// Only the first is on the wire until it resolves.
Assert.Equal(first, await fake.NextWriteAsync());
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(100)));
fake.Enqueue((byte)RioControl.Ack);
Assert.Equal(second, await fake.NextWriteAsync()); // now B goes out
fake.Enqueue((byte)RioControl.Ack);
await sendA.WaitAsync(Timeout);
await sendB.WaitAsync(Timeout);
cts.Cancel();
await run;
}
[Fact]
public async Task RequestReply_ResolvesTheInFlightCommand_WithoutExplicitAck()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait());
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// An analog request answered by the reply alone (no ACK byte) must not
// burn the ACK timeout — the reply proves the request landed.
Task send = link.RequestAnalogAsync();
await fake.NextWriteAsync();
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
await send.WaitAsync(TimeSpan.FromMilliseconds(120)); // well under AckTimeout
Assert.Equal(0, link.Retransmits);
cts.Cancel();
await run;
}
[Fact]
public async Task AnalogReply_DoesNotResolve_PendingLampCommand()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait(limit: 1, timeoutMs: 250));
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// Lamp command in flight; a straggler analog reply (from an earlier
// poll) arrives. It must NOT confirm the lamp — the lamp keeps waiting
// and is resolved only by its own ACK.
Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
await fake.NextWriteAsync();
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
await Task.Delay(100);
Assert.False(send.IsCompleted, "stray analog reply must not resolve a lamp command");
fake.Enqueue((byte)RioControl.Ack);
await send.WaitAsync(Timeout);
Assert.Equal(0, link.Retransmits);
cts.Cancel();
await run;
}
[Fact]
public async Task UnsolicitedNak_WithNothingPending_IsIgnored()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait());
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// Inbound button packet -> our 1-byte ACK reply (bypasses the command
// gate). A stray NAK afterwards must not resend anything.
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
Assert.Equal(new byte[] { (byte)RioControl.Ack }, await fake.NextWriteAsync());
fake.Enqueue((byte)RioControl.Nak);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
Assert.Equal(0, link.Retransmits);
cts.Cancel();
await run;
}
[Fact]
public async Task Retransmit_Disabled_IsFireAndForget()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait(limit: 0));
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// Completes immediately (no ACK wait), and a NAK triggers nothing.
await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02)).WaitAsync(Timeout);
await fake.NextWriteAsync();
fake.Enqueue((byte)RioControl.Nak);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
Assert.Equal(0, link.Retransmits);
cts.Cancel();
await run;
}
}
+11
View File
@@ -18,4 +18,15 @@ internal static class TaskTestExtensions
cts.Cancel(); // stop the delay timer
return await task.ConfigureAwait(false);
}
/// <summary>Non-generic counterpart of <see cref="WaitAsync{T}"/>.</summary>
public static async Task WaitAsync(this Task task, TimeSpan timeout)
{
using var cts = new CancellationTokenSource();
Task completed = await Task.WhenAny(task, Task.Delay(timeout, cts.Token)).ConfigureAwait(false);
if (completed != task)
throw new TimeoutException($"Task did not complete within {timeout}.");
cts.Cancel();
await task.ConfigureAwait(false);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copied to RioJoy.Core.Tests.dll.config so the VSTest host binds the single
deployed System.Runtime.CompilerServices.Unsafe 6.0.0.0 (pinned in the
csproj — System.Threading.Channels 8.0's net462 binary requires it) for
the older versions System.Memory / System.Threading.Tasks.Extensions
request. Static rather than auto-generated so the suite doesn't depend on
RAR's redirect suggestions, which vary with the installed SDK/reference
assemblies. -->
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
+303
View File
@@ -0,0 +1,303 @@
using System.Diagnostics;
using RioJoy.Core.Serial;
namespace RioSerialMonitor;
/// <summary>
/// E0-threshold firmware verification (TeslaRel410 restoration/rio-firmware `--e0thresh` images).
///
/// Two phases against a DTR-reset board (counters zeroed, display F0000000):
/// - Phase A (gate hold): analog requests answered NAK-then-ACK — NAKing
/// the COMPLETED reply frame forces exactly one immediate retransmit,
/// ACKing that retransmit ends the cycle. Each event increments the
/// teardown counter $3185, invoking the display renderer sub-threshold.
/// The _e0t5 gate must keep the display F0000000; stock firmware paints
/// E0... at the first event. (Responses must follow the complete frame:
/// the board arms its reply-await only when the frame finishes sending,
/// and once the first retry timeout lapses the cycle runs blind.)
/// - Phase B (flip): analog requests we never ACK — the board runs the
/// full retry cycle (5 retransmits), gives up ($3184++, RESTART $FE)
/// and tears down ($3185++). $3185 crosses the threshold; the display
/// must flip to the E0 readout the tool predicts from observed traffic.
///
/// Bench 2026-07-19 (9600 e0t5 chip): PASS — display held F0000000 through
/// the handshake and all four phase-A teardowns, flipped exactly at the
/// 5th teardown to E0000105 ($3187=00, $3184=01, $3185=05).
///
/// dotnet run --project tools/RioSerialMonitor -- --e0test [port]
/// [--baud rate] [--hold n] [--flip n]
///
/// No --baud probes 9600, 31250, 62500 (DTR-resetting each try). HANDS OFF
/// buttons/keypads during the run — an unACKed button packet shifts the
/// counters (the tool folds observed traffic into its predictions either
/// way). Exit: 0 = ran, 2 = no board reply on any baud.
/// </summary>
internal static class E0Test
{
private const int Threshold = 5;
private enum AckMode { Never, Immediate, NakThenAck }
public static async Task<int> RunAsync(string[] args)
{
string port = "COM1";
int baud = 0, holdEvents = 4, flipEvents = 1;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--e0test": break;
case "--baud" when i + 1 < args.Length && int.TryParse(args[i + 1], out int b): baud = b; i++; break;
case "--hold" when i + 1 < args.Length && int.TryParse(args[i + 1], out int h) && h >= 0: holdEvents = h; i++; break;
case "--flip" when i + 1 < args.Length && int.TryParse(args[i + 1], out int f) && f > 0: flipEvents = f; i++; break;
default:
if (!args[i].StartsWith("--")) port = args[i];
break;
}
}
var sw = Stopwatch.StartNew();
object gate = new();
var buffer = new List<byte>();
int cursor = 0;
// Reader-thread ACK automation (latency matters: the board's ACK wait
// at 9600 is ~4ms/retry, so ACKs must not wait for a polling loop).
AckMode ackMode = AckMode.Never;
int replyByte = 0x87; // frame command byte the current phase expects
int frameLen = 12; // full frame: cmd + payload + checksum
int repliesSeen = 0; // completed frames of replyByte since phase reset
int frameCountdown = 0; // >0: inside a reply frame, bytes remaining
bool ackSent = false;
IRioTransport? transport = null;
Task? reader = null;
void PhaseReset(AckMode mode, int expectReply, int expectLen)
{
lock (gate)
{
ackMode = mode;
replyByte = expectReply;
frameLen = expectLen;
repliesSeen = 0;
frameCountdown = 0;
ackSent = false;
cursor = buffer.Count;
}
}
int[] bauds = baud != 0 ? new[] { baud } : new[] { 9600, 31250, 62500 };
(int Major, int Minor)? version = null;
foreach (int tryBaud in bauds)
{
Console.WriteLine($"[{sw.Elapsed.TotalSeconds,6:F2}s] probing {port} @ {tryBaud} (DTR reset, ~2s boot wait)...");
try { transport = RioTransportFactory.Open(port, tryBaud); }
catch (Exception ex)
{
Console.WriteLine($" FAILED to open {port}: {ex.GetType().Name}: {ex.Message}");
return 2;
}
lock (gate) { buffer.Clear(); cursor = 0; }
IRioTransport t = transport;
reader = Task.Run(async () =>
{
var tmp = new byte[256];
try
{
while (true)
{
int n = await t.ReadAsync(tmp, CancellationToken.None);
if (n <= 0) break;
byte? respond = null;
lock (gate)
{
for (int i = 0; i < n; i++)
{
buffer.Add(tmp[i]);
// Frame tracking: respond only once the FULL reply
// frame is on the wire the board arms its
// reply-await when the frame finishes sending; a
// response mid-frame is ignored.
if (frameCountdown > 0)
{
if (--frameCountdown > 0) continue;
repliesSeen++;
if (ackMode == AckMode.Immediate)
{
// ACK every completed frame (check replies
// arrive as several 0x85 frames in a row).
ackSent = true;
respond = 0xFC;
}
else if (ackMode == AckMode.NakThenAck && repliesSeen <= 2)
{
// frame 1 -> NAK (board resends, $3184++),
// frame 2 -> ACK (cycle ends cleanly).
respond = repliesSeen == 1 ? (byte)0xFD : (byte)0xFC;
ackSent = repliesSeen == 2;
}
}
else if (tmp[i] == replyByte)
{
frameCountdown = frameLen - 1;
}
}
}
if (respond is byte r)
await t.WriteAsync(new byte[] { r }, CancellationToken.None);
}
}
catch { /* port closed */ }
});
await Task.Delay(2000); // boot: counters cleared, display F0000000
PhaseReset(AckMode.Immediate, 0x86, 4); // version reply: cmd+2+ck
await t.WriteAsync(new byte[] { 0x81, 0x01 }, CancellationToken.None);
byte[] got = await Collect(1200, b => IndexOf(b, 0x86) >= 0);
int vi = IndexOf(got, 0x86);
if (vi >= 0)
{
if (vi + 2 < got.Length) version = (got[vi + 1], got[vi + 2]);
baud = tryBaud;
break;
}
Console.WriteLine($" no version reply at {tryBaud}");
transport.Dispose();
transport = null;
if (reader is not null) await reader;
}
if (transport is null)
{
Console.WriteLine(" no board reply on any baud — is the board powered / the right chip in?");
return 2;
}
// Handshake bookkeeping: fold any retries/give-up into the prediction.
// Bench-calibrated counter semantics (2026-07-19, confirmed on the
// display): $3184 = timeout-retry cycles (give-ups), $3185 = reply
// teardowns, i.e. any cycle that needed at least one retransmission.
await Task.Delay(300);
byte[] hs = Drain();
int cycles = SeenAll(0xFE) ? 1 : 0; // $3184 prediction
int teardowns = CountAll(0x86) > 1 ? 1 : 0; // $3185 prediction
Console.WriteLine();
Console.WriteLine($"== E0-threshold test :: {transport.Description}, firmware {version?.Major}.{version?.Minor} ==");
Console.WriteLine($" threshold {Threshold}; phase A: {holdEvents} sub-threshold event(s), phase B: {flipEvents} give-up event(s)");
if (cycles > 0 || teardowns > 0)
Console.WriteLine($" note: handshake was not clean (teardowns={teardowns}, give-ups={cycles}); predictions include it.");
Console.WriteLine(" HANDS OFF buttons/keypads for the whole run.");
Console.WriteLine();
string expected = Expected(cycles, teardowns);
Console.WriteLine($">>> WATCH THE 8-DIGIT DISPLAY. It should read {expected} right now. <<<");
await Task.Delay(4000);
int eventNo = 0;
bool ok = true;
for (int k = 0; k < holdEvents && ok; k++)
ok = await RunEvent(AckMode.NakThenAck, "hold");
for (int k = 0; k < flipEvents && ok; k++)
ok = await RunEvent(AckMode.Never, "flip");
if (ok)
{
// Edit-6 acceptance (rc1/RIO4.3 chips): a CheckRequest self-test
// must repaint the display afterwards — F0000000 when healthy,
// or the E0 readout when counters are over threshold (they are
// now, after the flip). Chips without edit 6 show 04000000 here.
PhaseReset(AckMode.Immediate, 0x85, 4); // check replies: cmd+2+ck
await transport.WriteAsync(new byte[] { 0x80, 0x00 }, CancellationToken.None);
byte[] chk = await Collect(4000, _ => false); // self-test ~1-2s, lamps flash
int checkReplies = Count(chk, 0x85);
Console.WriteLine($"[check ] CheckRequest sent: self-test ran (lamps flash), {checkReplies} status frame(s).");
Console.WriteLine($" Edit-6 chips repaint, then re-render the over-threshold E0 readout.");
Console.WriteLine($" >>> DISPLAY SHOULD NOW READ: {Expected(cycles, teardowns)} — small drift in either pair is normal");
Console.WriteLine($" (late ACKs among the status frames start timeout retries: $3184 +1 each;");
Console.WriteLine($" 04000000 here = no edit 6 on this chip) <<<");
await Task.Delay(3000);
}
Console.WriteLine();
Console.WriteLine("== done ==");
Console.WriteLine($" final predicted counters: $3184=${cycles:X2} $3185=${teardowns:X2} -> display {Expected(cycles, teardowns)}");
Console.WriteLine(" PASS = display held F0000000 through every sub-threshold line above and");
Console.WriteLine(" matched the E0 predictions after the threshold crossing.");
Console.WriteLine(" (Stock firmware flips at the very first retry.) DTR reset restores F0.");
transport.Dispose();
if (reader is not null) await reader;
return 0;
async Task<bool> RunEvent(AckMode mode, string phase)
{
eventNo++;
PhaseReset(mode, 0x87, 12); // analog reply: cmd+10+ck
await transport!.WriteAsync(new byte[] { 0x82, 0x02 }, CancellationToken.None);
byte[] bytes = mode == AckMode.Never
? await Collect(2500, b => IndexOf(b, 0xFE) >= 0)
: await Collect(800, _ => false);
await Task.Delay(250); // let the cycle finish either way
bytes = Concat(bytes, Drain());
int transmissions = Count(bytes, 0x87);
bool sawRestart = IndexOf(bytes, 0xFE) >= 0;
if (transmissions == 0)
{
Console.WriteLine($"[event {eventNo}] NO analog reply ({bytes.Length} bytes) — aborting.");
return false;
}
if (transmissions > 1) teardowns++; // $3185: imperfect cycle
if (sawRestart && mode == AckMode.Never) cycles++; // $3184: give-up
Console.WriteLine($"[event {eventNo}] {phase}: reply x{transmissions} " +
$"({transmissions - 1} retransmit(s)), give-up cycle={mode == AckMode.Never} " +
$"-> $3184=${cycles:X2} $3185=${teardowns:X2}");
Console.WriteLine($" >>> DISPLAY SHOULD NOW READ: {Expected(cycles, teardowns)} <<<");
await Task.Delay(4000);
return true;
}
string Expected(int cy, int td) =>
cy >= Threshold || td >= Threshold ? $"E000{cy:X2}{td:X2}" : "F0000000";
byte[] Drain()
{
lock (gate)
{
byte[] r = buffer.Skip(cursor).ToArray();
cursor = buffer.Count;
return r;
}
}
int CountAll(byte v) { lock (gate) return buffer.Count(x => x == v); }
bool SeenAll(byte v) { lock (gate) return buffer.Contains(v); }
async Task<byte[]> Collect(int ms, Func<byte[], bool> done)
{
var end = sw.Elapsed + TimeSpan.FromMilliseconds(ms);
var all = new List<byte>();
while (sw.Elapsed < end)
{
all.AddRange(Drain());
if (done(all.ToArray())) break;
await Task.Delay(15);
}
all.AddRange(Drain());
return all.ToArray();
}
static int IndexOf(byte[] a, byte v) => Array.IndexOf(a, v);
static int Count(byte[] a, byte v) => a.Count(x => x == v);
static byte[] Concat(byte[] a, byte[] b) => a.Concat(b).ToArray();
}
}
+400
View File
@@ -0,0 +1,400 @@
using System.Diagnostics;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
using RioJoy.Core.Serial;
namespace RioSerialMonitor;
/// <summary>
/// Instrumented firmware mash test (the RIO_TAP protocol, mechanized) for
/// validating the RIOv4.2 reply-latch wedge patch — see the firmware
/// analysis in the TeslaRel410 repo (restoration/rio-firmware/RIOv4_2-ANALYSIS.md).
///
/// Runs the live link with the app's own &gt;5s reset-recovery DISABLED (so a
/// board wedge stays observable instead of being revived by our watchdog),
/// echoes lamps on every press (lamp writes colliding with analog replies are
/// the wedge trigger), and records:
/// - an inter-analog-reply gap histogram + the longest gaps with timestamps
/// (gap timing uses ANY AnalogReply packet, valid or 0xFE-sentinel — a
/// sentinel still proves the reply path is alive);
/// - WEDGE events: analog silent past the threshold while button traffic
/// continues; on resume, whether it self-recovered or was revived by a
/// button press (the known unpatched revival mechanism);
/// - the board's own RestartCount/AbandonCount/FullBufferCount before and
/// after (CheckReply), with the delta;
/// - a fixed-layout summary block, teed to a log file, so runs on the
/// original vs patched chip diff cleanly.
///
/// Usage:
/// dotnet run --project tools/RioSerialMonitor -- --mash [port] [seconds]
/// [--label name] [--no-lamps] [--wedge seconds]
/// Defaults: COM1, 300 s, label "unlabeled", lamps on, wedge threshold 2 s.
/// Exit: 0 = ran with no wedge, 1 = wedge detected, 2 = could not open port.
/// </summary>
internal static class MashTest
{
private sealed class WedgeEvent
{
public TimeSpan Start;
public TimeSpan Duration;
public int ButtonEventsDuring;
public bool RevivedByButton;
public bool Unresolved; // run ended while still wedged
}
public static async Task<int> RunAsync(string[] args)
{
// --- args ------------------------------------------------------------
string port = "COM1";
int seconds = 300;
string label = "unlabeled";
bool lampEcho = true;
double wedgeSec = 2.0;
bool selftest = false;
int baud = 9600;
int pollMs = 55; // legacy cadence; FastRIO can sustain much faster
var positional = new List<string>();
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--mash": break;
case "--no-lamps": lampEcho = false; break;
case "--selftest": selftest = true; break;
case "--label" when i + 1 < args.Length: label = args[++i]; break;
case "--wedge" when i + 1 < args.Length && double.TryParse(args[i + 1], out double w):
wedgeSec = w; i++; break;
case "--baud" when i + 1 < args.Length && int.TryParse(args[i + 1], out int b):
baud = b; i++; break;
case "--poll" when i + 1 < args.Length && int.TryParse(args[i + 1], out int p) && p > 0:
pollMs = p; i++; break;
default: positional.Add(args[i]); break;
}
}
if (positional.Count > 0) port = positional[0];
if (positional.Count > 1 && int.TryParse(positional[1], out int s)) seconds = s;
if (selftest)
{
// Scripted board (SelftestTransport): short fixed run, expect exactly
// one button-revived wedge and a +4/+1 counter delta → exit 1.
port = "selftest";
seconds = 10;
if (label == "unlabeled") label = "selftest";
}
string logPath = $"riomash-{label}-{DateTime.Now:yyyyMMdd-HHmmss}.log";
using var logFile = new StreamWriter(logPath) { AutoFlush = true };
var gate = new object();
var sw = Stopwatch.StartNew();
void Log(string msg)
{
lock (gate)
{
string line = $"[{sw.Elapsed.TotalSeconds,7:F2}s] {msg}";
Console.WriteLine(line);
logFile.WriteLine(line);
}
}
void Raw(string msg)
{
lock (gate) { Console.WriteLine(msg); logFile.WriteLine(msg); }
}
Raw($"== RIO mash test :: {port} @ {baud} 8N1, {seconds}s, chip label '{label}' ==");
Raw($" lamp echo {(lampEcho ? "ON (drives reply/lamp collisions)" : "OFF")}, " +
$"wedge threshold {wedgeSec:F1}s, poll {pollMs}ms, app auto-recovery DISABLED");
Raw($" log: {Path.GetFullPath(logPath)}");
IRioTransport transport;
try
{
transport = selftest ? new SelftestTransport() : RioTransportFactory.Open(port, baud);
}
catch (Exception ex)
{
Raw($" FAILED to open {port}: {ex.GetType().Name}: {ex.Message}");
return 2;
}
// Recovery timeout effectively infinite: a wedge must be OURS to observe,
// not silently repaired by the link's legacy >5s general-reset rule.
var link = new RioSerialLink(transport, new RioSerialLinkOptions
{
AnalogRecoveryTimeout = TimeSpan.FromDays(365),
AnalogPollInterval = TimeSpan.FromMilliseconds(pollMs),
});
// --- shared state (guarded by 'gate') ---------------------------------
int presses = 0, releases = 0, framing = 0, naks = 0, sentinels = 0;
long analogReplies = 0;
TimeSpan lastAnalog = TimeSpan.Zero;
TimeSpan lastButton = TimeSpan.MinValue;
// Gap histogram buckets (upper bounds in ms; last = overflow).
double[] bucketMs = { 100, 250, 500, 1000, 2000, 5000, double.MaxValue };
string[] bucketNames = { "<100ms", "100-250ms", "250-500ms", "0.5-1s", "1-2s", "2-5s", ">5s" };
long[] buckets = new long[bucketMs.Length];
var longestGaps = new List<(TimeSpan At, double Ms)>(); // keep top 10
bool inWedge = false;
var currentWedge = default(WedgeEvent);
var wedges = new List<WedgeEvent>();
bool statusCollecting = false;
var statusItems = new List<CheckStatus>();
VersionInfo? version = null;
void NoteGap(double ms, TimeSpan at)
{
for (int i = 0; i < bucketMs.Length; i++)
{
if (ms <= bucketMs[i]) { buckets[i]++; break; }
}
longestGaps.Add((at, ms));
if (longestGaps.Count > 10)
{
longestGaps.Sort((a, b) => b.Ms.CompareTo(a.Ms));
longestGaps.RemoveAt(10);
}
}
link.PacketReceived += p =>
{
lock (gate)
{
switch (p.Command)
{
case RioCommand.AnalogReply:
{
TimeSpan now = sw.Elapsed;
analogReplies++;
foreach (byte b in p.Payload)
{
if (b == (byte)RioControl.Restart) { sentinels++; break; }
}
if (lastAnalog != TimeSpan.Zero)
NoteGap((now - lastAnalog).TotalMilliseconds, lastAnalog);
if (inWedge && currentWedge is not null)
{
currentWedge.Duration = now - currentWedge.Start;
currentWedge.RevivedByButton =
lastButton > currentWedge.Start &&
(now - lastButton) < TimeSpan.FromMilliseconds(300);
wedges.Add(currentWedge);
Log($"*** WEDGE ENDED after {currentWedge.Duration.TotalSeconds:F2}s — " +
(currentWedge.RevivedByButton
? "revived by a button press (unpatched-firmware signature)"
: "self-recovered (patched-firmware expectation)") +
$", {currentWedge.ButtonEventsDuring} button events during");
inWedge = false;
currentWedge = null;
}
lastAnalog = now;
break;
}
case RioCommand.ButtonPressed:
case RioCommand.KeyPressed:
presses++;
lastButton = sw.Elapsed;
if (inWedge && currentWedge is not null) currentWedge.ButtonEventsDuring++;
// Lamp echo on lamp buttons (keypads have none): the lamp
// number is the RIO address, not the raw button code.
if (lampEcho && p.Command == RioCommand.ButtonPressed &&
p.Payload[0] < RioAddress.ButtonCount)
_ = link.SetLampAsync((byte)RioAddress.FromButton(p.Payload[0]), RioLampState.SolidBright);
break;
case RioCommand.ButtonReleased:
case RioCommand.KeyReleased:
releases++;
lastButton = sw.Elapsed;
if (inWedge && currentWedge is not null) currentWedge.ButtonEventsDuring++;
if (lampEcho && p.Command == RioCommand.ButtonReleased &&
p.Payload[0] < RioAddress.ButtonCount)
_ = link.SetLampAsync((byte)RioAddress.FromButton(p.Payload[0]), RioLampState.SolidDim);
break;
}
}
};
link.VersionReceived += v => { lock (gate) version = v; };
link.CheckReceived += c => { lock (gate) { if (statusCollecting) statusItems.Add(c); } };
link.FramingError += () => { lock (gate) framing++; };
link.ControlReceived += b => { if ((RioControl)b == RioControl.Nak) lock (gate) naks++; };
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// Ctrl+C = "my fingers hurt": end the run NOW but still snapshot the
// counters and print the full summary, instead of dying summary-less
// (and leaving an orphan holding the port, as an interrupted run did).
bool stopRequested = false;
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true; // we shut down ourselves
stopRequested = true;
};
// --- counters BEFORE ---------------------------------------------------
await Task.Delay(500);
Dictionary<RioStatusType, int> before = await SnapshotStatusAsync();
Raw($" firmware: {(version is null ? "(no version reply!)" : version.ToString())}");
Raw($" counters before: {FormatCounters(before)}");
Raw("");
Raw(">>> MASH NOW: two hands, 8 lamp buttons, as fast as you can. <<<");
Raw($">>> Test runs {seconds}s. A wedge alarm will beep + banner. <<<");
Raw(">>> Ctrl+C ends the run early and still prints the summary. <<<");
Raw("");
// --- wedge watchdog + progress ticker ----------------------------------
TimeSpan runEnd = sw.Elapsed + TimeSpan.FromSeconds(seconds);
TimeSpan nextProgress = sw.Elapsed + TimeSpan.FromSeconds(30);
while (sw.Elapsed < runEnd)
{
if (stopRequested)
{
seconds = (int)sw.Elapsed.TotalSeconds; // summary reflects reality
Log("operator stop (Ctrl+C) — ending run, snapshotting counters...");
break;
}
await Task.Delay(100);
lock (gate)
{
if (!inWedge && lastAnalog != TimeSpan.Zero &&
(sw.Elapsed - lastAnalog).TotalSeconds > wedgeSec)
{
inWedge = true;
currentWedge = new WedgeEvent { Start = lastAnalog };
Log($"*** WEDGE: no analog reply for >{wedgeSec:F1}s (last at {lastAnalog.TotalSeconds:F2}s). " +
"Keep pressing buttons — do NOT power cycle. ***");
try { Console.Beep(880, 400); } catch { /* no console beep available */ }
}
}
if (sw.Elapsed >= nextProgress)
{
lock (gate)
{
double rate = presses / Math.Max(1.0, sw.Elapsed.TotalMinutes);
double maxGap = longestGaps.Count > 0 ? longestGaps.Max(g => g.Ms) : 0;
Log($"progress: presses={presses} ({rate:F0}/min) analog={analogReplies} " +
$"maxGap={maxGap / 1000:F2}s wedges={wedges.Count}{(inWedge ? " [WEDGED NOW]" : "")}");
}
nextProgress = sw.Elapsed + TimeSpan.FromSeconds(30);
}
}
// Close out an unresolved wedge.
lock (gate)
{
if (inWedge && currentWedge is not null)
{
currentWedge.Duration = sw.Elapsed - currentWedge.Start;
currentWedge.Unresolved = true;
wedges.Add(currentWedge);
Log($"*** RUN ENDED WHILE WEDGED ({currentWedge.Duration.TotalSeconds:F1}s and counting) ***");
}
}
// --- counters AFTER ------------------------------------------------------
Dictionary<RioStatusType, int> after = await SnapshotStatusAsync();
// Dispose the transport BEFORE awaiting the link: on a wedged (silent)
// board the receive loop sits in a pending serial read that ignores
// cancellation — closing the port is what unblocks it. Awaiting first
// hangs forever and eats the summary (seen on the baseline chip run).
cts.Cancel();
transport.Dispose();
try { await run; } catch { /* shutdown */ }
// --- summary (fixed layout for diffing runs) ------------------------------
double mins = Math.Max(sw.Elapsed.TotalMinutes, 0.001);
long expectedPolls = (long)(seconds * 1000.0 / pollMs);
bool anyWedge = wedges.Count > 0;
bool endedWedged = wedges.Any(w => w.Unresolved);
bool anyButtonRevival = wedges.Any(w => w.RevivedByButton);
Raw("");
Raw($"== MASH SUMMARY [{label}] ==");
Raw($"run : {seconds}s on {port}, lamps {(lampEcho ? "on" : "off")}, wedge threshold {wedgeSec:F1}s");
Raw($"firmware : {(version is null ? "(no reply)" : version.ToString())}");
Raw($"presses/releases : {presses} / {releases} ({presses / mins:F0} presses/min)");
Raw($"analog replies : {analogReplies} (~{expectedPolls} poll slots; {100.0 * analogReplies / Math.Max(1, expectedPolls):F1}%), sentinels {sentinels}");
Raw($"framing / NAK : {framing} / {naks} (command resends NAK/timeout: {link.Retransmits})");
Raw("gap histogram : " + string.Join(" ", bucketNames.Select((n, i) => $"{n}:{buckets[i]}")));
Raw("longest gaps : " + (longestGaps.Count == 0
? "(none)"
: string.Join(", ", longestGaps.OrderByDescending(g => g.Ms).Take(10)
.Select(g => $"{g.Ms / 1000:F2}s@{g.At.TotalSeconds:F1}s"))));
Raw($"wedge events : {wedges.Count}" + (wedges.Count == 0 ? "" :
" — " + string.Join("; ", wedges.Select(w =>
$"{w.Duration.TotalSeconds:F1}s@{w.Start.TotalSeconds:F1}s " +
(w.Unresolved ? "UNRESOLVED" : w.RevivedByButton ? "button-revived" : "self-recovered")))));
Raw($"counters before : {FormatCounters(before)}");
Raw($"counters after : {FormatCounters(after)}");
Raw($"counter delta : {FormatDelta(before, after)}");
string verdict =
endedWedged ? "FAIL — board wedged and never recovered (power cycle it now)" :
anyButtonRevival ? "FAIL — wedge required a button press to revive (unpatched behavior)" :
anyWedge ? "MARGINAL — wedge(s) occurred but self-recovered; compare durations to baseline" :
"PASS — no wedge; compare gap histogram + counter delta to baseline";
Raw($"verdict : {verdict}");
Raw($"log file : {Path.GetFullPath(logPath)}");
return anyWedge ? 1 : 0;
// --- helpers ---------------------------------------------------------
async Task<Dictionary<RioStatusType, int>> SnapshotStatusAsync()
{
// Two attempts: the first request can lose a race with the board's
// post-DTR boot (port open pulses DTR = board reset), or get eaten
// by a reply collision — seen on the 31250 bench run.
for (int attempt = 0; ; attempt++)
{
lock (gate) { statusItems.Clear(); statusCollecting = true; }
try
{
await link.RequestVersionAsync();
await link.RequestCheckAsync();
}
catch { /* port trouble surfaces as an empty snapshot */ }
await Task.Delay(1200);
lock (gate)
{
statusCollecting = false;
// Counters arrive one item per type; keep the last value per type.
var map = new Dictionary<RioStatusType, int>();
foreach (CheckStatus item in statusItems)
map[item.Type] = item.Number;
if (map.Count > 0 || attempt == 1)
return map;
}
}
}
static string FormatCounters(Dictionary<RioStatusType, int> c) =>
c.Count == 0
? "(no status reply — reply path dead?)"
: string.Join(" ", new[] { RioStatusType.RestartCount, RioStatusType.AbandonCount, RioStatusType.FullBufferCount }
.Select(t => $"{t}={(c.TryGetValue(t, out int v) ? v.ToString() : "-")}"))
+ (c.ContainsKey(RioStatusType.BoardBad) || c.ContainsKey(RioStatusType.LampBad)
? " [board/lamp faults reported!]" : "");
static string FormatDelta(Dictionary<RioStatusType, int> b, Dictionary<RioStatusType, int> a)
{
if (b.Count == 0 || a.Count == 0) return "(incomplete — a snapshot got no reply)";
return string.Join(" ", new[] { RioStatusType.RestartCount, RioStatusType.AbandonCount, RioStatusType.FullBufferCount }
.Select(t =>
{
bool hb = b.TryGetValue(t, out int vb), ha = a.TryGetValue(t, out int va);
// 7-bit payload counters can wrap at 128.
return hb && ha ? $"{t}=+{(va - vb + 128) % 128}" : $"{t}=?";
}));
}
}
}
+31 -8
View File
@@ -8,11 +8,32 @@ using RioJoy.Core.Serial;
// log every event (buttons, keypad, axis, version/check, control bytes, framing).
// It flashes all lamps once to prove the PC -> RIO output path, then echoes a lamp
// on each button press so a physical press lights up.
// dotnet run --project tools/RioSerialMonitor -- [port] [seconds]
// Exit: 0 = ran, 2 = could not open the port.
// dotnet run --project tools/RioSerialMonitor -- [port] [seconds] [--baud rate]
// [port] is a COM name or a pipe endpoint: pipe:vrio connects to the vRIO
// emulator's \\.\pipe\vrio (no com0com pair; vRIO must have its pipe open).
// Firmware wedge-patch validation (RIO_TAP mash test, see MashTest.cs):
// dotnet run --project tools/RioSerialMonitor -- --mash [port] [seconds]
// [--label baseline|patched] [--no-lamps] [--wedge seconds] [--baud rate]
// --baud: 31250 for the retuned FastRIO firmware (TeslaRel410 restoration/rio-firmware, make_patch.py --baud31250).
// Exit: 0 = ran, 2 = could not open the port (mash: 1 = wedge detected).
string port = args.Length > 0 ? args[0] : "COM1";
int seconds = args.Length > 1 && int.TryParse(args[1], out int s) ? s : 30;
// E0-threshold firmware verification (TeslaRel410 restoration/rio-firmware --e0thresh images, see E0Test.cs):
// dotnet run --project tools/RioSerialMonitor -- --e0test [port] [--baud rate] [--events n]
if (args.Contains("--e0test"))
return await RioSerialMonitor.E0Test.RunAsync(args);
if (args.Contains("--mash"))
return await RioSerialMonitor.MashTest.RunAsync(args);
int baud = 9600;
var positional = new List<string>();
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "--baud" && i + 1 < args.Length && int.TryParse(args[i + 1], out int b)) { baud = b; i++; }
else positional.Add(args[i]);
}
string port = positional.Count > 0 ? positional[0] : "COM1";
int seconds = positional.Count > 1 && int.TryParse(positional[1], out int s) ? s : 30;
Dictionary<int, string> groupOf = CockpitPanel.Buttons().ToDictionary(b => b.Address, b => b.Group.Title);
string Name(int addr) => groupOf.TryGetValue(addr, out string? g) ? $"0x{addr:X2} ({g})" : $"0x{addr:X2}";
@@ -24,12 +45,12 @@ void Log(string msg)
lock (gate) Console.WriteLine($"[{sw.Elapsed.TotalSeconds,6:F2}s] {msg}");
}
Console.WriteLine($"== RIO serial monitor :: {port} @ 9600 8N1 for {seconds}s ==");
Console.WriteLine($"== RIO serial monitor :: {port} @ {baud} 8N1 for {seconds}s ==");
SerialPortTransport transport;
IRioTransport transport;
try
{
transport = new SerialPortTransport(port);
transport = RioTransportFactory.Open(port, baud);
}
catch (Exception ex)
{
@@ -134,9 +155,11 @@ Log(">>> NOW: press the MFD buttons, press keypad keys, and move the axis <<<");
Log(">>> a pressed button should light up <<<");
await Task.Delay(TimeSpan.FromSeconds(seconds));
// Transport first: a silent board leaves the receive loop in a pending read
// that only the port close unblocks (net48 ReadAsync ignores cancellation).
cts.Cancel();
try { await run; } catch { /* shutdown */ }
transport.Dispose();
try { await run; } catch { /* shutdown */ }
Console.WriteLine();
Console.WriteLine("== summary ==");
+108
View File
@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using RioJoy.Core.Protocol;
using RioJoy.Core.Serial;
namespace RioSerialMonitor;
/// <summary>
/// Scripted in-memory RIO for <c>--mash --selftest</c>: proves the mash-test
/// instrumentation (wedge alarm, revival classification, counter delta) works
/// before it is trusted to judge firmware at the cabinet.
///
/// Timeline (seconds from open):
/// 0.0 4.0 analog replies every 50 ms (healthy)
/// 4.0 6.7 analog SILENT (the wedge; alarm must fire at ~6.0 with the
/// default 2 s threshold) while the port stays open
/// 6.5 / 7.0 ButtonPressed / ButtonReleased 0x05 (masher still mashing)
/// 6.7 end analog resumes 200 ms after the button — the tool must
/// classify the wedge as "button-revived" (unpatched signature)
/// CheckRequest is answered with RestartCount 3 / FullBufferCount 1 on the
/// first ask and 7 / 2 afterwards, so the counter delta must read +4 / +1.
/// Expected run outcome: 1 wedge event, verdict FAIL (button-revived), exit 1.
/// </summary>
internal sealed class SelftestTransport : IRioTransport
{
private readonly ConcurrentQueue<byte[]> _rx = new();
private readonly SemaphoreSlim _rxReady = new(0);
private readonly CancellationTokenSource _cts = new();
private readonly Stopwatch _clock = Stopwatch.StartNew();
private int _checkCalls;
public SelftestTransport() => _ = ProduceAsync(_cts.Token);
public string Description => "selftest (scripted in-memory RIO)";
public async Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token);
await _rxReady.WaitAsync(linked.Token).ConfigureAwait(false);
if (!_rx.TryDequeue(out byte[]? chunk))
return 0;
Array.Copy(chunk, buffer, chunk.Length);
return chunk.Length;
}
public Task WriteAsync(byte[] data, CancellationToken cancellationToken)
{
// Answer the requests the test sends; swallow analog polls + lamp writes.
if (data.Length > 0)
{
if (data[0] == (byte)RioCommand.VersionRequest)
{
Enqueue(PacketBuilder.Build(RioCommand.VersionReply, new byte[] { 4, 2 }));
}
else if (data[0] == (byte)RioCommand.CheckRequest)
{
bool first = Interlocked.Increment(ref _checkCalls) == 1;
Enqueue(PacketBuilder.Build(RioCommand.CheckReply,
new[] { (byte)RioStatusType.RestartCount, first ? (byte)3 : (byte)7 }));
Enqueue(PacketBuilder.Build(RioCommand.CheckReply,
new[] { (byte)RioStatusType.AbandonCount, (byte)0 }));
Enqueue(PacketBuilder.Build(RioCommand.CheckReply,
new[] { (byte)RioStatusType.FullBufferCount, first ? (byte)1 : (byte)2 }));
}
}
return Task.CompletedTask;
}
private async Task ProduceAsync(CancellationToken ct)
{
bool pressSent = false, releaseSent = false;
var analogPayload = new byte[10]; // all axes zero — valid sample
while (!ct.IsCancellationRequested)
{
await Task.Delay(50, ct).ConfigureAwait(false);
double t = _clock.Elapsed.TotalSeconds;
bool inGap = t >= 4.0 && t < 6.7;
if (!inGap)
Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, analogPayload));
if (!pressSent && t >= 6.5)
{
pressSent = true;
Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
}
if (!releaseSent && t >= 7.0)
{
releaseSent = true;
Enqueue(PacketBuilder.Build(RioCommand.ButtonReleased, new byte[] { 0x05 }));
}
}
}
private void Enqueue(byte[] packet)
{
_rx.Enqueue(packet);
_rxReady.Release();
}
public void Dispose()
{
_cts.Cancel();
_rxReady.Release(); // wake a pending read so the loop can wind down
_cts.Dispose();
}
}