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
CydandClaude Fable 5 2ecb617c09 Phase 8D: universal deployment package (XP + Win10/11, two entry points)
One RIOJoy-<ver>.zip now deploys on both OS generations (~75 MB with the
offline XP prerequisites):
- Layout: RIOJoy\app (net48 x64) + RIOJoy\app-xp (net40 x86) +
  RIOJoy\vendor (ViGEmBus; vendor\xp: .NET 4.0 offline installer +
  KB2468871, both signature-verified; RioGamepadXP driver slots in when
  8B lands - build warns/skips until then).
- RIOJoy\install-core.bat: shared OS-detecting install logic (ver ->
  5.1 = XP); pure cmd.exe on the XP path, delegates to install-rio.ps1
  on 10/11. Exports RIOJOY_APPDIR for shortcut creation.
- postinstall.bat (TeslaConsole, unattended): elevates, runs the core,
  then deletes install.bat + README.txt so C:\games stays clean.
- install.bat (standalone computers): same core + Start Menu/desktop
  shortcuts via make-shortcut.vbs (XP-safe); keeps the README.
- README.txt at the zip root explains which entry point to run.
- deploy *.ps1 re-encoded UTF-8-with-BOM: Windows PowerShell read the
  BOM-less files as ANSI, where an em-dash byte parses as a curly quote
  and breaks string parsing (bit install-rio.ps1 in dry-run testing).

Verified: zip layout correct; extracted package dispatch-tested on
Win10 non-elevated (OS detect -> modern route -> admin guard, system
untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:53:43 -05:00
CydandClaude Fable 5 63af7bde01 Phase 8C: RioJoy.Tray multi-targets net48 (x64) + net40 (x86, Windows XP)
The XP flavor keeps the full runtime AND the mapping editor (decided:
editor everywhere). Gated per flavor:
- Wallpaper maker + WallpaperCanvas + generation (SkiaSharp) are
  net48-only; the XP coordinator applies the profile's pre-rendered
  wallpaper instead, and WallpaperApplier converts PNG->BMP on net40
  (XP SPI_SETDESKWALLPAPER accepts only BMP).
- Joystick chain: ViGEm branch compiled out on net40 (HID feeder stays,
  ready for RioGamepadXP.sys); the editor button picker offers the full
  96 HID buttons on net40 vs the 11 named Xbox buttons on net48.

Verified: net40/x86 exe boots on Win10; the editor renders offline in a
32-bit host with a Button-50 joystick binding resolving correctly and
live gauges working (screenshot harness).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:45:34 -05:00
CydandClaude Fable 5 1ff0b16015 Phase 8A (2/2): RioJoy.Core multi-targets net48 + net40 (Windows XP flavor)
- TargetFrameworks net48;net40. net48 keeps x64 + ViGEm + System.IO.Ports
  package; net40 adds Microsoft.Bcl.Async + System.ValueTuple and uses the
  in-box SerialPort.
- Compat/TaskCompat bridges Task.Run/Delay/WhenAny/WhenAll (TaskEx on
  net40) and SemaphoreSlim.WaitAsync (net40 blocks briefly - trivial at
  9600 baud).
- IReadOnlyList/IReadOnlyDictionary -> IList/IDictionary throughout
  (net40 predates the IReadOnly* interfaces and the Bcl backport cannot
  make arrays implement them).
- HashCode.Combine replaced with a manual combine (Bcl.HashCode has no
  net40 build); Marshal.SizeOf<T> -> typeof form; ViGEmJoystickSink
  gated #if !NET40. HidFeederJoystickSink stays on both flavors - it
  will drive RioGamepadXP.sys on XP via the same contract.

Both TFMs build; 275 tests green on net48.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:41:58 -05:00
CydandClaude Fable 5 3b2af7b79a Phase 8A (1/2): de-Span the serial layer, swap JSON to Newtonsoft
Prepares RioJoy.Core for the net40 (Windows XP) target, which has no
System.Memory, ValueTask, or System.Text.Json:
- IRioTransport and the whole protocol/framing layer now use byte[] +
  Task (RioPacket.Payload, PacketParser/Builder, RioChecksum, replies,
  AnalogReport, RioHidReport). At 9600 baud Span bought nothing; the
  SerialPortTransport bridge copies disappear entirely.
- ConfigStore/OverlayTemplateStore switch to Newtonsoft 13 with the
  same conventions (indented, PascalCase, string enums, null-skipping);
  verified against the real STJ-written config.json and regions.json
  (load + round-trip). System.Memory and System.Text.Json packages
  dropped.

275 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:36:19 -05:00
CydandClaude Fable 5 02abfa8a14 Phase 8D: root README.txt + postinstall.bat cleans the C:\games root
The universal archive gains a root README.txt aimed at standalone
installers (which entry point to run, supported OSes, offline prereqs).
postinstall.bat (the TeslaConsole hook) deletes install.bat and that
README as its final step so cabinet deploys leave C:\games clean;
install.bat keeps the README as the standalone machine's docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:27:41 -05:00
CydandClaude Fable 5 a9de26eff6 Phase 8: final decisions — editor everywhere, universal archive, dual installers
- Mapping editor ships in all instances, XP included (wallpaper maker
  stays modern-only with SkiaSharp).
- One dist zip deploys on both XP and Win10/11: app (net48 x64) +
  app-xp (net40 x86) + vendor prereqs incl. offline .NET 4.0/KB2468871
  redistributables and RioGamepadXP.sys.
- Two entry points: postinstall.bat (TeslaConsole/launcher, unattended)
  and install.bat (freestanding, adds shortcuts). OS-detected via ver;
  pure cmd on the XP path.

All Phase 8 open decisions are now resolved; ready to implement 8A.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:22:20 -05:00
CydandClaude Fable 5 6ad1abc919 Phase 8 decision: no vJoy — XP gets RioGamepadXP.sys, our own HID minidriver
vJoy/PPJoy are unmaintained, so third-party virtual joysticks are out.
The XP joystick becomes a thin WDM HID minidriver (vhidmini shape,
WDK 7.1.0, x86, no signing required on XP) exposing the same Public.h
IOCTL + 25-byte report contract as the modern RioGamepad.sys, so
HidFeederJoystickSink drives both. Precedent: FASA's tasgame.sys was an
XP HID minidriver. Staged: XP app ships keyboard/lamps/plasma first,
driver as milestone 2. Win10/11 chain unchanged (ViGEm preferred).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:14:23 -05:00
CydandClaude Fable 5 ae7a5d21ee Phase 8 decision: Xbox 360 pad (ViGEm) stays preferred on Win10/11
vJoy is fallback-only on modern Windows and the primary backend on XP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:12:06 -05:00
CydandClaude Fable 5 39d79e1fb1 Plan Phase 8: Windows XP compatibility (net40/x86 + vJoy dual-target)
One codebase, two flavors: Win10/11 stays net48/x64 with ViGEm/RioGamepad
and remains the authoring platform; XP SP3 gets a net40/x86 runtime with
vJoy (full 96-button fidelity), pre-rendered wallpapers (PNG converted to
BMP at apply), Newtonsoft JSON shared by both flavors, Bcl.Async + small
compat shims, and a .bat-only installer. Grounded in a source audit:
about 20 Span/Memory uses in the 9600-baud serial path, 6 modern Task
calls, 1 HashCode.Combine, System.Text.Json in two stores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:55:38 -05:00
CydandClaude Fable 5 2ac3188528 Fix pedal gauges frozen under ZR mix: feed L/R from pre-mix pedal readouts
The editor's L/R gauges read Rx/Ry, but EnableZR (on in real profiles)
folds the pedals into Rz and pins Rx/Ry to center, so the bars never
moved with the physical pedals. AxisCalibrator now exposes
LeftPedalOutput/RightPedalOutput (the calibrated pre-mix positions) and
RioRuntime.AxesUpdated carries an AxisReadout (the six virtual axes +
both pedals); the strip draws L/R from the pedal readouts, Z/Rz/X/Y
from the axes. With ZR off the readouts equal Rx/Ry, so nothing
changes there. Verified by screenshot: Rx/Ry centered while L=75% and
R=25% render correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:22:05 -05:00
103 changed files with 4289 additions and 27154 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.
+59
View File
@@ -0,0 +1,59 @@
RIOJoy - cockpit interface for the VWE pods
============================================
This package installs RIOJoy, the program that connects the cockpit's RIO
hardware (joystick, throttle, pedals, 96 lighted buttons, plasma display)
to games that don't know about the cockpit. It works on two kinds of
Windows:
* Windows 10 / 11 (64-bit) - full app, appears to games as an Xbox 360
controller (ViGEmBus driver, included)
* Windows XP SP3 (32-bit) - full runtime + mapping editor; all
prerequisites are included for offline
install (.NET 4.0 + its async update)
The installer detects which Windows it is running on and installs the
right pieces automatically. Everything needed is inside this package -
no internet connection is required.
WHICH FILE DO I RUN?
--------------------
install.bat <-- RUN THIS on a normal, standalone computer.
Right-click -> "Run as administrator" (on XP, log
in as an Administrator user first). It installs
the prerequisites and puts RIOJoy shortcuts in the
Start Menu and on the desktop. RIOJoy does NOT
auto-start with Windows - launch it from the
shortcut when you want the cockpit active.
postinstall.bat -- Used by the arcade cabinet's launcher software
(TeslaConsole). It runs automatically after the
cabinet extracts this package; you do not need to
run it by hand. (It also deletes install.bat and
this README so the cabinet's games folder stays
clean.)
WHAT GETS INSTALLED
-------------------
RIOJoy\app\ the program for Windows 10/11
RIOJoy\app-xp\ the program for Windows XP
RIOJoy\vendor\ third-party prerequisites (ViGEmBus for 10/11;
.NET 4.0 + KB2468871 + the RioGamepadXP joystick
driver for XP)
After installing, start RIOJoy and look for its icon in the system tray
(near the clock). Right-click the icon to pick a profile, edit button
mappings, or send commands to the cockpit hardware. Full deployment
notes for cabinet operators are in RIOJoy\README-DEPLOY.txt.
REMOVING RIOJOY
---------------
Run RIOJoy\pre-uninstall.bat as administrator, then delete the folder
you extracted. On a standalone computer also delete the RIOJoy shortcuts
from the Start Menu and desktop.
+57 -12
View File
@@ -1,8 +1,11 @@
<#
<#
.SYNOPSIS
Build the RIOJoy cockpit deployment zip: publish the tray app self-contained,
bundle the signed ViGEmBus installer + the post-install scripts, and zip it for
TeslaConsole. The zip extracts directly into a single directory (C:\games\RIOJOY).
Build the UNIVERSAL RIOJoy deployment zip (PLAN.md §8D): one archive that
deploys on Windows 10/11 (net48 x64, ViGEmBus) AND Windows XP SP3 (net40
x86, offline prerequisites). Two install entry points sit at the zip root:
postinstall.bat (TeslaConsole, unattended, cleans the root after itself)
and install.bat (standalone computers, adds shortcuts). README.txt at the
root explains it to a human installer.
.PARAMETER VigemInstaller
Path to the signed ViGEmBus installer to bundle. If omitted, looks in
@@ -11,6 +14,12 @@
Where to write the zip (default: dist, relative to the repo root).
.PARAMETER Configuration
Build configuration (default: Release).
.NOTES
XP offline prerequisites are picked up from deploy\vendor\xp\ when present
(dotNetFx40_Full_x86_x64.exe, NDP40-KB2468871-v2-x86.exe, and the
RioGamepadXP driver once Phase 8B lands); missing ones produce warnings,
not failures, so modern-only builds still work.
#>
param(
[string]$VigemInstaller,
@@ -47,14 +56,23 @@ try {
$pkgDir = Join-Path $staging 'RIOJoy'
New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
# 1. Publish the tray app into riojoy\app (net48, framework-dependent — relies on
# the in-box .NET Framework 4.8 present on every Windows 10/11 machine).
# 1. Publish the tray app into RIOJoy\app (net48 x64, framework-dependent — relies
# on the in-box .NET Framework 4.8 present on every Windows 10/11 machine).
$appOut = Join-Path $pkgDir 'app'
Write-Host "Publishing RioJoy.Tray ($Configuration, net48 framework-dependent)..."
Write-Host "Publishing RioJoy.Tray ($Configuration, net48 x64 framework-dependent)..."
& dotnet publish (Join-Path $repo 'src\RioJoy.Tray\RioJoy.Tray.csproj') `
-c $Configuration -p:DebugType=none `
-c $Configuration -f net48 -p:DebugType=none `
-o $appOut | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish (net48) failed.' }
# 1a. Publish the Windows XP flavor into RIOJoy\app-xp (net40 x86,
# framework-dependent on the bundled .NET 4.0).
$appXpOut = Join-Path $pkgDir 'app-xp'
Write-Host "Publishing RioJoy.Tray ($Configuration, net40 x86 for Windows XP)..."
& dotnet publish (Join-Path $repo 'src\RioJoy.Tray\RioJoy.Tray.csproj') `
-c $Configuration -f net40 -p:DebugType=none `
-o $appXpOut | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish (net40) failed.' }
# 1b. Trim native SkiaSharp variants the x64 pod never uses. SkiaSharp's net48
# loader finds the native in either the app root or an arch subfolder (both
@@ -71,19 +89,46 @@ try {
Get-ChildItem $appOut -Filter '*.dylib' -ErrorAction SilentlyContinue | Remove-Item -Force
Get-ChildItem $appOut -Filter '*.so' -ErrorAction SilentlyContinue | Remove-Item -Force
# 2. Bundle the signed ViGEmBus installer into riojoy\vendor.
# 2. Bundle the signed ViGEmBus installer into RIOJoy\vendor, and the XP
# offline prerequisites into RIOJoy\vendor\xp (warn if absent).
$vendorOut = Join-Path $pkgDir 'vendor'
New-Item -ItemType Directory -Force -Path $vendorOut | Out-Null
Copy-Item $VigemInstaller $vendorOut
# 3. Install/uninstall scripts + readme + version stamp inside RIOJoy\;
# postinstall.bat at root, pre-uninstall.bat inside the payload folder.
$vendorXpSrc = Join-Path $PSScriptRoot 'vendor\xp'
$vendorXpOut = Join-Path $vendorOut 'xp'
New-Item -ItemType Directory -Force -Path $vendorXpOut | Out-Null
$xpWanted = @(
@{ 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 = 'devcon.exe'; What = 'devcon (creates the root-enumerated driver devnode on XP)' }
)
foreach ($item in $xpWanted) {
$src = Join-Path $vendorXpSrc $item.Name
if (Test-Path $src) {
Copy-Item $src $vendorXpOut
Write-Host "XP vendor: $($item.Name)"
} else {
Write-Warning "XP vendor missing: $($item.Name) - $($item.What). XP installs will warn/skip."
}
}
# 3. Install/uninstall scripts + readmes + version stamp. Payload scripts in
# RIOJoy\; the two entry points (postinstall.bat, install.bat) and the
# human README at the zip root (postinstall.bat deletes install.bat +
# README.txt after a cabinet deploy to keep C:\games clean).
Copy-Item (Join-Path $PSScriptRoot 'install-rio.ps1') $pkgDir
Copy-Item (Join-Path $PSScriptRoot 'uninstall-rio.ps1') $pkgDir
Copy-Item (Join-Path $PSScriptRoot 'pre-uninstall.bat') $pkgDir
Copy-Item (Join-Path $PSScriptRoot 'install-core.bat') $pkgDir
Copy-Item (Join-Path $PSScriptRoot 'make-shortcut.vbs') $pkgDir
Copy-Item (Join-Path $PSScriptRoot 'README-DEPLOY.txt') $pkgDir
Set-Content -Path (Join-Path $pkgDir 'VERSION.txt') -Value "RIOJoy $version" -Encoding utf8
Copy-Item (Join-Path $PSScriptRoot 'postinstall.bat') $staging
Copy-Item (Join-Path $PSScriptRoot 'install.bat') $staging
Copy-Item (Join-Path $PSScriptRoot 'README.txt') $staging
# 4. Zip the package contents (so it extracts straight into C:\games\RIOJOY).
$outDirFull = if ([IO.Path]::IsPathRooted($OutDir)) { $OutDir } else { Join-Path $repo $OutDir }
+75
View File
@@ -0,0 +1,75 @@
@echo off
rem ===========================================================================
rem RIOJoy shared install core - called by postinstall.bat (cabinet /
rem TeslaConsole) and install.bat (freestanding). NOT run directly.
rem Detects the OS and installs the matching flavor's prerequisites:
rem Windows XP (5.1) -> .NET 4.0 + KB2468871 from RIOJoy\vendor\xp,
rem RioGamepadXP driver INF when bundled;
rem app = RIOJoy\app-xp (net40 x86)
rem Windows 10/11 -> RIOJoy\install-rio.ps1 (ViGEmBus etc.);
rem app = RIOJoy\app (net48 x64)
rem Pure cmd.exe on the XP path (XP has no in-box PowerShell). Idempotent.
rem Sets RIOJOY_APPDIR for the caller (shortcut creation).
rem ===========================================================================
setlocal EnableExtensions
set CORE_RC=0
set PKGROOT=%~dp0
rem PKGROOT = ...\RIOJoy\ (this file lives inside the payload folder)
ver | findstr /C:"Version 5.1" >nul
if %errorlevel% equ 0 goto xp
rem --- Windows 10/11 ---------------------------------------------------------
echo Detected modern Windows - installing the net48 x64 flavor prerequisites.
powershell -NoProfile -ExecutionPolicy Bypass -File "%PKGROOT%install-rio.ps1"
set CORE_RC=%errorlevel%
endlocal & set "RIOJOY_APPDIR=%~dp0app" & exit /b %CORE_RC%
:xp
rem --- Windows XP SP3 ---------------------------------------------------------
echo Detected Windows XP - installing the net40 x86 flavor prerequisites.
rem .NET Framework 4.0 (XP's ceiling; required by the app).
reg query "HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" /v Install 2>nul | findstr /C:"0x1" >nul
if %errorlevel% equ 0 (
echo .NET Framework 4.0 already installed.
) else (
if exist "%PKGROOT%vendor\xp\dotNetFx40_Full_x86_x64.exe" (
echo Installing .NET Framework 4.0 - this takes several minutes...
"%PKGROOT%vendor\xp\dotNetFx40_Full_x86_x64.exe" /q /norestart
if errorlevel 3011 echo .NET install requests a reboot - reboot before first use.
) else (
echo ERROR: vendor\xp\dotNetFx40_Full_x86_x64.exe is missing from the package.
set CORE_RC=1
goto xpdone
)
)
rem KB2468871 - required by the async runtime (Microsoft.Bcl.Async).
rem The update no-ops quickly when already applied, so run unconditionally.
if exist "%PKGROOT%vendor\xp\NDP40-KB2468871-v2-x86.exe" (
echo Applying .NET 4.0 update KB2468871...
"%PKGROOT%vendor\xp\NDP40-KB2468871-v2-x86.exe" /q /norestart
) else (
echo WARNING: vendor\xp\NDP40-KB2468871-v2-x86.exe not bundled - the app
echo will not run without KB2468871. Install it before first use.
)
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" (
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 - joystick output is
echo unavailable on XP; keyboard/mouse, lamps and plasma still work.
)
:xpdone
endlocal & set "RIOJOY_APPDIR=%~dp0app-xp" & exit /b %CORE_RC%
+1 -1
View File
@@ -1,4 +1,4 @@
<#
<#
.SYNOPSIS
RIOJoy cockpit post-install. Installs the signed ViGEmBus virtual-controller
driver (if absent). Must run elevated (postinstall.bat elevates for you).
+54
View File
@@ -0,0 +1,54 @@
@echo off
rem ===========================================================================
rem RIOJoy standalone install entry point - for a computer WITHOUT the
rem TeslaConsole launcher. Extract the whole zip somewhere permanent (e.g.
rem C:\games), then run this as administrator. Installs the prerequisites
rem for this Windows version (XP or 10/11) via the shared install core and
rem creates Start Menu + desktop shortcuts to the right flavor of the app.
rem Nothing auto-starts: launch RIOJoy from the shortcut when you want it.
rem See README.txt for details.
rem ===========================================================================
setlocal
title RIOJoy install
rem --- elevate if we are not already administrator --------------------------
net session >nul 2>&1
if %errorlevel% neq 0 (
rem "if errorlevel" evaluates at run time (a %var% here would be stale).
ver | findstr /C:"Version 5.1" >nul
if not errorlevel 1 (
echo This installer must run as an Administrator user.
pause
exit /b 1
)
echo Requesting administrator privileges...
powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
exit /b 0
)
cd /d "%~dp0"
call "%~dp0RIOJoy\install-core.bat"
set RC=%errorlevel%
if %RC% neq 0 goto done
rem --- shortcuts (Start Menu + desktop) --------------------------------------
rem RIOJOY_APPDIR is set by install-core.bat to app (modern) or app-xp (XP).
set EXE=%RIOJOY_APPDIR%\RioJoy.Tray.exe
if not exist "%EXE%" (
echo ERROR: app executable not found: %EXE%
set RC=1
goto done
)
echo Creating shortcuts...
cscript //nologo "%~dp0RIOJoy\make-shortcut.vbs" "%EXE%" "RIOJoy" "%RIOJOY_APPDIR%"
:done
echo.
if %RC% neq 0 (
echo install FAILED with exit code %RC%.
) else (
echo install completed. Launch RIOJoy from the Start Menu or desktop shortcut.
)
pause
exit /b %RC%
+27
View File
@@ -0,0 +1,27 @@
' make-shortcut.vbs <targetExe> <name> <workingDir>
' Creates Start Menu (all users) and desktop shortcuts. VBScript so the same
' helper works on Windows XP (no PowerShell) and Windows 10/11.
Option Explicit
Dim shell, fso, target, name, workdir, places, place, lnk
If WScript.Arguments.Count < 3 Then
WScript.Echo "usage: make-shortcut.vbs <targetExe> <name> <workingDir>"
WScript.Quit 1
End If
target = WScript.Arguments(0)
name = WScript.Arguments(1)
workdir = WScript.Arguments(2)
Set shell = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")
places = Array(shell.SpecialFolders("AllUsersPrograms"), shell.SpecialFolders("Desktop"))
For Each place In places
If fso.FolderExists(place) Then
Set lnk = shell.CreateShortcut(fso.BuildPath(place, name & ".lnk"))
lnk.TargetPath = target
lnk.WorkingDirectory = workdir
lnk.Description = "RIOJoy cockpit interface"
lnk.Save
WScript.Echo " shortcut: " & fso.BuildPath(place, name & ".lnk")
End If
Next
+17 -4
View File
@@ -1,8 +1,10 @@
@echo off
rem ===========================================================================
rem RIOJoy cockpit post-install entry point (run by TeslaConsole).
rem Sits beside the 'RIOJoy' payload folder; elevates, then runs
rem RIOJoy\install-rio.ps1.
rem RIOJoy cockpit post-install entry point (run by TeslaConsole after the
rem package is extracted into C:\games). Sits beside the 'RIOJoy' payload
rem folder; elevates, runs the shared install core (which picks the XP or
rem modern flavor), then removes the standalone-install files so only
rem launcher-managed files stay at the C:\games root.
rem ===========================================================================
setlocal
title RIOJoy post-install
@@ -10,6 +12,12 @@ title RIOJoy post-install
rem --- elevate if we are not already administrator --------------------------
net session >nul 2>&1
if %errorlevel% neq 0 (
rem "if errorlevel" evaluates at run time (a %var% here would be stale).
ver | findstr /C:"Version 5.1" >nul
if not errorlevel 1 (
echo This installer must run as an Administrator user.
exit /b 1
)
echo Requesting administrator privileges...
powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
exit /b 0
@@ -17,9 +25,14 @@ if %errorlevel% neq 0 (
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0RIOJoy\install-rio.ps1"
call "%~dp0RIOJoy\install-core.bat"
set RC=%errorlevel%
rem --- cleanup: cabinet deploys keep the C:\games root clean ----------------
rem (del tolerates already-missing files, so re-runs stay idempotent)
if exist "%~dp0install.bat" del /q "%~dp0install.bat"
if exist "%~dp0README.txt" del /q "%~dp0README.txt"
echo.
if %RC% neq 0 (
echo postinstall FAILED with exit code %RC%.
+1 -1
View File
@@ -1,4 +1,4 @@
<#
<#
.SYNOPSIS
RIOJoy cockpit pre-uninstall cleanup. Reverses install-rio.ps1: stops the
running tray app, removes the logon entry, optionally uninstalls ViGEmBus,
+143 -5
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
@@ -281,11 +306,12 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
context-sensitive picker (keyboard key by name via `KeyCatalog`, joystick Button N,
hat direction, mouse/RIO-command enum); modifiers enable only for keyboard.
Opened from the tray ("Edit profile…"); Save persists the profile. The encoder
gauges are **live**: `RioRuntime.AxesUpdated` streams the calibrated axis values
(post invert/deadzone/mix — exactly what the virtual joystick receives) into the
gauges are **live**: `RioRuntime.AxesUpdated` streams an `AxisReadout` (the six
virtual-joystick outputs plus the calibrated pre-mix pedal positions) into the
strip at the analog poll rate — Z and the L/R pedals fill bottom-up, Rz deflects
from its center tick, and the X/Y box tracks the stick as a dot (pure fraction
math in `RioJoy.Core.Editing.AxisGauges`, unit-tested).
math in `RioJoy.Core.Editing.AxisGauges`, unit-tested). L/R read the pedals
from before the ZR mix, since the mix pins Rx/Ry to center.
⏳ Still to refine: showing each button's assigned key as a
caption, grouping a button's two bank addresses, and clone-from-existing.
- The unified profile JSON supersedes both `RIO.ini` and the Google Sheet, with
@@ -293,6 +319,118 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
- To confirm at Phase 7: target wallpaper resolution(s); static wallpaper vs.
live overlay (e.g. lit-button highlighting mirroring lamp state).
### Phase 8 — Windows XP compatibility (dual-target) — in progress
(8A/8C/8D done ✅; remaining: 8B driver + 8E XP-VM/cabinet verification)
Bring RIOJoy back to the original XP-era cabinets (x86, XP SP3) **without
regressing Windows 10/11**. Strategy: one codebase, two flavors, **one
universal deployment archive**. The mapping editor ships everywhere
(decided); only wallpaper *generation* (SkiaSharp) stays modern-only —
XP consumes pre-rendered wallpapers.
| | Windows 10/11 (unchanged) | Windows XP SP3 |
|---|---|---|
| TFM / arch | net48, x64 | **net40, x86** (.NET 4.0 is XP's ceiling; 4.5+ needs Vista) |
| Virtual joystick | ViGEm → RioGamepad → none (unchanged) | **RioGamepadXP.sys** — our own thin WDM HID minidriver (same feeder contract) — full 6-axis/96-button fidelity |
| Overlay render | SkiaSharp (generate + apply) | consume pre-rendered wallpaper only (**PNG→BMP** — XP's `SystemParametersInfo` takes BMP only) |
| Editor | full (incl. wallpaper maker) | **mapping editor included** (decided; pure WinForms/GDI+); wallpaper maker gated (Skia) |
| JSON | System.Text.Json → **Newtonsoft 13** | Newtonsoft 13 (STJ needs net461+; one serializer for both flavors) |
| Install scripts | PowerShell | **.bat only** (XP has no in-box PowerShell) |
- **8A — Core retarget — done ✅** (`net48;net40` multi-target): de-Span the
protocol/serial layer (≈20 uses / 11 files → `byte[]`/`ArraySegment`;
System.Memory doesn't go below net45, and at 9600 baud Span buys nothing);
swap System.Text.Json → Newtonsoft in `ConfigStore`/`OverlayTemplateStore`
(both TFMs, so the config format can't drift); async on net40 via
**Microsoft.Bcl.Async** + a `Compat/TaskCompat` shim (6 call sites:
Task.Run/Delay/WhenAny/WhenAll → TaskEx; XP prereq: KB2468871, bundle it);
shim the one `HashCode.Combine`; `#if`-gate the net48-only sinks
(`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 — 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
`SystemParametersInfo` (harmless on 10/11, required on XP); profile editor
stays (Segoe UI falls back to Tahoma). XP cabinets consume wallpapers
pre-rendered on a modern machine (`RioProfile.WallpaperPath` travels with
the config).
- **8D — Packaging — done ✅ (one universal archive, two install entry
points).** `build-package.ps1` produces a single `RIOJoy-<ver>.zip` (~75 MB
with the offline XP redistributables) that deploys on **both** XP and 10/11;
the RioGamepadXP driver files bundle automatically once 8B lands
(warn/skip until then). The shared OS-detecting install logic lives in
`RIOJoy\install-core.bat` (pure cmd on the XP path); shortcuts via
`make-shortcut.vbs` (works on XP and 10/11 alike):
- Layout: `RIOJoy\app\` (net48 x64) + `RIOJoy\app-xp\` (net40 x86) +
`RIOJoy\vendor\` (ViGEmBus installer for 10/11; RioGamepadXP.sys + INF
for XP; **.NET 4.0 Full + KB2468871 redistributables** so an offline XP
cabinet needs nothing else — adds ~70 MB, XP can't download anymore) +
`VERSION.txt` + README-DEPLOY (payload-internal detail doc).
- **`README.txt`** (zip root): written for a person installing on a
standalone computer — which Windows versions are supported, what the
two .bat entry points are and which one to run (`install.bat` for a
standalone machine; `postinstall.bat` is the cabinet launcher's hook),
that all prerequisites are bundled for offline install, and where the
app lands. Plain ASCII so XP-era Notepad renders it cleanly.
- **`postinstall.bat`** (zip root, unattended): the TeslaConsole/launcher
entry point, as today — detects the OS (`ver` → 5.1 = XP, 10.x = modern),
installs the matching prereqs (ViGEmBus silently via the existing
PowerShell on 10/11; .NET 4.0 + KB2468871 + driver INF on XP), and wires
the matching app flavor. No prompts, idempotent. **Its final step
deletes `install.bat` and the root `README.txt`** — on cabinet deploys
the zip extracts into `C:\games`, and only launcher-managed files may
stay at that root (`del` tolerating already-missing files, so re-runs
stay idempotent).
- **`install.bat`** (zip root, freestanding computers): same OS detection
and prereq install, plus what a machine without the launcher needs —
Start Menu/desktop shortcut to the right flavor's exe (still no logon
auto-start; starting RIOJoy stays deliberate). Pure cmd.exe on the XP
path; may call PowerShell only on the 10/11 path. Leaves the README in
place (it's the standalone machine's documentation).
- `pre-uninstall.bat` / uninstall mirror both scenarios. Release the one
zip to Gitea per the established process.
- **8E — Verification:** full suite stays net48-hosted (xUnit needs
net452+; shared sources are what's tested) + a tiny net40 console
self-test for the shims, run on XP. Ladder: net40/x86 binary boots on
Win10 → XP VM with vRIO over a virtual COM pair (app milestone; the
driver needs real/virtualized XP too — vhidmini-class drivers run fine
in a VM) → real cabinet (joy.cpl shows 6 axes + 96 buttons, SendInput
into a game, lamps, plasma, auto-switch yield, BMP wallpaper).
- **All open decisions resolved:**
1. The Xbox 360 pad (ViGEm) remains the preferred controller on
Windows 10/11 whenever ViGEmBus is present.
2. **No third-party virtual joystick drivers** (vJoy/PPJoy are
unmaintained) — XP gets our own RioGamepadXP.sys.
3. The **mapping editor ships in all instances**, XP included.
4. **Both install scenarios** in one archive: `postinstall.bat`
(TeslaConsole/launcher, unattended) and `install.bat` (freestanding
computers, adds shortcuts); the single dist zip carries everything
needed for both XP and 10/11, including offline redistributables.
---
## Open items / risks
+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}")
+35 -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;
@@ -70,6 +77,16 @@ public sealed class AxisCalibrator
private static int Clamp(int v) => RioJoy.Core.Compat.Net48Math.Clamp(v, 0, AxisOutputs.Max);
/// <summary>
/// The calibrated left-pedal position from the last <see cref="Update"/>
/// (<c>0..32766</c>) — always tracks the pedal, even when
/// <see cref="AxisCalibrationConfig.EnableZR"/> centers <see cref="AxisOutputs.Rx"/>.
/// </summary>
public int LeftPedalOutput => Clamp(_leftPedalLast);
/// <summary>Right-pedal counterpart of <see cref="LeftPedalOutput"/>.</summary>
public int RightPedalOutput => Clamp(_rightPedalLast);
private int Throttle(int throttle)
{
if (throttle < -RangeThrottle || throttle > RangeThrottle)
@@ -88,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);
@@ -0,0 +1,27 @@
namespace RioJoy.Core.Calibration;
/// <summary>
/// One live calibration sample for gauge displays: the six virtual-joystick axis
/// outputs plus the calibrated pedal positions. The pedals are surfaced
/// separately because the ZR mix (<see cref="AxisCalibrationConfig.EnableZR"/>)
/// folds them into Rz and pins Rx/Ry to center — a pedal gauge fed from the axes
/// alone would freeze while the physical pedals move.
/// </summary>
public readonly struct AxisReadout
{
/// <summary>The six virtual-joystick outputs (what the HID device receives).</summary>
public AxisOutputs Axes { get; }
/// <summary>Calibrated left-pedal position (<c>0..32766</c>), before the ZR mix.</summary>
public int LeftPedal { get; }
/// <summary>Calibrated right-pedal position (<c>0..32766</c>), before the ZR mix.</summary>
public int RightPedal { get; }
public AxisReadout(AxisOutputs axes, int leftPedal, int rightPedal)
{
Axes = axes;
LeftPedal = leftPedal;
RightPedal = rightPedal;
}
}
+4 -2
View File
@@ -10,10 +10,12 @@ namespace System.Collections.Generic
/// <summary>
/// Polyfill for CollectionExtensions.GetValueOrDefault (netstandard2.1+),
/// absent on net48. Returns the value for <paramref name="key"/> or
/// default(TValue) when the key is missing.
/// default(TValue) when the key is missing. Takes IDictionary rather than
/// IReadOnlyDictionary: net40 (Windows XP flavor) predates the IReadOnly*
/// interfaces, so shared code uses the classic ones throughout.
/// </summary>
public static TValue? GetValueOrDefault<TKey, TValue>(
this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key)
this IDictionary<TKey, TValue> dictionary, TKey key)
{
if (dictionary is null) throw new ArgumentNullException(nameof(dictionary));
return dictionary.TryGetValue(key, out var value) ? value : default;
+40
View File
@@ -0,0 +1,40 @@
namespace RioJoy.Core.Compat;
/// <summary>
/// net48/net40 bridge for the handful of Task-era statics the code uses. On
/// net40 (the Windows XP flavor) these come from Microsoft.Bcl.Async's
/// <c>TaskEx</c>, and <c>SemaphoreSlim.WaitAsync</c> doesn't exist at all —
/// there the wait blocks briefly instead, which at 9600 baud (tiny writes,
/// rare contention) costs nothing measurable.
/// </summary>
internal static class TaskCompat
{
#if NET40
public static Task Run(Action action) => TaskEx.Run(action);
public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) =>
TaskEx.Delay(delay, cancellationToken);
public static Task<Task> WhenAny(IEnumerable<Task> tasks) => TaskEx.WhenAny(tasks);
public static Task WhenAll(IEnumerable<Task> tasks) => TaskEx.WhenAll(tasks);
public static Task WaitAsync(SemaphoreSlim semaphore, CancellationToken cancellationToken)
{
semaphore.Wait(cancellationToken); // net40: no WaitAsync; block (see class doc)
return TaskEx.FromResult(true);
}
#else
public static Task Run(Action action) => Task.Run(action);
public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) =>
Task.Delay(delay, cancellationToken);
public static Task<Task> WhenAny(IEnumerable<Task> tasks) => Task.WhenAny(tasks);
public static Task WhenAll(IEnumerable<Task> tasks) => Task.WhenAll(tasks);
public static Task WaitAsync(SemaphoreSlim semaphore, CancellationToken cancellationToken) =>
semaphore.WaitAsync(cancellationToken);
#endif
}
+3 -3
View File
@@ -27,7 +27,7 @@ public sealed record PanelGroup(
bool LampCapable,
int OriginCol,
int OriginRow,
IReadOnlyList<int?> Addresses);
IList<int?> Addresses);
/// <summary>One address button placed on the panel (absolute cell coordinates).</summary>
public sealed record PanelButton(int Address, PanelGroup Group, int Col, int Row, bool LampCapable);
@@ -66,7 +66,7 @@ public static class CockpitPanel
}
/// <summary>All panel groups, positioned for rendering.</summary>
public static IReadOnlyList<PanelGroup> Groups { get; } = new[]
public static IList<PanelGroup> Groups { get; } = new[]
{
// Upper MFD row.
new PanelGroup("Upper Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 1, Mfd(0x2F)),
@@ -90,7 +90,7 @@ public static class CockpitPanel
};
/// <summary>Flatten the groups to positioned <see cref="PanelButton"/>s (absolute cells).</summary>
public static IReadOnlyList<PanelButton> Buttons()
public static IList<PanelButton> Buttons()
{
var buttons = new List<PanelButton>();
foreach (PanelGroup g in Groups)
+2 -2
View File
@@ -34,7 +34,7 @@ public sealed record SheetCell(int Row, int Col, string Text, int? Address, Shee
/// </summary>
public static class SheetLayout
{
public static IReadOnlyList<SheetCell> Load(string path) => Parse(File.ReadAllText(path));
public static IList<SheetCell> Load(string path) => Parse(File.ReadAllText(path));
/// <summary>
/// The RIO address a cell's text encodes (two hex digits in 0x000x6F), or null.
@@ -66,7 +66,7 @@ public static class SheetLayout
/// longer than <paramref name="maxTextLength"/> is dropped as a merged-cell
/// artifact; the clean export has none.
/// </summary>
public static IReadOnlyList<SheetCell> Parse(string csv, int maxTextLength = 40)
public static IList<SheetCell> Parse(string csv, int maxTextLength = 40)
{
if (csv is null) throw new ArgumentNullException(nameof(csv));
+1 -4
View File
@@ -43,10 +43,7 @@ public sealed class RioHidReport
SetHat(RioHat.Centered);
}
/// <summary>The current report bytes (length <see cref="Size"/>).</summary>
public ReadOnlySpan<byte> Bytes => _buffer;
/// <summary>Copy of the current report bytes.</summary>
/// <summary>Copy of the current report bytes (length <see cref="Size"/>).</summary>
public byte[] ToArray() => (byte[])_buffer.Clone();
/// <summary>Set an axis value (clamped to 0..<see cref="AxisMax"/>), little-endian.</summary>
+1 -1
View File
@@ -13,7 +13,7 @@ public readonly record struct KeyName(string Name, byte Value);
public static class KeyCatalog
{
/// <summary>All catalogued keys, in menu order (letters, digits, F-keys, …).</summary>
public static IReadOnlyList<KeyName> Entries { get; } = Build();
public static IList<KeyName> Entries { get; } = Build();
private static readonly Dictionary<byte, string> ByValue =
Entries.GroupBy(e => e.Value).ToDictionary(g => g.Key, g => g.First().Name);
+1 -1
View File
@@ -14,7 +14,7 @@ public sealed class RioInputMap
public RioInputMap() { }
/// <summary>Create a map from raw 16-bit words indexed by address.</summary>
public RioInputMap(IReadOnlyDictionary<int, ushort> entries)
public RioInputMap(IDictionary<int, ushort> entries)
{
if (entries is null) throw new ArgumentNullException(nameof(entries));
foreach ((int address, ushort raw) in entries)
+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;
@@ -114,7 +122,7 @@ public sealed class HidFeederJoystickSink : IJoystickSink, IDisposable
try
{
var ifData = new SP_DEVICE_INTERFACE_DATA();
ifData.cbSize = Marshal.SizeOf<SP_DEVICE_INTERFACE_DATA>();
ifData.cbSize = Marshal.SizeOf(typeof(SP_DEVICE_INTERFACE_DATA));
if (!SetupDiEnumDeviceInterfaces(devInfo, IntPtr.Zero, ref guid, 0, ref ifData))
return null;
+1 -1
View File
@@ -69,7 +69,7 @@ public sealed class SendInputSink : IInputSink
}
private static void Send(INPUT input) =>
SendInput(1, new[] { input }, Marshal.SizeOf<INPUT>());
SendInput(1, new[] { input }, Marshal.SizeOf(typeof(INPUT)));
// --- Win32 interop --------------------------------------------------------
+37 -17
View File
@@ -1,3 +1,4 @@
#if !NET40 // ViGEmBus is Win10+ only; the XP flavor uses RioGamepadXP (PLAN.md §8B)
using Nefarius.ViGEm.Client;
using Nefarius.ViGEm.Client.Targets;
using Nefarius.ViGEm.Client.Targets.Xbox360;
@@ -26,7 +27,7 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
};
/// <summary>Display names for the mappable buttons, aligned with <see cref="ButtonMap"/>.</summary>
public static IReadOnlyList<string> ButtonNames { get; } = new[]
public static IList<string> ButtonNames { get; } = new[]
{
"A", "B", "X", "Y", "LB", "RB", "Back", "Start", "L3", "R3", "Guide",
};
@@ -37,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)
{
@@ -44,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
@@ -93,35 +117,31 @@ 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 */ }
_client.Dispose();
}
}
#endif
@@ -18,8 +18,8 @@ public static class GoobieDataImporter
{
/// <summary>A parsed data file: the field names and one label map per row.</summary>
public sealed record Sheet(
IReadOnlyList<string> Fields,
IReadOnlyList<IReadOnlyDictionary<string, string>> Rows);
IList<string> Fields,
IList<IDictionary<string, string>> Rows);
public static Sheet Load(string path) => Parse(File.ReadAllText(path));
@@ -33,7 +33,7 @@ public static class GoobieDataImporter
throw new FormatException("No lists found; expected a header list of field names.");
List<string> fields = lists[0];
var rows = new List<IReadOnlyDictionary<string, string>>();
var rows = new List<IDictionary<string, string>>();
for (int i = 1; i < lists.Count; i++)
{
@@ -51,7 +51,7 @@ public static class GoobieDataImporter
/// Convenience: the label map for a single row (default the first), with empty
/// values dropped — ready to hand to <see cref="OverlayLayoutEngine.Layout"/>.
/// </summary>
public static IReadOnlyDictionary<string, string> LabelsForRow(Sheet sheet, int rowIndex = 0)
public static IDictionary<string, string> LabelsForRow(Sheet sheet, int rowIndex = 0)
{
if (sheet is null) throw new ArgumentNullException(nameof(sheet));
if (rowIndex < 0 || rowIndex >= sheet.Rows.Count)
+2 -2
View File
@@ -15,7 +15,7 @@ public static class OverlayHitTester
/// A region's rectangle is its stored cell geometry; label rotation does not
/// affect hit-testing.
/// </summary>
public static IReadOnlyList<OverlayRegion> RegionsAt(OverlayTemplate template, double x, double y)
public static IList<OverlayRegion> RegionsAt(OverlayTemplate template, double x, double y)
{
if (template is null) throw new ArgumentNullException(nameof(template));
@@ -33,7 +33,7 @@ public static class OverlayHitTester
/// </summary>
public static OverlayRegion? NextAt(OverlayTemplate template, double x, double y, string? currentName)
{
IReadOnlyList<OverlayRegion> hits = RegionsAt(template, x, y);
IList<OverlayRegion> hits = RegionsAt(template, x, y);
if (hits.Count == 0)
return null;
@@ -20,9 +20,9 @@ public sealed class OverlayLayoutEngine
/// in <paramref name="labels"/> (or with empty text) are skipped — matching the
/// legacy behavior where a blank field produced no visible text.
/// </summary>
public IReadOnlyList<PlacedLabel> Layout(
public IList<PlacedLabel> Layout(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
IDictionary<string, string> labels,
OverlayLayoutOptions? options = null)
{
if (template is null) throw new ArgumentNullException(nameof(template));
@@ -1,5 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace RioJoy.Core.Overlay;
@@ -11,24 +11,24 @@ namespace RioJoy.Core.Overlay;
/// </summary>
public static class OverlayTemplateStore
{
private static readonly JsonSerializerOptions Options = new()
private static readonly JsonSerializerSettings Options = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore,
Converters = { new StringEnumConverter() },
};
public static string Serialize(OverlayTemplate template)
{
if (template is null) throw new ArgumentNullException(nameof(template));
return JsonSerializer.Serialize(template, Options);
return JsonConvert.SerializeObject(template, Options);
}
public static OverlayTemplate Deserialize(string json)
{
if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
return JsonSerializer.Deserialize<OverlayTemplate>(json, Options)
?? throw new JsonException("Overlay template JSON deserialized to null.");
return JsonConvert.DeserializeObject<OverlayTemplate>(json, Options)
?? throw new JsonSerializationException("Overlay template JSON deserialized to null.");
}
public static void Save(OverlayTemplate template, string path)
+1 -1
View File
@@ -48,5 +48,5 @@ public sealed class PlasmaDisplay
}
private Task WriteAsync(byte[] data, CancellationToken ct) =>
_transport.WriteAsync(data, ct).AsTask();
_transport.WriteAsync(data, ct);
}
+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";
+4 -1
View File
@@ -33,7 +33,10 @@ public readonly struct SwitchDecision : IEquatable<SwitchDecision>
public bool Equals(SwitchDecision other) => Mode == other.Mode && ReferenceEquals(Profile, other.Profile);
public override bool Equals(object? obj) => obj is SwitchDecision d && Equals(d);
public override int GetHashCode() => HashCode.Combine(Mode, Profile);
// Manual combine: HashCode.Combine needs Microsoft.Bcl.HashCode, which has
// no net40 build (Windows XP flavor).
public override int GetHashCode() =>
((int)Mode * 397) ^ (Profile?.GetHashCode() ?? 0);
public override string ToString() => Mode == SwitchMode.Activate ? $"Activate({Profile?.Name})" : Mode.ToString();
}
@@ -62,7 +62,7 @@ public sealed class AutoSwitchWatcher
{
try
{
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
await Compat.TaskCompat.Delay(interval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
+57 -9
View File
@@ -1,32 +1,36 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace RioJoy.Core.Profiles;
/// <summary>
/// Loads and saves <see cref="AppConfig"/> as JSON. Replaces the legacy
/// SimpleIni single-file config with a profile library (see docs/PLAN.md).
/// Newtonsoft rather than System.Text.Json so the net40 (Windows XP) flavor
/// shares one serializer — and one on-disk format — with net48 (PLAN.md §8A).
/// Conventions match the original STJ settings: indented, PascalCase names,
/// enums as strings, nulls skipped — existing config.json files parse as-is.
/// </summary>
public static class ConfigStore
{
private static readonly JsonSerializerOptions Options = new()
private static readonly JsonSerializerSettings Options = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore,
Converters = { new StringEnumConverter() },
};
public static string Serialize(AppConfig config)
{
if (config is null) throw new ArgumentNullException(nameof(config));
return JsonSerializer.Serialize(config, Options);
return JsonConvert.SerializeObject(config, Options);
}
public static AppConfig Deserialize(string json)
{
if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
return JsonSerializer.Deserialize<AppConfig>(json, Options)
?? throw new JsonException("Config JSON deserialized to null.");
return JsonConvert.DeserializeObject<AppConfig>(json, Options)
?? throw new JsonSerializationException("Config JSON deserialized to null.");
}
/// <summary>Save the config to <paramref name="path"/> (creating directories).</summary>
@@ -48,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);
+1 -1
View File
@@ -44,7 +44,7 @@ public sealed class IniFile
}
/// <summary>All key/value pairs in a section (empty if the section is absent).</summary>
public IReadOnlyDictionary<string, string> Section(string name) =>
public IDictionary<string, string> Section(string name) =>
_sections.TryGetValue(name, out Dictionary<string, string>? s)
? s
: new Dictionary<string, string>();
+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; }
+2 -1
View File
@@ -44,8 +44,9 @@ public readonly struct AnalogReport
/// <c>0xFE</c> (<see cref="RioControl.Restart"/>), which the RIO uses as an
/// "invalid sample" sentinel — the legacy code ignores such replies.
/// </summary>
public static bool TryParse(ReadOnlySpan<byte> payload, out AnalogReport report)
public static bool TryParse(byte[] payload, out AnalogReport report)
{
if (payload is null) throw new ArgumentNullException(nameof(payload));
if (payload.Length != RioCommandTable.PayloadLength(RioCommand.AnalogReply))
throw new ArgumentException(
$"AnalogReply payload must be {RioCommandTable.PayloadLength(RioCommand.AnalogReply)} bytes.",
+9 -6
View File
@@ -13,8 +13,9 @@ public static class PacketBuilder
/// <paramref name="payload"/>. The payload length must match the command's
/// entry in the length table.
/// </summary>
public static byte[] Build(RioCommand command, ReadOnlySpan<byte> payload)
public static byte[] Build(RioCommand command, byte[] payload)
{
if (payload is null) throw new ArgumentNullException(nameof(payload));
int expected = RioCommandTable.PayloadLength(command);
if (payload.Length != expected)
throw new ArgumentException(
@@ -23,13 +24,15 @@ public static class PacketBuilder
var packet = new byte[1 + expected + 1];
packet[0] = (byte)command;
payload.CopyTo(packet.AsSpan(1));
packet[^1] = RioChecksum.Compute(packet.AsSpan(0, 1 + expected));
Array.Copy(payload, 0, packet, 1, expected);
packet[packet.Length - 1] = RioChecksum.Compute(packet, 0, 1 + expected);
return packet;
}
/// <summary>Build a zero-payload command packet (CheckRequest etc.).</summary>
public static byte[] Build(RioCommand command) => Build(command, ReadOnlySpan<byte>.Empty);
public static byte[] Build(RioCommand command) => Build(command, EmptyPayload);
private static readonly byte[] EmptyPayload = new byte[0];
// --- Convenience builders for the PC → RIO commands ---------------------
@@ -40,12 +43,12 @@ public static class PacketBuilder
public static byte[] AnalogRequest() => Build(RioCommand.AnalogRequest);
public static byte[] ResetRequest(RioResetTarget target) =>
Build(RioCommand.ResetRequest, stackalloc byte[] { (byte)target });
Build(RioCommand.ResetRequest, new[] { (byte)target });
/// <summary>
/// LampRequest: payload is <c>[lamp#, state]</c>. The state byte is a
/// 7-bit lamp-state value (see <see cref="RioLampState"/>).
/// </summary>
public static byte[] LampRequest(byte lampNumber, byte state) =>
Build(RioCommand.LampRequest, stackalloc byte[] { lampNumber, state });
Build(RioCommand.LampRequest, new[] { lampNumber, state });
}
+10 -7
View File
@@ -92,15 +92,17 @@ public sealed class PacketParser
}
/// <summary>
/// Feed a chunk of received bytes, invoking <paramref name="onEvent"/> for
/// each event produced, in order.
/// Feed the first <paramref name="count"/> received bytes of
/// <paramref name="data"/>, invoking <paramref name="onEvent"/> for each
/// event produced, in order.
/// </summary>
public void Feed(ReadOnlySpan<byte> data, Action<RioRxEvent> onEvent)
public void Feed(byte[] data, int count, Action<RioRxEvent> onEvent)
{
if (data is null) throw new ArgumentNullException(nameof(data));
if (onEvent is null) throw new ArgumentNullException(nameof(onEvent));
foreach (byte ch in data)
for (int i = 0; i < count; i++)
{
if (Feed(ch, out RioRxEvent ev))
if (Feed(data[i], out RioRxEvent ev))
onEvent(ev);
}
}
@@ -110,11 +112,12 @@ public sealed class PacketParser
// _buffer = [command][payload…][checksum]; _count includes all of them.
int bodyLen = _count - 1; // command + payload (exclude checksum)
byte receivedChecksum = _buffer[bodyLen];
byte computed = RioChecksum.Compute(_buffer.AsSpan(0, bodyLen));
byte computed = RioChecksum.Compute(_buffer, 0, bodyLen);
bool checksumValid = computed == receivedChecksum;
var command = (RioCommand)_buffer[0];
var payload = _buffer.AsSpan(1, bodyLen - 1).ToArray(); // copy out; buffer is reused
var payload = new byte[bodyLen - 1]; // copy out; buffer is reused
Array.Copy(_buffer, 1, payload, 0, payload.Length);
var packet = new RioPacket(command, payload);
Reset();
+12 -5
View File
@@ -9,15 +9,22 @@ namespace RioJoy.Core.Protocol;
public static class RioChecksum
{
/// <summary>
/// Compute the 7-bit checksum over <paramref name="commandAndPayload"/>
/// (the command byte followed by its payload bytes — not the checksum byte).
/// Compute the 7-bit checksum over the command byte followed by its payload
/// bytes (not the checksum byte): <paramref name="count"/> bytes of
/// <paramref name="commandAndPayload"/> starting at <paramref name="offset"/>.
/// </summary>
public static byte Compute(ReadOnlySpan<byte> commandAndPayload)
public static byte Compute(byte[] commandAndPayload, int offset, int count)
{
if (commandAndPayload is null) throw new ArgumentNullException(nameof(commandAndPayload));
byte sum = 0;
foreach (byte b in commandAndPayload)
sum += (byte)(b & 0x7F);
for (int i = offset; i < offset + count; i++)
sum += (byte)(commandAndPayload[i] & 0x7F);
return (byte)(sum & 0x7F);
}
/// <summary>Compute the checksum over all of <paramref name="commandAndPayload"/>.</summary>
public static byte Compute(byte[] commandAndPayload) =>
Compute(commandAndPayload, 0, commandAndPayload?.Length ?? 0);
}
+7 -6
View File
@@ -11,13 +11,14 @@ public readonly struct RioPacket
public RioCommand Command { get; }
/// <summary>
/// The payload bytes (high bit always clear). Length matches
/// <see cref="RioCommandTable.PayloadLength(RioCommand)"/>.
/// The payload bytes (high bit always clear; treat as read-only). Length
/// matches <see cref="RioCommandTable.PayloadLength(RioCommand)"/>.
/// </summary>
public ReadOnlyMemory<byte> Payload { get; }
public byte[] Payload { get; }
public RioPacket(RioCommand command, ReadOnlyMemory<byte> payload)
public RioPacket(RioCommand command, byte[] payload)
{
if (payload is null) throw new ArgumentNullException(nameof(payload));
int expected = RioCommandTable.PayloadLength(command);
if (payload.Length != expected)
throw new ArgumentException(
@@ -30,7 +31,7 @@ public readonly struct RioPacket
public override string ToString()
{
var hex = BitConverter.ToString(Payload.ToArray()).Replace("-", string.Empty);
return Payload.IsEmpty ? Command.ToString() : $"{Command} [{hex}]";
var hex = BitConverter.ToString(Payload).Replace("-", string.Empty);
return Payload.Length == 0 ? Command.ToString() : $"{Command} [{hex}]";
}
}
+4 -3
View File
@@ -16,7 +16,7 @@ public readonly struct VersionInfo
}
/// <summary>Decode a <see cref="RioCommand.VersionReply"/> payload (2 bytes).</summary>
public static VersionInfo Parse(ReadOnlySpan<byte> payload)
public static VersionInfo Parse(byte[] payload)
{
Require(payload, RioCommand.VersionReply);
return new VersionInfo(payload[0], payload[1]);
@@ -24,8 +24,9 @@ public readonly struct VersionInfo
public override string ToString() => $"{Major}.{Minor}";
internal static void Require(ReadOnlySpan<byte> payload, RioCommand command)
internal static void Require(byte[] payload, RioCommand command)
{
if (payload is null) throw new ArgumentNullException(nameof(payload));
int expected = RioCommandTable.PayloadLength(command);
if (payload.Length != expected)
throw new ArgumentException(
@@ -51,7 +52,7 @@ public readonly struct CheckStatus
}
/// <summary>Decode a <see cref="RioCommand.CheckReply"/> payload (2 bytes).</summary>
public static CheckStatus Parse(ReadOnlySpan<byte> payload)
public static CheckStatus Parse(byte[] payload)
{
VersionInfo.Require(payload, RioCommand.CheckReply);
return new CheckStatus((RioStatusType)payload[0], payload[1]);
+30 -10
View File
@@ -1,27 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- net48 test branch. Uses Win32 P/Invoke (SendInput, SystemParametersInfo)
and System.IO.Ports. Span/records/init/Index are polyfilled (System.Memory
+ PolySharp); System.Text.Json comes from NuGet (not in the net48 BCL). -->
<TargetFramework>net48</TargetFramework>
<!-- Two flavors (PLAN.md §Phase 8): net48 = Windows 10/11 (x64, ViGEm +
RioGamepad); net40 = Windows XP SP3 (x86 cabinets — .NET 4.0 is XP's
ceiling). Modern language features come from PolySharp on both; net40
async machinery comes from Microsoft.Bcl.Async (TaskEx via
Compat/TaskCompat). Win32 P/Invoke (SendInput, DeviceIoControl) and
in-box System.IO.Ports work on both. -->
<TargetFrameworks>net48;net40</TargetFrameworks>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<!-- net48 pins x64 (matches the driver + tray exe); net40 stays AnyCPU and
the XP tray exe pins x86. -->
<PropertyGroup Condition="'$(TargetFramework)' == 'net48'">
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net40'">
<!-- net40 has no System.Net.Http assembly for the implicit global using. -->
<Using Remove="System.Net.Http" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Nefarius.ViGEm.Client" Version="1.21.256" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
<PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<PackageReference Include="Nefarius.ViGEm.Client" Version="1.21.256" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net40'">
<PackageReference Include="Microsoft.Bcl.Async" Version="1.0.168" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup>
</Project>
+8 -7
View File
@@ -57,11 +57,11 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
public event Action<int, bool>? ButtonActivity;
/// <summary>
/// Raised with the six calibrated axis values after every analog reply —
/// independent of the joystick sink, so the editor can show live gauges even
/// while input routing is suppressed.
/// Raised with the calibrated readout (virtual axes + pre-mix pedal positions)
/// after every analog reply — independent of the joystick sink, so the editor
/// can show live gauges even while input routing is suppressed.
/// </summary>
public event Action<AxisOutputs>? AxesUpdated;
public event Action<AxisReadout>? AxesUpdated;
/// <summary>Raised with the firmware version when a <see cref="RioCommand.VersionReply"/> arrives.</summary>
public event Action<VersionInfo>? VersionReceived;
@@ -137,13 +137,14 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
// Output is best-effort: a faulty joystick sink must not kill the link.
}
AxesUpdated?.Invoke(axes);
AxesUpdated?.Invoke(new AxisReadout(
axes, _calibrator.LeftPedalOutput, _calibrator.RightPedalOutput));
}
private void OnPacket(RioPacket packet)
{
EnsureLampsInitialized();
ReadOnlySpan<byte> p = packet.Payload.Span;
byte[] p = packet.Payload;
switch (packet.Command)
{
case RioCommand.ButtonPressed when p[0] < RioAddress.ButtonCount:
@@ -184,7 +185,7 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
ButtonActivity?.Invoke(address, pressed);
}
private static bool IsKeypad(ReadOnlySpan<byte> p) => p[0] is 0 or 1 && p[1] <= 0x0F;
private static bool IsKeypad(byte[] p) => p[0] is 0 or 1 && p[1] <= 0x0F;
// IRioCommandSink: RIO commands routed from a button (RIOcmd port).
void IRioCommandSink.Execute(RioCommandCode command)
+7 -2
View File
@@ -15,8 +15,13 @@ public interface IRioTransport : IDisposable
/// Read available bytes into <paramref name="buffer"/>. Returns the number of
/// bytes read; a return of 0 indicates the transport has closed.
/// </summary>
ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
/// <remarks>
/// Plain <c>byte[]</c>/<see cref="Task"/> rather than Memory/ValueTask: the
/// link runs at 9600 baud, and the net40 (Windows XP) flavor has neither
/// System.Memory nor ValueTask (see docs/PLAN.md §Phase 8).
/// </remarks>
Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken);
/// <summary>Write all of <paramref name="data"/> to the transport.</summary>
ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken);
Task WriteAsync(byte[] data, CancellationToken cancellationToken);
}
@@ -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;
}
}
+121 -9
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.
@@ -69,19 +92,78 @@ public sealed class RioSerialLink
{
// If any running loop ends (transport closed / error / cancellation),
// tear the others down too.
await Task.WhenAny(loops).ConfigureAwait(false);
await Compat.TaskCompat.WhenAny(loops).ConfigureAwait(false);
}
finally
{
linked.Cancel();
await Task.WhenAll(loops.Select(Swallow)).ConfigureAwait(false);
await Compat.TaskCompat.WhenAll(loops.Select(Swallow)).ConfigureAwait(false);
}
}
/// <summary>Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.</summary>
public async Task SendAsync(ReadOnlyMemory<byte> packet, CancellationToken cancellationToken = default)
/// <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)
{
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
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
{
await _transport.WriteAsync(packet, cancellationToken).ConfigureAwait(false);
@@ -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,10 +245,29 @@ 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:
if (AnalogReport.TryParse(packet.Payload.Span, out AnalogReport report))
if (AnalogReport.TryParse(packet.Payload, out AnalogReport report))
{
_sinceAnalog.Restart();
AnalogReceived?.Invoke(report);
@@ -163,11 +275,11 @@ public sealed class RioSerialLink
break;
case RioCommand.VersionReply:
VersionReceived?.Invoke(VersionInfo.Parse(packet.Payload.Span));
VersionReceived?.Invoke(VersionInfo.Parse(packet.Payload));
break;
case RioCommand.CheckReply:
CheckReceived?.Invoke(CheckStatus.Parse(packet.Payload.Span));
CheckReceived?.Invoke(CheckStatus.Parse(packet.Payload));
break;
}
}
@@ -191,7 +303,7 @@ public sealed class RioSerialLink
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(_options.AnalogPollInterval, ct).ConfigureAwait(false);
await Compat.TaskCompat.Delay(_options.AnalogPollInterval, ct).ConfigureAwait(false);
await RequestAnalogAsync(ct).ConfigureAwait(false);
@@ -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);
}
}
+16 -19
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,23 +52,13 @@ public sealed class SerialPortTransport : IRioTransport
_stream = _port.BaseStream;
}
public string Description => $"{_port.PortName} @ {BaudRate} 8N1";
public string Description => $"{_port.PortName} @ {_port.BaudRate} 8N1";
// net48's Stream has no Memory-based ReadAsync/WriteAsync overloads, so bridge
// through a pooled array and copy into/out of the caller's Memory<byte>.
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
byte[] tmp = new byte[buffer.Length];
int read = await _stream.ReadAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false);
new ReadOnlySpan<byte>(tmp, 0, read).CopyTo(buffer.Span);
return read;
}
public Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken) =>
_stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
public async ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
byte[] tmp = data.ToArray();
await _stream.WriteAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false);
}
public Task WriteAsync(byte[] data, CancellationToken cancellationToken) =>
_stream.WriteAsync(data, 0, data.Length, cancellationToken);
public void Dispose()
{
@@ -84,7 +81,7 @@ public sealed class SerialPortTransport : IRioTransport
// still releases the handle.
}
Task close = Task.Run(() =>
Task close = Compat.TaskCompat.Run(() =>
{
try { _port.Dispose(); }
catch { /* best-effort release */ }
@@ -28,7 +28,7 @@ public sealed class ProfileWallpaperGenerator
public string Generate(
OverlayTemplate template,
string templateDirectory,
IReadOnlyDictionary<string, string> labels,
IDictionary<string, string> labels,
string outputPath,
OverlayLayoutOptions? options = null)
{
+3 -3
View File
@@ -31,7 +31,7 @@ public sealed class SkiaOverlayRenderer
/// </summary>
public SKBitmap Render(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
IDictionary<string, string> labels,
SKBitmap baseImage,
OverlayLayoutOptions? options = null)
{
@@ -95,7 +95,7 @@ public sealed class SkiaOverlayRenderer
/// <summary>Render and encode to PNG bytes.</summary>
public byte[] RenderToPng(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
IDictionary<string, string> labels,
SKBitmap baseImage,
OverlayLayoutOptions? options = null)
{
@@ -110,7 +110,7 @@ public sealed class SkiaOverlayRenderer
/// </summary>
public void RenderToFile(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
IDictionary<string, string> labels,
string outputPath,
OverlayLayoutOptions? options = null)
{
+8 -6
View File
@@ -10,7 +10,7 @@ namespace RioJoy.Tray.Editor;
/// </summary>
internal sealed class PanelCanvas : Control
{
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitPanel.Buttons();
private static readonly IList<PanelButton> AllButtons = CockpitPanel.Buttons();
public PanelCanvas()
{
@@ -32,7 +32,7 @@ internal sealed class PanelCanvas : Control
public Func<JoyStickSetting, bool>? CalibrationProvider { get; set; }
private readonly HashSet<int> _livePressed = new();
private AxisOutputs? _axes;
private AxisReadout? _axes;
public int? SelectedAddress { get; private set; }
@@ -46,14 +46,16 @@ internal sealed class PanelCanvas : Control
}
/// <summary>
/// Show the latest calibrated axis values on the encoder-gauge strip. Arrives at
/// Show the latest calibrated readout on the encoder-gauge strip. Arrives at
/// the analog poll rate (~18 Hz), so only the strip is repainted, and only when a
/// value actually changed.
/// </summary>
public void SetAxes(AxisOutputs axes)
public void SetAxes(AxisReadout axes)
{
if (_axes is { } p && p.X == axes.X && p.Y == axes.Y && p.Z == axes.Z &&
p.Rx == axes.Rx && p.Ry == axes.Ry && p.Rz == axes.Rz)
if (_axes is { } p &&
p.Axes.X == axes.Axes.X && p.Axes.Y == axes.Axes.Y && p.Axes.Z == axes.Axes.Z &&
p.Axes.Rx == axes.Axes.Rx && p.Axes.Ry == axes.Axes.Ry && p.Axes.Rz == axes.Axes.Rz &&
p.LeftPedal == axes.LeftPedal && p.RightPedal == axes.RightPedal)
return;
_axes = axes;
Invalidate(new Rectangle(0, 0, Width, PanelView.TopStrip));
+15 -14
View File
@@ -40,7 +40,7 @@ public static class PanelView
public const int CalOriginCol = 2;
public const int CalOriginRow = 9;
public static IReadOnlyList<CalibrationCell> CalibrationCells { get; } = new[]
public static IList<CalibrationCell> CalibrationCells { get; } = new[]
{
new CalibrationCell(JoyStickSetting.InvertX, "Inv X", 2, 10),
new CalibrationCell(JoyStickSetting.InvertY, "Inv Y", 2, 11),
@@ -51,7 +51,7 @@ public static class PanelView
new CalibrationCell(JoyStickSetting.EnableZR, "ZR mix", 2, 16),
};
public static Size GridSize(IReadOnlyList<PanelButton> buttons)
public static Size GridSize(IList<PanelButton> buttons)
{
int maxCol = 0, maxRow = 0;
foreach (PanelButton b in buttons)
@@ -69,13 +69,13 @@ public static class PanelView
public static void Paint(
Graphics g,
IReadOnlyList<PanelButton> buttons,
IReadOnlyList<PanelGroup> groups,
IList<PanelButton> buttons,
IList<PanelGroup> groups,
Func<int, string?> label,
Func<int, string?> function,
Func<int, bool> lit,
Func<int, bool> live,
AxisOutputs? axes,
AxisReadout? axes,
int? selected)
{
g.Clear(Color.FromArgb(28, 28, 28));
@@ -152,7 +152,7 @@ public static class PanelView
}
}
private static void DrawEncoderStrip(Graphics g, Font font, AxisOutputs? axes)
private static void DrawEncoderStrip(Graphics g, Font font, AxisReadout? axes)
{
using var pen = new Pen(Color.FromArgb(120, 160, 120));
using var gauge = new SolidBrush(Color.FromArgb(20, 40, 20));
@@ -181,12 +181,13 @@ public static class PanelView
g.FillRectangle(gauge, box);
// Live values from the RIO's analog stream (null when the link is down).
// The gauges show the calibrated outputs — post invert/deadzone/mix — i.e.
// exactly what the virtual joystick receives.
// Z/Rz/X/Y show the virtual-joystick outputs; L and R show the calibrated
// pedal positions from before the ZR mix, which pins Rx/Ry to center — the
// pedal gauges must track the physical pedals either way.
if (axes is { } a)
{
// Z (throttle) and the L/R pedals (Rx/Ry) fill bottom-up.
foreach (var (box, value) in new[] { (zBox, a.Z), (lBox, a.Rx), (rBox, a.Ry) })
// Z (throttle) and the L/R pedals fill bottom-up.
foreach (var (box, value) in new[] { (zBox, a.Axes.Z), (lBox, a.LeftPedal), (rBox, a.RightPedal) })
{
int h = (int)Math.Round(box.Height * AxisGauges.Fraction(value));
if (h > 0)
@@ -194,7 +195,7 @@ public static class PanelView
}
// Rz (mixed rudder) deflects left/right from the bar's center.
double d = AxisGauges.Deflection(a.Rz);
double d = AxisGauges.Deflection(a.Axes.Rz);
int cx = rzBox.X + rzBox.Width / 2;
int w = (int)Math.Round(rzBox.Width / 2.0 * Math.Abs(d));
if (w > 0)
@@ -217,14 +218,14 @@ public static class PanelView
// (0 = left/top, matching the HID axis convention).
if (axes is { } live)
{
int px = xyBox.X + (int)Math.Round(AxisGauges.Fraction(live.X) * (xyBox.Width - 1));
int py = xyBox.Y + (int)Math.Round(AxisGauges.Fraction(live.Y) * (xyBox.Height - 1));
int px = xyBox.X + (int)Math.Round(AxisGauges.Fraction(live.Axes.X) * (xyBox.Width - 1));
int py = xyBox.Y + (int)Math.Round(AxisGauges.Fraction(live.Axes.Y) * (xyBox.Height - 1));
using var dot = new SolidBrush(Color.FromArgb(150, 230, 150));
g.FillEllipse(dot, px - 3, py - 3, 7, 7);
}
}
public static PanelButton? HitTest(IReadOnlyList<PanelButton> buttons, Point p)
public static PanelButton? HitTest(IList<PanelButton> buttons, Point p)
{
foreach (PanelButton b in buttons)
if (Cell(b.Col, b.Row).Contains(p))
+69 -24
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)
@@ -261,9 +295,15 @@ public sealed class ProfileEditorForm : Form
_valueCombo.Items.Add(new ValueItem(k.Name, k.Value));
break;
case RioRouteKind.Joystick:
#if NET40
// XP: RioGamepadXP exposes the full 96-button HID layout.
for (int btn = 1; btn <= RioJoy.Core.Hid.RioHidReport.ButtonCount; btn++)
_valueCombo.Items.Add(new ValueItem($"Button {btn}", (byte)btn));
#else
// The signed ViGEmBus Xbox 360 pad exposes 11 buttons; show their names.
for (int btn = 1; btn <= ViGEmJoystickSink.MappableButtonCount; btn++)
_valueCombo.Items.Add(new ValueItem($"Button {btn} ({ViGEmJoystickSink.ButtonNames[btn - 1]})", (byte)btn));
#endif
break;
case RioRouteKind.Hat:
foreach (RioHat h in new[] { RioHat.Up, RioHat.Right, RioHat.Down, RioHat.Left })
@@ -353,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
{
@@ -466,8 +511,8 @@ public sealed class ProfileEditorForm : Form
public void ShowLiveActivity(int address, bool pressed) =>
RunOnUi(() => _canvas.SetLivePressed(address, pressed));
/// <summary>Show the live calibrated axis values on the encoder gauges (serial thread).</summary>
public void ShowAxes(AxisOutputs axes) =>
/// <summary>Show the live calibrated readout on the encoder gauges (serial thread).</summary>
public void ShowAxes(AxisReadout axes) =>
RunOnUi(() => _canvas.SetAxes(axes));
/// <summary>Show the firmware version from a RIO version reply (serial thread).</summary>
+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;
}
}
}
+73 -4
View File
@@ -5,7 +5,9 @@ using RioJoy.Core.Output;
using RioJoy.Core.Overlay;
using RioJoy.Core.Profiles;
using RioJoy.Core.Serial;
using RioJoy.Overlay;
#if !NET40
using RioJoy.Overlay; // SkiaSharp wallpaper generation — modern flavor only
#endif
namespace RioJoy.Tray;
@@ -42,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>
@@ -149,12 +157,14 @@ public sealed class RioCoordinator : IDisposable
// feeder, then a no-op when neither driver is present.
private IJoystickSink CreateJoystickSink(out string note)
{
#if !NET40 // ViGEmBus is Win10+; on XP the HID feeder drives RioGamepadXP.sys
if (ViGEmJoystickSink.TryCreate(out ViGEmJoystickSink? vigem))
{
_joystick = vigem;
note = string.Empty;
return vigem!;
}
#endif
if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder))
{
@@ -189,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
@@ -207,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(),
@@ -241,6 +262,21 @@ public sealed class RioCoordinator : IDisposable
/// </summary>
private void ApplyWallpaper(RioProfile profile)
{
#if NET40
// XP flavor: no SkiaSharp, so no generation — apply the profile's
// pre-rendered wallpaper (authored on a modern machine) if it exists.
if (string.IsNullOrWhiteSpace(profile.WallpaperPath) || !File.Exists(profile.WallpaperPath))
return;
try
{
CaptureWallpaperOnce();
WallpaperApplier.Apply(profile.WallpaperPath!);
}
catch (Exception ex)
{
SetStatus($"{Status} [wallpaper: {ex.Message}]");
}
#else
string? templatePath = _config().OverlayTemplatePath;
if (string.IsNullOrWhiteSpace(templatePath) || !File.Exists(templatePath))
return;
@@ -257,12 +293,40 @@ public sealed class RioCoordinator : IDisposable
new ProfileWallpaperGenerator().Generate(template, templateDir, profile.OverlayLabels, outPath);
profile.WallpaperPath = outPath;
CaptureWallpaperOnce();
WallpaperApplier.Apply(outPath);
}
catch (Exception ex)
{
SetStatus($"{Status} [wallpaper: {ex.Message}]");
}
#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)
@@ -275,6 +339,7 @@ public sealed class RioCoordinator : IDisposable
private void GoDormant(string status)
{
Teardown();
RestoreWallpaper(); // put the user's own desktop back
SetStatus(status);
}
@@ -306,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
}
}
+35 -5
View File
@@ -2,9 +2,12 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net48</TargetFramework>
<!-- net48 = Windows 10/11 (x64, full app incl. wallpaper maker/SkiaSharp).
net40 = Windows XP SP3 (x86): full runtime + mapping editor; wallpaper
GENERATION is excluded (SkiaSharp has no XP support) — XP applies
pre-rendered wallpapers only. See docs/PLAN.md §Phase 8. -->
<TargetFrameworks>net48;net40</TargetFrameworks>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
@@ -12,12 +15,39 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\RioJoy.Core\RioJoy.Core.csproj" />
<ProjectReference Include="..\RioJoy.Overlay\RioJoy.Overlay.csproj" />
<PropertyGroup Condition="'$(TargetFramework)' == 'net48'">
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<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'">
<!-- net40 has no System.Net.Http assembly for the implicit global using. -->
<Using Remove="System.Net.Http" />
<!-- Wallpaper maker needs the SkiaSharp renderer (RioJoy.Overlay) — modern only. -->
<Compile Remove="Editor\WallpaperMakerForm.cs" />
<Compile Remove="Editor\WallpaperCanvas.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RioJoy.Core\RioJoy.Core.csproj" />
<ProjectReference Include="..\RioJoy.Overlay\RioJoy.Overlay.csproj" Condition="'$(TargetFramework)' == 'net48'" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+53 -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);
@@ -74,10 +75,12 @@ internal sealed class TrayApplicationContext : ApplicationContext
RebuildEditMenu(editMenu);
menu.Items.Add(editMenu);
#if !NET40 // wallpaper maker needs SkiaSharp — modern flavor only (XP applies pre-rendered)
var wallpaperMenu = new ToolStripMenuItem("Wallpaper maker");
wallpaperMenu.DropDownOpening += (_, _) => RebuildWallpaperMenu(wallpaperMenu);
RebuildWallpaperMenu(wallpaperMenu);
menu.Items.Add(wallpaperMenu);
#endif
menu.Items.Add("Import .ini…", null, (_, _) => ImportIniProfiles());
@@ -155,6 +158,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
editMenu.DropDownItems.Add(new ToolStripMenuItem("New profile…", null, (_, _) => OpenEditor(NewProfile())));
}
#if !NET40
// Rebuild the "Wallpaper maker" submenu: one entry per profile.
private void RebuildWallpaperMenu(ToolStripMenuItem wallpaperMenu)
{
@@ -205,6 +209,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
#endif
// Create, register and persist a fresh empty profile with a unique name.
private RioProfile NewProfile()
@@ -226,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();
+48
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
@@ -30,11 +33,56 @@ public static class WallpaperApplier
if (!File.Exists(full))
throw new FileNotFoundException("Wallpaper image not found.", full);
#if NET40
// Windows XP's SPI_SETDESKWALLPAPER accepts only BMP. Wallpapers are
// authored as PNG on a modern machine, so convert beside the source on
// apply (idempotent: reuses an up-to-date .bmp).
if (!full.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase))
{
string bmp = Path.ChangeExtension(full, ".bmp");
if (!File.Exists(bmp) || File.GetLastWriteTimeUtc(bmp) < File.GetLastWriteTimeUtc(full))
{
using var image = System.Drawing.Image.FromFile(full);
image.Save(bmp, System.Drawing.Imaging.ImageFormat.Bmp);
}
full = bmp;
}
#endif
return SystemParametersInfo(
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>
@@ -69,6 +69,26 @@ public class AxisCalibratorTests
// lP = (-100) - 0 = -100 → ×1000/500 = -200 → |·|×32 = 6400.
Assert.Equal(6400, o.Rx);
Assert.Equal(AxisOutputs.Center, o.Rz); // Rz centered when ZR disabled
// The pedal readouts mirror Rx/Ry when there is no mix.
Assert.Equal(o.Rx, cal.LeftPedalOutput);
Assert.Equal(o.Ry, cal.RightPedalOutput);
}
[Fact]
public void PedalOutputs_TrackPedals_EvenWithZrMix()
{
var cal = new AxisCalibrator(); // EnableZR default true
cal.Update(Report(left: -100, right: -100));
AxisOutputs o = cal.Update(Report(left: 0, right: 0));
// The mix centers Rx/Ry, but the pedal readouts still track the pedals
// (same math as the ZR-off Rx/Ry: 6400 for this deflection).
Assert.Equal(AxisOutputs.Center, o.Rx);
Assert.Equal(AxisOutputs.Center, o.Ry);
Assert.Equal(6400, cal.LeftPedalOutput);
Assert.Equal(6400, cal.RightPedalOutput);
}
// --- Joystick (X / Y) ----------------------------------------------------
@@ -108,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
}
}
@@ -50,7 +50,7 @@ public class CockpitPanelTests
public void Keypad_PhysicalLayout_MapsDigitsToAddresses()
{
// Internal keypad: top-left "1" -> 0x51, bottom-middle "0" -> 0x50, "C" -> 0x5C.
IReadOnlyList<PanelButton> all = CockpitPanel.Buttons();
IList<PanelButton> all = CockpitPanel.Buttons();
PanelGroup pad = CockpitPanel.Groups.Single(g => g.Title == "Internal Keypad");
Assert.Equal(0x51, pad.Addresses[0]); // row0 col0 = "1"
@@ -47,7 +47,7 @@ public class SheetLayoutTests
"\"\",\"IsLit\",\"\",\"\",\"0F\"\n" +
"\"\",\"\",\"\",\"\",\"HOME\"";
IReadOnlyList<SheetCell> cells = SheetLayout.Parse(csv);
IList<SheetCell> cells = SheetLayout.Parse(csv);
SheetCell addr = Assert.Single(cells, c => c.Text == "0F");
Assert.Equal(0x0F, addr.Address);
@@ -64,7 +64,7 @@ public class SheetLayoutTests
[Fact]
public void Parse_RealSheet_HasFullAddressMap()
{
IReadOnlyList<SheetCell> cells = SheetLayout.Load(
IList<SheetCell> cells = SheetLayout.Load(
TestRepo.CustomBackground("config-sheet.csv"));
// Every RIO address 0x00..0x6F that exists should appear exactly once as a cell.
@@ -29,12 +29,12 @@ public class RioHidReportTests
{
var report = new RioHidReport();
report.SetAxis(JoyAxis.Z, 0x1234);
Assert.Equal(0x34, report.Bytes[4]);
Assert.Equal(0x12, report.Bytes[5]);
Assert.Equal(0x34, report.ToArray()[4]);
Assert.Equal(0x12, report.ToArray()[5]);
report.SetAxis(JoyAxis.Z, 70000); // over max → clamped to 32767
Assert.Equal(0xFF, report.Bytes[4]);
Assert.Equal(0x7F, report.Bytes[5]);
Assert.Equal(0xFF, report.ToArray()[4]);
Assert.Equal(0x7F, report.ToArray()[5]);
}
[Theory]
@@ -47,7 +47,7 @@ public class RioHidReportTests
{
var report = new RioHidReport();
report.SetHat(hat);
Assert.Equal(expected, report.Bytes[12] & 0x0F);
Assert.Equal(expected, report.ToArray()[12] & 0x0F);
}
[Theory]
@@ -59,10 +59,10 @@ public class RioHidReportTests
{
var report = new RioHidReport();
report.SetButton(button, pressed: true);
Assert.Equal(mask, report.Bytes[byteIndex]);
Assert.Equal(mask, report.ToArray()[byteIndex]);
report.SetButton(button, pressed: false);
Assert.Equal(0, report.Bytes[byteIndex]);
Assert.Equal(0, report.ToArray()[byteIndex]);
}
[Fact]
@@ -80,6 +80,6 @@ public class RioHidReportTests
report.SetButton(1, true);
report.SetButton(2, true);
report.SetButton(1, false);
Assert.Equal(0x02, report.Bytes[13]); // only button 2 remains
Assert.Equal(0x02, report.ToArray()[13]); // only button 2 remains
}
}
@@ -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);
}
}
}
@@ -26,7 +26,7 @@ public class GoobieDataImporterTests
[Fact]
public void LabelsForRow_DropsEmptyValues()
{
IReadOnlyDictionary<string, string> labels =
IDictionary<string, string> labels =
GoobieDataImporter.LabelsForRow(GoobieDataImporter.Parse(Sample));
Assert.True(labels.ContainsKey("b-00"));
@@ -21,7 +21,7 @@ public class OverlayHitTesterTests
[Fact]
public void RegionsAt_ReturnsSmallestFirst()
{
IReadOnlyList<OverlayRegion> hits = OverlayHitTester.RegionsAt(Template(), 100, 50);
IList<OverlayRegion> hits = OverlayHitTester.RegionsAt(Template(), 100, 50);
Assert.Equal(new[] { "small", "big" }, hits.Select(r => r.Name));
}
@@ -133,7 +133,7 @@ public class OverlayLayoutEngineTests
["b-99"] = "ORPHAN", // no such region -> ignored
};
IReadOnlyList<PlacedLabel> placed = Engine.Layout(template, labels);
IList<PlacedLabel> placed = Engine.Layout(template, labels);
Assert.Equal(2, placed.Count);
Assert.Equal("b-00", placed[0].RegionName);
@@ -35,7 +35,7 @@ public class OverlayRenderIntegrationTests
Assert.Equal(template.Width, baseImg.Width);
Assert.Equal(template.Height, baseImg.Height);
IReadOnlyDictionary<string, string> labels =
IDictionary<string, string> labels =
GoobieDataImporter.LabelsForRow(GoobieDataImporter.Load(TestRepo.CustomBackground("TEST.data")));
using SKBitmap rendered = new SkiaOverlayRenderer().Render(template, labels, baseImg);
@@ -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()
{
@@ -41,7 +41,7 @@ public class PacketBuilderTests
byte[] packet = PacketBuilder.Build(RioCommand.AnalogReply, payload);
Assert.Equal((byte)RioCommand.AnalogReply, packet[0]);
Assert.Equal(payload, packet.AsSpan(1, packet.Length - 2).ToArray());
Assert.Equal(RioChecksum.Compute(packet.AsSpan(0, packet.Length - 1)), packet[^1]);
Assert.Equal(payload, packet.Skip(1).Take(packet.Length - 2).ToArray());
Assert.Equal(RioChecksum.Compute(packet, 0, packet.Length - 1), packet[packet.Length - 1]);
}
}
@@ -5,7 +5,7 @@ namespace RioJoy.Core.Tests.Protocol;
public class PacketParserTests
{
private static List<RioRxEvent> FeedAll(PacketParser parser, ReadOnlySpan<byte> data)
private static List<RioRxEvent> FeedAll(PacketParser parser, byte[] data)
{
var events = new List<RioRxEvent>();
foreach (byte b in data)
@@ -8,7 +8,7 @@ public class RioChecksumTests
[Fact]
public void Empty_IsZero()
{
Assert.Equal(0, RioChecksum.Compute(ReadOnlySpan<byte>.Empty));
Assert.Equal(0, RioChecksum.Compute(new byte[0]));
}
[Fact]
@@ -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>
+8 -4
View File
@@ -1,5 +1,6 @@
using System.Diagnostics;
using AxisOutputs = RioJoy.Core.Calibration.AxisOutputs;
using AxisReadout = RioJoy.Core.Calibration.AxisReadout;
using RioJoy.Core;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
@@ -81,19 +82,22 @@ public class RioRuntimeTests
var recorder = new RecordingSink();
using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder);
AxisOutputs? seen = null;
AxisReadout? seen = null;
runtime.AxesUpdated += axes => seen = axes;
runtime.Start();
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// All raw axes at zero → joystick X/Y centered, same values the sink receives.
// All raw axes at zero → joystick X/Y centered, same values the sink receives;
// both pedals at rest → pedal readouts empty.
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
await WaitUntilAsync(() => seen is not null);
Assert.Equal(AxisOutputs.Center, seen!.Value.X);
Assert.Equal(AxisOutputs.Center, seen.Value.Y);
Assert.Equal(AxisOutputs.Center, seen!.Value.Axes.X);
Assert.Equal(AxisOutputs.Center, seen.Value.Axes.Y);
Assert.Equal(0, seen.Value.LeftPedal);
Assert.Equal(0, seen.Value.RightPedal);
cts.Cancel();
await run;
@@ -23,13 +23,13 @@ internal sealed class FakeTransport : IRioTransport
/// <summary>Signal that no more inbound data will arrive (transport closed).</summary>
public void CompleteIncoming() => _incoming.Writer.TryComplete();
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
public async Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken)
{
while (await _incoming.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
if (_incoming.Reader.TryRead(out byte[]? chunk))
{
chunk.AsSpan().CopyTo(buffer.Span);
Array.Copy(chunk, buffer, chunk.Length);
return chunk.Length;
}
}
@@ -37,10 +37,10 @@ internal sealed class FakeTransport : IRioTransport
return 0; // completed
}
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
public Task WriteAsync(byte[] data, CancellationToken cancellationToken)
{
_writes.Writer.TryWrite(data.ToArray());
return default; // net48 has no ValueTask.CompletedTask; default(ValueTask) is the completed task
_writes.Writer.TryWrite((byte[])data.Clone());
return Task.CompletedTask;
}
/// <summary>Read the next outbound write, failing if none arrives in time.</summary>
@@ -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();
}
}

Some files were not shown because too many files have changed in this diff Show More