Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0674cf5ba4 | ||
|
|
90bf6723dc | ||
|
|
359e1c0a40 | ||
|
|
c6e0522408 | ||
|
|
9a1c8478bf | ||
|
|
19a1abbff2 | ||
|
|
a1b7dae3da |
@@ -10,8 +10,18 @@ Build + tests (tests are CI's job; run the app for verification):
|
|||||||
```powershell
|
```powershell
|
||||||
dotnet build vrio.sln -v q -nologo # Debug
|
dotnet build vrio.sln -v q -nologo # Debug
|
||||||
# exe: src\VRio.App\bin\Debug\net48\VRio.App.exe
|
# 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
|
## Driving the GUI without touching the user's desktop
|
||||||
|
|
||||||
This is the user's live desktop — **never** use SetForegroundWindow +
|
This is the user's live desktop — **never** use SetForegroundWindow +
|
||||||
@@ -33,6 +43,14 @@ VS Code and your clicks land in the user's windows. Instead:
|
|||||||
(they surface as bare Panes) and don't answer BM_GETCHECK — but
|
(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
|
`SendMessage(hwnd, BM_CLICK, 0, 0)` works. Get the HWND from the UIA
|
||||||
element's NativeWindowHandle (find by Name).
|
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,
|
- Pattern that works: Add-Type user32 P/Invokes, Start-Process the exe,
|
||||||
drive via messages, PrintWindow screenshot to the scratchpad, kill
|
drive via messages, PrintWindow screenshot to the scratchpad, kill
|
||||||
the process in `finally`.
|
the process in `finally`.
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,206 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
**Parked pending a firmware dump.** The software emulator (vPLASMA) is built
|
||||||
|
and released; this hardware/protocol thread is blocked on getting the U3
|
||||||
|
EPROM image (or the Babcock programming manual). Resume at the dump plan
|
||||||
|
above once a dump is in hand.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 675 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 896 KiB |
Binary file not shown.
|
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
|
||||||
@@ -91,24 +93,68 @@ device behavior grounded in the **real v4.2 firmware dump**
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
## 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 port row in the control strip (label colour = port
|
||||||
|
status; auto-opens **COM12** at startup when present). 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 through a 5×7 font at a cursor:
|
||||||
|
fonts 0–3 give a 21×4 cell grid, fonts 4–7 the same glyphs doubled to
|
||||||
|
10×2. Half-intensity draws dimmer, reverse/underline render in the
|
||||||
|
cell, flashing text (and the flashing cursor) blink on the glass. The
|
||||||
|
panel's own ROM glyphs are lost with the hardware, so the classic
|
||||||
|
public-domain 5×7 set stands in.
|
||||||
|
- **Double-click** the glass to cycle three self-test pages (banner,
|
||||||
|
charset, graphics pattern) through the same parser the wire feeds —
|
||||||
|
useful without a host. **Right-click** resets the display to its
|
||||||
|
power-on state.
|
||||||
|
- Pair it with the game like vRIO ↔ RIOJoy: a second com0com null-modem
|
||||||
|
pair `COM2 ⇄ COM12` — the game's plasma output writes COM2, vPLASMA
|
||||||
|
listens on COM12. The plasma never talks back, so there is no transmit
|
||||||
|
path to pace.
|
||||||
|
|
||||||
## Repository layout
|
## 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, 5×7 font, serial listener (class library) |
|
||||||
|
| `src/VPlasma.App` | WinForms dot-matrix display UI |
|
||||||
| `pkg` | Sparse-package manifest + registration script: grants VRio.App.exe the package identity Dynamic Lighting's background-control list requires |
|
| `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,104 @@
|
|||||||
|
using VPlasma.Core.Device;
|
||||||
|
|
||||||
|
namespace VPlasma.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// vPLASMA main window: just the plasma glass, sized to the display. The
|
||||||
|
/// device end is hardwired to <see cref="PortName"/> (the plasma side of the
|
||||||
|
/// second com0com pair) and opened automatically — a retry timer keeps
|
||||||
|
/// trying while the port is missing or busy, and reopens it if it dies. The
|
||||||
|
/// title bar carries the port status; double-clicking the glass cycles the
|
||||||
|
/// self-test pages (banner, charset, graphics) through the wire parser, and
|
||||||
|
/// a right-click resets the display to its power-on state.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MainForm : Form
|
||||||
|
{
|
||||||
|
/// <summary>The plasma's fixed port: the device end of the COM2 pair.</summary>
|
||||||
|
private const string PortName = "COM12";
|
||||||
|
|
||||||
|
private readonly VPlasmaDevice _device = new();
|
||||||
|
private readonly VPlasmaSerialService _service;
|
||||||
|
private readonly PlasmaCanvas _canvas = new();
|
||||||
|
|
||||||
|
private readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 };
|
||||||
|
private int _selfTestPage;
|
||||||
|
|
||||||
|
public MainForm()
|
||||||
|
{
|
||||||
|
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedSingle; // the glass is a fixed-size device
|
||||||
|
MaximizeBox = false;
|
||||||
|
ClientSize = _canvas.Size;
|
||||||
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
|
|
||||||
|
_service = new VPlasmaSerialService(_device);
|
||||||
|
|
||||||
|
_canvas.Location = new Point(0, 0);
|
||||||
|
Controls.Add(_canvas);
|
||||||
|
|
||||||
|
// Device / service events arrive on the serial reader thread; marshal to the UI.
|
||||||
|
_device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device));
|
||||||
|
_service.ConnectionChanged += _ => RunOnUi(UpdateTitle);
|
||||||
|
|
||||||
|
_canvas.DoubleClick += (_, _) => RunSelfTest();
|
||||||
|
_canvas.MouseClick += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Button == MouseButtons.Right)
|
||||||
|
_device.Reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open at startup, retry while closed (port missing/busy, host restarts).
|
||||||
|
_reconnectTimer.Tick += (_, _) => EnsureOpen();
|
||||||
|
_reconnectTimer.Start();
|
||||||
|
|
||||||
|
FormClosed += (_, _) =>
|
||||||
|
{
|
||||||
|
_reconnectTimer.Dispose();
|
||||||
|
_service.Dispose();
|
||||||
|
};
|
||||||
|
|
||||||
|
_canvas.UpdateFrom(_device);
|
||||||
|
EnsureOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureOpen()
|
||||||
|
{
|
||||||
|
if (_service.IsOpen)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_service.Open(PortName);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
|
||||||
|
{
|
||||||
|
UpdateTitle(); // stays closed; the timer tries again
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateTitle()
|
||||||
|
{
|
||||||
|
// ProductVersion carries the git stamp (see StampGitVersion in the csproj).
|
||||||
|
string status = _service.IsOpen
|
||||||
|
? $"{PortName} @ 9600 8N1"
|
||||||
|
: $"{PortName} unavailable — retrying";
|
||||||
|
Text = $"vPLASMA v{Application.ProductVersion} — {status}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunSelfTest()
|
||||||
|
{
|
||||||
|
byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
|
||||||
|
_device.OnReceived(bytes, bytes.Length);
|
||||||
|
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunOnUi(Action action)
|
||||||
|
{
|
||||||
|
if (IsDisposed || Disposing)
|
||||||
|
return;
|
||||||
|
if (InvokeRequired)
|
||||||
|
BeginInvoke(action);
|
||||||
|
else
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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 VPlasmaDevice.Point _cursor;
|
||||||
|
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);
|
||||||
|
_cursor = device.CursorCell;
|
||||||
|
_cursorMode = device.CursorMode;
|
||||||
|
_cellWidth = device.CellWidth;
|
||||||
|
_cellHeight = device.CellHeight;
|
||||||
|
|
||||||
|
_anythingBlinks = _cursorMode == PlasmaCursorMode.Flashing;
|
||||||
|
if (!_anythingBlinks)
|
||||||
|
foreach (byte dot in _frame)
|
||||||
|
if ((dot & VPlasmaDevice.PixelFlash) != 0)
|
||||||
|
{
|
||||||
|
_anythingBlinks = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPaint(PaintEventArgs e)
|
||||||
|
{
|
||||||
|
Graphics g = e.Graphics;
|
||||||
|
g.Clear(BezelColor);
|
||||||
|
|
||||||
|
using (var glass = new SolidBrush(GlassColor))
|
||||||
|
g.FillRectangle(glass, Bezel - 4, Bezel - 4,
|
||||||
|
VPlasmaDevice.Width * _dotPitch + 7, VPlasmaDevice.Height * _dotPitch + 7);
|
||||||
|
|
||||||
|
using var unlit = new SolidBrush(UnlitDot);
|
||||||
|
using var lit = new SolidBrush(LitDot);
|
||||||
|
using var half = new SolidBrush(HalfDot);
|
||||||
|
|
||||||
|
for (int y = 0; y < VPlasmaDevice.Height; ++y)
|
||||||
|
{
|
||||||
|
int rowOffset = y * VPlasmaDevice.Width;
|
||||||
|
int py = Bezel + y * _dotPitch;
|
||||||
|
for (int x = 0; x < VPlasmaDevice.Width; ++x)
|
||||||
|
{
|
||||||
|
byte dot = _frame[rowOffset + x];
|
||||||
|
Brush brush = unlit;
|
||||||
|
if ((dot & VPlasmaDevice.PixelLit) != 0
|
||||||
|
&& ((dot & VPlasmaDevice.PixelFlash) == 0 || _blinkPhase))
|
||||||
|
{
|
||||||
|
brush = (dot & VPlasmaDevice.PixelHalf) != 0 ? half : lit;
|
||||||
|
}
|
||||||
|
g.FillRectangle(brush, Bezel + x * _dotPitch, py, _dotSize, _dotSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Underline cursor on the bottom dot-row of its cell.
|
||||||
|
if (_cursorMode == PlasmaCursorMode.Steady
|
||||||
|
|| (_cursorMode == PlasmaCursorMode.Flashing && _blinkPhase))
|
||||||
|
{
|
||||||
|
int cx = _cursor.Col * _cellWidth;
|
||||||
|
int cy = _cursor.Row * _cellHeight + _cellHeight - 1;
|
||||||
|
if (cy < VPlasmaDevice.Height)
|
||||||
|
for (int i = 0; i < _cellWidth && cx + i < VPlasmaDevice.Width; ++i)
|
||||||
|
g.FillRectangle(lit, Bezel + (cx + i) * _dotPitch, Bezel + cy * _dotPitch, _dotSize, _dotSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
_blinkTimer.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,130 @@
|
|||||||
|
namespace VPlasma.Core.Device;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The display's character generator: a classic 5×7 dot-matrix font for
|
||||||
|
/// ASCII 0x20..0x7E, stored column-major (5 column bytes per glyph, bit 0 =
|
||||||
|
/// top row) — the layout every KS0108-era controller used. The real panel's
|
||||||
|
/// ROM glyphs are lost with the hardware; this is the standard public-domain
|
||||||
|
/// 5×7 set, which is what such panels shipped with. Codes outside the range
|
||||||
|
/// render as a solid block so stream corruption is visible on the glass.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlasmaFont
|
||||||
|
{
|
||||||
|
public const int GlyphWidth = 5;
|
||||||
|
public const int GlyphHeight = 7;
|
||||||
|
public const byte First = 0x20;
|
||||||
|
public const byte Last = 0x7E;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Column bits for <paramref name="code"/>'s glyph. Bit r of
|
||||||
|
/// <c>result[c]</c> is the dot at column c, row r (row 0 at the top).
|
||||||
|
/// </summary>
|
||||||
|
public static void GetColumns(byte code, Span<byte> columns)
|
||||||
|
{
|
||||||
|
if (code < First || code > Last)
|
||||||
|
{
|
||||||
|
columns.Slice(0, GlyphWidth).Fill(0x7F); // solid block
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Glyphs.AsSpan((code - First) * GlyphWidth, GlyphWidth).CopyTo(columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly byte[] Glyphs =
|
||||||
|
{
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, // 0x20 ' '
|
||||||
|
0x00, 0x00, 0x5F, 0x00, 0x00, // 0x21 '!'
|
||||||
|
0x00, 0x07, 0x00, 0x07, 0x00, // 0x22 '"'
|
||||||
|
0x14, 0x7F, 0x14, 0x7F, 0x14, // 0x23 '#'
|
||||||
|
0x24, 0x2A, 0x7F, 0x2A, 0x12, // 0x24 '$'
|
||||||
|
0x23, 0x13, 0x08, 0x64, 0x62, // 0x25 '%'
|
||||||
|
0x36, 0x49, 0x55, 0x22, 0x50, // 0x26 '&'
|
||||||
|
0x00, 0x05, 0x03, 0x00, 0x00, // 0x27 '''
|
||||||
|
0x00, 0x1C, 0x22, 0x41, 0x00, // 0x28 '('
|
||||||
|
0x00, 0x41, 0x22, 0x1C, 0x00, // 0x29 ')'
|
||||||
|
0x08, 0x2A, 0x1C, 0x2A, 0x08, // 0x2A '*'
|
||||||
|
0x08, 0x08, 0x3E, 0x08, 0x08, // 0x2B '+'
|
||||||
|
0x00, 0x50, 0x30, 0x00, 0x00, // 0x2C ','
|
||||||
|
0x08, 0x08, 0x08, 0x08, 0x08, // 0x2D '-'
|
||||||
|
0x00, 0x60, 0x60, 0x00, 0x00, // 0x2E '.'
|
||||||
|
0x20, 0x10, 0x08, 0x04, 0x02, // 0x2F '/'
|
||||||
|
0x3E, 0x51, 0x49, 0x45, 0x3E, // 0x30 '0'
|
||||||
|
0x00, 0x42, 0x7F, 0x40, 0x00, // 0x31 '1'
|
||||||
|
0x42, 0x61, 0x51, 0x49, 0x46, // 0x32 '2'
|
||||||
|
0x21, 0x41, 0x45, 0x4B, 0x31, // 0x33 '3'
|
||||||
|
0x18, 0x14, 0x12, 0x7F, 0x10, // 0x34 '4'
|
||||||
|
0x27, 0x45, 0x45, 0x45, 0x39, // 0x35 '5'
|
||||||
|
0x3C, 0x4A, 0x49, 0x49, 0x30, // 0x36 '6'
|
||||||
|
0x01, 0x71, 0x09, 0x05, 0x03, // 0x37 '7'
|
||||||
|
0x36, 0x49, 0x49, 0x49, 0x36, // 0x38 '8'
|
||||||
|
0x06, 0x49, 0x49, 0x29, 0x1E, // 0x39 '9'
|
||||||
|
0x00, 0x36, 0x36, 0x00, 0x00, // 0x3A ':'
|
||||||
|
0x00, 0x56, 0x36, 0x00, 0x00, // 0x3B ';'
|
||||||
|
0x00, 0x08, 0x14, 0x22, 0x41, // 0x3C '<'
|
||||||
|
0x14, 0x14, 0x14, 0x14, 0x14, // 0x3D '='
|
||||||
|
0x41, 0x22, 0x14, 0x08, 0x00, // 0x3E '>'
|
||||||
|
0x02, 0x01, 0x51, 0x09, 0x06, // 0x3F '?'
|
||||||
|
0x32, 0x49, 0x79, 0x41, 0x3E, // 0x40 '@'
|
||||||
|
0x7E, 0x11, 0x11, 0x11, 0x7E, // 0x41 'A'
|
||||||
|
0x7F, 0x49, 0x49, 0x49, 0x36, // 0x42 'B'
|
||||||
|
0x3E, 0x41, 0x41, 0x41, 0x22, // 0x43 'C'
|
||||||
|
0x7F, 0x41, 0x41, 0x22, 0x1C, // 0x44 'D'
|
||||||
|
0x7F, 0x49, 0x49, 0x49, 0x41, // 0x45 'E'
|
||||||
|
0x7F, 0x09, 0x09, 0x09, 0x01, // 0x46 'F'
|
||||||
|
0x3E, 0x41, 0x49, 0x49, 0x7A, // 0x47 'G'
|
||||||
|
0x7F, 0x08, 0x08, 0x08, 0x7F, // 0x48 'H'
|
||||||
|
0x00, 0x41, 0x7F, 0x41, 0x00, // 0x49 'I'
|
||||||
|
0x20, 0x40, 0x41, 0x3F, 0x01, // 0x4A 'J'
|
||||||
|
0x7F, 0x08, 0x14, 0x22, 0x41, // 0x4B 'K'
|
||||||
|
0x7F, 0x40, 0x40, 0x40, 0x40, // 0x4C 'L'
|
||||||
|
0x7F, 0x02, 0x0C, 0x02, 0x7F, // 0x4D 'M'
|
||||||
|
0x7F, 0x04, 0x08, 0x10, 0x7F, // 0x4E 'N'
|
||||||
|
0x3E, 0x41, 0x41, 0x41, 0x3E, // 0x4F 'O'
|
||||||
|
0x7F, 0x09, 0x09, 0x09, 0x06, // 0x50 'P'
|
||||||
|
0x3E, 0x41, 0x51, 0x21, 0x5E, // 0x51 'Q'
|
||||||
|
0x7F, 0x09, 0x19, 0x29, 0x46, // 0x52 'R'
|
||||||
|
0x46, 0x49, 0x49, 0x49, 0x31, // 0x53 'S'
|
||||||
|
0x01, 0x01, 0x7F, 0x01, 0x01, // 0x54 'T'
|
||||||
|
0x3F, 0x40, 0x40, 0x40, 0x3F, // 0x55 'U'
|
||||||
|
0x1F, 0x20, 0x40, 0x20, 0x1F, // 0x56 'V'
|
||||||
|
0x3F, 0x40, 0x38, 0x40, 0x3F, // 0x57 'W'
|
||||||
|
0x63, 0x14, 0x08, 0x14, 0x63, // 0x58 'X'
|
||||||
|
0x07, 0x08, 0x70, 0x08, 0x07, // 0x59 'Y'
|
||||||
|
0x61, 0x51, 0x49, 0x45, 0x43, // 0x5A 'Z'
|
||||||
|
0x00, 0x7F, 0x41, 0x41, 0x00, // 0x5B '['
|
||||||
|
0x02, 0x04, 0x08, 0x10, 0x20, // 0x5C '\'
|
||||||
|
0x00, 0x41, 0x41, 0x7F, 0x00, // 0x5D ']'
|
||||||
|
0x04, 0x02, 0x01, 0x02, 0x04, // 0x5E '^'
|
||||||
|
0x40, 0x40, 0x40, 0x40, 0x40, // 0x5F '_'
|
||||||
|
0x00, 0x01, 0x02, 0x04, 0x00, // 0x60 '`'
|
||||||
|
0x20, 0x54, 0x54, 0x54, 0x78, // 0x61 'a'
|
||||||
|
0x7F, 0x48, 0x44, 0x44, 0x38, // 0x62 'b'
|
||||||
|
0x38, 0x44, 0x44, 0x44, 0x20, // 0x63 'c'
|
||||||
|
0x38, 0x44, 0x44, 0x48, 0x7F, // 0x64 'd'
|
||||||
|
0x38, 0x54, 0x54, 0x54, 0x18, // 0x65 'e'
|
||||||
|
0x08, 0x7E, 0x09, 0x01, 0x02, // 0x66 'f'
|
||||||
|
0x0C, 0x52, 0x52, 0x52, 0x3E, // 0x67 'g'
|
||||||
|
0x7F, 0x08, 0x04, 0x04, 0x78, // 0x68 'h'
|
||||||
|
0x00, 0x44, 0x7D, 0x40, 0x00, // 0x69 'i'
|
||||||
|
0x20, 0x40, 0x44, 0x3D, 0x00, // 0x6A 'j'
|
||||||
|
0x7F, 0x10, 0x28, 0x44, 0x00, // 0x6B 'k'
|
||||||
|
0x00, 0x41, 0x7F, 0x40, 0x00, // 0x6C 'l'
|
||||||
|
0x7C, 0x04, 0x18, 0x04, 0x78, // 0x6D 'm'
|
||||||
|
0x7C, 0x08, 0x04, 0x04, 0x78, // 0x6E 'n'
|
||||||
|
0x38, 0x44, 0x44, 0x44, 0x38, // 0x6F 'o'
|
||||||
|
0x7C, 0x14, 0x14, 0x14, 0x08, // 0x70 'p'
|
||||||
|
0x08, 0x14, 0x14, 0x14, 0x7C, // 0x71 'q'
|
||||||
|
0x7C, 0x08, 0x04, 0x04, 0x08, // 0x72 'r'
|
||||||
|
0x48, 0x54, 0x54, 0x54, 0x20, // 0x73 's'
|
||||||
|
0x04, 0x3F, 0x44, 0x40, 0x20, // 0x74 't'
|
||||||
|
0x3C, 0x40, 0x40, 0x20, 0x7C, // 0x75 'u'
|
||||||
|
0x1C, 0x20, 0x40, 0x20, 0x1C, // 0x76 'v'
|
||||||
|
0x3C, 0x40, 0x30, 0x40, 0x3C, // 0x77 'w'
|
||||||
|
0x44, 0x28, 0x10, 0x28, 0x44, // 0x78 'x'
|
||||||
|
0x0C, 0x50, 0x50, 0x50, 0x3C, // 0x79 'y'
|
||||||
|
0x44, 0x64, 0x54, 0x4C, 0x44, // 0x7A 'z'
|
||||||
|
0x00, 0x08, 0x36, 0x41, 0x00, // 0x7B '{'
|
||||||
|
0x00, 0x00, 0x7F, 0x00, 0x00, // 0x7C '|'
|
||||||
|
0x00, 0x41, 0x36, 0x08, 0x00, // 0x7D '}'
|
||||||
|
0x08, 0x04, 0x08, 0x10, 0x08, // 0x7E '~'
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using VPlasma.Core.Protocol;
|
||||||
|
|
||||||
|
namespace VPlasma.Core.Device;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canned wire streams for the UI's <i>Self test</i> button: each page is a
|
||||||
|
/// byte sequence exactly as a host would send it over COM2, fed through the
|
||||||
|
/// same parser as live traffic — so the button doubles as an end-to-end
|
||||||
|
/// exercise of the command set without a host attached.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlasmaSelfTest
|
||||||
|
{
|
||||||
|
public const int PageCount = 3;
|
||||||
|
|
||||||
|
/// <summary>The wire bytes for self-test page <paramref name="page"/> (0-based).</summary>
|
||||||
|
public static byte[] BuildPage(int page) => page switch
|
||||||
|
{
|
||||||
|
0 => BuildBanner(),
|
||||||
|
1 => BuildCharset(),
|
||||||
|
2 => BuildGraphics(),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(page)),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void Esc(List<byte> b, byte cmd, params byte[] operands)
|
||||||
|
{
|
||||||
|
b.Add(PlasmaProtocol.Esc);
|
||||||
|
b.Add(cmd);
|
||||||
|
b.AddRange(operands);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Text(List<byte> b, string s)
|
||||||
|
{
|
||||||
|
foreach (char c in s)
|
||||||
|
b.Add((byte)c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Big-font banner over small-font attribute samples.</summary>
|
||||||
|
private static byte[] BuildBanner()
|
||||||
|
{
|
||||||
|
var b = new List<byte>();
|
||||||
|
Esc(b, PlasmaProtocol.CmdClearScreen);
|
||||||
|
Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
|
||||||
|
|
||||||
|
Esc(b, PlasmaProtocol.CmdFontSelect, 0x04); // 12×16 cells: 10 × 2
|
||||||
|
Text(b, " vPLASMA");
|
||||||
|
|
||||||
|
Esc(b, PlasmaProtocol.CmdFontSelect, 0x00); // 6×8 cells: 21 × 4
|
||||||
|
Esc(b, PlasmaProtocol.CmdHomeCursor);
|
||||||
|
b.Add(PlasmaProtocol.LineFeed);
|
||||||
|
b.Add(PlasmaProtocol.LineFeed);
|
||||||
|
Text(b, "128x32 PLASMA DISPLAY"); // exactly one 21-cell row
|
||||||
|
|
||||||
|
Esc(b, PlasmaProtocol.CmdAttributes, 1); Text(b, "HALF ");
|
||||||
|
Esc(b, PlasmaProtocol.CmdAttributes, 2); Text(b, "UNDER ");
|
||||||
|
Esc(b, PlasmaProtocol.CmdAttributes, 3); Text(b, "REV");
|
||||||
|
Esc(b, PlasmaProtocol.CmdAttributes, 4); Text(b, " FLASH");
|
||||||
|
Esc(b, PlasmaProtocol.CmdAttributes, PlasmaProtocol.OperandDefault);
|
||||||
|
return b.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The printable set 0x20..0x6F — a full 21×4 grid, minus a cell.</summary>
|
||||||
|
private static byte[] BuildCharset()
|
||||||
|
{
|
||||||
|
var b = new List<byte>();
|
||||||
|
Esc(b, PlasmaProtocol.CmdClearScreen);
|
||||||
|
Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
|
||||||
|
for (byte c = 0x20; c <= 0x6F; ++c)
|
||||||
|
b.Add(c);
|
||||||
|
return b.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The rest of the charset plus an ESC P pattern block.</summary>
|
||||||
|
private static byte[] BuildGraphics()
|
||||||
|
{
|
||||||
|
var b = new List<byte>();
|
||||||
|
Esc(b, PlasmaProtocol.CmdClearScreen);
|
||||||
|
Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
|
||||||
|
for (byte c = 0x70; c <= 0x7E; ++c)
|
||||||
|
b.Add(c);
|
||||||
|
|
||||||
|
// A framed checkerboard sent the way the game sends everything:
|
||||||
|
// full-width ESC P rows (screen 0, xbyte 0, 16 bytes, 1 row each).
|
||||||
|
for (int y = 10; y < VPlasmaDevice.Height; ++y)
|
||||||
|
{
|
||||||
|
Esc(b, PlasmaProtocol.CmdGraphicsWrite,
|
||||||
|
0, (byte)y, 0, VPlasmaDevice.WidthBytes, 1);
|
||||||
|
bool edge = y is 10 or VPlasmaDevice.Height - 1;
|
||||||
|
for (int x = 0; x < VPlasmaDevice.WidthBytes; ++x)
|
||||||
|
{
|
||||||
|
byte fill = edge ? (byte)0xFF : (y & 2) == 0 ? (byte)0xAA : (byte)0x55;
|
||||||
|
if (!edge)
|
||||||
|
{
|
||||||
|
if (x == 0) fill |= 0x80; // left frame edge
|
||||||
|
if (x == VPlasmaDevice.WidthBytes - 1) fill |= 0x01; // right frame edge
|
||||||
|
}
|
||||||
|
b.Add(fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
using VPlasma.Core.Protocol;
|
||||||
|
|
||||||
|
namespace VPlasma.Core.Device;
|
||||||
|
|
||||||
|
/// <summary>How the text cursor is shown (set with <c>ESC G</c>).</summary>
|
||||||
|
public enum PlasmaCursorMode
|
||||||
|
{
|
||||||
|
Hidden,
|
||||||
|
Steady,
|
||||||
|
Flashing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Text rendering attributes (set with <c>ESC H</c>).</summary>
|
||||||
|
[Flags]
|
||||||
|
public enum PlasmaAttributes : byte
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
HalfIntensity = 1,
|
||||||
|
Underline = 2,
|
||||||
|
Reverse = 4,
|
||||||
|
Flash = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The plasma display proper: a 128×32 1bpp frame plus the text-mode state
|
||||||
|
/// (cursor, font, attributes), driven by the byte stream a host writes to
|
||||||
|
/// COM2. Feed raw wire bytes to <see cref="OnReceived"/>; the parser is a
|
||||||
|
/// state machine, so commands may arrive split across any chunk boundaries.
|
||||||
|
///
|
||||||
|
/// <para>Pixels carry flags rather than a plain bit: graphics writes set
|
||||||
|
/// full-intensity dots, while text can stamp half-intensity or flashing dots
|
||||||
|
/// (<c>ESC H</c>); the UI renders <see cref="PixelHalf"/> dimmer and blinks
|
||||||
|
/// <see cref="PixelFlash"/>. The command set itself is documented on
|
||||||
|
/// <see cref="PlasmaProtocol"/>.</para>
|
||||||
|
///
|
||||||
|
/// <para>Grid geometry: fonts 0–3 are the 5×7 set in a 6×8 cell (21 columns
|
||||||
|
/// × 4 rows), fonts 4–7 the same glyphs doubled into a 12×16 cell
|
||||||
|
/// (10 × 2). Which of the eight slots the real panel mapped to which face is
|
||||||
|
/// lost with the hardware; two sizes cover what the surviving software
|
||||||
|
/// exercises.</para>
|
||||||
|
///
|
||||||
|
/// <para>Thread-safe: the serial reader feeds bytes while the UI snapshots
|
||||||
|
/// frames. Events are raised outside the lock, on the caller's thread.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VPlasmaDevice
|
||||||
|
{
|
||||||
|
public const int Width = 128;
|
||||||
|
public const int Height = 32;
|
||||||
|
public const int WidthBytes = Width / 8;
|
||||||
|
|
||||||
|
// Per-pixel flag bits in the frame buffer.
|
||||||
|
public const byte PixelLit = 0x01;
|
||||||
|
public const byte PixelHalf = 0x02;
|
||||||
|
public const byte PixelFlash = 0x04;
|
||||||
|
|
||||||
|
private readonly object _sync = new();
|
||||||
|
private readonly byte[] _pixels = new byte[Width * Height];
|
||||||
|
|
||||||
|
// ---- text-mode state -------------------------------------------------
|
||||||
|
private int _font; // 0..7
|
||||||
|
private PlasmaAttributes _attributes;
|
||||||
|
private int _col, _row; // cursor, in cells of the current grid
|
||||||
|
private PlasmaCursorMode _cursorMode = PlasmaCursorMode.Steady; // power-on default; the game hides it
|
||||||
|
|
||||||
|
// ---- parser state ----------------------------------------------------
|
||||||
|
private enum State
|
||||||
|
{
|
||||||
|
Text, // printable chars + control bytes
|
||||||
|
Escape, // got ESC, awaiting the command letter
|
||||||
|
Operand, // awaiting the 1-byte operand of _pendingCommand
|
||||||
|
GraphicsHeader, // collecting ESC P's 5 header bytes
|
||||||
|
GraphicsData, // consuming ESC P's w*h data bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private State _state;
|
||||||
|
private byte _pendingCommand;
|
||||||
|
private readonly byte[] _header = new byte[5]; // screen, y, x, w, h
|
||||||
|
private int _headerFill;
|
||||||
|
private int _dataIndex, _dataLength;
|
||||||
|
|
||||||
|
private bool _dirty; // frame/cursor changed during this chunk
|
||||||
|
private List<string>? _pendingLog; // lines queued under the lock
|
||||||
|
private bool _graphicsLogArmed = true; // log the first ESC P of a stream, then go quiet
|
||||||
|
private readonly HashSet<byte> _loggedUnknown = new();
|
||||||
|
|
||||||
|
private long _bytesReceived, _graphicsRows, _textCharsDrawn;
|
||||||
|
|
||||||
|
/// <summary>Frame or cursor changed. Raised on the feeding thread.</summary>
|
||||||
|
public event Action? Updated;
|
||||||
|
|
||||||
|
/// <summary>Decoded-command log lines. Raised on the feeding thread.</summary>
|
||||||
|
public event Action<string>? Logged;
|
||||||
|
|
||||||
|
public long BytesReceived { get { lock (_sync) return _bytesReceived; } }
|
||||||
|
public long GraphicsRows { get { lock (_sync) return _graphicsRows; } }
|
||||||
|
public long TextCharsDrawn { get { lock (_sync) return _textCharsDrawn; } }
|
||||||
|
|
||||||
|
public PlasmaCursorMode CursorMode { get { lock (_sync) return _cursorMode; } }
|
||||||
|
public int Font { get { lock (_sync) return _font; } }
|
||||||
|
public PlasmaAttributes Attributes { get { lock (_sync) return _attributes; } }
|
||||||
|
|
||||||
|
// Current font grid, for the UI's cursor overlay and status line.
|
||||||
|
private int FontScale => _font >= 4 ? 2 : 1;
|
||||||
|
public int CellWidth { get { lock (_sync) return 6 * FontScale; } }
|
||||||
|
public int CellHeight { get { lock (_sync) return 8 * FontScale; } }
|
||||||
|
private int Columns => Width / (6 * FontScale);
|
||||||
|
private int Rows => Height / (8 * FontScale);
|
||||||
|
public Point CursorCell { get { lock (_sync) return new Point(_col, _row); } }
|
||||||
|
|
||||||
|
/// <summary>A cursor cell position (avoids dragging in System.Drawing).</summary>
|
||||||
|
public readonly record struct Point(int Col, int Row);
|
||||||
|
|
||||||
|
/// <summary>Copy the frame into <paramref name="destination"/> (Width*Height flag bytes).</summary>
|
||||||
|
public void CopyFrame(byte[] destination)
|
||||||
|
{
|
||||||
|
if (destination.Length < _pixels.Length)
|
||||||
|
throw new ArgumentException("Buffer too small.", nameof(destination));
|
||||||
|
lock (_sync)
|
||||||
|
Buffer.BlockCopy(_pixels, 0, destination, 0, _pixels.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Power-on state: dark glass, home cursor, defaults.</summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
Array.Clear(_pixels, 0, _pixels.Length);
|
||||||
|
_col = _row = 0;
|
||||||
|
_font = 0;
|
||||||
|
_attributes = PlasmaAttributes.None;
|
||||||
|
_cursorMode = PlasmaCursorMode.Steady;
|
||||||
|
_state = State.Text;
|
||||||
|
_dirty = true;
|
||||||
|
}
|
||||||
|
FlushEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Feed <paramref name="count"/> received wire bytes.</summary>
|
||||||
|
public void OnReceived(byte[] buffer, int count)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
_bytesReceived += count;
|
||||||
|
for (int i = 0; i < count; ++i)
|
||||||
|
Step(buffer[i]);
|
||||||
|
}
|
||||||
|
FlushEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- parser ------------------------------------------------------------
|
||||||
|
|
||||||
|
private void Step(byte b)
|
||||||
|
{
|
||||||
|
switch (_state)
|
||||||
|
{
|
||||||
|
case State.Text:
|
||||||
|
StepText(b);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.Escape:
|
||||||
|
StepEscape(b);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.Operand:
|
||||||
|
_state = State.Text;
|
||||||
|
ApplyOperand(_pendingCommand, b);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.GraphicsHeader:
|
||||||
|
_header[_headerFill++] = b;
|
||||||
|
if (_headerFill == _header.Length)
|
||||||
|
BeginGraphicsData();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.GraphicsData:
|
||||||
|
StepGraphicsData(b);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StepText(byte b)
|
||||||
|
{
|
||||||
|
switch (b)
|
||||||
|
{
|
||||||
|
case PlasmaProtocol.Esc:
|
||||||
|
_state = State.Escape;
|
||||||
|
return;
|
||||||
|
|
||||||
|
case PlasmaProtocol.BackSpace:
|
||||||
|
if (_col > 0) _col--;
|
||||||
|
_dirty = true;
|
||||||
|
return;
|
||||||
|
|
||||||
|
case PlasmaProtocol.HorizontalTab:
|
||||||
|
AdvanceCursor();
|
||||||
|
_dirty = true;
|
||||||
|
return;
|
||||||
|
|
||||||
|
case PlasmaProtocol.LineFeed:
|
||||||
|
_row = (_row + 1) % Rows;
|
||||||
|
_dirty = true;
|
||||||
|
return;
|
||||||
|
|
||||||
|
case PlasmaProtocol.VerticalTab:
|
||||||
|
_row = (_row + Rows - 1) % Rows;
|
||||||
|
_dirty = true;
|
||||||
|
return;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CarriageReturn:
|
||||||
|
_col = 0;
|
||||||
|
_dirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b < 0x20)
|
||||||
|
{
|
||||||
|
// A control byte the surviving software never sends: swallow it,
|
||||||
|
// but say so once per value — it's the tell of a desynced stream.
|
||||||
|
if (_loggedUnknown.Add(b))
|
||||||
|
Log($"Unhandled control byte 0x{b:X2} ignored");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawChar(b);
|
||||||
|
_graphicsLogArmed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StepEscape(byte b)
|
||||||
|
{
|
||||||
|
_state = State.Text;
|
||||||
|
switch (b)
|
||||||
|
{
|
||||||
|
case PlasmaProtocol.CmdClearScreen:
|
||||||
|
Array.Clear(_pixels, 0, _pixels.Length);
|
||||||
|
_col = _row = 0;
|
||||||
|
_font = 0;
|
||||||
|
_attributes = PlasmaAttributes.None;
|
||||||
|
_dirty = true;
|
||||||
|
Log("Clear screen (ESC @)");
|
||||||
|
_graphicsLogArmed = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdHomeCursor:
|
||||||
|
_col = _row = 0;
|
||||||
|
_dirty = true;
|
||||||
|
Log("Home cursor (ESC L)");
|
||||||
|
_graphicsLogArmed = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdCursorMode:
|
||||||
|
case PlasmaProtocol.CmdFontSelect:
|
||||||
|
case PlasmaProtocol.CmdAttributes:
|
||||||
|
_pendingCommand = b;
|
||||||
|
_state = State.Operand;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdGraphicsWrite:
|
||||||
|
_headerFill = 0;
|
||||||
|
_state = State.GraphicsHeader;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (_loggedUnknown.Add(b))
|
||||||
|
Log($"Unknown command ESC 0x{b:X2} ('{(char)b}') ignored");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyOperand(byte command, byte operand)
|
||||||
|
{
|
||||||
|
switch (command)
|
||||||
|
{
|
||||||
|
case PlasmaProtocol.CmdCursorMode:
|
||||||
|
// The game hides the cursor with 00, the test tool with FF;
|
||||||
|
// 01 shows it steady, 03 flashing (bit 1 = blink).
|
||||||
|
_cursorMode =
|
||||||
|
operand is 0x00 or 0xFF ? PlasmaCursorMode.Hidden :
|
||||||
|
(operand & 0x02) != 0 ? PlasmaCursorMode.Flashing :
|
||||||
|
PlasmaCursorMode.Steady;
|
||||||
|
_dirty = true;
|
||||||
|
Log($"Cursor {_cursorMode} (ESC G {operand:X2})");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdFontSelect:
|
||||||
|
_font = operand == PlasmaProtocol.OperandDefault ? 0 : operand & 0x07;
|
||||||
|
// The cursor keeps its cell coordinates but the grid changed size.
|
||||||
|
_col = Math.Min(_col, Columns - 1);
|
||||||
|
_row = Math.Min(_row, Rows - 1);
|
||||||
|
_dirty = true;
|
||||||
|
Log($"Font {_font}: {Columns}×{Rows} cells (ESC K {operand:X2})");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdAttributes:
|
||||||
|
_attributes = DecodeAttributes(operand);
|
||||||
|
Log($"Attributes {(_attributes == PlasmaAttributes.None ? "default" : _attributes.ToString())} (ESC H {operand:X2})");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_graphicsLogArmed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ESC H</c>'s operand indexes the style list PLASMA.EXE enumerates
|
||||||
|
/// (its <c>/s</c> option): the 17 intensity/underline/reverse/flash
|
||||||
|
/// combos below, in the tool's own order. FF (and anything out of range)
|
||||||
|
/// restores the defaults.
|
||||||
|
/// </summary>
|
||||||
|
private static PlasmaAttributes DecodeAttributes(byte operand) => operand switch
|
||||||
|
{
|
||||||
|
0 => PlasmaAttributes.None,
|
||||||
|
1 => PlasmaAttributes.HalfIntensity,
|
||||||
|
2 => PlasmaAttributes.Underline,
|
||||||
|
3 => PlasmaAttributes.Reverse,
|
||||||
|
4 => PlasmaAttributes.Flash,
|
||||||
|
5 => PlasmaAttributes.Underline,
|
||||||
|
6 => PlasmaAttributes.Reverse,
|
||||||
|
7 => PlasmaAttributes.Flash,
|
||||||
|
8 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline,
|
||||||
|
9 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Reverse,
|
||||||
|
10 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Flash,
|
||||||
|
11 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
|
||||||
|
12 => PlasmaAttributes.Underline | PlasmaAttributes.Flash,
|
||||||
|
13 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
|
||||||
|
14 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
|
||||||
|
15 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Flash,
|
||||||
|
16 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
|
||||||
|
_ => PlasmaAttributes.None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- graphics writes (ESC P) -------------------------------------------
|
||||||
|
|
||||||
|
private void BeginGraphicsData()
|
||||||
|
{
|
||||||
|
int w = _header[3], h = _header[4];
|
||||||
|
_dataLength = w * h;
|
||||||
|
_dataIndex = 0;
|
||||||
|
|
||||||
|
if (_graphicsLogArmed)
|
||||||
|
{
|
||||||
|
// The game streams row upon row; log the first of a run only.
|
||||||
|
_graphicsLogArmed = false;
|
||||||
|
Log($"Graphics stream: screen={_header[0]} y={_header[1]} xbyte={_header[2]} " +
|
||||||
|
$"{w} byte(s)/row × {h} row(s) (further rows counted silently)");
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = _dataLength > 0 ? State.GraphicsData : State.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StepGraphicsData(byte b)
|
||||||
|
{
|
||||||
|
int w = _header[3];
|
||||||
|
int rowOfBlock = _dataIndex / w;
|
||||||
|
int byteOfRow = _dataIndex % w;
|
||||||
|
|
||||||
|
int y = _header[1] + rowOfBlock;
|
||||||
|
int xByte = _header[2] + byteOfRow;
|
||||||
|
if (y < Height && xByte < WidthBytes)
|
||||||
|
{
|
||||||
|
// MSB is the leftmost pixel (L4PLASMA.CPP packs 0x80 first).
|
||||||
|
// Graphics dots are plain full intensity: overwriting text clears
|
||||||
|
// its half/flash flags, like repainting the glass.
|
||||||
|
int offset = y * Width + xByte * 8;
|
||||||
|
for (int bit = 0; bit < 8; ++bit)
|
||||||
|
_pixels[offset + bit] = (b & (0x80 >> bit)) != 0 ? PixelLit : (byte)0;
|
||||||
|
_dirty = true;
|
||||||
|
if (byteOfRow == w - 1)
|
||||||
|
_graphicsRows++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (++_dataIndex >= _dataLength)
|
||||||
|
_state = State.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- text rendering ------------------------------------------------------
|
||||||
|
|
||||||
|
private void DrawChar(byte code)
|
||||||
|
{
|
||||||
|
int scale = FontScale;
|
||||||
|
int cellW = 6 * scale, cellH = 8 * scale;
|
||||||
|
int ox = _col * cellW, oy = _row * cellH;
|
||||||
|
|
||||||
|
Span<byte> columns = stackalloc byte[PlasmaFont.GlyphWidth];
|
||||||
|
PlasmaFont.GetColumns(code, columns);
|
||||||
|
|
||||||
|
bool reverse = (_attributes & PlasmaAttributes.Reverse) != 0;
|
||||||
|
bool underline = (_attributes & PlasmaAttributes.Underline) != 0;
|
||||||
|
byte litFlags = PixelLit;
|
||||||
|
if ((_attributes & PlasmaAttributes.HalfIntensity) != 0) litFlags |= PixelHalf;
|
||||||
|
if ((_attributes & PlasmaAttributes.Flash) != 0) litFlags |= PixelFlash;
|
||||||
|
|
||||||
|
for (int cy = 0; cy < cellH; ++cy)
|
||||||
|
{
|
||||||
|
int glyphRow = cy / scale; // 0..7; row 7 is the gap/underline row
|
||||||
|
int rowOffset = (oy + cy) * Width + ox;
|
||||||
|
for (int cx = 0; cx < cellW; ++cx)
|
||||||
|
{
|
||||||
|
int glyphCol = cx / scale; // 0..5; column 5 is the gap column
|
||||||
|
bool on = glyphCol < PlasmaFont.GlyphWidth
|
||||||
|
&& glyphRow < PlasmaFont.GlyphHeight
|
||||||
|
&& (columns[glyphCol] >> glyphRow & 1) != 0;
|
||||||
|
if (underline && glyphRow == 7)
|
||||||
|
on = true;
|
||||||
|
if (reverse)
|
||||||
|
on = !on;
|
||||||
|
_pixels[rowOffset + cx] = on ? litFlags : (byte)0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_textCharsDrawn++;
|
||||||
|
_dirty = true;
|
||||||
|
AdvanceCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AdvanceCursor()
|
||||||
|
{
|
||||||
|
if (++_col >= Columns)
|
||||||
|
{
|
||||||
|
_col = 0;
|
||||||
|
// No scroll on these panels: writing past the last row wraps to the top.
|
||||||
|
if (++_row >= Rows)
|
||||||
|
_row = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- event plumbing --------------------------------------------------------
|
||||||
|
|
||||||
|
private void Log(string line) => (_pendingLog ??= new List<string>()).Add(line);
|
||||||
|
|
||||||
|
/// <summary>Raise queued events outside the lock, on the caller's thread.</summary>
|
||||||
|
private void FlushEvents()
|
||||||
|
{
|
||||||
|
List<string>? log;
|
||||||
|
bool dirty;
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
log = _pendingLog;
|
||||||
|
_pendingLog = null;
|
||||||
|
dirty = _dirty;
|
||||||
|
_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log is not null && Logged is { } logged)
|
||||||
|
foreach (string line in log)
|
||||||
|
logged(line);
|
||||||
|
if (dirty)
|
||||||
|
Updated?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System.IO.Ports;
|
||||||
|
using VPlasma.Core.Protocol;
|
||||||
|
|
||||||
|
namespace VPlasma.Core.Device;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pumps a real COM port into a <see cref="VPlasmaDevice"/> at the plasma's
|
||||||
|
/// 9600 8N1 settings. On a single PC, pair it with the game through a
|
||||||
|
/// virtual null-modem (e.g. com0com): the game's COM2 passthrough opens one
|
||||||
|
/// end, vPLASMA the other.
|
||||||
|
///
|
||||||
|
/// <para>Unlike the RIO, the plasma is a pure listener — the game opens the
|
||||||
|
/// port with flow control disabled and never reads a byte back — so there is
|
||||||
|
/// no transmit path and no wire pacing to emulate. Our DTR/RTS are asserted
|
||||||
|
/// so a host that does check its modem lines sees "display present".</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VPlasmaSerialService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly VPlasmaDevice _device;
|
||||||
|
|
||||||
|
private SerialPort? _port;
|
||||||
|
private Thread? _reader;
|
||||||
|
private volatile bool _running;
|
||||||
|
|
||||||
|
public VPlasmaSerialService(VPlasmaDevice device)
|
||||||
|
{
|
||||||
|
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True while a COM port is open.</summary>
|
||||||
|
public bool IsOpen => _port?.IsOpen == true;
|
||||||
|
|
||||||
|
/// <summary>The open port's name, or null.</summary>
|
||||||
|
public string? PortName => _port?.PortName;
|
||||||
|
|
||||||
|
/// <summary>Raised after the port opens (true) or closes (false).</summary>
|
||||||
|
public event Action<bool>? ConnectionChanged;
|
||||||
|
|
||||||
|
/// <summary>Port-level log lines (open/close/errors).</summary>
|
||||||
|
public event Action<string>? Logged;
|
||||||
|
|
||||||
|
/// <summary>Open <paramref name="portName"/> and start listening.</summary>
|
||||||
|
public void Open(string portName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(portName))
|
||||||
|
throw new ArgumentException("Port name is required.", nameof(portName));
|
||||||
|
|
||||||
|
Close();
|
||||||
|
|
||||||
|
var port = new SerialPort(portName, PlasmaProtocol.BaudRate, Parity.None, 8, StopBits.One)
|
||||||
|
{
|
||||||
|
Handshake = Handshake.None,
|
||||||
|
// Finite read timeout so the reader thread can notice shutdown.
|
||||||
|
ReadTimeout = 200,
|
||||||
|
// Assert our modem lines: through a null modem the host sees
|
||||||
|
// DSR/CTS high, i.e. "display present".
|
||||||
|
DtrEnable = true,
|
||||||
|
RtsEnable = true,
|
||||||
|
};
|
||||||
|
port.Open();
|
||||||
|
|
||||||
|
_port = port;
|
||||||
|
_running = true;
|
||||||
|
|
||||||
|
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "vPLASMA serial reader" };
|
||||||
|
_reader.Start();
|
||||||
|
|
||||||
|
Logged?.Invoke($"Opened {portName} @ {PlasmaProtocol.BaudRate} 8N1 — listening for the host");
|
||||||
|
ConnectionChanged?.Invoke(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Close the port (idempotent).</summary>
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
SerialPort? port = _port;
|
||||||
|
if (port is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_running = false;
|
||||||
|
_port = null;
|
||||||
|
try { port.Close(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
port.Dispose();
|
||||||
|
|
||||||
|
_reader?.Join(1000);
|
||||||
|
_reader = null;
|
||||||
|
|
||||||
|
Logged?.Invoke("Port closed");
|
||||||
|
ConnectionChanged?.Invoke(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReadLoop()
|
||||||
|
{
|
||||||
|
var buffer = new byte[256];
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
SerialPort? port = _port;
|
||||||
|
if (port is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int n;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
n = port.Read(buffer, 0, buffer.Length);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
continue; // just a poll tick; check _running again
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidOperationException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
if (_running)
|
||||||
|
Logged?.Invoke($"Port error: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n > 0)
|
||||||
|
_device.OnReceived(buffer, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
namespace VPlasma.Core.Protocol;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cockpit plasma display's serial command set, as recovered from the
|
||||||
|
/// Tesla 4.10 sources and tools:
|
||||||
|
///
|
||||||
|
/// <para>The display is a 128×32 dot-matrix plasma panel on COM2 at
|
||||||
|
/// <b>9600 8N1</b>, no flow control. The game side
|
||||||
|
/// (<c>CODE\RP\MUNGA_L4\L4PLASMA.CPP</c>) renders everything into a local
|
||||||
|
/// 1bpp buffer and streams changed rows with the <c>ESC P</c> graphics
|
||||||
|
/// command, sending <c>ESC G 0</c> once at startup to hide the cursor. The
|
||||||
|
/// factory test tool (<c>VWETEST\VGLTEST\PLASMA.EXE</c>) additionally
|
||||||
|
/// exercises a text mode: printable ASCII renders at a cursor, with escape
|
||||||
|
/// commands for clear/home, cursor visibility, font select, and text
|
||||||
|
/// attributes (intensity/underline/reverse/flash), plus BS/HT/LF/VT/CR
|
||||||
|
/// cursor motion.</para>
|
||||||
|
///
|
||||||
|
/// <para>Every multi-byte command begins with ESC (0x1B) followed by one
|
||||||
|
/// command letter:</para>
|
||||||
|
///
|
||||||
|
/// <code>
|
||||||
|
/// ESC @ clear screen, reset text state
|
||||||
|
/// ESC L home the cursor (0,0)
|
||||||
|
/// ESC G n cursor mode: 00/FF hidden, 01 steady, 03 flashing
|
||||||
|
/// ESC K n select font n (FF = default font 0)
|
||||||
|
/// ESC H n text attributes: index 0..16 into the
|
||||||
|
/// intensity/underline/reverse/flash combos the
|
||||||
|
/// test tool enumerates; FF = defaults
|
||||||
|
/// ESC P s y x w h data… graphics write: screen s (single-screen, ignored),
|
||||||
|
/// top row y (0..31), left byte column x (0..15),
|
||||||
|
/// w bytes per row, h rows, then w*h data bytes,
|
||||||
|
/// MSB = leftmost pixel. The game always sends
|
||||||
|
/// whole rows: x=0, w=16, h=1.
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// <para>Where the two sources disagree on <c>ESC G</c> (the game hides the
|
||||||
|
/// cursor with 00, the test tool with FF) both operands are treated as
|
||||||
|
/// hidden.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class PlasmaProtocol
|
||||||
|
{
|
||||||
|
/// <summary>Wire bit rate — the game opens PCS_9600, PCS_N81.</summary>
|
||||||
|
public const int BaudRate = 9600;
|
||||||
|
|
||||||
|
public const byte Esc = 0x1B;
|
||||||
|
|
||||||
|
// Single-byte cursor-motion controls (PLASMA.EXE's /b /c /l /t /v options).
|
||||||
|
public const byte BackSpace = 0x08;
|
||||||
|
public const byte HorizontalTab = 0x09;
|
||||||
|
public const byte LineFeed = 0x0A;
|
||||||
|
public const byte VerticalTab = 0x0B;
|
||||||
|
public const byte CarriageReturn = 0x0D;
|
||||||
|
|
||||||
|
// ESC command letters.
|
||||||
|
public const byte CmdClearScreen = (byte)'@';
|
||||||
|
public const byte CmdCursorMode = (byte)'G';
|
||||||
|
public const byte CmdAttributes = (byte)'H';
|
||||||
|
public const byte CmdFontSelect = (byte)'K';
|
||||||
|
public const byte CmdHomeCursor = (byte)'L';
|
||||||
|
public const byte CmdGraphicsWrite = (byte)'P';
|
||||||
|
|
||||||
|
/// <summary>Operand meaning "restore the default" for ESC K / ESC H.</summary>
|
||||||
|
public const byte OperandDefault = 0xFF;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
+163
-25
@@ -1,5 +1,7 @@
|
|||||||
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;
|
using VRio.Core.Input;
|
||||||
|
|
||||||
@@ -8,15 +10,40 @@ 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 — COM ports, device settings, and a live wire log. The panel's
|
||||||
/// point RIOJoy at the other end of the null-modem pair, and every click here
|
/// encoder strip also hosts the built-in plasma glass (the vPLASMA display
|
||||||
/// arrives at RIOJoy exactly as if the physical cockpit sent it.
|
/// emulator) on its own COM port. At startup the usual ports
|
||||||
|
/// (<see cref="PreferredPort"/>, <see cref="PreferredPlasmaPort"/>) are
|
||||||
|
/// opened automatically when available; otherwise open them by hand. Point
|
||||||
|
/// RIOJoy at the other end of the null-modem pair, and every click here
|
||||||
|
/// arrives at RIOJoy exactly as if the physical cockpit sent it; point the
|
||||||
|
/// game's COM2 passthrough at the plasma pair and the glass lights up.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class MainForm : Form
|
internal sealed class MainForm : Form
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// vRIO's usual port: the device end of the COM1⇄COM11 com0com pair.
|
||||||
|
/// Auto-opened at startup when present and free; the picker still allows
|
||||||
|
/// any other port.
|
||||||
|
/// </summary>
|
||||||
|
private const string PreferredPort = "COM11";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The built-in plasma's usual port: the device end of the COM2⇄COM12
|
||||||
|
/// com0com pair (the game's COM2 passthrough opens the other end).
|
||||||
|
/// </summary>
|
||||||
|
private const string PreferredPlasmaPort = "COM12";
|
||||||
|
|
||||||
private readonly VRioDevice _device = new();
|
private readonly VRioDevice _device = new();
|
||||||
private readonly VRioSerialService _service;
|
private readonly VRioSerialService _service;
|
||||||
|
private readonly VPlasmaDevice _plasmaDevice = new();
|
||||||
|
private readonly VPlasmaSerialService _plasmaService;
|
||||||
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 InputRouter _router;
|
||||||
private readonly XInputGamepad _gamepad = new();
|
private readonly XInputGamepad _gamepad = new();
|
||||||
private readonly KeyboardLampMirror _lampMirror;
|
private readonly KeyboardLampMirror _lampMirror;
|
||||||
@@ -25,6 +52,16 @@ internal sealed class MainForm : Form
|
|||||||
private readonly string _bindingsPath = Path.Combine(
|
private readonly string _bindingsPath = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
|
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),
|
||||||
@@ -33,13 +70,20 @@ 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()
|
||||||
|
{
|
||||||
|
Location = new Point(80, 42),
|
||||||
|
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 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 _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 };
|
||||||
@@ -103,7 +147,8 @@ internal sealed class MainForm : Form
|
|||||||
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()
|
||||||
@@ -144,11 +189,14 @@ internal sealed class MainForm : Form
|
|||||||
KeyPreview = true; // form-level key routing for the input bindings
|
KeyPreview = true; // form-level key routing for the input bindings
|
||||||
|
|
||||||
_service = new VRioSerialService(_device);
|
_service = new VRioSerialService(_device);
|
||||||
|
_plasmaService = new VPlasmaSerialService(_plasmaDevice);
|
||||||
_router = new InputRouter(_device);
|
_router = new InputRouter(_device);
|
||||||
_lampMirror = new KeyboardLampMirror(_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);
|
||||||
@@ -177,8 +225,22 @@ internal sealed class MainForm : Form
|
|||||||
_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"));
|
||||||
|
|
||||||
|
// 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 += open => RunOnUi(() => OnPlasmaConnectionChanged(open));
|
||||||
|
_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();
|
||||||
|
_plasmaOpenClose.Click += (_, _) => TogglePlasmaOpen();
|
||||||
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
|
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
|
||||||
_centerAxes.Click += (_, _) =>
|
_centerAxes.Click += (_, _) =>
|
||||||
{
|
{
|
||||||
@@ -242,12 +304,15 @@ internal sealed class MainForm : Form
|
|||||||
_lampMirror.Dispose();
|
_lampMirror.Dispose();
|
||||||
_rawKeyboard.Dispose();
|
_rawKeyboard.Dispose();
|
||||||
_service.Dispose();
|
_service.Dispose();
|
||||||
|
_plasmaService.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. Open a COM port, then point RIOJoy at the other end of the pair.");
|
||||||
LoadBindings();
|
LoadBindings();
|
||||||
|
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
|
||||||
|
AutoOpenPreferredPorts();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Panel BuildControlStrip()
|
private Panel BuildControlStrip()
|
||||||
@@ -265,11 +330,13 @@ 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, 88) };
|
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) };
|
||||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
|
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
|
||||||
@@ -296,26 +363,80 @@ internal sealed class MainForm : Form
|
|||||||
|
|
||||||
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 service's picker is disabled and shows the served port;
|
||||||
_portBox.Items.Add(name);
|
// leave it alone so the display can't drift from reality.
|
||||||
|
if (!_service.IsOpen)
|
||||||
|
RefreshPicker(_portBox, names);
|
||||||
|
if (!_plasmaService.IsOpen)
|
||||||
|
RefreshPicker(_plasmaPortBox, names);
|
||||||
|
}
|
||||||
|
|
||||||
if (_portBox.Items.Count == 0)
|
private static void RefreshPicker(ComboBox box, string[] portNames)
|
||||||
|
{
|
||||||
|
string? current = box.SelectedItem?.ToString();
|
||||||
|
box.Items.Clear();
|
||||||
|
foreach (string name in portNames)
|
||||||
|
box.Items.Add(name);
|
||||||
|
|
||||||
|
if (box.Items.Count == 0)
|
||||||
return;
|
return;
|
||||||
int idx = current is null ? -1 : _portBox.Items.IndexOf(current);
|
int idx = current is null ? -1 : box.Items.IndexOf(current);
|
||||||
_portBox.SelectedIndex = idx >= 0 ? idx : 0;
|
box.SelectedIndex = idx >= 0 ? idx : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Startup convenience: if a device's usual port exists, select it and try
|
||||||
|
/// to open it. Failures (port missing, or busy because another vRIO/app
|
||||||
|
/// holds it) just log — no modal box at launch — and leave the manual
|
||||||
|
/// pickers in charge.
|
||||||
|
/// </summary>
|
||||||
|
private void AutoOpenPreferredPorts()
|
||||||
|
{
|
||||||
|
AutoOpen(_portBox, PreferredPort, _service.Open);
|
||||||
|
AutoOpen(_plasmaPortBox, PreferredPlasmaPort, _plasmaService.Open);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AutoOpen(ComboBox box, string port, Action<string> open)
|
||||||
|
{
|
||||||
|
int idx = box.Items.IndexOf(port);
|
||||||
|
if (idx < 0)
|
||||||
|
{
|
||||||
|
PrependLog($"{port} not present — pick a port and open it manually.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
box.SelectedIndex = idx;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
open(port);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
|
||||||
|
{
|
||||||
|
PrependLog($"{port} is present but could not be opened ({ex.Message.TrimEnd('.')}) — open it manually once it frees up.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleOpen()
|
private void ToggleOpen()
|
||||||
{
|
{
|
||||||
if (_service.IsOpen)
|
if (_service.IsOpen)
|
||||||
{
|
|
||||||
_service.Close();
|
_service.Close();
|
||||||
return;
|
else
|
||||||
}
|
OpenFromPicker(_portBox, _service.Open);
|
||||||
|
}
|
||||||
|
|
||||||
if (_portBox.SelectedItem?.ToString() is not { } port)
|
private void TogglePlasmaOpen()
|
||||||
|
{
|
||||||
|
if (_plasmaService.IsOpen)
|
||||||
|
_plasmaService.Close();
|
||||||
|
else
|
||||||
|
OpenFromPicker(_plasmaPortBox, _plasmaService.Open);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenFromPicker(ComboBox box, Action<string> open)
|
||||||
|
{
|
||||||
|
if (box.SelectedItem?.ToString() is not { } port)
|
||||||
{
|
{
|
||||||
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " +
|
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " +
|
||||||
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
@@ -324,7 +445,7 @@ internal sealed class MainForm : Form
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_service.Open(port);
|
open(port);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -336,12 +457,29 @@ internal sealed class MainForm : Form
|
|||||||
private void OnConnectionChanged(bool open)
|
private void OnConnectionChanged(bool open)
|
||||||
{
|
{
|
||||||
_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 OnPlasmaConnectionChanged(bool open)
|
||||||
|
{
|
||||||
|
_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 -------------------------------------------
|
// ---- Keyboard / gamepad input -------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+44
-18
@@ -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);
|
||||||
@@ -109,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 ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -142,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);
|
||||||
@@ -288,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);
|
||||||
@@ -307,11 +333,11 @@ internal sealed class PanelCanvas : Control
|
|||||||
|
|
||||||
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
|
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
|
||||||
string readout =
|
string readout =
|
||||||
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3} " +
|
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3}\n" +
|
||||||
$"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
|
|
||||||
<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
|
<!-- Stamp commit date + short sha into InformationalVersion; the window
|
||||||
|
|||||||
@@ -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,307 @@
|
|||||||
|
using VPlasma.Core.Device;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace VPlasma.Core.Tests;
|
||||||
|
|
||||||
|
public class VPlasmaDeviceTests
|
||||||
|
{
|
||||||
|
private const byte Esc = 0x1B;
|
||||||
|
|
||||||
|
private static void Feed(VPlasmaDevice device, params byte[] bytes)
|
||||||
|
=> device.OnReceived(bytes, bytes.Length);
|
||||||
|
|
||||||
|
private static void Feed(VPlasmaDevice device, IEnumerable<byte> bytes)
|
||||||
|
{
|
||||||
|
byte[] arr = bytes.ToArray();
|
||||||
|
device.OnReceived(arr, arr.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte Pixel(VPlasmaDevice device, int x, int y)
|
||||||
|
{
|
||||||
|
var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
|
||||||
|
device.CopyFrame(frame);
|
||||||
|
return frame[y * VPlasmaDevice.Width + x];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A full-width ESC P row the way L4PLASMA.CPP sends one.</summary>
|
||||||
|
private static byte[] GraphicsRow(int y, params byte[] data)
|
||||||
|
{
|
||||||
|
var row = new List<byte> { Esc, (byte)'P', 0, (byte)y, 0, (byte)data.Length, 1 };
|
||||||
|
row.AddRange(data);
|
||||||
|
return row.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- graphics writes -------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GraphicsRow_SetsPixelsMsbFirst()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, GraphicsRow(5, 0x80, 0x01)); // xbyte 0..1
|
||||||
|
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 5)); // MSB of byte 0
|
||||||
|
Assert.Equal(0, Pixel(device, 1, 5));
|
||||||
|
Assert.Equal(0, Pixel(device, 14, 5));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 15, 5)); // LSB of byte 1
|
||||||
|
Assert.Equal(0, Pixel(device, 0, 4));
|
||||||
|
Assert.Equal(0, Pixel(device, 0, 6));
|
||||||
|
Assert.Equal(1, device.GraphicsRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GraphicsRow_SurvivesAnyChunkBoundary()
|
||||||
|
{
|
||||||
|
byte[] wire = GraphicsRow(3, Enumerable.Repeat((byte)0xFF, 16).ToArray());
|
||||||
|
|
||||||
|
// Split the same command at every possible boundary.
|
||||||
|
for (int split = 1; split < wire.Length; ++split)
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
device.OnReceived(wire, split);
|
||||||
|
byte[] rest = wire.Skip(split).ToArray();
|
||||||
|
device.OnReceived(rest, rest.Length);
|
||||||
|
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 3));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 127, 3));
|
||||||
|
Assert.Equal(1, device.GraphicsRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GraphicsBlock_MultipleRowsAdvanceY()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
// screen 0, y=10, xbyte=0, 1 byte/row, 3 rows.
|
||||||
|
Feed(device, Esc, (byte)'P', 0, 10, 0, 1, 3, 0x80, 0x80, 0x80);
|
||||||
|
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 10));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 11));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 12));
|
||||||
|
Assert.Equal(0, Pixel(device, 0, 13));
|
||||||
|
Assert.Equal(3, device.GraphicsRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GraphicsWrite_HonorsByteColumnOffset()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, Esc, (byte)'P', 0, 0, 2, 1, 1, 0xFF); // xbyte=2 → x 16..23
|
||||||
|
|
||||||
|
Assert.Equal(0, Pixel(device, 15, 0));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 16, 0));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 23, 0));
|
||||||
|
Assert.Equal(0, Pixel(device, 24, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GraphicsWrite_OutOfRangeRowIsConsumedNotDrawn()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, GraphicsRow(40, Enumerable.Repeat((byte)0xFF, 16).ToArray()));
|
||||||
|
Feed(device, (byte)'!'); // parser must be back in text mode
|
||||||
|
|
||||||
|
var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
|
||||||
|
device.CopyFrame(frame);
|
||||||
|
Assert.Equal(1, device.TextCharsDrawn);
|
||||||
|
Assert.Equal(0, device.GraphicsRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GraphicsWrite_OverwritesTextAttributes()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, Esc, (byte)'H', 4); // flashing text
|
||||||
|
Feed(device, (byte)'H'); // glyph col 0 is a full bar at x=0
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelFlash, Pixel(device, 0, 0));
|
||||||
|
|
||||||
|
Feed(device, GraphicsRow(0, 0x80)); // repaint that row from the wire
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- text mode ---------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Text_DrawsGlyphAndAdvancesCursor()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, (byte)'H'); // 5×7 'H': column 0 = 0x7F (all seven rows)
|
||||||
|
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 6));
|
||||||
|
Assert.Equal(0, Pixel(device, 0, 7)); // gap row
|
||||||
|
Assert.Equal(0, Pixel(device, 5, 0)); // gap column
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(1, 0), device.CursorCell);
|
||||||
|
Assert.Equal(1, device.TextCharsDrawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ControlChars_MoveTheCursor()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
|
||||||
|
Feed(device, 0x09, 0x09, 0x09); // HT ×3
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(3, 0), device.CursorCell);
|
||||||
|
|
||||||
|
Feed(device, 0x08); // BS
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(2, 0), device.CursorCell);
|
||||||
|
|
||||||
|
Feed(device, 0x0A); // LF
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(2, 1), device.CursorCell);
|
||||||
|
|
||||||
|
Feed(device, 0x0D); // CR
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(0, 1), device.CursorCell);
|
||||||
|
|
||||||
|
Feed(device, 0x0B); // VT
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
|
||||||
|
|
||||||
|
Feed(device, 0x0B); // VT off the top wraps to the bottom row
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(0, 3), device.CursorCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Text_WrapsAtRowAndScreenEnd()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, Enumerable.Repeat((byte)'X', 22)); // one full 21-cell row + 1
|
||||||
|
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(1, 1), device.CursorCell);
|
||||||
|
|
||||||
|
Feed(device, Enumerable.Repeat((byte)'X', 21 * 3 - 1)); // exactly to the end
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell); // wrapped to top
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscAt_ClearsScreenAndResetsTextState()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, Esc, (byte)'K', 4, Esc, (byte)'H', 1, (byte)'H');
|
||||||
|
Feed(device, Esc, (byte)'@');
|
||||||
|
|
||||||
|
Assert.Equal(0, Pixel(device, 0, 0));
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
|
||||||
|
Assert.Equal(0, device.Font);
|
||||||
|
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscL_HomesCursorWithoutClearing()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, (byte)'H', Esc, (byte)'L');
|
||||||
|
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0)); // glyph survives
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x00, PlasmaCursorMode.Hidden)] // the game's cursor-off
|
||||||
|
[InlineData(0xFF, PlasmaCursorMode.Hidden)] // the test tool's hide
|
||||||
|
[InlineData(0x01, PlasmaCursorMode.Steady)]
|
||||||
|
[InlineData(0x03, PlasmaCursorMode.Flashing)]
|
||||||
|
public void EscG_SetsCursorMode(byte operand, PlasmaCursorMode expected)
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, Esc, (byte)'G', operand);
|
||||||
|
Assert.Equal(expected, device.CursorMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscK_SelectsFontGrids()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'K', 4); // large: 12×16 cells
|
||||||
|
Assert.Equal(4, device.Font);
|
||||||
|
Assert.Equal(12, device.CellWidth);
|
||||||
|
Assert.Equal(16, device.CellHeight);
|
||||||
|
|
||||||
|
Feed(device, (byte)'A'); // large glyph: 'A' col 1 (0x11) row 0 → 2×2 dots at (2..3, 0..1)
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 2, 0));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 3, 1));
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'K', 0xFF); // FF → default font 0
|
||||||
|
Assert.Equal(0, device.Font);
|
||||||
|
Assert.Equal(6, device.CellWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscH_AppliesAttributes()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'H', 1, (byte)'H'); // half intensity
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelHalf, Pixel(device, 0, 0));
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'H', 2, (byte)'H'); // underline: gap row lit
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 6, 7));
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'H', 3, (byte)' '); // reverse: a space renders solid
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 12, 0));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 17, 7));
|
||||||
|
|
||||||
|
Feed(device, Esc, (byte)'H', 0xFF, (byte)'H'); // defaults restored
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 18, 0));
|
||||||
|
Assert.Equal(0, Pixel(device, 18, 7));
|
||||||
|
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- robustness ----------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnknownEscape_IsConsumedAndTextResumes()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, Esc, (byte)'Z', (byte)'H');
|
||||||
|
|
||||||
|
Assert.Equal(1, device.TextCharsDrawn);
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GameStartupSequence_HidesCursor()
|
||||||
|
{
|
||||||
|
// L4PLASMA.CPP's constructor sends exactly ESC 'G' 0x00.
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode); // power-on default
|
||||||
|
|
||||||
|
Feed(device, 27, (byte)'G', 0x00);
|
||||||
|
Assert.Equal(PlasmaCursorMode.Hidden, device.CursorMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelfTestPages_ParseWithoutUnknownCommands()
|
||||||
|
{
|
||||||
|
for (int page = 0; page < PlasmaSelfTest.PageCount; ++page)
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
var complaints = new List<string>();
|
||||||
|
device.Logged += line =>
|
||||||
|
{
|
||||||
|
if (line.StartsWith("Unknown", StringComparison.Ordinal)
|
||||||
|
|| line.StartsWith("Unhandled", StringComparison.Ordinal))
|
||||||
|
complaints.Add(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
byte[] bytes = PlasmaSelfTest.BuildPage(page);
|
||||||
|
device.OnReceived(bytes, bytes.Length);
|
||||||
|
|
||||||
|
Assert.Empty(complaints);
|
||||||
|
Assert.True(device.BytesReceived > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reset_RestoresPowerOnState()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, Esc, (byte)'K', 5, Esc, (byte)'H', 4, Esc, (byte)'G', 0, (byte)'H');
|
||||||
|
|
||||||
|
device.Reset();
|
||||||
|
|
||||||
|
Assert.Equal(0, Pixel(device, 0, 0));
|
||||||
|
Assert.Equal(0, device.Font);
|
||||||
|
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
||||||
|
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode);
|
||||||
|
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user