Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49e88fbe20 | ||
|
|
2dca8bfd8f | ||
|
|
697cf3129b | ||
|
|
e507f1740c | ||
|
|
0674cf5ba4 | ||
|
|
90bf6723dc | ||
|
|
359e1c0a40 | ||
|
|
c6e0522408 | ||
|
|
9a1c8478bf | ||
|
|
19a1abbff2 | ||
|
|
a1b7dae3da | ||
|
|
767473d1cf | ||
|
|
007d15e668 | ||
|
|
391c53f294 | ||
|
|
bec3bb1e4a | ||
|
|
6923c9f252 | ||
|
|
23204ba9c6 | ||
|
|
97e78b0eea | ||
|
|
ce5ed1117a | ||
|
|
41f6ef364d | ||
|
|
f77cd55b11 | ||
|
|
31c3a910f0 | ||
|
|
1e79711181 | ||
|
|
dc9a294101 | ||
|
|
1eded793af | ||
|
|
44dc8e48e7 | ||
|
|
d26000f906 | ||
|
|
e590b89c47 |
@@ -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.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# PD01D221 firmware analysis (`tms27pc512.BIN`)
|
||||||
|
|
||||||
|
Reverse-engineering notes for the dumped controller firmware — the 64 KB
|
||||||
|
TI TMS27PC512 EPROM (U3) from the Babcock PD01D221. This is the authoritative
|
||||||
|
source for the display's command set, and it feeds both [vPLASMA](../src/VPlasma.App/)
|
||||||
|
and the planned [hardware replica](README.md).
|
||||||
|
|
||||||
|
Dump: `tms27pc512.BIN`, 65,536 bytes, MD5 `b775427806857f60ca4a4cc501f4b5cc`.
|
||||||
|
Analysis tooling: [`hc11dis.py`](hc11dis.py) (a purpose-built 68HC11
|
||||||
|
disassembler — the toolchain has no m68hc11 target).
|
||||||
|
|
||||||
|
## Memory map
|
||||||
|
|
||||||
|
- **CPU $8000–$FFFF = EPROM upper 32 KB, 1:1** (the HC11 vector table lands at
|
||||||
|
ROM offset `$FFC0–$FFFF` and is valid, which pins the mapping). The EPROM's
|
||||||
|
**lower 32 KB is unused** (all `$00`) — only A15-high is decoded to the ROM.
|
||||||
|
- **Code:** `$9000–$B8xx`. **Data/tables:** `$8000–$8FFF` (demo), `$98AC+`
|
||||||
|
(dispatch tables), `$BC03+` (font descriptors), `$C000–$DFFF` (glyph
|
||||||
|
bitmaps + graphics).
|
||||||
|
- **RAM (Mosel MS62256, 32 KB) at low addresses:** HC11 registers on page 0
|
||||||
|
(`$00–$3F`; SCSR=`$2E`, SCDR=`$2F`), zero-page variables `$40–$FF`, RX ring
|
||||||
|
buffer at `$0228`, and **ten 128×32 screen buffers** from `$0F6D` up.
|
||||||
|
|
||||||
|
## Vectors
|
||||||
|
|
||||||
|
| Vector | Target | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| RESET | `$9059` | Init: registers, stack `$0227`, then main loop |
|
||||||
|
| **SCI (serial rx)** | **`$B85C`** | Interrupt-driven receive → ring buffer |
|
||||||
|
| COP watchdog | `$905E` | (re-inits) |
|
||||||
|
| others | `$9059` | default → reset |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
1. **SCI RX ISR (`$B85C`)** — on RDRF, reads `SCSR`/`SCDR`, stores the byte to
|
||||||
|
a ring buffer at `$0228` (write ptr `$0228`, count `$022C`). No parsing here.
|
||||||
|
2. **Main-loop parser (`$B7E0–$B859`)** — pulls buffered bytes and runs an
|
||||||
|
`ESC`-state machine (flag `$AF`: bit `$10` = ESC seen, bit `$20` = operand
|
||||||
|
pending). Dispatch is **table-driven** (below). Printable chars in the
|
||||||
|
current font's `[first,last]` range (`$62`/`$63`) go to the character
|
||||||
|
renderer (`$9648`), which **enqueues** a glyph to a deferred rasterizer.
|
||||||
|
3. **Ten double-buffered screens** — descriptor table at **`$A4E0`** (10 × 6
|
||||||
|
bytes): each screen has a **draw** pointer (`$BE`) and a **display** pointer
|
||||||
|
(`$BC`) into SRAM, 1 KB apart. `ESC I` sets the draw target, `ESC i` sets
|
||||||
|
what's scanned to the glass → **page-flipping / double-buffering**.
|
||||||
|
|
||||||
|
## Command dispatch
|
||||||
|
|
||||||
|
Two jump tables, indexed by byte:
|
||||||
|
|
||||||
|
- **`ESC` + letter → command table at `$98AC`.** Valid letters `0x30`–`0x7E`;
|
||||||
|
index = `letter − 0x30`; null entry = ignored. **58 commands populated.**
|
||||||
|
- **Control bytes `0x08`–`0x14` → table at `$994C`.** index = `byte − 0x08`.
|
||||||
|
|
||||||
|
Most command handlers share a prologue: first sighting of the letter sets the
|
||||||
|
"operand pending" flag and returns; the **next byte is the 1-byte operand**
|
||||||
|
(in `$C6`). Multi-operand commands (`ESC P/X/Y`) collect into a parameter
|
||||||
|
block at `$0070`.
|
||||||
|
|
||||||
|
### Control characters (`$994C`)
|
||||||
|
|
||||||
|
| Byte | Handler | Meaning |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `0x08` BS | `$99AF` | cursor left |
|
||||||
|
| `0x09` HT | `$9966` | tab |
|
||||||
|
| `0x0A` LF | `$99F3` | line feed |
|
||||||
|
| `0x0B` VT | `$9A30` | vertical tab |
|
||||||
|
| `0x0D` CR | `$9A55` | carriage return |
|
||||||
|
| `0x11`–`0x14` DC1–DC4 | `$9A5C`/`$9B34`/`$9C09`/`$9CFC` | device controls (TBD) |
|
||||||
|
| `0x0C` FF, `0x0E`–`0x10` | — | no handler |
|
||||||
|
|
||||||
|
### ESC commands (`$98AC`) — confirmed semantics
|
||||||
|
|
||||||
|
| Cmd | Handler | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `ESC @` | `$9F26` | **Clear** the active draw buffer (512 bytes = 128×32÷8) |
|
||||||
|
| `ESC G n` | `$A42B` | **Cursor mode**, low nibble of `$B4` (n = 0–7) |
|
||||||
|
| `ESC H n` | `$A44C` | **Text attributes**, low 4 bits of `$B1` (intensity/underline/reverse/flash) |
|
||||||
|
| `ESC K n` | `$A3EA` | **Font select** (n = 0–9; 8 real fonts) |
|
||||||
|
| `ESC L` | `$A556` | **Home** cursor (X=0, Y=0) |
|
||||||
|
| `ESC Q n` | `$A51C` | **Set cursor row Y** (range-checked 0–31) |
|
||||||
|
| `ESC R n` | `$A539` | **Set cursor column X** (range-checked 0–127) |
|
||||||
|
| `ESC I n` | `$A473` | **Select DRAW page** 0–9 (sets `$BE` from `$A4E0` table) |
|
||||||
|
| `ESC i n` | `$A4A2` | **Select DISPLAY page** 0–9 (page-flip; sets `$BC`) |
|
||||||
|
| `ESC P …` | `$AAF1` | **Graphics bitmap write** (multi-operand: screen,y,x,w,h,data) |
|
||||||
|
| `ESC A`–`ESC F` | `$9FB6`–`$A13C` | **Vector/graphics primitives** (line/point/move; pen state `$B6`, coords `$58/$59`, line routine `$A16C`) |
|
||||||
|
| `ESC X n` | `$A748` | **Set graphics pen X** (multi-op, 0–127) |
|
||||||
|
| `ESC Y n` | `$A644` | **Set graphics pen Y** (multi-op) |
|
||||||
|
| `ESC J` | `$A4D4` | **Toggle** mode bit `$B7.7` (orientation/display — TBD) |
|
||||||
|
|
||||||
|
### ESC commands — populated but not yet decoded
|
||||||
|
|
||||||
|
`ESC 0`–`9` (`$9E27`+, set continuations — likely custom-char / numeric entry),
|
||||||
|
`ESC : ; =` , `ESC < > W w _` (cluster `$AEBA–$AF00`), `ESC B C D E F` variants,
|
||||||
|
`ESC M N O` (`$A5BD/$A5C5/$A5E0`), `ESC Z ^ z ~` (cluster `$AC73–$ACA1`),
|
||||||
|
`ESC a`–`f`, `ESC h l n p q r x`. ~30 handlers remain to label — full list with
|
||||||
|
addresses is dumped by the tooling below.
|
||||||
|
|
||||||
|
## Fonts
|
||||||
|
|
||||||
|
Font-pointer table at **`$BC03`** (10 slots) → 12-byte descriptors. **8 real
|
||||||
|
fonts** (slots 8–9 are junk pointers, matching the demo's "8 STORED CHARACTER
|
||||||
|
FONTS"):
|
||||||
|
|
||||||
|
| Font | First–Last | W×H | Notes |
|
||||||
|
|------|-----------|-----|-------|
|
||||||
|
| 0 | `0x20`–`0xFF` | 6×8 | base font, full range |
|
||||||
|
| 1 | `0x40`–`0x7F` | 6×8 | uppercase-only |
|
||||||
|
| 2 | `0x20`–`0xFF` | 6×10 | |
|
||||||
|
| 3 | `0x40`–`0x7F` | 6×10 | |
|
||||||
|
| 4 | `0x20`–`0x7F` | 12×16 | large |
|
||||||
|
| 5 | `0x20`–`0x7F` | 12×20 | largest |
|
||||||
|
| 6 | `0x20`–`0xFF` | 7×10 | |
|
||||||
|
| 7 | `0x40`–`0x7F` | 7×10 | |
|
||||||
|
|
||||||
|
Glyph bitmaps live in ROM (`~$C000–$DFFF`). **Exact glyph base + encoding
|
||||||
|
pending** — the renderer at `$9648` enqueues to a deferred rasterizer; tracing
|
||||||
|
that (or brute-forcing the 'A' pattern at the known stride) will extract the
|
||||||
|
real glyphs to replace vPLASMA's public-domain 5×7 stand-in.
|
||||||
|
|
||||||
|
## Demo program
|
||||||
|
|
||||||
|
Enabled by **jumper 6** (PD3) — confirms the [JP1 map](README.md). A 10-screen
|
||||||
|
scripted demo; the script at `$8028+` is literal command usage (pointer table
|
||||||
|
at `$8000`). It's how the new `ESC R/I/Q/i/K` commands were first spotted, e.g.
|
||||||
|
`1B 47 00` (ESC G 0) `1B 40` (ESC @) `1B 4C` (ESC L) `1B 52 04` (ESC R 4) …
|
||||||
|
|
||||||
|
## What this means
|
||||||
|
|
||||||
|
**For vPLASMA (folded in 2026-07-16):** the recovered spec replaced the
|
||||||
|
guessed behavior. vPLASMA now uses the **8 real ROM fonts** (extracted to
|
||||||
|
`src/VPlasma.Core/Device/PlasmaFonts.cs`), a **pixel-addressed cursor** with
|
||||||
|
the real `ESC Q` (row) / `ESC R` (column) positioning, `ESC K` 0–7 font
|
||||||
|
select, and `ESC H` attributes as the low 4 bits. Verified: 24 unit tests +
|
||||||
|
the three self-test pages render the real glyphs. Still deferred (documented,
|
||||||
|
single-page model retained): the 10 double-buffered pages (`ESC I`/`ESC i`)
|
||||||
|
and the vector-graphics primitives (`ESC A`–`F`).
|
||||||
|
|
||||||
|
**For the replica:** this *is* the spec. The firmware confirms a clean model —
|
||||||
|
a byte-stream command parser, a 512-byte-per-page frame buffer, 10 pages with
|
||||||
|
page-flip, 8 fonts, text attributes as 4 flags, plus vector graphics. All of
|
||||||
|
it ports directly onto a modern MCU. The one artifact still to extract is the
|
||||||
|
glyph bitmaps.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python hc11dis.py <hexaddr> <count> # disassemble from a CPU address
|
||||||
|
# e.g. python hc11dis.py B7E0 70 # the command parser
|
||||||
|
```
|
||||||
|
Command/control tables are at `$98AC` / `$994C`; font table `$BC03`; screen
|
||||||
|
table `$A4E0`.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# PlasmaNew — reverse-engineering the real cockpit plasma display
|
||||||
|
|
||||||
|
Working notes and reference material for the cockpit plasma display.
|
||||||
|
|
||||||
|
**End goal: a hardware replica.** The original Babcock plasma panels are
|
||||||
|
starting to fail and are effectively irreplaceable. The plan is to drive a
|
||||||
|
modern **128 × 32 LED array** with a **modern microcontroller** that reads
|
||||||
|
the same RS-232 serial bus and speaks the same command protocol as the
|
||||||
|
original PD01D221 — a drop-in replacement, functionally identical from the
|
||||||
|
host's side, with none of the plasma physics or high voltage.
|
||||||
|
|
||||||
|
[vPLASMA](../src/VPlasma.App/) (the C# app in this repo) is the software
|
||||||
|
counterpart and serves the replica directly: it is an **executable
|
||||||
|
specification** of the display's behavior and a **test oracle**. Every
|
||||||
|
command semantic pinned down in `VPlasmaDevice` ports straight to the
|
||||||
|
replica's firmware, and the same differential-test rig (real panel vs.
|
||||||
|
vPLASMA) validates the replica. vPLASMA today is built from *observed
|
||||||
|
traffic* (the game's driver + a factory test tool); grounding it in the
|
||||||
|
*actual hardware* — protocol, fonts, and timing — feeds both the emulator
|
||||||
|
and the replacement firmware.
|
||||||
|
|
||||||
|
## What the display is
|
||||||
|
|
||||||
|
A **commercial off-the-shelf Babcock Display Products Division PD01D221** —
|
||||||
|
"128 × 32 dot-matrix, gas-plasma display with controller and DC-DC
|
||||||
|
converter," with an RS-232C serial interface and a dedicated microprocessor
|
||||||
|
for refresh and the user interface. Built by **Cherry** (PCB assembly
|
||||||
|
**4317-C**, Made in Taiwan, © 1994). See [`PD01D221.pdf`](PD01D221.pdf)
|
||||||
|
(Babcock doc 9200-0109 Rev A).
|
||||||
|
|
||||||
|
Product family (the suffix letter = how much is on the board):
|
||||||
|
|
||||||
|
| Model | Contents |
|
||||||
|
|-------|----------|
|
||||||
|
| PD01**B**22B | 128×32 panel + driver electronics only (host refreshes it) |
|
||||||
|
| PD01**F**221 | + on-board DC-DC converter |
|
||||||
|
| PD01**D**221 | **+ controller: RS-232C, dedicated microprocessor** ← this unit |
|
||||||
|
|
||||||
|
**VWE used it stock — no custom fonts or bitmaps were installed.** So the
|
||||||
|
display's behavior is entirely the standard Babcock PD-series firmware, and
|
||||||
|
the `ESC P` "graphics" the game drew were rendered at runtime by the game,
|
||||||
|
not preloaded. Nothing on the display is VWE-specific.
|
||||||
|
|
||||||
|
## Board inventory
|
||||||
|
|
||||||
|
Chip IDs read from the photos below.
|
||||||
|
|
||||||
|
| Ref | Part | Role |
|
||||||
|
|-----|------|------|
|
||||||
|
| U1 | **Motorola MC68HC11D0** (44-pin QFP, mask 1C17F, wk 28/94) | ROMless HC11 MCU — the controller. Runs from external bus in expanded mode. |
|
||||||
|
| U3 | **TI TMS27PC512** (PLCC-32, −150 ns, Singapore) | **64 KB OTP EPROM = the firmware** (stock Babcock code + fonts). Standard 27C512. |
|
||||||
|
| U2 | QFP ~100-pin, label **"35GWP004 REV A 3994"** | Custom Cherry display/scan **ASIC** (wk 39/94). Drives the HV stage. *Not* the firmware. |
|
||||||
|
| U4 | **Mosel MS62256L-10** | 32 KB SRAM — frame buffer / scratch. |
|
||||||
|
| U7 | **Supertex HV7708** | 32-channel high-voltage plasma driver (more HV off-frame). |
|
||||||
|
| U5 | **Maxim MAX202CWE** | RS-232 transceiver — the serial interface. |
|
||||||
|
| — | **MAX707** | Reset / watchdog supervisor. |
|
||||||
|
| Y1 | **7.3728 MHz** crystal | E-clock = 1.8432 MHz; gives exact standard baud rates. |
|
||||||
|
|
||||||
|
Memory picture: ROMless HC11 + external 64 KB EPROM (code + fonts) + 32 KB
|
||||||
|
SRAM + custom scan ASIC + HV drivers. A 64 KB program EPROM for a 128×32
|
||||||
|
panel implies far more feature set than the game ever used.
|
||||||
|
|
||||||
|
## Reference photos
|
||||||
|
|
||||||
|
| File | Shows |
|
||||||
|
|------|-------|
|
||||||
|
| [`mpul-2026-07-07-152834.jpeg`](mpul-2026-07-07-152834.jpeg) | Controller overview: MC68HC11D0 (U1), the "35GWP004" ASIC (U2), HV7708 (U7), MAX202, MAX707. |
|
||||||
|
| [`silkscreenl-2026-07-07-152841.jpeg`](silkscreenl-2026-07-07-152841.jpeg) | Cherry silkscreen: PCB **4317-C**, © 1994, "Made in Taiwan". |
|
||||||
|
| [`unknown-2026-07-07-153818.jpeg`](unknown-2026-07-07-153818.jpeg) | The **TMS27PC512 EPROM** (U3, initially unidentified), Mosel SRAM (U4), HC11. |
|
||||||
|
| [`jumpers-2026-07-07-163733.jpeg`](jumpers-2026-07-07-163733.jpeg) | The **JP1** config header next to the HC11. |
|
||||||
|
|
||||||
|
## Datasheet-confirmed facts (`PD01D221.pdf`, doc 9200-0109 Rev A)
|
||||||
|
|
||||||
|
- Serial format **8N1**, baud **jumper-selectable 4800 / 9600 / 19.2K /
|
||||||
|
38.4K** (the game uses 9600).
|
||||||
|
- "Choice of standard fonts and styles" (= `ESC K` / `ESC H`); "program
|
||||||
|
custom characters" (a custom-char download command — **exists but VWE
|
||||||
|
didn't use it**); "graphic input commands / overlays" (= `ESC P`).
|
||||||
|
- Serial is **bidirectional**. Connector **J1**: pin 2 TxD (display→host),
|
||||||
|
pin 3 RxD (host→display), pin 4 CTS, pin 8 DTR ("display ready"), pin 5
|
||||||
|
GND. The game drove it write-only (flow control disabled, TxD ignored),
|
||||||
|
so vPLASMA's listen-only model is faithful.
|
||||||
|
- Also carries an 8-bit **parallel** port (J2), unused by the game.
|
||||||
|
- **The datasheet does *not* contain the `ESC` command table.** That's a
|
||||||
|
separate Babcock programming/user manual, which is **not available online**
|
||||||
|
(checked general web, datasheetarchive, bitsavers, archive.org, resellers;
|
||||||
|
only this datasheet was ever digitized). Sources for it: ask Babcock
|
||||||
|
directly (La Mirada CA, (714) 994-6500, babcockinc.com), or reconstruct it
|
||||||
|
from the dump + the sources we already have.
|
||||||
|
|
||||||
|
## Command protocol recovered so far
|
||||||
|
|
||||||
|
From the game driver (`TeslaRel410\CODE\RP\MUNGA_L4\L4PLASMA.CPP`) and the
|
||||||
|
factory test tool (`…\VWETEST\VGLTEST\PLASMA.EXE`). Full grammar lives in
|
||||||
|
[`../src/VPlasma.Core/Protocol/PlasmaProtocol.cs`](../src/VPlasma.Core/Protocol/PlasmaProtocol.cs).
|
||||||
|
|
||||||
|
| Bytes | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `ESC @` | Clear screen, reset text state |
|
||||||
|
| `ESC L` | Home cursor |
|
||||||
|
| `ESC G n` | Cursor mode (00/FF hidden, 01 steady, 03 flashing) |
|
||||||
|
| `ESC K n` | Font select (0–7; FF = default) |
|
||||||
|
| `ESC H n` | Text attributes (intensity / underline / reverse / flash) |
|
||||||
|
| `ESC P s y x w h data…` | Graphics write: MSB = leftmost pixel |
|
||||||
|
| BS / HT / LF / VT / CR | Cursor motion |
|
||||||
|
|
||||||
|
The Babcock manual (or a firmware dump) would fill in exact operand
|
||||||
|
encodings, tab stops, the `ESC P` "screen" byte, and any commands the game
|
||||||
|
never used.
|
||||||
|
|
||||||
|
## JP1 configuration header
|
||||||
|
|
||||||
|
Traced pin-by-pin (see the jumper photo). **JP1 is firmware-read
|
||||||
|
configuration, not CPU mode select** — each shunt ties a GP port pin the
|
||||||
|
firmware polls at boot. Shunt to GND = logic 0.
|
||||||
|
|
||||||
|
| JP1 pos | HC11 pin | Function |
|
||||||
|
|---------|----------|----------|
|
||||||
|
| 1 | pin 24 / PA0 | Baud select bit 0 |
|
||||||
|
| 2 | pin 22 / PA2 | Baud select bit 1 |
|
||||||
|
| 3 | pin 21 / PA3 | Option (unknown) |
|
||||||
|
| 4 | pin 15 / PD5 | Option (unknown) |
|
||||||
|
| 5 | pin 14 / PD4 | Option (unknown) |
|
||||||
|
| 6 | pin 13 / PD3 | Option (unknown) |
|
||||||
|
| 7 | J2 SEL → +5 V | Parallel interface select |
|
||||||
|
|
||||||
|
Positions 1–2 = the datasheet's baud "JUMPER 1 / JUMPER 2." Positions 3–6
|
||||||
|
are four unknown firmware option bits — candidates for a hidden factory
|
||||||
|
self-test / diagnostic mode.
|
||||||
|
|
||||||
|
HC11 pin map cross-checked while tracing: PD0–PD5 = pins 10–15, PA0–PA7 =
|
||||||
|
pins 24–17 (descending).
|
||||||
|
|
||||||
|
**MODA/MODB are hardwired high (expanded mode) through a diode to +5 V — not
|
||||||
|
jumper-selectable.** So bootstrap mode cannot be entered by moving a jumper;
|
||||||
|
it needs a mode-pin override. (Exact diode circuit still to be characterized.)
|
||||||
|
|
||||||
|
## Firmware-dump plan
|
||||||
|
|
||||||
|
Goal: get the 64 KB EPROM image, disassemble the HC11 code to recover the
|
||||||
|
full command table + font bitmaps + timing, then differential-test vPLASMA
|
||||||
|
against the real panel on identical byte streams. The recovered spec feeds
|
||||||
|
**both** vPLASMA and the replacement firmware.
|
||||||
|
|
||||||
|
1. **Free, no-solder — hunt for a diagnostic mode.** Capture J1 TxD while
|
||||||
|
power-cycling normally (may emit a banner/version), then step the four
|
||||||
|
unknown config jumpers (PA3, PD5, PD4, PD3) through combinations watching
|
||||||
|
TxD for a factory self-test or ROM dump.
|
||||||
|
2. **Serial bootstrap (conditional).** Bootstrap needs MODA = MODB = 0 at the
|
||||||
|
reset edge; they're pulled to +5 V via a diode. If that circuit has a
|
||||||
|
series resistor (or a diode-OR node), pull both low during a reset pulse
|
||||||
|
and run the standard **Motorola AN1060** dump loader out J1 — no cutting.
|
||||||
|
If hard-tied, a single trace cut/lift is needed. *Blocked on the diode
|
||||||
|
details.*
|
||||||
|
3. **Reliable fallback — read the EPROM directly.** PLCC-32 test clip on U3
|
||||||
|
with the HC11 held in reset, or hot-air U3 off and read it in a 27C512
|
||||||
|
adapter. Guaranteed image.
|
||||||
|
|
||||||
|
Safety: the panel runs on a few hundred volts from the on-board DC-DC. Keep
|
||||||
|
all work in the logic corner (HC11 / EPROM / MAX202); never probe the HV
|
||||||
|
section or the panel connector while powered.
|
||||||
|
|
||||||
|
## Open items
|
||||||
|
|
||||||
|
- Characterize the MODA/MODB diode circuit → decide if serial bootstrap is a
|
||||||
|
tack-a-wire job or needs a trace cut.
|
||||||
|
- Capture J1 TxD across config-jumper combinations (path 1).
|
||||||
|
- Obtain the Babcock PD01D programming manual, **or** dump the U3 EPROM.
|
||||||
|
- Once we have the command table + fonts: fold into `VPlasmaDevice`, replace
|
||||||
|
the public-domain 5×7 stand-in with the real Babcock glyphs, and
|
||||||
|
differential-test against the hardware.
|
||||||
|
- **Prototype the replica.** A modern MCU (RP2040 / ESP32 / Teensy) reads the
|
||||||
|
command stream into the same command parser and drives a 128×32 LED matrix
|
||||||
|
from the same frame buffer — the per-pixel lit / half-intensity / flash
|
||||||
|
flags in `VPlasmaDevice` map directly onto PWM brightness + blink. An amber
|
||||||
|
matrix best mimics the neon-orange plasma; for a true cockpit swap, match
|
||||||
|
the original active area (~12.75" × 3.15", ~0.1" pitch = 128×32).
|
||||||
|
|
||||||
|
## Replica interface — USB, not RS-232
|
||||||
|
|
||||||
|
The cockpit PCs are now **Win x64**, so the replica likely needs **no real
|
||||||
|
serial port**: a native-USB MCU presenting as a **USB CDC virtual COM port**
|
||||||
|
is transparent — the host opens `COMx` and can't tell it isn't a UART. This
|
||||||
|
deletes the RS-232 transceiver and connector from the BOM. Consequences:
|
||||||
|
|
||||||
|
- **Baud is cosmetic** over USB CDC (the 9600/… setting is accepted as a
|
||||||
|
no-op; the two baud-select jumpers need no hardware equivalent).
|
||||||
|
- **Timing becomes instant** rather than ~1 ms/byte — harmless for a display,
|
||||||
|
and vPLASMA can still throttle to mimic the original for differential tests.
|
||||||
|
- **Pin the COM number** the host expects (original was COM2) in Device
|
||||||
|
Manager so it drops in with no host-side config change.
|
||||||
|
- **DTR/RTS still cross** the CDC link if any host logic ever needs them (the
|
||||||
|
game didn't use flow control).
|
||||||
|
- **Power gotcha:** USB alone can't drive the LED array at full brightness —
|
||||||
|
use USB for data + a **separate DC feed** for the LEDs (or USB-C PD).
|
||||||
|
|
||||||
|
Transparency assumes the host reaches the display as a Windows `COMx`
|
||||||
|
endpoint — e.g. DOSBox-X `serial2=directserial realport:COMx`, which a USB
|
||||||
|
CDC port satisfies perfectly. Confirm the current drive path.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Firmware dump received — analysis underway.** The U3 EPROM was dumped
|
||||||
|
(`tms27pc512.BIN`); see [`FIRMWARE.md`](FIRMWARE.md) for the disassembly
|
||||||
|
findings: memory map, the full command dispatch tables (58 `ESC` commands +
|
||||||
|
control chars), the 10-page double-buffered architecture, 8 fonts, and
|
||||||
|
decoded command semantics. Remaining: extract the glyph bitmaps and label the
|
||||||
|
~30 not-yet-decoded commands. The Babcock programming manual is no longer on
|
||||||
|
the critical path — the firmware is the authoritative spec.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal-but-practical Motorola 68HC11 disassembler for the PD01D221 ROM.
|
||||||
|
CPU address == ROM offset for $8000-$FFFF (EPROM upper 32KB maps 1:1)."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# addressing modes and their extra operand length (beyond opcode)
|
||||||
|
INH='inh'; IMM8='imm8'; IMM16='imm16'; DIR='dir'; EXT='ext'; IDX='idx'; IDY='idy'; REL='rel'
|
||||||
|
BITDIR='bitdir'; BITIDX='bitidx'; BITIDY='bitidy' # BSET/BCLR (mask)
|
||||||
|
BRDIR='brdir'; BRIDX='bridx'; BRIDY='bridy' # BRSET/BRCLR (mask+rel)
|
||||||
|
|
||||||
|
# page 0
|
||||||
|
P0 = {
|
||||||
|
0x00:('TEST',INH),0x01:('NOP',INH),0x02:('IDIV',INH),0x03:('FDIV',INH),
|
||||||
|
0x04:('LSRD',INH),0x05:('ASLD',INH),0x06:('TAP',INH),0x07:('TPA',INH),
|
||||||
|
0x08:('INX',INH),0x09:('DEX',INH),0x0A:('CLV',INH),0x0B:('SEV',INH),
|
||||||
|
0x0C:('CLC',INH),0x0D:('SEC',INH),0x0E:('CLI',INH),0x0F:('SEI',INH),
|
||||||
|
0x10:('SBA',INH),0x11:('CBA',INH),0x12:('BRSET',BRDIR),0x13:('BRCLR',BRDIR),
|
||||||
|
0x14:('BSET',BITDIR),0x15:('BCLR',BITDIR),0x16:('TAB',INH),0x17:('TBA',INH),
|
||||||
|
0x19:('DAA',INH),0x1B:('ABA',INH),
|
||||||
|
0x1C:('BSET',BITIDX),0x1D:('BCLR',BITIDX),0x1E:('BRSET',BRIDX),0x1F:('BRCLR',BRIDX),
|
||||||
|
0x20:('BRA',REL),0x21:('BRN',REL),0x22:('BHI',REL),0x23:('BLS',REL),
|
||||||
|
0x24:('BCC',REL),0x25:('BCS',REL),0x26:('BNE',REL),0x27:('BEQ',REL),
|
||||||
|
0x28:('BVC',REL),0x29:('BVS',REL),0x2A:('BPL',REL),0x2B:('BMI',REL),
|
||||||
|
0x2C:('BGE',REL),0x2D:('BLT',REL),0x2E:('BGT',REL),0x2F:('BLE',REL),
|
||||||
|
0x30:('TSX',INH),0x31:('INS',INH),0x32:('PULA',INH),0x33:('PULB',INH),
|
||||||
|
0x34:('DES',INH),0x35:('TXS',INH),0x36:('PSHA',INH),0x37:('PSHB',INH),
|
||||||
|
0x38:('PULX',INH),0x39:('RTS',INH),0x3A:('ABX',INH),0x3B:('RTI',INH),
|
||||||
|
0x3C:('PSHX',INH),0x3D:('MUL',INH),0x3E:('WAI',INH),0x3F:('SWI',INH),
|
||||||
|
0x40:('NEGA',INH),0x43:('COMA',INH),0x44:('LSRA',INH),0x46:('RORA',INH),
|
||||||
|
0x47:('ASRA',INH),0x48:('ASLA',INH),0x49:('ROLA',INH),0x4A:('DECA',INH),
|
||||||
|
0x4C:('INCA',INH),0x4D:('TSTA',INH),0x4F:('CLRA',INH),
|
||||||
|
0x50:('NEGB',INH),0x53:('COMB',INH),0x54:('LSRB',INH),0x56:('RORB',INH),
|
||||||
|
0x57:('ASRB',INH),0x58:('ASLB',INH),0x59:('ROLB',INH),0x5A:('DECB',INH),
|
||||||
|
0x5C:('INCB',INH),0x5D:('TSTB',INH),0x5F:('CLRB',INH),
|
||||||
|
0x60:('NEG',IDX),0x63:('COM',IDX),0x64:('LSR',IDX),0x66:('ROR',IDX),
|
||||||
|
0x67:('ASR',IDX),0x68:('ASL',IDX),0x69:('ROL',IDX),0x6A:('DEC',IDX),
|
||||||
|
0x6C:('INC',IDX),0x6D:('TST',IDX),0x6E:('JMP',IDX),0x6F:('CLR',IDX),
|
||||||
|
0x70:('NEG',EXT),0x73:('COM',EXT),0x74:('LSR',EXT),0x76:('ROR',EXT),
|
||||||
|
0x77:('ASR',EXT),0x78:('ASL',EXT),0x79:('ROL',EXT),0x7A:('DEC',EXT),
|
||||||
|
0x7C:('INC',EXT),0x7D:('TST',EXT),0x7E:('JMP',EXT),0x7F:('CLR',EXT),
|
||||||
|
0x80:('SUBA',IMM8),0x81:('CMPA',IMM8),0x82:('SBCA',IMM8),0x83:('SUBD',IMM16),
|
||||||
|
0x84:('ANDA',IMM8),0x85:('BITA',IMM8),0x86:('LDAA',IMM8),0x88:('EORA',IMM8),
|
||||||
|
0x89:('ADCA',IMM8),0x8A:('ORAA',IMM8),0x8B:('ADDA',IMM8),0x8C:('CPX',IMM16),
|
||||||
|
0x8D:('BSR',REL),0x8E:('LDS',IMM16),0x8F:('XGDX',INH),
|
||||||
|
0x90:('SUBA',DIR),0x91:('CMPA',DIR),0x92:('SBCA',DIR),0x93:('SUBD',DIR),
|
||||||
|
0x94:('ANDA',DIR),0x95:('BITA',DIR),0x96:('LDAA',DIR),0x97:('STAA',DIR),
|
||||||
|
0x98:('EORA',DIR),0x99:('ADCA',DIR),0x9A:('ORAA',DIR),0x9B:('ADDA',DIR),
|
||||||
|
0x9C:('CPX',DIR),0x9D:('JSR',DIR),0x9E:('LDS',DIR),0x9F:('STS',DIR),
|
||||||
|
0xA0:('SUBA',IDX),0xA1:('CMPA',IDX),0xA2:('SBCA',IDX),0xA3:('SUBD',IDX),
|
||||||
|
0xA4:('ANDA',IDX),0xA5:('BITA',IDX),0xA6:('LDAA',IDX),0xA7:('STAA',IDX),
|
||||||
|
0xA8:('EORA',IDX),0xA9:('ADCA',IDX),0xAA:('ORAA',IDX),0xAB:('ADDA',IDX),
|
||||||
|
0xAC:('CPX',IDX),0xAD:('JSR',IDX),0xAE:('LDS',IDX),0xAF:('STS',IDX),
|
||||||
|
0xB0:('SUBA',EXT),0xB1:('CMPA',EXT),0xB2:('SBCA',EXT),0xB3:('SUBD',EXT),
|
||||||
|
0xB4:('ANDA',EXT),0xB5:('BITA',EXT),0xB6:('LDAA',EXT),0xB7:('STAA',EXT),
|
||||||
|
0xB8:('EORA',EXT),0xB9:('ADCA',EXT),0xBA:('ORAA',EXT),0xBB:('ADDA',EXT),
|
||||||
|
0xBC:('CPX',EXT),0xBD:('JSR',EXT),0xBE:('LDS',EXT),0xBF:('STS',EXT),
|
||||||
|
0xC0:('SUBB',IMM8),0xC1:('CMPB',IMM8),0xC2:('SBCB',IMM8),0xC3:('ADDD',IMM16),
|
||||||
|
0xC4:('ANDB',IMM8),0xC5:('BITB',IMM8),0xC6:('LDAB',IMM8),0xC8:('EORB',IMM8),
|
||||||
|
0xC9:('ADCB',IMM8),0xCA:('ORAB',IMM8),0xCB:('ADDB',IMM8),0xCC:('LDD',IMM16),
|
||||||
|
0xCE:('LDX',IMM16),0xCF:('STOP',INH),
|
||||||
|
0xD0:('SUBB',DIR),0xD1:('CMPB',DIR),0xD2:('SBCB',DIR),0xD3:('ADDD',DIR),
|
||||||
|
0xD4:('ANDB',DIR),0xD5:('BITB',DIR),0xD6:('LDAB',DIR),0xD7:('STAB',DIR),
|
||||||
|
0xD8:('EORB',DIR),0xD9:('ADCB',DIR),0xDA:('ORAB',DIR),0xDB:('ADDB',DIR),
|
||||||
|
0xDC:('LDD',DIR),0xDD:('STD',DIR),0xDE:('LDX',DIR),0xDF:('STX',DIR),
|
||||||
|
0xE0:('SUBB',IDX),0xE1:('CMPB',IDX),0xE2:('SBCB',IDX),0xE3:('ADDD',IDX),
|
||||||
|
0xE4:('ANDB',IDX),0xE5:('BITB',IDX),0xE6:('LDAB',IDX),0xE7:('STAB',IDX),
|
||||||
|
0xE8:('EORB',IDX),0xE9:('ADCB',IDX),0xEA:('ORAB',IDX),0xEB:('ADDB',IDX),
|
||||||
|
0xEC:('LDD',IDX),0xED:('STD',IDX),0xEE:('LDX',IDX),0xEF:('STX',IDX),
|
||||||
|
0xF0:('SUBB',EXT),0xF1:('CMPB',EXT),0xF2:('SBCB',EXT),0xF3:('ADDD',EXT),
|
||||||
|
0xF4:('ANDB',EXT),0xF5:('BITB',EXT),0xF6:('LDAB',EXT),0xF7:('STAB',EXT),
|
||||||
|
0xF8:('EORB',EXT),0xF9:('ADCB',EXT),0xFA:('ORAB',EXT),0xFB:('ADDB',EXT),
|
||||||
|
0xFC:('LDD',EXT),0xFD:('STD',EXT),0xFE:('LDX',EXT),0xFF:('STX',EXT),
|
||||||
|
}
|
||||||
|
# page 1 ($18): Y versions
|
||||||
|
P1 = {
|
||||||
|
0x08:('INY',INH),0x09:('DEY',INH),0x1C:('BSET',BITIDY),0x1D:('BCLR',BITIDY),
|
||||||
|
0x1E:('BRSET',BRIDY),0x1F:('BRCLR',BRIDY),0x30:('TSY',INH),0x35:('TYS',INH),
|
||||||
|
0x38:('PULY',INH),0x3A:('ABY',INH),0x3C:('PSHY',INH),
|
||||||
|
0x60:('NEG',IDY),0x63:('COM',IDY),0x64:('LSR',IDY),0x66:('ROR',IDY),
|
||||||
|
0x67:('ASR',IDY),0x68:('ASL',IDY),0x69:('ROL',IDY),0x6A:('DEC',IDY),
|
||||||
|
0x6C:('INC',IDY),0x6D:('TST',IDY),0x6E:('JMP',IDY),0x6F:('CLR',IDY),
|
||||||
|
0x8C:('CPY',IMM16),0x8F:('XGDY',INH),0x9C:('CPY',DIR),
|
||||||
|
0xA0:('SUBA',IDY),0xA1:('CMPA',IDY),0xA2:('SBCA',IDY),0xA3:('SUBD',IDY),
|
||||||
|
0xA4:('ANDA',IDY),0xA5:('BITA',IDY),0xA6:('LDAA',IDY),0xA7:('STAA',IDY),
|
||||||
|
0xA8:('EORA',IDY),0xA9:('ADCA',IDY),0xAA:('ORAA',IDY),0xAB:('ADDA',IDY),
|
||||||
|
0xAC:('CPY',IDY),0xAD:('JSR',IDY),0xAE:('LDS',IDY),0xAF:('STS',IDY),
|
||||||
|
0xBC:('CPY',EXT),0xCE:('LDY',IMM16),0xDE:('LDY',DIR),0xDF:('STY',DIR),
|
||||||
|
0xE0:('SUBB',IDY),0xE1:('CMPB',IDY),0xE2:('SBCB',IDY),0xE3:('ADDD',IDY),
|
||||||
|
0xE4:('ANDB',IDY),0xE5:('BITB',IDY),0xE6:('LDAB',IDY),0xE7:('STAB',IDY),
|
||||||
|
0xE8:('EORB',IDY),0xE9:('ADCB',IDY),0xEA:('ORAB',IDY),0xEB:('ADDB',IDY),
|
||||||
|
0xEC:('LDD',IDY),0xED:('STD',IDY),0xEE:('LDY',IDY),0xEF:('STY',IDY),
|
||||||
|
0xFE:('LDY',EXT),0xFF:('STY',EXT),
|
||||||
|
}
|
||||||
|
# page 2 ($1A): CPD, and X/Y cross indexed
|
||||||
|
P2 = {0x83:('CPD',IMM16),0x93:('CPD',DIR),0xA3:('CPD',IDX),0xB3:('CPD',EXT),
|
||||||
|
0xAC:('CPY',IDX),0xEE:('LDY',IDX),0xEF:('STY',IDX)}
|
||||||
|
# page 4 ($CD): X/Y cross indexed
|
||||||
|
P4 = {0xA3:('CPD',IDY),0xAC:('CPX',IDY),0xEE:('LDX',IDY),0xEF:('STX',IDY)}
|
||||||
|
|
||||||
|
def disasm(rom, addr, count):
|
||||||
|
out=[]
|
||||||
|
for _ in range(count):
|
||||||
|
start=addr
|
||||||
|
op=rom[addr]; addr+=1
|
||||||
|
tab=P0; pfx=''
|
||||||
|
if op==0x18: pfx='18 '; op=rom[addr]; addr+=1; tab=P1
|
||||||
|
elif op==0x1A: pfx='1A '; op=rom[addr]; addr+=1; tab=P2
|
||||||
|
elif op==0xCD: pfx='CD '; op=rom[addr]; addr+=1; tab=P4
|
||||||
|
ent=tab.get(op)
|
||||||
|
if ent is None:
|
||||||
|
out.append((start,f".byte ${op:02X}",'?')); continue
|
||||||
|
mn,mode=ent; operand=''; tgt=None
|
||||||
|
if mode==INH: pass
|
||||||
|
elif mode==IMM8: operand=f"#${rom[addr]:02X}"; addr+=1
|
||||||
|
elif mode==IMM16: operand=f"#${(rom[addr]<<8)|rom[addr+1]:04X}"; addr+=2
|
||||||
|
elif mode==DIR: operand=f"${rom[addr]:02X}"; addr+=1
|
||||||
|
elif mode==EXT:
|
||||||
|
v=(rom[addr]<<8)|rom[addr+1]; addr+=2; operand=f"${v:04X}"; tgt=v
|
||||||
|
elif mode==IDX: operand=f"${rom[addr]:02X},X"; addr+=1
|
||||||
|
elif mode==IDY: operand=f"${rom[addr]:02X},Y"; addr+=1
|
||||||
|
elif mode==REL:
|
||||||
|
rel=rom[addr]; addr+=1; d=rel-256 if rel>127 else rel
|
||||||
|
tgt=(addr+d)&0xFFFF; operand=f"${tgt:04X}"
|
||||||
|
elif mode==BITDIR: operand=f"${rom[addr]:02X} #${rom[addr+1]:02X}"; addr+=2
|
||||||
|
elif mode==BITIDX: operand=f"${rom[addr]:02X},X #${rom[addr+1]:02X}"; addr+=2
|
||||||
|
elif mode==BITIDY: operand=f"${rom[addr]:02X},Y #${rom[addr+1]:02X}"; addr+=2
|
||||||
|
elif mode==BRDIR:
|
||||||
|
dd=rom[addr]; mk=rom[addr+1]; rel=rom[addr+2]; addr+=3
|
||||||
|
s=rel-256 if rel>127 else rel; tgt=(addr+s)&0xFFFF
|
||||||
|
operand=f"${dd:02X} #${mk:02X} ${tgt:04X}"
|
||||||
|
elif mode==BRIDX:
|
||||||
|
dd=rom[addr]; mk=rom[addr+1]; rel=rom[addr+2]; addr+=3
|
||||||
|
s=rel-256 if rel>127 else rel; tgt=(addr+s)&0xFFFF
|
||||||
|
operand=f"${dd:02X},X #${mk:02X} ${tgt:04X}"
|
||||||
|
elif mode==BRIDY:
|
||||||
|
dd=rom[addr]; mk=rom[addr+1]; rel=rom[addr+2]; addr+=3
|
||||||
|
s=rel-256 if rel>127 else rel; tgt=(addr+s)&0xFFFF
|
||||||
|
operand=f"${dd:02X},Y #${mk:02X} ${tgt:04X}"
|
||||||
|
raw=' '.join(f'{rom[b]:02X}' for b in range(start,addr))
|
||||||
|
out.append((start,f"{pfx}{mn} {operand}".strip(), raw, mn, tgt))
|
||||||
|
return out
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
rom=open(__import__('os').path.join(__import__('os').path.dirname(__file__),'tms27pc512.BIN'),'rb').read()
|
||||||
|
start=int(sys.argv[1],16); count=int(sys.argv[2]) if len(sys.argv)>2 else 40
|
||||||
|
for row in disasm(rom,start,count):
|
||||||
|
a=row[0]; txt=row[1]; raw=row[2]
|
||||||
|
print(f"{a:04X}: {raw:<12} {txt}")
|
||||||
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 896 KiB |
|
After Width: | Height: | Size: 943 KiB |
@@ -3,7 +3,9 @@
|
|||||||
A software replica of the cockpit **RIO** (Remote Input/Output) board: it opens
|
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
|
a COM port and speaks the **device side** of the RIO serial protocol, so any
|
||||||
host that expects the real hardware — most importantly
|
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
|
The window is an interactive version of the cockpit control panel that
|
||||||
RIOJoy's profile editor draws (the same functional layout from the original
|
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`:
|
- Cells shade to the **lamp state the host commands** (`LampRequest`:
|
||||||
off / dim / bright, with slow/med/fast flash), so RIOJoy's press-feedback
|
off / dim / bright, with slow/med/fast flash), so RIOJoy's press-feedback
|
||||||
lights the on-screen panel just like the real buttons.
|
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),
|
||||||
|
F1–F6 / F7–F12 = 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
|
## Wire behavior
|
||||||
|
|
||||||
@@ -32,39 +70,122 @@ device behavior grounded in the **real v4.2 firmware dump**
|
|||||||
(`riojoy/rio-firmware/RIOv4_2-ANALYSIS.md`):
|
(`riojoy/rio-firmware/RIOv4_2-ANALYSIS.md`):
|
||||||
|
|
||||||
- ACKs every well-formed packet; NAKs bad-checksum packets.
|
- ACKs every well-formed packet; NAKs bad-checksum packets.
|
||||||
- `CheckRequest` → one `BoardOk` CheckReply per board (the 11 boards from the
|
- **TX is paced at the wire rate** — one byte per 10-bit frame (~1.04 ms at
|
||||||
legacy firmware's table). `VersionRequest` → configurable version,
|
9600 8N1), never closer. A virtual null-modem has no UART, so unpaced
|
||||||
default **4.2**.
|
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).
|
- `ResetRequest` re-zeroes the targeted axis (or all).
|
||||||
- A NAK re-sends the last event up to **4 times**, then gives up with a
|
- A NAK re-sends the last event up to **4 times**, then gives up with a
|
||||||
RESTART byte — the real board's retry budget.
|
RESTART byte — the real board's retry budget.
|
||||||
- Optional **v4.2 reply-wedge emulation**: after retry exhaustion (or the
|
- Optional **v4.2 reply-wedge emulation** (in `VRio.Core`; the UI toggles
|
||||||
"Wedge analog now" button), analog requests are silently dropped — still
|
were removed): after retry exhaustion, analog requests are silently
|
||||||
ACK'd, RX path alive — until a host `ResetRequest`, reproducing the
|
dropped — still ACK'd, RX path alive — until a host `ResetRequest`,
|
||||||
latch-leak fault the firmware analysis documents. Use it to exercise
|
reproducing the latch-leak fault the firmware analysis documents.
|
||||||
RIOJoy's 5-second no-analog recovery watchdog.
|
|
||||||
|
|
||||||
## Using it with RIOJoy on one PC
|
## Using it with RIOJoy on one PC
|
||||||
|
|
||||||
The two apps need a crossed serial link. Install a
|
The two apps need a crossed serial link. Install a
|
||||||
[com0com](https://com0com.sourceforge.net/) virtual null-modem pair
|
[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**.
|
1. Run `VRio.App`, pick `COM11`, **Open**.
|
||||||
2. Point RIOJoy at `COM6`.
|
2. RIOJoy will always point to `COM1`.
|
||||||
|
|
||||||
RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms
|
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
|
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
|
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
|
||||||
a null-modem cable work the same way.
|
a null-modem cable work the same way.
|
||||||
|
|
||||||
|
## Named pipes for DOSBox-X (no com0com)
|
||||||
|
|
||||||
|
When the game runs in the patched DOSBox-X (the fork's `namedpipe` serial
|
||||||
|
backend), the virtual ports aren't needed at all: each device's connection
|
||||||
|
picker lists its named-pipe endpoint — `pipe:vrio`, `pipe:vplasma` —
|
||||||
|
alongside the machine's COM ports. Select it and **Open** to serve
|
||||||
|
`\\.\pipe\vrio` / `\\.\pipe\vplasma`; DOSBox-X connects as the client,
|
||||||
|
retrying in the background until the pipe exists:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[serial]
|
||||||
|
serial1 = namedpipe pipe:vrio rxpollus:100 rxburst:16
|
||||||
|
serial2 = namedpipe pipe:vplasma
|
||||||
|
```
|
||||||
|
|
||||||
|
A pipe is a plain byte stream, so serial data and modem lines travel as
|
||||||
|
typed frames (`0x00 len bytes` data, `0x01 bits` DTR/RTS) — the game's DTR
|
||||||
|
reset pulse keeps its exact position in the byte stream, and a disconnect
|
||||||
|
reads as all-lines-low (cable unplugged). The contract lives in
|
||||||
|
`src/VRio.Core/Device/PipeFraming.cs` on this side and `serialnamedpipe.h`
|
||||||
|
on the fork's. TX is paced exactly like the COM path — with the 9600-baud
|
||||||
|
wire gone, the pacer is the only thing between the host and an impossible
|
||||||
|
burst. Each row serves one endpoint at a time — the RIO is a single-host
|
||||||
|
device — and nothing opens automatically at startup; the COM path is
|
||||||
|
unchanged for RIOJoy and real pods. vRIO's built-in glass and the
|
||||||
|
standalone vPLASMA share the `vplasma` pipe name — whoever starts second
|
||||||
|
retries until the name frees up.
|
||||||
|
|
||||||
|
## 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. The software replica comes in two
|
||||||
|
forms. **Built into vRIO**: the panel's encoder strip hosts the glass at
|
||||||
|
top left, with its own connection row in the control strip (label colour =
|
||||||
|
connection status; picker offers `pipe:vplasma` and the COM ports). And standalone,
|
||||||
|
`VPlasma.App`: 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. Both decode the display's
|
||||||
|
command stream and render the dot matrix in plasma orange, text mode
|
||||||
|
included; only one can hold COM12 at a time.
|
||||||
|
|
||||||
|
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 at a **pixel-addressed cursor**
|
||||||
|
(`ESC Q` sets row Y 0–31, `ESC R` sets column X 0–127) through the
|
||||||
|
**real ROM character generator** — the eight Babcock fonts recovered
|
||||||
|
from the firmware dump (`ESC K` 0–7): 6×8, 6×10, 7×10, and the large
|
||||||
|
12×16 / 12×20. Attributes (`ESC H`, low 4 bits) — half-intensity
|
||||||
|
draws dimmer, underline/reverse render in the cell, flashing text (and
|
||||||
|
the flashing cursor) blink on the glass. Extracted from the U3 EPROM;
|
||||||
|
see [`PlasmaNew/FIRMWARE.md`](PlasmaNew/FIRMWARE.md). (Deferred, and
|
||||||
|
documented there: the 10 double-buffered pages and vector graphics.)
|
||||||
|
- **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
|
## Repository layout
|
||||||
|
|
||||||
| Path | Contents |
|
| Path | Contents |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `src/VRio.Core` | Protocol framing/builder/parser, the `VRioDevice` state machine, serial pump, panel layout data (class library) |
|
| `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/VRio.App` | WinForms panel UI |
|
||||||
|
| `src/VPlasma.Core` | Plasma command-stream parser, the `VPlasmaDevice` display state machine, the 8 real ROM fonts (`PlasmaFonts`), 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/VRio.Core.Tests` | xUnit tests for the protocol + device engine |
|
||||||
|
| `tests/VPlasma.Core.Tests` | xUnit tests for the plasma parser + display engine |
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C4993638
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.Core.Tests", "tests\VRio.Core.Tests\VRio.Core.Tests.csproj", "{986638BB-F289-4480-8575-F1699D201590}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.Core.Tests", "tests\VRio.Core.Tests\VRio.Core.Tests.csproj", "{986638BB-F289-4480-8575-F1699D201590}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{986638BB-F289-4480-8575-F1699D201590}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{80312F43-09BF-4F09-A0FA-A60FDF86274D} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
|
{80312F43-09BF-4F09-A0FA-A60FDF86274D} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
|
||||||
{2D1A482C-D907-47EB-9830-B78132154E57} = {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}
|
{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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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.'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Public folder for the com.microsoft.windows.lighting app extension (required by the manifest schema).
|
||||||
@@ -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.'
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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
|
||||||
|
/// DOSBox-X fork's named-pipe transport (<c>\\.\pipe\vplasma</c>) is served
|
||||||
|
/// permanently alongside it (if vRIO's built-in glass already holds the
|
||||||
|
/// pipe name, the server just retries until it frees up). 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 VPlasmaPipeService _pipeService;
|
||||||
|
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);
|
||||||
|
_pipeService = new VPlasmaPipeService(_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);
|
||||||
|
_pipeService.ClientChanged += _ => 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();
|
||||||
|
_pipeService.Dispose();
|
||||||
|
};
|
||||||
|
|
||||||
|
_canvas.UpdateFrom(_device);
|
||||||
|
EnsureOpen();
|
||||||
|
_pipeService.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
if (_pipeService.IsClientConnected)
|
||||||
|
status += " • pipe host connected";
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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 Bezel = 4;
|
||||||
|
|
||||||
|
// Dot geometry: an N px pitch renders (N−1) px dots with a 1 px gap. The
|
||||||
|
// standalone glass uses the default pitch 5 (4 px dots, a 640×160 dot
|
||||||
|
// field); vRIO embeds the same control at pitch 3 to fit its encoder strip.
|
||||||
|
private readonly int _dotPitch;
|
||||||
|
private readonly int _dotSize;
|
||||||
|
|
||||||
|
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 int _cursorX, _cursorY;
|
||||||
|
private PlasmaCursorMode _cursorMode;
|
||||||
|
private int _cellWidth = 6, _cellHeight = 8;
|
||||||
|
|
||||||
|
public PlasmaCanvas(int dotPitch = 5)
|
||||||
|
{
|
||||||
|
_dotPitch = dotPitch;
|
||||||
|
_dotSize = dotPitch - 1;
|
||||||
|
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||||
|
ControlStyles.OptimizedDoubleBuffer | ControlStyles.FixedWidth |
|
||||||
|
ControlStyles.FixedHeight, true);
|
||||||
|
Size = SizeFor(dotPitch);
|
||||||
|
BackColor = BezelColor;
|
||||||
|
|
||||||
|
_blinkTimer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
_blinkPhase = !_blinkPhase;
|
||||||
|
if (_anythingBlinks)
|
||||||
|
Invalidate();
|
||||||
|
};
|
||||||
|
_blinkTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Control size at a given dot pitch: the dot field plus bezel.</summary>
|
||||||
|
public static Size SizeFor(int dotPitch) => new(
|
||||||
|
VPlasmaDevice.Width * dotPitch + 2 * Bezel,
|
||||||
|
VPlasmaDevice.Height * dotPitch + 2 * Bezel);
|
||||||
|
|
||||||
|
/// <summary>Snapshot the device state and repaint. Call on the UI thread.</summary>
|
||||||
|
public void UpdateFrom(VPlasmaDevice device)
|
||||||
|
{
|
||||||
|
device.CopyFrame(_frame);
|
||||||
|
_cursorX = device.CursorX;
|
||||||
|
_cursorY = device.CursorY;
|
||||||
|
_cursorMode = device.CursorMode;
|
||||||
|
_cellWidth = device.FontWidth;
|
||||||
|
_cellHeight = device.FontHeight;
|
||||||
|
|
||||||
|
_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 = _cursorX;
|
||||||
|
int cy = _cursorY + _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "$(MSBuildProjectDirectory)" 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 "$(MSBuildProjectDirectory)" 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>
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,450 @@
|
|||||||
|
// AUTO-GENERATED from the PD01D221 firmware (tms27pc512.BIN) by
|
||||||
|
// PlasmaNew tooling. The real Babcock character generator, all 8 fonts.
|
||||||
|
// Each glyph row is a ushort; bit 15 = leftmost pixel. See PlasmaNew/FIRMWARE.md.
|
||||||
|
namespace VPlasma.Core.Device;
|
||||||
|
|
||||||
|
/// <summary>The display's 8 built-in fonts, extracted from ROM.</summary>
|
||||||
|
public static class PlasmaFonts
|
||||||
|
{
|
||||||
|
/// <summary>One font face: character range, cell size, and packed glyph rows.</summary>
|
||||||
|
public sealed class Face
|
||||||
|
{
|
||||||
|
public int Index { get; }
|
||||||
|
public byte First { get; }
|
||||||
|
public byte Last { get; }
|
||||||
|
public int Width { get; }
|
||||||
|
public int Height { get; }
|
||||||
|
private readonly ushort[] _rows; // (c-First)*Height + row; bit15 = leftmost
|
||||||
|
public Face(int index, byte first, byte last, int width, int height, string hex)
|
||||||
|
{
|
||||||
|
Index = index; First = first; Last = last; Width = width; Height = height;
|
||||||
|
_rows = new ushort[hex.Length / 4];
|
||||||
|
for (int i = 0; i < _rows.Length; i++)
|
||||||
|
_rows[i] = (ushort)((HexNibble(hex[i*4]) << 12) | (HexNibble(hex[i*4+1]) << 8) | (HexNibble(hex[i*4+2]) << 4) | HexNibble(hex[i*4+3]));
|
||||||
|
}
|
||||||
|
private static int HexNibble(char c) => c <= '9' ? c - '0' : (c & 0x5F) - 'A' + 10;
|
||||||
|
/// <summary>True if this face has a glyph for <paramref name="code"/>.</summary>
|
||||||
|
public bool Has(byte code) => code >= First && code <= Last;
|
||||||
|
/// <summary>Row <paramref name="row"/> of glyph <paramref name="code"/> (bit15 = leftmost), or 0.</summary>
|
||||||
|
public ushort Row(byte code, int row)
|
||||||
|
=> Has(code) && (uint)row < (uint)Height ? _rows[(code - First) * Height + row] : (ushort)0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// font 0: 6x8, chars 0x20-0xFF, 224 glyphs
|
||||||
|
private static readonly Face F0 = new(0, 0x20, 0xFF, 6, 8,
|
||||||
|
"00000000000000000000000000000000200020002000200000000000200000005000500050000000000000000000000050005000F8005000F8005000"
|
||||||
|
+ "5000000020007800A00070002800F00020000000C000C80010002000400098001800000060009000A0004000A8009000680000006000200040000000"
|
||||||
|
+ "0000000000000000100020004000400040002000100000004000200010001000100020004000000000002000A8007000A80020000000000000002000"
|
||||||
|
+ "2000F800200020000000000000000000000000006000200040000000000000000000F800000000000000000000000000000000000000600060000000"
|
||||||
|
+ "00000800100020004000800000000000700088009800A800C80088007000000020006000200020002000200070000000700088000800100020004000"
|
||||||
|
+ "F8000000F80010002000100008000800F00000001000300050009000F800100010000000F8008000F000080008000800F0000000300040008000F000"
|
||||||
|
+ "8800880070000000F8000800100020004000400040000000700088008800700088008800700000007000880088007800080010006000000000006000"
|
||||||
|
+ "600000006000600000000000000060006000000060002000400000001000200040008000400020001000000000000000F8000000F800000000000000"
|
||||||
|
+ "80004000200010002000400080000000700088000800100020000000200000007000880008006800A800A800700000007000880088008800F8008800"
|
||||||
|
+ "88000000F00088008800F00088008800F000000070008800800080008000880070000000E00090008800880088009000E0000000F80080008000F000"
|
||||||
|
+ "80008000F8000000F80080008000F0008000800080000000700088008000B8008800880070000000880088008800F800880088008800000070002000"
|
||||||
|
+ "2000200020002000700000003800100010001000100090006000000088009000A000C000A000900088000000800080008000800080008000F8000000"
|
||||||
|
+ "8800D800A800A800880088008800000088008800C800A800980088008800000070008800880088008800880070000000F00088008800F00080008000"
|
||||||
|
+ "800000007000880088008800A800900068000000F00088008800F000A000900088000000780080008000700008000800F0000000F800200020002000"
|
||||||
|
+ "20002000200000008800880088008800880088007000000088008800880088008800500020000000880088008800A800A800A8005000000088008800"
|
||||||
|
+ "50002000500088008800000088008800880050002000200020000000F80008001000200040008000F800000070004000400040004000400070000000"
|
||||||
|
+ "000080004000200010000800000000007000100010001000100010007000000020005000880000000000000000000000000000000000000000000000"
|
||||||
|
+ "FC000000600040002000000000000000000000000000000070000800780088007800000080008000B000C80088008800F00000000000000070008000"
|
||||||
|
+ "8000880070000000080008006800980088008800780000000000000070008800F8008000700000001000280020007000200020002000000000000000"
|
||||||
|
+ "780088007800080070000000800080008000B000C8008800880000000000200000002000200020002000000010000000300010001000900060000000"
|
||||||
|
+ "800080009000A000C000A000900000006000200020002000200020007000000000000000D000A800A80088008800000000000000B000C80088008800"
|
||||||
|
+ "88000000000000007000880088008800700000000000B000C8008800F0008000800000000000680098008800780008000800000000000000B000C800"
|
||||||
|
+ "8000800080000000000000007800800070000800F000000000002000F800200020002800100000000000000088008800880098006800000000000000"
|
||||||
|
+ "8800880088005000200000000000000088008800A800A800500000000000000088005000200050008800000000000000880050002000200040000000"
|
||||||
|
+ "00000000F800100020004000F80000003000400040008000400040003000000020002000200000002000200020000000600010001000080010001000"
|
||||||
|
+ "6000000008007000800000000000000000000000FC00FC00FC00FC00FC00FC00FC00FC00000000000000F80000000000000000002000200020002000"
|
||||||
|
+ "2000200020002000200020002000E0000000000000000000000000000000700020002000200020002000200020007000000000000000000000000000"
|
||||||
|
+ "0000E000200020002000200020002000200070002000200020002000200020002000E0002000200020002000200020002000F8002000200020002000"
|
||||||
|
+ "200020002000F8000000000000000000000000000000F800200020002000200000000000000008001000200020002000000000000000800040002000"
|
||||||
|
+ "200020002000200040008000000000000000000020002000100008000000000000000000600060006000F800F800600060006000000000000000F800"
|
||||||
|
+ "F80000000000000060006000600040006000600060007000600060006000E000E0000000000000000000000000007800780060006000700060006000"
|
||||||
|
+ "600078007800000000000000000000000000E000E00060006000600060006000600078007800600060006000600060006000E000E000600060006000"
|
||||||
|
+ "000020007000A80020002000200020000000000040002000F0002000400000000000000020004000F8004000200000000000000020002000E0000000"
|
||||||
|
+ "000000000000200020002000A80070002000000000002000500088005000500070000000000020007000F800700070007000000000004000F800F800"
|
||||||
|
+ "F800400000000000000090009000080008009000E00000000000000000000000E000A000E00000000000700040004000400000000000000000000000"
|
||||||
|
+ "0000400040004000C000000000000000000000008000400020000000000000000000C000C0000000000000000000F8000800F8000800100020000000"
|
||||||
|
+ "00000000F8000800300020004000000000000000100020006000A00020000000000000002000F8008800080030000000000000000000F80020002000"
|
||||||
|
+ "F8000000000000001000F8003000500090000000000000004000F8004800500040000000000000000000700010001000F800000000000000F0001000"
|
||||||
|
+ "F0001000F0000000000000000000A800A800080030000000000000000000F0000000000000000000F800080028003000200020004000000008001000"
|
||||||
|
+ "20006000A0002000200000002000F800880088000800100020000000F80020002000200020002000F80000001000F800100030005000900010000000"
|
||||||
|
+ "4000F8004800500048004800900000002000F8002000F800200020002000000000000000780050008800100020000000400078009000100010001000"
|
||||||
|
+ "200000000000F8000800080008000800F80000005000F800500050001000200040000000C0000000C800080008001000600000000000F80008001000"
|
||||||
|
+ "20005000880000004000F800C8005000400040003800000000008800880048000800100060000000000078005000A8001C001000600000001000E000"
|
||||||
|
+ "2000F80020002000400000000000A800A800A800080010002000000070000000F80020002000200040000000800080008000C000A000800080000000"
|
||||||
|
+ "20002000F80020002000400080000000000070000000000000000000F80000000000F8000800500020005000800000002000F800100020007000A800"
|
||||||
|
+ "20000000100010001000100010002000400000000000000040002000900090009000000080008000F800800080008000780000000000F80008000800"
|
||||||
|
+ "080010006000000000004000A000100008000800000000002000F80020002000A800A800200000000000F8000800080050002000100000000000E000"
|
||||||
|
+ "0000E0000000E0001000000000002000400080008800F80008000000000008000800500020005000800000000000F8004000F8004000400038000000"
|
||||||
|
+ "40004000F80048005000400040000000000070001000100010001000F80000000000F8000800F80008000800F800000070000000F800080010002000"
|
||||||
|
+ "40000000900090009000900010002000400000002000A000A000A000A800A8003000000080008000800088009000A000C000000000000000F8008800"
|
||||||
|
+ "88008800F80000000000F8008800080008001000200000000000C0000000080008001000E0000000000020009000400000000000000000000000E000"
|
||||||
|
+ "A000E00000000000000000002000000020004000800090006000000010002000700088008800F8008800000080004000700088008800F80088000000"
|
||||||
|
+ "50000000700088008800F800880000007000880080008000880070002000600010002000F0008000C0008000F000000080004000F0008000C0008000"
|
||||||
|
+ "F00000007000880070002000200020007000000050000000700088008800880070000000500000008800880088008800700000000800100070000800"
|
||||||
|
+ "780088007000000070008800700008007800880070000000800040007000080078008800700000005000000070000800780088007000000000007000"
|
||||||
|
+ "8000800088007000200060001000000070008800F8008000700000007000880070008800F0008000700000008000000070008800F000800070000000"
|
||||||
|
+ "080050000000C00040004000E00000007000A80000006000200020007000000080005000000030001000100038000000500020000000600020002000"
|
||||||
|
+ "700000006800B8000000B000C80088008800000008001000000070008800880070000000700088000000700088008800700000008000400000007000"
|
||||||
|
+ "880088007000000000005000000070008800880070000000F0008800B00088008800D000B00000002000400000008800880098006800000070008800"
|
||||||
|
+ "0000880088009800680000008000400000008800880098006800000000005000000088008800980068000000");
|
||||||
|
|
||||||
|
// font 1: 6x8, chars 0x40-0x7F, 64 glyphs
|
||||||
|
private static readonly Face F1 = new(1, 0x40, 0x7F, 6, 8,
|
||||||
|
"100020004000200010000000F8000000400020001000200040000000F800000020002000F800200020000000F8000000000020000000F80000002000"
|
||||||
|
+ "00000000200050005000500088008800F800000008001000F8002000F80040008000000020007000A800A800A800A80070002000F800400040004000"
|
||||||
|
+ "40004000E00000000000F8000000F8000000F80000000000080070008000080070008000000000000800700080000000F80000000000000004000400"
|
||||||
|
+ "4800A8002800100010000000200050005000500088008800880000007000880008006800980088009000600000000000880050002000500088000000"
|
||||||
|
+ "000020007000F800700020000000000020007000A800A000A8007000200000000000F800880088008800880088000000700088008800A80088008800"
|
||||||
|
+ "70000000000000005000A8005000000000000000F80040002000100020004000F8000000300048004800300000000000000000000000000000000000"
|
||||||
|
+ "000000000000000000000000000000000000000000000000000070008800880088005000D8000000F80000000000700000000000F8000000A800A800"
|
||||||
|
+ "A80070002000200020000000000010007000A800A800A8007000400000000000000000000000000000000000F4005C00540054005400000000000000"
|
||||||
|
+ "20007000A8002000200020002000200020002000200020002000A80070002000300048004000F00040004800B0000000000000000000680090009000"
|
||||||
|
+ "6800000070008800880070008800C800B000000000008000880050002000500088000800700088004000200050008800880070000000000070008800"
|
||||||
|
+ "6000880070000000000020007000A800A800A800700020000000000088008800500050007000200000000000B000C800880088008800080000000000"
|
||||||
|
+ "00006000200020003000000000001000A800A800A80070002000000000000000480050006000500048000000C0004000200010002800440044000000"
|
||||||
|
+ "0000000088008800C800B000800080000000000000008800880050002000000000000000700088008800880070000000880050002000F8002000F800"
|
||||||
|
+ "2000000000000000F80050005000500048000000700088008800F80088008800700000000000000070008800C800B000800080000000000078009000"
|
||||||
|
+ "8800880070000000000008007000A00020002000100000000000000000009000480048003000000000000000F80050008800A8005000000000000000"
|
||||||
|
+ "000050008800A80050000000400030004000300040004000380008002000A800A8007000200020002000000040003000400040004000400038000800"
|
||||||
|
+ "000010000800FC000800100000000000000020004000FC0040002000000000000000000000000000000000000000000020007000A80020002000A800"
|
||||||
|
+ "70002000");
|
||||||
|
|
||||||
|
// font 2: 6x10, chars 0x20-0xFF, 224 glyphs
|
||||||
|
private static readonly Face F2 = new(2, 0x20, 0xFF, 6, 10,
|
||||||
|
"000000000000000000000000000000000000000000002000200020002000000000002000000000000000500050005000000000000000000000000000"
|
||||||
|
+ "000050005000F8005000F8005000500000000000000020007800A00070002800F0002000000000000000C000C8001000200040009800180000000000"
|
||||||
|
+ "000060009000A0004000A800900068000000000000006000200040000000000000000000000000000000100020004000400040002000100000000000"
|
||||||
|
+ "0000400020001000100010002000400000000000000000002000A8007000A80020000000000000000000000020002000F80020002000000000000000"
|
||||||
|
+ "00000000000000000000000060002000400000000000000000000000F800000000000000000000000000000000000000000000006000600000000000"
|
||||||
|
+ "00000000080010002000400080000000000000000000700088009800A800C80088007000000000000000200060002000200020002000700000000000"
|
||||||
|
+ "0000700088000800100020004000F800000000000000F80010002000100008000800F0000000000000001000300050009000F8001000100000000000"
|
||||||
|
+ "0000F8008000F000080008000800F000000000000000300040008000F000880088007000000000000000F80008001000200040004000400000000000"
|
||||||
|
+ "000070008800880070008800880070000000000000007000880088007800080010006000000000000000000060006000000060006000000000000000"
|
||||||
|
+ "00000000600060000000600020004000000000000000100020004000800040002000100000000000000000000000F8000000F8000000000000000000"
|
||||||
|
+ "0000800040002000100020004000800000000000000070008800080010002000000020000000000000007000880008006800A800A800700000000000"
|
||||||
|
+ "00007000880088008800F80088008800000000000000F00088008800F00088008800F000000000000000700088008000800080008800700000000000"
|
||||||
|
+ "0000E00090008800880088009000E000000000000000F80080008000F00080008000F800000000000000F80080008000F00080008000800000000000"
|
||||||
|
+ "0000700088008000B800880088007000000000000000880088008800F800880088008800000000000000700020002000200020002000700000000000"
|
||||||
|
+ "0000380010001000100010009000600000000000000088009000A000C000A00090008800000000000000800080008000800080008000F80000000000"
|
||||||
|
+ "00008800D800A800A80088008800880000000000000088008800C800A800980088008800000000000000700088008800880088008800700000000000"
|
||||||
|
+ "0000F00088008800F0008000800080000000000000007000880088008800A80090006800000000000000F00088008800F000A0009000880000000000"
|
||||||
|
+ "0000780080008000700008000800F000000000000000F800200020002000200020002000000000000000880088008800880088008800700000000000"
|
||||||
|
+ "00008800880088008800880050002000000000000000880088008800A800A800A8005000000000000000880088005000200050008800880000000000"
|
||||||
|
+ "00008800880088005000200020002000000000000000F80008001000200040008000F800000000000000700040004000400040004000700000000000"
|
||||||
|
+ "000080004000200010000800000000000000000000007000100010001000100010007000000000000000200050008800000000000000000000000000"
|
||||||
|
+ "0000000000000000000000000000FC000000000000006000400020000000000000000000000000000000000000007000080078008800780000000000"
|
||||||
|
+ "000080008000B000C80088008800F0000000000000000000000070008000800088007000000000000000080008003400980088008800780000000000"
|
||||||
|
+ "00000000000070008800F800800070000000000000001000280020007000200020002000000000000000000000007800880088007800080008007000"
|
||||||
|
+ "0000800080008000B000C800880088000000000000000000200000002000200020002000000000000000100000003000100010001000100090006000"
|
||||||
|
+ "0000800080009000A000C000A0009000000000000000600020002000200020002000700000000000000000000000D000A800A8008800880000000000"
|
||||||
|
+ "000000000000B000C800880088008800000000000000000000007000880088008800700000000000000000000000B000C8008800F000800080008000"
|
||||||
|
+ "0000000000006800980088007800080008000800000000000000B000C800800080008000000000000000000000007800800070000800F00000000000"
|
||||||
|
+ "000000002000F80020002000280010000000000000000000000088008800880098006800000000000000000000008800880088005000200000000000"
|
||||||
|
+ "00000000000088008800A800A80050000000000000000000000088005000200050008800000000000000000000008800880088007800080008007000"
|
||||||
|
+ "000000000000F800100020004000F8000000000000003000400040008000400040003000000000000000200020002000000020002000200000000000"
|
||||||
|
+ "00006000100010000800100010006000000000000000080070008000000000000000000000000000FC00FC00FC00FC00FC00FC00FC00FC00FC00FC00"
|
||||||
|
+ "0000000000000000FC000000000000000000000010001000100010001000100010001000100010001000100010001000F00000000000000000000000"
|
||||||
|
+ "00000000000000001C001000100010001000100010001000100010001C00000000000000000000000000000000000000F00010001000100010001000"
|
||||||
|
+ "10001000100010001C00100010001000100010001000100010001000F000100010001000100010001000100010001000FC0010001000100010001000"
|
||||||
|
+ "1000100010001000FC00000000000000000000000000000000000000FC00100010001000100010000000000000000000040008001000100010001000"
|
||||||
|
+ "0000000000000000C000200010001000100010001000100010002000C000000000000000000000001000100010000800040000000000000000000000"
|
||||||
|
+ "380038003800FC00FC00FC003800380038003800000000000000FC00FC00FC0000000000000000003800380038003800380038003800380038003800"
|
||||||
|
+ "380038003800F800F800F80000000000000000000000000000003C003C003C0038003800380038003800380038003C003C003C000000000000000000"
|
||||||
|
+ "000000000000F800F800F80038003800380038003800380038003C003C003C003800380038003800380038003800F800F800F8003800380038003800"
|
||||||
|
+ "000000001000380054001000100010001000000000000000000020001000F8001000200000000000000000000000100020007C002000100000000000"
|
||||||
|
+ "00000000000010001000F000000000000000000000000000100010001000540038001000000000000000000010002800440028002800380000000000"
|
||||||
|
+ "00000000100038007C00380038003800000000000000000020007C00FC007C0020000000000000000000000048004800840084004800300000000000"
|
||||||
|
+ "000000000000000000007000500070000000000000000000380020002000200000000000000000000000000000000000200020002000E00000000000"
|
||||||
|
+ "00000000000000000000400020001000000000000000000000000000600060000000000000000000000000007C0004007C0004000800100000000000"
|
||||||
|
+ "0000000000007C00040018001000200000000000000000000000080010003000500010000000000000000000000010007C0044000400180000000000"
|
||||||
|
+ "00000000000000007C00100010007C000000000000000000000008007C001800280048000000000000000000000020007C0024002800200000000000"
|
||||||
|
+ "00000000000000003800080008007C000000000000000000000078000800780008007800000000000000000000000000540054000400180000000000"
|
||||||
|
+ "000000000000000078000000000000000000000000007C00040014001800100010002000000000000000040008001000300050001000100000000000"
|
||||||
|
+ "000010007C00440044000400080010000000000000007C00100010001000100010007C0000000000000008007C000800180028004800080000000000"
|
||||||
|
+ "000020007C002400240024002400480000000000000010007C0010007C00100010001000000000000000000000003C00240044000800100000000000"
|
||||||
|
+ "000020003C004800080008000800100000000000000000007C0004000400040004007C0000000000000028007C002800280008001000200000000000"
|
||||||
|
+ "0000600000006400040004000800300000000000000000007C000400080010002800440000000000000020007C0024002800200020001C0000000000"
|
||||||
|
+ "0000000044004400240004000800300000000000000000003C00240054000C00080030000000000000000800700010007C0010001000200000000000"
|
||||||
|
+ "00000000540054005400040008001000000000000000380000007C001000100010002000000000000000400040004000600050004000400000000000"
|
||||||
|
+ "0000100010007C0010001000200040000000000000000000380000000000000000007C0000000000000000007C000400280010002800400000000000"
|
||||||
|
+ "000010007C00080010003800540010000000000000000800080008000800080010002000000000000000000000002000100048004800480000000000"
|
||||||
|
+ "0000400040007C004000400040003C0000000000000000007C0004000400040008003000000000000000000020005000080004000400000000000000"
|
||||||
|
+ "000010007C001000100054005400100000000000000000007C0004000400280010000800000000000000000070000000700000007000080000000000"
|
||||||
|
+ "0000000010002000400044007C000400000000000000000004000400280010002800400000000000000000007C0020007C00200020001C0000000000"
|
||||||
|
+ "0000200020007C0024002800200020000000000000000000380008000800080008007C0000000000000000007C0004007C00040004007C0000000000"
|
||||||
|
+ "0000380000007C0004000800100020000000000000004800480048004800080010002000000000000000100050005000500054005400180000000000"
|
||||||
|
+ "00004000400040004400480050006000000000000000000000007C004400440044007C0000000000000000007C004400040004000800100000000000"
|
||||||
|
+ "000000006000000004000400080070000000000000000000100048002000000000000000000000000000000070005000700000000000000000000000"
|
||||||
|
+ "000010000000100020004000480030000000000008001000700088008800F800880088000000000080004000700088008800F8008800880000000000"
|
||||||
|
+ "50000000700088008800F8008800880000000000000070008800800080008000880070002000600008001000F8008000F00080008000F80000000000"
|
||||||
|
+ "80004000F8008000F00080008000F8000000000070008800700020002000200020007000000000005000000070008800880088008800700000000000"
|
||||||
|
+ "500000008800880088008800880070000000000008001000000070000800780088007800000000007000880000007000080078008800780000000000"
|
||||||
|
+ "800040000000700008007800880078000000000000005000000070000800780088007800000000000000000000007000800080008800700020006000"
|
||||||
|
+ "08001000000070008800F800800070000000000070008800000070008800F000800070000000000080004000000070008800F8008000700000000000"
|
||||||
|
+ "0800100040000000C00040004000E0000000000070008800200000006000200020007000000000008000400010000000300010001000380000000000"
|
||||||
|
+ "28000000100000003000100010003800000000006800B80000007000C800880088008800000000000800100000007000880088008800700000000000"
|
||||||
|
+ "700088000000700088008800880070000000000080004000000070008800880088007000000000000000500000007000880088008800700000000000"
|
||||||
|
+ "0000780044005800440044006400D8000000000010002000000088008800880098006800000000007000880000008800880088009800680000000000"
|
||||||
|
+ "80004000000088008800880098006800000000000000500000008800880088009800680000000000");
|
||||||
|
|
||||||
|
// font 3: 6x10, chars 0x40-0x7F, 64 glyphs
|
||||||
|
private static readonly Face F3 = new(3, 0x40, 0x7F, 6, 10,
|
||||||
|
"0000100020004000200010000000F800000000000000400020001000200040000000F80000000000000020002000F800200020000000F80000000000"
|
||||||
|
+ "0000000020000000F800000020000000000000000000200050005000500088008800F80000000000000008001000F8002000F8004000800000000000"
|
||||||
|
+ "20007000A800A800A800A800A8007000200000000000F80040004000400040004000E0000000000000000000F8000000F8000000F800000000000000"
|
||||||
|
+ "000008007000800008007000800000000000000000000800700080000000F800000000000000000000000400040004004800A8002800100010000000"
|
||||||
|
+ "0000200050005000500088008800880000000000000070008800080068008C0088009000600000000000000000008800500020005000880000000000"
|
||||||
|
+ "0000000020007000F80070002000000000000000000020007000A800A000A80070002000000000000000F80088008800880088008800880000000000"
|
||||||
|
+ "0000700088008800A800880088007000000000000000000000005000A800500000000000000000000000F80040002000100020004000F80000000000"
|
||||||
|
+ "000030004800480030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
+ "0000000070008800880088005000D800000000000000F80000000000700000000000F800000000000000A800A800A800700020002000200000000000"
|
||||||
|
+ "0000100010007000A800A800A80070004000400000000000000000000000000000000000000000000000F4005C005400540000000000000000000000"
|
||||||
|
+ "20007000A80020002000200020002000200020002000200020002000200020002000A800700020000000300048004000F00040004800B00000000000"
|
||||||
|
+ "00000000000000006800900090006800000000000000700088008800F0008800C800B000800000000000000080008800500020005000880008000000"
|
||||||
|
+ "000070008800400020005000880088007000000000000000000070008800600088007000000000000000000020007000A800A800A800700020000000"
|
||||||
|
+ "0000000000008800880050005000700020002000000000000000B000C800880088008800080000000000000000000000600020002000300000000000"
|
||||||
|
+ "0000000000001000A800A800A80070002000000000000000000048005000600050004800000000000000C00040002000200050008800880000000000"
|
||||||
|
+ "00000000000088008800C800B00080008000000000000000000000008800880050002000000000000000000000007000880088008800700000000000"
|
||||||
|
+ "0000880050002000F8002000F800200000000000000000000000F8005000500050004800000000000000700088008800F80088008800700000000000"
|
||||||
|
+ "00000000000070008800C800B00080008000000000000000000078009000880088007000000000000000000008007000A00020002000100000000000"
|
||||||
|
+ "0000000000008000500048004800300000000000000000000000F80050008800A800500000000000000000000000000050008800A800500000000000"
|
||||||
|
+ "0000400030004000300040004000380008000000000000002000A800A800A80070002000200000000000400030004000400040004000380008000000"
|
||||||
|
+ "0000000010000800FC00080010000000000000000000000020004000FC00400020000000000000000000000000000000000000000000000000000000"
|
||||||
|
+ "1000380054001000100010001000540038001000");
|
||||||
|
|
||||||
|
// font 4: 12x16, chars 0x20-0x7F, 96 glyphs
|
||||||
|
private static readonly Face F4 = new(4, 0x20, 0x7F, 12, 16,
|
||||||
|
"00000000000000000000000000000000000000000000000000000000000000000C000C000C000C000C000C000C000C000C000C00000000000C000C00"
|
||||||
|
+ "0000000033003300330033003300330000000000000000000000000000000000000000003300330033003300FFC0FFC033003300FFC0FFC033003300"
|
||||||
|
+ "33003300000000000C000C003FC07FC0CC00CC007F003F800CC00CC0FF80FF000C000C0000000000F000F000F0C0F1C0038007000E001C0038007000"
|
||||||
|
+ "E3C0C3C003C003C0000000003C007E00C300C700CE00DC0078007800DCC0CFC0C700C7007EC03CC0000000001E001E00060006001C00180000000000"
|
||||||
|
+ "0000000000000000000000000000000000C001C0038007000E000C000C000C000C000E000700038001C000C000000000300038001C000E0007000300"
|
||||||
|
+ "03000300030007000E001C003800300000000000000000000C000C00CCC0EDC07F807F80EDC0CCC00C000C000000000000000000000000000C000C00"
|
||||||
|
+ "0C000C00FFC0FFC00C000C000C000C000000000000000000000000000000000000000000000000000F000F00030003000E000C000000000000000000"
|
||||||
|
+ "0000000000000000FFC0FFC00000000000000000000000000000000000000000000000000000000000000000000000000F000F000F000F0000000000"
|
||||||
|
+ "0000000000C001C0038007000E001C0038007000E000C00000000000000000003F007F80E1C0C0C0C1C0C2C0C4C0C8C0D0C0E0C0C0C0E1C07F803F00"
|
||||||
|
+ "000000000C001C003C000C000C000C000C000C000C000C000C000C003F003F00000000003F007F80E1C0C0C000C001C0038007000E001C0038007000"
|
||||||
|
+ "FFC0FFC000000000FFC0FFC0038007000E000E000700038001C000C000C001C0FF80FF000000000007000F001F003B0033006300E300C300FFC0FFC0"
|
||||||
|
+ "030003000300030000000000FFC0FFC0C000C000C000C000FF00FF8001C000C000C001C0FF80FF00000000000F001F0038007000E000C000FF00FF80"
|
||||||
|
+ "C1C0C0C0C0C0E1C07F803F0000000000FFC0FFC000C001C0038007000E001C00380030003000300030003000000000003F007F80E1C0C0C0C0C0E1C0"
|
||||||
|
+ "7F807F80E1C0C0C0C0C0E1C07F803F00000000003F007F80E1C0C0C0C0C0E0C07FC03FC000C001C0038007003E003C0000000000000000000F000F00"
|
||||||
|
+ "0F000F00000000000F000F000F000F000000000000000000000000000F000F000F000F00000000000F000F00030003000E000C000000000000C001C0"
|
||||||
|
+ "038007000E001C00380038001C000E000700038001C000C0000000000000000000000000FFC0FFC000000000FFC0FFC0000000000000000000000000"
|
||||||
|
+ "300038001C000E000700038001C001C0038007000E001C0038003000000000003F007F80E1C0C0C000C001C0038007000E000C00000000000C000C00"
|
||||||
|
+ "000000003F007F80E1C0C0C000C000C038C07CC0CCC0CCC0CCC0CCC07F803F00000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0FFC0FFC0C0C0C0C0"
|
||||||
|
+ "C0C0C0C000000000FF00FF80C1C0C0C0C0C0C1C0FF80FF80C1C0C0C0C0C0C1C0FF80FF00000000003F007F80E1C0C0C0C0C0C000C000C000C000C0C0"
|
||||||
|
+ "C0C0C1C07F803F0000000000FF00FF80C1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C1C0FF80FF0000000000FFC0FFC0C000C000C000C000FF00FF00"
|
||||||
|
+ "C000C000C000C000FFC0FFC000000000FFC0FFC0C000C000C000C000FF00FF00C000C000C000C000C000C000000000003F007F80E1C0C0C0C000C000"
|
||||||
|
+ "C000C7C0C7C0C0C0C0C0E1C07F803F0000000000C0C0C0C0C0C0C0C0C0C0C0C0FFC0FFC0C0C0C0C0C0C0C0C0C0C0C0C0000000003F003F000C000C00"
|
||||||
|
+ "0C000C000C000C000C000C000C000C003F003F00000000000FC00FC003000300030003000300030003000300C300E7007E003C0000000000C0C0C1C0"
|
||||||
|
+ "C380C700CE00DC00F800F800DC00CE00C700C380C1C0C0C000000000C000C000C000C000C000C000C000C000C000C000C000C000FFC0FFC000000000"
|
||||||
|
+ "C0C0E1C0F3C0F3C0DEC0CCC0CCC0C0C0C0C0C0C0C0C0C0C0C0C0C0C000000000C0C0C0C0C0C0E0C0F0C0F8C0DCC0CEC0C7C0C3C0C1C0C0C0C0C0C0C0"
|
||||||
|
+ "000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0E1C07F803F0000000000FF00FF80C1C0C0C0C0C0C1C0FF80FF00C000C000C000C000"
|
||||||
|
+ "C000C000000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C2C0E1C07F803F4000000000FF00FF80C1C0C0C0C0C0C1C0FF80FF00DC00CE00"
|
||||||
|
+ "C700C380C1C0C0C0000000003FC07FC0E000C000C000E0007F003F8001C000C000C001C0FF80FF0000000000FFC0FFC00C000C000C000C000C000C00"
|
||||||
|
+ "0C000C000C000C000C000C0000000000C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0E1C07F803F0000000000C0C0C0C0C0C0C0C0C0C0C0C0"
|
||||||
|
+ "C0C0C0C0C0C0E1C0738033001E000C0000000000C0C0C0C0C0C0C0C0CCC0CCC0CCC0CCC0CCC0CCC0CCC0CCC07F80330000000000C0C0C0C0C0C0E1C0"
|
||||||
|
+ "73803F001E001E003F007380E1C0C0C0C0C0C0C000000000C0C0C0C0C0C0C0C0C0C0E1C073803F001E000C000C000C000C000C0000000000FFC0FFC0"
|
||||||
|
+ "00C001C0038007000E001C0038007000E000C000FFC0FFC0000000003F003F0030003000300030003000300030003000300030003F003F0000000000"
|
||||||
|
+ "00000000C000E000700038001C000E000700038001C000C000000000000000003F003F0003000300030003000300030003000300030003003F003F00"
|
||||||
|
+ "000000000C001E003F007380E1C0C0C00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
+ "FFC0FFC0000000000C000E000700038001C000C0000000000000000000000000000000000000000000000000000000003F003F8000C000C03FC07FC0"
|
||||||
|
+ "C0C0C0C07FC03FC000000000C000C000C000C000C000C000DF00FF80F1C0C0C0C0C0C1C0FF80FF000000000000000000000000003F007F80E1C0C0C0"
|
||||||
|
+ "C000C000C0C0E1C07F803F000000000000C000C000C000C000C000C03EC07FC0E3C0C0C0C0C0E0C07FC03FC00000000000000000000000003F007F80"
|
||||||
|
+ "C0C0C0C0FF80FF00C000C0007F003F00000000000F001F8039C030C030003000FC00FC00300030003000300030003000000000000000000000000000"
|
||||||
|
+ "3FC07FC0C0C0C0C07FC03FC000C000C0FF80FF0000000000C000C000C000C000C000C000DF00FF80E1C0C0C0C0C0C0C0C0C0C0C0000000000C000C00"
|
||||||
|
+ "000000003C003C000C000C000C000C000C000C003F003F000000000000C000C00000000000C000C000C000C000C000C030C039C01F800F0000000000"
|
||||||
|
+ "300030003000300030C031C0338037003E003E003700338031C030C0000000003C003C000C000C000C000C000C000C000C000C000C000C003F003F00"
|
||||||
|
+ "000000000000000000000000D300FF80FFC0CCC0CCC0CCC0CCC0CCC0CCC0CCC0000000000000000000000000CF00FF80F1C0C0C0C0C0C0C0C0C0C0C0"
|
||||||
|
+ "C0C0C0C00000000000000000000000003F007F80E1C0C0C0C0C0C0C0C0C0E1C07F803F000000000000000000DF00FF80F1C0C0C0C0C0C1C0FF80FF00"
|
||||||
|
+ "C000C000C000C00000000000000000003EC07FC0E3C0C0C0C0C0E0C07FC03FC000C000C000C000C0000000000000000000000000DF00FF80F1C0C0C0"
|
||||||
|
+ "C000C000C000C000C000C0000000000000000000000000003FC07FC0C000C0007F003F8000C000C0FF80FF000000000030003000FC00FC0030003000"
|
||||||
|
+ "300030003000300030C039C01F800F00000000000000000000000000C0C0C0C0C0C0C0C0C0C0C0C0C0C0E3C07FC03CC0000000000000000000000000"
|
||||||
|
+ "C0C0C0C0C0C0C0C0C0C0E1C0738033001E000C00000000000000000000000000C0C0C0C0C0C0C0C0C0C0CCC0CCC0CCC07F8033000000000000000000"
|
||||||
|
+ "00000000C0C0E1C073803F001E001E003F007380E1C0C0C0000000000000000000000000C0C0E1C073803F001E000C000C001C003800300000000000"
|
||||||
|
+ "0000000000000000FFC0FFC0030007000E001C0038007000FFC0FFC00000000000C001C003800300030006000C000C00060003000300038001C000C0"
|
||||||
|
+ "000000000C000C000C000C000C000C00000000000C000C000C000C000C000C0000000000300038001C000C000C0006000300030006000C000C001C00"
|
||||||
|
+ "380030000000000000C001C00F801F00380030000000000000000000000000000000000000000000FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0"
|
||||||
|
+ "FFF0FFF0FFF0FFF0FFF0FFF0");
|
||||||
|
|
||||||
|
// font 5: 12x20, chars 0x20-0x7F, 96 glyphs
|
||||||
|
private static readonly Face F5 = new(5, 0x20, 0x7F, 12, 20,
|
||||||
|
"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C000C000C000C000C000C000C000C00"
|
||||||
|
+ "0C000C00000000000C000C00000000000000000000000000330033003300330033003300000000000000000000000000000000000000000000000000"
|
||||||
|
+ "000000003300330033003300FFC0FFC033003300FFC0FFC033003300330033000000000000000000000000000C000C003FC07FC0CC00CC007F003F80"
|
||||||
|
+ "0CC00CC0FF80FF000C000C00000000000000000000000000F000F000F0C0F1C0038007000E001C0038007000E3C0C3C003C003C00000000000000000"
|
||||||
|
+ "000000003C007E00C300C700CE00DC0078007800DCC0CFC0C700C7007EC03CC00000000000000000000000001E001E00060006001C00180000000000"
|
||||||
|
+ "00000000000000000000000000000000000000000000000000C001C0038007000E000C000C000C000C000E000700038001C000C00000000000000000"
|
||||||
|
+ "00000000300038001C000E000700030003000300030007000E001C0038003000000000000000000000000000000000000C000C00CCC0EDC07F807F80"
|
||||||
|
+ "EDC0CCC00C000C0000000000000000000000000000000000000000000C000C000C000C00FFC0FFC00C000C000C000C00000000000000000000000000"
|
||||||
|
+ "000000000000000000000000000000000000000000000000000000000F000F00030003000E000C0000000000000000000000000000000000FFC0FFC0"
|
||||||
|
+ "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000F000F000F000F000000000000000000"
|
||||||
|
+ "000000000000000000C001C0038007000E001C0038007000E000C000000000000000000000000000000000003F007F80E1C0C0C0C1C0C2C0C4C0C8C0"
|
||||||
|
+ "D0C0E0C0C0C0E1C07F803F000000000000000000000000000C001C003C000C000C000C000C000C000C000C000C000C003F003F000000000000000000"
|
||||||
|
+ "000000003F007F80E1C0C0C000C001C0038007000E001C0038007000FFC0FFC0000000000000000000000000FFC0FFC0038007000E000E0007000380"
|
||||||
|
+ "01C000C000C001C0FF80FF0000000000000000000000000007000F001F003B0033006300E300C300FFC0FFC003000300030003000000000000000000"
|
||||||
|
+ "00000000FFC0FFC0C000C000C000C000FF00FF8001C000C000C001C0FF80FF000000000000000000000000000F001F0038007000E000C000FF00FF80"
|
||||||
|
+ "C1C0C0C0C0C0E1C07F803F00000000000000000000000000FFC0FFC000C001C0038007000E001C003800300030003000300030000000000000000000"
|
||||||
|
+ "000000003F007F80E1C0C0C0C0C0E1C07F807F80E1C0C0C0C0C0E1C07F803F000000000000000000000000003F007F80E1C0C0C0C0C0E0C07FC03FC0"
|
||||||
|
+ "00C001C0038007003E003C00000000000000000000000000000000000F000F000F000F00000000000F000F000F000F00000000000000000000000000"
|
||||||
|
+ "00000000000000000F000F000F000F00000000000F000F00030003000E000C0000000000000000000000000000C001C0038007000E001C0038003800"
|
||||||
|
+ "1C000E000700038001C000C00000000000000000000000000000000000000000FFC0FFC000000000FFC0FFC000000000000000000000000000000000"
|
||||||
|
+ "00000000300038001C000E000700038001C001C0038007000E001C00380030000000000000000000000000003F007F80E1C0C0C000C001C003800700"
|
||||||
|
+ "0E000C00000000000C000C000000000000000000000000003F007F80E1C0C0C000C000C038C07CC0CCC0CCC0CCC0CCC07F803F000000000000000000"
|
||||||
|
+ "000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0FFC0FFC0C0C0C0C0C0C0C0C0000000000000000000000000FF00FF80C1C0C0C0C0C0C1C0FF80FF80"
|
||||||
|
+ "C1C0C0C0C0C0C1C0FF80FF000000000000000000000000003F007F80E1C0C0C0C0C0C000C000C000C000C0C0C0C0C1C07F803F000000000000000000"
|
||||||
|
+ "00000000FF00FF80C1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C1C0FF80FF00000000000000000000000000FFC0FFC0C000C000C000C000FF00FF00"
|
||||||
|
+ "C000C000C000C000FFC0FFC0000000000000000000000000FFC0FFC0C000C000C000C000FF00FF00C000C000C000C000C000C0000000000000000000"
|
||||||
|
+ "000000003F007F80E1C0C0C0C000C000C000C7C0C7C0C0C0C0C0E1C07F803F00000000000000000000000000C0C0C0C0C0C0C0C0C0C0C0C0FFC0FFC0"
|
||||||
|
+ "C0C0C0C0C0C0C0C0C0C0C0C00000000000000000000000003F003F000C000C000C000C000C000C000C000C000C000C003F003F000000000000000000"
|
||||||
|
+ "000000000FC00FC003000300030003000300030003000300C300E7007E003C00000000000000000000000000C0C0C1C0C380C700CE00DC00F800F800"
|
||||||
|
+ "DC00CE00C700C380C1C0C0C0000000000000000000000000C000C000C000C000C000C000C000C000C000C000C000C000FFC0FFC00000000000000000"
|
||||||
|
+ "00000000C0C0E1C0F3C0F3C0DEC0CCC0CCC0C0C0C0C0C0C0C0C0C0C0C0C0C0C0000000000000000000000000C0C0C0C0C0C0E0C0F0C0F8C0DCC0CEC0"
|
||||||
|
+ "C7C0C3C0C1C0C0C0C0C0C0C00000000000000000000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0E1C07F803F000000000000000000"
|
||||||
|
+ "00000000FF00FF80C1C0C0C0C0C0C1C0FF80FF00C000C000C000C000C000C0000000000000000000000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0"
|
||||||
|
+ "C0C0C0C0C2C0E1C07F803F40000000000000000000000000FF00FF80C1C0C0C0C0C0C1C0FF80FF00DC00CE00C700C380C1C0C0C00000000000000000"
|
||||||
|
+ "000000003FC07FC0E000C000C000E0007F003F8001C000C000C001C0FF80FF00000000000000000000000000FFC0FFC00C000C000C000C000C000C00"
|
||||||
|
+ "0C000C000C000C000C000C00000000000000000000000000C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0E1C07F803F000000000000000000"
|
||||||
|
+ "00000000C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0E1C0738033001E000C00000000000000000000000000C0C0C0C0C0C0C0C0CCC0CCC0CCC0CCC0"
|
||||||
|
+ "CCC0CCC0CCC0CCC07F803300000000000000000000000000C0C0C0C0C0C0E1C073803F001E001E003F007380E1C0C0C0C0C0C0C00000000000000000"
|
||||||
|
+ "00000000C0C0C0C0C0C0C0C0C0C0E1C073803F001E000C000C000C000C000C00000000000000000000000000FFC0FFC000C001C0038007000E001C00"
|
||||||
|
+ "38007000E000C000FFC0FFC00000000000000000000000003F003F0030003000300030003000300030003000300030003F003F000000000000000000"
|
||||||
|
+ "0000000000000000C000E000700038001C000E000700038001C000C0000000000000000000000000000000003F003F00030003000300030003000300"
|
||||||
|
+ "03000300030003003F003F000000000000000000000000000C001E003F007380E1C0C0C0000000000000000000000000000000000000000000000000"
|
||||||
|
+ "0000000000000000000000000000000000000000000000000000000000000000FFC0FFC000000000000000000C000E000700038001C000C000000000"
|
||||||
|
+ "00000000000000000000000000000000000000000000000000000000000000003F003F8000C000C03FC07FC0C0C0C0C07FC03FC00000000000000000"
|
||||||
|
+ "00000000C000C000C000C000C000C000DF00FF80F1C0C0C0C0C0C1C0FF80FF0000000000000000000000000000000000000000003F007F80E1C0C0C0"
|
||||||
|
+ "C000C000C0C0E1C07F803F0000000000000000000000000000C000C000C000C000C000C03EC07FC0E3C0C0C0C0C0E0C07FC03FC00000000000000000"
|
||||||
|
+ "0000000000000000000000003F007F80C0C0C0C0FF80FF00C000C0007F003F000000000000000000000000000F001F8039C030C030003000FC00FC00"
|
||||||
|
+ "30003000300030003000300000000000000000000000000000000000000000003FC07FC0E0C0C0C0C0C0E0C07FC03FC000C000C000C001C03F803F00"
|
||||||
|
+ "00000000C000C000C000C000C000C000DF00FF80E1C0C0C0C0C0C0C0C0C0C0C00000000000000000000000000C000C00000000003C003C000C000C00"
|
||||||
|
+ "0C000C000C000C003F003F0000000000000000000000000003000300000000000F000F00030003000300030003000300C300C300C300E7007E003C00"
|
||||||
|
+ "00000000300030003000300030C031C0338037003E003E003700338031C030C00000000000000000000000003C003C000C000C000C000C000C000C00"
|
||||||
|
+ "0C000C000C000C003F003F000000000000000000000000000000000000000000D300FF80FFC0CCC0CCC0CCC0CCC0CCC0CCC0CCC00000000000000000"
|
||||||
|
+ "000000000000000000000000CF00FF80F1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C000000000000000000000000000000000000000003F007F80E1C0C0C0"
|
||||||
|
+ "C0C0C0C0C0C0E1C07F803F000000000000000000000000000000000000000000CF00DF80F9C0F0C0E0C0C1C0FF80FF00C000C000C000C000C000C000"
|
||||||
|
+ "0000000000000000000000003CC07EC0E7C0C3C0C1C0E0C07FC03FC000C000C000C000C000C000C0000000000000000000000000DF00FF80F1C0C0C0"
|
||||||
|
+ "C000C000C000C000C000C00000000000000000000000000000000000000000003FC07FC0C000C0007F003F8000C000C0FF80FF000000000000000000"
|
||||||
|
+ "0000000030003000FC00FC0030003000300030003000300030C039C01F800F000000000000000000000000000000000000000000C0C0C0C0C0C0C0C0"
|
||||||
|
+ "C0C0C0C0C0C0E3C07FC03CC00000000000000000000000000000000000000000C0C0C0C0C0C0C0C0C0C0E1C0738033001E000C000000000000000000"
|
||||||
|
+ "000000000000000000000000C0C0C0C0C0C0C0C0C0C0CCC0CCC0CCC07F8033000000000000000000000000000000000000000000C0C0E1C073803F00"
|
||||||
|
+ "1E001E003F007380E1C0C0C00000000000000000000000000000000000000000C0C0C0C0C0C0C0C0C0C0E0C07FC03FC000C000C000C001C03F803F00"
|
||||||
|
+ "000000000000000000000000FFC0FFC0030007000E001C0038007000FFC0FFC000000000000000000000000000C001C003800300030006000C000C00"
|
||||||
|
+ "060003000300038001C000C00000000000000000000000000C000C000C000C000C000C00000000000C000C000C000C000C000C000000000000000000"
|
||||||
|
+ "00000000300038001C000C000C0006000300030006000C000C001C003800300000000000000000000000000000C001C00F801F003800300000000000"
|
||||||
|
+ "000000000000000000000000000000000000000000000000FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF000000000");
|
||||||
|
|
||||||
|
// font 6: 7x10, chars 0x20-0xFF, 224 glyphs
|
||||||
|
private static readonly Face F6 = new(6, 0x20, 0xFF, 7, 10,
|
||||||
|
"000000000000000000000000000000000000000000001000100010001000000000001000000000000000280028002800000000000000000000000000"
|
||||||
|
+ "0000280028007C0028007C002800280000000000000010003C0050003800140078001000000000000000600064000800100020004C000C0000000000"
|
||||||
|
+ "000030004800500020005400480034000000000000003000100020000000000000000000000000000000080010002000200020001000080000000000"
|
||||||
|
+ "0000200010000800080008001000200000000000000000001000540038005400100000000000000000000000100010007C0010001000000000000000"
|
||||||
|
+ "000000000000000000000000300010002000000000000000000000007C00000000000000000000000000000000000000000000003000300000000000"
|
||||||
|
+ "00000000040008001000200040000000000000000000380044004C005400640044003800000000000000100030001000100010001000380000000000"
|
||||||
|
+ "00003800440004000800100020007C000000000000007C0008001000080004000400780000000000000008001800280048007C000800080000000000"
|
||||||
|
+ "00007C0040007800040004000400780000000000000018002000400078004400440038000000000000007C0004000800100020002000200000000000"
|
||||||
|
+ "000038004400440038004400440038000000000000003800440044003C00040008003000000000000000000030003000000030003000000000000000"
|
||||||
|
+ "000000003000300000003000100020000000000000000800100020004000200010000800000000000000000000007C0000007C000000000000000000"
|
||||||
|
+ "000040002000100008001000200040000000000000003800440004000800100000001000000000000000380044000400340054005400380000000000"
|
||||||
|
+ "000038004400440044007C00440044000000000000007800440044007800440044007800000000000000380044004000400040004400380000000000"
|
||||||
|
+ "000078004400440044004400440078000000000000007C00400040007800400040007C000000000000007C0040004000780040004000400000000000"
|
||||||
|
+ "00003800440040005C004400440038000000000000004400440044007C00440044004400000000000000380010001000100010001000380000000000"
|
||||||
|
+ "00001C0008000800080008004800300000000000000044004800500060005000480044000000000000004000400040004000400040007C0000000000"
|
||||||
|
+ "000044006C005400540044004400440000000000000044004400640054004C0044004400000000000000380044004400440044004400380000000000"
|
||||||
|
+ "000078004400440078004000400040000000000000003800440044004400540048003400000000000000780044004400780050004800440000000000"
|
||||||
|
+ "00003C004000400038000400040078000000000000007C00100010001000100010001000000000000000440044004400440044004400380000000000"
|
||||||
|
+ "000044004400440044004400280010000000000000004400440044005400540054002800000000000000440044002800100028004400440000000000"
|
||||||
|
+ "000044004400440028001000100010000000000000007C00040008001000200040007C00000000000000380020002000200020002000380000000000"
|
||||||
|
+ "000040002000100008000400000000000000000000003800080008000800080008003800000000000000100028004400000000000000000000000000"
|
||||||
|
+ "000000000000000000000000000000000000FE000000300020001000000000000000000000000000000000000000380004003C0044003C0000000000"
|
||||||
|
+ "0000400040005800640044004400780000000000000000000000380040004000400038000000000000000400040034004C00440044003C0000000000"
|
||||||
|
+ "000000000000380044007C00400038000000000000001800240020007000200020002000000000000000000000003C00440044003C00040004003800"
|
||||||
|
+ "000040004000580064004400440044000000000000001000000030001000100010003800000000000000080000001800080008000800080048003000"
|
||||||
|
+ "000040004000480050006000500048000000000000003000100010001000100010003800000000000000000000006800540054004400440000000000"
|
||||||
|
+ "000000000000580064004400440044000000000000000000000038004400440044003800000000000000000000005800640044007800400040004000"
|
||||||
|
+ "00000000000034004C0044003C0004000400040000000000000058006400400040004000000000000000000000003C00400038000400780000000000"
|
||||||
|
+ "000000002000F8002000200028001000000000000000000000004400440044004C003400000000000000000000004400440044002800100000000000"
|
||||||
|
+ "000000000000440044005400540028000000000000000000000044002800100028004400000000000000000000004400440044003C00040004003800"
|
||||||
|
+ "0000000000007C000800100020007C000000000000000800100010002000100010000800000000000000100010001000000010001000100000000000"
|
||||||
|
+ "00002000100010000800100010002000000000000000040038004000000000000000000000000000FE00FE00FE00FE00FE00FE00FE00FE00FE00FE00"
|
||||||
|
+ "0000000000000000FE000000000000000000000008000800080008000800080008000800080008000800080008000800F80000000000000000000000"
|
||||||
|
+ "00000000000000000E000800080008000800080008000800080008000E00000000000000000000000000000000000000F80008000800080008000800"
|
||||||
|
+ "08000800080008000E00080008000800080008000800080008000800F800080008000800080008000800080008000800FE0008000800080008000800"
|
||||||
|
+ "0800080008000800FE00000000000000000000000000000000000000FE00080008000800080008000000000000000000020004000800080008000800"
|
||||||
|
+ "0000000000000000E000100008000800080008000800080008001000E000000000000000000000000800080008000400020000000000000000000000"
|
||||||
|
+ "1C001C001C00FE00FE00FE001C001C001C001C00000000000000FE00FE00FE0000000000000000001C001C001C001C001C001C001C001C001C001C00"
|
||||||
|
+ "1C001C001C00FC00FC00FC0000000000000000000000000000001E001E001E001C001C001C001C001C001C001C001E001E001E000000000000000000"
|
||||||
|
+ "000000000000FC00FC00FC001C001C001C001C001C001C001C001E001E001E001C001C001C001C001C001C001C00FC00FC00FC001C001C001C001C00"
|
||||||
|
+ "000000001000380054001000100010001000000000000000000020001000F8001000200000000000000000000000080010003E001000080000000000"
|
||||||
|
+ "00000000000008000800F8000000000000000000000008000800080008002A001C0008000000000000000000080014002200140014001C0000000000"
|
||||||
|
+ "0000000008001C003E001C001C001C00000000000000000010003E007E003E0010000000000000000000000054004400820082008200440038000000"
|
||||||
|
+ "000000000000000000007000500070000000000000001C00100010001000000000000000000000000000000000000000100010001000700000000000"
|
||||||
|
+ "00000000000000000000400020001000000000000000000000000000300030000000000000000000000000007C0004007C0004000800100000000000"
|
||||||
|
+ "0000000000007C00040018001000200000000000000000000000080010003000500010000000000000000000000010007C0044000400180000000000"
|
||||||
|
+ "00000000000000007C00100010007C000000000000000000000008007C001800280048000000000000000000000020007C0024002800200000000000"
|
||||||
|
+ "00000000000000003800080008007C000000000000000000000078000800780008007800000000000000000000000000540054000400180000000000"
|
||||||
|
+ "0000000000007C0000000000000000000000000000007C00040014001800100010002000000000000000040008001000300050001000100000000000"
|
||||||
|
+ "000010007C004400440004000800100000000000000000007C0010001000100010007C0000000000000008007C000800180028004800080000000000"
|
||||||
|
+ "000020007C002400240024002400480000000000000010007C0010007C0010001000100000000000000000003C002400440004000800300000000000"
|
||||||
|
+ "000020003C004800080008000800100000000000000000007C0004000400040004007C0000000000000028007C002800280008001000200000000000"
|
||||||
|
+ "0000000060000400640004000800700000000000000000007C000400080010002800440000000000000020007C0024002800200020001C0000000000"
|
||||||
|
+ "0000000044004400240004000800300000000000000000003C00240054000C00080030000000000000000800700010007C0010001000200000000000"
|
||||||
|
+ "00000000540054005400040008001000000000000000380000007C001000100010002000000000000000200020002000300028002000200000000000"
|
||||||
|
+ "0000100010007C0010001000200040000000000000000000380000000000000000007C0000000000000000007C000400280010002800400000000000"
|
||||||
|
+ "000010007C00080010003800540010000000000000000800080008000800080010002000000000000000000010000800440044004400440000000000"
|
||||||
|
+ "0000400040007C004000400040003C0000000000000000007C0004000400040008003000000000000000000020005000080004000400000000000000"
|
||||||
|
+ "000010007C001000100054005400100000000000000000007C0004000400280010000800000000000000000038000000380000003800040000000000"
|
||||||
|
+ "0000000010002000400044007C000400000000000000000004000400280010002800400000000000000000007C0020007C00200020001C0000000000"
|
||||||
|
+ "0000200020007C0024002800200020000000000000000000380008000800080008007C0000000000000000007C0004007C00040004007C0000000000"
|
||||||
|
+ "0000380000007C0004000400080010000000000000004800480048004800080010002000000000000000100050005000500054005400180000000000"
|
||||||
|
+ "0000400040004000440048005000600000000000000000007C0044004400440044007C0000000000000000007C004400440004000800100000000000"
|
||||||
|
+ "000000006000000004000400080070000000000000001000480020000000000000000000000000000000700050007000000000000000000000000000"
|
||||||
|
+ "0000100000001000200040004400380000000000040008003800440044007C004400440000000000400020003800440044007C004400440000000000"
|
||||||
|
+ "280000003800440044007C0044004400000000000000380044004000400040004400380010003000040008007C0040007800400040007C0000000000"
|
||||||
|
+ "400020007C0040007800400040007C000000000038004400380010001000100010003800000000002800000038004400440044004400380000000000"
|
||||||
|
+ "2800000044004400440044004400380000000000040008000000380004003C0044003C0000000000380044000000380004003C0044003C0000000000"
|
||||||
|
+ "400020000000380004003C0044003C0000000000000028000000380004003C0044003C00000000000000000000003800400040004400380010003000"
|
||||||
|
+ "040008000000380044007C004000380000000000380044000000380044007C004000380000000000400020000000380044007C004000380000000000"
|
||||||
|
+ "040008002000000060002000200070000000000038004400100000003000100010003800000000008000400010000000300010001000380000000000"
|
||||||
|
+ "280000001000000030001000100038000000000034005C00000058006400440044004400000000000400080000003800440044004400380000000000"
|
||||||
|
+ "380044000000380044004400440038000000000040002000000038004400440044003800000000000000280000003800440044004400380000000000"
|
||||||
|
+ "0000380044005800440044006400D800000000000800100000004400440044004C003400000000003800440000004400440044004C00340000000000"
|
||||||
|
+ "4000200000004400440044004C003400000000000000280000004400440044004C00340000000000");
|
||||||
|
|
||||||
|
// font 7: 7x10, chars 0x40-0x7F, 64 glyphs
|
||||||
|
private static readonly Face F7 = new(7, 0x40, 0x7F, 7, 10,
|
||||||
|
"00000800100020001000080000007C000000000000002000100008001000200000007C00000000000000100010007C001000100000007C0000000000"
|
||||||
|
+ "00000000100000007C000000100000000000000000001000280028002800440044007C00000000000000040008007C0010007C002000400000000000"
|
||||||
|
+ "100038005400540054005400540038001000000000007C0020002000200020002000700000000000000000007C0000007C0000007C00000000000000"
|
||||||
|
+ "0000040038004000040038004000000000000000000004003800400000007C00000000000000000000000400040004004800A8002800100010000000"
|
||||||
|
+ "0000100028002800280044004400440000000000000038004400040034004C0044004800300000000000000000004400280010002800440000000000"
|
||||||
|
+ "00000000100038007C0038001000000000000000000010003800540050005400380010000000000000007C0044004400440044004400440000000000"
|
||||||
|
+ "0000380044004400540044004400380000000000000000000000280054002800000000000000000000007C00200010000800100020007C0000000000"
|
||||||
|
+ "00001800240024001800000000000000000000000000380044009200BA0092004400380000000000000038004400AA009200AA004400380000000000"
|
||||||
|
+ "00000000380044004400440028006C000000000000007C00000000003800000000007C00000000000000540054005400380010001000100000000000"
|
||||||
|
+ "00003A0044008A009200A2004400B800000000000000380044009A00A2009A00440038000000000000007A002E002A002A0000000000000000000000"
|
||||||
|
+ "08001C002A00080008000800080008000800080008000800080008000800080008002A001C0008000000180024002000780020002400580000000000"
|
||||||
|
+ "000000000000340048004800340000000000000000003800440044007800440064004C00400000000000000040004400280010002800440004000000"
|
||||||
|
+ "000038004400200010002800440044003800000000000000000038004400300044003800000000000000000010003800540054005400380010000000"
|
||||||
|
+ "000000000000440044002800280038001000100000000000000058006400440044004400040000000000000000000000300010001000180000000000"
|
||||||
|
+ "000000000000080054005400540038001000000000000000000024002800300028002400000000000000600020001000100028004400440000000000"
|
||||||
|
+ "000000000000440044006400580040004000000000000000000044004400280028001000000000000000000000003800440044004400380000000000"
|
||||||
|
+ "00004400280010007C0010007C001000000000000000000000007C0028002800280024000000000000003800440044007C0044004400380000000000"
|
||||||
|
+ "00000000000038004400640058004000400000000000000000003C004800440044003800000000000000000004003800500010001000080000000000"
|
||||||
|
+ "00000000000040002800240024001800000000000000000000007C002800440054002800000000000000000000000000280044005400280000000000"
|
||||||
|
+ "00002000180020001800200020001C0004000000000000001000540054003800100010000000000000002000180020002000200020001C0004000000"
|
||||||
|
+ "00000000080004007E00040008000000000000000000000020004000FE00400020000000000000000000000028004400FE0044002800000000000000"
|
||||||
|
+ "08001C002A0008000800080008002A001C000800");
|
||||||
|
|
||||||
|
/// <summary>All 8 faces, indexed 0-7 (matches ESC K).</summary>
|
||||||
|
public static readonly Face[] All = { F0, F1, F2, F3, F4, F5, F6, F7 };
|
||||||
|
|
||||||
|
/// <summary>The default/power-on font (font 0: 6x8, full 0x20-0xFF range).</summary>
|
||||||
|
public static Face Default => F0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// Attributes are the low 4 bits: 1=half, 2=underline, 4=reverse, 8=flash.
|
||||||
|
Esc(b, PlasmaProtocol.CmdAttributes, 1); Text(b, "HALF ");
|
||||||
|
Esc(b, PlasmaProtocol.CmdAttributes, 2); Text(b, "UNDER ");
|
||||||
|
Esc(b, PlasmaProtocol.CmdAttributes, 4); Text(b, "REV");
|
||||||
|
Esc(b, PlasmaProtocol.CmdAttributes, 8); 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
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>, low 4 bits).</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>Grounded in the real firmware (<c>PlasmaNew/FIRMWARE.md</c>): the
|
||||||
|
/// cursor is a <b>pixel</b> position — <c>ESC Q</c> sets its row (Y, 0–31),
|
||||||
|
/// <c>ESC R</c> its column (X, 0–127) — and glyphs are drawn there and advance
|
||||||
|
/// X by the font's width. The eight fonts are the real ROM character generator
|
||||||
|
/// (<see cref="PlasmaFonts"/>): 6×8, 6×10, 7×10, and the large 12×16 / 12×20,
|
||||||
|
/// selected with <c>ESC K</c> (0–7). Attributes are the low 4 bits of the
|
||||||
|
/// <c>ESC H</c> operand.</para>
|
||||||
|
///
|
||||||
|
/// <para>Pixels carry flags rather than a plain bit: graphics writes set
|
||||||
|
/// full-intensity dots, while text can stamp half-intensity or flashing dots;
|
||||||
|
/// the UI renders <see cref="PixelHalf"/> dimmer and blinks
|
||||||
|
/// <see cref="PixelFlash"/>. The command set is documented on
|
||||||
|
/// <see cref="PlasmaProtocol"/>.</para>
|
||||||
|
///
|
||||||
|
/// <para>Not yet folded in from the firmware (documented, deferred): the 10
|
||||||
|
/// double-buffered pages (<c>ESC I</c>/<c>ESC i</c>) and the vector-graphics
|
||||||
|
/// primitives (<c>ESC A</c>–<c>F</c>). vPLASMA models a single page.</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 PlasmaFonts.Face _face = PlasmaFonts.Default;
|
||||||
|
private PlasmaAttributes _attributes;
|
||||||
|
private int _x, _y; // cursor, in pixels (0..127, 0..31)
|
||||||
|
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; } }
|
||||||
|
|
||||||
|
/// <summary>Cursor column X in pixels (0–127).</summary>
|
||||||
|
public int CursorX { get { lock (_sync) return _x; } }
|
||||||
|
/// <summary>Cursor row Y in pixels (0–31).</summary>
|
||||||
|
public int CursorY { get { lock (_sync) return _y; } }
|
||||||
|
/// <summary>Current font cell width in pixels.</summary>
|
||||||
|
public int FontWidth { get { lock (_sync) return _face.Width; } }
|
||||||
|
/// <summary>Current font cell height in pixels.</summary>
|
||||||
|
public int FontHeight { get { lock (_sync) return _face.Height; } }
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
_x = _y = 0;
|
||||||
|
_font = 0;
|
||||||
|
_face = PlasmaFonts.Default;
|
||||||
|
_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:
|
||||||
|
_x = Math.Max(0, _x - _face.Width);
|
||||||
|
_dirty = true;
|
||||||
|
return;
|
||||||
|
|
||||||
|
case PlasmaProtocol.HorizontalTab:
|
||||||
|
AdvanceCursor();
|
||||||
|
_dirty = true;
|
||||||
|
return;
|
||||||
|
|
||||||
|
case PlasmaProtocol.LineFeed:
|
||||||
|
NextLine();
|
||||||
|
_dirty = true;
|
||||||
|
return;
|
||||||
|
|
||||||
|
case PlasmaProtocol.VerticalTab:
|
||||||
|
_y -= _face.Height;
|
||||||
|
if (_y < 0) _y = Math.Max(0, Height - _face.Height);
|
||||||
|
_dirty = true;
|
||||||
|
return;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CarriageReturn:
|
||||||
|
_x = 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);
|
||||||
|
_x = _y = 0;
|
||||||
|
_font = 0;
|
||||||
|
_face = PlasmaFonts.Default;
|
||||||
|
_attributes = PlasmaAttributes.None;
|
||||||
|
_dirty = true;
|
||||||
|
Log("Clear screen (ESC @)");
|
||||||
|
_graphicsLogArmed = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdHomeCursor:
|
||||||
|
_x = _y = 0;
|
||||||
|
_dirty = true;
|
||||||
|
Log("Home cursor (ESC L)");
|
||||||
|
_graphicsLogArmed = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdCursorMode:
|
||||||
|
case PlasmaProtocol.CmdFontSelect:
|
||||||
|
case PlasmaProtocol.CmdAttributes:
|
||||||
|
case PlasmaProtocol.CmdSetRow:
|
||||||
|
case PlasmaProtocol.CmdSetColumn:
|
||||||
|
_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:
|
||||||
|
// Firmware: operands 0–7 select a real font (8 fonts); it range-
|
||||||
|
// checks and ignores anything larger, so ESC K FF is a no-op.
|
||||||
|
if (operand < PlasmaFonts.All.Length)
|
||||||
|
{
|
||||||
|
_font = operand;
|
||||||
|
_face = PlasmaFonts.All[operand];
|
||||||
|
_x = Math.Min(_x, Width - 1);
|
||||||
|
_y = Math.Min(_y, Height - 1);
|
||||||
|
_dirty = true;
|
||||||
|
Log($"Font {_font}: {_face.Width}×{_face.Height} (ESC K {operand:X2})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log($"Font select ignored (ESC K {operand:X2} out of range)");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdAttributes:
|
||||||
|
// Firmware stores the low 4 bits directly as flags.
|
||||||
|
_attributes = (PlasmaAttributes)(operand & 0x0F);
|
||||||
|
Log($"Attributes {(_attributes == PlasmaAttributes.None ? "default" : _attributes.ToString())} (ESC H {operand:X2})");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdSetRow:
|
||||||
|
// ESC Q: set cursor row Y in pixels; firmware range-checks 0–31.
|
||||||
|
if (operand < Height)
|
||||||
|
{
|
||||||
|
_y = operand;
|
||||||
|
_dirty = true;
|
||||||
|
Log($"Cursor row Y={operand} (ESC Q {operand:X2})");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdSetColumn:
|
||||||
|
// ESC R: set cursor column X in pixels; firmware range-checks 0–127.
|
||||||
|
if (operand < Width)
|
||||||
|
{
|
||||||
|
_x = operand;
|
||||||
|
_dirty = true;
|
||||||
|
Log($"Cursor col X={operand} (ESC R {operand:X2})");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_graphicsLogArmed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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)
|
||||||
|
{
|
||||||
|
// The firmware ignores a character outside the current font's range.
|
||||||
|
if (!_face.Has(code))
|
||||||
|
return;
|
||||||
|
|
||||||
|
int w = _face.Width, h = _face.Height;
|
||||||
|
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 row = 0; row < h; ++row)
|
||||||
|
{
|
||||||
|
int py = _y + row;
|
||||||
|
if ((uint)py >= Height)
|
||||||
|
continue;
|
||||||
|
ushort bits = _face.Row(code, row); // bit 15 = leftmost pixel
|
||||||
|
int rowOffset = py * Width;
|
||||||
|
for (int col = 0; col < w; ++col)
|
||||||
|
{
|
||||||
|
int px = _x + col;
|
||||||
|
if ((uint)px >= Width)
|
||||||
|
continue;
|
||||||
|
bool on = (bits & (0x8000 >> col)) != 0;
|
||||||
|
if (underline && row == h - 1)
|
||||||
|
on = true;
|
||||||
|
if (reverse)
|
||||||
|
on = !on;
|
||||||
|
_pixels[rowOffset + px] = on ? litFlags : (byte)0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_textCharsDrawn++;
|
||||||
|
_dirty = true;
|
||||||
|
AdvanceCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Advance the cursor one cell, wrapping at the right/bottom edges.</summary>
|
||||||
|
private void AdvanceCursor()
|
||||||
|
{
|
||||||
|
_x += _face.Width;
|
||||||
|
if (_x > Width - _face.Width)
|
||||||
|
NextLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NextLine()
|
||||||
|
{
|
||||||
|
_x = 0;
|
||||||
|
_y += _face.Height;
|
||||||
|
// No scroll on these panels: past the last line wraps to the top.
|
||||||
|
if (_y > Height - _face.Height)
|
||||||
|
_y = 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,240 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
|
||||||
|
namespace VPlasma.Core.Device;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves a <see cref="VPlasmaDevice"/> over the named pipe that the
|
||||||
|
/// DOSBox-X fork's <c>serial2=namedpipe pipe:vplasma</c> backend connects
|
||||||
|
/// to — the com0com-free transport. We are the pipe <em>server</em> (the
|
||||||
|
/// display is always present); DOSBox-X is the client and background-retries
|
||||||
|
/// while the pipe is missing, so this service listens for the whole app
|
||||||
|
/// lifetime alongside the COM path.
|
||||||
|
///
|
||||||
|
/// <para>Framing is the contract shared with the fork (see VRio.Core's
|
||||||
|
/// <c>PipeFraming</c>, and <c>serialnamedpipe.h</c> on the fork side):
|
||||||
|
/// <c>0x00 len bytes</c> carries serial data, <c>0x01 bits</c> carries the
|
||||||
|
/// sender's DTR/RTS. Like the serial twin, the plasma is a pure listener —
|
||||||
|
/// we send one lines frame on connect (DTR+RTS high, "display present") and
|
||||||
|
/// then only ever read. The game may toggle its own lines around writes
|
||||||
|
/// (DOS console redirection flips RTS per byte); the display doesn't care,
|
||||||
|
/// so inbound lines frames are consumed silently.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VPlasmaPipeService : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>The contract pipe name: DOSBox-X connects to <c>\\.\pipe\vplasma</c>.</summary>
|
||||||
|
public const string DefaultPipeName = "vplasma";
|
||||||
|
|
||||||
|
// Frame types and line bits, per the shared contract.
|
||||||
|
private const byte DataType = 0x00;
|
||||||
|
private const byte LinesType = 0x01;
|
||||||
|
private const byte LinesPresent = 0x03; // DTR + RTS
|
||||||
|
|
||||||
|
private readonly VPlasmaDevice _device;
|
||||||
|
private readonly string _pipeName;
|
||||||
|
|
||||||
|
private NamedPipeServerStream? _pipe;
|
||||||
|
private Thread? _server;
|
||||||
|
private volatile bool _running;
|
||||||
|
private volatile bool _clientConnected;
|
||||||
|
|
||||||
|
public VPlasmaPipeService(VPlasmaDevice device, string pipeName = DefaultPipeName)
|
||||||
|
{
|
||||||
|
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||||
|
if (string.IsNullOrWhiteSpace(pipeName))
|
||||||
|
throw new ArgumentException("Pipe name is required.", nameof(pipeName));
|
||||||
|
_pipeName = pipeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
|
||||||
|
public string PipeName => _pipeName;
|
||||||
|
|
||||||
|
/// <summary>True while the server is up (whether or not a client is connected).</summary>
|
||||||
|
public bool IsListening => _running;
|
||||||
|
|
||||||
|
/// <summary>True while a client is connected.</summary>
|
||||||
|
public bool IsClientConnected => _clientConnected;
|
||||||
|
|
||||||
|
/// <summary>A client connected (true) or went away (false).</summary>
|
||||||
|
public event Action<bool>? ClientChanged;
|
||||||
|
|
||||||
|
/// <summary>Pipe-level log lines (listen/connect/errors).</summary>
|
||||||
|
public event Action<string>? Logged;
|
||||||
|
|
||||||
|
/// <summary>Start listening (idempotent). Clients may come and go forever.</summary>
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_running = true;
|
||||||
|
_server = new Thread(ServerLoop) { IsBackground = true, Name = $"vPLASMA pipe server ({_pipeName})" };
|
||||||
|
_server.Start();
|
||||||
|
|
||||||
|
Logged?.Invoke($@"Listening on \\.\pipe\{_pipeName} — DOSBox-X connects when it boots");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stop listening and drop any client (idempotent).</summary>
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (!_running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_running = false;
|
||||||
|
NamedPipeServerStream? pipe = _pipe;
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe?.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
|
||||||
|
// A WaitForConnection pending on the disposed stream can survive the
|
||||||
|
// Dispose on net48; a throwaway client connect releases it either way.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var poke = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out);
|
||||||
|
poke.Connect(100);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) { }
|
||||||
|
|
||||||
|
_server?.Join(1000);
|
||||||
|
_server = null;
|
||||||
|
_clientConnected = false;
|
||||||
|
|
||||||
|
Logged?.Invoke("Pipe server stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ServerLoop()
|
||||||
|
{
|
||||||
|
bool busyLogged = false; // log the name collision once, not per retry
|
||||||
|
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
NamedPipeServerStream pipe;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
|
||||||
|
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Name already served — e.g. vRIO's built-in glass and the
|
||||||
|
// standalone vPLASMA both running. Whoever loses just waits.
|
||||||
|
if (!busyLogged)
|
||||||
|
{
|
||||||
|
busyLogged = true;
|
||||||
|
Logged?.Invoke($@"\\.\pipe\{_pipeName} is busy ({ex.Message.TrimEnd('.')}) — retrying until it frees up");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 20 && _running; i++)
|
||||||
|
Thread.Sleep(100);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
busyLogged = false;
|
||||||
|
|
||||||
|
_pipe = pipe;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe.WaitForConnection();
|
||||||
|
|
||||||
|
if (_running)
|
||||||
|
{
|
||||||
|
// Our lines frame goes first: display present.
|
||||||
|
pipe.Write(new byte[] { LinesType, LinesPresent }, 0, 2);
|
||||||
|
pipe.Flush();
|
||||||
|
|
||||||
|
_clientConnected = true;
|
||||||
|
Logged?.Invoke("Pipe client connected — sent lines DTR+RTS (display present)");
|
||||||
|
ClientChanged?.Invoke(true);
|
||||||
|
|
||||||
|
ReadUntilDisconnect(pipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Disposed by Stop(), or a client vanished mid-connect.
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wasConnected = _clientConnected;
|
||||||
|
_clientConnected = false;
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
|
||||||
|
if (wasConnected && _running)
|
||||||
|
{
|
||||||
|
Logged?.Invoke("Pipe client disconnected");
|
||||||
|
ClientChanged?.Invoke(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReadUntilDisconnect(NamedPipeServerStream pipe)
|
||||||
|
{
|
||||||
|
// Inline deframer (the listener-only subset of VRio.Core's
|
||||||
|
// PipeFrameDecoder): data payloads go to the device, lines frames are
|
||||||
|
// consumed silently, anything else is a protocol bug — log and drop
|
||||||
|
// the connection rather than resync.
|
||||||
|
const int StateType = 0, StateLength = 1, StatePayload = 2, StateLines = 3;
|
||||||
|
int state = StateType, remaining = 0;
|
||||||
|
|
||||||
|
var buffer = new byte[512];
|
||||||
|
var payload = new byte[byte.MaxValue];
|
||||||
|
int fill = 0;
|
||||||
|
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
int n;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
n = pipe.Read(buffer, 0, buffer.Length);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
if (_running)
|
||||||
|
Logged?.Invoke($"Pipe error: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n == 0)
|
||||||
|
return; // client closed its end
|
||||||
|
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
byte b = buffer[i];
|
||||||
|
switch (state)
|
||||||
|
{
|
||||||
|
case StateType when b == DataType:
|
||||||
|
state = StateLength;
|
||||||
|
break;
|
||||||
|
case StateType when b == LinesType:
|
||||||
|
state = StateLines;
|
||||||
|
break;
|
||||||
|
case StateType:
|
||||||
|
Logged?.Invoke($"Pipe protocol violation: unknown frame type 0x{b:X2} — dropping the connection");
|
||||||
|
return;
|
||||||
|
|
||||||
|
case StateLength when b == 0:
|
||||||
|
Logged?.Invoke("Pipe protocol violation: zero-length data frame — dropping the connection");
|
||||||
|
return;
|
||||||
|
case StateLength:
|
||||||
|
remaining = b;
|
||||||
|
fill = 0;
|
||||||
|
state = StatePayload;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case StatePayload:
|
||||||
|
payload[fill++] = b;
|
||||||
|
if (fill == remaining)
|
||||||
|
{
|
||||||
|
state = StateType;
|
||||||
|
_device.OnReceived(payload, remaining);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case StateLines:
|
||||||
|
state = StateType; // the display ignores host line state
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Stop();
|
||||||
|
}
|
||||||
@@ -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,69 @@
|
|||||||
|
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';
|
||||||
|
// Cursor positioning, confirmed from the firmware dump (FIRMWARE.md): the
|
||||||
|
// cursor is a pixel position, ESC Q sets its row (Y, 0-31) and ESC R its
|
||||||
|
// column (X, 0-127) — the exact range checks the firmware enforces.
|
||||||
|
public const byte CmdSetRow = (byte)'Q';
|
||||||
|
public const byte CmdSetColumn = (byte)'R';
|
||||||
|
|
||||||
|
/// <summary>Operand meaning "restore the default" for ESC K / ESC H.</summary>
|
||||||
|
public const byte OperandDefault = 0xFF;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<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="PolySharp" Version="1.14.1">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,70 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO.Ports;
|
using System.IO.Ports;
|
||||||
|
using VPlasma.App;
|
||||||
|
using VPlasma.Core.Device;
|
||||||
using VRio.Core.Device;
|
using VRio.Core.Device;
|
||||||
|
using VRio.Core.Input;
|
||||||
|
|
||||||
namespace VRio.App;
|
namespace VRio.App;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// vRIO main window: the interactive cockpit panel on the left (the same
|
/// vRIO main window: the interactive cockpit panel on the left (the same
|
||||||
/// functional map RIOJoy's profile editor shows) and a control strip on the
|
/// functional map RIOJoy's profile editor shows) and a control strip on the
|
||||||
/// right — COM port, device settings, and a live wire log. Open the COM port,
|
/// right — connection rows, device settings, and a live wire log. The
|
||||||
/// point RIOJoy at the other end of the null-modem pair, and every click here
|
/// panel's encoder strip also hosts the built-in plasma glass (the vPLASMA
|
||||||
/// arrives at RIOJoy exactly as if the physical cockpit sent it.
|
/// display emulator) on its own connection row. Each row's picker offers
|
||||||
|
/// the device's named-pipe endpoint (the DOSBox-X fork's namedpipe backend
|
||||||
|
/// — the com0com-free path) alongside the machine's COM ports; nothing
|
||||||
|
/// opens automatically — pick an endpoint and Open. Point RIOJoy at the
|
||||||
|
/// other end of a null-modem pair and every click here arrives exactly as
|
||||||
|
/// if the physical cockpit sent it; point the game at
|
||||||
|
/// <see cref="RioPipeEndpoint"/>/<see cref="PlasmaPipeEndpoint"/> (or the
|
||||||
|
/// COM2 passthrough at the plasma pair) and the glass lights up.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class MainForm : Form
|
internal sealed class MainForm : Form
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The RIO's pipe endpoint as listed in the picker. Matches the
|
||||||
|
/// DOSBox-X conf syntax: <c>serial1=namedpipe pipe:vrio</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string RioPipeEndpoint = $"pipe:{VRioPipeService.DefaultPipeName}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The plasma's pipe endpoint as listed in the picker
|
||||||
|
/// (<c>serial2=namedpipe pipe:vplasma</c>).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string PlasmaPipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
|
||||||
|
|
||||||
private readonly VRioDevice _device = new();
|
private readonly VRioDevice _device = new();
|
||||||
private readonly VRioSerialService _service;
|
private readonly VRioSerialService _service;
|
||||||
|
private readonly VRioPipeService _pipeService;
|
||||||
|
private readonly VPlasmaDevice _plasmaDevice = new();
|
||||||
|
private readonly VPlasmaSerialService _plasmaService;
|
||||||
|
private readonly VPlasmaPipeService _plasmaPipeService;
|
||||||
private readonly PanelCanvas _canvas = new();
|
private readonly PanelCanvas _canvas = new();
|
||||||
|
private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch)
|
||||||
|
{
|
||||||
|
Location = PanelCanvas.PlasmaSlot,
|
||||||
|
};
|
||||||
|
private int _selfTestPage;
|
||||||
|
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");
|
||||||
|
|
||||||
|
// Port rows: one line per built-in device — a name label whose colour is
|
||||||
|
// the port status (green = open, gray = closed), the port picker, and an
|
||||||
|
// Open/Close button. The one Rescan button refreshes both pickers.
|
||||||
|
private readonly Label _rioLabel = new()
|
||||||
|
{
|
||||||
|
Text = "vRIO",
|
||||||
|
Location = new Point(12, 15),
|
||||||
|
AutoSize = true,
|
||||||
|
ForeColor = Color.Gray,
|
||||||
|
};
|
||||||
private readonly ComboBox _portBox = new()
|
private readonly ComboBox _portBox = new()
|
||||||
{
|
{
|
||||||
Location = new Point(80, 12),
|
Location = new Point(80, 12),
|
||||||
@@ -25,52 +73,93 @@ internal sealed class MainForm : Form
|
|||||||
};
|
};
|
||||||
private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 };
|
private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 };
|
||||||
private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 };
|
private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 };
|
||||||
private readonly Label _linkStatus = new()
|
private readonly Label _plasmaLabel = new()
|
||||||
{
|
{
|
||||||
Text = "Port closed.",
|
Text = "vPLASMA",
|
||||||
Location = new Point(12, 42),
|
Location = new Point(12, 45),
|
||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
ForeColor = Color.Gray,
|
ForeColor = Color.Gray,
|
||||||
};
|
};
|
||||||
|
private readonly ComboBox _plasmaPortBox = new()
|
||||||
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()
|
|
||||||
{
|
{
|
||||||
Text = "Emulate the v4.2 reply-wedge bug",
|
Location = new Point(80, 42),
|
||||||
Location = new Point(10, 148),
|
Width = 128,
|
||||||
|
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||||
|
};
|
||||||
|
private readonly Button _plasmaOpenClose = new() { Text = "Open", Location = new Point(272, 41), Width = 46 };
|
||||||
|
|
||||||
|
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 = "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,
|
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()
|
private readonly Label _counters = new()
|
||||||
{
|
{
|
||||||
Location = new Point(12, 290),
|
Location = new Point(12, 372),
|
||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
Font = new Font("Consolas", 8f),
|
Font = new Font("Consolas", 8f),
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly Label _help = new()
|
private readonly Label _help = new()
|
||||||
{
|
{
|
||||||
Location = new Point(12, 348),
|
Location = new Point(12, 428),
|
||||||
MaximumSize = new Size(306, 0),
|
MaximumSize = new Size(306, 0),
|
||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
ForeColor = Color.Gray,
|
ForeColor = Color.Gray,
|
||||||
Text = "Left-click a cell: momentary press. Right-click: latch it down. " +
|
Text = "Left-click a cell: momentary press. Right-click: latch it down. " +
|
||||||
"Drag the X/Y box and the Z / L / R gauges to move the axes.",
|
"Drag the X/Y box and the Z / L / R gauges to move the axes. " +
|
||||||
|
"Double-click the plasma glass to cycle its self-test pages; right-click it to reset.",
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly TextBox _logBox = new()
|
private readonly TextBox _logBox = new()
|
||||||
{
|
{
|
||||||
Location = new Point(12, 428),
|
Location = new Point(12, 498),
|
||||||
Multiline = true,
|
Multiline = true,
|
||||||
ReadOnly = true,
|
ReadOnly = true,
|
||||||
ScrollBars = ScrollBars.Vertical,
|
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
|
||||||
BackColor = Color.FromArgb(24, 24, 24),
|
BackColor = Color.FromArgb(24, 24, 24),
|
||||||
ForeColor = Color.Gainsboro,
|
ForeColor = Color.Gainsboro,
|
||||||
Font = new Font("Consolas", 8f),
|
Font = new Font("Consolas", 8f),
|
||||||
@@ -85,21 +174,36 @@ internal sealed class MainForm : Form
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 };
|
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 readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||||
|
private double _lastInputTick;
|
||||||
|
|
||||||
public MainForm()
|
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
|
// Fit the window to its content: the cockpit canvas plus the 330px
|
||||||
// control strip, with just enough height for the strip's log area.
|
// control strip, with just enough height for the strip's log area.
|
||||||
ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640));
|
ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640));
|
||||||
MinimumSize = new Size(1000, 620);
|
MinimumSize = new Size(1000, 620);
|
||||||
StartPosition = FormStartPosition.CenterScreen;
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
|
KeyPreview = true; // form-level key routing for the input bindings
|
||||||
|
|
||||||
_service = new VRioSerialService(_device);
|
_service = new VRioSerialService(_device);
|
||||||
|
_plasmaService = new VPlasmaSerialService(_plasmaDevice);
|
||||||
|
// The named-pipe endpoints for DOSBox-X's namedpipe serial backend,
|
||||||
|
// offered in the connection pickers alongside the COM ports.
|
||||||
|
_pipeService = new VRioPipeService(_device);
|
||||||
|
_plasmaPipeService = new VPlasmaPipeService(_plasmaDevice);
|
||||||
|
_router = new InputRouter(_device);
|
||||||
|
_lampMirror = new KeyboardLampMirror(_device);
|
||||||
|
|
||||||
// Panel canvas, scrolled if the window is smaller than the grid.
|
// Panel canvas, scrolled if the window is smaller than the grid. The
|
||||||
|
// plasma glass rides along as a child in the encoder strip's left slot.
|
||||||
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
||||||
|
_canvas.Controls.Add(_plasmaCanvas);
|
||||||
scroller.Controls.Add(_canvas);
|
scroller.Controls.Add(_canvas);
|
||||||
|
|
||||||
Controls.Add(scroller);
|
Controls.Add(scroller);
|
||||||
@@ -108,56 +212,120 @@ internal sealed class MainForm : Form
|
|||||||
// Canvas ↔ device wiring.
|
// Canvas ↔ device wiring.
|
||||||
_canvas.LampProvider = _device.GetLamp;
|
_canvas.LampProvider = _device.GetLamp;
|
||||||
_canvas.AxisProvider = _device.GetAxis;
|
_canvas.AxisProvider = _device.GetAxis;
|
||||||
|
_canvas.WireAxisProvider = _device.GetWireAxis;
|
||||||
_canvas.AddressPressed += _device.PressAddress;
|
_canvas.AddressPressed += _device.PressAddress;
|
||||||
_canvas.AddressReleased += _device.ReleaseAddress;
|
_canvas.AddressReleased += _device.ReleaseAddress;
|
||||||
_canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value);
|
_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 / service events arrive on worker threads; marshal to the UI.
|
||||||
_device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate);
|
_device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate);
|
||||||
_device.AxesChanged += () => 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));
|
_device.Logged += line => RunOnUi(() => PrependLog(line));
|
||||||
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
||||||
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
|
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
|
||||||
|
_service.ConnectionChanged += _ => RunOnUi(UpdateRioEndpointUi);
|
||||||
_service.HostHandshake += high => RunOnUi(() =>
|
_service.HostHandshake += high => RunOnUi(() =>
|
||||||
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
|
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
|
||||||
|
_pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}"));
|
||||||
|
_pipeService.HostHandshake += high => RunOnUi(() =>
|
||||||
|
PrependLog(high ? "Pipe host raised DTR (board-reset handshake)" : "Pipe host dropped DTR"));
|
||||||
|
_plasmaPipeService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA pipe: {line}"));
|
||||||
|
|
||||||
|
// Built-in plasma glass: a pure listener on its own port, same gestures
|
||||||
|
// as the standalone vPLASMA — double-click cycles the self-test pages,
|
||||||
|
// right-click resets the display to its power-on state.
|
||||||
|
_plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice));
|
||||||
|
_plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}"));
|
||||||
|
_plasmaService.ConnectionChanged += _ => RunOnUi(UpdatePlasmaEndpointUi);
|
||||||
|
_plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest();
|
||||||
|
_plasmaCanvas.MouseClick += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Button == MouseButtons.Right)
|
||||||
|
_plasmaDevice.Reset();
|
||||||
|
};
|
||||||
|
|
||||||
_rescan.Click += (_, _) => RefreshPorts();
|
_rescan.Click += (_, _) => RefreshPorts();
|
||||||
_openClose.Click += (_, _) => ToggleOpen();
|
_openClose.Click += (_, _) => ToggleOpen();
|
||||||
_verMajor.ValueChanged += (_, _) => _device.VersionMajor = (byte)_verMajor.Value;
|
_plasmaOpenClose.Click += (_, _) => TogglePlasmaOpen();
|
||||||
_verMinor.ValueChanged += (_, _) => _device.VersionMinor = (byte)_verMinor.Value;
|
|
||||||
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
|
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
|
||||||
_centerAxes.Click += (_, _) =>
|
_centerAxes.Click += (_, _) =>
|
||||||
{
|
{
|
||||||
foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis)))
|
foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis)))
|
||||||
_device.SetAxis(axis, 0);
|
_device.SetAxis(axis, 0);
|
||||||
|
_router.ResetAxisState();
|
||||||
};
|
};
|
||||||
_lampsOff.Click += (_, _) =>
|
_lampsOff.Click += (_, _) =>
|
||||||
{
|
{
|
||||||
_device.ClearLamps();
|
_device.ClearLamps();
|
||||||
_canvas.Invalidate();
|
_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();
|
_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.Tick += (_, _) => UpdateStatus();
|
||||||
_uiTimer.Start();
|
_uiTimer.Start();
|
||||||
|
|
||||||
|
_inputTimer.Tick += (_, _) => InputTick();
|
||||||
|
_inputTimer.Start();
|
||||||
|
|
||||||
FormClosed += (_, _) =>
|
FormClosed += (_, _) =>
|
||||||
{
|
{
|
||||||
_uiTimer.Dispose();
|
_uiTimer.Dispose();
|
||||||
|
_inputTimer.Dispose();
|
||||||
|
_lampMirror.Dispose();
|
||||||
|
_rawKeyboard.Dispose();
|
||||||
_service.Dispose();
|
_service.Dispose();
|
||||||
|
_plasmaService.Dispose();
|
||||||
|
_pipeService.Dispose();
|
||||||
|
_plasmaPipeService.Dispose();
|
||||||
};
|
};
|
||||||
|
|
||||||
RefreshPorts();
|
RefreshPorts();
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair.");
|
PrependLog($"vRIO ready. Pick an endpoint per device — {RioPipeEndpoint} / {PlasmaPipeEndpoint} " +
|
||||||
|
"for DOSBox-X, or a COM port for RIOJoy — and Open.");
|
||||||
|
LoadBindings();
|
||||||
|
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
|
||||||
}
|
}
|
||||||
|
|
||||||
private Panel BuildControlStrip()
|
private Panel BuildControlStrip()
|
||||||
@@ -175,23 +343,25 @@ internal sealed class MainForm : Form
|
|||||||
BorderStyle = BorderStyle.FixedSingle,
|
BorderStyle = BorderStyle.FixedSingle,
|
||||||
};
|
};
|
||||||
|
|
||||||
panel.Controls.Add(new Label { Text = "COM port:", Location = new Point(12, 15), AutoSize = true });
|
panel.Controls.Add(_rioLabel);
|
||||||
panel.Controls.Add(_portBox);
|
panel.Controls.Add(_portBox);
|
||||||
panel.Controls.Add(_rescan);
|
panel.Controls.Add(_rescan);
|
||||||
panel.Controls.Add(_openClose);
|
panel.Controls.Add(_openClose);
|
||||||
panel.Controls.Add(_linkStatus);
|
panel.Controls.Add(_plasmaLabel);
|
||||||
|
panel.Controls.Add(_plasmaPortBox);
|
||||||
|
panel.Controls.Add(_plasmaOpenClose);
|
||||||
|
|
||||||
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 210) };
|
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) };
|
||||||
device.Controls.Add(new Label { Text = "Firmware:", Location = new Point(10, 27), AutoSize = true });
|
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
|
||||||
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 });
|
|
||||||
panel.Controls.Add(device);
|
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(_counters);
|
||||||
panel.Controls.Add(_help);
|
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);
|
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
|
||||||
panel.Controls.Add(_logBox);
|
panel.Controls.Add(_logBox);
|
||||||
@@ -202,56 +372,330 @@ internal sealed class MainForm : Form
|
|||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Port handling -----------------------------------------------------
|
// ---- Endpoint handling ---------------------------------------------------
|
||||||
|
// Each device row serves one endpoint at a time — the RIO is a
|
||||||
|
// single-host device, so its pipe server and COM port never run together.
|
||||||
|
|
||||||
|
private bool RioOpen => _service.IsOpen || _pipeService.IsListening;
|
||||||
|
private bool PlasmaOpen => _plasmaService.IsOpen || _plasmaPipeService.IsListening;
|
||||||
|
|
||||||
private void RefreshPorts()
|
private void RefreshPorts()
|
||||||
{
|
{
|
||||||
string? current = _portBox.SelectedItem?.ToString();
|
string[] names = SerialPort.GetPortNames().Distinct()
|
||||||
_portBox.Items.Clear();
|
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
|
// An open row's picker is disabled and shows the served endpoint;
|
||||||
_portBox.Items.Add(name);
|
// leave it alone so the display can't drift from reality.
|
||||||
|
if (!RioOpen)
|
||||||
|
RefreshPicker(_portBox, RioPipeEndpoint, names);
|
||||||
|
if (!PlasmaOpen)
|
||||||
|
RefreshPicker(_plasmaPortBox, PlasmaPipeEndpoint, names);
|
||||||
|
}
|
||||||
|
|
||||||
if (_portBox.Items.Count == 0)
|
private static void RefreshPicker(ComboBox box, string pipeEndpoint, string[] portNames)
|
||||||
return;
|
{
|
||||||
int idx = current is null ? -1 : _portBox.Items.IndexOf(current);
|
string? current = box.SelectedItem?.ToString();
|
||||||
_portBox.SelectedIndex = idx >= 0 ? idx : 0;
|
box.Items.Clear();
|
||||||
|
box.Items.Add(pipeEndpoint); // always present — a pipe server needs no hardware
|
||||||
|
foreach (string name in portNames)
|
||||||
|
box.Items.Add(name);
|
||||||
|
|
||||||
|
int idx = current is null ? -1 : box.Items.IndexOf(current);
|
||||||
|
box.SelectedIndex = idx >= 0 ? idx : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleOpen()
|
private void ToggleOpen()
|
||||||
{
|
{
|
||||||
if (_service.IsOpen)
|
if (RioOpen)
|
||||||
{
|
{
|
||||||
_service.Close();
|
_service.Close(); // idempotent —
|
||||||
return;
|
_pipeService.Stop(); // whichever was serving closes
|
||||||
|
UpdateRioEndpointUi();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
if (_portBox.SelectedItem?.ToString() is not { } port)
|
|
||||||
{
|
{
|
||||||
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " +
|
OpenFromPicker(_portBox, OpenRioEndpoint);
|
||||||
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TogglePlasmaOpen()
|
||||||
|
{
|
||||||
|
if (PlasmaOpen)
|
||||||
|
{
|
||||||
|
_plasmaService.Close();
|
||||||
|
_plasmaPipeService.Stop();
|
||||||
|
UpdatePlasmaEndpointUi();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
OpenFromPicker(_plasmaPortBox, OpenPlasmaEndpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenRioEndpoint(string endpoint)
|
||||||
|
{
|
||||||
|
if (endpoint == RioPipeEndpoint)
|
||||||
|
_pipeService.Start();
|
||||||
|
else
|
||||||
|
_service.Open(endpoint);
|
||||||
|
UpdateRioEndpointUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenPlasmaEndpoint(string endpoint)
|
||||||
|
{
|
||||||
|
if (endpoint == PlasmaPipeEndpoint)
|
||||||
|
_plasmaPipeService.Start();
|
||||||
|
else
|
||||||
|
_plasmaService.Open(endpoint);
|
||||||
|
UpdatePlasmaEndpointUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenFromPicker(ComboBox box, Action<string> open)
|
||||||
|
{
|
||||||
|
if (box.SelectedItem?.ToString() is not { } endpoint)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "No endpoint selected. Pick the device's named pipe (for " +
|
||||||
|
"DOSBox-X's namedpipe backend) or a COM port (for RIOJoy through a com0com " +
|
||||||
|
"null-modem pair).", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_service.Open(port);
|
open(endpoint);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
MessageBox.Show(this, $"Could not open {port}:\n{ex.Message}", "vRIO",
|
MessageBox.Show(this, $"Could not open {endpoint}:\n{ex.Message}", "vRIO",
|
||||||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnConnectionChanged(bool open)
|
private void UpdateRioEndpointUi()
|
||||||
{
|
{
|
||||||
|
bool open = RioOpen;
|
||||||
_openClose.Text = open ? "Close" : "Open";
|
_openClose.Text = open ? "Close" : "Open";
|
||||||
_portBox.Enabled = _rescan.Enabled = !open;
|
_portBox.Enabled = !open;
|
||||||
_linkStatus.Text = open ? $"Serving {_service.PortName} @ 9600 8N1." : "Port closed.";
|
_rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
||||||
_linkStatus.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdatePlasmaEndpointUi()
|
||||||
|
{
|
||||||
|
bool open = PlasmaOpen;
|
||||||
|
_plasmaOpenClose.Text = open ? "Close" : "Open";
|
||||||
|
_plasmaPortBox.Enabled = !open;
|
||||||
|
_plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feed the next canned self-test page through the plasma's wire parser —
|
||||||
|
/// the same end-to-end exercise the standalone vPLASMA runs on double-click.
|
||||||
|
/// </summary>
|
||||||
|
private void RunPlasmaSelfTest()
|
||||||
|
{
|
||||||
|
byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
|
||||||
|
_plasmaDevice.OnReceived(bytes, bytes.Length);
|
||||||
|
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 ------------------------------------------------------
|
// ---- Status / log ------------------------------------------------------
|
||||||
|
|
||||||
private void UpdateStatus()
|
private void UpdateStatus()
|
||||||
@@ -264,12 +708,15 @@ internal sealed class MainForm : Form
|
|||||||
$"Analog polls dropped: {dropped}\n" +
|
$"Analog polls dropped: {dropped}\n" +
|
||||||
$"Bad checksums: {bad}";
|
$"Bad checksums: {bad}";
|
||||||
|
|
||||||
_canvas.StatusText = _service.IsOpen
|
string? endpoint = _service.IsOpen ? _service.PortName
|
||||||
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" +
|
: _pipeService.IsListening ? RioPipeEndpoint
|
||||||
|
: null;
|
||||||
|
_canvas.StatusText = endpoint is not null
|
||||||
|
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {endpoint}\n" +
|
||||||
(wedged
|
(wedged
|
||||||
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
|
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
|
||||||
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
|
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
|
||||||
: "PORT CLOSED\nOpen a COM port to go live.";
|
: "OFFLINE\nOpen an endpoint (named pipe or COM port) to go live.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PrependLog(string line)
|
private void PrependLog(string line)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using VPlasma.App;
|
||||||
using VRio.Core.Device;
|
using VRio.Core.Device;
|
||||||
using VRio.Core.Panel;
|
using VRio.Core.Panel;
|
||||||
using VRio.Core.Protocol;
|
using VRio.Core.Protocol;
|
||||||
@@ -16,6 +17,10 @@ namespace VRio.App;
|
|||||||
/// fill toward full travel (negative on the wire for the throttle, positive
|
/// fill toward full travel (negative on the wire for the throttle, positive
|
||||||
/// for the pedals), and the stick covers ±80 around its center.
|
/// for the pedals), and the stick covers ±80 around its center.
|
||||||
///
|
///
|
||||||
|
/// <para>The strip's left slot hosts the built-in plasma glass: MainForm parks
|
||||||
|
/// its <see cref="PlasmaCanvas"/> child at <see cref="PlasmaSlot"/>, and the
|
||||||
|
/// strip height and gauge positions derive from the glass size.</para>
|
||||||
|
///
|
||||||
/// <para>Left-click = momentary press (release on mouse-up). Right-click =
|
/// <para>Left-click = momentary press (release on mouse-up). Right-click =
|
||||||
/// latch the button down / release it (handy for testing holds).</para>
|
/// latch the button down / release it (handy for testing holds).</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -24,16 +29,27 @@ internal sealed class PanelCanvas : Control
|
|||||||
// Cell geometry — identical to RIOJoy's PanelView so the two panels align.
|
// Cell geometry — identical to RIOJoy's PanelView so the two panels align.
|
||||||
private const int CellW = 66;
|
private const int CellW = 66;
|
||||||
private const int CellH = 34;
|
private const int CellH = 34;
|
||||||
private const int TopStrip = 78; // encoder strip: gauges end at 74, small gap to the grid
|
|
||||||
|
|
||||||
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitLayout.Buttons();
|
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitLayout.Buttons();
|
||||||
|
|
||||||
// Encoder-strip geometry (same placement as RIOJoy's PanelView): Z gauge,
|
// First grid row any group occupies (the shared layout leaves row 0
|
||||||
// the pedal gauges L / R, and the X/Y stick box.
|
// empty). Rendering shifts all rows up by this, so the button grid starts
|
||||||
|
// right under the encoder strip instead of a blank cell row below it.
|
||||||
|
private static readonly int FirstRow = CockpitLayout.Groups.Min(g => g.OriginRow);
|
||||||
|
|
||||||
|
// Encoder-strip geometry. The built-in plasma glass parks at the strip's
|
||||||
|
// left edge (MainForm adds its PlasmaCanvas as a child at PlasmaSlot),
|
||||||
|
// the axis gauges — Z, the pedal gauges L / R, and the X/Y stick box —
|
||||||
|
// hug the grid's right edge, and the green status text fills the gap
|
||||||
|
// between them. The strip is exactly as tall as the glass.
|
||||||
|
internal const int PlasmaDotPitch = 3; // 2 px dots: the 128×32 glass fits the strip
|
||||||
|
internal static readonly Size PlasmaSize = PlasmaCanvas.SizeFor(PlasmaDotPitch);
|
||||||
private const int StripTop = 6;
|
private const int StripTop = 6;
|
||||||
private const int StripH = 68;
|
internal static readonly Point PlasmaSlot = new(6, StripTop);
|
||||||
|
private static readonly int StripH = PlasmaSize.Height;
|
||||||
|
private static readonly int TopStrip = StripTop + StripH + 6; // button grid starts under the strip
|
||||||
private const int Bar = 30;
|
private const int Bar = 30;
|
||||||
private static readonly int BaseX = 6 * CellW - 100;
|
private static readonly int BaseX = GridWidth() - 6 - 200; // the gauge cluster spans 200 px, right-aligned
|
||||||
private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH);
|
private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH);
|
||||||
private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH);
|
private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH);
|
||||||
private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH);
|
private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH);
|
||||||
@@ -43,6 +59,7 @@ internal sealed class PanelCanvas : Control
|
|||||||
private bool _wasFlashing;
|
private bool _wasFlashing;
|
||||||
|
|
||||||
private readonly HashSet<int> _latched = new();
|
private readonly HashSet<int> _latched = new();
|
||||||
|
private readonly HashSet<int> _externalHeld = new();
|
||||||
private int? _mouseDownAddress;
|
private int? _mouseDownAddress;
|
||||||
|
|
||||||
private RioAxis? _dragAxis; // Z / L / R gauge drag
|
private RioAxis? _dragAxis; // Z / L / R gauge drag
|
||||||
@@ -72,6 +89,12 @@ internal sealed class PanelCanvas : Control
|
|||||||
/// <summary>Current raw value of an axis (from the device).</summary>
|
/// <summary>Current raw value of an axis (from the device).</summary>
|
||||||
public Func<RioAxis, short>? AxisProvider { get; set; }
|
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>
|
/// <summary>The user pressed a panel control.</summary>
|
||||||
public event Action<int>? AddressPressed;
|
public event Action<int>? AddressPressed;
|
||||||
|
|
||||||
@@ -102,19 +125,27 @@ internal sealed class PanelCanvas : Control
|
|||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split from GridSize so the static gauge layout can use the width
|
||||||
|
// without touching TopStrip (which initializes after AllButtons).
|
||||||
|
private static int GridWidth()
|
||||||
|
{
|
||||||
|
int maxCol = 0;
|
||||||
|
foreach (PanelButton b in AllButtons)
|
||||||
|
if (b.Col > maxCol) maxCol = b.Col;
|
||||||
|
return (maxCol + 1) * CellW + 6;
|
||||||
|
}
|
||||||
|
|
||||||
private static Size GridSize()
|
private static Size GridSize()
|
||||||
{
|
{
|
||||||
int maxCol = 0, maxRow = 0;
|
int maxRow = 0;
|
||||||
foreach (PanelButton b in AllButtons)
|
foreach (PanelButton b in AllButtons)
|
||||||
{
|
|
||||||
if (b.Col > maxCol) maxCol = b.Col;
|
|
||||||
if (b.Row > maxRow) maxRow = b.Row;
|
if (b.Row > maxRow) maxRow = b.Row;
|
||||||
}
|
// The last used row plus a small margin under the lowest group frame.
|
||||||
return new Size((maxCol + 1) * CellW + 6, TopStrip + (maxRow + 2) * CellH);
|
return new Size(GridWidth(), TopStrip + (maxRow - FirstRow + 1) * CellH + 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Rectangle Cell(int col, int row) =>
|
private static Rectangle Cell(int col, int row) =>
|
||||||
new(col * CellW + 2, TopStrip + row * CellH + 2, CellW - 4, CellH - 4);
|
new(col * CellW + 2, TopStrip + (row - FirstRow) * CellH + 2, CellW - 4, CellH - 4);
|
||||||
|
|
||||||
// ---- Painting ----------------------------------------------------------
|
// ---- Painting ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -135,7 +166,7 @@ internal sealed class PanelCanvas : Control
|
|||||||
{
|
{
|
||||||
var frame = new Rectangle(
|
var frame = new Rectangle(
|
||||||
grp.OriginCol * CellW + 1,
|
grp.OriginCol * CellW + 1,
|
||||||
TopStrip + grp.OriginRow * CellH + 1,
|
TopStrip + (grp.OriginRow - FirstRow) * CellH + 1,
|
||||||
grp.Cols * CellW,
|
grp.Cols * CellW,
|
||||||
(grp.Rows + 1) * CellH);
|
(grp.Rows + 1) * CellH);
|
||||||
g.DrawRectangle(groupPen, frame);
|
g.DrawRectangle(groupPen, frame);
|
||||||
@@ -215,7 +246,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)
|
private static bool FlashPhaseOn(LampFlash flash, int tick)
|
||||||
{
|
{
|
||||||
@@ -270,10 +312,12 @@ internal sealed class PanelCanvas : Control
|
|||||||
new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro,
|
new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro,
|
||||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom);
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom);
|
||||||
|
|
||||||
// The mockup's green status/instruction area, right of the gauges; the
|
// The mockup's green status/instruction area, between the plasma glass
|
||||||
// live axis readout sits directly under the status lines (painted per
|
// and the gauges; the live axis readout sits directly under the status
|
||||||
// frame, so drags track).
|
// lines (painted per frame, so drags track).
|
||||||
var statusRect = new Rectangle(BoxXY.Right + 16, StripTop, Width - BoxXY.Right - 24, TopStrip - StripTop - 6);
|
var statusRect = new Rectangle(
|
||||||
|
PlasmaSlot.X + PlasmaSize.Width + 12, StripTop,
|
||||||
|
BoxZ.Left - PlasmaSlot.X - PlasmaSize.Width - 24, StripH);
|
||||||
if (statusRect.Width > 60)
|
if (statusRect.Width > 60)
|
||||||
{
|
{
|
||||||
using var statusFont = new Font("Consolas", 8f);
|
using var statusFont = new Font("Consolas", 8f);
|
||||||
@@ -287,12 +331,13 @@ internal sealed class PanelCanvas : Control
|
|||||||
new Size(statusRect.Width, 0), TextFormatFlags.WordBreak).Height + 2;
|
new Size(statusRect.Width, 0), TextFormatFlags.WordBreak).Height + 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
|
||||||
string readout =
|
string readout =
|
||||||
$"Z {Axis(RioAxis.Throttle),4} L {Axis(RioAxis.LeftPedal),3} R {Axis(RioAxis.RightPedal),3} " +
|
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3}\n" +
|
||||||
$"X {Axis(RioAxis.JoystickX),3} Y {Axis(RioAxis.JoystickY),3}";
|
$"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}";
|
||||||
TextRenderer.DrawText(g, readout, statusFont,
|
TextRenderer.DrawText(g, readout, statusFont,
|
||||||
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
|
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
|
||||||
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.SingleLine);
|
TextFormatFlags.Left | TextFormatFlags.Top);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,18 +8,52 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>vwe.ico</ApplicationIcon>
|
||||||
<AssemblyTitle>vRIO — Virtual RIO device</AssemblyTitle>
|
<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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\VRio.Core\VRio.Core.csproj" />
|
<ProjectReference Include="..\VRio.Core\VRio.Core.csproj" />
|
||||||
|
<!-- The built-in plasma glass: vPLASMA's device/protocol core, plus its
|
||||||
|
canvas control compiled in directly (shared file, not a library —
|
||||||
|
VPlasma.Core stays UI-free). -->
|
||||||
|
<ProjectReference Include="..\VPlasma.Core\VPlasma.Core.csproj" />
|
||||||
|
<Compile Include="..\VPlasma.App\PlasmaCanvas.cs" Link="PlasmaCanvas.cs" />
|
||||||
</ItemGroup>
|
</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 "$(MSBuildProjectDirectory)" 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 "$(MSBuildProjectDirectory)" 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>
|
<ItemGroup>
|
||||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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 0–3 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,134 @@
|
|||||||
|
namespace VRio.Core.Device;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The framing shared with the DOSBox-X fork's <c>namedpipe</c> serial
|
||||||
|
/// backend (its <c>serialnamedpipe.h</c> header comment is the contract's
|
||||||
|
/// source of truth on that side). A pipe is a plain byte stream, so serial
|
||||||
|
/// data and modem control lines are multiplexed as typed frames:
|
||||||
|
///
|
||||||
|
/// <code>
|
||||||
|
/// 0x00 <len:u8> <len bytes> serial data, len ≥ 1 (batching allowed)
|
||||||
|
/// 0x01 <lines:u8> the sender's OWN output lines (bit0 DTR,
|
||||||
|
/// bit1 RTS); the receiver applies the
|
||||||
|
/// null-modem cross: peer DTR → local DSR,
|
||||||
|
/// peer RTS → local CTS
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// Each side sends one lines frame immediately on connect; until it arrives
|
||||||
|
/// the peer's lines are assumed low, and a disconnect drops them low again.
|
||||||
|
/// Any other frame type is a protocol bug, not line noise — pipes don't drop
|
||||||
|
/// bytes — so the receiver logs it and drops the connection instead of
|
||||||
|
/// trying to resync.
|
||||||
|
/// </summary>
|
||||||
|
public static class PipeFraming
|
||||||
|
{
|
||||||
|
public const byte DataType = 0x00;
|
||||||
|
public const byte LinesType = 0x01;
|
||||||
|
|
||||||
|
/// <summary>Lines-frame bit: the sender's DTR output.</summary>
|
||||||
|
public const byte LineDtr = 0x01;
|
||||||
|
|
||||||
|
/// <summary>Lines-frame bit: the sender's RTS output.</summary>
|
||||||
|
public const byte LineRts = 0x02;
|
||||||
|
|
||||||
|
/// <summary>Both outputs asserted — what a present, powered device reports.</summary>
|
||||||
|
public const byte LinesPresent = LineDtr | LineRts;
|
||||||
|
|
||||||
|
/// <summary>Build a lines frame carrying <paramref name="lines"/>.</summary>
|
||||||
|
public static byte[] EncodeLines(byte lines) => new[] { LinesType, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Incremental decoder for <see cref="PipeFraming"/>: feed it raw pipe reads,
|
||||||
|
/// get one <see cref="Data"/> event per complete data frame and one
|
||||||
|
/// <see cref="Lines"/> event per lines frame. Frames may split across reads
|
||||||
|
/// at any byte boundary. A malformed stream (unknown type, zero-length data
|
||||||
|
/// frame) poisons the decoder: <see cref="Feed"/> returns false and
|
||||||
|
/// <see cref="Violation"/> says why — drop the connection and
|
||||||
|
/// <see cref="Reset"/> before serving the next one.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PipeFrameDecoder
|
||||||
|
{
|
||||||
|
private enum State { Type, Length, Payload, Lines }
|
||||||
|
|
||||||
|
private readonly byte[] _payload = new byte[byte.MaxValue];
|
||||||
|
private State _state;
|
||||||
|
private int _fill, _length;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A complete data frame's payload as (buffer, count). The buffer is
|
||||||
|
/// reused across frames — consume it synchronously.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<byte[], int>? Data;
|
||||||
|
|
||||||
|
/// <summary>A lines frame's bits (see <see cref="PipeFraming.LineDtr"/>).</summary>
|
||||||
|
public event Action<byte>? Lines;
|
||||||
|
|
||||||
|
/// <summary>Why the stream was rejected, or null while it is healthy.</summary>
|
||||||
|
public string? Violation { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Forget any partial frame and clear a violation (new connection).</summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_state = State.Type;
|
||||||
|
_fill = _length = 0;
|
||||||
|
Violation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Consume <paramref name="count"/> received bytes from
|
||||||
|
/// <paramref name="buffer"/>. Returns false when the stream violates the
|
||||||
|
/// framing contract (see <see cref="Violation"/>); a poisoned decoder
|
||||||
|
/// keeps returning false until <see cref="Reset"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool Feed(byte[] buffer, int count)
|
||||||
|
{
|
||||||
|
if (Violation is not null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
byte b = buffer[i];
|
||||||
|
switch (_state)
|
||||||
|
{
|
||||||
|
case State.Type when b == PipeFraming.DataType:
|
||||||
|
_state = State.Length;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.Type when b == PipeFraming.LinesType:
|
||||||
|
_state = State.Lines;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.Type:
|
||||||
|
Violation = $"unknown frame type 0x{b:X2}";
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case State.Length when b == 0:
|
||||||
|
Violation = "zero-length data frame";
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case State.Length:
|
||||||
|
_length = b;
|
||||||
|
_fill = 0;
|
||||||
|
_state = State.Payload;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.Payload:
|
||||||
|
_payload[_fill++] = b;
|
||||||
|
if (_fill == _length)
|
||||||
|
{
|
||||||
|
_state = State.Type;
|
||||||
|
Data?.Invoke(_payload, _length);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.Lines:
|
||||||
|
_state = State.Type;
|
||||||
|
Lines?.Invoke(b);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ namespace VRio.Core.Device;
|
|||||||
/// <para>Wire behavior (mirroring what RIOJoy expects from the real board):</para>
|
/// <para>Wire behavior (mirroring what RIOJoy expects from the real board):</para>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>ACKs every well-formed inbound packet, NAKs one with a bad checksum.</item>
|
/// <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
|
/// <item>VersionRequest → VersionReply with the configured firmware version
|
||||||
/// (default 4.2, matching the real board's dumped EPROM).</item>
|
/// (default 4.2, matching the real board's dumped EPROM).</item>
|
||||||
/// <item>AnalogRequest → AnalogReply with the current five axis values.</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>
|
/// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
|
||||||
public byte VersionMinor { get; set; } = 2;
|
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>
|
/// <summary>
|
||||||
/// When true, retry exhaustion leaves the analog reply path wedged (the
|
/// When true, retry exhaustion leaves the analog reply path wedged (the
|
||||||
/// v4.2 latch-leak bug) until a host ResetRequest clears it.
|
/// 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];
|
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>
|
/// <summary>
|
||||||
/// Move an axis. Values are clamped to the 14-bit signed range the wire
|
/// 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.
|
/// can carry; the new value is returned by the next AnalogReply.
|
||||||
@@ -239,9 +265,14 @@ public sealed class VRioDevice
|
|||||||
switch (packet.Command)
|
switch (packet.Command)
|
||||||
{
|
{
|
||||||
case RioCommand.CheckRequest:
|
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)
|
foreach ((byte number, string _) in RioAddressSpace.Boards)
|
||||||
Send(PacketBuilder.CheckReply(RioStatusType.BoardOk, number));
|
Send(PacketBuilder.CheckReply(RioStatusType.BoardOk, number));
|
||||||
|
Send(PacketBuilder.TestModeChange(0));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case RioCommand.VersionRequest:
|
case RioCommand.VersionRequest:
|
||||||
@@ -267,7 +298,7 @@ public sealed class VRioDevice
|
|||||||
y = _axes[(int)RioAxis.JoystickY];
|
y = _axes[(int)RioAxis.JoystickY];
|
||||||
x = _axes[(int)RioAxis.JoystickX];
|
x = _axes[(int)RioAxis.JoystickX];
|
||||||
}
|
}
|
||||||
Send(PacketBuilder.AnalogReply(t, l, r, y, x));
|
Send(PacketBuilder.AnalogReply(t, l, r, WireY(y), x));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case RioCommand.ResetRequest:
|
case RioCommand.ResetRequest:
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace VRio.Core.Device;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves a <see cref="VRioDevice"/> over the named pipe that the DOSBox-X
|
||||||
|
/// fork's <c>serial1=namedpipe pipe:vrio</c> backend connects to — the
|
||||||
|
/// com0com-free transport. vRIO is the pipe <em>server</em> (the device is
|
||||||
|
/// always present); DOSBox-X is the client and background-retries while the
|
||||||
|
/// pipe is missing, so this service listens for the whole app lifetime
|
||||||
|
/// alongside the COM path at no cost. An unconnected pipe is an unplugged
|
||||||
|
/// cable: the device keeps running, transmit bytes fall on the floor.
|
||||||
|
///
|
||||||
|
/// <para>Framing per <see cref="PipeFraming"/>. On connect we immediately
|
||||||
|
/// send our lines frame (DTR+RTS high — "board present", the same lines the
|
||||||
|
/// serial path asserts via DtrEnable/RtsEnable); the peer's DTR edges surface
|
||||||
|
/// through <see cref="HostHandshake"/> exactly like the DSR blips through a
|
||||||
|
/// null modem, and a disconnect drops all lines low. The game hardware-resets
|
||||||
|
/// the RIO by pulsing DTR, and the in-band lines frames keep that edge's
|
||||||
|
/// position in the byte stream — the reason the contract multiplexes control
|
||||||
|
/// onto the data pipe instead of using a second one.</para>
|
||||||
|
///
|
||||||
|
/// <para>Outbound bytes are paced one per 10-bit frame time exactly like
|
||||||
|
/// <see cref="VRioSerialService"/> (see its remarks for the full rationale).
|
||||||
|
/// With the 9600-baud wire gone, this pacer is the only thing standing
|
||||||
|
/// between the host and an impossible burst, so each paced byte travels as
|
||||||
|
/// its own 3-byte data frame and the inter-byte timing survives the pipe.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VRioPipeService : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>The contract pipe name: DOSBox-X connects to <c>\\.\pipe\vrio</c>.</summary>
|
||||||
|
public const string DefaultPipeName = "vrio";
|
||||||
|
|
||||||
|
// Pacing mirrors VRioSerialService: one byte per 10-bit frame at 9600
|
||||||
|
// baud; sleep until ~1.8 ms from the slot, then spin the remainder.
|
||||||
|
private static readonly long BytePeriodTicks = Stopwatch.Frequency * 10 / VRioSerialService.BaudRate;
|
||||||
|
private static readonly long SpinThresholdTicks = Stopwatch.Frequency * 18 / 10_000;
|
||||||
|
|
||||||
|
private readonly VRioDevice _device;
|
||||||
|
private readonly string _pipeName;
|
||||||
|
private readonly object _txGate = new();
|
||||||
|
private readonly Queue<byte> _txQueue = new();
|
||||||
|
private readonly PipeFrameDecoder _decoder = new();
|
||||||
|
|
||||||
|
private NamedPipeServerStream? _pipe;
|
||||||
|
private Thread? _server;
|
||||||
|
private Thread? _writer;
|
||||||
|
private volatile bool _running;
|
||||||
|
private volatile bool _clientConnected;
|
||||||
|
private bool _peerDtr, _peerRts;
|
||||||
|
private bool _timerResolutionRaised;
|
||||||
|
|
||||||
|
public VRioPipeService(VRioDevice device, string pipeName = DefaultPipeName)
|
||||||
|
{
|
||||||
|
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||||
|
if (string.IsNullOrWhiteSpace(pipeName))
|
||||||
|
throw new ArgumentException("Pipe name is required.", nameof(pipeName));
|
||||||
|
_pipeName = pipeName;
|
||||||
|
|
||||||
|
_device.Transmit += Write;
|
||||||
|
_decoder.Data += (buffer, count) => _device.OnReceived(buffer, count);
|
||||||
|
_decoder.Lines += OnPeerLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
|
||||||
|
public string PipeName => _pipeName;
|
||||||
|
|
||||||
|
/// <summary>True while the server is up (whether or not a client is connected).</summary>
|
||||||
|
public bool IsListening => _running;
|
||||||
|
|
||||||
|
/// <summary>True while a client is connected.</summary>
|
||||||
|
public bool IsClientConnected => _clientConnected;
|
||||||
|
|
||||||
|
/// <summary>A client connected (true) or went away (false).</summary>
|
||||||
|
public event Action<bool>? ClientChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The host's DTR line changed (the game pulses it to hardware-reset the
|
||||||
|
/// board). Same semantics as <see cref="VRioSerialService.HostHandshake"/>:
|
||||||
|
/// the argument is the new line state as seen on our DSR pin.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<bool>? HostHandshake;
|
||||||
|
|
||||||
|
/// <summary>Pipe-level log lines (listen/connect/errors).</summary>
|
||||||
|
public event Action<string>? Logged;
|
||||||
|
|
||||||
|
/// <summary>Start listening (idempotent). Clients may come and go forever.</summary>
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_running = true;
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
|
||||||
|
_server = new Thread(ServerLoop) { IsBackground = true, Name = $"vRIO pipe server ({_pipeName})" };
|
||||||
|
_server.Start();
|
||||||
|
_writer = new Thread(WriteLoop) { IsBackground = true, Name = $"vRIO pipe writer ({_pipeName})" };
|
||||||
|
_writer.Start();
|
||||||
|
|
||||||
|
Logged?.Invoke($@"Listening on \\.\pipe\{_pipeName} (TX paced at the wire rate) — DOSBox-X connects when it boots");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stop listening and drop any client (idempotent).</summary>
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (!_running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_running = false;
|
||||||
|
lock (_txGate)
|
||||||
|
{
|
||||||
|
_txQueue.Clear();
|
||||||
|
Monitor.PulseAll(_txGate); // wake the writer so it can exit
|
||||||
|
}
|
||||||
|
|
||||||
|
NamedPipeServerStream? pipe = _pipe;
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe?.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
|
||||||
|
// A WaitForConnection pending on the disposed stream can survive the
|
||||||
|
// Dispose on net48; a throwaway client connect releases it either way.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var poke = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out);
|
||||||
|
poke.Connect(100);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) { }
|
||||||
|
|
||||||
|
_server?.Join(1000);
|
||||||
|
_server = null;
|
||||||
|
_writer?.Join(1000);
|
||||||
|
_writer = null;
|
||||||
|
|
||||||
|
if (_timerResolutionRaised)
|
||||||
|
{
|
||||||
|
timeEndPeriod(1);
|
||||||
|
_timerResolutionRaised = false;
|
||||||
|
}
|
||||||
|
_clientConnected = false;
|
||||||
|
|
||||||
|
Logged?.Invoke("Pipe server stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ServerLoop()
|
||||||
|
{
|
||||||
|
bool busyLogged = false; // log the name collision once, not per retry
|
||||||
|
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
NamedPipeServerStream pipe;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
|
||||||
|
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Name already served — most likely a second vRIO instance.
|
||||||
|
if (!busyLogged)
|
||||||
|
{
|
||||||
|
busyLogged = true;
|
||||||
|
Logged?.Invoke($@"\\.\pipe\{_pipeName} is busy ({ex.Message.TrimEnd('.')}) — retrying until it frees up");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 20 && _running; i++)
|
||||||
|
Thread.Sleep(100);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
busyLogged = false;
|
||||||
|
|
||||||
|
_pipe = pipe;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe.WaitForConnection();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Disposed by Stop(), or a client vanished mid-connect.
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_running || !OnClientConnected(pipe))
|
||||||
|
{
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadUntilDisconnect(pipe);
|
||||||
|
|
||||||
|
OnClientDisconnected();
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-connection setup; false if the client died before it finished.</summary>
|
||||||
|
private bool OnClientConnected(NamedPipeServerStream pipe)
|
||||||
|
{
|
||||||
|
_decoder.Reset();
|
||||||
|
_peerDtr = _peerRts = false;
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
|
||||||
|
// Our lines frame goes first, before the writer can interleave data
|
||||||
|
// (it only writes once _clientConnected flips below): DTR+RTS high,
|
||||||
|
// board present — the peer maps them to its DSR/CTS.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] lines = PipeFraming.EncodeLines(PipeFraming.LinesPresent);
|
||||||
|
pipe.Write(lines, 0, lines.Length);
|
||||||
|
pipe.Flush();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 ms system timer resolution while a client is connected, so the
|
||||||
|
// pacer's Thread.Sleep(1) actually sleeps ~1 ms.
|
||||||
|
_timerResolutionRaised = timeBeginPeriod(1) == 0;
|
||||||
|
_clientConnected = true;
|
||||||
|
|
||||||
|
Logged?.Invoke("Pipe client connected — sent lines DTR+RTS (board present)");
|
||||||
|
ClientChanged?.Invoke(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClientDisconnected()
|
||||||
|
{
|
||||||
|
_clientConnected = false;
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
|
||||||
|
if (_timerResolutionRaised)
|
||||||
|
{
|
||||||
|
timeEndPeriod(1);
|
||||||
|
_timerResolutionRaised = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contract: a disconnect drops all lines low on the surviving side.
|
||||||
|
if (_peerDtr)
|
||||||
|
{
|
||||||
|
_peerDtr = false;
|
||||||
|
HostHandshake?.Invoke(false);
|
||||||
|
}
|
||||||
|
_peerRts = false;
|
||||||
|
|
||||||
|
if (_running)
|
||||||
|
{
|
||||||
|
Logged?.Invoke("Pipe client disconnected");
|
||||||
|
ClientChanged?.Invoke(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReadUntilDisconnect(NamedPipeServerStream pipe)
|
||||||
|
{
|
||||||
|
var buffer = new byte[512];
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
int n;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
n = pipe.Read(buffer, 0, buffer.Length);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
if (_running)
|
||||||
|
Logged?.Invoke($"Pipe error: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n == 0)
|
||||||
|
return; // client closed its end
|
||||||
|
|
||||||
|
if (!_decoder.Feed(buffer, n))
|
||||||
|
{
|
||||||
|
Logged?.Invoke($"Pipe protocol violation: {_decoder.Violation} — dropping the connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerLines(byte lines)
|
||||||
|
{
|
||||||
|
_peerRts = (lines & PipeFraming.LineRts) != 0; // tracked; nothing consumes CTS today
|
||||||
|
bool dtr = (lines & PipeFraming.LineDtr) != 0;
|
||||||
|
if (dtr == _peerDtr)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_peerDtr = dtr;
|
||||||
|
HostHandshake?.Invoke(dtr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 pipe.
|
||||||
|
private void Write(byte[] data)
|
||||||
|
{
|
||||||
|
if (!_running || !_clientConnected)
|
||||||
|
return; // no host on the pipe — a real UART shifts into an unterminated line
|
||||||
|
|
||||||
|
lock (_txGate)
|
||||||
|
{
|
||||||
|
foreach (byte b in data)
|
||||||
|
_txQueue.Enqueue(b);
|
||||||
|
Monitor.Pulse(_txGate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteLoop()
|
||||||
|
{
|
||||||
|
// One data frame per paced byte, so the pipe carries the same
|
||||||
|
// inter-byte spacing a 9600-baud wire would.
|
||||||
|
var frame = new byte[] { PipeFraming.DataType, 1, 0 };
|
||||||
|
long slot = Stopwatch.GetTimestamp();
|
||||||
|
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
lock (_txGate)
|
||||||
|
{
|
||||||
|
while (_txQueue.Count == 0)
|
||||||
|
{
|
||||||
|
if (!_running)
|
||||||
|
return;
|
||||||
|
Monitor.Wait(_txGate, 200); // timed, so a missed pulse can't wedge shutdown
|
||||||
|
}
|
||||||
|
frame[2] = _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);
|
||||||
|
|
||||||
|
NamedPipeServerStream? pipe = _pipe;
|
||||||
|
if (pipe is null || !_clientConnected)
|
||||||
|
continue; // client left while this byte waited its slot — it's stale, drop it
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe.Write(frame, 0, frame.Length);
|
||||||
|
pipe.Flush();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// The client died mid-write; the backlog is already stale.
|
||||||
|
// Drop it and keep serving — the server loop notices the EOF
|
||||||
|
// and cycles back to WaitForConnection.
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the wait overshot its slot, pace the next byte from the
|
||||||
|
// actual emission instead — 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;
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.IO.Ports;
|
using System.IO.Ports;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using VRio.Core.Protocol;
|
using VRio.Core.Protocol;
|
||||||
|
|
||||||
namespace VRio.Core.Device;
|
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
|
/// 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.
|
/// 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
|
/// <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
|
/// 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
|
/// 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>
|
/// <summary>RIO link bit rate (must match RIOJoy's transport).</summary>
|
||||||
public const int BaudRate = 9600;
|
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 VRioDevice _device;
|
||||||
private readonly object _writeGate = new();
|
private readonly object _txGate = new();
|
||||||
|
private readonly Queue<byte> _txQueue = new();
|
||||||
|
|
||||||
private SerialPort? _port;
|
private SerialPort? _port;
|
||||||
private Thread? _reader;
|
private Thread? _reader;
|
||||||
|
private Thread? _writer;
|
||||||
private volatile bool _running;
|
private volatile bool _running;
|
||||||
|
private bool _timerResolutionRaised;
|
||||||
|
|
||||||
public VRioSerialService(VRioDevice device)
|
public VRioSerialService(VRioDevice device)
|
||||||
{
|
{
|
||||||
@@ -74,10 +100,18 @@ public sealed class VRioSerialService : IDisposable
|
|||||||
|
|
||||||
_port = port;
|
_port = port;
|
||||||
_running = true;
|
_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 = new Thread(ReadLoop) { IsBackground = true, Name = "vRIO serial reader" };
|
||||||
_reader.Start();
|
_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);
|
ConnectionChanged?.Invoke(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +123,7 @@ public sealed class VRioSerialService : IDisposable
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
_running = false;
|
_running = false;
|
||||||
|
lock (_txGate) Monitor.PulseAll(_txGate); // wake the writer so it can exit
|
||||||
_port = null;
|
_port = null;
|
||||||
port.PinChanged -= OnPinChanged;
|
port.PinChanged -= OnPinChanged;
|
||||||
try { port.Close(); }
|
try { port.Close(); }
|
||||||
@@ -97,6 +132,15 @@ public sealed class VRioSerialService : IDisposable
|
|||||||
|
|
||||||
_reader?.Join(1000);
|
_reader?.Join(1000);
|
||||||
_reader = null;
|
_reader = null;
|
||||||
|
_writer?.Join(1000);
|
||||||
|
_writer = null;
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
|
||||||
|
if (_timerResolutionRaised)
|
||||||
|
{
|
||||||
|
timeEndPeriod(1);
|
||||||
|
_timerResolutionRaised = false;
|
||||||
|
}
|
||||||
|
|
||||||
Logged?.Invoke("Port closed");
|
Logged?.Invoke("Port closed");
|
||||||
ConnectionChanged?.Invoke(false);
|
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)
|
private void Write(byte[] data)
|
||||||
{
|
{
|
||||||
SerialPort? port = _port;
|
if (!_running)
|
||||||
if (port is null || !port.IsOpen)
|
|
||||||
return; // device poked while offline — drop silently
|
return; // device poked while offline — drop silently
|
||||||
|
|
||||||
try
|
lock (_txGate)
|
||||||
{
|
{
|
||||||
lock (_writeGate)
|
foreach (byte b in data)
|
||||||
port.Write(data, 0, data.Length);
|
_txQueue.Enqueue(b);
|
||||||
}
|
Monitor.Pulse(_txGate);
|
||||||
catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
|
|
||||||
{
|
|
||||||
Logged?.Invoke($"Write failed: {ex.Message}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_device.Transmit -= Write;
|
_device.Transmit -= Write;
|
||||||
|
|||||||
@@ -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 <name> button <addr> [toggle]
|
||||||
|
/// key <name> axis <axis> deflect <n> | rate <n>
|
||||||
|
/// pad <button> button <addr> [toggle]
|
||||||
|
/// padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>]
|
||||||
|
/// </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
|
||||||
|
";
|
||||||
|
}
|
||||||
@@ -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 0x00–0x47 or keypad 0x50–0x6F).</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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="System.Memory" Version="4.5.5" />
|
<PackageReference Include="System.Memory" Version="4.5.5" />
|
||||||
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
|
|
||||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
@@ -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,324 @@
|
|||||||
|
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());
|
||||||
|
|
||||||
|
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();
|
||||||
|
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)'H'); // parser must be back in text mode
|
||||||
|
|
||||||
|
Assert.Equal(1, device.TextCharsDrawn);
|
||||||
|
Assert.Equal(0, device.GraphicsRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GraphicsWrite_OverwritesTextAttributes()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, Esc, (byte)'H', 8); // flashing text
|
||||||
|
Feed(device, (byte)'H'); // font-0 'H' lights (0,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 (real font 0 = 6×8) -----------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Text_DrawsRealGlyphAndAdvancesByFontWidth()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, (byte)'H'); // font-0 'H': row0 = pixels at x0 and x4
|
||||||
|
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 4, 0));
|
||||||
|
Assert.Equal(0, Pixel(device, 1, 0));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 3)); // crossbar row
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 4, 3));
|
||||||
|
Assert.Equal(0, Pixel(device, 0, 7)); // blank bottom row
|
||||||
|
Assert.Equal(6, device.CursorX); // advanced one 6-px cell
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
|
Assert.Equal(1, device.TextCharsDrawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ControlChars_MoveCursorByPixels()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice(); // font 0: 6 wide, 8 tall
|
||||||
|
|
||||||
|
Feed(device, 0x09, 0x09, 0x09); // HT ×3 → x = 18
|
||||||
|
Assert.Equal(18, device.CursorX);
|
||||||
|
|
||||||
|
Feed(device, 0x08); // BS → x = 12
|
||||||
|
Assert.Equal(12, device.CursorX);
|
||||||
|
|
||||||
|
Feed(device, 0x0A); // LF → y = 8
|
||||||
|
Assert.Equal(8, device.CursorY);
|
||||||
|
Assert.Equal(0, device.CursorX);
|
||||||
|
|
||||||
|
Feed(device, (byte)'H', 0x0D); // draw + CR → x back to 0
|
||||||
|
Assert.Equal(0, device.CursorX);
|
||||||
|
|
||||||
|
Feed(device, 0x0B); // VT → y back to 0
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscQ_EscR_SetPixelCursor()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'Q', 20); // set row Y=20
|
||||||
|
Feed(device, Esc, (byte)'R', 37); // set col X=37
|
||||||
|
Assert.Equal(37, device.CursorX);
|
||||||
|
Assert.Equal(20, device.CursorY);
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'Q', 40); // out of range (>31): ignored
|
||||||
|
Assert.Equal(20, device.CursorY);
|
||||||
|
Feed(device, Esc, (byte)'R', 200); // out of range (>127): ignored
|
||||||
|
Assert.Equal(37, device.CursorX);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Text_WrapsAtRightAndBottom()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice(); // 21 cells of 6px per line, 4 lines
|
||||||
|
|
||||||
|
Feed(device, Enumerable.Repeat((byte)'X', 21)); // fills first line, wraps
|
||||||
|
Assert.Equal(0, device.CursorX);
|
||||||
|
Assert.Equal(8, device.CursorY);
|
||||||
|
|
||||||
|
Feed(device, Enumerable.Repeat((byte)'X', 21 * 3)); // to the end, wraps to top
|
||||||
|
Assert.Equal(0, device.CursorX);
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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(0, device.CursorX);
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
|
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(0, device.CursorX);
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
|
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_SelectsRealFonts()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'K', 4); // large font: 12×16
|
||||||
|
Assert.Equal(4, device.Font);
|
||||||
|
Assert.Equal(12, device.FontWidth);
|
||||||
|
Assert.Equal(16, device.FontHeight);
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'K', 0); // back to font 0: 6×8
|
||||||
|
Assert.Equal(0, device.Font);
|
||||||
|
Assert.Equal(6, device.FontWidth);
|
||||||
|
Assert.Equal(8, device.FontHeight);
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'K', 0xFF); // out of range: firmware ignores it
|
||||||
|
Assert.Equal(0, device.Font);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscH_AppliesAttributesAsLowFourBits()
|
||||||
|
{
|
||||||
|
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)'L', Esc, (byte)'@'); // clear
|
||||||
|
Feed(device, Esc, (byte)'H', 2, (byte)'H'); // underline: bottom row (y=7) lit
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 7));
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'@');
|
||||||
|
Feed(device, Esc, (byte)'H', 4, (byte)' '); // reverse: a space renders solid
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 5, 7));
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'H', 0); // defaults restored
|
||||||
|
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- robustness ----------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnknownEscape_IsConsumedAndTextResumes()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, Esc, (byte)'\\', (byte)'H'); // ESC '\' is not a command
|
||||||
|
|
||||||
|
Assert.Equal(1, device.TextCharsDrawn);
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GameStartupSequence_HidesCursor()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode); // power-on default
|
||||||
|
|
||||||
|
Feed(device, 27, (byte)'G', 0x00); // L4PLASMA.CPP's boot byte
|
||||||
|
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(0, device.CursorX);
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using VPlasma.Core.Device;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace VPlasma.Core.Tests;
|
||||||
|
|
||||||
|
public class VPlasmaPipeServiceTests
|
||||||
|
{
|
||||||
|
private static string UniqueName() => $"vplasma-test-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
private static void WaitFor(Func<bool> condition, int timeoutMs = 2000)
|
||||||
|
{
|
||||||
|
var clock = Stopwatch.StartNew();
|
||||||
|
while (!condition())
|
||||||
|
{
|
||||||
|
Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
|
||||||
|
Thread.Sleep(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NamedPipeClientStream Connect(string name)
|
||||||
|
{
|
||||||
|
var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||||
|
var clock = Stopwatch.StartNew();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe.Connect(200);
|
||||||
|
return pipe;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or TimeoutException)
|
||||||
|
{
|
||||||
|
if (clock.ElapsedMilliseconds >= 3000)
|
||||||
|
throw;
|
||||||
|
Thread.Sleep(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Framed_text_reaches_the_display_and_lines_are_ignored()
|
||||||
|
{
|
||||||
|
string name = UniqueName();
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
using var service = new VPlasmaPipeService(device, name);
|
||||||
|
service.Start();
|
||||||
|
|
||||||
|
using var client = Connect(name);
|
||||||
|
var hello = new byte[2];
|
||||||
|
Assert.Equal(1, client.Read(hello, 0, 1)); // on-connect lines frame,
|
||||||
|
Assert.Equal(1, client.Read(hello, 1, 1)); // possibly split
|
||||||
|
Assert.Equal(new byte[] { 0x01, 0x03 }, hello); // DTR+RTS: display present
|
||||||
|
|
||||||
|
// RTS chatter (DOS console redirection toggles it per byte) then "HI",
|
||||||
|
// the data frame torn in half mid-payload.
|
||||||
|
client.Write(new byte[] { 0x01, 0x02, 0x00, 0x02, (byte)'H' }, 0, 5);
|
||||||
|
client.Flush();
|
||||||
|
Thread.Sleep(20);
|
||||||
|
client.Write(new byte[] { (byte)'I' }, 0, 1);
|
||||||
|
client.Flush();
|
||||||
|
|
||||||
|
WaitFor(() => device.BytesReceived == 2);
|
||||||
|
Assert.Equal(2, device.TextCharsDrawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_frame_type_drops_the_client_and_server_recovers()
|
||||||
|
{
|
||||||
|
string name = UniqueName();
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
using var service = new VPlasmaPipeService(device, name);
|
||||||
|
service.Start();
|
||||||
|
|
||||||
|
using (var bad = Connect(name))
|
||||||
|
{
|
||||||
|
var hello = new byte[2];
|
||||||
|
bad.Read(hello, 0, 2);
|
||||||
|
bad.Write(new byte[] { 0x7F }, 0, 1); // not a frame type
|
||||||
|
bad.Flush();
|
||||||
|
// Contract: log + drop, no resync — our next read sees the close.
|
||||||
|
var eof = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try { return bad.Read(new byte[1], 0, 1) == 0; }
|
||||||
|
catch (IOException) { return true; }
|
||||||
|
});
|
||||||
|
Assert.True(await Task.WhenAny(eof, Task.Delay(2000)) == eof,
|
||||||
|
"server did not drop the connection");
|
||||||
|
Assert.True(await eof);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var good = Connect(name);
|
||||||
|
var again = new byte[2];
|
||||||
|
Assert.Equal(1, good.Read(again, 0, 1));
|
||||||
|
Assert.Equal(1, good.Read(again, 1, 1));
|
||||||
|
Assert.Equal(new byte[] { 0x01, 0x03 }, again);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using VRio.Core.Device;
|
||||||
|
using VRio.Core.Protocol;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace VRio.Core.Tests;
|
||||||
|
|
||||||
|
public class PipeFrameDecoderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Data_frame_delivers_payload()
|
||||||
|
{
|
||||||
|
var decoder = new PipeFrameDecoder();
|
||||||
|
byte[]? got = null;
|
||||||
|
decoder.Data += (buf, count) => got = buf.Take(count).ToArray();
|
||||||
|
|
||||||
|
Assert.True(decoder.Feed(new byte[] { 0x00, 0x03, 0xAA, 0xBB, 0xCC }, 5));
|
||||||
|
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, got);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Frames_survive_any_split_across_reads()
|
||||||
|
{
|
||||||
|
var decoder = new PipeFrameDecoder();
|
||||||
|
var data = new List<byte>();
|
||||||
|
var lines = new List<byte>();
|
||||||
|
decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
|
||||||
|
decoder.Lines += lines.Add;
|
||||||
|
|
||||||
|
// A lines frame, a 2-byte data frame, a 1-byte data frame — one byte at a time.
|
||||||
|
byte[] stream = { 0x01, 0x03, 0x00, 0x02, 0x81, 0x01, 0x00, 0x01, 0xFC };
|
||||||
|
foreach (byte b in stream)
|
||||||
|
Assert.True(decoder.Feed(new[] { b }, 1));
|
||||||
|
|
||||||
|
Assert.Equal(new byte[] { 0x03 }, lines);
|
||||||
|
Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Multiple_frames_in_one_read_all_deliver()
|
||||||
|
{
|
||||||
|
var decoder = new PipeFrameDecoder();
|
||||||
|
var data = new List<byte>();
|
||||||
|
var lines = new List<byte>();
|
||||||
|
decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
|
||||||
|
decoder.Lines += lines.Add;
|
||||||
|
|
||||||
|
byte[] chunk = { 0x00, 0x01, 0x42, 0x01, 0x00, 0x00, 0x01, 0x43 };
|
||||||
|
Assert.True(decoder.Feed(chunk, chunk.Length));
|
||||||
|
|
||||||
|
Assert.Equal(new byte[] { 0x42, 0x43 }, data);
|
||||||
|
Assert.Equal(new byte[] { 0x00 }, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_frame_type_poisons_the_decoder()
|
||||||
|
{
|
||||||
|
var decoder = new PipeFrameDecoder();
|
||||||
|
|
||||||
|
Assert.False(decoder.Feed(new byte[] { 0x7F }, 1));
|
||||||
|
Assert.Contains("0x7F", decoder.Violation);
|
||||||
|
// Poisoned: even a healthy frame is rejected until Reset.
|
||||||
|
Assert.False(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
|
||||||
|
|
||||||
|
decoder.Reset();
|
||||||
|
Assert.Null(decoder.Violation);
|
||||||
|
Assert.True(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Zero_length_data_frame_is_a_violation()
|
||||||
|
{
|
||||||
|
var decoder = new PipeFrameDecoder();
|
||||||
|
Assert.False(decoder.Feed(new byte[] { 0x00, 0x00 }, 2));
|
||||||
|
Assert.Contains("zero-length", decoder.Violation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VRioPipeServiceTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A contract-speaking client: connects to the service's pipe, deframes
|
||||||
|
/// everything it receives (timestamping data bytes for the pacing test),
|
||||||
|
/// and sends raw frame bytes.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class TestClient : IDisposable
|
||||||
|
{
|
||||||
|
private readonly NamedPipeClientStream _pipe;
|
||||||
|
private readonly Thread _reader;
|
||||||
|
private readonly object _sync = new();
|
||||||
|
private readonly List<byte> _data = new();
|
||||||
|
private readonly List<long> _dataTicks = new();
|
||||||
|
private readonly List<byte> _lines = new();
|
||||||
|
private volatile bool _closed;
|
||||||
|
|
||||||
|
public TestClient(string pipeName)
|
||||||
|
{
|
||||||
|
_pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut,
|
||||||
|
PipeOptions.Asynchronous);
|
||||||
|
|
||||||
|
// The server's listener may still be spinning up; retry briefly.
|
||||||
|
var connectClock = Stopwatch.StartNew();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pipe.Connect(200);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or TimeoutException)
|
||||||
|
{
|
||||||
|
if (connectClock.ElapsedMilliseconds >= 3000)
|
||||||
|
throw;
|
||||||
|
Thread.Sleep(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoder = new PipeFrameDecoder();
|
||||||
|
decoder.Data += (buf, count) =>
|
||||||
|
{
|
||||||
|
long now = Stopwatch.GetTimestamp();
|
||||||
|
lock (_sync)
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
_data.Add(buf[i]);
|
||||||
|
_dataTicks.Add(now);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
decoder.Lines += b => { lock (_sync) _lines.Add(b); };
|
||||||
|
|
||||||
|
_reader = new Thread(() =>
|
||||||
|
{
|
||||||
|
var buffer = new byte[512];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int n;
|
||||||
|
try { n = _pipe.Read(buffer, 0, buffer.Length); }
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException) { break; }
|
||||||
|
if (n == 0 || !decoder.Feed(buffer, n))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_closed = true;
|
||||||
|
})
|
||||||
|
{ IsBackground = true };
|
||||||
|
_reader.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Closed => _closed;
|
||||||
|
public byte[] Data { get { lock (_sync) return _data.ToArray(); } }
|
||||||
|
public long[] DataTicks { get { lock (_sync) return _dataTicks.ToArray(); } }
|
||||||
|
public byte[] Lines { get { lock (_sync) return _lines.ToArray(); } }
|
||||||
|
|
||||||
|
public void SendRaw(params byte[] bytes)
|
||||||
|
{
|
||||||
|
_pipe.Write(bytes, 0, bytes.Length);
|
||||||
|
_pipe.Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SendData(byte[] payload)
|
||||||
|
{
|
||||||
|
var frame = new byte[2 + payload.Length];
|
||||||
|
frame[0] = PipeFraming.DataType;
|
||||||
|
frame[1] = (byte)payload.Length;
|
||||||
|
payload.CopyTo(frame, 2);
|
||||||
|
SendRaw(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_pipe.Dispose();
|
||||||
|
_reader.Join(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UniqueName() => $"vrio-test-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
private static void WaitFor(Func<bool> condition, int timeoutMs = 2000)
|
||||||
|
{
|
||||||
|
var clock = Stopwatch.StartNew();
|
||||||
|
while (!condition())
|
||||||
|
{
|
||||||
|
Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
|
||||||
|
Thread.Sleep(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Client_gets_board_present_lines_on_connect()
|
||||||
|
{
|
||||||
|
string name = UniqueName();
|
||||||
|
var device = new VRioDevice();
|
||||||
|
using var service = new VRioPipeService(device, name);
|
||||||
|
service.Start();
|
||||||
|
|
||||||
|
using var client = new TestClient(name);
|
||||||
|
WaitFor(() => client.Lines.Length > 0);
|
||||||
|
|
||||||
|
Assert.Equal(PipeFraming.LinesPresent, client.Lines[0]); // DTR+RTS: board present
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Peer_dtr_edges_surface_as_host_handshake()
|
||||||
|
{
|
||||||
|
string name = UniqueName();
|
||||||
|
var device = new VRioDevice();
|
||||||
|
using var service = new VRioPipeService(device, name);
|
||||||
|
var edges = new List<bool>();
|
||||||
|
service.HostHandshake += high => { lock (edges) edges.Add(high); };
|
||||||
|
service.Start();
|
||||||
|
|
||||||
|
using var client = new TestClient(name);
|
||||||
|
client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr); // DTR up
|
||||||
|
client.SendRaw(PipeFraming.LinesType, 0x00); // DTR down (the reset pulse)
|
||||||
|
|
||||||
|
WaitFor(() => { lock (edges) return edges.Count == 2; });
|
||||||
|
lock (edges)
|
||||||
|
Assert.Equal(new[] { true, false }, edges);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Version_request_round_trips_ack_then_reply()
|
||||||
|
{
|
||||||
|
string name = UniqueName();
|
||||||
|
var device = new VRioDevice();
|
||||||
|
using var service = new VRioPipeService(device, name);
|
||||||
|
service.Start();
|
||||||
|
|
||||||
|
using var client = new TestClient(name);
|
||||||
|
WaitFor(() => client.Lines.Length > 0); // fully connected
|
||||||
|
client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
|
||||||
|
|
||||||
|
byte[] expected = new[] { (byte)RioControl.Ack }
|
||||||
|
.Concat(PacketBuilder.VersionReply(4, 2)).ToArray();
|
||||||
|
WaitFor(() => client.Data.Length >= expected.Length);
|
||||||
|
|
||||||
|
Assert.Equal(expected, client.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reply_bytes_are_paced_at_the_wire_rate()
|
||||||
|
{
|
||||||
|
string name = UniqueName();
|
||||||
|
var device = new VRioDevice();
|
||||||
|
using var service = new VRioPipeService(device, name);
|
||||||
|
service.Start();
|
||||||
|
|
||||||
|
using var client = new TestClient(name);
|
||||||
|
WaitFor(() => client.Lines.Length > 0);
|
||||||
|
client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
|
||||||
|
|
||||||
|
// ACK + 4-byte VersionReply = 5 bytes = 4 inter-byte gaps ≈ 4.2 ms at
|
||||||
|
// 9600 baud. An unpaced writer would land them in well under 1 ms.
|
||||||
|
WaitFor(() => client.Data.Length >= 5);
|
||||||
|
long[] ticks = client.DataTicks;
|
||||||
|
double spanMs = (ticks[4] - ticks[0]) * 1000.0 / Stopwatch.Frequency;
|
||||||
|
|
||||||
|
Assert.True(spanMs >= 2.5, $"5 reply bytes arrived in {spanMs:F2} ms — pipe TX is not paced");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_frame_type_drops_the_client_and_server_recovers()
|
||||||
|
{
|
||||||
|
string name = UniqueName();
|
||||||
|
var device = new VRioDevice();
|
||||||
|
using var service = new VRioPipeService(device, name);
|
||||||
|
service.Start();
|
||||||
|
|
||||||
|
using (var bad = new TestClient(name))
|
||||||
|
{
|
||||||
|
WaitFor(() => bad.Lines.Length > 0);
|
||||||
|
bad.SendRaw(0x7F); // not a frame type
|
||||||
|
WaitFor(() => bad.Closed); // contract: log + drop, no resync
|
||||||
|
}
|
||||||
|
|
||||||
|
// The listener must come back for the next client.
|
||||||
|
using var good = new TestClient(name);
|
||||||
|
WaitFor(() => good.Lines.Length > 0);
|
||||||
|
Assert.Equal(PipeFraming.LinesPresent, good.Lines[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Client_disconnect_drops_dtr_low()
|
||||||
|
{
|
||||||
|
string name = UniqueName();
|
||||||
|
var device = new VRioDevice();
|
||||||
|
using var service = new VRioPipeService(device, name);
|
||||||
|
var edges = new List<bool>();
|
||||||
|
service.HostHandshake += high => { lock (edges) edges.Add(high); };
|
||||||
|
service.Start();
|
||||||
|
|
||||||
|
using (var client = new TestClient(name))
|
||||||
|
{
|
||||||
|
client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr);
|
||||||
|
WaitFor(() => { lock (edges) return edges.Count == 1; });
|
||||||
|
} // client leaves with DTR still high
|
||||||
|
|
||||||
|
WaitFor(() => { lock (edges) return edges.Count == 2; });
|
||||||
|
lock (edges)
|
||||||
|
Assert.Equal(new[] { true, false }, edges);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Data_split_mid_frame_still_reaches_the_device()
|
||||||
|
{
|
||||||
|
string name = UniqueName();
|
||||||
|
var device = new VRioDevice();
|
||||||
|
using var service = new VRioPipeService(device, name);
|
||||||
|
var lamps = new List<(int Address, byte State)>();
|
||||||
|
device.LampChanged += (address, state) => { lock (lamps) lamps.Add((address, state)); };
|
||||||
|
service.Start();
|
||||||
|
|
||||||
|
using var client = new TestClient(name);
|
||||||
|
WaitFor(() => client.Lines.Length > 0);
|
||||||
|
|
||||||
|
// LampRequest 0x00 ← 0x01, framed, then torn in half mid-payload.
|
||||||
|
byte[] packet = PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x00, 0x01 });
|
||||||
|
var frame = new byte[] { PipeFraming.DataType, (byte)packet.Length }.Concat(packet).ToArray();
|
||||||
|
client.SendRaw(frame.Take(3).ToArray());
|
||||||
|
Thread.Sleep(20);
|
||||||
|
client.SendRaw(frame.Skip(3).ToArray());
|
||||||
|
|
||||||
|
WaitFor(() => { lock (lamps) return lamps.Count == 1; });
|
||||||
|
lock (lamps)
|
||||||
|
Assert.Equal((0x00, (byte)0x01), lamps[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ public class VRioDeviceTests
|
|||||||
var device = new VRioDevice();
|
var device = new VRioDevice();
|
||||||
var wire = new Wire(device);
|
var wire = new Wire(device);
|
||||||
device.SetAxis(RioAxis.Throttle, 1000);
|
device.SetAxis(RioAxis.Throttle, 1000);
|
||||||
|
device.SetAxis(RioAxis.JoystickY, 3000);
|
||||||
device.SetAxis(RioAxis.JoystickX, -5000);
|
device.SetAxis(RioAxis.JoystickX, -5000);
|
||||||
|
|
||||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
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(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[2], p[3])); // left pedal
|
||||||
Assert.Equal(0, AnalogCodec.Combine(p[4], p[5])); // right 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(-5000, AnalogCodec.Combine(p[8], p[9])); // joystick X
|
||||||
Assert.Equal(1, device.AnalogRequests);
|
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]
|
[Fact]
|
||||||
public void VersionRequest_reports_configured_firmware()
|
public void VersionRequest_reports_configured_firmware()
|
||||||
{
|
{
|
||||||
@@ -79,22 +116,35 @@ public class VRioDeviceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CheckRequest_reports_every_board_ok()
|
public void CheckRequest_enters_test_mode_reports_boards_then_exits()
|
||||||
{
|
{
|
||||||
var device = new VRioDevice();
|
var device = new VRioDevice();
|
||||||
var wire = new Wire(device);
|
var wire = new Wire(device);
|
||||||
|
|
||||||
Send(device, PacketBuilder.Build(RioCommand.CheckRequest));
|
Send(device, PacketBuilder.Build(RioCommand.CheckRequest));
|
||||||
|
|
||||||
Assert.Equal(RioAddressSpace.Boards.Count, wire.Packets.Count);
|
// The init handshake: TestModeChange ENTER, one CheckReply per board,
|
||||||
Assert.All(wire.Packets, p =>
|
// 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(RioCommand.CheckReply, p.Command);
|
||||||
Assert.Equal((byte)RioStatusType.BoardOk, p.Payload[0]);
|
Assert.Equal((byte)RioStatusType.BoardOk, p.Payload[0]);
|
||||||
});
|
});
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
RioAddressSpace.Boards.Select(b => b.Number),
|
RioAddressSpace.Boards.Select(b => b.Number),
|
||||||
wire.Packets.Select(p => p.Payload[1]));
|
checks.Select(p => p.Payload[1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||