Author SHA1 Message Date
CydandClaude Fable 5 19a1abbff2 vPLASMA slims to a bare glass hardwired to COM12
The window is now just the display: 640x160 dot field (5 px pitch,
4 px bezel) plus the title bar, which carries the port status. COM12 —
the device end of the plasma's null-modem pair — is hardwired and
opened at startup, with a retry timer that keeps trying while the port
is missing or busy and reopens it if it dies. The control strip, port
picker, counters, and wire log are gone; the glass keeps two gestures:
double-click cycles the self-test pages, right-click resets the
display to its power-on state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:06:36 -05:00
CydandClaude Fable 5 a1b7dae3da vPLASMA: companion app emulating the cockpit plasma display
The cockpit's second serial device joins vRIO: the 128x32 dot-matrix
plasma on COM2 (9600 8N1) that the game draws mission text and status
graphics on. VPlasma.App opens the device end of a COM port, decodes
the display's command stream, and renders the matrix in plasma orange.

The command set is recovered from two Tesla 4.10 artifacts: the game's
driver (L4PLASMA.CPP — ESC P packed-bitmap row writes, ESC G 0 cursor
hide at boot) and the factory test tool PLASMA.EXE, whose data segment
pairs each command literal with its printf description (BS/HT/LF/VT/CR
motion, ESC @ clear, ESC L home, ESC G cursor, ESC K fonts, ESC H
attributes: intensity/underline/reverse/flash). Text renders through
the classic public-domain 5x7 set standing in for the lost ROM glyphs;
fonts 0-3 give 21x4 cells, fonts 4-7 the doubled 10x2. A Self test
cycles banner/charset/graphics pages through the same parser the wire
feeds, and the wire log shows every decoded command.

Verified end-to-end over the second com0com pair (host writing COM2,
vPLASMA listening on COM12). The verify skill gains the cross-process
combo-box lesson: string-carrying CB_* messages hang across processes,
so select ports by locally computed index via CB_SETCURSEL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:22:00 -05:00
CydandClaude Fable 5 767473d1cf Project verify skill: drive the GUI via PostMessage + PrintWindow
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:47:35 -05:00
CydandClaude Fable 5 007d15e668 Wire carries stick-up as negative; Invert Y opts back to physical
The old Invert joystick Y box (checked by default) is now the wire's
native convention: full up = -80 in AnalogReply with the flag off.
Checking the renamed Invert Y sends the physical direction (up = +80).

Control strip cleanup: test-mode buttons and the firmware selector are
gone (the device still reports 4.2), Keyboard/Gamepad/Invert Y share
one line, the help text drops the bindings sentence, and the wire log
gets the reclaimed height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:47:25 -05:00
CydandClaude Opus 4.8 391c53f294 Capture a chosen keyboard's keys without window focus (Raw Input)
The WinForms focus path only sees keys while vRIO is foreground and can't
tell one keyboard from another, so vRIO went deaf the moment the sim took
focus. The new "Capture keyboard" picker in the Input panel taps one
physical keyboard through the Win32 Raw Input API (RIDEV_INPUTSINK) and
routes its keys to the panel even in the background — the input-side twin
of the lamp mirror writing that keyboard's LEDs under the same condition.

Raw Input observes without intercepting, so keys still reach the focused
app; the picker defaults to "All keyboards (focus only)", preserving the
prior focus-bound behavior, and only registers Raw Input while a specific
device is selected. While capturing, the focus path stands down but still
swallows bound keys so they can't click panel buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:31:49 -05:00
CydandClaude Fable 5 bec3bb1e4a Sparse package icons need resources.pri at the external location
The shell resolves Square44x44Logo through the package resource index;
this package never had a resources.pri, so every icon lookup failed and
Start/taskbar showed a generic icon regardless of the Assets PNGs.
Empirically (SHLoadIndirectString against the live package), with
AllowExternalContent MRT loads resources from the EXTERNAL location,
not the manifest's folder — so Register-vRIO.ps1 now copies Assets\ and
resources.pri next to the exe before registering. Make-Assets.ps1
regenerates the PNGs from vwe.ico and rebuilds the pri (needs makepri
from the Windows SDK / SDK.BuildTools NuGet). Package version bumped;
the MrtCache is keyed by package full name, so same-version
re-registration serves stale lookups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:43:37 -05:00
CydandClaude Fable 5 6923c9f252 Panel readout shows the wire values while the dot stays physical
With Invert Y checked the readout claimed "Y 80" while the AnalogReply
carried -80. The flip is factored into VRioDevice.WireY, shared by the
reply builder and the new GetWireAxis accessor; the canvas readout uses
a WireAxisProvider fed from it, and toggling the checkbox repaints so
the sign flips in place. Dot and gauges keep tracking the stick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:33:28 -05:00
CydandClaude Fable 5 23204ba9c6 Package assets carry the vwe.ico artwork so the taskbar shows it
With package identity the taskbar/Start icon comes from the manifest's
Square44x44Logo assets, not the exe's embedded icon — the placeholder
PNGs are replaced with renders of vwe.ico, plus the targetsize /
altform-unplated variants the Win11 taskbar prefers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:28:31 -05:00
CydandClaude Fable 5 97e78b0eea App icon: vwe.ico as the exe and window icon
ApplicationIcon embeds it in the exe (Explorer, taskbar); MainForm pulls
the same embedded icon for the title bar via ExtractAssociatedIcon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:22:30 -05:00
CydandClaude Fable 5 ce5ed1117a Invert Y on the wire, not in the router: the panel keeps tracking the stick
InputRouter.InvertJoystickY flipped the stored axis, so the X/Y dot and
readout moved opposite the physical stick. The toggle now lives on
VRioDevice and negates joystick Y only while building AnalogReply: local
state keeps the physical direction for every source (pad, keys, panel
drags) and only the host sees the flip. Negating a full -8192 deflection
lands one past the 14-bit Max, so the reply clamps it to 8191.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:21:57 -05:00
CydandClaude Fable 5 41f6ef364d Invert-Y input toggle; git-stamped version in the window title
- InputRouter.InvertJoystickY flips the composed joystick Y (keys and
  pad alike) before wire scaling; panel drags write the device directly
  and stay untouched. New "Invert joystick Y" checkbox in the Input
  group, default on.
- StampGitVersion target bakes "YYYY.MM.DD (shortsha)" of HEAD into
  InformationalVersion; the title shows it via Application.ProductVersion
  so a running build can be matched to its vYYYY.MM.DD release tag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:46:02 -05:00
CydandClaude Fable 5 f77cd55b11 pkg: package identity so Dynamic Lighting grants background control
The Settings "Background light control" list only offers apps that have
BOTH package identity and the com.microsoft.windows.lighting
windows.appExtension in their manifest (declaration verified against
Lenovo's LegionLightingController manifest) — without a grant, Windows
hands the LEDs to vRIO only while it is the foreground window, i.e.
never during gameplay.

pkg/AppxManifest.xml is a sparse package (external location) that
grants the existing VRio.App.exe identity as-is: win32App runtime
behavior, runFullTrust + unvirtualizedResources, and the lighting
app-extension. Register-vRIO.ps1 registers/unregisters it against any
exe folder (repo build or an extracted dist zip); unsigned registration
needs Developer Mode. Identity is granted through shell activation
only — launch from the Start menu entry or an AUMID taskbar pin, not by
double-clicking the exe (the script prints this warning).

Verified end-to-end on this machine: process reports the package full
name, vRIO appears in the background-control list, and the wire log
shows the withheld → available transition once granted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:08:38 -05:00
CydandClaude Fable 5 31c3a910f0 Mirror lamps onto RGB keyboards via Windows Dynamic Lighting
Opt-in checkbox in the Input group: keys bound to lamp-capable button
addresses glow with the panel's palette (red family, yellow for the
Secondary/Screen columns) and blink at the panel's own flash rates, so
keyboard and on-screen panel stay in step; unbound keys are blacked out
so the keyboard reads as the button field. Zone-lit keyboards without
per-key addressing (e.g. this laptop's 24-zone ITE board) fall back to
mirroring the strongest current lamp board-wide. A picker under the
checkbox narrows the mirror to one keyboard when several are attached
(hot-plug aware; releases the others back to Windows).

KeyboardLampMirror claims keyboard-kind LampArrays via a DeviceWatcher,
repaints at 100 ms only when colors actually change, survives paint
faults (a leaked Timer exception would kill the process), and logs
attach/detach plus IsAvailable transitions with a pointer to the
Dynamic Lighting background-control setting. Disabling releases every
claim so the LEDs revert to the ambient scene.

Also: the wire log gets a horizontal scrollbar (long lines don't wrap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:08:38 -05:00
CydandClaude Fable 5 1e79711181 Drop the reply-wedge emulation controls from the UI
The "Emulate the v4.2 reply-wedge bug" checkbox and "Wedge analog now"
button are not needed for the renderer-comparison work, so remove them
from the control strip: Device group shrinks 60px and the Input group,
counters, help text, and wire log move up to match (the log gains the
height). EmulateReplyWedge/WedgeAnalogNow stay in VRio.Core with their
tests; only the UI entry points are gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:41:54 -05:00
CydandClaude Fable 5 dc9a294101 Writer thread survives write faults instead of dying mid-run
A single failed port write killed the paced writer thread permanently
while the port still reported open, so every later byte (ACKs, the
CheckRequest handshake) queued forever: wire tap showed 2 of 3 bytes of
a stray ButtonPressed at t=54ms, then 1167 unanswered CheckRequests.
The trigger was com0com flow control with the game side not yet open --
the third byte blocked past WriteTimeout, threw, and the catch returned.

A real UART cannot wedge; it shifts bits into the line whether or not
anyone listens. On a write fault, drop the stalled byte plus the stale
backlog, log the stall/recovery transition once, and keep the writer
alive -- TX resumes as soon as the host drains its end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:41:16 -05:00
CydandClaude Fable 5 1eded793af CheckRequest: send the real board's test-mode handshake
The host waits up to 5s after CheckRequest for TestModeChange ENTER
(8C 01 0D) before anything else, and sends no requests until the
matching EXIT (8C 00 0C) arrives; vRIO jumped straight to the
CheckReply dump, so hosts logged "RIO never came back from check
request" and skipped the version exchange. Bracket the per-board
BoardOk replies with enter/exit, byte-for-byte what the real v4.2
board sends on the wire tap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:13:06 -05:00
CydandClaude Fable 5 44dc8e48e7 Default bindings: pad-mandatory axes, full keyboard button field
Rework the shipped default profile around a required Xbox controller,
refined from live-game testing:

- All five axes are pad-only now (left stick / triggers / right stick);
  the key-axis bindings (arrows/WASD-style deflect and rate) are gone
  from the defaults, though the grammar remains supported.
- Keyboard becomes the button field, geometry mirroring the panel:
  number row + QWERTY row = upper MFD bank, home + bottom rows =
  lower MFD bank as two 4-key blocks with an unbound gap key (G / B)
  standing in for the panel keypad gap, F1-F6 / F7-F12 = the
  Secondary / Screen columns top-to-bottom (0x16/0x17, 0x1E/0x1F
  deliberately unmapped), numpad = the full internal keypad with the
  hex keys on the operators (/ * - + . Enter = A-F).
- Hat rides the arrow keys, Main stays on Space; Pinky / Middle /
  Upper / Panic are pad-only so no panic key sits inside the MFD field.
- Pad B and LeftShoulder swap to Middle / Panic.
- Rest of the throttle column and the external keypad stay unmapped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:17:40 -05:00
CydandClaude Fable 5 d26000f906 Pace TX bytes at the 9600-baud wire rate
vRIO wrote whole reply packets in one SerialPort.Write; through a
com0com null-modem (no UART) the host saw the bytes ~30-40 us apart,
a burst no real board can produce and the prime suspect in a rocky
game init. Replies now leave one byte per 10-bit frame (~1.04 ms), so
the 45-byte CheckRequest response takes ~47 ms like real hardware
(measured 0.94-1.10 ms gaps over a com0com pair).

- Transmit frames queue to a writer thread; each byte is scheduled
  against a monotonic slot deadline slot = max(prev + period, now),
  so the stream averages true 9600 baud without bursting after idle.
- After a write the schedule is floored at the actual emission time:
  a late wake-up can never be followed by a catch-up burst - two
  frames closer than the frame time is structurally impossible.
- 1 ms system timer resolution while the port is open (timeBeginPeriod)
  so the pacer sleeps most of each gap and only spins the last ~1.8 ms.
- Side benefit: UI clicks no longer block on SerialPort.Write (a
  stalled port could previously hang the UI for the 2 s write timeout).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:31:39 -05:00
CydandClaude Fable 5 e590b89c47 Keyboard and Xbox-gamepad input drive the panel
New input pipeline: sources feed an InputRouter (VRio.Core.Input) that
calls the same VRioDevice press/release/axis entry points the mouse
canvas uses, so routed input is indistinguishable on the wire.

- Bindings live in %APPDATA%\vRIO\bindings.txt (plain text, commented
  defaults written on first run; Reload/Edit buttons in the new Input
  group). Keys and pad buttons press any RIO address, momentary or
  toggle; axes bind in normalized units of each axis realistic travel
  window with deflect (spring-back), rate (position holds), deadzone,
  and invert options - RioAxisRange supplies the wire signs.
- Router suppresses key auto-repeat, edge-detects pad buttons, and
  hold-counts per address so overlapping sources press once and
  release last; axes only write when the composed value changes, so
  mouse drags keep working while sources idle.
- Xbox pad via a zero-dependency XInput P/Invoke poller (xinput1_4,
  fallback xinput9_1_0), throttled rescan while disconnected.
- MainForm intercepts bound keys in ProcessCmdKey so arrows/space
  reach the panel instead of moving focus; keys release on focus
  loss; center-axes and host ResetRequest reset the rate integrators.
- Canvas highlights router-held addresses like clicks.
- Defaults: arrows=stick, W/S=throttle, Q/E=pedals, numpad=internal
  keypad, IJKL/Space=hat+main; pad left stick/triggers/right stick =
  stick/pedals/throttle-rate, ABXY/dpad/shoulders = named buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:30:55 -05:00
47 changed files with 4507 additions and 67 deletions
+68
View File
@@ -0,0 +1,68 @@
---
name: verify
description: Build, launch, and drive the vRIO WinForms app to verify changes at the GUI surface — screenshots via PrintWindow, input via PostMessage, no focus stealing.
---
# Verifying vRIO changes
Build + tests (tests are CI's job; run the app for verification):
```powershell
dotnet build vrio.sln -v q -nologo # Debug
# exe: src\VRio.App\bin\Debug\net48\VRio.App.exe
# exe: src\VPlasma.App\bin\Debug\net48\VPlasma.App.exe (companion display)
```
Everything below applies to vPLASMA too. It auto-opens **COM12** on
launch (hardwired; status in the title bar), so its serial path tests
end-to-end with no UI driving: the script just plays the game writing
**COM2** (second com0com pair; vRIO/RIOJoy use COM1⇄COM11). Double-click
the glass = self-test pages, right-click = reset.
Killing a writer mid-stream can leave stale bytes queued in the com0com
buffer — the next session's byte counter will run high; re-run clean
before trusting counts.
## Driving the GUI without touching the user's desktop
This is the user's live desktop — **never** use SetForegroundWindow +
SendKeys/mouse_event: focus-stealing prevention leaves the app behind
VS Code and your clicks land in the user's windows. Instead:
- **Screenshots**: `PrintWindow(hwnd, dc, 2)` (PW_RENDERFULLCONTENT) —
captures the window even when occluded. CopyFromScreen captures
whatever is on top; don't use it.
- **Canvas input** (stick drags, cell clicks): PostMessage
`WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP` straight to the canvas
child HWND. Find it by descending `RealChildWindowFromPoint` from the
form. Canvas geometry is compile-time constant (PanelCanvas.cs):
CellW=66, BoxXY = x 416..496, y 6..74 (X/Y stick box).
The **first** posted drag after launch can half-land (only the
button-down registers); do a throwaway drag first, then the measured
one, screenshot **while held** (spring-back recenters on up).
- **Checkboxes/buttons**: WinForms controls expose no UIA TogglePattern
(they surface as bare Panes) and don't answer BM_GETCHECK — but
`SendMessage(hwnd, BM_CLICK, 0, 0)` works. Get the HWND from the UIA
element's NativeWindowHandle (find by Name).
- **Combo boxes** (COM port pickers): string-carrying messages
(`CB_FINDSTRINGEXACT`, `CB_GETLBTEXT`) do **not** marshal across
processes — the SendMessage hangs. Compute the item index locally
(both apps fill from `SerialPort.GetPortNames()` sorted
OrdinalIgnoreCase) and send index-only `CB_SETCURSEL`; the apps read
`SelectedItem` lazily so no CBN_SELCHANGE notification is needed.
Find the combo child HWND via EnumChildWindows + GetClassName
containing `COMBOBOX` (UIA ClassName "ComboBox" doesn't match).
- Pattern that works: Add-Type user32 P/Invokes, Start-Process the exe,
drive via messages, PrintWindow screenshot to the scratchpad, kill
the process in `finally`.
## Environment gotchas
- Bindings load from `%APPDATA%\vRIO\bindings.txt` (the user's file),
not the built-in defaults — arrow keys are bound to Hat *buttons*,
not the joystick axis. Drive axes via the X/Y box.
- A real XInput gamepad is connected on this machine ("Controller #1
connected"); the router only writes an axis when its composed value
changes, so a centered pad won't fight a canvas drag.
- The wire readout (top center, green) shows `GetWireAxis` values —
joystick Y is natively negated on the wire (stick up = 80) unless
"Invert Y" is checked.
+98 -12
View File
@@ -3,7 +3,9 @@
A software replica of the cockpit **RIO** (Remote Input/Output) board: it opens
a COM port and speaks the **device side** of the RIO serial protocol, so any
host that expects the real hardware — most importantly
[RIOJoy](../riojoy/) — can talk to it without a cockpit attached.
[RIOJoy](../riojoy/) — can talk to it without a cockpit attached. (The
cockpit's other serial device gets the same treatment: see
[vPLASMA](#vplasma--the-companion-plasma-display) below.)
The window is an interactive version of the cockpit control panel that
RIOJoy's profile editor draws (the same functional layout from the original
@@ -23,6 +25,42 @@ controls:
- Cells shade to the **lamp state the host commands** (`LampRequest`:
off / dim / bright, with slow/med/fast flash), so RIOJoy's press-feedback
lights the on-screen panel just like the real buttons.
- **Lamps can mirror onto an RGB keyboard** (*Mirror lamps on RGB keyboard*,
via Windows 11 Dynamic Lighting): keys bound to lamp-capable buttons glow
with the panel's palette and blink in step with the on-screen flash modes;
per-key keyboards get the full button field, zone-lit keyboards show the
strongest current lamp board-wide, and a picker narrows the mirror to one
keyboard when several are attached. For the LEDs to stay lit while the
*game* has focus, vRIO needs package identity: run `pkg\Register-vRIO.ps1`
once (Developer Mode required; pass the exe folder for a deployed copy),
launch vRIO from its **Start menu entry** (a direct exe launch runs without
identity), then drag vRIO to the top of *Settings → Personalization →
Dynamic Lighting → Background light control*.
- **Keyboard and Xbox (XInput) controller input** drive the same controls
through a bindings file (`%APPDATA%\vRIO\bindings.txt`, created with
commented defaults on first run — *Edit bindings…* opens it, *Reload
bindings* applies edits live). Keys and pad buttons press any RIO address;
pad sticks/triggers and keys drive the axes in each axis' realistic travel
window, with deflect (spring-back), rate (throttle-style, position holds),
deadzone, and invert options. The default profile makes the controller
mandatory: all five axes live on the pad (left stick / triggers / right
stick = stick / pedals / throttle) and the keyboard covers the button
field — number row + QWERTY row = the upper MFD bank, home + bottom
rows = the lower MFD bank (4-key blocks split by an unbound gap key),
F1F6 / F7F12 = the Secondary / Screen columns, numpad = internal
keypad (hex keys on the operators), arrows + Space = hat + main, with
ABXY / dpad / shoulders on the pad's named buttons.
- **Capture one keyboard in the background** (*Capture keyboard* picker in the
Input panel): by default keyboard input follows the focused window, so vRIO
goes deaf the moment the sim takes focus. Selecting a specific keyboard
instead taps it through the Windows **Raw Input** API (`RIDEV_INPUTSINK`), so
its keys drive the panel *while the game has focus* — the input-side twin of
the lamp mirror writing that keyboard's LEDs under the same condition. Raw
Input observes without intercepting: the keystroke still reaches the focused
app, so this is meant for a keyboard *dedicated* to the panel, not the one you
also type on. Leaving the picker on *All keyboards* keeps the plain
focus-only behavior. (No package identity needed — unlike the lamp side, this
is a plain Win32 tap.)
## Wire behavior
@@ -32,39 +70,87 @@ device behavior grounded in the **real v4.2 firmware dump**
(`riojoy/rio-firmware/RIOv4_2-ANALYSIS.md`):
- ACKs every well-formed packet; NAKs bad-checksum packets.
- `CheckRequest` → one `BoardOk` CheckReply per board (the 11 boards from the
legacy firmware's table). `VersionRequest` → configurable version,
default **4.2**.
- **TX is paced at the wire rate** — one byte per 10-bit frame (~1.04 ms at
9600 8N1), never closer. A virtual null-modem has no UART, so unpaced
writes would land at the host in microsecond bursts no real board could
produce; vRIO's writer thread schedules each byte against a monotonic
slot deadline instead, so e.g. the 51-byte CheckRequest response takes
the same ~53 ms it takes real hardware.
- `CheckRequest` → the real board's init handshake: `TestModeChange` **enter**,
one `BoardOk` CheckReply per board (the 11 boards from the legacy firmware's
table), then `TestModeChange` **exit**. Hosts wait (≤5 s per step) on both
test-mode packets and send nothing while test mode is active, so the exit is
mandatory. `VersionRequest` → configurable version, default **4.2**.
- `ResetRequest` re-zeroes the targeted axis (or all).
- A NAK re-sends the last event up to **4 times**, then gives up with a
RESTART byte — the real board's retry budget.
- Optional **v4.2 reply-wedge emulation**: after retry exhaustion (or the
"Wedge analog now" button), analog requests are silently dropped — still
ACK'd, RX path alive — until a host `ResetRequest`, reproducing the
latch-leak fault the firmware analysis documents. Use it to exercise
RIOJoy's 5-second no-analog recovery watchdog.
- Optional **v4.2 reply-wedge emulation** (in `VRio.Core`; the UI toggles
were removed): after retry exhaustion, analog requests are silently
dropped — still ACK'd, RX path alive — until a host `ResetRequest`,
reproducing the latch-leak fault the firmware analysis documents.
## Using it with RIOJoy on one PC
The two apps need a crossed serial link. Install a
[com0com](https://com0com.sourceforge.net/) virtual null-modem pair
(e.g. `COM5 ⇄ COM6`), then:
(e.g. `COM1 ⇄ COM11`), then:
1. Run `VRio.App`, pick `COM5`, **Open**.
2. Point RIOJoy at `COM6`.
1. Run `VRio.App`, pick `COM11`, **Open**.
2. RIOJoy will always poit to `COM1`.
RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms
analog polling drives the "analog polls served" counter, and every click on
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
a null-modem cable work the same way.
## vPLASMA — the companion plasma display
The cockpit's second serial device is the **plasma display**: a 128×32
dot-matrix panel on COM2 (9600 8N1, no flow control) that the game draws
mission text and status graphics on. `VPlasma.App` is its software replica:
a bare-glass window that opens **COM12** (the device end of the plasma's
null-modem pair) on startup — retrying while the port is missing or busy,
port status in the title bar — decodes the display's command stream, and
renders the dot matrix in plasma orange, text mode included.
The command set was recovered from two Tesla 4.10 artifacts:
`CODE\RP\MUNGA_L4\L4PLASMA.CPP` (the game's driver — it renders everything
into a local 1bpp buffer and streams changed rows as `ESC P` graphics
writes, hiding the cursor with `ESC G 0` at boot) and the factory test tool
`VWETEST\VGLTEST\PLASMA.EXE` (the text mode: BS/HT/LF/VT/CR cursor motion,
`ESC @` clear, `ESC L` home, `ESC G` cursor visibility, `ESC K` font
select, `ESC H` attributes — intensity/underline/reverse/flash). The full
recovered grammar lives in `src/VPlasma.Core/Protocol/PlasmaProtocol.cs`.
- **Graphics** — `ESC P screen y xbyte width rows` + packed 1bpp row data,
MSB = leftmost pixel. This is everything the game sends, so it is the
wire path a pod's plasma actually sees.
- **Text** — printable ASCII renders through a 5×7 font at a cursor:
fonts 03 give a 21×4 cell grid, fonts 47 the same glyphs doubled to
10×2. Half-intensity draws dimmer, reverse/underline render in the
cell, flashing text (and the flashing cursor) blink on the glass. The
panel's own ROM glyphs are lost with the hardware, so the classic
public-domain 5×7 set stands in.
- **Double-click** the glass to cycle three self-test pages (banner,
charset, graphics pattern) through the same parser the wire feeds —
useful without a host. **Right-click** resets the display to its
power-on state.
- Pair it with the game like vRIO ↔ RIOJoy: a second com0com null-modem
pair `COM2 ⇄ COM12` — the game's plasma output writes COM2, vPLASMA
listens on COM12. The plasma never talks back, so there is no transmit
path to pace.
## Repository layout
| Path | Contents |
|------|----------|
| `src/VRio.Core` | Protocol framing/builder/parser, the `VRioDevice` state machine, serial pump, panel layout data (class library) |
| `src/VRio.App` | WinForms panel UI |
| `src/VPlasma.Core` | Plasma command-stream parser, the `VPlasmaDevice` display state machine, 5×7 font, serial listener (class library) |
| `src/VPlasma.App` | WinForms dot-matrix display UI |
| `pkg` | Sparse-package manifest + registration script: grants VRio.App.exe the package identity Dynamic Lighting's background-control list requires |
| `tests/VRio.Core.Tests` | xUnit tests for the protocol + device engine |
| `tests/VPlasma.Core.Tests` | xUnit tests for the plasma parser + display engine |
## Building
+21
View File
@@ -13,6 +13,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C4993638
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.Core.Tests", "tests\VRio.Core.Tests\VRio.Core.Tests.csproj", "{986638BB-F289-4480-8575-F1699D201590}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Core", "src\VPlasma.Core\VPlasma.Core.csproj", "{39E7C28F-8B07-495C-A887-21F2F6AF9A86}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.App", "src\VPlasma.App\VPlasma.App.csproj", "{72D03B3A-7D4E-496C-8DA9-596DFC91704E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Core.Tests", "tests\VPlasma.Core.Tests\VPlasma.Core.Tests.csproj", "{F31F1D86-546A-4B0C-A283-C04FAAC49F46}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -34,10 +40,25 @@ Global
{986638BB-F289-4480-8575-F1699D201590}.Debug|Any CPU.Build.0 = Debug|Any CPU
{986638BB-F289-4480-8575-F1699D201590}.Release|Any CPU.ActiveCfg = Release|Any CPU
{986638BB-F289-4480-8575-F1699D201590}.Release|Any CPU.Build.0 = Release|Any CPU
{39E7C28F-8B07-495C-A887-21F2F6AF9A86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{39E7C28F-8B07-495C-A887-21F2F6AF9A86}.Debug|Any CPU.Build.0 = Debug|Any CPU
{39E7C28F-8B07-495C-A887-21F2F6AF9A86}.Release|Any CPU.ActiveCfg = Release|Any CPU
{39E7C28F-8B07-495C-A887-21F2F6AF9A86}.Release|Any CPU.Build.0 = Release|Any CPU
{72D03B3A-7D4E-496C-8DA9-596DFC91704E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{72D03B3A-7D4E-496C-8DA9-596DFC91704E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72D03B3A-7D4E-496C-8DA9-596DFC91704E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72D03B3A-7D4E-496C-8DA9-596DFC91704E}.Release|Any CPU.Build.0 = Release|Any CPU
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{80312F43-09BF-4F09-A0FA-A60FDF86274D} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
{2D1A482C-D907-47EB-9830-B78132154E57} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
{986638BB-F289-4480-8575-F1699D201590} = {C4993638-7EB6-47A9-897C-976DB9939601}
{39E7C28F-8B07-495C-A887-21F2F6AF9A86} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
{72D03B3A-7D4E-496C-8DA9-596DFC91704E} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
{F31F1D86-546A-4B0C-A283-C04FAAC49F46} = {C4993638-7EB6-47A9-897C-976DB9939601}
EndGlobalSection
EndGlobal
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sparse package ("package with external location"): grants the plain win32
VRio.App.exe a package identity without changing how it is built or run.
vRIO needs identity so Windows Dynamic Lighting can list it under
Settings → Personalization → Dynamic Lighting → Background light control —
without it the keyboard lamp mirror only works while vRIO has focus.
Register (Developer Mode, unsigned) with the exe's folder as the external
location: see Register-vRIO.ps1 next to this file.
-->
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap uap3 uap10 rescap">
<!-- Bump Version on any Assets/resources.pri change: the shell's MrtCache
is keyed by package full name and serves stale (even failed) icon
lookups for a re-registered same-version package. -->
<Identity Name="VWE.vRIO"
ProcessorArchitecture="neutral"
Publisher="CN=VWE"
Version="1.0.0.1" />
<Properties>
<DisplayName>vRIO</DisplayName>
<PublisherDisplayName>VWE</PublisherDisplayName>
<Logo>Assets\logo150.png</Logo>
<uap10:AllowExternalContent>true</uap10:AllowExternalContent>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.26200.0" />
</Dependencies>
<Resources>
<Resource Language="en-us" />
</Resources>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<rescap:Capability Name="unvirtualizedResources" />
</Capabilities>
<Applications>
<Application Id="vRIO"
Executable="VRio.App.exe"
uap10:TrustLevel="mediumIL"
uap10:RuntimeBehavior="win32App">
<uap:VisualElements DisplayName="vRIO"
Description="Virtual RIO cockpit device emulator"
Square150x150Logo="Assets\logo150.png"
Square44x44Logo="Assets\logo44.png"
BackgroundColor="transparent" />
<Extensions>
<!-- Advertise as a lighting-controller app so Dynamic Lighting offers
vRIO in Settings' "Background light control" picker. -->
<uap3:Extension Category="windows.appExtension">
<uap3:AppExtension Name="com.microsoft.windows.lighting"
Id="vrio"
DisplayName="vRIO"
Description="vRIO cockpit lamp mirror"
PublicFolder="Public" />
</uap3:Extension>
</Extensions>
</Application>
</Applications>
</Package>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+62
View File
@@ -0,0 +1,62 @@
<#
.SYNOPSIS
Regenerates pkg\Assets\*.png from src\VRio.App\vwe.ico and rebuilds
resources.pri — run after changing the icon artwork.
The shell resolves the manifest's Square44x44Logo through the package
resource index; without resources.pri every icon lookup fails and the
Start menu / taskbar show a generic icon. After regenerating, bump the
Version in AppxManifest.xml (the shell's MrtCache is keyed by package
full name and serves stale lookups otherwise) and re-register with
Register-vRIO.ps1.
.PARAMETER MakePri
Path to makepri.exe. Auto-detected from the Windows SDK if installed;
otherwise extract it from the Microsoft.Windows.SDK.BuildTools NuGet
package (it is a zip: bin\<ver>\x64\makepri.exe).
#>
param([string]$MakePri)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
if (-not $MakePri) {
$MakePri = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter makepri.exe -ErrorAction SilentlyContinue |
Where-Object FullName -like '*\x64\*' | Select-Object -First 1 -ExpandProperty FullName
if (-not $MakePri) {
throw 'makepri.exe not found — install the Windows SDK or pass -MakePri (see help).'
}
}
$assets = Join-Path $PSScriptRoot 'Assets'
$ico = New-Object System.Drawing.Icon((Join-Path $PSScriptRoot '..\src\VRio.App\vwe.ico'), 32, 32)
$src = $ico.ToBitmap()
function Save-Png([int]$canvas, [int]$content, [string]$name) {
$bmp = New-Object System.Drawing.Bitmap($canvas, $canvas)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$off = [int](($canvas - $content) / 2)
$g.DrawImage($src, $off, $off, $content, $content)
$g.Dispose()
$bmp.Save((Join-Path $assets $name), [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
Write-Host " $name"
}
Save-Png 44 44 'logo44.png'
Save-Png 150 96 'logo150.png' # medium tile: 3x upscale centered, crisper than full-bleed
foreach ($ts in 16, 24, 32, 48) {
# targetsize / altform-unplated variants are found by naming convention;
# unplated is what the Win11 taskbar prefers.
Save-Png $ts $ts "logo44.targetsize-$ts.png"
Save-Png $ts $ts "logo44.targetsize-${ts}_altform-unplated.png"
}
$src.Dispose(); $ico.Dispose()
$cf = Join-Path $env:TEMP 'vrio-priconfig.xml'
& $MakePri createconfig /cf $cf /dq en-US /o | Out-Null
& $MakePri new /pr $PSScriptRoot /cf $cf /mn (Join-Path $PSScriptRoot 'AppxManifest.xml') /of (Join-Path $PSScriptRoot 'resources.pri') /o | Out-Null
Remove-Item $cf
Write-Host 'resources.pri rebuilt. Now bump Version in AppxManifest.xml and re-register.'
+1
View File
@@ -0,0 +1 @@
Public folder for the com.microsoft.windows.lighting app extension (required by the manifest schema).
+60
View File
@@ -0,0 +1,60 @@
<#
.SYNOPSIS
Grants VRio.App.exe package identity by registering the sparse package
(pkg\AppxManifest.xml) with the exe's folder as the external location.
Identity is what lets Windows Dynamic Lighting offer vRIO under
Settings > Personalization > Dynamic Lighting > "Background light control",
so the keyboard lamp mirror keeps working while the game has focus.
Unsigned registration requires Developer Mode
(Settings > System > For developers).
.PARAMETER ExePath
Folder containing VRio.App.exe. Defaults to the repo's Release output;
for a deployed zip, pass the extracted VRio folder.
.PARAMETER Unregister
Remove the registration instead.
#>
param(
[string]$ExePath = (Join-Path $PSScriptRoot '..\src\VRio.App\bin\Release\net48'),
[switch]$Unregister
)
$ErrorActionPreference = 'Stop'
$packageName = 'VWE.vRIO'
$existing = Get-AppxPackage -Name $packageName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "Removing existing registration $($existing.PackageFullName)..."
Remove-AppxPackage -Package $existing.PackageFullName
}
if ($Unregister) {
Write-Host 'Unregistered.'
return
}
$exe = Join-Path $ExePath 'VRio.App.exe'
if (-not (Test-Path $exe)) { throw "VRio.App.exe not found in '$ExePath'" }
$manifest = Join-Path $PSScriptRoot 'AppxManifest.xml'
# With AllowExternalContent the shell resolves the manifest's logo assets
# against the EXTERNAL location, not the manifest's folder — the Start/taskbar
# icon only shows if Assets\ and resources.pri sit next to the exe.
$assetDir = New-Item -ItemType Directory -Force (Join-Path $ExePath 'Assets')
Copy-Item (Join-Path $PSScriptRoot 'Assets\*') $assetDir -Force
Copy-Item (Join-Path $PSScriptRoot 'resources.pri') $ExePath -Force
Add-AppxPackage -Register $manifest -ExternalLocation (Resolve-Path $ExePath).Path
$family = (Get-AppxPackage -Name $packageName).PackageFamilyName
Write-Host "Registered $packageName with external location '$ExePath'."
Write-Host ''
Write-Host 'IMPORTANT: identity is granted through shell activation only. Launch vRIO'
Write-Host 'from the Start menu entry ("vRIO") or via:'
Write-Host " explorer shell:AppsFolder\$family!vRIO"
Write-Host 'Double-clicking VRio.App.exe runs it WITHOUT identity (lamp mirror then'
Write-Host 'works only while vRIO has focus).'
Write-Host ''
Write-Host 'Then enable the lamp mirror and pick vRIO under Settings > Personalization >'
Write-Host 'Dynamic Lighting > Background light control.'
BIN
View File
Binary file not shown.
+104
View File
@@ -0,0 +1,104 @@
using VPlasma.Core.Device;
namespace VPlasma.App;
/// <summary>
/// vPLASMA main window: just the plasma glass, sized to the display. The
/// device end is hardwired to <see cref="PortName"/> (the plasma side of the
/// second com0com pair) and opened automatically — a retry timer keeps
/// trying while the port is missing or busy, and reopens it if it dies. The
/// title bar carries the port status; double-clicking the glass cycles the
/// self-test pages (banner, charset, graphics) through the wire parser, and
/// a right-click resets the display to its power-on state.
/// </summary>
internal sealed class MainForm : Form
{
/// <summary>The plasma's fixed port: the device end of the COM2 pair.</summary>
private const string PortName = "COM12";
private readonly VPlasmaDevice _device = new();
private readonly VPlasmaSerialService _service;
private readonly PlasmaCanvas _canvas = new();
private readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 };
private int _selfTestPage;
public MainForm()
{
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
FormBorderStyle = FormBorderStyle.FixedSingle; // the glass is a fixed-size device
MaximizeBox = false;
ClientSize = _canvas.Size;
StartPosition = FormStartPosition.CenterScreen;
_service = new VPlasmaSerialService(_device);
_canvas.Location = new Point(0, 0);
Controls.Add(_canvas);
// Device / service events arrive on the serial reader thread; marshal to the UI.
_device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device));
_service.ConnectionChanged += _ => RunOnUi(UpdateTitle);
_canvas.DoubleClick += (_, _) => RunSelfTest();
_canvas.MouseClick += (_, e) =>
{
if (e.Button == MouseButtons.Right)
_device.Reset();
};
// Open at startup, retry while closed (port missing/busy, host restarts).
_reconnectTimer.Tick += (_, _) => EnsureOpen();
_reconnectTimer.Start();
FormClosed += (_, _) =>
{
_reconnectTimer.Dispose();
_service.Dispose();
};
_canvas.UpdateFrom(_device);
EnsureOpen();
}
private void EnsureOpen()
{
if (_service.IsOpen)
return;
try
{
_service.Open(PortName);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
{
UpdateTitle(); // stays closed; the timer tries again
}
}
private void UpdateTitle()
{
// ProductVersion carries the git stamp (see StampGitVersion in the csproj).
string status = _service.IsOpen
? $"{PortName} @ 9600 8N1"
: $"{PortName} unavailable — retrying";
Text = $"vPLASMA v{Application.ProductVersion} — {status}";
}
private void RunSelfTest()
{
byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
_device.OnReceived(bytes, bytes.Length);
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
}
private void RunOnUi(Action action)
{
if (IsDisposed || Disposing)
return;
if (InvokeRequired)
BeginInvoke(action);
else
action();
}
}
+121
View File
@@ -0,0 +1,121 @@
using VPlasma.Core.Device;
namespace VPlasma.App;
/// <summary>
/// The glass: renders the device's 128×32 frame as a dot-matrix panel —
/// neon-orange plasma dots on dark glass, with a faint unlit grid so the
/// matrix reads as hardware. Half-intensity dots draw dimmer; flashing dots
/// and the flashing cursor blink on a shared phase timer that only runs
/// invalidations while something on screen actually blinks.
/// </summary>
internal sealed class PlasmaCanvas : Control
{
private const int DotPitch = 5; // 4 px dot + 1 px gap → a 640×160 dot field
private const int DotSize = 4;
private const int Bezel = 4;
private static readonly Color BezelColor = Color.FromArgb(20, 18, 16);
private static readonly Color GlassColor = Color.FromArgb(26, 14, 6);
private static readonly Color UnlitDot = Color.FromArgb(46, 24, 10);
private static readonly Color LitDot = Color.FromArgb(255, 106, 26);
private static readonly Color HalfDot = Color.FromArgb(150, 62, 15);
private readonly byte[] _frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
private readonly System.Windows.Forms.Timer _blinkTimer = new() { Interval = 266 };
private bool _blinkPhase = true;
private bool _anythingBlinks;
private VPlasmaDevice.Point _cursor;
private PlasmaCursorMode _cursorMode;
private int _cellWidth = 6, _cellHeight = 8;
public PlasmaCanvas()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.FixedWidth |
ControlStyles.FixedHeight, true);
Size = new Size(
VPlasmaDevice.Width * DotPitch + 2 * Bezel,
VPlasmaDevice.Height * DotPitch + 2 * Bezel);
BackColor = BezelColor;
_blinkTimer.Tick += (_, _) =>
{
_blinkPhase = !_blinkPhase;
if (_anythingBlinks)
Invalidate();
};
_blinkTimer.Start();
}
/// <summary>Snapshot the device state and repaint. Call on the UI thread.</summary>
public void UpdateFrom(VPlasmaDevice device)
{
device.CopyFrame(_frame);
_cursor = device.CursorCell;
_cursorMode = device.CursorMode;
_cellWidth = device.CellWidth;
_cellHeight = device.CellHeight;
_anythingBlinks = _cursorMode == PlasmaCursorMode.Flashing;
if (!_anythingBlinks)
foreach (byte dot in _frame)
if ((dot & VPlasmaDevice.PixelFlash) != 0)
{
_anythingBlinks = true;
break;
}
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
Graphics g = e.Graphics;
g.Clear(BezelColor);
using (var glass = new SolidBrush(GlassColor))
g.FillRectangle(glass, Bezel - 4, Bezel - 4,
VPlasmaDevice.Width * DotPitch + 7, VPlasmaDevice.Height * DotPitch + 7);
using var unlit = new SolidBrush(UnlitDot);
using var lit = new SolidBrush(LitDot);
using var half = new SolidBrush(HalfDot);
for (int y = 0; y < VPlasmaDevice.Height; ++y)
{
int rowOffset = y * VPlasmaDevice.Width;
int py = Bezel + y * DotPitch;
for (int x = 0; x < VPlasmaDevice.Width; ++x)
{
byte dot = _frame[rowOffset + x];
Brush brush = unlit;
if ((dot & VPlasmaDevice.PixelLit) != 0
&& ((dot & VPlasmaDevice.PixelFlash) == 0 || _blinkPhase))
{
brush = (dot & VPlasmaDevice.PixelHalf) != 0 ? half : lit;
}
g.FillRectangle(brush, Bezel + x * DotPitch, py, DotSize, DotSize);
}
}
// Underline cursor on the bottom dot-row of its cell.
if (_cursorMode == PlasmaCursorMode.Steady
|| (_cursorMode == PlasmaCursorMode.Flashing && _blinkPhase))
{
int cx = _cursor.Col * _cellWidth;
int cy = _cursor.Row * _cellHeight + _cellHeight - 1;
if (cy < VPlasmaDevice.Height)
for (int i = 0; i < _cellWidth && cx + i < VPlasmaDevice.Width; ++i)
g.FillRectangle(lit, Bezel + (cx + i) * DotPitch, Bezel + cy * DotPitch, DotSize, DotSize);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
_blinkTimer.Dispose();
base.Dispose(disposing);
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace VPlasma.App;
internal static class Program
{
[STAThread]
private static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new MainForm());
}
}
+51
View File
@@ -0,0 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net48</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>vwe.ico</ApplicationIcon>
<AssemblyTitle>vPLASMA — Virtual plasma display</AssemblyTitle>
<!-- StampGitVersion already puts the sha in InformationalVersion; stop the
SDK appending "+fullsha" on top of it. -->
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\VPlasma.Core\VPlasma.Core.csproj" />
</ItemGroup>
<!-- Stamp commit date + short sha into InformationalVersion; the window
title shows it (Application.ProductVersion) so a running build can be
matched to its vYYYY.MM.DD release tag at a glance. Falls back to the
default 1.0.0 when git isn't available. -->
<Target Name="StampGitVersion" BeforeTargets="GetAssemblyVersion" Condition="'$(DesignTimeBuild)' != 'true'">
<!-- %%cs survives cmd's percent expansion as %cs: committer date, YYYY-MM-DD. -->
<Exec Command="git -C &quot;$(MSBuildProjectDirectory)&quot; log -1 --format=%%cs"
ConsoleToMSBuild="true" StandardOutputImportance="low" IgnoreExitCode="true" ContinueOnError="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitDate" />
<Output TaskParameter="ExitCode" PropertyName="GitDateExitCode" />
</Exec>
<!-- The exclude flag keeps describe off the release tags: always the short
sha, with a "dirty" suffix when the working tree has local edits. -->
<Exec Command="git -C &quot;$(MSBuildProjectDirectory)&quot; describe --always --dirty --exclude=*"
ConsoleToMSBuild="true" StandardOutputImportance="low" IgnoreExitCode="true" ContinueOnError="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitShortSha" />
</Exec>
<PropertyGroup Condition="'$(GitDateExitCode)' == '0' And '$(GitShortSha)' != ''">
<InformationalVersion>$(GitCommitDate.Trim().Replace('-', '.')) ($(GitShortSha))</InformationalVersion>
</PropertyGroup>
</Target>
<ItemGroup>
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="VPlasma.App" type="win32" />
<!-- Run as the invoking user: vPLASMA only opens a COM port and draws a window. -->
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<!-- Declare Windows 10/11 compatibility for correct DPI / API behavior. -->
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <!-- Windows 10/11 -->
</application>
</compatibility>
</assembly>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+130
View File
@@ -0,0 +1,130 @@
namespace VPlasma.Core.Device;
/// <summary>
/// The display's character generator: a classic 5×7 dot-matrix font for
/// ASCII 0x20..0x7E, stored column-major (5 column bytes per glyph, bit 0 =
/// top row) — the layout every KS0108-era controller used. The real panel's
/// ROM glyphs are lost with the hardware; this is the standard public-domain
/// 5×7 set, which is what such panels shipped with. Codes outside the range
/// render as a solid block so stream corruption is visible on the glass.
/// </summary>
public static class PlasmaFont
{
public const int GlyphWidth = 5;
public const int GlyphHeight = 7;
public const byte First = 0x20;
public const byte Last = 0x7E;
/// <summary>
/// Column bits for <paramref name="code"/>'s glyph. Bit r of
/// <c>result[c]</c> is the dot at column c, row r (row 0 at the top).
/// </summary>
public static void GetColumns(byte code, Span<byte> columns)
{
if (code < First || code > Last)
{
columns.Slice(0, GlyphWidth).Fill(0x7F); // solid block
return;
}
Glyphs.AsSpan((code - First) * GlyphWidth, GlyphWidth).CopyTo(columns);
}
private static readonly byte[] Glyphs =
{
0x00, 0x00, 0x00, 0x00, 0x00, // 0x20 ' '
0x00, 0x00, 0x5F, 0x00, 0x00, // 0x21 '!'
0x00, 0x07, 0x00, 0x07, 0x00, // 0x22 '"'
0x14, 0x7F, 0x14, 0x7F, 0x14, // 0x23 '#'
0x24, 0x2A, 0x7F, 0x2A, 0x12, // 0x24 '$'
0x23, 0x13, 0x08, 0x64, 0x62, // 0x25 '%'
0x36, 0x49, 0x55, 0x22, 0x50, // 0x26 '&'
0x00, 0x05, 0x03, 0x00, 0x00, // 0x27 '''
0x00, 0x1C, 0x22, 0x41, 0x00, // 0x28 '('
0x00, 0x41, 0x22, 0x1C, 0x00, // 0x29 ')'
0x08, 0x2A, 0x1C, 0x2A, 0x08, // 0x2A '*'
0x08, 0x08, 0x3E, 0x08, 0x08, // 0x2B '+'
0x00, 0x50, 0x30, 0x00, 0x00, // 0x2C ','
0x08, 0x08, 0x08, 0x08, 0x08, // 0x2D '-'
0x00, 0x60, 0x60, 0x00, 0x00, // 0x2E '.'
0x20, 0x10, 0x08, 0x04, 0x02, // 0x2F '/'
0x3E, 0x51, 0x49, 0x45, 0x3E, // 0x30 '0'
0x00, 0x42, 0x7F, 0x40, 0x00, // 0x31 '1'
0x42, 0x61, 0x51, 0x49, 0x46, // 0x32 '2'
0x21, 0x41, 0x45, 0x4B, 0x31, // 0x33 '3'
0x18, 0x14, 0x12, 0x7F, 0x10, // 0x34 '4'
0x27, 0x45, 0x45, 0x45, 0x39, // 0x35 '5'
0x3C, 0x4A, 0x49, 0x49, 0x30, // 0x36 '6'
0x01, 0x71, 0x09, 0x05, 0x03, // 0x37 '7'
0x36, 0x49, 0x49, 0x49, 0x36, // 0x38 '8'
0x06, 0x49, 0x49, 0x29, 0x1E, // 0x39 '9'
0x00, 0x36, 0x36, 0x00, 0x00, // 0x3A ':'
0x00, 0x56, 0x36, 0x00, 0x00, // 0x3B ';'
0x00, 0x08, 0x14, 0x22, 0x41, // 0x3C '<'
0x14, 0x14, 0x14, 0x14, 0x14, // 0x3D '='
0x41, 0x22, 0x14, 0x08, 0x00, // 0x3E '>'
0x02, 0x01, 0x51, 0x09, 0x06, // 0x3F '?'
0x32, 0x49, 0x79, 0x41, 0x3E, // 0x40 '@'
0x7E, 0x11, 0x11, 0x11, 0x7E, // 0x41 'A'
0x7F, 0x49, 0x49, 0x49, 0x36, // 0x42 'B'
0x3E, 0x41, 0x41, 0x41, 0x22, // 0x43 'C'
0x7F, 0x41, 0x41, 0x22, 0x1C, // 0x44 'D'
0x7F, 0x49, 0x49, 0x49, 0x41, // 0x45 'E'
0x7F, 0x09, 0x09, 0x09, 0x01, // 0x46 'F'
0x3E, 0x41, 0x49, 0x49, 0x7A, // 0x47 'G'
0x7F, 0x08, 0x08, 0x08, 0x7F, // 0x48 'H'
0x00, 0x41, 0x7F, 0x41, 0x00, // 0x49 'I'
0x20, 0x40, 0x41, 0x3F, 0x01, // 0x4A 'J'
0x7F, 0x08, 0x14, 0x22, 0x41, // 0x4B 'K'
0x7F, 0x40, 0x40, 0x40, 0x40, // 0x4C 'L'
0x7F, 0x02, 0x0C, 0x02, 0x7F, // 0x4D 'M'
0x7F, 0x04, 0x08, 0x10, 0x7F, // 0x4E 'N'
0x3E, 0x41, 0x41, 0x41, 0x3E, // 0x4F 'O'
0x7F, 0x09, 0x09, 0x09, 0x06, // 0x50 'P'
0x3E, 0x41, 0x51, 0x21, 0x5E, // 0x51 'Q'
0x7F, 0x09, 0x19, 0x29, 0x46, // 0x52 'R'
0x46, 0x49, 0x49, 0x49, 0x31, // 0x53 'S'
0x01, 0x01, 0x7F, 0x01, 0x01, // 0x54 'T'
0x3F, 0x40, 0x40, 0x40, 0x3F, // 0x55 'U'
0x1F, 0x20, 0x40, 0x20, 0x1F, // 0x56 'V'
0x3F, 0x40, 0x38, 0x40, 0x3F, // 0x57 'W'
0x63, 0x14, 0x08, 0x14, 0x63, // 0x58 'X'
0x07, 0x08, 0x70, 0x08, 0x07, // 0x59 'Y'
0x61, 0x51, 0x49, 0x45, 0x43, // 0x5A 'Z'
0x00, 0x7F, 0x41, 0x41, 0x00, // 0x5B '['
0x02, 0x04, 0x08, 0x10, 0x20, // 0x5C '\'
0x00, 0x41, 0x41, 0x7F, 0x00, // 0x5D ']'
0x04, 0x02, 0x01, 0x02, 0x04, // 0x5E '^'
0x40, 0x40, 0x40, 0x40, 0x40, // 0x5F '_'
0x00, 0x01, 0x02, 0x04, 0x00, // 0x60 '`'
0x20, 0x54, 0x54, 0x54, 0x78, // 0x61 'a'
0x7F, 0x48, 0x44, 0x44, 0x38, // 0x62 'b'
0x38, 0x44, 0x44, 0x44, 0x20, // 0x63 'c'
0x38, 0x44, 0x44, 0x48, 0x7F, // 0x64 'd'
0x38, 0x54, 0x54, 0x54, 0x18, // 0x65 'e'
0x08, 0x7E, 0x09, 0x01, 0x02, // 0x66 'f'
0x0C, 0x52, 0x52, 0x52, 0x3E, // 0x67 'g'
0x7F, 0x08, 0x04, 0x04, 0x78, // 0x68 'h'
0x00, 0x44, 0x7D, 0x40, 0x00, // 0x69 'i'
0x20, 0x40, 0x44, 0x3D, 0x00, // 0x6A 'j'
0x7F, 0x10, 0x28, 0x44, 0x00, // 0x6B 'k'
0x00, 0x41, 0x7F, 0x40, 0x00, // 0x6C 'l'
0x7C, 0x04, 0x18, 0x04, 0x78, // 0x6D 'm'
0x7C, 0x08, 0x04, 0x04, 0x78, // 0x6E 'n'
0x38, 0x44, 0x44, 0x44, 0x38, // 0x6F 'o'
0x7C, 0x14, 0x14, 0x14, 0x08, // 0x70 'p'
0x08, 0x14, 0x14, 0x14, 0x7C, // 0x71 'q'
0x7C, 0x08, 0x04, 0x04, 0x08, // 0x72 'r'
0x48, 0x54, 0x54, 0x54, 0x20, // 0x73 's'
0x04, 0x3F, 0x44, 0x40, 0x20, // 0x74 't'
0x3C, 0x40, 0x40, 0x20, 0x7C, // 0x75 'u'
0x1C, 0x20, 0x40, 0x20, 0x1C, // 0x76 'v'
0x3C, 0x40, 0x30, 0x40, 0x3C, // 0x77 'w'
0x44, 0x28, 0x10, 0x28, 0x44, // 0x78 'x'
0x0C, 0x50, 0x50, 0x50, 0x3C, // 0x79 'y'
0x44, 0x64, 0x54, 0x4C, 0x44, // 0x7A 'z'
0x00, 0x08, 0x36, 0x41, 0x00, // 0x7B '{'
0x00, 0x00, 0x7F, 0x00, 0x00, // 0x7C '|'
0x00, 0x41, 0x36, 0x08, 0x00, // 0x7D '}'
0x08, 0x04, 0x08, 0x10, 0x08, // 0x7E '~'
};
}
+101
View File
@@ -0,0 +1,101 @@
using VPlasma.Core.Protocol;
namespace VPlasma.Core.Device;
/// <summary>
/// Canned wire streams for the UI's <i>Self test</i> button: each page is a
/// byte sequence exactly as a host would send it over COM2, fed through the
/// same parser as live traffic — so the button doubles as an end-to-end
/// exercise of the command set without a host attached.
/// </summary>
public static class PlasmaSelfTest
{
public const int PageCount = 3;
/// <summary>The wire bytes for self-test page <paramref name="page"/> (0-based).</summary>
public static byte[] BuildPage(int page) => page switch
{
0 => BuildBanner(),
1 => BuildCharset(),
2 => BuildGraphics(),
_ => throw new ArgumentOutOfRangeException(nameof(page)),
};
private static void Esc(List<byte> b, byte cmd, params byte[] operands)
{
b.Add(PlasmaProtocol.Esc);
b.Add(cmd);
b.AddRange(operands);
}
private static void Text(List<byte> b, string s)
{
foreach (char c in s)
b.Add((byte)c);
}
/// <summary>Big-font banner over small-font attribute samples.</summary>
private static byte[] BuildBanner()
{
var b = new List<byte>();
Esc(b, PlasmaProtocol.CmdClearScreen);
Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
Esc(b, PlasmaProtocol.CmdFontSelect, 0x04); // 12×16 cells: 10 × 2
Text(b, " vPLASMA");
Esc(b, PlasmaProtocol.CmdFontSelect, 0x00); // 6×8 cells: 21 × 4
Esc(b, PlasmaProtocol.CmdHomeCursor);
b.Add(PlasmaProtocol.LineFeed);
b.Add(PlasmaProtocol.LineFeed);
Text(b, "128x32 PLASMA DISPLAY"); // exactly one 21-cell row
Esc(b, PlasmaProtocol.CmdAttributes, 1); Text(b, "HALF ");
Esc(b, PlasmaProtocol.CmdAttributes, 2); Text(b, "UNDER ");
Esc(b, PlasmaProtocol.CmdAttributes, 3); Text(b, "REV");
Esc(b, PlasmaProtocol.CmdAttributes, 4); Text(b, " FLASH");
Esc(b, PlasmaProtocol.CmdAttributes, PlasmaProtocol.OperandDefault);
return b.ToArray();
}
/// <summary>The printable set 0x20..0x6F — a full 21×4 grid, minus a cell.</summary>
private static byte[] BuildCharset()
{
var b = new List<byte>();
Esc(b, PlasmaProtocol.CmdClearScreen);
Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
for (byte c = 0x20; c <= 0x6F; ++c)
b.Add(c);
return b.ToArray();
}
/// <summary>The rest of the charset plus an ESC P pattern block.</summary>
private static byte[] BuildGraphics()
{
var b = new List<byte>();
Esc(b, PlasmaProtocol.CmdClearScreen);
Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
for (byte c = 0x70; c <= 0x7E; ++c)
b.Add(c);
// A framed checkerboard sent the way the game sends everything:
// full-width ESC P rows (screen 0, xbyte 0, 16 bytes, 1 row each).
for (int y = 10; y < VPlasmaDevice.Height; ++y)
{
Esc(b, PlasmaProtocol.CmdGraphicsWrite,
0, (byte)y, 0, VPlasmaDevice.WidthBytes, 1);
bool edge = y is 10 or VPlasmaDevice.Height - 1;
for (int x = 0; x < VPlasmaDevice.WidthBytes; ++x)
{
byte fill = edge ? (byte)0xFF : (y & 2) == 0 ? (byte)0xAA : (byte)0x55;
if (!edge)
{
if (x == 0) fill |= 0x80; // left frame edge
if (x == VPlasmaDevice.WidthBytes - 1) fill |= 0x01; // right frame edge
}
b.Add(fill);
}
}
return b.ToArray();
}
}
+447
View File
@@ -0,0 +1,447 @@
using VPlasma.Core.Protocol;
namespace VPlasma.Core.Device;
/// <summary>How the text cursor is shown (set with <c>ESC G</c>).</summary>
public enum PlasmaCursorMode
{
Hidden,
Steady,
Flashing,
}
/// <summary>Text rendering attributes (set with <c>ESC H</c>).</summary>
[Flags]
public enum PlasmaAttributes : byte
{
None = 0,
HalfIntensity = 1,
Underline = 2,
Reverse = 4,
Flash = 8,
}
/// <summary>
/// The plasma display proper: a 128×32 1bpp frame plus the text-mode state
/// (cursor, font, attributes), driven by the byte stream a host writes to
/// COM2. Feed raw wire bytes to <see cref="OnReceived"/>; the parser is a
/// state machine, so commands may arrive split across any chunk boundaries.
///
/// <para>Pixels carry flags rather than a plain bit: graphics writes set
/// full-intensity dots, while text can stamp half-intensity or flashing dots
/// (<c>ESC H</c>); the UI renders <see cref="PixelHalf"/> dimmer and blinks
/// <see cref="PixelFlash"/>. The command set itself is documented on
/// <see cref="PlasmaProtocol"/>.</para>
///
/// <para>Grid geometry: fonts 03 are the 5×7 set in a 6×8 cell (21 columns
/// × 4 rows), fonts 47 the same glyphs doubled into a 12×16 cell
/// (10 × 2). Which of the eight slots the real panel mapped to which face is
/// lost with the hardware; two sizes cover what the surviving software
/// exercises.</para>
///
/// <para>Thread-safe: the serial reader feeds bytes while the UI snapshots
/// frames. Events are raised outside the lock, on the caller's thread.</para>
/// </summary>
public sealed class VPlasmaDevice
{
public const int Width = 128;
public const int Height = 32;
public const int WidthBytes = Width / 8;
// Per-pixel flag bits in the frame buffer.
public const byte PixelLit = 0x01;
public const byte PixelHalf = 0x02;
public const byte PixelFlash = 0x04;
private readonly object _sync = new();
private readonly byte[] _pixels = new byte[Width * Height];
// ---- text-mode state -------------------------------------------------
private int _font; // 0..7
private PlasmaAttributes _attributes;
private int _col, _row; // cursor, in cells of the current grid
private PlasmaCursorMode _cursorMode = PlasmaCursorMode.Steady; // power-on default; the game hides it
// ---- parser state ----------------------------------------------------
private enum State
{
Text, // printable chars + control bytes
Escape, // got ESC, awaiting the command letter
Operand, // awaiting the 1-byte operand of _pendingCommand
GraphicsHeader, // collecting ESC P's 5 header bytes
GraphicsData, // consuming ESC P's w*h data bytes
}
private State _state;
private byte _pendingCommand;
private readonly byte[] _header = new byte[5]; // screen, y, x, w, h
private int _headerFill;
private int _dataIndex, _dataLength;
private bool _dirty; // frame/cursor changed during this chunk
private List<string>? _pendingLog; // lines queued under the lock
private bool _graphicsLogArmed = true; // log the first ESC P of a stream, then go quiet
private readonly HashSet<byte> _loggedUnknown = new();
private long _bytesReceived, _graphicsRows, _textCharsDrawn;
/// <summary>Frame or cursor changed. Raised on the feeding thread.</summary>
public event Action? Updated;
/// <summary>Decoded-command log lines. Raised on the feeding thread.</summary>
public event Action<string>? Logged;
public long BytesReceived { get { lock (_sync) return _bytesReceived; } }
public long GraphicsRows { get { lock (_sync) return _graphicsRows; } }
public long TextCharsDrawn { get { lock (_sync) return _textCharsDrawn; } }
public PlasmaCursorMode CursorMode { get { lock (_sync) return _cursorMode; } }
public int Font { get { lock (_sync) return _font; } }
public PlasmaAttributes Attributes { get { lock (_sync) return _attributes; } }
// Current font grid, for the UI's cursor overlay and status line.
private int FontScale => _font >= 4 ? 2 : 1;
public int CellWidth { get { lock (_sync) return 6 * FontScale; } }
public int CellHeight { get { lock (_sync) return 8 * FontScale; } }
private int Columns => Width / (6 * FontScale);
private int Rows => Height / (8 * FontScale);
public Point CursorCell { get { lock (_sync) return new Point(_col, _row); } }
/// <summary>A cursor cell position (avoids dragging in System.Drawing).</summary>
public readonly record struct Point(int Col, int Row);
/// <summary>Copy the frame into <paramref name="destination"/> (Width*Height flag bytes).</summary>
public void CopyFrame(byte[] destination)
{
if (destination.Length < _pixels.Length)
throw new ArgumentException("Buffer too small.", nameof(destination));
lock (_sync)
Buffer.BlockCopy(_pixels, 0, destination, 0, _pixels.Length);
}
/// <summary>Power-on state: dark glass, home cursor, defaults.</summary>
public void Reset()
{
lock (_sync)
{
Array.Clear(_pixels, 0, _pixels.Length);
_col = _row = 0;
_font = 0;
_attributes = PlasmaAttributes.None;
_cursorMode = PlasmaCursorMode.Steady;
_state = State.Text;
_dirty = true;
}
FlushEvents();
}
/// <summary>Feed <paramref name="count"/> received wire bytes.</summary>
public void OnReceived(byte[] buffer, int count)
{
lock (_sync)
{
_bytesReceived += count;
for (int i = 0; i < count; ++i)
Step(buffer[i]);
}
FlushEvents();
}
// ---- parser ------------------------------------------------------------
private void Step(byte b)
{
switch (_state)
{
case State.Text:
StepText(b);
break;
case State.Escape:
StepEscape(b);
break;
case State.Operand:
_state = State.Text;
ApplyOperand(_pendingCommand, b);
break;
case State.GraphicsHeader:
_header[_headerFill++] = b;
if (_headerFill == _header.Length)
BeginGraphicsData();
break;
case State.GraphicsData:
StepGraphicsData(b);
break;
}
}
private void StepText(byte b)
{
switch (b)
{
case PlasmaProtocol.Esc:
_state = State.Escape;
return;
case PlasmaProtocol.BackSpace:
if (_col > 0) _col--;
_dirty = true;
return;
case PlasmaProtocol.HorizontalTab:
AdvanceCursor();
_dirty = true;
return;
case PlasmaProtocol.LineFeed:
_row = (_row + 1) % Rows;
_dirty = true;
return;
case PlasmaProtocol.VerticalTab:
_row = (_row + Rows - 1) % Rows;
_dirty = true;
return;
case PlasmaProtocol.CarriageReturn:
_col = 0;
_dirty = true;
return;
}
if (b < 0x20)
{
// A control byte the surviving software never sends: swallow it,
// but say so once per value — it's the tell of a desynced stream.
if (_loggedUnknown.Add(b))
Log($"Unhandled control byte 0x{b:X2} ignored");
return;
}
DrawChar(b);
_graphicsLogArmed = true;
}
private void StepEscape(byte b)
{
_state = State.Text;
switch (b)
{
case PlasmaProtocol.CmdClearScreen:
Array.Clear(_pixels, 0, _pixels.Length);
_col = _row = 0;
_font = 0;
_attributes = PlasmaAttributes.None;
_dirty = true;
Log("Clear screen (ESC @)");
_graphicsLogArmed = true;
break;
case PlasmaProtocol.CmdHomeCursor:
_col = _row = 0;
_dirty = true;
Log("Home cursor (ESC L)");
_graphicsLogArmed = true;
break;
case PlasmaProtocol.CmdCursorMode:
case PlasmaProtocol.CmdFontSelect:
case PlasmaProtocol.CmdAttributes:
_pendingCommand = b;
_state = State.Operand;
break;
case PlasmaProtocol.CmdGraphicsWrite:
_headerFill = 0;
_state = State.GraphicsHeader;
break;
default:
if (_loggedUnknown.Add(b))
Log($"Unknown command ESC 0x{b:X2} ('{(char)b}') ignored");
break;
}
}
private void ApplyOperand(byte command, byte operand)
{
switch (command)
{
case PlasmaProtocol.CmdCursorMode:
// The game hides the cursor with 00, the test tool with FF;
// 01 shows it steady, 03 flashing (bit 1 = blink).
_cursorMode =
operand is 0x00 or 0xFF ? PlasmaCursorMode.Hidden :
(operand & 0x02) != 0 ? PlasmaCursorMode.Flashing :
PlasmaCursorMode.Steady;
_dirty = true;
Log($"Cursor {_cursorMode} (ESC G {operand:X2})");
break;
case PlasmaProtocol.CmdFontSelect:
_font = operand == PlasmaProtocol.OperandDefault ? 0 : operand & 0x07;
// The cursor keeps its cell coordinates but the grid changed size.
_col = Math.Min(_col, Columns - 1);
_row = Math.Min(_row, Rows - 1);
_dirty = true;
Log($"Font {_font}: {Columns}×{Rows} cells (ESC K {operand:X2})");
break;
case PlasmaProtocol.CmdAttributes:
_attributes = DecodeAttributes(operand);
Log($"Attributes {(_attributes == PlasmaAttributes.None ? "default" : _attributes.ToString())} (ESC H {operand:X2})");
break;
}
_graphicsLogArmed = true;
}
/// <summary>
/// <c>ESC H</c>'s operand indexes the style list PLASMA.EXE enumerates
/// (its <c>/s</c> option): the 17 intensity/underline/reverse/flash
/// combos below, in the tool's own order. FF (and anything out of range)
/// restores the defaults.
/// </summary>
private static PlasmaAttributes DecodeAttributes(byte operand) => operand switch
{
0 => PlasmaAttributes.None,
1 => PlasmaAttributes.HalfIntensity,
2 => PlasmaAttributes.Underline,
3 => PlasmaAttributes.Reverse,
4 => PlasmaAttributes.Flash,
5 => PlasmaAttributes.Underline,
6 => PlasmaAttributes.Reverse,
7 => PlasmaAttributes.Flash,
8 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline,
9 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Reverse,
10 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Flash,
11 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
12 => PlasmaAttributes.Underline | PlasmaAttributes.Flash,
13 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
14 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
15 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Flash,
16 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
_ => PlasmaAttributes.None,
};
// ---- graphics writes (ESC P) -------------------------------------------
private void BeginGraphicsData()
{
int w = _header[3], h = _header[4];
_dataLength = w * h;
_dataIndex = 0;
if (_graphicsLogArmed)
{
// The game streams row upon row; log the first of a run only.
_graphicsLogArmed = false;
Log($"Graphics stream: screen={_header[0]} y={_header[1]} xbyte={_header[2]} " +
$"{w} byte(s)/row × {h} row(s) (further rows counted silently)");
}
_state = _dataLength > 0 ? State.GraphicsData : State.Text;
}
private void StepGraphicsData(byte b)
{
int w = _header[3];
int rowOfBlock = _dataIndex / w;
int byteOfRow = _dataIndex % w;
int y = _header[1] + rowOfBlock;
int xByte = _header[2] + byteOfRow;
if (y < Height && xByte < WidthBytes)
{
// MSB is the leftmost pixel (L4PLASMA.CPP packs 0x80 first).
// Graphics dots are plain full intensity: overwriting text clears
// its half/flash flags, like repainting the glass.
int offset = y * Width + xByte * 8;
for (int bit = 0; bit < 8; ++bit)
_pixels[offset + bit] = (b & (0x80 >> bit)) != 0 ? PixelLit : (byte)0;
_dirty = true;
if (byteOfRow == w - 1)
_graphicsRows++;
}
if (++_dataIndex >= _dataLength)
_state = State.Text;
}
// ---- text rendering ------------------------------------------------------
private void DrawChar(byte code)
{
int scale = FontScale;
int cellW = 6 * scale, cellH = 8 * scale;
int ox = _col * cellW, oy = _row * cellH;
Span<byte> columns = stackalloc byte[PlasmaFont.GlyphWidth];
PlasmaFont.GetColumns(code, columns);
bool reverse = (_attributes & PlasmaAttributes.Reverse) != 0;
bool underline = (_attributes & PlasmaAttributes.Underline) != 0;
byte litFlags = PixelLit;
if ((_attributes & PlasmaAttributes.HalfIntensity) != 0) litFlags |= PixelHalf;
if ((_attributes & PlasmaAttributes.Flash) != 0) litFlags |= PixelFlash;
for (int cy = 0; cy < cellH; ++cy)
{
int glyphRow = cy / scale; // 0..7; row 7 is the gap/underline row
int rowOffset = (oy + cy) * Width + ox;
for (int cx = 0; cx < cellW; ++cx)
{
int glyphCol = cx / scale; // 0..5; column 5 is the gap column
bool on = glyphCol < PlasmaFont.GlyphWidth
&& glyphRow < PlasmaFont.GlyphHeight
&& (columns[glyphCol] >> glyphRow & 1) != 0;
if (underline && glyphRow == 7)
on = true;
if (reverse)
on = !on;
_pixels[rowOffset + cx] = on ? litFlags : (byte)0;
}
}
_textCharsDrawn++;
_dirty = true;
AdvanceCursor();
}
private void AdvanceCursor()
{
if (++_col >= Columns)
{
_col = 0;
// No scroll on these panels: writing past the last row wraps to the top.
if (++_row >= Rows)
_row = 0;
}
}
// ---- event plumbing --------------------------------------------------------
private void Log(string line) => (_pendingLog ??= new List<string>()).Add(line);
/// <summary>Raise queued events outside the lock, on the caller's thread.</summary>
private void FlushEvents()
{
List<string>? log;
bool dirty;
lock (_sync)
{
log = _pendingLog;
_pendingLog = null;
dirty = _dirty;
_dirty = false;
}
if (log is not null && Logged is { } logged)
foreach (string line in log)
logged(line);
if (dirty)
Updated?.Invoke();
}
}
@@ -0,0 +1,123 @@
using System.IO.Ports;
using VPlasma.Core.Protocol;
namespace VPlasma.Core.Device;
/// <summary>
/// Pumps a real COM port into a <see cref="VPlasmaDevice"/> at the plasma's
/// 9600 8N1 settings. On a single PC, pair it with the game through a
/// virtual null-modem (e.g. com0com): the game's COM2 passthrough opens one
/// end, vPLASMA the other.
///
/// <para>Unlike the RIO, the plasma is a pure listener — the game opens the
/// port with flow control disabled and never reads a byte back — so there is
/// no transmit path and no wire pacing to emulate. Our DTR/RTS are asserted
/// so a host that does check its modem lines sees "display present".</para>
/// </summary>
public sealed class VPlasmaSerialService : IDisposable
{
private readonly VPlasmaDevice _device;
private SerialPort? _port;
private Thread? _reader;
private volatile bool _running;
public VPlasmaSerialService(VPlasmaDevice device)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
}
/// <summary>True while a COM port is open.</summary>
public bool IsOpen => _port?.IsOpen == true;
/// <summary>The open port's name, or null.</summary>
public string? PortName => _port?.PortName;
/// <summary>Raised after the port opens (true) or closes (false).</summary>
public event Action<bool>? ConnectionChanged;
/// <summary>Port-level log lines (open/close/errors).</summary>
public event Action<string>? Logged;
/// <summary>Open <paramref name="portName"/> and start listening.</summary>
public void Open(string portName)
{
if (string.IsNullOrWhiteSpace(portName))
throw new ArgumentException("Port name is required.", nameof(portName));
Close();
var port = new SerialPort(portName, PlasmaProtocol.BaudRate, Parity.None, 8, StopBits.One)
{
Handshake = Handshake.None,
// Finite read timeout so the reader thread can notice shutdown.
ReadTimeout = 200,
// Assert our modem lines: through a null modem the host sees
// DSR/CTS high, i.e. "display present".
DtrEnable = true,
RtsEnable = true,
};
port.Open();
_port = port;
_running = true;
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "vPLASMA serial reader" };
_reader.Start();
Logged?.Invoke($"Opened {portName} @ {PlasmaProtocol.BaudRate} 8N1 — listening for the host");
ConnectionChanged?.Invoke(true);
}
/// <summary>Close the port (idempotent).</summary>
public void Close()
{
SerialPort? port = _port;
if (port is null)
return;
_running = false;
_port = null;
try { port.Close(); }
catch (IOException) { }
port.Dispose();
_reader?.Join(1000);
_reader = null;
Logged?.Invoke("Port closed");
ConnectionChanged?.Invoke(false);
}
private void ReadLoop()
{
var buffer = new byte[256];
while (_running)
{
SerialPort? port = _port;
if (port is null)
return;
int n;
try
{
n = port.Read(buffer, 0, buffer.Length);
}
catch (TimeoutException)
{
continue; // just a poll tick; check _running again
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or OperationCanceledException)
{
if (_running)
Logged?.Invoke($"Port error: {ex.Message}");
return;
}
if (n > 0)
_device.OnReceived(buffer, n);
}
}
public void Dispose() => Close();
}
@@ -0,0 +1,64 @@
namespace VPlasma.Core.Protocol;
/// <summary>
/// The cockpit plasma display's serial command set, as recovered from the
/// Tesla 4.10 sources and tools:
///
/// <para>The display is a 128×32 dot-matrix plasma panel on COM2 at
/// <b>9600 8N1</b>, no flow control. The game side
/// (<c>CODE\RP\MUNGA_L4\L4PLASMA.CPP</c>) renders everything into a local
/// 1bpp buffer and streams changed rows with the <c>ESC P</c> graphics
/// command, sending <c>ESC G 0</c> once at startup to hide the cursor. The
/// factory test tool (<c>VWETEST\VGLTEST\PLASMA.EXE</c>) additionally
/// exercises a text mode: printable ASCII renders at a cursor, with escape
/// commands for clear/home, cursor visibility, font select, and text
/// attributes (intensity/underline/reverse/flash), plus BS/HT/LF/VT/CR
/// cursor motion.</para>
///
/// <para>Every multi-byte command begins with ESC (0x1B) followed by one
/// command letter:</para>
///
/// <code>
/// ESC @ clear screen, reset text state
/// ESC L home the cursor (0,0)
/// ESC G n cursor mode: 00/FF hidden, 01 steady, 03 flashing
/// ESC K n select font n (FF = default font 0)
/// ESC H n text attributes: index 0..16 into the
/// intensity/underline/reverse/flash combos the
/// test tool enumerates; FF = defaults
/// ESC P s y x w h data… graphics write: screen s (single-screen, ignored),
/// top row y (0..31), left byte column x (0..15),
/// w bytes per row, h rows, then w*h data bytes,
/// MSB = leftmost pixel. The game always sends
/// whole rows: x=0, w=16, h=1.
/// </code>
///
/// <para>Where the two sources disagree on <c>ESC G</c> (the game hides the
/// cursor with 00, the test tool with FF) both operands are treated as
/// hidden.</para>
/// </summary>
public static class PlasmaProtocol
{
/// <summary>Wire bit rate — the game opens PCS_9600, PCS_N81.</summary>
public const int BaudRate = 9600;
public const byte Esc = 0x1B;
// Single-byte cursor-motion controls (PLASMA.EXE's /b /c /l /t /v options).
public const byte BackSpace = 0x08;
public const byte HorizontalTab = 0x09;
public const byte LineFeed = 0x0A;
public const byte VerticalTab = 0x0B;
public const byte CarriageReturn = 0x0D;
// ESC command letters.
public const byte CmdClearScreen = (byte)'@';
public const byte CmdCursorMode = (byte)'G';
public const byte CmdAttributes = (byte)'H';
public const byte CmdFontSelect = (byte)'K';
public const byte CmdHomeCursor = (byte)'L';
public const byte CmdGraphicsWrite = (byte)'P';
/// <summary>Operand meaning "restore the default" for ESC K / ESC H.</summary>
public const byte OperandDefault = 0xFF;
}
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- net48 so it runs in-box on the cabinet PCs, same as VRio.Core. Uses
System.IO.Ports from the framework BCL; Span/records/init come from
System.Memory + PolySharp. -->
<TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+431
View File
@@ -0,0 +1,431 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Windows.Forms;
using VRio.Core.Device;
using VRio.Core.Input;
using VRio.Core.Panel;
using VRio.Core.Protocol;
using Windows.Devices.Enumeration;
using Windows.Devices.Lights;
using Windows.System;
using Windows.UI;
using Color = Windows.UI.Color;
namespace VRio.App;
/// <summary>
/// Mirrors the host-commanded lamp states onto per-key RGB keyboards through
/// Windows Dynamic Lighting (<see cref="LampArray"/>). Keys bound to
/// lamp-capable button addresses in the active profile glow with the panel's
/// palette — the red family for most groups, yellow for Secondary/Screen —
/// and the flash bits blink at the panel's rates; every other key is blacked
/// out so the keyboard reads as the button field. Zone-lit keyboards (no
/// per-key addressing, common on laptops) fall back to a whole-board mirror
/// of the strongest current lamp state.
///
/// <para>Opt-in: enabling claims keyboard-kind lamp arrays (hot-plug aware),
/// disabling releases them so Windows hands the LEDs back to the ambient
/// scene — no explicit restore needed. With several keyboards attached,
/// <see cref="SetTarget"/> narrows the mirror to one of them
/// (<see cref="KeyboardsChanged"/> feeds the picker). Dynamic Lighting gives
/// LED control to the <em>foreground</em> app by default; for the keys to
/// stay lit while the game has focus, vRIO needs package identity and a pick
/// under Settings → Personalization → Dynamic Lighting → "Background light
/// control".</para>
/// </summary>
public sealed class KeyboardLampMirror : IDisposable
{
// Refresh cadence and flash phases match PanelCanvas, so the keyboard and
// the on-screen panel blink in step.
private const int RefreshMs = 100;
private static readonly Stopwatch Clock = Stopwatch.StartNew();
// Address → panel colouring, from the cockpit layout (yellow = the
// Secondary/Screen columns, same test as PanelCanvas).
private static readonly Dictionary<int, bool> LampAddressIsYellow = BuildAddressInfo();
private readonly VRioDevice _device;
private readonly object _gate = new();
private readonly Dictionary<string, (LampArray Array, bool PerKey)> _claimed = new();
private readonly Dictionary<string, string> _known = new(); // every keyboard array seen, id → name
// One entry per lamp-capable bound key: which lamp drives it, how to paint it.
private (int Address, VirtualKey Key, bool Yellow)[] _map = Array.Empty<(int, VirtualKey, bool)>();
private DeviceWatcher? _watcher;
private System.Threading.Timer? _timer;
private Color[]? _lastPushed; // parallel to _map; null forces a full repaint
private Color _lastAggregate; // last whole-board color for zone keyboards
private string? _target; // device id to mirror to; null = all keyboards
private bool _enabled;
private bool _unsupported; // WinRT/Dynamic Lighting missing — don't retry
private bool _anyArraySeen; // a lamp-array device event arrived this session
public KeyboardLampMirror(VRioDevice device) =>
_device = device ?? throw new ArgumentNullException(nameof(device));
/// <summary>Status/log lines (attach, detach, availability hints).</summary>
public event Action<string>? Logged;
/// <summary>The set of detected keyboards changed (id, name) — for a picker.</summary>
public event Action<IReadOnlyList<(string Id, string Name)>>? KeyboardsChanged;
/// <summary>Mirror on/off. Off releases the LEDs back to Windows.</summary>
public bool Enabled
{
get { lock (_gate) return _enabled; }
set
{
lock (_gate)
{
if (_enabled == value || _unsupported)
return;
_enabled = value;
}
if (value) Start();
else Stop();
}
}
/// <summary>Mirror to one keyboard by device id, or to all (null).</summary>
public void SetTarget(string? deviceId)
{
LampArray[] release;
List<(string Id, string Name)> reclaim = new();
lock (_gate)
{
if (_target == deviceId)
return;
_target = deviceId;
release = _claimed.Where(kv => !Matches(kv.Key))
.Select(kv => kv.Value.Array).ToArray();
foreach (string id in _claimed.Keys.Where(id => !Matches(id)).ToArray())
_claimed.Remove(id);
foreach (KeyValuePair<string, string> kv in _known)
if (Matches(kv.Key) && !_claimed.ContainsKey(kv.Key))
reclaim.Add((kv.Key, kv.Value));
_lastPushed = null;
}
foreach (LampArray array in release)
Release(array);
foreach ((string id, string name) in reclaim)
ReclaimAsync(id, name);
}
/// <summary>Rebuild the key map from a loaded profile (lamp-capable buttons only).</summary>
public void SetProfile(BindingProfile profile)
{
var map = new List<(int, VirtualKey, bool)>();
var seen = new HashSet<VirtualKey>();
foreach (KeyButtonBinding b in profile.KeyButtons)
{
if (!LampAddressIsYellow.TryGetValue(b.Address, out bool yellow))
continue; // keypads and unknown addresses have no lamp
if (!Enum.TryParse(b.Key, ignoreCase: true, out Keys key))
continue;
// WinForms Keys and WinRT VirtualKey share the Win32 VK number space.
var vk = (VirtualKey)(int)(key & Keys.KeyCode);
if (seen.Add(vk)) // first binding wins, like the router's key lookup
map.Add((b.Address, vk, yellow));
}
lock (_gate)
{
_map = map.ToArray();
_lastPushed = null; // repaint under the new map
}
}
public void Dispose()
{
Enabled = false;
Logged = null;
KeyboardsChanged = null;
}
// ---- Device lifecycle ---------------------------------------------------
private void Start()
{
try
{
var watcher = DeviceInformation.CreateWatcher(LampArray.GetDeviceSelector());
watcher.Added += OnArrayAdded;
watcher.Removed += OnArrayRemoved;
watcher.Updated += (_, _) => { }; // required for the watcher to progress
watcher.EnumerationCompleted += (_, _) =>
{
// Attaches log themselves (and may still be in flight); only the
// empty case needs a line here.
bool any;
lock (_gate) any = _anyArraySeen;
if (!any)
Logged?.Invoke("Keyboard lighting: no Dynamic Lighting keyboard found (check Settings → Personalization → Dynamic Lighting)");
};
lock (_gate) _watcher = watcher;
watcher.Start();
// A leaked exception in a Timer callback kills the process — never
// let a lighting hiccup take vRIO down mid-flight.
_timer = new System.Threading.Timer(_ =>
{
try { Tick(); }
catch (Exception ex) { Logged?.Invoke($"Keyboard lighting: paint failed: {ex.Message}"); }
}, null, RefreshMs, RefreshMs);
}
catch (Exception ex)
{
lock (_gate)
{
_unsupported = true;
_enabled = false;
}
Logged?.Invoke($"Keyboard lighting unavailable (Dynamic Lighting needs Windows 11): {ex.Message}");
}
}
private void Stop()
{
DeviceWatcher? watcher;
LampArray[] arrays;
lock (_gate)
{
watcher = _watcher;
_watcher = null;
arrays = _claimed.Values.Select(a => a.Array).ToArray();
_claimed.Clear();
_known.Clear();
_lastPushed = null;
}
_timer?.Dispose();
_timer = null;
try { watcher?.Stop(); }
catch (Exception ex) when (ex is InvalidOperationException or COMException) { }
// Releasing the arrays hands the LEDs back to the system ambient scene.
foreach (LampArray array in arrays)
Release(array);
RaiseKeyboardsChanged();
}
private async void OnArrayAdded(DeviceWatcher sender, DeviceInformation info)
{
lock (_gate) _anyArraySeen = true;
try
{
LampArray array = await LampArray.FromIdAsync(info.Id);
if (array is null || array.LampArrayKind != LampArrayKind.Keyboard)
return; // don't paint mice/strips/cases
lock (_gate) _known[info.Id] = info.Name;
if (!TryClaim(info.Id, info.Name, array))
Release(array);
RaiseKeyboardsChanged();
}
catch (Exception ex)
{
Logged?.Invoke($"Keyboard lighting: could not open {info.Name}: {ex.Message}");
}
}
private void OnArrayRemoved(DeviceWatcher sender, DeviceInformationUpdate update)
{
string? name;
LampArray? gone = null;
lock (_gate)
{
if (!_known.TryGetValue(update.Id, out name))
return;
_known.Remove(update.Id);
if (_claimed.TryGetValue(update.Id, out (LampArray Array, bool PerKey) entry))
{
gone = entry.Array;
_claimed.Remove(update.Id);
}
}
if (gone is not null)
Release(gone);
Logged?.Invoke($"Keyboard lighting: {name} disconnected");
RaiseKeyboardsChanged();
}
private async void ReclaimAsync(string id, string name)
{
try
{
LampArray array = await LampArray.FromIdAsync(id);
if (array is null)
return;
if (!TryClaim(id, name, array))
Release(array);
}
catch (Exception ex)
{
Logged?.Invoke($"Keyboard lighting: could not reopen {name}: {ex.Message}");
}
}
/// <summary>Store the array if it is wanted right now; false = caller releases.</summary>
private bool TryClaim(string id, string name, LampArray array)
{
bool perKey = array.SupportsVirtualKeys;
lock (_gate)
{
if (!_enabled || !Matches(id) || _claimed.ContainsKey(id))
return false;
_claimed[id] = (array, perKey);
_lastPushed = null; // full repaint including the base coat
}
Logged?.Invoke(perKey
? $"Keyboard lighting: + {name} ({array.LampCount} LEDs, per-key)"
: $"Keyboard lighting: + {name} ({array.LampCount} zones — no per-key map, mirroring the strongest lamp board-wide)");
HookAvailability(array, name);
return true;
}
private bool Matches(string id) => _target is null || _target == id;
private static void Release(LampArray array)
{
// No IDisposable projection on .NET Framework — drop the COM wrapper.
try { Marshal.ReleaseComObject(array); } catch (ArgumentException) { }
}
private void RaiseKeyboardsChanged()
{
(string, string)[] known;
lock (_gate) known = _known.Select(kv => (kv.Key, kv.Value)).ToArray();
KeyboardsChanged?.Invoke(known);
}
private void HookAvailability(LampArray array, string name)
{
// IsAvailable/AvailabilityChanged need Windows 11 22H2+; older builds
// just never get the background-control hint.
try
{
bool lastAvailable = array.IsAvailable;
if (!lastAvailable)
Logged?.Invoke($"Keyboard lighting: Windows is withholding {name} — enable vRIO under " +
"Settings → Personalization → Dynamic Lighting → Background light control");
array.AvailabilityChanged += (a, _) =>
{
bool available = a.IsAvailable;
if (available == lastAvailable)
return; // the event also fires without a state change
lastAvailable = available;
Logged?.Invoke(available
? $"Keyboard lighting: {name} available"
: $"Keyboard lighting: {name} withheld (foreground app owns it — see Dynamic Lighting settings)");
lock (_gate) _lastPushed = null; // repaint when control returns
};
}
catch (Exception ex) when (ex is COMException or InvalidCastException or MissingMethodException) { }
}
// ---- Rendering ----------------------------------------------------------
private void Tick()
{
(int Address, VirtualKey Key, bool Yellow)[] map;
(LampArray Array, bool PerKey)[] arrays;
Color[]? last;
Color lastAggregate;
lock (_gate)
{
if (!_enabled || _claimed.Count == 0)
return;
map = _map;
arrays = _claimed.Values.ToArray();
last = _lastPushed;
lastAggregate = _lastAggregate;
}
long tick = Clock.ElapsedMilliseconds;
var colors = new Color[map.Length];
var keys = new VirtualKey[map.Length];
var bestShade = LampBrightness.Off;
bool bestYellow = false;
bool changed = last is null || last.Length != map.Length;
for (int i = 0; i < map.Length; i++)
{
byte state = _device.GetLamp(map[i].Address);
LampBrightness shade = RioLampState.Brightness(state);
if (shade != LampBrightness.Off && !FlashPhaseOn(RioLampState.Flash(state), tick))
shade = LampBrightness.Off;
if (shade > bestShade)
(bestShade, bestYellow) = (shade, map[i].Yellow);
colors[i] = Shade(shade, map[i].Yellow);
keys[i] = map[i].Key;
if (last is not null)
changed |= !colors[i].Equals(last[i]);
}
Color aggregate = Shade(bestShade, bestYellow);
changed |= !aggregate.Equals(lastAggregate);
if (!changed)
return;
foreach ((LampArray array, bool perKey) in arrays)
{
try
{
if (perKey)
{
if (last is null) // new claim/map: black out the whole board first
array.SetColor(ColorHelper.FromArgb(255, 0, 0, 0));
if (map.Length > 0)
array.SetColorsForKeys(colors, keys);
}
else if (map.Length > 0)
{
// Zone keyboard: the whole board is one big lamp showing
// the strongest current state (idle dim, alerts bright/flash).
array.SetColor(aggregate);
}
}
catch (COMException) { } // device wobble; the watcher handles removal
}
lock (_gate)
{
_lastPushed = colors;
_lastAggregate = aggregate;
}
}
/// <summary>The panel's lamp palette (PanelCanvas fills), as LED colors.</summary>
private static Color Shade(LampBrightness shade, bool yellow) =>
yellow
? shade switch
{
LampBrightness.Bright => ColorHelper.FromArgb(255, 245, 210, 60),
LampBrightness.Dim => ColorHelper.FromArgb(255, 140, 118, 38),
_ => ColorHelper.FromArgb(255, 70, 60, 24),
}
: shade switch
{
LampBrightness.Bright => ColorHelper.FromArgb(255, 230, 70, 70),
LampBrightness.Dim => ColorHelper.FromArgb(255, 120, 50, 50),
_ => ColorHelper.FromArgb(255, 64, 40, 40),
};
// Same half-periods as PanelCanvas, so panel and keyboard blink together.
private static bool FlashPhaseOn(LampFlash flash, long tick)
{
long halfPeriod = flash switch
{
LampFlash.FlashSlow => 500,
LampFlash.FlashMed => 250,
LampFlash.FlashFast => 125,
_ => 0,
};
return halfPeriod == 0 || tick / halfPeriod % 2 == 0;
}
private static Dictionary<int, bool> BuildAddressInfo()
{
var info = new Dictionary<int, bool>();
foreach (PanelButton b in CockpitLayout.Buttons())
if (b.LampCapable)
info[b.Address] = b.Group.Title is "Secondary" or "Screen";
return info;
}
}
+321 -33
View File
@@ -1,6 +1,7 @@
using System.Diagnostics;
using System.IO.Ports;
using VRio.Core.Device;
using VRio.Core.Input;
namespace VRio.App;
@@ -16,6 +17,13 @@ internal sealed class MainForm : Form
private readonly VRioDevice _device = new();
private readonly VRioSerialService _service;
private readonly PanelCanvas _canvas = new();
private readonly InputRouter _router;
private readonly XInputGamepad _gamepad = new();
private readonly KeyboardLampMirror _lampMirror;
private readonly RawKeyboardSource _rawKeyboard = new();
private string? _activeInputId; // interface path of the keyboard being captured; null = WinForms focus path
private readonly string _bindingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
private readonly ComboBox _portBox = new()
{
@@ -33,31 +41,64 @@ internal sealed class MainForm : Form
ForeColor = Color.Gray,
};
private readonly NumericUpDown _verMajor = new() { Location = new Point(80, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 4 };
private readonly NumericUpDown _verMinor = new() { Location = new Point(140, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 2 };
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 56), AutoSize = true, Checked = true };
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 82), Width = 140, Height = 26 };
private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 82), Width = 140, Height = 26 };
private readonly Button _testEnter = new() { Text = "Enter test mode", Location = new Point(10, 114), Width = 140, Height = 26 };
private readonly Button _testExit = new() { Text = "Exit test mode", Location = new Point(156, 114), Width = 140, Height = 26 };
private readonly CheckBox _wedgeBug = new()
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true };
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 };
private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 52), Width = 140, Height = 26 };
private readonly CheckBox _kbInput = new() { Text = "Keyboard", Location = new Point(10, 22), AutoSize = true, Checked = true };
private readonly CheckBox _padInput = new() { Text = "Gamepad", Location = new Point(100, 22), AutoSize = true, Checked = true };
private readonly CheckBox _invertY = new() { Text = "Invert Y", Location = new Point(190, 22), AutoSize = true };
private readonly Label _padStatus = new()
{
Text = "Emulate the v4.2 reply-wedge bug",
Location = new Point(10, 148),
Text = "No controller detected.",
Location = new Point(10, 46),
AutoSize = true,
ForeColor = Color.Gray,
};
private readonly Button _reloadBindings = new() { Text = "Reload bindings", Location = new Point(10, 68), Width = 140, Height = 26 };
private readonly Button _editBindings = new() { Text = "Edit bindings…", Location = new Point(156, 68), Width = 140, Height = 26 };
private readonly CheckBox _kbLights = new()
{
Text = "Mirror lamps on RGB keyboard (Dynamic Lighting)",
Location = new Point(10, 100),
AutoSize = true,
};
private readonly Button _wedgeNow = new() { Text = "Wedge analog now", Location = new Point(10, 172), Width = 140, Height = 26 };
private readonly ComboBox _kbLightsTarget = new()
{
Location = new Point(10, 122),
Width = 286,
DropDownStyle = ComboBoxStyle.DropDownList,
Enabled = false, // populated while the mirror is on
};
private readonly Label _kbInputLabel = new()
{
Text = "Capture keyboard (no window focus needed):",
Location = new Point(10, 144),
AutoSize = true,
};
private readonly ComboBox _kbInputTarget = new()
{
Location = new Point(10, 162),
Width = 286,
DropDownStyle = ComboBoxStyle.DropDownList,
};
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
private sealed record KbChoice(string? Id, string Name)
{
public override string ToString() => Name;
}
private readonly Label _counters = new()
{
Location = new Point(12, 290),
Location = new Point(12, 372),
AutoSize = true,
Font = new Font("Consolas", 8f),
};
private readonly Label _help = new()
{
Location = new Point(12, 348),
Location = new Point(12, 428),
MaximumSize = new Size(306, 0),
AutoSize = true,
ForeColor = Color.Gray,
@@ -67,10 +108,10 @@ internal sealed class MainForm : Form
private readonly TextBox _logBox = new()
{
Location = new Point(12, 428),
Location = new Point(12, 498),
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
BackColor = Color.FromArgb(24, 24, 24),
ForeColor = Color.Gainsboro,
Font = new Font("Consolas", 8f),
@@ -85,18 +126,26 @@ internal sealed class MainForm : Form
};
private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 };
private readonly System.Windows.Forms.Timer _inputTimer = new() { Interval = 16 };
private readonly Stopwatch _clock = Stopwatch.StartNew();
private double _lastInputTick;
public MainForm()
{
Text = "vRIO — Virtual RIO cockpit device";
// ProductVersion carries the git stamp (see StampGitVersion in the csproj).
Text = $"vRIO v{Application.ProductVersion} — Virtual RIO cockpit device";
// Title-bar/taskbar icon from the exe's embedded ApplicationIcon (vwe.ico).
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
// Fit the window to its content: the cockpit canvas plus the 330px
// control strip, with just enough height for the strip's log area.
ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640));
MinimumSize = new Size(1000, 620);
StartPosition = FormStartPosition.CenterScreen;
KeyPreview = true; // form-level key routing for the input bindings
_service = new VRioSerialService(_device);
_router = new InputRouter(_device);
_lampMirror = new KeyboardLampMirror(_device);
// Panel canvas, scrolled if the window is smaller than the grid.
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
@@ -108,56 +157,97 @@ internal sealed class MainForm : Form
// Canvas ↔ device wiring.
_canvas.LampProvider = _device.GetLamp;
_canvas.AxisProvider = _device.GetAxis;
_canvas.WireAxisProvider = _device.GetWireAxis;
_canvas.AddressPressed += _device.PressAddress;
_canvas.AddressReleased += _device.ReleaseAddress;
_canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value);
// Router-driven presses light up like clicks (router runs on the UI thread).
_router.AddressHeldChanged += _canvas.SetExternalHeld;
// Device / service events arrive on worker threads; marshal to the UI.
_device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate);
_device.AxesChanged += () => RunOnUi(_canvas.Invalidate);
// A host reset re-zeroes the axes behind the router's back.
_device.ResetReceived += _ => RunOnUi(_router.ResetAxisState);
_device.Logged += line => RunOnUi(() => PrependLog(line));
_service.Logged += line => RunOnUi(() => PrependLog(line));
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
_service.HostHandshake += high => RunOnUi(() =>
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
_rescan.Click += (_, _) => RefreshPorts();
_openClose.Click += (_, _) => ToggleOpen();
_verMajor.ValueChanged += (_, _) => _device.VersionMajor = (byte)_verMajor.Value;
_verMinor.ValueChanged += (_, _) => _device.VersionMinor = (byte)_verMinor.Value;
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
_centerAxes.Click += (_, _) =>
{
foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis)))
_device.SetAxis(axis, 0);
_router.ResetAxisState();
};
_lampsOff.Click += (_, _) =>
{
_device.ClearLamps();
_canvas.Invalidate();
};
_testEnter.Click += (_, _) => _device.SendTestMode(1);
_testExit.Click += (_, _) => _device.SendTestMode(0);
_wedgeBug.CheckedChanged += (_, _) => _device.EmulateReplyWedge = _wedgeBug.Checked;
_wedgeNow.Click += (_, _) =>
{
_device.WedgeAnalogNow();
UpdateStatus();
};
_clearLog.Click += (_, _) => _logBox.Clear();
_kbInput.CheckedChanged += (_, _) =>
{
if (!_kbInput.Checked)
_router.ReleaseAllKeys();
};
_invertY.CheckedChanged += (_, _) =>
{
_device.InvertJoystickY = _invertY.Checked;
_canvas.Invalidate(); // the wire readout flips even though the axes didn't move
};
_device.InvertJoystickY = _invertY.Checked; // the field initializer raises no event
_kbLights.CheckedChanged += (_, _) =>
{
_lampMirror.Enabled = _kbLights.Checked;
_kbLightsTarget.Enabled = _kbLights.Checked;
};
_kbLightsTarget.SelectedIndexChanged += (_, _) =>
_lampMirror.SetTarget((_kbLightsTarget.SelectedItem as KbChoice)?.Id);
_lampMirror.KeyboardsChanged += list => RunOnUi(() => RebuildKeyboardPicker(list));
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
_kbLightsTarget.SelectedIndex = 0;
// Raw-input capture: a chosen keyboard drives the panel even while the
// sim has focus. Down-edges only fire when keyboard input is enabled;
// up-edges always land so a release is never stranded by a mid-hold flip.
_rawKeyboard.KeyDown += name => RunOnUi(() => { if (_kbInput.Checked) _router.KeyDown(name); });
_rawKeyboard.KeyUp += name => RunOnUi(() => _router.KeyUp(name));
_rawKeyboard.Logged += line => RunOnUi(() => PrependLog(line));
_rawKeyboard.KeyboardsChanged += list => RunOnUi(() => RebuildInputKeyboardPicker(list));
_kbInputTarget.SelectedIndexChanged += (_, _) => ApplyInputSource();
_kbInputTarget.DropDown += (_, _) => _rawKeyboard.RefreshKeyboards();
_kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
_kbInputTarget.SelectedIndex = 0;
_reloadBindings.Click += (_, _) => LoadBindings();
_editBindings.Click += (_, _) => OpenBindingsFile();
_uiTimer.Tick += (_, _) => UpdateStatus();
_uiTimer.Start();
_inputTimer.Tick += (_, _) => InputTick();
_inputTimer.Start();
FormClosed += (_, _) =>
{
_uiTimer.Dispose();
_inputTimer.Dispose();
_lampMirror.Dispose();
_rawKeyboard.Dispose();
_service.Dispose();
};
RefreshPorts();
UpdateStatus();
PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair.");
LoadBindings();
}
private Panel BuildControlStrip()
@@ -181,17 +271,17 @@ internal sealed class MainForm : Form
panel.Controls.Add(_openClose);
panel.Controls.Add(_linkStatus);
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 210) };
device.Controls.Add(new Label { Text = "Firmware:", Location = new Point(10, 27), AutoSize = true });
device.Controls.Add(_verMajor);
device.Controls.Add(new Label { Text = ".", Location = new Point(127, 27), AutoSize = true });
device.Controls.Add(_verMinor);
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit, _wedgeBug, _wedgeNow });
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) };
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
panel.Controls.Add(device);
var input = new GroupBox { Text = "Input", Location = new Point(12, 162), Size = new Size(306, 202) };
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget });
panel.Controls.Add(input);
panel.Controls.Add(_counters);
panel.Controls.Add(_help);
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 410), AutoSize = true });
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 480), AutoSize = true });
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
panel.Controls.Add(_logBox);
@@ -252,6 +342,204 @@ internal sealed class MainForm : Form
UpdateStatus();
}
// ---- Keyboard / gamepad input -------------------------------------------
/// <summary>
/// Keys route to the panel unless the user is in a control that needs
/// them (port list, log box scrolling).
/// </summary>
private bool KeyboardRoutingActive =>
_kbInput.Checked &&
!_portBox.ContainsFocus && !_logBox.ContainsFocus;
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
_rawKeyboard.Attach(Handle);
_rawKeyboard.RefreshKeyboards(); // seed the capture picker
}
protected override void WndProc(ref Message m)
{
// Raw Input arrives outside the normal key-message path; feed it to the
// capture source, then let DefWindowProc run its WM_INPUT cleanup.
const int WM_INPUT = 0x00FF, WM_INPUT_DEVICE_CHANGE = 0x00FE;
switch (m.Msg)
{
case WM_INPUT:
_rawKeyboard.ProcessInput(m.LParam);
break;
case WM_INPUT_DEVICE_CHANGE:
_rawKeyboard.HandleDeviceChange();
break;
}
base.WndProc(ref m);
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
// Intercept WM_KEYDOWN before dialog-key processing, or arrows/space
// would move focus and click buttons instead of reaching the panel.
const int WM_KEYDOWN = 0x0100, WM_SYSKEYDOWN = 0x0104;
if ((msg.Msg is WM_KEYDOWN or WM_SYSKEYDOWN)
&& (keyData & (Keys.Control | Keys.Alt)) == 0
&& KeyboardRoutingActive)
{
string name = (keyData & Keys.KeyCode).ToString();
if (_router.HasKeyBinding(name))
{
// While a specific keyboard is captured, that source owns
// routing; still swallow the key so it can't click a button.
if (!_rawKeyboard.IsCapturing)
_router.KeyDown(name);
return true;
}
}
return base.ProcessCmdKey(ref msg, keyData);
}
protected override void OnKeyUp(KeyEventArgs e)
{
// Unconditional in focus mode: the router ignores keys it never saw go
// down, and a release must land even if the checkbox flipped mid-hold.
// In capture mode the raw source owns edges, so don't double-release.
if (!_rawKeyboard.IsCapturing)
_router.KeyUp(e.KeyCode.ToString());
base.OnKeyUp(e);
}
protected override void OnDeactivate(EventArgs e)
{
// Focus mode loses key-up events once unfocused, so release held keys.
// Capture mode keeps receiving them in the background — leave holds be.
if (!_rawKeyboard.IsCapturing)
_router.ReleaseAllKeys();
base.OnDeactivate(e);
}
/// <summary>Apply the capture-picker selection: swap the keyboard input source.</summary>
private void ApplyInputSource()
{
string? id = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
if (id == _activeInputId)
return;
_activeInputId = id;
_router.ReleaseAllKeys(); // don't strand holds across a source swap
_rawKeyboard.SetTarget(id);
PrependLog(id is null
? "Keyboard input: all keyboards, only while vRIO has focus"
: $"Keyboard input: capturing \"{(_kbInputTarget.SelectedItem as KbChoice)?.Name}\" in the background");
}
/// <summary>Refresh the capture picker, keeping the pick if the device survived.</summary>
private void RebuildInputKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
{
string? selected = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
_kbInputTarget.BeginUpdate();
_kbInputTarget.Items.Clear();
_kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
int select = 0;
foreach ((string id, string name) in keyboards)
{
int idx = _kbInputTarget.Items.Add(new KbChoice(id, name));
if (id == selected)
select = idx;
}
// A vanished capture device falls back to "All keyboards"; the
// selection-changed handler (ApplyInputSource) unregisters accordingly.
_kbInputTarget.SelectedIndex = select;
_kbInputTarget.EndUpdate();
}
private void InputTick()
{
double now = _clock.Elapsed.TotalSeconds;
double dt = Math.Min(0.25, now - _lastInputTick); // cap catch-up after a stall
_lastInputTick = now;
if (_padInput.Checked && _gamepad.TryRead(out var pad))
_router.SetPadState(pad);
else
_router.SetPadState(default); // releases whatever the pad held
_router.Tick(dt);
string status = _padInput.Checked
? _gamepad.Connected ? $"Controller #{_gamepad.UserIndex + 1} connected." : "No controller detected."
: "Gamepad input off.";
if (_padStatus.Text != status)
{
_padStatus.Text = status;
_padStatus.ForeColor = _gamepad.Connected && _padInput.Checked ? Color.ForestGreen : Color.Gray;
}
}
private void LoadBindings()
{
try
{
string text;
if (File.Exists(_bindingsPath))
{
text = File.ReadAllText(_bindingsPath);
}
else
{
// First run: materialize the commented default file so
// "Edit bindings…" has something self-documenting to open.
Directory.CreateDirectory(Path.GetDirectoryName(_bindingsPath)!);
File.WriteAllText(_bindingsPath, BindingProfileFormat.DefaultText);
text = BindingProfileFormat.DefaultText;
}
var profile = BindingProfileFormat.Parse(text, out var errors);
_router.Profile = profile;
_lampMirror.SetProfile(profile);
foreach (string error in errors)
PrependLog($"Bindings: {error}");
PrependLog($"Bindings loaded: {profile.Count} ({_bindingsPath})");
}
catch (Exception ex)
{
PrependLog($"Bindings load failed: {ex.Message}");
}
}
private void OpenBindingsFile()
{
try
{
if (!File.Exists(_bindingsPath))
LoadBindings(); // writes the default file
Process.Start(new ProcessStartInfo(_bindingsPath) { UseShellExecute = true });
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not open {_bindingsPath}:\n{ex.Message}", "vRIO",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
/// <summary>Refresh the lamp-mirror keyboard picker, keeping the pick if it survived.</summary>
private void RebuildKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
{
string? selected = (_kbLightsTarget.SelectedItem as KbChoice)?.Id;
_kbLightsTarget.BeginUpdate();
_kbLightsTarget.Items.Clear();
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
int select = 0;
foreach ((string id, string name) in keyboards)
{
int idx = _kbLightsTarget.Items.Add(new KbChoice(id, name));
if (id == selected)
select = idx;
}
// Falls back to "All keyboards" if the picked device vanished (the
// selection-changed handler re-targets the mirror accordingly).
_kbLightsTarget.SelectedIndex = select;
_kbLightsTarget.EndUpdate();
}
// ---- Status / log ------------------------------------------------------
private void UpdateStatus()
+22 -3
View File
@@ -43,6 +43,7 @@ internal sealed class PanelCanvas : Control
private bool _wasFlashing;
private readonly HashSet<int> _latched = new();
private readonly HashSet<int> _externalHeld = new();
private int? _mouseDownAddress;
private RioAxis? _dragAxis; // Z / L / R gauge drag
@@ -72,6 +73,12 @@ internal sealed class PanelCanvas : Control
/// <summary>Current raw value of an axis (from the device).</summary>
public Func<RioAxis, short>? AxisProvider { get; set; }
/// <summary>
/// Axis value as transmitted to the host (joystick Y sign flip applied).
/// The text readout shows this; the dot and gauges track physical state.
/// </summary>
public Func<RioAxis, short>? WireAxisProvider { get; set; }
/// <summary>The user pressed a panel control.</summary>
public event Action<int>? AddressPressed;
@@ -215,7 +222,18 @@ internal sealed class PanelCanvas : Control
}
}
private bool IsHeld(int address) => _mouseDownAddress == address || _latched.Contains(address);
private bool IsHeld(int address) =>
_mouseDownAddress == address || _latched.Contains(address) || _externalHeld.Contains(address);
/// <summary>
/// Mark an address held/released by a non-mouse source (keyboard or
/// gamepad via the input router) so it lights up like a click.
/// </summary>
public void SetExternalHeld(int address, bool held)
{
if (held ? _externalHeld.Add(address) : _externalHeld.Remove(address))
Invalidate();
}
private static bool FlashPhaseOn(LampFlash flash, int tick)
{
@@ -287,9 +305,10 @@ internal sealed class PanelCanvas : Control
new Size(statusRect.Width, 0), TextFormatFlags.WordBreak).Height + 2;
}
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
string readout =
$"Z {Axis(RioAxis.Throttle),4} L {Axis(RioAxis.LeftPedal),3} R {Axis(RioAxis.RightPedal),3} " +
$"X {Axis(RioAxis.JoystickX),3} Y {Axis(RioAxis.JoystickY),3}";
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3} " +
$"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}";
TextRenderer.DrawText(g, readout, statusFont,
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.SingleLine);
+376
View File
@@ -0,0 +1,376 @@
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using Microsoft.Win32;
namespace VRio.App;
/// <summary>
/// A background keyboard input source built on the Win32 <b>Raw Input</b> API.
/// Where the WinForms focus path (<c>ProcessCmdKey</c>/<c>OnKeyUp</c>) only sees
/// keys while vRIO is the foreground window and can't tell one keyboard from
/// another, Raw Input carries a per-device handle with every keystroke and —
/// with the <c>RIDEV_INPUTSINK</c> flag — keeps delivering <c>WM_INPUT</c> even
/// when vRIO is in the background. That lets the user nominate one physical
/// keyboard as a dedicated cockpit panel: its keys drive the RIO controls while
/// the sim (or RIOJoy) holds focus, and other keyboards are ignored.
///
/// <para>This is the input-side twin of <see cref="KeyboardLampMirror"/>: that
/// class <em>writes</em> a chosen keyboard's LEDs while the game has focus, this
/// one <em>reads</em> a chosen keyboard's keys under the same condition. Raw
/// Input observes without intercepting — the keystroke still reaches whatever
/// app has focus — so the selected keyboard should be one dedicated to the
/// panel, not the one you also type on.</para>
///
/// <para>Registration is scoped to when a device is actually selected
/// (<see cref="SetTarget"/>): picking "all keyboards" unregisters and hands
/// input back to the focus path, so vRIO isn't pumping <c>WM_INPUT</c> for every
/// keystroke system-wide when the feature is idle. Not thread-safe;
/// <see cref="ProcessInput"/> and the events fire on the UI thread that owns the
/// window handle, which is exactly where <see cref="Input.InputRouter"/> wants
/// to be called.</para>
/// </summary>
public sealed class RawKeyboardSource : IDisposable
{
// --- Win32 Raw Input interop ---------------------------------------------
[StructLayout(LayoutKind.Sequential)]
private struct RAWINPUTDEVICE
{
public ushort usUsagePage;
public ushort usUsage;
public uint dwFlags;
public IntPtr hwndTarget;
}
[StructLayout(LayoutKind.Sequential)]
private struct RAWINPUTDEVICELIST
{
public IntPtr hDevice;
public uint dwType;
}
[StructLayout(LayoutKind.Sequential)]
private struct RAWINPUTHEADER
{
public uint dwType;
public uint dwSize;
public IntPtr hDevice;
public IntPtr wParam;
}
[StructLayout(LayoutKind.Sequential)]
private struct RAWKEYBOARD
{
public ushort MakeCode;
public ushort Flags;
public ushort Reserved;
public ushort VKey;
public uint Message;
public uint ExtraInformation;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RegisterRawInputDevices(
[In] RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize);
[DllImport("user32.dll", SetLastError = true)]
private static extern uint GetRawInputData(
IntPtr hRawInput, uint uiCommand, IntPtr pData, ref uint pcbSize, uint cbSizeHeader);
[DllImport("user32.dll", SetLastError = true)]
private static extern uint GetRawInputDeviceList(
[In, Out] RAWINPUTDEVICELIST[]? pRawInputDeviceList, ref uint puiNumDevices, uint cbSize);
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern uint GetRawInputDeviceInfoW(
IntPtr hDevice, uint uiCommand, IntPtr pData, ref uint pcbSize);
private const uint Err = 0xFFFFFFFF; // sentinel: all three APIs return (UINT)-1 on failure
private const uint RIDEV_REMOVE = 0x00000001;
private const uint RIDEV_INPUTSINK = 0x00000100;
private const uint RIDEV_DEVNOTIFY = 0x00002000; // WM_INPUT_DEVICE_CHANGE on hot-plug
private const uint RID_INPUT = 0x10000003;
private const uint RIDI_DEVICENAME = 0x20000007;
private const uint RIM_TYPEKEYBOARD = 1;
private const ushort RI_KEY_BREAK = 0x01; // set = key up (make/break)
private const ushort HidUsagePageGeneric = 0x01;
private const ushort HidUsageKeyboard = 0x06;
private static readonly int HeaderSize = Marshal.SizeOf<RAWINPUTHEADER>();
// --- State ---------------------------------------------------------------
// hDevice → interface path, so the hot per-keystroke path skips the
// two-call GetRawInputDeviceInfo dance once a device is known.
private readonly Dictionary<IntPtr, string> _pathByHandle = new();
private IntPtr _hwnd;
private string? _target; // interface path of the keyboard to capture; null = disabled
private bool _registered;
private bool _disposed;
/// <summary>A key on the captured keyboard went down (.NET <see cref="Keys"/> name).</summary>
public event Action<string>? KeyDown;
/// <summary>A key on the captured keyboard came up (.NET <see cref="Keys"/> name).</summary>
public event Action<string>? KeyUp;
/// <summary>The set of attached keyboards changed (interface path, display name) — for a picker.</summary>
public event Action<IReadOnlyList<(string Id, string Name)>>? KeyboardsChanged;
/// <summary>Status/diagnostic lines.</summary>
public event Action<string>? Logged;
/// <summary>True while a specific keyboard is being captured (focus path should stand down).</summary>
public bool IsCapturing => _target is not null;
/// <summary>Bind to the window whose message loop delivers <c>WM_INPUT</c>.</summary>
public void Attach(IntPtr hwnd) => _hwnd = hwnd;
/// <summary>
/// Capture one keyboard by interface path, or none (<paramref name="deviceId"/>
/// null) to release input back to the focus path. Registering/unregistering
/// Raw Input is scoped here so vRIO only taps the global key stream while the
/// feature is actually in use.
/// </summary>
public void SetTarget(string? deviceId)
{
if (_target == deviceId)
return;
_target = deviceId;
if (deviceId is null)
Unregister();
else
Register();
}
/// <summary>Re-enumerate keyboards and raise <see cref="KeyboardsChanged"/>.</summary>
public void RefreshKeyboards()
{
_pathByHandle.Clear(); // handles are only stable per connection
KeyboardsChanged?.Invoke(EnumerateKeyboards());
}
/// <summary>Handle a <c>WM_INPUT_DEVICE_CHANGE</c>: a keyboard arrived or left.</summary>
public void HandleDeviceChange() => RefreshKeyboards();
/// <summary>
/// Handle a <c>WM_INPUT</c> message (pass its <c>LParam</c>). Silently ignores
/// input from any keyboard other than the captured one.
/// </summary>
public void ProcessInput(IntPtr hRawInput)
{
string? target = _target;
if (target is null)
return;
uint size = 0;
if (GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref size, (uint)HeaderSize) == Err || size == 0)
return;
IntPtr buffer = Marshal.AllocHGlobal((int)size);
try
{
if (GetRawInputData(hRawInput, RID_INPUT, buffer, ref size, (uint)HeaderSize) != size)
return;
var header = Marshal.PtrToStructure<RAWINPUTHEADER>(buffer);
if (header.dwType != RIM_TYPEKEYBOARD)
return;
string? path = PathFor(header.hDevice);
if (!string.Equals(path, target, StringComparison.OrdinalIgnoreCase))
return;
var kb = Marshal.PtrToStructure<RAWKEYBOARD>(IntPtr.Add(buffer, HeaderSize));
ushort vkey = kb.VKey;
// 0 = no VK, 0xFF = fake key emitted as part of an escaped sequence.
if (vkey is 0 or 0xFF)
return;
string key = ((Keys)vkey).ToString();
if ((kb.Flags & RI_KEY_BREAK) != 0)
KeyUp?.Invoke(key);
else
KeyDown?.Invoke(key); // repeats while held are dropped by the router
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_target = null;
Unregister();
KeyDown = null;
KeyUp = null;
KeyboardsChanged = null;
Logged = null;
}
// --- Registration --------------------------------------------------------
private void Register()
{
if (_registered || _hwnd == IntPtr.Zero)
return;
var rid = new[]
{
new RAWINPUTDEVICE
{
usUsagePage = HidUsagePageGeneric,
usUsage = HidUsageKeyboard,
dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, // background delivery + hot-plug notices
hwndTarget = _hwnd,
},
};
if (RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf<RAWINPUTDEVICE>()))
_registered = true;
else
Logged?.Invoke($"Raw keyboard capture unavailable (error {Marshal.GetLastWin32Error()})");
}
private void Unregister()
{
if (!_registered)
return;
// RIDEV_REMOVE must pass a null target window, or the call fails.
var rid = new[]
{
new RAWINPUTDEVICE
{
usUsagePage = HidUsagePageGeneric,
usUsage = HidUsageKeyboard,
dwFlags = RIDEV_REMOVE,
hwndTarget = IntPtr.Zero,
},
};
RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf<RAWINPUTDEVICE>());
_registered = false;
}
// --- Enumeration / naming ------------------------------------------------
private List<(string Id, string Name)> EnumerateKeyboards()
{
var result = new List<(string, string)>();
uint listSize = (uint)Marshal.SizeOf<RAWINPUTDEVICELIST>();
uint count = 0;
if (GetRawInputDeviceList(null, ref count, listSize) == Err || count == 0)
return result;
var devices = new RAWINPUTDEVICELIST[count];
uint got = GetRawInputDeviceList(devices, ref count, listSize);
if (got == Err)
return result;
var labelCounts = new Dictionary<string, int>();
for (int i = 0; i < got; i++)
{
if (devices[i].dwType != RIM_TYPEKEYBOARD)
continue;
string? path = PathFor(devices[i].hDevice);
if (string.IsNullOrEmpty(path))
continue;
string label = FriendlyName(path!);
// Physical keyboards can surface as more than one collection; a bare
// count suffix keeps otherwise-identical labels distinguishable.
if (labelCounts.TryGetValue(label, out int n))
{
labelCounts[label] = n + 1;
label = $"{label} ({n + 1})";
}
else
{
labelCounts[label] = 1;
}
result.Add((path!, label));
}
return result;
}
private string? PathFor(IntPtr hDevice)
{
if (_pathByHandle.TryGetValue(hDevice, out string? cached))
return cached;
string? path = DeviceName(hDevice);
if (path is not null)
_pathByHandle[hDevice] = path;
return path;
}
private static string? DeviceName(IntPtr hDevice)
{
uint chars = 0;
if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, IntPtr.Zero, ref chars) == Err || chars == 0)
return null;
IntPtr buffer = Marshal.AllocHGlobal((int)chars * sizeof(char));
try
{
if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, buffer, ref chars) == Err)
return null;
return Marshal.PtrToStringUni(buffer);
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
/// <summary>
/// Turn a raw device interface path into something a human recognizes: the
/// device's registry description if we can resolve it, else its VID:PID.
/// </summary>
private static string FriendlyName(string interfacePath)
{
string? desc = RegistryName(interfacePath);
if (!string.IsNullOrWhiteSpace(desc))
return desc!;
Match m = Regex.Match(interfacePath, @"VID_([0-9A-Fa-f]{4}).*?PID_([0-9A-Fa-f]{4})");
return m.Success
? $"Keyboard {m.Groups[1].Value.ToUpperInvariant()}:{m.Groups[2].Value.ToUpperInvariant()}"
: "Keyboard";
}
private static string? RegistryName(string interfacePath)
{
try
{
// \\?\HID#VID_x&PID_y#instance#{class-guid} → Enum key HID\VID_x&PID_y\instance
string s = interfacePath;
if (s.StartsWith(@"\\?\", StringComparison.Ordinal) || s.StartsWith(@"\\.\", StringComparison.Ordinal))
s = s.Substring(4);
int guid = s.LastIndexOf("#{", StringComparison.Ordinal);
if (guid >= 0)
s = s.Substring(0, guid);
string instance = s.Replace('#', '\\');
using RegistryKey? key = Registry.LocalMachine.OpenSubKey(
@"SYSTEM\CurrentControlSet\Enum\" + instance);
if (key is null)
return null;
string? name = key.GetValue("FriendlyName") as string;
if (string.IsNullOrWhiteSpace(name))
name = key.GetValue("DeviceDesc") as string;
if (string.IsNullOrWhiteSpace(name))
return null;
// DeviceDesc is "@driver.inf,%token%;Actual Name" — keep the tail.
int semi = name!.LastIndexOf(';');
return semi >= 0 ? name.Substring(semi + 1) : name;
}
catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException)
{
return null;
}
}
}
+29
View File
@@ -8,18 +8,47 @@
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>vwe.ico</ApplicationIcon>
<AssemblyTitle>vRIO — Virtual RIO device</AssemblyTitle>
<!-- StampGitVersion already puts the sha in InformationalVersion; stop the
SDK appending "+fullsha" on top of it. -->
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\VRio.Core\VRio.Core.csproj" />
</ItemGroup>
<!-- Stamp commit date + short sha into InformationalVersion; the window
title shows it (Application.ProductVersion) so a running build can be
matched to its vYYYY.MM.DD release tag at a glance. Falls back to the
default 1.0.0 when git isn't available. -->
<Target Name="StampGitVersion" BeforeTargets="GetAssemblyVersion" Condition="'$(DesignTimeBuild)' != 'true'">
<!-- %%cs survives cmd's percent expansion as %cs: committer date, YYYY-MM-DD. -->
<Exec Command="git -C &quot;$(MSBuildProjectDirectory)&quot; log -1 --format=%%cs"
ConsoleToMSBuild="true" StandardOutputImportance="low" IgnoreExitCode="true" ContinueOnError="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitDate" />
<Output TaskParameter="ExitCode" PropertyName="GitDateExitCode" />
</Exec>
<!-- The exclude flag keeps describe off the release tags: always the short
sha, with a "dirty" suffix when the working tree has local edits. -->
<Exec Command="git -C &quot;$(MSBuildProjectDirectory)&quot; describe --always --dirty --exclude=*"
ConsoleToMSBuild="true" StandardOutputImportance="low" IgnoreExitCode="true" ContinueOnError="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitShortSha" />
</Exec>
<PropertyGroup Condition="'$(GitDateExitCode)' == '0' And '$(GitShortSha)' != ''">
<InformationalVersion>$(GitCommitDate.Trim().Replace('-', '.')) ($(GitShortSha))</InformationalVersion>
</PropertyGroup>
</Target>
<ItemGroup>
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- WinRT projections for Windows.Devices.Lights (Dynamic Lighting /
LampArray) on .NET Framework — the keyboard lamp mirror. -->
<PackageReference Include="Microsoft.Windows.SDK.Contracts" Version="10.0.22621.3233" />
</ItemGroup>
</Project>
+120
View File
@@ -0,0 +1,120 @@
using System.Runtime.InteropServices;
using VRio.Core.Input;
namespace VRio.App;
/// <summary>
/// Polls the first connected XInput (Xbox) controller and converts its state
/// to the Core's normalized <see cref="PadState"/>. Uses the in-box
/// xinput1_4.dll (Windows 8+), falling back to xinput9_1_0.dll, so no
/// redistributable is needed. Polling a disconnected user index is expensive,
/// so while no pad is present the 03 scan only runs every ~1 s.
/// </summary>
internal sealed class XInputGamepad
{
private const int MaxUsers = 4;
private const uint ErrorSuccess = 0;
private const int ScanEveryNPolls = 60; // ≈1 s at the 16 ms input tick
private static bool _tryXInput14 = true;
private int _user = -1;
private int _scanCountdown;
/// <summary>True while a controller is connected and being polled.</summary>
public bool Connected => _user >= 0;
/// <summary>XInput user index of the connected controller (0-based).</summary>
public int UserIndex => _user;
/// <summary>
/// Poll the pad. False (with a rest-state snapshot) while disconnected.
/// </summary>
public bool TryRead(out PadState state)
{
if (_user >= 0)
{
if (GetState(_user, out XINPUT_STATE s) == ErrorSuccess)
{
state = Convert(s.Gamepad);
return true;
}
_user = -1; // unplugged; fall through to (throttled) rescan
_scanCountdown = 0;
}
if (--_scanCountdown <= 0)
{
_scanCountdown = ScanEveryNPolls;
for (int i = 0; i < MaxUsers; i++)
{
if (GetState(i, out XINPUT_STATE s) == ErrorSuccess)
{
_user = i;
state = Convert(s.Gamepad);
return true;
}
}
}
state = default;
return false;
}
private static PadState Convert(in XINPUT_GAMEPAD g) => new(
(PadButtons)g.wButtons,
Thumb(g.sThumbLX), Thumb(g.sThumbLY),
Thumb(g.sThumbRX), Thumb(g.sThumbRY),
g.bLeftTrigger / 255f, g.bRightTrigger / 255f);
private static float Thumb(short v) => Math.Max(-1f, v / 32767f);
private static uint GetState(int user, out XINPUT_STATE state)
{
if (_tryXInput14)
{
try
{
return XInputGetState14(user, out state);
}
catch (DllNotFoundException)
{
_tryXInput14 = false;
}
}
try
{
return XInputGetState910(user, out state);
}
catch (DllNotFoundException)
{
state = default;
return uint.MaxValue; // no XInput on this system — behaves as "no pad"
}
}
[DllImport("xinput1_4.dll", EntryPoint = "XInputGetState")]
private static extern uint XInputGetState14(int dwUserIndex, out XINPUT_STATE state);
[DllImport("xinput9_1_0.dll", EntryPoint = "XInputGetState")]
private static extern uint XInputGetState910(int dwUserIndex, out XINPUT_STATE state);
[StructLayout(LayoutKind.Sequential)]
private struct XINPUT_STATE
{
public uint dwPacketNumber;
public XINPUT_GAMEPAD Gamepad;
}
[StructLayout(LayoutKind.Sequential)]
private struct XINPUT_GAMEPAD
{
public ushort wButtons;
public byte bLeftTrigger;
public byte bRightTrigger;
public short sThumbLX;
public short sThumbLY;
public short sThumbRX;
public short sThumbRY;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+34 -3
View File
@@ -13,7 +13,9 @@ namespace VRio.Core.Device;
/// <para>Wire behavior (mirroring what RIOJoy expects from the real board):</para>
/// <list type="bullet">
/// <item>ACKs every well-formed inbound packet, NAKs one with a bad checksum.</item>
/// <item>CheckRequest → a BoardOk CheckReply per known board.</item>
/// <item>CheckRequest → TestModeChange ENTER, a BoardOk CheckReply per known
/// board (the self-test stream), then TestModeChange EXIT — the init
/// handshake the real board performs and the game waits (≤5s per step) on.</item>
/// <item>VersionRequest → VersionReply with the configured firmware version
/// (default 4.2, matching the real board's dumped EPROM).</item>
/// <item>AnalogRequest → AnalogReply with the current five axis values.</item>
@@ -55,6 +57,15 @@ public sealed class VRioDevice
/// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
public byte VersionMinor { get; set; } = 2;
/// <summary>
/// The wire natively carries stick-up as negative (full up = 80 in
/// AnalogReply); set this to send the physical direction (up = positive)
/// instead. Only the host sees the difference — local state
/// (<see cref="GetAxis"/>, the panel's dot) keeps the physical stick
/// direction either way.
/// </summary>
public bool InvertJoystickY { get; set; }
/// <summary>
/// When true, retry exhaustion leaves the analog reply path wedged (the
/// v4.2 latch-leak bug) until a host ResetRequest clears it.
@@ -99,6 +110,21 @@ public sealed class VRioDevice
lock (_gate) return _axes[(int)axis];
}
/// <summary>
/// Axis value as the next AnalogReply will carry it — same as
/// <see cref="GetAxis"/> except joystick Y follows the wire convention
/// (see <see cref="InvertJoystickY"/>).
/// </summary>
public short GetWireAxis(RioAxis axis)
{
short value = GetAxis(axis);
return axis == RioAxis.JoystickY ? WireY(value) : value;
}
private short WireY(short y) => InvertJoystickY
? y
: (short)Math.Min(AnalogCodec.Max, -y); // clamp: -Min (8192) is one past the 14-bit Max
/// <summary>
/// Move an axis. Values are clamped to the 14-bit signed range the wire
/// can carry; the new value is returned by the next AnalogReply.
@@ -239,9 +265,14 @@ public sealed class VRioDevice
switch (packet.Command)
{
case RioCommand.CheckRequest:
Logged?.Invoke("RX CheckRequest → all boards OK");
// Init handshake (verified against a real v4.2 board tap): the
// host waits ≤5s for TestModeChange ENTER before anything else,
// and sends no requests at all until the matching EXIT arrives.
Logged?.Invoke("RX CheckRequest → test mode enter, all boards OK, exit");
Send(PacketBuilder.TestModeChange(1));
foreach ((byte number, string _) in RioAddressSpace.Boards)
Send(PacketBuilder.CheckReply(RioStatusType.BoardOk, number));
Send(PacketBuilder.TestModeChange(0));
break;
case RioCommand.VersionRequest:
@@ -267,7 +298,7 @@ public sealed class VRioDevice
y = _axes[(int)RioAxis.JoystickY];
x = _axes[(int)RioAxis.JoystickX];
}
Send(PacketBuilder.AnalogReply(t, l, r, y, x));
Send(PacketBuilder.AnalogReply(t, l, r, WireY(y), x));
break;
case RioCommand.ResetRequest:
+141 -11
View File
@@ -1,4 +1,6 @@
using System.Diagnostics;
using System.IO.Ports;
using System.Runtime.InteropServices;
using VRio.Core.Protocol;
namespace VRio.Core.Device;
@@ -8,6 +10,20 @@ namespace VRio.Core.Device;
/// 9600 8N1 settings. On a single PC, pair it with RIOJoy through a virtual
/// null-modem (e.g. com0com): vRIO opens one end, RIOJoy the other.
///
/// <para>Outbound bytes are paced at the wire rate: one byte per 10-bit frame
/// time (~1.04 ms at 9600 8N1). A virtual null-modem has no UART, so an
/// unpaced multi-byte write lands at the host back-to-back in microseconds —
/// a burst no real board could produce, and a timing tell that has tripped up
/// hosts tuned to hardware. A writer thread schedules each byte against a
/// monotonic slot deadline (<c>slot = max(prevSlot + period, now)</c>), so
/// the stream averages the true baud rate without bursting after idle.</para>
///
/// <para>A write fault never kills the writer — a real UART streams into an
/// unterminated line rather than blocking. If the virtual wire's far side
/// stops draining (peer end closed, write timeout), the stalled byte and the
/// queued backlog are dropped and transmission resumes with the next fresh
/// packet once the host reads again.</para>
///
/// <para>RIOJoy pulses DTR for 50 ms when it opens its end (the board-reset
/// handshake); through a null modem that arrives here as a DSR blip, which is
/// surfaced via <see cref="HostHandshake"/> so the UI can show that a host
@@ -18,12 +34,22 @@ public sealed class VRioSerialService : IDisposable
/// <summary>RIO link bit rate (must match RIOJoy's transport).</summary>
public const int BaudRate = 9600;
// One byte on the wire is 10 bits (start + 8 data + stop) at 9600 baud.
private static readonly long BytePeriodTicks = Stopwatch.Frequency * 10 / BaudRate;
// Below this remaining wait (~1.8 ms) Thread.Sleep(1) would overshoot the
// slot even at 1 ms timer resolution, so the pacer spins the remainder.
private static readonly long SpinThresholdTicks = Stopwatch.Frequency * 18 / 10_000;
private readonly VRioDevice _device;
private readonly object _writeGate = new();
private readonly object _txGate = new();
private readonly Queue<byte> _txQueue = new();
private SerialPort? _port;
private Thread? _reader;
private Thread? _writer;
private volatile bool _running;
private bool _timerResolutionRaised;
public VRioSerialService(VRioDevice device)
{
@@ -74,10 +100,18 @@ public sealed class VRioSerialService : IDisposable
_port = port;
_running = true;
lock (_txGate) _txQueue.Clear();
// 1 ms system timer resolution while the port is open, so the pacer's
// Thread.Sleep(1) actually sleeps ~1 ms instead of the 15.6 ms default.
_timerResolutionRaised = timeBeginPeriod(1) == 0;
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "vRIO serial reader" };
_reader.Start();
_writer = new Thread(WriteLoop) { IsBackground = true, Name = "vRIO serial writer" };
_writer.Start();
Logged?.Invoke($"Opened {portName} @ {BaudRate} 8N1 — waiting for the host");
Logged?.Invoke($"Opened {portName} @ {BaudRate} 8N1 (TX paced at the wire rate) — waiting for the host");
ConnectionChanged?.Invoke(true);
}
@@ -89,6 +123,7 @@ public sealed class VRioSerialService : IDisposable
return;
_running = false;
lock (_txGate) Monitor.PulseAll(_txGate); // wake the writer so it can exit
_port = null;
port.PinChanged -= OnPinChanged;
try { port.Close(); }
@@ -97,6 +132,15 @@ public sealed class VRioSerialService : IDisposable
_reader?.Join(1000);
_reader = null;
_writer?.Join(1000);
_writer = null;
lock (_txGate) _txQueue.Clear();
if (_timerResolutionRaised)
{
timeEndPeriod(1);
_timerResolutionRaised = false;
}
Logged?.Invoke("Port closed");
ConnectionChanged?.Invoke(false);
@@ -144,23 +188,109 @@ public sealed class VRioSerialService : IDisposable
}
}
// The device's Transmit handler: queue the frame for the paced writer so
// the caller (UI click, reader thread mid-reply) never blocks on the port.
private void Write(byte[] data)
{
SerialPort? port = _port;
if (port is null || !port.IsOpen)
if (!_running)
return; // device poked while offline — drop silently
try
lock (_txGate)
{
lock (_writeGate)
port.Write(data, 0, data.Length);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
{
Logged?.Invoke($"Write failed: {ex.Message}");
foreach (byte b in data)
_txQueue.Enqueue(b);
Monitor.Pulse(_txGate);
}
}
private void WriteLoop()
{
var one = new byte[1];
long slot = Stopwatch.GetTimestamp();
bool txHealthy = true; // log stall/recovery transitions, not every byte
while (_running)
{
lock (_txGate)
{
while (_txQueue.Count == 0)
{
if (!_running)
return;
Monitor.Wait(_txGate, 200); // timed, so a missed pulse can't wedge shutdown
}
one[0] = _txQueue.Dequeue();
}
// This byte's wire slot: one frame after the previous byte, or now
// if the line has been idle (no burst "catch-up" debt).
slot = Math.Max(slot + BytePeriodTicks, Stopwatch.GetTimestamp());
PaceUntil(slot);
SerialPort? port = _port;
if (port is null)
return;
try
{
port.Write(one, 0, 1);
if (!txHealthy)
{
txHealthy = true;
Logged?.Invoke("TX recovered — host is draining the wire again");
}
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
{
if (!_running)
return;
// A real UART cannot wedge: it shifts bits onto the line whether
// or not anyone is listening. A failed write means the virtual
// wire's far side stopped draining (peer end closed), so the
// queued backlog is already stale — drop it and keep serving
// fresh traffic; writes land again once the host reads its end.
int dropped;
lock (_txGate)
{
dropped = _txQueue.Count;
_txQueue.Clear();
}
if (txHealthy)
{
txHealthy = false;
Logged?.Invoke($"TX stalled ({ex.Message.TrimEnd('.')}) — dropped {dropped + 1} stale byte(s), writer stays alive");
}
continue;
}
// If the wait overshot its slot, pace the next byte from the
// actual emission instead: a UART can never put two frames closer
// than the frame time, so a stall must not cause a catch-up burst.
long now = Stopwatch.GetTimestamp();
if (now > slot)
slot = now;
}
}
private static void PaceUntil(long slotTicks)
{
while (true)
{
long remaining = slotTicks - Stopwatch.GetTimestamp();
if (remaining <= 0)
return;
if (remaining > SpinThresholdTicks)
Thread.Sleep(1);
else
Thread.SpinWait(64);
}
}
[DllImport("winmm.dll")]
private static extern uint timeBeginPeriod(uint uMilliseconds);
[DllImport("winmm.dll")]
private static extern uint timeEndPeriod(uint uMilliseconds);
public void Dispose()
{
_device.Transmit -= Write;
+314
View File
@@ -0,0 +1,314 @@
using System.Globalization;
using VRio.Core.Device;
namespace VRio.Core.Input;
/// <summary>
/// The plain-text bindings file: one binding per line, <c>#</c> comments,
/// case-insensitive keywords, whitespace-separated tokens. Grammar:
/// <code>
/// key &lt;name&gt; button &lt;addr&gt; [toggle]
/// key &lt;name&gt; axis &lt;axis&gt; deflect &lt;n&gt; | rate &lt;n&gt;
/// pad &lt;button&gt; button &lt;addr&gt; [toggle]
/// padaxis &lt;src&gt; axis &lt;axis&gt; [invert] [deadzone &lt;d&gt;] [rate &lt;n&gt;]
/// </code>
/// Bad lines are reported (with their line number) and skipped, so one typo
/// doesn't take the whole profile down.
/// </summary>
public static class BindingProfileFormat
{
/// <summary>
/// Parse a bindings file. <paramref name="errors"/> receives one message
/// per rejected line; every well-formed line still becomes a binding.
/// </summary>
public static BindingProfile Parse(string text, out IReadOnlyList<string> errors)
{
var keyButtons = new List<KeyButtonBinding>();
var keyAxes = new List<KeyAxisBinding>();
var padButtons = new List<PadButtonBinding>();
var padAxes = new List<PadAxisBinding>();
var errs = new List<string>();
errors = errs;
string[] lines = text.Split('\n');
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i];
int hash = line.IndexOf('#');
if (hash >= 0) line = line.Substring(0, hash);
string[] tok = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (tok.Length == 0)
continue;
try
{
ParseLine(tok, keyButtons, keyAxes, padButtons, padAxes);
}
catch (FormatException ex)
{
errs.Add($"line {i + 1}: {ex.Message}");
}
}
return new BindingProfile(keyButtons, keyAxes, padButtons, padAxes);
}
private static void ParseLine(string[] tok,
List<KeyButtonBinding> keyButtons, List<KeyAxisBinding> keyAxes,
List<PadButtonBinding> padButtons, List<PadAxisBinding> padAxes)
{
if (tok.Length < 4)
throw new FormatException("expected '<source> <name> <target> <value> …'");
string source = tok[0].ToLowerInvariant();
string target = tok[2].ToLowerInvariant();
switch (source, target)
{
case ("key", "button"):
keyButtons.Add(new KeyButtonBinding(tok[1], ParseAddress(tok[3]), ParseToggle(tok, 4)));
break;
case ("key", "axis"):
{
if (tok.Length < 6)
throw new FormatException("key axis bindings need 'deflect <n>' or 'rate <n>'");
KeyAxisMode mode = tok[4].ToLowerInvariant() switch
{
"deflect" => KeyAxisMode.Deflect,
"rate" => KeyAxisMode.Rate,
var other => throw new FormatException($"unknown key-axis mode '{other}' (deflect/rate)"),
};
keyAxes.Add(new KeyAxisBinding(tok[1], ParseRioAxis(tok[3]), mode, ParseFloat(tok[5])));
break;
}
case ("pad", "button"):
padButtons.Add(new PadButtonBinding(ParsePadButton(tok[1]), ParseAddress(tok[3]), ParseToggle(tok, 4)));
break;
case ("padaxis", "axis"):
{
bool invert = false;
float deadzone = 0f, rate = 0f;
for (int i = 4; i < tok.Length; i++)
{
switch (tok[i].ToLowerInvariant())
{
case "invert":
invert = true;
break;
case "deadzone":
deadzone = ParseFloat(TakeValue(tok, ref i, "deadzone"));
break;
case "rate":
rate = ParseFloat(TakeValue(tok, ref i, "rate"));
break;
default:
throw new FormatException($"unknown padaxis option '{tok[i]}'");
}
}
padAxes.Add(new PadAxisBinding(ParsePadAxis(tok[1]), ParseRioAxis(tok[3]), invert, deadzone, rate));
break;
}
default:
throw new FormatException($"unknown binding form '{tok[0]} … {tok[2]}'");
}
}
private static string TakeValue(string[] tok, ref int i, string option)
{
if (i + 1 >= tok.Length)
throw new FormatException($"'{option}' needs a value");
return tok[++i];
}
private static bool ParseToggle(string[] tok, int index)
{
if (tok.Length <= index)
return false;
if (tok.Length == index + 1 && tok[index].Equals("toggle", StringComparison.OrdinalIgnoreCase))
return true;
throw new FormatException($"unexpected '{tok[index]}' (only 'toggle' may follow the address)");
}
private static int ParseAddress(string s)
{
int addr;
bool ok = s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? int.TryParse(s.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out addr)
: int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out addr);
if (!ok || !(RioAddressSpace.IsButton(addr) || RioAddressSpace.IsKeypad(addr)))
throw new FormatException($"'{s}' is not a RIO input address (0x00-0x47, 0x50-0x6F)");
return addr;
}
private static RioAxis ParseRioAxis(string s) =>
Enum.TryParse(s, ignoreCase: true, out RioAxis axis) && Enum.IsDefined(typeof(RioAxis), axis)
? axis
: throw new FormatException($"unknown RIO axis '{s}' (Throttle/LeftPedal/RightPedal/JoystickY/JoystickX)");
private static PadButtons ParsePadButton(string s) =>
Enum.TryParse(s, ignoreCase: true, out PadButtons b) && b != PadButtons.None && Enum.IsDefined(typeof(PadButtons), b)
? b
: throw new FormatException($"unknown pad button '{s}'");
private static PadAxis ParsePadAxis(string s) =>
Enum.TryParse(s, ignoreCase: true, out PadAxis a) && Enum.IsDefined(typeof(PadAxis), a)
? a
: throw new FormatException($"unknown pad axis '{s}'");
private static float ParseFloat(string s) =>
float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float f)
? f
: throw new FormatException($"'{s}' is not a number");
/// <summary>
/// The default profile text, written verbatim (comments and all) as the
/// user's bindings file on first run.
/// </summary>
public const string DefaultText = @"# vRIO input bindings — keyboard and Xbox (XInput) controller.
#
# One binding per line, '#' starts a comment, keywords are case-
# insensitive. Edit freely, then press 'Reload bindings' in vRIO.
#
# key <name> button <addr> [toggle]
# key <name> axis <axis> deflect <n>
# key <name> axis <axis> rate <n-per-second>
# pad <button> button <addr> [toggle]
# padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n-per-second>]
#
# <addr> RIO input address: lamp buttons 0x00-0x47, internal keypad
# 0x50-0x5F, external keypad 0x60-0x6F (hex or decimal).
# <axis> Throttle | LeftPedal | RightPedal | JoystickY | JoystickX
# <name> .NET Keys name: A-Z, D0-D9 (digit row), F1-F12,
# NumPad0-NumPad9, Up, Down, Left, Right, Space, Enter,
# OemMinus, Oemplus, Oemcomma, OemPeriod, ...
# <button> A B X Y DPadUp DPadDown DPadLeft DPadRight Start Back
# LeftShoulder RightShoulder LeftThumb RightThumb
# <src> LeftStickX LeftStickY RightStickX RightStickY
# LeftTrigger RightTrigger
#
# Axis values are normalized: 1 = the axis' full realistic travel
# (throttle 0..-800 raw, pedals 0..+500, stick +/-80 — the wire signs
# are applied for you). 'deflect' holds the axis there while the key
# is down and springs back on release; 'rate' walks the axis by <n>
# per second and the position sticks (use it for the throttle).
# NOTE: raw stick X runs NEGATIVE to the right (that is what RIOJoy
# expects), so pad LeftStickX is inverted below.
# ---- Axes: controller only ----------------------------------------
# This profile drives all five axes from the Xbox controller - a pad
# is required. Keyboard axis bindings ('key <name> axis ...' with
# deflect or rate, per the grammar above) are still supported if you
# want them back.
# ---- Upper MFD bank: the number row is the top MFD row and the
# ---- QWERTY row is the row under it, left to right across the
# ---- Left / Middle / Right MFDs.
key D1 button 0x2F
key D2 button 0x2E
key D3 button 0x2D
key D4 button 0x2C
key D5 button 0x27
key D6 button 0x26
key D7 button 0x25
key D8 button 0x24
key D9 button 0x37
key D0 button 0x36
key OemMinus button 0x35
key Oemplus button 0x34
key Q button 0x2B
key W button 0x2A
key E button 0x29
key R button 0x28
key T button 0x23
key Y button 0x22
key U button 0x21
key I button 0x20
key O button 0x33
key P button 0x32
key OemOpenBrackets button 0x31
key OemCloseBrackets button 0x30
# ---- Lower MFD bank: home row and the row below it, two 4-key
# ---- blocks split by an unbound gap key (G / B), mirroring the
# ---- keypad gap between the Lower Left and Lower Right MFDs.
key A button 0x0F
key S button 0x0E
key D button 0x0D
key F button 0x0C
key H button 0x07
key J button 0x06
key K button 0x05
key L button 0x04
key Z button 0x0B
key X button 0x0A
key C button 0x09
key V button 0x08
key N button 0x03
key M button 0x02
key Oemcomma button 0x01
key OemPeriod button 0x00
# ---- Secondary / Screen columns on the function keys, top to
# ---- bottom (0x16/0x17 and 0x1E/0x1F intentionally unmapped).
key F1 button 0x10
key F2 button 0x11
key F3 button 0x12
key F4 button 0x13
key F5 button 0x14
key F6 button 0x15
key F7 button 0x18
key F8 button 0x19
key F9 button 0x1A
key F10 button 0x1B
key F11 button 0x1C
key F12 button 0x1D
# ---- Joystick column: hat on the arrows, Main on Space. Pinky /
# ---- Middle / Upper / Panic are pad-only (X / B / Y / LeftShoulder).
key Space button 0x40 # Main
key Up button 0x42 # Hat Up
key Down button 0x41 # Hat Back
key Left button 0x44 # Hat Left
key Right button 0x43 # Hat Right
# ---- Internal keypad on the numpad (hex keys on the operators) ----
key NumPad0 button 0x50
key NumPad1 button 0x51
key NumPad2 button 0x52
key NumPad3 button 0x53
key NumPad4 button 0x54
key NumPad5 button 0x55
key NumPad6 button 0x56
key NumPad7 button 0x57
key NumPad8 button 0x58
key NumPad9 button 0x59
key Divide button 0x5A # keypad A (/)
key Multiply button 0x5B # keypad B (*)
key Subtract button 0x5C # keypad C (-)
key Add button 0x5D # keypad D (+)
key Decimal button 0x5E # keypad E (.)
key Return button 0x5F # keypad F (Enter - the main Enter key too)
# ---- Xbox controller: axes ----------------------------------------
padaxis LeftStickX axis JoystickX invert deadzone 0.2
padaxis LeftStickY axis JoystickY deadzone 0.2
padaxis RightStickY axis Throttle deadzone 0.2 rate 0.75
padaxis LeftTrigger axis LeftPedal deadzone 0.12
padaxis RightTrigger axis RightPedal deadzone 0.12
# ---- Xbox controller: buttons -------------------------------------
pad A button 0x40 # Main
pad B button 0x46 # Middle
pad X button 0x45 # Pinky
pad Y button 0x47 # Upper
pad DPadUp button 0x42 # Hat Up
pad DPadDown button 0x41 # Hat Back
pad DPadLeft button 0x44 # Hat Left
pad DPadRight button 0x43 # Hat Right
pad LeftShoulder button 0x3D # Panic
pad RightShoulder button 0x3F # Throttle button
";
}
+69
View File
@@ -0,0 +1,69 @@
using VRio.Core.Device;
namespace VRio.Core.Input;
/// <summary>
/// How a key drives an axis: <see cref="Deflect"/> holds the axis at the
/// binding's value while the key is down and springs back when released
/// (stick, pedals); <see cref="Rate"/> walks the axis by value-per-second
/// while held and the position sticks (throttle).
/// </summary>
public enum KeyAxisMode
{
Deflect,
Rate,
}
/// <summary>A keyboard key pressing a RIO input address.</summary>
/// <param name="Key">Key name as the host reports it (System.Windows.Forms
/// Keys.ToString(): "W", "Up", "NumPad1", …); matched case-insensitively.</param>
/// <param name="Address">RIO input address (button 0x000x47 or keypad 0x500x6F).</param>
/// <param name="Toggle">Latch on press instead of momentary press/release.</param>
public sealed record KeyButtonBinding(string Key, int Address, bool Toggle);
/// <summary>
/// A keyboard key driving a RIO axis in normalized units, where 1 is the
/// axis' full realistic travel (<see cref="RioAxisRange.Full"/> carries the
/// wire sign, so bindings never deal with the throttle's negative direction).
/// </summary>
/// <param name="Value">Deflect: the normalized position held while the key is
/// down. Rate: normalized units per second, signed.</param>
public sealed record KeyAxisBinding(string Key, RioAxis Axis, KeyAxisMode Mode, float Value);
/// <summary>A gamepad button pressing a RIO input address.</summary>
public sealed record PadButtonBinding(PadButtons Button, int Address, bool Toggle);
/// <summary>
/// A gamepad analog control driving a RIO axis. With <paramref name="Rate"/>
/// zero the (deadzoned, optionally inverted) pad value maps directly to the
/// normalized axis position; with a positive rate the pad value is a speed —
/// the axis integrates by value × rate per second and holds (throttle-style).
/// </summary>
public sealed record PadAxisBinding(PadAxis Source, RioAxis Axis, bool Invert, float Deadzone, float Rate);
/// <summary>An immutable set of input bindings (one parsed profile file).</summary>
public sealed class BindingProfile
{
public static readonly BindingProfile Empty = new(
Array.Empty<KeyButtonBinding>(), Array.Empty<KeyAxisBinding>(),
Array.Empty<PadButtonBinding>(), Array.Empty<PadAxisBinding>());
public BindingProfile(
IReadOnlyList<KeyButtonBinding> keyButtons,
IReadOnlyList<KeyAxisBinding> keyAxes,
IReadOnlyList<PadButtonBinding> padButtons,
IReadOnlyList<PadAxisBinding> padAxes)
{
KeyButtons = keyButtons;
KeyAxes = keyAxes;
PadButtons = padButtons;
PadAxes = padAxes;
}
public IReadOnlyList<KeyButtonBinding> KeyButtons { get; }
public IReadOnlyList<KeyAxisBinding> KeyAxes { get; }
public IReadOnlyList<PadButtonBinding> PadButtons { get; }
public IReadOnlyList<PadAxisBinding> PadAxes { get; }
public int Count => KeyButtons.Count + KeyAxes.Count + PadButtons.Count + PadAxes.Count;
}
+316
View File
@@ -0,0 +1,316 @@
using VRio.Core.Device;
namespace VRio.Core.Input;
/// <summary>
/// Routes keyboard and gamepad input into a <see cref="VRioDevice"/> through
/// a <see cref="BindingProfile"/>. The host feeds key edges
/// (<see cref="KeyDown"/>/<see cref="KeyUp"/> — auto-repeat is suppressed
/// here), gamepad snapshots (<see cref="SetPadState"/>), and a steady
/// <see cref="Tick"/> for the time-based axis modes.
///
/// <para>Axes are composed in normalized units (1 = full realistic travel,
/// <see cref="RioAxisRange.Full"/> supplies the wire sign): rate integrators
/// (throttle-style, position holds) + deflect keys currently held + direct
/// pad positions, clamped to the axis' travel window. The device is only
/// written when the composed value changes, so mouse drags on the panel keep
/// working while a source is idle.</para>
///
/// <para>Buttons keep a hold count per RIO address, so a key and a pad button
/// bound to the same control overlap cleanly: the press goes on the wire at
/// 0→1 and the release at 1→0. <see cref="AddressHeldChanged"/> mirrors those
/// edges for the UI. Not thread-safe — call from one thread (the UI's).</para>
/// </summary>
public sealed class InputRouter
{
private static readonly RioAxis[] Axes = (RioAxis[])Enum.GetValues(typeof(RioAxis));
private readonly VRioDevice _device;
private BindingProfile _profile = BindingProfile.Empty;
private readonly Dictionary<string, List<KeyButtonBinding>> _keyButtons = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, List<KeyAxisBinding>> _keyAxes = new(StringComparer.OrdinalIgnoreCase);
private readonly List<PadAxisBinding>[] _padAxesByAxis;
private readonly List<KeyAxisBinding>[] _deflectByAxis;
private readonly HashSet<string> _heldKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<int, int> _holdCounts = new();
private readonly HashSet<int> _toggled = new();
private readonly float[] _rate = new float[Axes.Length]; // normalized integrators
private readonly short?[] _lastSent = new short?[Axes.Length];
private PadState _pad;
private PadButtons _prevPadButtons;
public InputRouter(VRioDevice device)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_padAxesByAxis = new List<PadAxisBinding>[Axes.Length];
_deflectByAxis = new List<KeyAxisBinding>[Axes.Length];
for (int i = 0; i < Axes.Length; i++)
{
_padAxesByAxis[i] = new List<PadAxisBinding>();
_deflectByAxis[i] = new List<KeyAxisBinding>();
}
}
/// <summary>A routed address went down (true) or came back up (false) — for panel highlighting.</summary>
public event Action<int, bool>? AddressHeldChanged;
/// <summary>
/// The active bindings. Assigning releases everything currently held
/// (keys, toggles, pad buttons) so a reload never strands a pressed input.
/// </summary>
public BindingProfile Profile
{
get => _profile;
set
{
ReleaseEverything();
_profile = value ?? BindingProfile.Empty;
_keyButtons.Clear();
_keyAxes.Clear();
foreach (KeyButtonBinding b in _profile.KeyButtons)
Bucket(_keyButtons, b.Key).Add(b);
foreach (KeyAxisBinding b in _profile.KeyAxes)
Bucket(_keyAxes, b.Key).Add(b);
for (int i = 0; i < Axes.Length; i++)
{
_padAxesByAxis[i].Clear();
_deflectByAxis[i].Clear();
}
foreach (PadAxisBinding b in _profile.PadAxes)
_padAxesByAxis[(int)b.Axis].Add(b);
foreach (KeyAxisBinding b in _profile.KeyAxes)
if (b.Mode == KeyAxisMode.Deflect)
_deflectByAxis[(int)b.Axis].Add(b);
ResetAxisState();
}
}
private static List<T> Bucket<T>(Dictionary<string, List<T>> map, string key)
{
if (!map.TryGetValue(key, out List<T>? list))
map[key] = list = new List<T>();
return list;
}
/// <summary>True if any binding uses this key (the host swallows only bound keys).</summary>
public bool HasKeyBinding(string key) => _keyButtons.ContainsKey(key) || _keyAxes.ContainsKey(key);
// ---- Keyboard -----------------------------------------------------------
/// <summary>A key went down. Repeats while held are ignored.</summary>
public void KeyDown(string key)
{
if (!_heldKeys.Add(key))
return;
if (!_keyButtons.TryGetValue(key, out List<KeyButtonBinding>? bindings))
return;
foreach (KeyButtonBinding b in bindings)
{
if (b.Toggle)
ToggleAddress(b.Address);
else
IncHold(b.Address);
}
}
/// <summary>A key came back up.</summary>
public void KeyUp(string key)
{
if (!_heldKeys.Remove(key))
return;
if (!_keyButtons.TryGetValue(key, out List<KeyButtonBinding>? bindings))
return;
foreach (KeyButtonBinding b in bindings)
{
if (!b.Toggle)
DecHold(b.Address);
}
}
/// <summary>
/// Release every held key (focus loss, keyboard input turned off).
/// Toggle latches survive, like the panel's right-click latches.
/// </summary>
public void ReleaseAllKeys()
{
foreach (string key in _heldKeys.ToArray())
KeyUp(key);
}
// ---- Gamepad ------------------------------------------------------------
/// <summary>
/// Feed the latest gamepad snapshot; button edges fire immediately, axes
/// are picked up by the next <see cref="Tick"/>. Feed <c>default</c> when
/// the pad disconnects or is disabled so everything it held releases.
/// </summary>
public void SetPadState(PadState state)
{
PadButtons changed = state.Buttons ^ _prevPadButtons;
if (changed != PadButtons.None)
{
foreach (PadButtonBinding b in _profile.PadButtons)
{
if ((changed & b.Button) == 0)
continue;
bool down = (state.Buttons & b.Button) != 0;
if (b.Toggle)
{
if (down)
ToggleAddress(b.Address);
}
else if (down)
{
IncHold(b.Address);
}
else
{
DecHold(b.Address);
}
}
}
_prevPadButtons = state.Buttons;
_pad = state;
}
// ---- Time base ----------------------------------------------------------
/// <summary>
/// Advance rate integrators by <paramref name="dtSeconds"/> and push the
/// composed axis values to the device (only where they changed).
/// </summary>
public void Tick(double dtSeconds)
{
// Pass 1: advance the rate integrators (held rate keys, rate-mode pad axes).
foreach (KeyAxisBinding b in _profile.KeyAxes)
{
if (b.Mode == KeyAxisMode.Rate && _heldKeys.Contains(b.Key))
AddRate(b.Axis, (float)(b.Value * dtSeconds));
}
foreach (PadAxisBinding b in _profile.PadAxes)
{
if (b.Rate > 0f)
AddRate(b.Axis, (float)(Shape(_pad.Axis(b.Source), b) * b.Rate * dtSeconds));
}
// Pass 2: compose each axis and write it if it moved.
for (int i = 0; i < Axes.Length; i++)
{
RioAxis axis = Axes[i];
float total = _rate[i];
foreach (KeyAxisBinding b in _deflectByAxis[i])
{
if (_heldKeys.Contains(b.Key))
total += b.Value;
}
foreach (PadAxisBinding b in _padAxesByAxis[i])
{
if (b.Rate <= 0f)
total += Shape(_pad.Axis(b.Source), b);
}
total = ClampNorm(axis, total);
short raw = (short)Math.Round(total * RioAxisRange.Full(axis));
short prev = _lastSent[i] ?? _device.GetAxis(axis);
if (raw != prev)
_device.SetAxis(axis, raw);
_lastSent[i] = raw;
}
}
/// <summary>
/// Forget integrated/last-sent axis state — call after the axes were
/// re-zeroed behind the router's back (center button, host ResetRequest)
/// so a rate-driven throttle doesn't resume from its old position.
/// </summary>
public void ResetAxisState()
{
Array.Clear(_rate, 0, _rate.Length);
for (int i = 0; i < _lastSent.Length; i++)
_lastSent[i] = null;
}
// ---- Internals ----------------------------------------------------------
private void AddRate(RioAxis axis, float delta) =>
_rate[(int)axis] = ClampNorm(axis, _rate[(int)axis] + delta);
/// <summary>Deadzone (rescaled so travel stays continuous) + inversion.</summary>
private static float Shape(float value, PadAxisBinding b)
{
float v = value;
if (b.Deadzone > 0f)
{
float mag = Math.Abs(v);
v = mag <= b.Deadzone ? 0f : Math.Sign(v) * (mag - b.Deadzone) / (1f - b.Deadzone);
}
return b.Invert ? -v : v;
}
/// <summary>Stick axes are bipolar (±1), throttle/pedals unipolar (0..1 of travel).</summary>
private static float ClampNorm(RioAxis axis, float value)
{
float min = axis is RioAxis.JoystickX or RioAxis.JoystickY ? -1f : 0f;
return Math.Max(min, Math.Min(1f, value));
}
private void ToggleAddress(int address)
{
if (_toggled.Remove(address))
DecHold(address);
else
{
_toggled.Add(address);
IncHold(address);
}
}
private void IncHold(int address)
{
_holdCounts.TryGetValue(address, out int count);
_holdCounts[address] = count + 1;
if (count == 0)
{
_device.PressAddress(address);
AddressHeldChanged?.Invoke(address, true);
}
}
private void DecHold(int address)
{
if (!_holdCounts.TryGetValue(address, out int count))
return;
if (count <= 1)
{
_holdCounts.Remove(address);
_device.ReleaseAddress(address);
AddressHeldChanged?.Invoke(address, false);
}
else
{
_holdCounts[address] = count - 1;
}
}
private void ReleaseEverything()
{
_heldKeys.Clear();
_toggled.Clear();
_prevPadButtons = PadButtons.None;
_pad = default;
foreach (int address in _holdCounts.Keys.ToArray())
{
_holdCounts.Remove(address);
_device.ReleaseAddress(address);
AddressHeldChanged?.Invoke(address, false);
}
}
}
+76
View File
@@ -0,0 +1,76 @@
namespace VRio.Core.Input;
/// <summary>
/// Gamepad button flags, bit-for-bit the XInput <c>wButtons</c> mask so the
/// App-side poller can cast the native value straight through.
/// </summary>
[Flags]
public enum PadButtons : ushort
{
None = 0,
DPadUp = 0x0001,
DPadDown = 0x0002,
DPadLeft = 0x0004,
DPadRight = 0x0008,
Start = 0x0010,
Back = 0x0020,
LeftThumb = 0x0040,
RightThumb = 0x0080,
LeftShoulder = 0x0100,
RightShoulder = 0x0200,
A = 0x1000,
B = 0x2000,
X = 0x4000,
Y = 0x8000,
}
/// <summary>A gamepad analog control usable as a binding source.</summary>
public enum PadAxis
{
LeftStickX,
LeftStickY,
RightStickX,
RightStickY,
LeftTrigger,
RightTrigger,
}
/// <summary>
/// One normalized gamepad snapshot: sticks 1..+1 (up/right positive),
/// triggers 0..1. The default value is "pad at rest / no pad connected".
/// </summary>
public readonly struct PadState
{
public PadState(PadButtons buttons,
float leftStickX, float leftStickY, float rightStickX, float rightStickY,
float leftTrigger, float rightTrigger)
{
Buttons = buttons;
LeftStickX = leftStickX;
LeftStickY = leftStickY;
RightStickX = rightStickX;
RightStickY = rightStickY;
LeftTrigger = leftTrigger;
RightTrigger = rightTrigger;
}
public PadButtons Buttons { get; }
public float LeftStickX { get; }
public float LeftStickY { get; }
public float RightStickX { get; }
public float RightStickY { get; }
public float LeftTrigger { get; }
public float RightTrigger { get; }
/// <summary>Value of one analog control.</summary>
public float Axis(PadAxis axis) => axis switch
{
PadAxis.LeftStickX => LeftStickX,
PadAxis.LeftStickY => LeftStickY,
PadAxis.RightStickX => RightStickX,
PadAxis.RightStickY => RightStickY,
PadAxis.LeftTrigger => LeftTrigger,
PadAxis.RightTrigger => RightTrigger,
_ => 0f,
};
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\VPlasma.Core\VPlasma.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,307 @@
using VPlasma.Core.Device;
using Xunit;
namespace VPlasma.Core.Tests;
public class VPlasmaDeviceTests
{
private const byte Esc = 0x1B;
private static void Feed(VPlasmaDevice device, params byte[] bytes)
=> device.OnReceived(bytes, bytes.Length);
private static void Feed(VPlasmaDevice device, IEnumerable<byte> bytes)
{
byte[] arr = bytes.ToArray();
device.OnReceived(arr, arr.Length);
}
private static byte Pixel(VPlasmaDevice device, int x, int y)
{
var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
device.CopyFrame(frame);
return frame[y * VPlasmaDevice.Width + x];
}
/// <summary>A full-width ESC P row the way L4PLASMA.CPP sends one.</summary>
private static byte[] GraphicsRow(int y, params byte[] data)
{
var row = new List<byte> { Esc, (byte)'P', 0, (byte)y, 0, (byte)data.Length, 1 };
row.AddRange(data);
return row.ToArray();
}
// ---- graphics writes -------------------------------------------------
[Fact]
public void GraphicsRow_SetsPixelsMsbFirst()
{
var device = new VPlasmaDevice();
Feed(device, GraphicsRow(5, 0x80, 0x01)); // xbyte 0..1
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 5)); // MSB of byte 0
Assert.Equal(0, Pixel(device, 1, 5));
Assert.Equal(0, Pixel(device, 14, 5));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 15, 5)); // LSB of byte 1
Assert.Equal(0, Pixel(device, 0, 4));
Assert.Equal(0, Pixel(device, 0, 6));
Assert.Equal(1, device.GraphicsRows);
}
[Fact]
public void GraphicsRow_SurvivesAnyChunkBoundary()
{
byte[] wire = GraphicsRow(3, Enumerable.Repeat((byte)0xFF, 16).ToArray());
// Split the same command at every possible boundary.
for (int split = 1; split < wire.Length; ++split)
{
var device = new VPlasmaDevice();
device.OnReceived(wire, split);
byte[] rest = wire.Skip(split).ToArray();
device.OnReceived(rest, rest.Length);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 3));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 127, 3));
Assert.Equal(1, device.GraphicsRows);
}
}
[Fact]
public void GraphicsBlock_MultipleRowsAdvanceY()
{
var device = new VPlasmaDevice();
// screen 0, y=10, xbyte=0, 1 byte/row, 3 rows.
Feed(device, Esc, (byte)'P', 0, 10, 0, 1, 3, 0x80, 0x80, 0x80);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 10));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 11));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 12));
Assert.Equal(0, Pixel(device, 0, 13));
Assert.Equal(3, device.GraphicsRows);
}
[Fact]
public void GraphicsWrite_HonorsByteColumnOffset()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'P', 0, 0, 2, 1, 1, 0xFF); // xbyte=2 → x 16..23
Assert.Equal(0, Pixel(device, 15, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 16, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 23, 0));
Assert.Equal(0, Pixel(device, 24, 0));
}
[Fact]
public void GraphicsWrite_OutOfRangeRowIsConsumedNotDrawn()
{
var device = new VPlasmaDevice();
Feed(device, GraphicsRow(40, Enumerable.Repeat((byte)0xFF, 16).ToArray()));
Feed(device, (byte)'!'); // parser must be back in text mode
var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
device.CopyFrame(frame);
Assert.Equal(1, device.TextCharsDrawn);
Assert.Equal(0, device.GraphicsRows);
}
[Fact]
public void GraphicsWrite_OverwritesTextAttributes()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'H', 4); // flashing text
Feed(device, (byte)'H'); // glyph col 0 is a full bar at x=0
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelFlash, Pixel(device, 0, 0));
Feed(device, GraphicsRow(0, 0x80)); // repaint that row from the wire
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
}
// ---- text mode ---------------------------------------------------------
[Fact]
public void Text_DrawsGlyphAndAdvancesCursor()
{
var device = new VPlasmaDevice();
Feed(device, (byte)'H'); // 5×7 'H': column 0 = 0x7F (all seven rows)
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 6));
Assert.Equal(0, Pixel(device, 0, 7)); // gap row
Assert.Equal(0, Pixel(device, 5, 0)); // gap column
Assert.Equal(new VPlasmaDevice.Point(1, 0), device.CursorCell);
Assert.Equal(1, device.TextCharsDrawn);
}
[Fact]
public void ControlChars_MoveTheCursor()
{
var device = new VPlasmaDevice();
Feed(device, 0x09, 0x09, 0x09); // HT ×3
Assert.Equal(new VPlasmaDevice.Point(3, 0), device.CursorCell);
Feed(device, 0x08); // BS
Assert.Equal(new VPlasmaDevice.Point(2, 0), device.CursorCell);
Feed(device, 0x0A); // LF
Assert.Equal(new VPlasmaDevice.Point(2, 1), device.CursorCell);
Feed(device, 0x0D); // CR
Assert.Equal(new VPlasmaDevice.Point(0, 1), device.CursorCell);
Feed(device, 0x0B); // VT
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
Feed(device, 0x0B); // VT off the top wraps to the bottom row
Assert.Equal(new VPlasmaDevice.Point(0, 3), device.CursorCell);
}
[Fact]
public void Text_WrapsAtRowAndScreenEnd()
{
var device = new VPlasmaDevice();
Feed(device, Enumerable.Repeat((byte)'X', 22)); // one full 21-cell row + 1
Assert.Equal(new VPlasmaDevice.Point(1, 1), device.CursorCell);
Feed(device, Enumerable.Repeat((byte)'X', 21 * 3 - 1)); // exactly to the end
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell); // wrapped to top
}
[Fact]
public void EscAt_ClearsScreenAndResetsTextState()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'K', 4, Esc, (byte)'H', 1, (byte)'H');
Feed(device, Esc, (byte)'@');
Assert.Equal(0, Pixel(device, 0, 0));
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
Assert.Equal(0, device.Font);
Assert.Equal(PlasmaAttributes.None, device.Attributes);
}
[Fact]
public void EscL_HomesCursorWithoutClearing()
{
var device = new VPlasmaDevice();
Feed(device, (byte)'H', Esc, (byte)'L');
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0)); // glyph survives
}
[Theory]
[InlineData(0x00, PlasmaCursorMode.Hidden)] // the game's cursor-off
[InlineData(0xFF, PlasmaCursorMode.Hidden)] // the test tool's hide
[InlineData(0x01, PlasmaCursorMode.Steady)]
[InlineData(0x03, PlasmaCursorMode.Flashing)]
public void EscG_SetsCursorMode(byte operand, PlasmaCursorMode expected)
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'G', operand);
Assert.Equal(expected, device.CursorMode);
}
[Fact]
public void EscK_SelectsFontGrids()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'K', 4); // large: 12×16 cells
Assert.Equal(4, device.Font);
Assert.Equal(12, device.CellWidth);
Assert.Equal(16, device.CellHeight);
Feed(device, (byte)'A'); // large glyph: 'A' col 1 (0x11) row 0 → 2×2 dots at (2..3, 0..1)
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 2, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 3, 1));
Feed(device, Esc, (byte)'K', 0xFF); // FF → default font 0
Assert.Equal(0, device.Font);
Assert.Equal(6, device.CellWidth);
}
[Fact]
public void EscH_AppliesAttributes()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'H', 1, (byte)'H'); // half intensity
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelHalf, Pixel(device, 0, 0));
Feed(device, Esc, (byte)'H', 2, (byte)'H'); // underline: gap row lit
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 6, 7));
Feed(device, Esc, (byte)'H', 3, (byte)' '); // reverse: a space renders solid
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 12, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 17, 7));
Feed(device, Esc, (byte)'H', 0xFF, (byte)'H'); // defaults restored
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 18, 0));
Assert.Equal(0, Pixel(device, 18, 7));
Assert.Equal(PlasmaAttributes.None, device.Attributes);
}
// ---- robustness ----------------------------------------------------------
[Fact]
public void UnknownEscape_IsConsumedAndTextResumes()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'Z', (byte)'H');
Assert.Equal(1, device.TextCharsDrawn);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
}
[Fact]
public void GameStartupSequence_HidesCursor()
{
// L4PLASMA.CPP's constructor sends exactly ESC 'G' 0x00.
var device = new VPlasmaDevice();
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode); // power-on default
Feed(device, 27, (byte)'G', 0x00);
Assert.Equal(PlasmaCursorMode.Hidden, device.CursorMode);
}
[Fact]
public void SelfTestPages_ParseWithoutUnknownCommands()
{
for (int page = 0; page < PlasmaSelfTest.PageCount; ++page)
{
var device = new VPlasmaDevice();
var complaints = new List<string>();
device.Logged += line =>
{
if (line.StartsWith("Unknown", StringComparison.Ordinal)
|| line.StartsWith("Unhandled", StringComparison.Ordinal))
complaints.Add(line);
};
byte[] bytes = PlasmaSelfTest.BuildPage(page);
device.OnReceived(bytes, bytes.Length);
Assert.Empty(complaints);
Assert.True(device.BytesReceived > 0);
}
}
[Fact]
public void Reset_RestoresPowerOnState()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'K', 5, Esc, (byte)'H', 4, Esc, (byte)'G', 0, (byte)'H');
device.Reset();
Assert.Equal(0, Pixel(device, 0, 0));
Assert.Equal(0, device.Font);
Assert.Equal(PlasmaAttributes.None, device.Attributes);
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode);
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
}
}
+296
View File
@@ -0,0 +1,296 @@
using VRio.Core.Device;
using VRio.Core.Input;
using VRio.Core.Protocol;
using Xunit;
namespace VRio.Core.Tests;
public class BindingProfileFormatTests
{
[Fact]
public void Default_profile_parses_clean()
{
BindingProfile profile = BindingProfileFormat.Parse(BindingProfileFormat.DefaultText, out var errors);
Assert.Empty(errors);
Assert.Equal(73, profile.KeyButtons.Count); // 40 MFD + 12 columns + 16 keypad + Space + 4 hat
Assert.Empty(profile.KeyAxes); // axes are pad-only in the default profile
Assert.Equal(10, profile.PadButtons.Count);
Assert.Equal(5, profile.PadAxes.Count);
}
[Fact]
public void Lines_parse_into_typed_bindings()
{
BindingProfile profile = BindingProfileFormat.Parse(
"key SPACE button 0x40 toggle # comment\n" +
"key w axis throttle rate -0.5\n" +
"pad dpadup button 81\n" +
"padaxis lefttrigger axis LeftPedal invert deadzone 0.25 rate 2\n",
out var errors);
Assert.Empty(errors);
KeyButtonBinding kb = Assert.Single(profile.KeyButtons);
Assert.Equal(("SPACE", 0x40, true), (kb.Key, kb.Address, kb.Toggle));
KeyAxisBinding ka = Assert.Single(profile.KeyAxes);
Assert.Equal((RioAxis.Throttle, KeyAxisMode.Rate, -0.5f), (ka.Axis, ka.Mode, ka.Value));
PadButtonBinding pb = Assert.Single(profile.PadButtons);
Assert.Equal((PadButtons.DPadUp, 81), (pb.Button, pb.Address)); // decimal keypad address
PadAxisBinding pa = Assert.Single(profile.PadAxes);
Assert.Equal((PadAxis.LeftTrigger, RioAxis.LeftPedal, true, 0.25f, 2f),
(pa.Source, pa.Axis, pa.Invert, pa.Deadzone, pa.Rate));
}
[Theory]
[InlineData("key W button 0x48")] // hole between buttons and keypads
[InlineData("key W button zz")]
[InlineData("key W axis Throttle deflect")] // missing value
[InlineData("key W axis Throttle wiggle 1")] // unknown mode
[InlineData("pad Guide button 0x00")] // unknown pad button
[InlineData("padaxis LeftStickX axis JoystickX sideways")]
[InlineData("mouse X button 0x00")] // unknown source
public void Bad_lines_are_reported_and_skipped(string line)
{
BindingProfile profile = BindingProfileFormat.Parse("key A button 0x00\n" + line, out var errors);
string error = Assert.Single(errors);
Assert.StartsWith("line 2:", error);
Assert.Equal(1, profile.Count); // the good line survived
}
}
public class InputRouterTests
{
private readonly VRioDevice _device = new();
private readonly InputRouter _router;
private readonly List<byte[]> _tx = new();
public InputRouterTests()
{
_router = new InputRouter(_device);
_device.Transmit += _tx.Add;
}
private void UseProfile(string text)
{
_router.Profile = BindingProfileFormat.Parse(text, out var errors);
Assert.Empty(errors);
_tx.Clear();
}
// ---- Buttons ------------------------------------------------------------
[Fact]
public void Key_press_and_release_hit_the_wire_once_despite_repeats()
{
UseProfile("key Space button 0x40");
_router.KeyDown("Space");
_router.KeyDown("Space"); // auto-repeat
_router.KeyDown("Space");
_router.KeyUp("Space");
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x40), PacketBuilder.ButtonReleased(0x40) }, _tx);
}
[Fact]
public void Keypad_addresses_go_out_as_key_events()
{
UseProfile("key NumPad7 button 0x67"); // external keypad, key 7
_router.KeyDown("NumPad7");
_router.KeyUp("NumPad7");
Assert.Equal(new[] { PacketBuilder.KeyPressed(1, 7), PacketBuilder.KeyReleased(1, 7) }, _tx);
}
[Fact]
public void Toggle_latches_across_key_up()
{
UseProfile("key T button 0x12 toggle");
_router.KeyDown("T");
_router.KeyUp("T");
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x12) }, _tx);
_router.KeyDown("T"); // second press releases the latch
_router.KeyUp("T");
Assert.Equal(PacketBuilder.ButtonReleased(0x12), _tx[1]);
}
[Fact]
public void Overlapping_sources_on_one_address_press_once_release_last()
{
UseProfile("key Space button 0x40\npad A button 0x40");
var held = new List<(int Address, bool Held)>();
_router.AddressHeldChanged += (a, h) => held.Add((a, h));
_router.KeyDown("Space");
_router.SetPadState(new PadState(PadButtons.A, 0, 0, 0, 0, 0, 0));
_router.KeyUp("Space");
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x40) }, _tx); // still held by the pad
_router.SetPadState(default);
Assert.Equal(PacketBuilder.ButtonReleased(0x40), _tx[1]);
Assert.Equal(new[] { (0x40, true), (0x40, false) }, held);
}
[Fact]
public void Pad_button_edges_only_fire_on_change()
{
UseProfile("pad B button 0x3D");
var down = new PadState(PadButtons.B, 0, 0, 0, 0, 0, 0);
_router.SetPadState(down);
_router.SetPadState(down); // unchanged snapshot
_router.SetPadState(default);
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x3D), PacketBuilder.ButtonReleased(0x3D) }, _tx);
}
[Fact]
public void ReleaseAllKeys_releases_momentaries_but_keeps_toggles()
{
UseProfile("key A button 0x01\nkey B button 0x02 toggle");
_router.KeyDown("A");
_router.KeyDown("B");
_router.ReleaseAllKeys();
Assert.Equal(0, _device.GetLamp(0)); // sanity: device untouched otherwise
Assert.Equal(new[]
{
PacketBuilder.ButtonPressed(0x01),
PacketBuilder.ButtonPressed(0x02),
PacketBuilder.ButtonReleased(0x01), // toggle at 0x02 stays latched
}, _tx);
}
// ---- Axes ---------------------------------------------------------------
[Fact]
public void Deflect_key_holds_full_travel_and_springs_back()
{
UseProfile("key Up axis JoystickY deflect 1\nkey Down axis JoystickY deflect -1");
_router.KeyDown("Up");
_router.Tick(0.016);
Assert.Equal(RioAxisRange.JoystickExtent, _device.GetAxis(RioAxis.JoystickY));
_router.KeyDown("Down"); // opposite deflects cancel
_router.Tick(0.016);
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickY));
_router.KeyUp("Up");
_router.KeyUp("Down");
_router.Tick(0.016);
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickY));
}
[Fact]
public void Rate_key_walks_the_throttle_and_position_sticks()
{
UseProfile("key W axis Throttle rate 0.5");
_router.KeyDown("W");
_router.Tick(1.0); // half travel
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
_router.KeyUp("W");
_router.Tick(1.0); // released: stays put
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
_router.KeyDown("W");
_router.Tick(10.0); // clamps at full realistic travel
Assert.Equal(RioAxisRange.ThrottleFull, _device.GetAxis(RioAxis.Throttle));
}
[Fact]
public void Pad_stick_maps_through_deadzone_and_invert()
{
UseProfile("padaxis LeftStickX axis JoystickX invert deadzone 0.2");
// Inside the deadzone: centered.
_router.SetPadState(new PadState(PadButtons.None, 0.1f, 0, 0, 0, 0, 0));
_router.Tick(0.016);
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickX));
// Full right, inverted → raw negative full extent (the wire direction).
_router.SetPadState(new PadState(PadButtons.None, 1f, 0, 0, 0, 0, 0));
_router.Tick(0.016);
Assert.Equal(-RioAxisRange.JoystickExtent, _device.GetAxis(RioAxis.JoystickX));
// Pad gone → springs back.
_router.SetPadState(default);
_router.Tick(0.016);
Assert.Equal(0, _device.GetAxis(RioAxis.JoystickX));
}
[Fact]
public void Pad_trigger_drives_pedal_to_full_press()
{
UseProfile("padaxis RightTrigger axis RightPedal");
_router.SetPadState(new PadState(PadButtons.None, 0, 0, 0, 0, 0, 1f));
_router.Tick(0.016);
Assert.Equal(RioAxisRange.PedalFull, _device.GetAxis(RioAxis.RightPedal));
}
[Fact]
public void Rate_mode_pad_axis_integrates_and_holds()
{
UseProfile("padaxis RightStickY axis Throttle rate 0.5");
_router.SetPadState(new PadState(PadButtons.None, 0, 0, 0, 1f, 0, 0));
_router.Tick(1.0);
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
_router.SetPadState(default); // stick released: throttle stays
_router.Tick(1.0);
Assert.Equal(RioAxisRange.ThrottleFull / 2, _device.GetAxis(RioAxis.Throttle));
}
[Fact]
public void ResetAxisState_forgets_integrated_position()
{
UseProfile("key W axis Throttle rate 1");
_router.KeyDown("W");
_router.Tick(0.5);
_router.KeyUp("W");
Assert.NotEqual(0, _device.GetAxis(RioAxis.Throttle));
// Emulate the center button / host reset combination.
_device.SetAxis(RioAxis.Throttle, 0);
_router.ResetAxisState();
_router.Tick(0.016);
Assert.Equal(0, _device.GetAxis(RioAxis.Throttle));
}
[Fact]
public void Idle_router_does_not_fight_other_axis_writers()
{
UseProfile("padaxis LeftStickX axis JoystickX");
_router.Tick(0.016);
_device.SetAxis(RioAxis.JoystickX, 42); // e.g. a mouse drag on the panel
_router.Tick(0.016); // router value unchanged → no clobber
Assert.Equal(42, _device.GetAxis(RioAxis.JoystickX));
}
[Fact]
public void Profile_swap_releases_held_inputs()
{
UseProfile("key A button 0x05");
_router.KeyDown("A");
_router.Profile = BindingProfile.Empty;
Assert.Equal(new[] { PacketBuilder.ButtonPressed(0x05), PacketBuilder.ButtonReleased(0x05) }, _tx);
}
}
+55 -5
View File
@@ -48,6 +48,7 @@ public class VRioDeviceTests
var device = new VRioDevice();
var wire = new Wire(device);
device.SetAxis(RioAxis.Throttle, 1000);
device.SetAxis(RioAxis.JoystickY, 3000);
device.SetAxis(RioAxis.JoystickX, -5000);
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
@@ -60,11 +61,47 @@ public class VRioDeviceTests
Assert.Equal(1000, AnalogCodec.Combine(p[0], p[1])); // throttle
Assert.Equal(0, AnalogCodec.Combine(p[2], p[3])); // left pedal
Assert.Equal(0, AnalogCodec.Combine(p[4], p[5])); // right pedal
Assert.Equal(0, AnalogCodec.Combine(p[6], p[7])); // joystick Y
Assert.Equal(-3000, AnalogCodec.Combine(p[6], p[7])); // joystick Y (up is negative on the wire)
Assert.Equal(-5000, AnalogCodec.Combine(p[8], p[9])); // joystick X
Assert.Equal(1, device.AnalogRequests);
}
[Fact]
public void Joystick_Y_is_negated_on_the_wire_but_not_locally()
{
var device = new VRioDevice();
var wire = new Wire(device);
device.SetAxis(RioAxis.JoystickY, 3000);
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
byte[] p = Assert.Single(wire.Packets).Payload;
Assert.Equal(-3000, AnalogCodec.Combine(p[6], p[7]));
Assert.Equal(3000, device.GetAxis(RioAxis.JoystickY)); // the panel's dot is unflipped
Assert.Equal(-3000, device.GetWireAxis(RioAxis.JoystickY)); // the panel's readout matches the wire
// Full negative deflection: -Min is one past Max, so it clamps rather than throws.
device.SetAxis(RioAxis.JoystickY, AnalogCodec.Min);
wire.Clear();
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
p = Assert.Single(wire.Packets).Payload;
Assert.Equal(AnalogCodec.Max, AnalogCodec.Combine(p[6], p[7]));
}
[Fact]
public void InvertJoystickY_sends_the_physical_direction_instead()
{
var device = new VRioDevice { InvertJoystickY = true };
var wire = new Wire(device);
device.SetAxis(RioAxis.JoystickY, 3000);
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
byte[] p = Assert.Single(wire.Packets).Payload;
Assert.Equal(3000, AnalogCodec.Combine(p[6], p[7]));
Assert.Equal(3000, device.GetWireAxis(RioAxis.JoystickY));
}
[Fact]
public void VersionRequest_reports_configured_firmware()
{
@@ -79,22 +116,35 @@ public class VRioDeviceTests
}
[Fact]
public void CheckRequest_reports_every_board_ok()
public void CheckRequest_enters_test_mode_reports_boards_then_exits()
{
var device = new VRioDevice();
var wire = new Wire(device);
Send(device, PacketBuilder.Build(RioCommand.CheckRequest));
Assert.Equal(RioAddressSpace.Boards.Count, wire.Packets.Count);
Assert.All(wire.Packets, p =>
// The init handshake: TestModeChange ENTER, one CheckReply per board,
// TestModeChange EXIT. The game waits on both test-mode packets and
// stays mute forever if the EXIT never arrives.
Assert.Equal(RioAddressSpace.Boards.Count + 2, wire.Packets.Count);
RioPacket enter = wire.Packets[0];
Assert.Equal(RioCommand.TestModeChange, enter.Command);
Assert.Equal(new byte[] { 1 }, enter.Payload);
RioPacket exit = wire.Packets[wire.Packets.Count - 1];
Assert.Equal(RioCommand.TestModeChange, exit.Command);
Assert.Equal(new byte[] { 0 }, exit.Payload);
var checks = wire.Packets.GetRange(1, RioAddressSpace.Boards.Count);
Assert.All(checks, p =>
{
Assert.Equal(RioCommand.CheckReply, p.Command);
Assert.Equal((byte)RioStatusType.BoardOk, p.Payload[0]);
});
Assert.Equal(
RioAddressSpace.Boards.Select(b => b.Number),
wire.Packets.Select(p => p.Payload[1]));
checks.Select(p => p.Payload[1]));
}
[Fact]