12 Commits
Author SHA1 Message Date
CydandClaude Opus 4.8 68e3d1f367 VPlasma.Wire: add diff + TeslaSuite plasma authoring tools
- diff — compare two frames/images/encodings (.bin ESC P / .png / .txt
  bitmap=), each reduced to 128x32; reports differing dots and match %, writes
  a red-on-mismatch image, exits 1 if they differ. Closes the differential-test
  loop: diff a cropped panel/replica photo against the vPLASMA golden image.
- text / encode / decode — ported from TeslaSuite's three plasma tools (Plasma
  Font Tool, Bitmap Decoder, Editor) via PlasmaBitmap.cs:
    * text   — render text in an auto-sized Windows font to a plasma bitmap.
    * encode — an image → plasma bitmap.
    * decode — the game's bitmap= hex encoding → a preview PNG.
  Each bridges to an ESC P wire stream (and/or the bitmap= hex encoding), so
  authored content replays straight to vPLASMA, the replica, or the real panel.

Verified: text "ALERT 12" → ESC P → vPLASMA renders it; the ESC P and hex
paths diff as byte-identical (504/504 lit, 100% match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:20:50 -05:00
CydandClaude Opus 4.8 78cd679b53 Add VPlasma.Wire: differential-test helper
A console tool for validating the hardware replica against vPLASMA and the
real panel by feeding all three identical byte streams.

- synth   — build a repeatable command stream (selftest/demo/banner/charset).
- render  — feed a stream through the vPLASMA engine and save the resulting
            128x32 frame as a PNG: the pixel-exact golden image.
- replay  — send a stream to any target port: the real panel (RS-232, self-
            pacing), the Matrix Portal replica (USB-CDC), or a virtual port
            (--paced).
- capture — log live traffic to a file, with --tee to forward it on to the
            real display so capture is non-intrusive.

Reuses VPlasma.Core (same parser/fonts as the emulator and the ported
firmware). The replica README documents the differential-test workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:42:00 -05:00
CydandClaude Opus 4.8 ebad6ee5fe PD4 display test: cycle diagnostic patterns, not just all-on
The real display's power-on test (JP1 jumper 5, firmware $B888) runs through
several drawing sequences — a solid fill, a border+grid, more — each held
briefly, to expose dead dots and addressing faults; it isn't just full on/off.

Both vPLASMA and the replica firmware now cycle 5 representative patterns:
solid, border+grid, horizontal stripes, vertical stripes, checkerboard.
- VPlasmaDevice.ShowTestPattern(int) + TestPatternCount; the app cycles them
  on a 1.2s timer while jumper 5 is installed.
- PlasmaDisplay::showTestPattern(int) + TEST_PATTERN_COUNT (shared logic); the
  Matrix Portal sketch's DOWN button toggles the cycling test.

(The firmware's exact byte patterns live in the display's native scan-buffer
layout, which we haven't mapped, so these stand in for that sequence rather
than reproducing it byte-for-byte.)

Verified: 29 C# tests pass; the C++ patterns render distinctly on the host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:34:33 -05:00
CydandClaude Opus 4.8 ab36cdd826 PlasmaNew/replica: Matrix Portal S3 firmware for the hardware replica
The hardware replacement for the failing Babcock PD01D221: an Adafruit Matrix
Portal S3 (ESP32-S3) reads the game's serial command stream over its native
USB-CDC port and renders it to two chained Adafruit 64x32 HUB75 RGB panels
(= 128x32) in neon-orange, via Adafruit Protomatter.

- PlasmaDisplay.h/.cpp — the PD01D221 command parser + 128x32 framebuffer,
  ported line-for-line from vPLASMA's VPlasmaDevice (the reference oracle):
  the full ESC command set, pixel-addressed cursor, attributes, orientation,
  and the rotate-onto-glass plot() mapping.
- plasma_fonts.h — the 8 real ROM fonts (PROGMEM), generated from
  tms27pc512.BIN.
- demo_screens.h — the 10 firmware demo screens (PROGMEM).
- MatrixPortalPlasma.ino — Protomatter init (128x32), USB serial input,
  the orange/dim/blink render loop, and onboard buttons (UP = firmware demo,
  DOWN = panel test) for no-host bring-up.
- README.md — BOM, wiring, build/flash, DOSBox-X serial config.

Parser verified on the host with g++ (font-0 'H', ESC Q/R positioning,
demo replay, test pattern) against a minimal Arduino shim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:22:06 -05:00
CydandClaude Opus 4.8 79eb53253e vPLASMA (standalone): vRIO-style picker, real firmware demo, PD5 fix
- Connection: drop the hardwired COM12; replicate vRIO's endpoint picker —
  a combo of pipe:vplasma (the DOSBox-X namedpipe backend) + the COM ports,
  with Rescan and Open/Close. Nothing opens automatically; baud straps drive
  a COM open.
- Demo (jumper 6) now runs the REAL firmware demonstration, not the vPLASMA
  self-test. The 10 demo screens are extracted verbatim from the ROM demo
  pointer table ($8000) into PlasmaFirmwareDemo.cs and looped through the
  parser. Added ESC I / ESC i (draw/display page select) as 1-operand
  commands — consumed but not acted on in the single-page model — so the
  demo's page commands don't desync the stream.
- Orientation (jumper 4 / PD5) polarity fixed: unstrapped = horizontal
  128x32 (the normal cockpit setup, now the default), installed = vertical.

Verified: 29 unit tests pass (2 new: demo replay, page-select operand); the
real demo screens render with the correct text/positioning/fonts, and the
picker lists pipe:vplasma + COM ports with no auto-open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:42:55 -05:00
CydandClaude Opus 4.8 95b701ae0a vPLASMA: make jumpers 4 (orientation) and 5 (display test) functional
Both now do what the firmware does, at the device level so the shared
PlasmaCanvas (and vRIO's embedded glass) are untouched:

- Jumper 4 → display orientation. VPlasmaDevice gains a PlasmaOrientation
  property; in Vertical the logical space is 32x128 and every dot is rotated
  onto the physical 128x32 glass via a new Plot() helper that all drawing
  (text, cursor, ESC P graphics, ESC Q/R bounds) now goes through. Jumper 4
  installed = horizontal (the normal cockpit setup, default), removed =
  vertical. Changing it re-inits the panel, like the boot strap read.
- Jumper 5 → ShowTestPattern(): lights every dot (the power-on dead-dot
  check the firmware runs at $B888). Removing the jumper clears it.

The config panel wires both up and shows the live orientation in the
counters. 27 unit tests pass (3 new: vertical rotation mapping, orientation
re-init, test pattern), including the unchanged horizontal path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:26:51 -05:00
CydandClaude Opus 4.8 d8ab0ffe49 vPLASMA (standalone): manual COM12 open, and decode jumpers 3/4/5
- Remove the COM12 auto-open and its retry timer; the serial link is now
  opened manually via an "Open/Close COM12" button in the config panel.
  The named-pipe transport still serves automatically. Baud straps reopen
  only if already open.
- Decode the three previously-unknown JP1 config jumpers from the firmware
  (boot config routine at $9190) and relabel them:
    * Jumper 3 (PA3): sets flag $B7.2, driving hardware output PA5 to a
      fixed level (a board config line).
    * Jumper 4 (PD5): display orientation — installed = horizontal 128x32,
      removed = vertical 32x128 (the demo's "HORIZONTAL OR VERTICAL").
    * Jumper 5 (PD4): power-on display/pixel test pattern ($B888).
  PlasmaNew/README.md's JP1 table now carries the full firmware-backed decode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:15:49 -05:00
CydandClaude Opus 4.8 90a57ba2cf vPLASMA (standalone): restore config panel with the 7 JP1 jumpers
The standalone app gets its right-side config panel back, now built around
the real PD01D221's JP1 jumper block (from PlasmaNew/FIRMWARE.md): seven
toggles labelled with each strap's function and HC11 pin. The two baud
straps drive the open baud (4800/9600/19.2K/38.4K; jumper 2 = 9600 default,
the game's rate) via a new VPlasmaSerialService.Baud property; jumper 6
runs a demonstration loop, mirroring the firmware's jumper-6 demo. Plus the
Display controls (self test / clear), live counters, and the wire log.

The glass, auto-open COM12 + named-pipe transport, double-click self-test,
and right-click reset all stay. Only VPlasma.App is touched (plus the
additive Baud property) — the vPLASMA glass embedded in vRIO, which shares
PlasmaCanvas, is left exactly as it was.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:04:55 -05:00
CydandClaude Opus 4.8 49e88fbe20 vPLASMA: fold in the real ROM fonts and firmware-confirmed commands
Replaces the guessed font/cursor model with what the firmware dump proved:

- The 8 real Babcock ROM fonts, extracted from tms27pc512.BIN into
  PlasmaFonts.cs (6x8, 6x10, 7x10, 12x16, 12x20). Drops the public-domain
  5x7 stand-in and PlasmaFont.cs.
- Pixel-addressed cursor with the real positioning commands: ESC Q (row Y,
  0-31) and ESC R (column X, 0-127), matching the firmware's range checks.
  Cursor motion (BS/HT/LF/VT/CR) now moves by font pixels, not cells.
- ESC K selects fonts 0-7 (out-of-range ignored, per firmware); ESC H
  attributes are the low 4 bits (half/underline/reverse/flash).

Deferred (documented in FIRMWARE.md): the 10 double-buffered pages
(ESC I/i) and vector-graphics primitives (ESC A-F); vPLASMA keeps a
single-page model for now.

Verified: 24 unit tests pass; the three self-test pages render the real
glyphs (big font 4 banner, full charset in font 0, graphics). Next: begin
the modern-parts hardware replica, with this as the reference firmware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:39:07 -05:00
CydandClaude Opus 4.8 2dca8bfd8f PlasmaNew: firmware dump + reverse-engineering analysis
Adds the dumped U3 EPROM image (tms27pc512.BIN) and the analysis it enabled.

Findings (FIRMWARE.md):
- Memory map: code+data at CPU $8000-$FFFF (EPROM upper 32KB, 1:1); lower
  half unused. Valid HC11 vectors; SCI rx ISR at $B85C fills a ring buffer,
  main-loop parser dispatches.
- Full command set recovered from the two jump tables: 58 ESC commands
  ($98AC) + control chars ($994C). Decoded semantics for @/G/H/K/L (confirm
  earlier guesses) plus NEW commands: Q=set-row, R=set-col, I=draw-page,
  i=display-page (10-page double-buffering), A-F=vector graphics, X/Y=pen.
- 8 fonts at $BC03 (6x8, 6x10, 7x10, 12x16, 12x20); glyph bitmaps in ROM
  (~$C000-$DFFF, exact base TBD).
- Built-in demo enabled by jumper 6 (confirms the JP1 map).

Tooling: hc11dis.py, a purpose-built 68HC11 disassembler (the toolchain has
no m68hc11 target), with the analysis reproducible from the addresses noted.

The firmware is now the authoritative command spec, superseding the need for
the Babcock programming manual. Feeds both vPLASMA and the replica firmware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:13:11 -05:00
CydandClaude Fable 5 697cf3129b Pipe endpoints join the connection pickers; no more COM11/COM12 auto-bind
Each device row now serves one endpoint at a time, picked from a list of
pipe:vrio / pipe:vplasma (first, default) plus the machine's COM ports —
nothing opens automatically at startup. The pipe services gained an
IsListening property and are started/stopped by the row's Open/Close
button instead of running for the whole app lifetime, which also retires
the dual-transport warning. Status line shows the active endpoint;
OFFLINE when none. Standalone vPLASMA is unchanged (hardwired COM12 +
permanent pipe). Also fixes the xUnit1031 warnings in the plasma pipe
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 23:17:34 -05:00
CydandClaude Fable 5 e507f1740c Named-pipe transport for the DOSBox-X fork's namedpipe backend
Both devices now serve \.\pipe\vrio and \.\pipe\vplasma for the whole
app lifetime alongside the COM rows — the com0com-free path. Framing per
the pinned contract (0x00 len data / 0x01 DTR+RTS lines, one lines frame
on connect, unknown type = log + drop): PipeFraming/PipeFrameDecoder and
VRioPipeService (TX paced at the wire rate, peer DTR edges feed
HostHandshake) in VRio.Core, listener-only VPlasmaPipeService twin in
VPlasma.Core. Busy pipe names retry, so vRIO's built-in glass and the
standalone vPLASMA coexist. README documents the transport and the fork
conf lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 23:03:02 -05:00
31 changed files with 5285 additions and 436 deletions
+160
View File
@@ -0,0 +1,160 @@
# PD01D221 firmware analysis (`tms27pc512.BIN`)
Reverse-engineering notes for the dumped controller firmware — the 64 KB
TI TMS27PC512 EPROM (U3) from the Babcock PD01D221. This is the authoritative
source for the display's command set, and it feeds both [vPLASMA](../src/VPlasma.App/)
and the planned [hardware replica](README.md).
Dump: `tms27pc512.BIN`, 65,536 bytes, MD5 `b775427806857f60ca4a4cc501f4b5cc`.
Analysis tooling: [`hc11dis.py`](hc11dis.py) (a purpose-built 68HC11
disassembler — the toolchain has no m68hc11 target).
## Memory map
- **CPU $8000$FFFF = EPROM upper 32 KB, 1:1** (the HC11 vector table lands at
ROM offset `$FFC0$FFFF` and is valid, which pins the mapping). The EPROM's
**lower 32 KB is unused** (all `$00`) — only A15-high is decoded to the ROM.
- **Code:** `$9000$B8xx`. **Data/tables:** `$8000$8FFF` (demo), `$98AC+`
(dispatch tables), `$BC03+` (font descriptors), `$C000$DFFF` (glyph
bitmaps + graphics).
- **RAM (Mosel MS62256, 32 KB) at low addresses:** HC11 registers on page 0
(`$00$3F`; SCSR=`$2E`, SCDR=`$2F`), zero-page variables `$40$FF`, RX ring
buffer at `$0228`, and **ten 128×32 screen buffers** from `$0F6D` up.
## Vectors
| Vector | Target | Notes |
|--------|--------|-------|
| RESET | `$9059` | Init: registers, stack `$0227`, then main loop |
| **SCI (serial rx)** | **`$B85C`** | Interrupt-driven receive → ring buffer |
| COP watchdog | `$905E` | (re-inits) |
| others | `$9059` | default → reset |
## Architecture
1. **SCI RX ISR (`$B85C`)** — on RDRF, reads `SCSR`/`SCDR`, stores the byte to
a ring buffer at `$0228` (write ptr `$0228`, count `$022C`). No parsing here.
2. **Main-loop parser (`$B7E0$B859`)** — pulls buffered bytes and runs an
`ESC`-state machine (flag `$AF`: bit `$10` = ESC seen, bit `$20` = operand
pending). Dispatch is **table-driven** (below). Printable chars in the
current font's `[first,last]` range (`$62`/`$63`) go to the character
renderer (`$9648`), which **enqueues** a glyph to a deferred rasterizer.
3. **Ten double-buffered screens** — descriptor table at **`$A4E0`** (10 × 6
bytes): each screen has a **draw** pointer (`$BE`) and a **display** pointer
(`$BC`) into SRAM, 1 KB apart. `ESC I` sets the draw target, `ESC i` sets
what's scanned to the glass → **page-flipping / double-buffering**.
## Command dispatch
Two jump tables, indexed by byte:
- **`ESC` + letter → command table at `$98AC`.** Valid letters `0x30``0x7E`;
index = `letter 0x30`; null entry = ignored. **58 commands populated.**
- **Control bytes `0x08``0x14` → table at `$994C`.** index = `byte 0x08`.
Most command handlers share a prologue: first sighting of the letter sets the
"operand pending" flag and returns; the **next byte is the 1-byte operand**
(in `$C6`). Multi-operand commands (`ESC P/X/Y`) collect into a parameter
block at `$0070`.
### Control characters (`$994C`)
| Byte | Handler | Meaning |
|------|---------|---------|
| `0x08` BS | `$99AF` | cursor left |
| `0x09` HT | `$9966` | tab |
| `0x0A` LF | `$99F3` | line feed |
| `0x0B` VT | `$9A30` | vertical tab |
| `0x0D` CR | `$9A55` | carriage return |
| `0x11``0x14` DC1DC4 | `$9A5C`/`$9B34`/`$9C09`/`$9CFC` | device controls (TBD) |
| `0x0C` FF, `0x0E``0x10` | — | no handler |
### ESC commands (`$98AC`) — confirmed semantics
| Cmd | Handler | Meaning |
|-----|---------|---------|
| `ESC @` | `$9F26` | **Clear** the active draw buffer (512 bytes = 128×32÷8) |
| `ESC G n` | `$A42B` | **Cursor mode**, low nibble of `$B4` (n = 07) |
| `ESC H n` | `$A44C` | **Text attributes**, low 4 bits of `$B1` (intensity/underline/reverse/flash) |
| `ESC K n` | `$A3EA` | **Font select** (n = 09; 8 real fonts) |
| `ESC L` | `$A556` | **Home** cursor (X=0, Y=0) |
| `ESC Q n` | `$A51C` | **Set cursor row Y** (range-checked 031) |
| `ESC R n` | `$A539` | **Set cursor column X** (range-checked 0127) |
| `ESC I n` | `$A473` | **Select DRAW page** 09 (sets `$BE` from `$A4E0` table) |
| `ESC i n` | `$A4A2` | **Select DISPLAY page** 09 (page-flip; sets `$BC`) |
| `ESC P …` | `$AAF1` | **Graphics bitmap write** (multi-operand: screen,y,x,w,h,data) |
| `ESC A``ESC F` | `$9FB6``$A13C` | **Vector/graphics primitives** (line/point/move; pen state `$B6`, coords `$58/$59`, line routine `$A16C`) |
| `ESC X n` | `$A748` | **Set graphics pen X** (multi-op, 0127) |
| `ESC Y n` | `$A644` | **Set graphics pen Y** (multi-op) |
| `ESC J` | `$A4D4` | **Toggle** mode bit `$B7.7` (orientation/display — TBD) |
### ESC commands — populated but not yet decoded
`ESC 0``9` (`$9E27`+, set continuations — likely custom-char / numeric entry),
`ESC : ; =` , `ESC < > W w _` (cluster `$AEBA$AF00`), `ESC B C D E F` variants,
`ESC M N O` (`$A5BD/$A5C5/$A5E0`), `ESC Z ^ z ~` (cluster `$AC73$ACA1`),
`ESC a``f`, `ESC h l n p q r x`. ~30 handlers remain to label — full list with
addresses is dumped by the tooling below.
## Fonts
Font-pointer table at **`$BC03`** (10 slots) → 12-byte descriptors. **8 real
fonts** (slots 89 are junk pointers, matching the demo's "8 STORED CHARACTER
FONTS"):
| Font | FirstLast | W×H | Notes |
|------|-----------|-----|-------|
| 0 | `0x20``0xFF` | 6×8 | base font, full range |
| 1 | `0x40``0x7F` | 6×8 | uppercase-only |
| 2 | `0x20``0xFF` | 6×10 | |
| 3 | `0x40``0x7F` | 6×10 | |
| 4 | `0x20``0x7F` | 12×16 | large |
| 5 | `0x20``0x7F` | 12×20 | largest |
| 6 | `0x20``0xFF` | 7×10 | |
| 7 | `0x40``0x7F` | 7×10 | |
Glyph bitmaps live in ROM (`~$C000$DFFF`). **Exact glyph base + encoding
pending** — the renderer at `$9648` enqueues to a deferred rasterizer; tracing
that (or brute-forcing the 'A' pattern at the known stride) will extract the
real glyphs to replace vPLASMA's public-domain 5×7 stand-in.
## Demo program
Enabled by **jumper 6** (PD3) — confirms the [JP1 map](README.md). A 10-screen
scripted demo; the pointer table at `$8000` (10 × 4-byte entries) points to
each screen, and every screen is `[2-byte count][command stream]`. The player
at `$BB60`/`$BBA4` loops the screens, feeding each byte through the command
parser. **Extracted verbatim** into `src/VPlasma.Core/Device/PlasmaFirmwareDemo.cs`
(all 10 screens as raw wire bytes); the standalone app replays it on jumper 6.
Commands used: `@ G I K L Q R Z i` + text. `ESC I`/`ESC i` (draw/display page)
are consumed by vPLASMA but not acted on (single-page); `ESC Z` (a rarely-used
animation command, one all-zero use in screen 9) is left unimplemented.
## What this means
**For vPLASMA (folded in 2026-07-16):** the recovered spec replaced the
guessed behavior. vPLASMA now uses the **8 real ROM fonts** (extracted to
`src/VPlasma.Core/Device/PlasmaFonts.cs`), a **pixel-addressed cursor** with
the real `ESC Q` (row) / `ESC R` (column) positioning, `ESC K` 07 font
select, and `ESC H` attributes as the low 4 bits. The standalone app also
implements the functional JP1 jumpers — baud (1+2), **orientation (4:
horizontal 128×32 / vertical 32×128)**, **display test (5: all-dot pattern)**,
and demo (6). Verified: 27 unit tests + the self-test pages render the real
glyphs. Still deferred (documented, single-page model retained): the 10
double-buffered pages (`ESC I`/`ESC i`) and the vector-graphics primitives
(`ESC A``F`).
**For the replica:** this *is* the spec. The firmware confirms a clean model —
a byte-stream command parser, a 512-byte-per-page frame buffer, 10 pages with
page-flip, 8 fonts, text attributes as 4 flags, plus vector graphics. All of
it ports directly onto a modern MCU. The one artifact still to extract is the
glyph bitmaps.
## Reproduce
```sh
python hc11dis.py <hexaddr> <count> # disassemble from a CPU address
# e.g. python hc11dis.py B7E0 70 # the command parser
```
Command/control tables are at `$98AC` / `$994C`; font table `$BC03`; screen
table `$A4E0`.
+21 -16
View File
@@ -114,19 +114,21 @@ 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 configuration, not CPU mode select** — each shunt ties a GP port pin the
firmware polls at boot. Shunt to GND = logic 0. firmware polls at boot. Shunt to GND = logic 0.
| JP1 pos | HC11 pin | Function | All decoded from the firmware's boot config routine at `$9190` (installed =
|---------|----------|----------| grounded = logic 0):
| 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 12 = the datasheet's baud "JUMPER 1 / JUMPER 2." Positions 36 | JP1 pos | HC11 pin | Function | Firmware |
are four unknown firmware option bits — candidates for a hidden factory |---------|----------|----------|----------|
self-test / diagnostic mode. | 1 | pin 24 / PA0 | Baud select bit 0 | `$9193` → SCI baud reg `$2B` |
| 2 | pin 22 / PA2 | Baud select bit 1 | `$9197` → SCI baud reg `$2B` |
| 3 | pin 21 / PA3 | HW config line | `$91CA` sets flag `$B7.2`, which drives output PA5 to a fixed level (board control line; exact effect board-dependent) |
| 4 | pin 15 / PD5 | Display orientation | `$90AE`: installed = horizontal 128×32, removed = vertical 32×128 (`$B3.0`; the demo's "HORIZONTAL OR VERTICAL ORIENTATION") |
| 5 | pin 14 / PD4 | Display/pixel test | `$91C3` installed → `$B888` writes a walking-bit test pattern (dead-dot check) |
| 6 | pin 13 / PD3 | Demonstration program | `$91DB` installed → `$BB60` runs the built-in demo |
| 7 | J2 SEL → +5 V | Parallel interface select | (board interface mux) |
Baud straps 1+2 pick SCI baud register (`$2B`) values `$13`/`$11`/`$12`/`$10`
= 4800 / 9600 / 19.2K / 38.4K (more zeros grounded ⇒ slower).
HC11 pin map cross-checked while tracing: PD0PD5 = pins 1015, PA0PA7 = HC11 pin map cross-checked while tracing: PD0PD5 = pins 1015, PA0PA7 =
pins 2417 (descending). pins 2417 (descending).
@@ -200,7 +202,10 @@ CDC port satisfies perfectly. Confirm the current drive path.
## Status ## Status
**Parked pending a firmware dump.** The software emulator (vPLASMA) is built **Firmware dump received — analysis underway.** The U3 EPROM was dumped
and released; this hardware/protocol thread is blocked on getting the U3 (`tms27pc512.BIN`); see [`FIRMWARE.md`](FIRMWARE.md) for the disassembly
EPROM image (or the Babcock programming manual). Resume at the dump plan findings: memory map, the full command dispatch tables (58 `ESC` commands +
above once a dump is in hand. control chars), the 10-page double-buffered architecture, 8 fonts, and
decoded command semantics. Remaining: extract the glyph bitmaps and label the
~30 not-yet-decoded commands. The Babcock programming manual is no longer on
the critical path — the firmware is the authoritative spec.
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Minimal-but-practical Motorola 68HC11 disassembler for the PD01D221 ROM.
CPU address == ROM offset for $8000-$FFFF (EPROM upper 32KB maps 1:1)."""
import sys
# addressing modes and their extra operand length (beyond opcode)
INH='inh'; IMM8='imm8'; IMM16='imm16'; DIR='dir'; EXT='ext'; IDX='idx'; IDY='idy'; REL='rel'
BITDIR='bitdir'; BITIDX='bitidx'; BITIDY='bitidy' # BSET/BCLR (mask)
BRDIR='brdir'; BRIDX='bridx'; BRIDY='bridy' # BRSET/BRCLR (mask+rel)
# page 0
P0 = {
0x00:('TEST',INH),0x01:('NOP',INH),0x02:('IDIV',INH),0x03:('FDIV',INH),
0x04:('LSRD',INH),0x05:('ASLD',INH),0x06:('TAP',INH),0x07:('TPA',INH),
0x08:('INX',INH),0x09:('DEX',INH),0x0A:('CLV',INH),0x0B:('SEV',INH),
0x0C:('CLC',INH),0x0D:('SEC',INH),0x0E:('CLI',INH),0x0F:('SEI',INH),
0x10:('SBA',INH),0x11:('CBA',INH),0x12:('BRSET',BRDIR),0x13:('BRCLR',BRDIR),
0x14:('BSET',BITDIR),0x15:('BCLR',BITDIR),0x16:('TAB',INH),0x17:('TBA',INH),
0x19:('DAA',INH),0x1B:('ABA',INH),
0x1C:('BSET',BITIDX),0x1D:('BCLR',BITIDX),0x1E:('BRSET',BRIDX),0x1F:('BRCLR',BRIDX),
0x20:('BRA',REL),0x21:('BRN',REL),0x22:('BHI',REL),0x23:('BLS',REL),
0x24:('BCC',REL),0x25:('BCS',REL),0x26:('BNE',REL),0x27:('BEQ',REL),
0x28:('BVC',REL),0x29:('BVS',REL),0x2A:('BPL',REL),0x2B:('BMI',REL),
0x2C:('BGE',REL),0x2D:('BLT',REL),0x2E:('BGT',REL),0x2F:('BLE',REL),
0x30:('TSX',INH),0x31:('INS',INH),0x32:('PULA',INH),0x33:('PULB',INH),
0x34:('DES',INH),0x35:('TXS',INH),0x36:('PSHA',INH),0x37:('PSHB',INH),
0x38:('PULX',INH),0x39:('RTS',INH),0x3A:('ABX',INH),0x3B:('RTI',INH),
0x3C:('PSHX',INH),0x3D:('MUL',INH),0x3E:('WAI',INH),0x3F:('SWI',INH),
0x40:('NEGA',INH),0x43:('COMA',INH),0x44:('LSRA',INH),0x46:('RORA',INH),
0x47:('ASRA',INH),0x48:('ASLA',INH),0x49:('ROLA',INH),0x4A:('DECA',INH),
0x4C:('INCA',INH),0x4D:('TSTA',INH),0x4F:('CLRA',INH),
0x50:('NEGB',INH),0x53:('COMB',INH),0x54:('LSRB',INH),0x56:('RORB',INH),
0x57:('ASRB',INH),0x58:('ASLB',INH),0x59:('ROLB',INH),0x5A:('DECB',INH),
0x5C:('INCB',INH),0x5D:('TSTB',INH),0x5F:('CLRB',INH),
0x60:('NEG',IDX),0x63:('COM',IDX),0x64:('LSR',IDX),0x66:('ROR',IDX),
0x67:('ASR',IDX),0x68:('ASL',IDX),0x69:('ROL',IDX),0x6A:('DEC',IDX),
0x6C:('INC',IDX),0x6D:('TST',IDX),0x6E:('JMP',IDX),0x6F:('CLR',IDX),
0x70:('NEG',EXT),0x73:('COM',EXT),0x74:('LSR',EXT),0x76:('ROR',EXT),
0x77:('ASR',EXT),0x78:('ASL',EXT),0x79:('ROL',EXT),0x7A:('DEC',EXT),
0x7C:('INC',EXT),0x7D:('TST',EXT),0x7E:('JMP',EXT),0x7F:('CLR',EXT),
0x80:('SUBA',IMM8),0x81:('CMPA',IMM8),0x82:('SBCA',IMM8),0x83:('SUBD',IMM16),
0x84:('ANDA',IMM8),0x85:('BITA',IMM8),0x86:('LDAA',IMM8),0x88:('EORA',IMM8),
0x89:('ADCA',IMM8),0x8A:('ORAA',IMM8),0x8B:('ADDA',IMM8),0x8C:('CPX',IMM16),
0x8D:('BSR',REL),0x8E:('LDS',IMM16),0x8F:('XGDX',INH),
0x90:('SUBA',DIR),0x91:('CMPA',DIR),0x92:('SBCA',DIR),0x93:('SUBD',DIR),
0x94:('ANDA',DIR),0x95:('BITA',DIR),0x96:('LDAA',DIR),0x97:('STAA',DIR),
0x98:('EORA',DIR),0x99:('ADCA',DIR),0x9A:('ORAA',DIR),0x9B:('ADDA',DIR),
0x9C:('CPX',DIR),0x9D:('JSR',DIR),0x9E:('LDS',DIR),0x9F:('STS',DIR),
0xA0:('SUBA',IDX),0xA1:('CMPA',IDX),0xA2:('SBCA',IDX),0xA3:('SUBD',IDX),
0xA4:('ANDA',IDX),0xA5:('BITA',IDX),0xA6:('LDAA',IDX),0xA7:('STAA',IDX),
0xA8:('EORA',IDX),0xA9:('ADCA',IDX),0xAA:('ORAA',IDX),0xAB:('ADDA',IDX),
0xAC:('CPX',IDX),0xAD:('JSR',IDX),0xAE:('LDS',IDX),0xAF:('STS',IDX),
0xB0:('SUBA',EXT),0xB1:('CMPA',EXT),0xB2:('SBCA',EXT),0xB3:('SUBD',EXT),
0xB4:('ANDA',EXT),0xB5:('BITA',EXT),0xB6:('LDAA',EXT),0xB7:('STAA',EXT),
0xB8:('EORA',EXT),0xB9:('ADCA',EXT),0xBA:('ORAA',EXT),0xBB:('ADDA',EXT),
0xBC:('CPX',EXT),0xBD:('JSR',EXT),0xBE:('LDS',EXT),0xBF:('STS',EXT),
0xC0:('SUBB',IMM8),0xC1:('CMPB',IMM8),0xC2:('SBCB',IMM8),0xC3:('ADDD',IMM16),
0xC4:('ANDB',IMM8),0xC5:('BITB',IMM8),0xC6:('LDAB',IMM8),0xC8:('EORB',IMM8),
0xC9:('ADCB',IMM8),0xCA:('ORAB',IMM8),0xCB:('ADDB',IMM8),0xCC:('LDD',IMM16),
0xCE:('LDX',IMM16),0xCF:('STOP',INH),
0xD0:('SUBB',DIR),0xD1:('CMPB',DIR),0xD2:('SBCB',DIR),0xD3:('ADDD',DIR),
0xD4:('ANDB',DIR),0xD5:('BITB',DIR),0xD6:('LDAB',DIR),0xD7:('STAB',DIR),
0xD8:('EORB',DIR),0xD9:('ADCB',DIR),0xDA:('ORAB',DIR),0xDB:('ADDB',DIR),
0xDC:('LDD',DIR),0xDD:('STD',DIR),0xDE:('LDX',DIR),0xDF:('STX',DIR),
0xE0:('SUBB',IDX),0xE1:('CMPB',IDX),0xE2:('SBCB',IDX),0xE3:('ADDD',IDX),
0xE4:('ANDB',IDX),0xE5:('BITB',IDX),0xE6:('LDAB',IDX),0xE7:('STAB',IDX),
0xE8:('EORB',IDX),0xE9:('ADCB',IDX),0xEA:('ORAB',IDX),0xEB:('ADDB',IDX),
0xEC:('LDD',IDX),0xED:('STD',IDX),0xEE:('LDX',IDX),0xEF:('STX',IDX),
0xF0:('SUBB',EXT),0xF1:('CMPB',EXT),0xF2:('SBCB',EXT),0xF3:('ADDD',EXT),
0xF4:('ANDB',EXT),0xF5:('BITB',EXT),0xF6:('LDAB',EXT),0xF7:('STAB',EXT),
0xF8:('EORB',EXT),0xF9:('ADCB',EXT),0xFA:('ORAB',EXT),0xFB:('ADDB',EXT),
0xFC:('LDD',EXT),0xFD:('STD',EXT),0xFE:('LDX',EXT),0xFF:('STX',EXT),
}
# page 1 ($18): Y versions
P1 = {
0x08:('INY',INH),0x09:('DEY',INH),0x1C:('BSET',BITIDY),0x1D:('BCLR',BITIDY),
0x1E:('BRSET',BRIDY),0x1F:('BRCLR',BRIDY),0x30:('TSY',INH),0x35:('TYS',INH),
0x38:('PULY',INH),0x3A:('ABY',INH),0x3C:('PSHY',INH),
0x60:('NEG',IDY),0x63:('COM',IDY),0x64:('LSR',IDY),0x66:('ROR',IDY),
0x67:('ASR',IDY),0x68:('ASL',IDY),0x69:('ROL',IDY),0x6A:('DEC',IDY),
0x6C:('INC',IDY),0x6D:('TST',IDY),0x6E:('JMP',IDY),0x6F:('CLR',IDY),
0x8C:('CPY',IMM16),0x8F:('XGDY',INH),0x9C:('CPY',DIR),
0xA0:('SUBA',IDY),0xA1:('CMPA',IDY),0xA2:('SBCA',IDY),0xA3:('SUBD',IDY),
0xA4:('ANDA',IDY),0xA5:('BITA',IDY),0xA6:('LDAA',IDY),0xA7:('STAA',IDY),
0xA8:('EORA',IDY),0xA9:('ADCA',IDY),0xAA:('ORAA',IDY),0xAB:('ADDA',IDY),
0xAC:('CPY',IDY),0xAD:('JSR',IDY),0xAE:('LDS',IDY),0xAF:('STS',IDY),
0xBC:('CPY',EXT),0xCE:('LDY',IMM16),0xDE:('LDY',DIR),0xDF:('STY',DIR),
0xE0:('SUBB',IDY),0xE1:('CMPB',IDY),0xE2:('SBCB',IDY),0xE3:('ADDD',IDY),
0xE4:('ANDB',IDY),0xE5:('BITB',IDY),0xE6:('LDAB',IDY),0xE7:('STAB',IDY),
0xE8:('EORB',IDY),0xE9:('ADCB',IDY),0xEA:('ORAB',IDY),0xEB:('ADDB',IDY),
0xEC:('LDD',IDY),0xED:('STD',IDY),0xEE:('LDY',IDY),0xEF:('STY',IDY),
0xFE:('LDY',EXT),0xFF:('STY',EXT),
}
# page 2 ($1A): CPD, and X/Y cross indexed
P2 = {0x83:('CPD',IMM16),0x93:('CPD',DIR),0xA3:('CPD',IDX),0xB3:('CPD',EXT),
0xAC:('CPY',IDX),0xEE:('LDY',IDX),0xEF:('STY',IDX)}
# page 4 ($CD): X/Y cross indexed
P4 = {0xA3:('CPD',IDY),0xAC:('CPX',IDY),0xEE:('LDX',IDY),0xEF:('STX',IDY)}
def disasm(rom, addr, count):
out=[]
for _ in range(count):
start=addr
op=rom[addr]; addr+=1
tab=P0; pfx=''
if op==0x18: pfx='18 '; op=rom[addr]; addr+=1; tab=P1
elif op==0x1A: pfx='1A '; op=rom[addr]; addr+=1; tab=P2
elif op==0xCD: pfx='CD '; op=rom[addr]; addr+=1; tab=P4
ent=tab.get(op)
if ent is None:
out.append((start,f".byte ${op:02X}",'?')); continue
mn,mode=ent; operand=''; tgt=None
if mode==INH: pass
elif mode==IMM8: operand=f"#${rom[addr]:02X}"; addr+=1
elif mode==IMM16: operand=f"#${(rom[addr]<<8)|rom[addr+1]:04X}"; addr+=2
elif mode==DIR: operand=f"${rom[addr]:02X}"; addr+=1
elif mode==EXT:
v=(rom[addr]<<8)|rom[addr+1]; addr+=2; operand=f"${v:04X}"; tgt=v
elif mode==IDX: operand=f"${rom[addr]:02X},X"; addr+=1
elif mode==IDY: operand=f"${rom[addr]:02X},Y"; addr+=1
elif mode==REL:
rel=rom[addr]; addr+=1; d=rel-256 if rel>127 else rel
tgt=(addr+d)&0xFFFF; operand=f"${tgt:04X}"
elif mode==BITDIR: operand=f"${rom[addr]:02X} #${rom[addr+1]:02X}"; addr+=2
elif mode==BITIDX: operand=f"${rom[addr]:02X},X #${rom[addr+1]:02X}"; addr+=2
elif mode==BITIDY: operand=f"${rom[addr]:02X},Y #${rom[addr+1]:02X}"; addr+=2
elif mode==BRDIR:
dd=rom[addr]; mk=rom[addr+1]; rel=rom[addr+2]; addr+=3
s=rel-256 if rel>127 else rel; tgt=(addr+s)&0xFFFF
operand=f"${dd:02X} #${mk:02X} ${tgt:04X}"
elif mode==BRIDX:
dd=rom[addr]; mk=rom[addr+1]; rel=rom[addr+2]; addr+=3
s=rel-256 if rel>127 else rel; tgt=(addr+s)&0xFFFF
operand=f"${dd:02X},X #${mk:02X} ${tgt:04X}"
elif mode==BRIDY:
dd=rom[addr]; mk=rom[addr+1]; rel=rom[addr+2]; addr+=3
s=rel-256 if rel>127 else rel; tgt=(addr+s)&0xFFFF
operand=f"${dd:02X},Y #${mk:02X} ${tgt:04X}"
raw=' '.join(f'{rom[b]:02X}' for b in range(start,addr))
out.append((start,f"{pfx}{mn} {operand}".strip(), raw, mn, tgt))
return out
if __name__=='__main__':
rom=open(__import__('os').path.join(__import__('os').path.dirname(__file__),'tms27pc512.BIN'),'rb').read()
start=int(sys.argv[1],16); count=int(sys.argv[2]) if len(sys.argv)>2 else 40
for row in disasm(rom,start,count):
a=row[0]; txt=row[1]; raw=row[2]
print(f"{a:04X}: {raw:<12} {txt}")
@@ -0,0 +1,170 @@
// MatrixPortalPlasma — a hardware replica of the Babcock PD01D221 cockpit
// plasma display, for the Adafruit Matrix Portal S3 driving two chained
// Adafruit 64x32 HUB75 RGB panels (= 128x32).
//
// It enumerates as a USB CDC serial port; point the game's plasma output at it
// (DOSBox-X: serial2=directserial realport:COMx) and it speaks the device side
// of the PD01D221 protocol, rendered in neon-orange to mimic the plasma. The
// command parser + fonts are ported verbatim from vRIO's vPLASMA emulator
// (the reference oracle) — see PlasmaNew/FIRMWARE.md.
//
// Onboard buttons (no host needed): UP = run the built-in firmware demo,
// DOWN = panel test pattern (all dots). BOOT/reset re-runs the power-on splash.
//
// Libraries: Adafruit Protomatter (+ Adafruit GFX). Board: "Adafruit Matrix
// Portal S3" (ESP32-S3). USB CDC On Boot: Enabled.
#include <Adafruit_Protomatter.h>
#include "PlasmaDisplay.h"
#include "demo_screens.h"
// ---- HUB75 pins for the Adafruit Matrix Portal S3 ------------------------
// These are Adafruit's published Matrix Portal S3 pins (Protomatter examples).
// Verify against your installed library version if the panel misbehaves.
static uint8_t rgbPins[] = {42, 41, 40, 38, 39, 37};
static uint8_t addrPins[] = {45, 36, 48, 35}; // A,B,C,D — 4 lines for 32-high (1/16 scan)
static uint8_t clockPin = 2;
static uint8_t latchPin = 47;
static uint8_t oePin = 14;
// 128 wide (two 64-wide panels chained), 4-bit color, one chain, double-buffered.
Adafruit_Protomatter matrix(128, 4, 1, rgbPins, 4, addrPins,
clockPin, latchPin, oePin, true);
PlasmaDisplay display;
// Plasma-orange palette (RGB565). Full-intensity, half-intensity, off.
static uint16_t COLOR_LIT, COLOR_HALF;
// Blink phase for flashing text / cursor (~3.8 Hz, matched to vPLASMA's 266 ms).
static const uint32_t BLINK_MS = 266;
static uint32_t lastBlink = 0;
static bool blinkPhase = true;
// Demo playback (UP button): loop the 10 firmware screens.
static bool demoRunning = false;
static int demoScreen = 0;
static uint32_t lastDemoStep = 0;
static const uint32_t DEMO_MS = 2800;
// Panel test (DOWN button): cycle the diagnostic patterns.
static bool testRunning = false;
static int testPattern = 0;
static uint32_t lastTestStep = 0;
static const uint32_t TEST_MS = 1200;
// ---- helpers -------------------------------------------------------------
static bool frameHasFlash = false;
static void renderFrame() {
const uint8_t *px = display.frame();
bool anyFlash = false;
for (int y = 0; y < PlasmaDisplay::HEIGHT; ++y) {
for (int x = 0; x < PlasmaDisplay::WIDTH; ++x) {
uint8_t dot = px[y * PlasmaDisplay::WIDTH + x];
uint16_t color = 0;
if (dot & PlasmaDisplay::PIX_LIT) {
if (dot & PlasmaDisplay::PIX_FLASH) {
anyFlash = true;
color = blinkPhase ? ((dot & PlasmaDisplay::PIX_HALF) ? COLOR_HALF : COLOR_LIT) : 0;
} else {
color = (dot & PlasmaDisplay::PIX_HALF) ? COLOR_HALF : COLOR_LIT;
}
}
matrix.drawPixel(x, y, color);
}
}
matrix.show();
frameHasFlash = anyFlash;
}
static void feedDemoScreen(int i) {
const PlasmaDemoScreen &s = plasmaDemo[i];
for (uint16_t j = 0; j < s.len; ++j)
display.feed(pgm_read_byte(&s.data[j]));
}
// ---- Arduino ------------------------------------------------------------
void setup() {
Serial.begin(9600); // USB CDC — the baud is cosmetic over USB
ProtomatterStatus status = matrix.begin();
// If begin() fails the wiring/pins are wrong; blink the onboard LED forever.
if (status != PROTOMATTER_OK) {
pinMode(LED_BUILTIN, OUTPUT);
for (;;) { digitalWrite(LED_BUILTIN, HIGH); delay(120); digitalWrite(LED_BUILTIN, LOW); delay(120); }
}
COLOR_LIT = matrix.color565(255, 96, 0); // neon orange
COLOR_HALF = matrix.color565(110, 40, 0); // dim orange
#ifdef BUTTON_UP
pinMode(BUTTON_UP, INPUT_PULLUP);
#endif
#ifdef BUTTON_DOWN
pinMode(BUTTON_DOWN, INPUT_PULLUP);
#endif
// Power-on splash: light every dot for ~1 s (confirms both panels), then clear.
display.showTestPattern();
renderFrame();
delay(1000);
display.reset();
renderFrame();
}
void loop() {
// 1) Drain the USB serial into the parser.
while (Serial.available() > 0)
display.feed((uint8_t)Serial.read());
// 2) Buttons (edge-detected).
#ifdef BUTTON_UP
static bool upPrev = HIGH;
bool up = digitalRead(BUTTON_UP);
if (upPrev == HIGH && up == LOW) { // pressed
demoRunning = !demoRunning;
testRunning = false;
if (demoRunning) { demoScreen = 0; feedDemoScreen(0); demoScreen = 1; lastDemoStep = millis(); }
else { display.reset(); }
}
upPrev = up;
#endif
#ifdef BUTTON_DOWN
static bool downPrev = HIGH;
bool down = digitalRead(BUTTON_DOWN);
if (downPrev == HIGH && down == LOW) { // pressed
testRunning = !testRunning;
demoRunning = false;
if (testRunning) { testPattern = 0; display.showTestPattern(0); lastTestStep = millis(); }
else { display.reset(); }
}
downPrev = down;
#endif
// 3) Advance the demo / test on their timers.
uint32_t now = millis();
if (demoRunning && now - lastDemoStep >= DEMO_MS) {
feedDemoScreen(demoScreen);
demoScreen = (demoScreen + 1) % plasmaDemoCount;
lastDemoStep = now;
}
if (testRunning && now - lastTestStep >= TEST_MS) {
testPattern = (testPattern + 1) % PlasmaDisplay::TEST_PATTERN_COUNT;
display.showTestPattern(testPattern);
lastTestStep = now;
}
// 4) Blink phase for flashing pixels.
if (now - lastBlink >= BLINK_MS) {
blinkPhase = !blinkPhase;
lastBlink = now;
if (frameHasFlash) renderFrame(); // only re-render if something blinks
}
// 5) Repaint when the parser changed the frame.
if (display.takeDirty())
renderFrame();
}
@@ -0,0 +1,225 @@
#include "PlasmaDisplay.h"
// Command bytes (see PlasmaProtocol.cs / FIRMWARE.md).
static const uint8_t ESC = 0x1B;
static const uint8_t BS = 0x08, HT = 0x09, LF = 0x0A, VT = 0x0B, CR = 0x0D;
static const uint8_t CMD_CLEAR = '@', CMD_CURSOR = 'G', CMD_ATTR = 'H';
static const uint8_t CMD_FONT = 'K', CMD_HOME = 'L', CMD_GRAPHICS = 'P';
static const uint8_t CMD_SETROW = 'Q', CMD_SETCOL = 'R';
static const uint8_t CMD_DRAWPAGE = 'I', CMD_DISPPAGE = 'i';
PlasmaDisplay::PlasmaDisplay() { reset(); }
void PlasmaDisplay::reset() {
memset(pixels_, 0, sizeof(pixels_));
cx_ = cy_ = 0;
font_ = 0;
face_ = &plasmaFonts[0];
attrs_ = 0;
cursorMode_ = CURSOR_STEADY; // power-on default; the game hides it
orient_ = HORIZONTAL;
state_ = TEXT;
headerFill_ = 0;
dataIndex_ = dataLength_ = 0;
dirty_ = true;
}
void PlasmaDisplay::setOrientation(Orientation o) {
if (orient_ == o) return;
orient_ = o;
memset(pixels_, 0, sizeof(pixels_));
cx_ = cy_ = 0;
dirty_ = true;
}
// The real display's power-on test (JP1 jumper 5, firmware $B888) runs through
// several drawing sequences — solid, border+grid, and more — to expose dead
// dots and addressing faults. These stand in for that; the sketch cycles them.
static bool testDot(int index, int x, int y) {
switch (index) {
case 0: return true; // all on
case 1: return x == 0 || x == PlasmaDisplay::WIDTH - 1 || // frame
y == 0 || y == PlasmaDisplay::HEIGHT - 1 ||
(x % 16) == 0 || (y % 8) == 0; // + grid
case 2: return ((y / 2) % 2) == 0; // horizontal stripes
case 3: return ((x / 4) % 2) == 0; // vertical stripes
default: return (((x / 8) + (y / 8)) % 2) == 0; // checkerboard
}
}
void PlasmaDisplay::showTestPattern(int index) {
for (int y = 0; y < HEIGHT; ++y)
for (int x = 0; x < WIDTH; ++x)
pixels_[y * WIDTH + x] = testDot(index, x, y) ? PIX_LIT : 0;
dirty_ = true;
}
// One logical dot → the physical 128x32 buffer, rotated per orientation.
void PlasmaDisplay::plot(int lx, int ly, uint8_t flags) {
if ((unsigned)lx >= (unsigned)logicalW() || (unsigned)ly >= (unsigned)logicalH()) return;
int px, py;
if (orient_ == HORIZONTAL) {
px = lx;
py = ly;
} else {
px = ly;
py = HEIGHT - 1 - lx; // 90° rotation onto landscape glass
}
pixels_[py * WIDTH + px] = flags;
}
void PlasmaDisplay::feed(const uint8_t *buf, size_t n) {
for (size_t i = 0; i < n; ++i) feed(buf[i]);
}
void PlasmaDisplay::feed(uint8_t b) {
switch (state_) {
case TEXT: stepText(b); break;
case ESCAPE: stepEscape(b); break;
case OPERAND:
state_ = TEXT;
applyOperand(pendingCmd_, b);
break;
case GHEADER:
header_[headerFill_++] = b;
if (headerFill_ == 5) beginGraphics();
break;
case GDATA: stepGraphics(b); break;
}
}
void PlasmaDisplay::stepText(uint8_t b) {
switch (b) {
case ESC: state_ = ESCAPE; return;
case BS: cx_ = max(0, cx_ - face_->width); dirty_ = true; return;
case HT: advanceCursor(); dirty_ = true; return;
case LF: nextLine(); dirty_ = true; return;
case VT:
cy_ -= face_->height;
if (cy_ < 0) cy_ = max(0, logicalH() - face_->height);
dirty_ = true;
return;
case CR: cx_ = 0; dirty_ = true; return;
}
if (b < 0x20) return; // any other control byte: swallow (matches firmware)
drawChar(b);
}
void PlasmaDisplay::stepEscape(uint8_t b) {
state_ = TEXT;
switch (b) {
case CMD_CLEAR:
memset(pixels_, 0, sizeof(pixels_));
cx_ = cy_ = 0;
font_ = 0;
face_ = &plasmaFonts[0];
attrs_ = 0;
dirty_ = true;
break;
case CMD_HOME:
cx_ = cy_ = 0;
dirty_ = true;
break;
case CMD_CURSOR:
case CMD_FONT:
case CMD_ATTR:
case CMD_SETROW:
case CMD_SETCOL:
case CMD_DRAWPAGE:
case CMD_DISPPAGE:
pendingCmd_ = b;
state_ = OPERAND;
break;
case CMD_GRAPHICS:
headerFill_ = 0;
state_ = GHEADER;
break;
default: break; // unknown command: ignored (does not consume an operand)
}
}
void PlasmaDisplay::applyOperand(uint8_t cmd, uint8_t op) {
switch (cmd) {
case CMD_CURSOR:
cursorMode_ = (op == 0x00 || op == 0xFF) ? CURSOR_HIDDEN
: (op & 0x02) ? CURSOR_FLASHING
: CURSOR_STEADY;
dirty_ = true;
break;
case CMD_FONT:
if (op < 8) { // 8 real fonts; firmware ignores larger operands
font_ = op;
face_ = &plasmaFonts[op];
if (cx_ > logicalW() - 1) cx_ = logicalW() - 1;
if (cy_ > logicalH() - 1) cy_ = logicalH() - 1;
dirty_ = true;
}
break;
case CMD_ATTR:
attrs_ = op & 0x0F; // low 4 bits: half/underline/reverse/flash
break;
case CMD_SETROW:
if (op < logicalH()) { cy_ = op; dirty_ = true; }
break;
case CMD_SETCOL:
if (op < logicalW()) { cx_ = op; dirty_ = true; }
break;
case CMD_DRAWPAGE:
case CMD_DISPPAGE:
break; // page select: consumed but single-page (see FIRMWARE.md)
}
}
void PlasmaDisplay::beginGraphics() {
int w = header_[3], h = header_[4];
dataLength_ = w * h;
dataIndex_ = 0;
state_ = dataLength_ > 0 ? GDATA : TEXT;
}
void PlasmaDisplay::stepGraphics(uint8_t b) {
int w = header_[3];
int rowOfBlock = dataIndex_ / w;
int byteOfRow = dataIndex_ % w;
int y = header_[1] + rowOfBlock;
int baseX = (header_[2] + byteOfRow) * 8;
// MSB = leftmost pixel (L4PLASMA.CPP packs 0x80 first).
for (int bit = 0; bit < 8; ++bit)
plot(baseX + bit, y, (b & (0x80 >> bit)) ? PIX_LIT : 0);
dirty_ = true;
if (++dataIndex_ >= dataLength_) state_ = TEXT;
}
void PlasmaDisplay::drawChar(uint8_t code) {
if (!faceHas(code)) return; // firmware ignores out-of-range chars
int w = face_->width, h = face_->height;
bool reverse = attrs_ & 0x04;
bool underline = attrs_ & 0x02;
uint8_t litFlags = PIX_LIT;
if (attrs_ & 0x01) litFlags |= PIX_HALF;
if (attrs_ & 0x08) litFlags |= PIX_FLASH;
for (int row = 0; row < h; ++row) {
uint16_t bits = faceRow(code, row); // bit15 = leftmost pixel
for (int col = 0; col < w; ++col) {
bool on = bits & (0x8000 >> col);
if (underline && row == h - 1) on = true;
if (reverse) on = !on;
plot(cx_ + col, cy_ + row, on ? litFlags : 0);
}
}
dirty_ = true;
advanceCursor();
}
void PlasmaDisplay::advanceCursor() {
cx_ += face_->width;
if (cx_ > logicalW() - face_->width) nextLine();
}
void PlasmaDisplay::nextLine() {
cx_ = 0;
cy_ += face_->height;
if (cy_ > logicalH() - face_->height) cy_ = 0; // wrap to top; no scroll
}
@@ -0,0 +1,90 @@
// PlasmaDisplay — the Babcock PD01D221 command parser + 128x32 framebuffer,
// ported line-for-line from vRIO's C# VPlasmaDevice (src/VPlasma.Core/Device/
// VPlasmaDevice.cs). Keep the two in sync: vPLASMA is the reference oracle.
//
// Feed received wire bytes to feed(); read the framebuffer with frame() and
// render it however the panel wants. Each pixel is a flag byte (PIX_LIT /
// PIX_HALF / PIX_FLASH), so the renderer can dim half-intensity dots and blink
// flashing ones. See PlasmaNew/FIRMWARE.md for the recovered command set.
#pragma once
#include <Arduino.h>
#include "plasma_fonts.h"
class PlasmaDisplay {
public:
static const int WIDTH = 128;
static const int HEIGHT = 32;
// Per-pixel flag bits in the framebuffer.
static const uint8_t PIX_LIT = 0x01;
static const uint8_t PIX_HALF = 0x02;
static const uint8_t PIX_FLASH = 0x04;
enum Orientation { HORIZONTAL, VERTICAL };
enum CursorMode { CURSOR_HIDDEN, CURSOR_STEADY, CURSOR_FLASHING };
PlasmaDisplay();
static const int TEST_PATTERN_COUNT = 5; // JP1 jumper-5 diagnostic patterns
void reset(); // power-on: dark glass, home cursor
void feed(uint8_t b); // one received wire byte
void feed(const uint8_t *buf, size_t n);
void showTestPattern() { showTestPattern(0); } // all dots lit
void showTestPattern(int index); // JP1 jumper 5: solid/grid/stripes/checker
void setOrientation(Orientation o); // JP1 jumper 4
const uint8_t *frame() const { return pixels_; }
bool takeDirty() { // true (and clears) if the frame changed
bool d = dirty_;
dirty_ = false;
return d;
}
CursorMode cursorMode() const { return cursorMode_; }
int cursorX() const { return cx_; }
int cursorY() const { return cy_; }
int fontWidth() const { return face_->width; }
int fontHeight() const { return face_->height; }
Orientation orientation() const { return orient_; }
private:
uint8_t pixels_[WIDTH * HEIGHT]; // always physical 128x32
// Text-mode state.
int font_;
const PlasmaFace *face_;
uint8_t attrs_; // low 4 bits: 1=half 2=underline 4=reverse 8=flash
int cx_, cy_; // cursor, logical pixels
CursorMode cursorMode_;
Orientation orient_;
// Parser state.
enum State { TEXT, ESCAPE, OPERAND, GHEADER, GDATA };
State state_;
uint8_t pendingCmd_;
uint8_t header_[5]; // screen, y, x, w, h
int headerFill_;
int dataIndex_, dataLength_;
bool dirty_;
int logicalW() const { return orient_ == HORIZONTAL ? WIDTH : HEIGHT; }
int logicalH() const { return orient_ == HORIZONTAL ? HEIGHT : WIDTH; }
void plot(int lx, int ly, uint8_t flags);
void stepText(uint8_t b);
void stepEscape(uint8_t b);
void applyOperand(uint8_t cmd, uint8_t op);
void beginGraphics();
void stepGraphics(uint8_t b);
void drawChar(uint8_t code);
void advanceCursor();
void nextLine();
bool faceHas(uint8_t code) const { return code >= face_->first && code <= face_->last; }
uint16_t faceRow(uint8_t code, int row) const {
if (!faceHas(code) || (unsigned)row >= (unsigned)face_->height) return 0;
return pgm_read_word(&face_->rows[(code - face_->first) * face_->height + row]);
}
};
@@ -0,0 +1,88 @@
// AUTO-GENERATED from the PD01D221 firmware (tms27pc512.BIN): the real
// 10-screen demonstration program (the display's built-in demo, JP1
// jumper 6), extracted from the demo pointer table at $8000. In PROGMEM.
#pragma once
#include <Arduino.h>
static const uint8_t PROGMEM plasmaDemo0[] = {
0x1B,0x47,0x00,0x1B,0x40,0x1B,0x4C,0x1B,0x52,0x04,0x59,0x4F,0x55,0x20,0x48,0x41,
0x56,0x45,0x20,0x45,0x4E,0x41,0x42,0x4C,0x45,0x44,0x20,0x54,0x48,0x45,0x1B,0x52,
0x25,0x50,0x44,0x30,0x31,0x2D,0x44,0x32,0x32,0x31,0x0A,0x1B,0x52,0x01,0x44,0x45,
0x4D,0x4F,0x4E,0x53,0x54,0x52,0x41,0x54,0x49,0x4F,0x4E,0x20,0x50,0x52,0x4F,0x47,
0x52,0x41,0x4D
};
static const uint8_t PROGMEM plasmaDemo1[] = {
0x1B,0x49,0x02,0x1B,0x40,0x1B,0x4C,0x1B,0x52,0x02,0x49,0x46,0x20,0x59,0x4F,0x55,
0x20,0x44,0x4F,0x20,0x4E,0x4F,0x54,0x20,0x57,0x49,0x53,0x48,0x20,0x54,0x4F,0x1B,
0x52,0x0A,0x56,0x49,0x45,0x57,0x20,0x54,0x48,0x49,0x53,0x20,0x50,0x52,0x4F,0x47,
0x52,0x41,0x4D,0x2C,0x0A,0x1B,0x52,0x07,0x52,0x45,0x4D,0x4F,0x56,0x45,0x20,0x4A,
0x55,0x4D,0x50,0x45,0x52,0x20,0x36,0x20,0x41,0x4E,0x44,0x0A,0x1B,0x52,0x0D,0x52,
0x45,0x53,0x45,0x54,0x20,0x54,0x48,0x45,0x20,0x44,0x49,0x53,0x50,0x4C,0x41,0x59,
0x1B,0x69,0x02
};
static const uint8_t PROGMEM plasmaDemo2[] = {
0x1B,0x49,0x01,0x1B,0x40,0x1B,0x4B,0x04,0x1B,0x51,0x00,0x1B,0x52,0x0A,0x50,0x4C,
0x41,0x53,0x4D,0x41,0x44,0x4F,0x54,0x1B,0x52,0x0A,0x50,0x44,0x30,0x31,0x2D,0x44,
0x32,0x32,0x31,0x1B,0x69,0x01
};
static const uint8_t PROGMEM plasmaDemo3[] = {
0x1B,0x49,0x00,0x1B,0x40,0x1B,0x4B,0x06,0x1B,0x51,0x00,0x1B,0x52,0x1D,0x41,0x20,
0x43,0x4F,0x4D,0x50,0x4C,0x45,0x54,0x45,0x0A,0x1B,0x52,0x0F,0x44,0x49,0x53,0x50,
0x4C,0x41,0x59,0x20,0x53,0x59,0x53,0x54,0x45,0x4D,0x0A,0x1B,0x52,0x0C,0x49,0x4E,
0x20,0x4F,0x4E,0x45,0x20,0x50,0x41,0x43,0x4B,0x41,0x47,0x45,0x21,0x1B,0x69,0x00
};
static const uint8_t PROGMEM plasmaDemo4[] = {
0x1B,0x49,0x01,0x1B,0x40,0x1B,0x4B,0x06,0x1B,0x4C,0x46,0x45,0x41,0x54,0x55,0x52,
0x45,0x53,0x20,0x49,0x4E,0x43,0x4C,0x55,0x44,0x45,0x20,0x2D,0x1B,0x4B,0x03,0x1B,
0x51,0x0C,0x1B,0x52,0x0A,0x4F,0x1B,0x4B,0x02,0x20,0x53,0x45,0x52,0x49,0x41,0x4C,
0x20,0x49,0x4E,0x54,0x45,0x52,0x46,0x41,0x43,0x45,0x0A,0x1B,0x4B,0x03,0x1B,0x52,
0x0A,0x4F,0x1B,0x4B,0x02,0x20,0x50,0x41,0x52,0x41,0x4C,0x4C,0x45,0x4C,0x20,0x50,
0x4F,0x52,0x54,0x1B,0x69,0x01
};
static const uint8_t PROGMEM plasmaDemo5[] = {
0x1B,0x49,0x02,0x1B,0x40,0x1B,0x4B,0x06,0x1B,0x4C,0x46,0x45,0x41,0x54,0x55,0x52,
0x45,0x53,0x20,0x49,0x4E,0x43,0x4C,0x55,0x44,0x45,0x20,0x2D,0x1B,0x51,0x0C,0x1B,
0x4B,0x03,0x1B,0x52,0x0A,0x4F,0x1B,0x4B,0x02,0x20,0x50,0x4F,0x57,0x45,0x52,0x20,
0x53,0x55,0x50,0x50,0x4C,0x59,0x0A,0x1B,0x4B,0x03,0x1B,0x52,0x0A,0x4F,0x1B,0x4B,
0x02,0x20,0x4B,0x45,0x59,0x50,0x41,0x44,0x20,0x49,0x4E,0x54,0x45,0x52,0x46,0x41,
0x43,0x45,0x1B,0x69,0x02
};
static const uint8_t PROGMEM plasmaDemo6[] = {
0x1B,0x49,0x00,0x1B,0x40,0x1B,0x4B,0x02,0x1B,0x51,0x00,0x1B,0x52,0x00,0x4D,0x49,
0x58,0x20,0x54,0x45,0x58,0x54,0x20,0x41,0x4E,0x44,0x20,0x47,0x52,0x41,0x50,0x48,
0x49,0x43,0x53,0x1B,0x52,0x18,0x55,0x53,0x49,0x4E,0x47,0x20,0x38,0x20,0x53,0x54,
0x4F,0x52,0x45,0x44,0x0A,0x1B,0x52,0x0D,0x43,0x48,0x41,0x52,0x41,0x43,0x54,0x45,
0x52,0x20,0x46,0x4F,0x4E,0x54,0x53,0x20,0x2D,0x1B,0x69,0x00
};
static const uint8_t PROGMEM plasmaDemo7[] = {
0x1B,0x49,0x01,0x1B,0x40,0x1B,0x4B,0x02,0x1B,0x4C,0x49,0x4E,0x54,0x45,0x4C,0x4C,
0x49,0x47,0x45,0x4E,0x54,0x20,0x49,0x4E,0x54,0x45,0x52,0x46,0x41,0x43,0x45,0x1B,
0x52,0x19,0x48,0x41,0x53,0x20,0x44,0x4F,0x5A,0x45,0x4E,0x53,0x20,0x4F,0x46,0x0A,
0x1B,0x52,0x25,0x43,0x4F,0x4D,0x4D,0x41,0x4E,0x44,0x53,0x2E,0x1B,0x69,0x01
};
static const uint8_t PROGMEM plasmaDemo8[] = {
0x1B,0x49,0x02,0x1B,0x40,0x1B,0x4B,0x02,0x1B,0x4C,0x1B,0x52,0x19,0x43,0x48,0x4F,
0x4F,0x53,0x45,0x20,0x45,0x49,0x54,0x48,0x45,0x52,0x0A,0x1B,0x52,0x19,0x48,0x4F,
0x52,0x49,0x5A,0x4F,0x4E,0x54,0x41,0x4C,0x20,0x4F,0x52,0x0A,0x1B,0x52,0x04,0x56,
0x45,0x52,0x54,0x49,0x43,0x41,0x4C,0x20,0x4F,0x52,0x49,0x45,0x4E,0x54,0x41,0x54,
0x49,0x4F,0x4E,0x1B,0x69,0x02
};
static const uint8_t PROGMEM plasmaDemo9[] = {
0x1B,0x49,0x00,0x1B,0x40,0x1B,0x5A,0x00,0x00,0x00,0x00,0x1B,0x69,0x00
};
struct PlasmaDemoScreen { const uint8_t *data; uint16_t len; };
static const PlasmaDemoScreen plasmaDemo[10] = {
{ plasmaDemo0, 67 },
{ plasmaDemo1, 99 },
{ plasmaDemo2, 38 },
{ plasmaDemo3, 64 },
{ plasmaDemo4, 86 },
{ plasmaDemo5, 85 },
{ plasmaDemo6, 76 },
{ plasmaDemo7, 63 },
{ plasmaDemo8, 70 },
{ plasmaDemo9, 14 },
};
static const int plasmaDemoCount = 10;
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
# Plasma display replica — Adafruit Matrix Portal S3 + HUB75
A hardware replacement for the failing Babcock **PD01D221** cockpit plasma
display ([../README.md](../README.md), [../FIRMWARE.md](../FIRMWARE.md)). A
modern microcontroller reads the same serial command stream the game sends and
renders it to a modern LED matrix — a drop-in from the host's point of view,
with none of the plasma physics or high voltage.
The firmware's command parser and fonts are **ported verbatim from vRIO's
vPLASMA emulator** (`src/VPlasma.Core`), which is the reference oracle. Feed
the replica and vPLASMA the same byte stream and they produce the same frame.
## Bill of materials
| Part | Notes |
|------|-------|
| **Adafruit Matrix Portal S3** | ESP32-S3 controller; plugs into the HUB75 header; native USB-C = the virtual COM port |
| **2 × Adafruit 64×32 RGB LED Matrix** (P-pitch to taste; 1/16 scan) | chained → **128×32**, the panel's native resolution |
| **5 V power supply, ≥ 4 A** | the panels are the load; USB cannot power them |
| USB-C cable | data (and logic power) from the host PC |
An amber-leaning pitch and diffuser best mimic the neon-orange plasma; the
firmware already renders in orange (`255,96,0` full / `110,40,0` half).
## Wiring
1. **Chain the panels**: panel A `OUT` → panel B `IN`, left-to-right, so the
pair reads as one 128-wide canvas (x 063 = panel A, 64127 = panel B). If
the image comes out swapped, reverse the chain order.
2. **Mount the Matrix Portal S3** onto panel A's `IN` HUB75 header.
3. **Power**: 5 V ≥ 4 A into the Matrix Portal's screw terminals; run the
panels' power pigtails from the same 5 V. USB-C carries data (and powers the
S3 logic) — do **not** rely on USB for panel current.
## Build & flash
Arduino IDE (or `arduino-cli`):
1. **Boards Manager** → install **esp32** (Espressif). Select board **"Adafruit
Matrix Portal S3"**. Set **USB CDC On Boot: Enabled** (so `Serial` is the
USB port the game opens).
2. **Library Manager** → install **Adafruit Protomatter** (pulls in Adafruit
GFX / BusIO).
3. Open `MatrixPortalPlasma/MatrixPortalPlasma.ino` (keep `PlasmaDisplay.*`,
`plasma_fonts.h`, `demo_screens.h` beside it) and Upload.
The HUB75 pin arrays at the top of the `.ino` are Adafruit's published Matrix
Portal S3 values; if the panel garbles, verify them against your installed
Protomatter version.
## Using it with the game
The Matrix Portal enumerates as a **USB CDC COM port**. In Windows Device
Manager you can pin it to the COM number the host expects. Point the game's
plasma output at it — under the DOSBox-X fork:
```
serial2 = directserial realport:COMx
```
Baud is cosmetic over USB CDC (the `9600` line-coding is accepted as a no-op),
so the display keeps up regardless. The link is one-way (the game writes, the
display listens), exactly as the cockpit drove the real panel.
## No-host testing (onboard buttons)
- **UP** — toggle the built-in **firmware demonstration** (the real 10-screen
PLASMADOT demo, `demo_screens.h`, extracted from the ROM).
- **DOWN** — toggle the **panel test**: cycles diagnostic patterns (solid,
border+grid, horizontal/vertical stripes, checkerboard) to expose dead dots
and addressing faults — the multi-pattern sequence the real firmware runs.
- **Power-on** briefly lights every dot (confirms both panels), then clears.
## Files
| File | |
|------|--|
| `MatrixPortalPlasma/MatrixPortalPlasma.ino` | sketch: Protomatter init, USB serial, render loop, buttons |
| `MatrixPortalPlasma/PlasmaDisplay.h/.cpp` | the PD01D221 parser + 128×32 framebuffer — a C++ port of `VPlasmaDevice` |
| `MatrixPortalPlasma/plasma_fonts.h` | the 8 real ROM fonts (PROGMEM), generated from `tms27pc512.BIN` |
| `MatrixPortalPlasma/demo_screens.h` | the 10 firmware demo screens (PROGMEM) |
## Keeping it faithful — the differential test
`PlasmaDisplay` is a line-for-line port of `VPlasmaDevice`; keep the two in
sync (same commands, fonts, orientation/`plot` mapping). Validate the replica
by sending **identical byte streams** to the replica, to vPLASMA, and to the
real panel, then comparing the glass.
The **`VPlasma.Wire`** tool (`tools/VPlasma.Wire`, `dotnet run --project
tools/VPlasma.Wire -- …`) drives this:
```sh
# 1) Build a repeatable stream (or capture a real one — see below).
VPlasma.Wire synth --kind demo --out demo.bin
# 2) The vPLASMA golden image (pixel-exact reference PNG).
VPlasma.Wire render --in demo.bin --out demo-golden.png --scale 8
# 3) Replay the SAME bytes to each target, and photograph the glass:
VPlasma.Wire replay --in demo.bin --port COM3 # the real panel (RS-232)
VPlasma.Wire replay --in demo.bin --port COM7 # the Matrix Portal (USB-CDC)
```
Compare the two photos against `demo-golden.png`. `synth` kinds: `selftest`,
`demo`, `banner`, `charset`. `render` takes `--orient v` for vertical.
**Capture a real session** to build a test corpus non-intrusively — insert the
tool in the path (point the game at `COM12`, tool tees on to the real display
on `COM3`):
```sh
VPlasma.Wire capture --port COM12 --out session.bin --tee COM3
```
Then `render`/`replay` `session.bin` like any other stream.
**Auto-compare** a photo (cropped to the glass) against the golden image, or
two streams against each other — each is reduced to 128×32 and diffed:
```sh
VPlasma.Wire diff --a demo-golden.png --b panel-photo.png --out delta.png
# prints "differing dots=N/4096 match=XX%", writes a red-on-mismatch image,
# exits 1 if they differ. Inputs may be .bin (ESC P) / .png / .txt (bitmap=).
```
### Authoring plasma content (from TeslaSuite's plasma tools)
Generate display content from text or images and push it over the wire:
```sh
# Text in a Windows font, auto-sized to the panel → preview PNG + ESC P + hex:
VPlasma.Wire text --text "ALERT 12" --font "Arial" --png a.png --bin a.bin --hex a.txt
VPlasma.Wire replay --in a.bin --port COM7 # show it on the replica
# An image → the ESC P wire stream (and/or the game's bitmap= encoding):
VPlasma.Wire encode --in logo.png --bin logo.bin --hex logo.txt
# A bitmap= encoding → a preview PNG (and/or ESC P):
VPlasma.Wire decode --in logo.txt --png logo.png
```
Still deferred (as in vPLASMA, documented in `../FIRMWARE.md`): the 10
double-buffered pages (`ESC I`/`ESC i` are consumed but single-page) and the
vector-graphics primitives (`ESC A``F`).
Binary file not shown.
+40 -9
View File
@@ -103,14 +103,42 @@ analog polling drives the "analog polls served" counter, and every click on
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
a null-modem cable work the same way. a null-modem cable work the same way.
## Named pipes for DOSBox-X (no com0com)
When the game runs in the patched DOSBox-X (the fork's `namedpipe` serial
backend), the virtual ports aren't needed at all: each device's connection
picker lists its named-pipe endpoint — `pipe:vrio`, `pipe:vplasma`
alongside the machine's COM ports. Select it and **Open** to serve
`\\.\pipe\vrio` / `\\.\pipe\vplasma`; DOSBox-X connects as the client,
retrying in the background until the pipe exists:
```ini
[serial]
serial1 = namedpipe pipe:vrio rxpollus:100 rxburst:16
serial2 = namedpipe pipe:vplasma
```
A pipe is a plain byte stream, so serial data and modem lines travel as
typed frames (`0x00 len bytes` data, `0x01 bits` DTR/RTS) — the game's DTR
reset pulse keeps its exact position in the byte stream, and a disconnect
reads as all-lines-low (cable unplugged). The contract lives in
`src/VRio.Core/Device/PipeFraming.cs` on this side and `serialnamedpipe.h`
on the fork's. TX is paced exactly like the COM path — with the 9600-baud
wire gone, the pacer is the only thing between the host and an impossible
burst. Each row serves one endpoint at a time — the RIO is a single-host
device — and nothing opens automatically at startup; the COM path is
unchanged for RIOJoy and real pods. vRIO's built-in glass and the
standalone vPLASMA share the `vplasma` pipe name — whoever starts second
retries until the name frees up.
## vPLASMA — the companion plasma display ## vPLASMA — the companion plasma display
The cockpit's second serial device is the **plasma display**: a 128×32 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 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 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 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 top left, with its own connection row in the control strip (label colour =
status; auto-opens **COM12** at startup when present). And standalone, connection status; picker offers `pipe:vplasma` and the COM ports). And standalone,
`VPlasma.App`: a bare-glass window that opens **COM12** (the device end of `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 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 missing or busy, port status in the title bar. Both decode the display's
@@ -129,12 +157,15 @@ recovered grammar lives in `src/VPlasma.Core/Protocol/PlasmaProtocol.cs`.
- **Graphics** — `ESC P screen y xbyte width rows` + packed 1bpp row data, - **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 MSB = leftmost pixel. This is everything the game sends, so it is the
wire path a pod's plasma actually sees. wire path a pod's plasma actually sees.
- **Text** — printable ASCII renders through a 5×7 font at a cursor: - **Text** — printable ASCII renders at a **pixel-addressed cursor**
fonts 03 give a 21×4 cell grid, fonts 47 the same glyphs doubled to (`ESC Q` sets row Y 031, `ESC R` sets column X 0127) through the
10×2. Half-intensity draws dimmer, reverse/underline render in the **real ROM character generator** — the eight Babcock fonts recovered
cell, flashing text (and the flashing cursor) blink on the glass. The from the firmware dump (`ESC K` 07): 6×8, 6×10, 7×10, and the large
panel's own ROM glyphs are lost with the hardware, so the classic 12×16 / 12×20. Attributes (`ESC H`, low 4 bits) — half-intensity
public-domain 5×7 set stands in. draws dimmer, underline/reverse render in the cell, flashing text (and
the flashing cursor) blink on the glass. Extracted from the U3 EPROM;
see [`PlasmaNew/FIRMWARE.md`](PlasmaNew/FIRMWARE.md). (Deferred, and
documented there: the 10 double-buffered pages and vector graphics.)
- **Double-click** the glass to cycle three self-test pages (banner, - **Double-click** the glass to cycle three self-test pages (banner,
charset, graphics pattern) through the same parser the wire feeds — charset, graphics pattern) through the same parser the wire feeds —
useful without a host. **Right-click** resets the display to its useful without a host. **Right-click** resets the display to its
@@ -150,7 +181,7 @@ recovered grammar lives in `src/VPlasma.Core/Protocol/PlasmaProtocol.cs`.
|------|----------| |------|----------|
| `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.Core` | Plasma command-stream parser, the `VPlasmaDevice` display state machine, the 8 real ROM fonts (`PlasmaFonts`), serial listener (class library) |
| `src/VPlasma.App` | WinForms dot-matrix display UI | | `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 |
+9
View File
@@ -19,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.App", "src\VPlasma.
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Core.Tests", "tests\VPlasma.Core.Tests\VPlasma.Core.Tests.csproj", "{F31F1D86-546A-4B0C-A283-C04FAAC49F46}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Core.Tests", "tests\VPlasma.Core.Tests\VPlasma.Core.Tests.csproj", "{F31F1D86-546A-4B0C-A283-C04FAAC49F46}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{01717774-2AD5-4CF5-A0D7-69AF81A534E8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Wire", "tools\VPlasma.Wire\VPlasma.Wire.csproj", "{CA883363-5610-4245-98B1-3192AE652F1A}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -52,6 +56,10 @@ Global
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.Build.0 = Release|Any CPU {F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.Build.0 = Release|Any CPU
{CA883363-5610-4245-98B1-3192AE652F1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CA883363-5610-4245-98B1-3192AE652F1A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CA883363-5610-4245-98B1-3192AE652F1A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CA883363-5610-4245-98B1-3192AE652F1A}.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}
@@ -60,5 +68,6 @@ Global
{39E7C28F-8B07-495C-A887-21F2F6AF9A86} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57} {39E7C28F-8B07-495C-A887-21F2F6AF9A86} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
{72D03B3A-7D4E-496C-8DA9-596DFC91704E} = {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} {F31F1D86-546A-4B0C-A283-C04FAAC49F46} = {C4993638-7EB6-47A9-897C-976DB9939601}
{CA883363-5610-4245-98B1-3192AE652F1A} = {01717774-2AD5-4CF5-A0D7-69AF81A534E8}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+336 -37
View File
@@ -1,90 +1,342 @@
using System.IO.Ports;
using VPlasma.Core.Device; using VPlasma.Core.Device;
namespace VPlasma.App; namespace VPlasma.App;
/// <summary> /// <summary>
/// vPLASMA main window: just the plasma glass, sized to the display. The /// vPLASMA main window: the plasma glass on the left, a config panel on the
/// device end is hardwired to <see cref="PortName"/> (the plasma side of the /// right. Connections mirror vRIO: pick an endpoint — the device's named pipe
/// second com0com pair) and opened automatically — a retry timer keeps /// (<c>pipe:vplasma</c>, the DOSBox-X fork's namedpipe backend) or a COM port
/// trying while the port is missing or busy, and reopens it if it dies. The /// (for the game through a com0com null-modem pair) — and Open. Nothing opens
/// title bar carries the port status; double-clicking the glass cycles the /// automatically.
/// self-test pages (banner, charset, graphics) through the wire parser, and ///
/// a right-click resets the display to its power-on state. /// <para>The panel mirrors the real PD01D221's <b>JP1 configuration jumpers</b>
/// (recovered in <c>PlasmaNew/FIRMWARE.md</c>): the baud straps (1+2) drive the
/// COM baud, orientation (4), display test (5), and the demonstration program
/// (6) — which replays the real 10-screen firmware demo. Plus display
/// controls, live counters, and a wire log. Double-click the glass to cycle
/// the self-test pages; right-click to reset.</para>
/// </summary> /// </summary>
internal sealed class MainForm : Form internal sealed class MainForm : Form
{ {
/// <summary>The plasma's fixed port: the device end of the COM2 pair.</summary> /// <summary>The plasma's pipe endpoint in the picker (matches DOSBox-X's
private const string PortName = "COM12"; /// <c>serial2=namedpipe pipe:vplasma</c>).</summary>
private static readonly string PipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
private readonly VPlasmaDevice _device = new(); private readonly VPlasmaDevice _device = new();
private readonly VPlasmaSerialService _service; private readonly VPlasmaSerialService _service;
private readonly VPlasmaPipeService _pipeService;
private readonly PlasmaCanvas _canvas = new(); private readonly PlasmaCanvas _canvas = new();
private readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 }; private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 };
private readonly System.Windows.Forms.Timer _demoTimer = new() { Interval = 2800 };
private readonly System.Windows.Forms.Timer _testTimer = new() { Interval = 1200 };
private int _selfTestPage; private int _selfTestPage;
private int _demoScreen;
private int _testPattern;
// ---- config panel controls -------------------------------------------
private readonly ComboBox _portBox = new() { Width = 168, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly Button _rescan = new() { Text = "Rescan", Width = 54, Height = 22 };
private readonly Button _openClose = new() { Text = "Open", Width = 54, Height = 22 };
private readonly Label _linkStatus = new()
{
AutoSize = true,
MaximumSize = new Size(286, 0),
ForeColor = Color.Gray,
};
// The seven JP1 jumpers, as toggles. Checked = shunt installed.
private readonly CheckBox _jp1 = Jumper("1 — Baud select A (PA0)");
private readonly CheckBox _jp2 = Jumper("2 — Baud select B (PA2)");
private readonly CheckBox _jp3 = Jumper("3 — HW config line (PA3)");
private readonly CheckBox _jp4 = Jumper("4 — Orientation H/V (PD5)");
private readonly CheckBox _jp5 = Jumper("5 — Display test (PD4)");
private readonly CheckBox _jp6 = Jumper("6 — Demo program (PD3)");
private readonly CheckBox _jp7 = Jumper("7 — Parallel select (J2 SEL)");
private readonly Label _baudLabel = new() { AutoSize = true, ForeColor = Color.DimGray };
private readonly Button _selfTest = new() { Text = "Self test", Width = 130, Height = 26 };
private readonly Button _clear = new() { Text = "Clear display", Width = 130, Height = 26 };
private readonly Label _counters = new() { AutoSize = true, Font = new Font("Consolas", 8f) };
private readonly TextBox _logBox = new()
{
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Both,
BackColor = Color.FromArgb(24, 24, 24),
ForeColor = Color.Gainsboro,
Font = new Font("Consolas", 8f),
WordWrap = false,
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
};
private readonly Button _clearLog = new() { Text = "Clear log", Width = 80, Height = 24, Anchor = AnchorStyles.Bottom | AnchorStyles.Left };
private readonly ToolTip _tips = new();
private static CheckBox Jumper(string text) => new() { Text = text, AutoSize = true };
private bool IsOpen => _service.IsOpen || _pipeService.IsListening;
public MainForm() public MainForm()
{ {
Text = $"vPLASMA v{Application.ProductVersion} — Virtual plasma display";
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
FormBorderStyle = FormBorderStyle.FixedSingle; // the glass is a fixed-size device ClientSize = new Size(_canvas.Width + 316, Math.Max(_canvas.Height + 8, 580));
MaximizeBox = false; MinimumSize = new Size(_canvas.Width + 332, 500);
ClientSize = _canvas.Size;
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
_service = new VPlasmaSerialService(_device); _service = new VPlasmaSerialService(_device);
_pipeService = new VPlasmaPipeService(_device);
_canvas.Location = new Point(0, 0); var backdrop = new Panel { Dock = DockStyle.Fill, BackColor = Color.FromArgb(28, 28, 28) };
Controls.Add(_canvas); _canvas.Location = new Point(8, 8);
backdrop.Controls.Add(_canvas);
Controls.Add(backdrop);
Controls.Add(BuildConfigPanel());
// Device / service events arrive on the serial reader thread; marshal to the UI. // Device / service events arrive on worker threads; marshal to the UI.
_device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device)); _device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device));
_service.ConnectionChanged += _ => RunOnUi(UpdateTitle); _device.Logged += line => RunOnUi(() => PrependLog(line));
_service.Logged += line => RunOnUi(() => PrependLog(line));
_service.ConnectionChanged += _ => RunOnUi(UpdateStatus);
_pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}"));
_pipeService.ClientChanged += _ => RunOnUi(UpdateStatus);
_rescan.Click += (_, _) => RefreshPorts();
_openClose.Click += (_, _) => ToggleOpen();
_canvas.DoubleClick += (_, _) => RunSelfTest(); _canvas.DoubleClick += (_, _) => RunSelfTest();
_canvas.MouseClick += (_, e) => _canvas.MouseClick += (_, e) => { if (e.Button == MouseButtons.Right) ResetDisplay(); };
{ _selfTest.Click += (_, _) => RunSelfTest();
if (e.Button == MouseButtons.Right) _clear.Click += (_, _) => ResetDisplay();
_device.Reset(); _clearLog.Click += (_, _) => _logBox.Clear();
};
// Open at startup, retry while closed (port missing/busy, host restarts). // Functional straps: baud (1+2), orientation (4), display test (5),
_reconnectTimer.Tick += (_, _) => EnsureOpen(); // demo (6). Jumpers 3 and 7 are tracked toggles.
_reconnectTimer.Start(); _jp1.CheckedChanged += (_, _) => ApplyBaud();
_jp2.CheckedChanged += (_, _) => ApplyBaud();
_jp4.CheckedChanged += (_, _) => ApplyOrientation();
_jp5.CheckedChanged += (_, _) => ApplyTestPattern();
_jp6.CheckedChanged += (_, _) => ApplyDemo();
_demoTimer.Tick += (_, _) => StepDemo();
_testTimer.Tick += (_, _) => StepTestPattern();
// Default straps: jumper 2 installed = 9600 (the game's rate). Jumper 4
// unstrapped = horizontal (the normal cockpit orientation).
_jp2.Checked = true;
_uiTimer.Tick += (_, _) => UpdateCounters();
_uiTimer.Start();
FormClosed += (_, _) => FormClosed += (_, _) =>
{ {
_reconnectTimer.Dispose(); _uiTimer.Dispose();
_demoTimer.Dispose();
_testTimer.Dispose();
_service.Dispose(); _service.Dispose();
_pipeService.Dispose();
}; };
_canvas.UpdateFrom(_device); _canvas.UpdateFrom(_device);
EnsureOpen(); _service.Baud = SelectedBaud();
RefreshPorts();
UpdateStatus();
UpdateCounters();
PrependLog("vPLASMA ready. Pick an endpoint (pipe:vplasma or a COM port) and Open. Jumper 6 runs the firmware demo.");
} }
private void EnsureOpen() private Panel BuildConfigPanel()
{ {
if (_service.IsOpen) var panel = new Panel { Dock = DockStyle.Right, Width = 300, BackColor = SystemColors.Control };
panel.Controls.Add(new Label { Text = "Connection:", Location = new Point(12, 14), AutoSize = true });
_portBox.Location = new Point(12, 34);
_rescan.Location = new Point(186, 34);
_openClose.Location = new Point(244, 34);
_linkStatus.Location = new Point(12, 62);
panel.Controls.Add(_portBox);
panel.Controls.Add(_rescan);
panel.Controls.Add(_openClose);
panel.Controls.Add(_linkStatus);
var jumpers = new GroupBox { Text = "JP1 configuration jumpers", Location = new Point(12, 90), Size = new Size(276, 176) };
CheckBox[] jp = { _jp1, _jp2, _jp3, _jp4, _jp5, _jp6, _jp7 };
for (int i = 0; i < jp.Length; i++)
{
jp[i].Location = new Point(12, 20 + i * 20);
jumpers.Controls.Add(jp[i]);
}
_baudLabel.Location = new Point(150, 20);
jumpers.Controls.Add(_baudLabel);
panel.Controls.Add(jumpers);
_tips.SetToolTip(_portBox, "pipe:vplasma serves the DOSBox-X namedpipe backend; a COM port pairs with the game via com0com.");
_tips.SetToolTip(_jp1, "Baud select, low bit. With jumper 2: 4800/9600/19.2K/38.4K.");
_tips.SetToolTip(_jp2, "Baud select, high bit. Installed alone = 9600 (the game's rate).");
_tips.SetToolTip(_jp3, "Sets flag B7.2, driving the PA5 hardware line to a fixed level (board config line; exact effect board-dependent).");
_tips.SetToolTip(_jp4, "Display orientation: removed = horizontal 128×32 (normal), installed = vertical 32×128.");
_tips.SetToolTip(_jp5, "Installed at power-on runs the panel pixel test pattern (a walking-bit dead-dot check).");
_tips.SetToolTip(_jp6, "Install to run the built-in demonstration program (the real 10-screen firmware demo).");
_tips.SetToolTip(_jp7, "Selects the parallel interface on the real board; no effect here (serial only).");
var display = new GroupBox { Text = "Display", Location = new Point(12, 276), Size = new Size(276, 62) };
_selfTest.Location = new Point(12, 22);
_clear.Location = new Point(146, 22);
display.Controls.Add(_selfTest);
display.Controls.Add(_clear);
panel.Controls.Add(display);
_counters.Location = new Point(12, 348);
panel.Controls.Add(_counters);
var logLabel = new Label { Text = "Wire log:", Location = new Point(12, 434), AutoSize = true };
panel.Controls.Add(logLabel);
_logBox.SetBounds(12, 452, 276, 0);
panel.Controls.Add(_logBox);
panel.Controls.Add(_clearLog);
panel.Resize += (_, _) =>
{
_logBox.Size = new Size(276, Math.Max(40, panel.ClientSize.Height - _logBox.Top - 40));
_clearLog.Location = new Point(12, panel.ClientSize.Height - 32);
};
return panel;
}
// ---- connection (mirrors vRIO's endpoint picker) -----------------------
private void RefreshPorts()
{
if (IsOpen)
return; // an open row is disabled and shows the served endpoint
string? current = _portBox.SelectedItem?.ToString();
_portBox.Items.Clear();
_portBox.Items.Add(PipeEndpoint); // always present — a pipe server needs no hardware
foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
_portBox.Items.Add(name);
int idx = current is null ? -1 : _portBox.Items.IndexOf(current);
_portBox.SelectedIndex = idx >= 0 ? idx : 0;
}
private void ToggleOpen()
{
if (IsOpen)
{
_service.Close();
_pipeService.Stop();
UpdateStatus();
return; return;
}
if (_portBox.SelectedItem?.ToString() is not { } endpoint)
{
PrependLog("No endpoint selected — pick pipe:vplasma or a COM port.");
return;
}
try try
{ {
_service.Open(PortName); if (endpoint == PipeEndpoint)
{
_pipeService.Start();
}
else
{
_service.Baud = SelectedBaud();
_service.Open(endpoint);
}
UpdateStatus();
} }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
{ {
UpdateTitle(); // stays closed; the timer tries again PrependLog($"Could not open {endpoint}: {ex.Message}");
UpdateStatus();
} }
} }
private void UpdateTitle() // ---- jumper behaviour --------------------------------------------------
/// <summary>Baud from the two straps (installed = logic 0), per the datasheet.</summary>
private int SelectedBaud() => (_jp1.Checked, _jp2.Checked) switch
{ {
// ProductVersion carries the git stamp (see StampGitVersion in the csproj). (true, true) => 4800,
string status = _service.IsOpen (false, true) => 9600,
? $"{PortName} @ 9600 8N1" (true, false) => 19200,
: $"{PortName} unavailable — retrying"; (false, false) => 38400,
Text = $"vPLASMA v{Application.ProductVersion} — {status}"; };
private void ApplyBaud()
{
int baud = SelectedBaud();
_service.Baud = baud;
if (_service.IsOpen && _portBox.SelectedItem?.ToString() is { } port && port != PipeEndpoint)
{
PrependLog($"Baud straps → {baud} 8N1 (reopening {port})");
_service.Close();
try { _service.Open(port); }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { PrependLog($"Reopen failed: {ex.Message}"); }
}
UpdateStatus();
} }
private void ApplyOrientation()
{
// Jumper 4 unstrapped (unchecked) = horizontal 128×32; installed = vertical.
_device.Orientation = _jp4.Checked ? PlasmaOrientation.Vertical : PlasmaOrientation.Horizontal;
PrependLog($"Jumper 4 → {(_jp4.Checked ? "vertical 32×128" : "horizontal 128×32")} orientation");
}
private void ApplyTestPattern()
{
if (_jp5.Checked)
{
PrependLog("Jumper 5 installed — panel test (cycling diagnostic patterns)");
_testPattern = 0;
_device.ShowTestPattern(0);
_testTimer.Start();
}
else
{
_testTimer.Stop();
_device.Reset();
PrependLog("Jumper 5 removed — test pattern cleared");
}
}
private void StepTestPattern()
{
_testPattern = (_testPattern + 1) % VPlasmaDevice.TestPatternCount;
_device.ShowTestPattern(_testPattern);
}
private void ApplyDemo()
{
if (_jp6.Checked)
{
PrependLog("Jumper 6 installed — running the firmware demonstration program");
_demoScreen = 0;
StepDemo();
_demoTimer.Start();
}
else
{
_demoTimer.Stop();
PrependLog("Jumper 6 removed — demo stopped");
}
}
/// <summary>Play the next real firmware demo screen (loops 0..9).</summary>
private void StepDemo()
{
byte[] screen = PlasmaFirmwareDemo.Screens[_demoScreen];
_device.OnReceived(screen, screen.Length);
_demoScreen = (_demoScreen + 1) % PlasmaFirmwareDemo.Count;
}
// ---- display controls --------------------------------------------------
private void RunSelfTest() private void RunSelfTest()
{ {
byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage); byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
@@ -92,6 +344,53 @@ internal sealed class MainForm : Form
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount; _selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
} }
private void ResetDisplay()
{
_device.Reset();
PrependLog("Display reset to power-on state");
}
// ---- status / counters / log ------------------------------------------
private void UpdateStatus()
{
_openClose.Text = IsOpen ? "Close" : "Open";
_portBox.Enabled = !IsOpen;
_rescan.Enabled = !IsOpen;
_baudLabel.Text = $"{SelectedBaud()} baud";
string line;
if (_service.IsOpen)
line = $"Serving {_service.PortName} @ {_service.Baud} 8N1.";
else if (_pipeService.IsListening)
line = $"Serving \\\\.\\pipe\\{_pipeService.PipeName}" + (_pipeService.IsClientConnected ? " — host connected." : " — waiting for host.");
else
line = "Not connected.";
_linkStatus.Text = line;
_linkStatus.ForeColor = IsOpen ? Color.ForestGreen : Color.Gray;
}
private void UpdateCounters()
{
_counters.Text =
$"Bytes received: {_device.BytesReceived}\n" +
$"Graphics rows: {_device.GraphicsRows}\n" +
$"Chars drawn: {_device.TextCharsDrawn}\n" +
$"Font: {_device.Font} ({_device.FontWidth}×{_device.FontHeight})\n" +
$"Cursor: X {_device.CursorX}, Y {_device.CursorY} ({_device.CursorMode})\n" +
$"Attributes: {(_device.Attributes == PlasmaAttributes.None ? "default" : _device.Attributes.ToString())}\n" +
$"Orientation: {_device.Orientation} ({_device.LogicalWidth}×{_device.LogicalHeight})";
}
private void PrependLog(string line)
{
string stamped = $"{DateTime.Now:HH:mm:ss.fff} {line}";
string text = _logBox.TextLength == 0 ? stamped : stamped + Environment.NewLine + _logBox.Text;
if (text.Length > 20000)
text = text.Substring(0, 20000);
_logBox.Text = text;
}
private void RunOnUi(Action action) private void RunOnUi(Action action)
{ {
if (IsDisposed || Disposing) if (IsDisposed || Disposing)
+7 -6
View File
@@ -30,7 +30,7 @@ internal sealed class PlasmaCanvas : Control
private bool _blinkPhase = true; private bool _blinkPhase = true;
private bool _anythingBlinks; private bool _anythingBlinks;
private VPlasmaDevice.Point _cursor; private int _cursorX, _cursorY;
private PlasmaCursorMode _cursorMode; private PlasmaCursorMode _cursorMode;
private int _cellWidth = 6, _cellHeight = 8; private int _cellWidth = 6, _cellHeight = 8;
@@ -62,10 +62,11 @@ internal sealed class PlasmaCanvas : Control
public void UpdateFrom(VPlasmaDevice device) public void UpdateFrom(VPlasmaDevice device)
{ {
device.CopyFrame(_frame); device.CopyFrame(_frame);
_cursor = device.CursorCell; _cursorX = device.CursorX;
_cursorY = device.CursorY;
_cursorMode = device.CursorMode; _cursorMode = device.CursorMode;
_cellWidth = device.CellWidth; _cellWidth = device.FontWidth;
_cellHeight = device.CellHeight; _cellHeight = device.FontHeight;
_anythingBlinks = _cursorMode == PlasmaCursorMode.Flashing; _anythingBlinks = _cursorMode == PlasmaCursorMode.Flashing;
if (!_anythingBlinks) if (!_anythingBlinks)
@@ -113,8 +114,8 @@ internal sealed class PlasmaCanvas : Control
if (_cursorMode == PlasmaCursorMode.Steady if (_cursorMode == PlasmaCursorMode.Steady
|| (_cursorMode == PlasmaCursorMode.Flashing && _blinkPhase)) || (_cursorMode == PlasmaCursorMode.Flashing && _blinkPhase))
{ {
int cx = _cursor.Col * _cellWidth; int cx = _cursorX;
int cy = _cursor.Row * _cellHeight + _cellHeight - 1; int cy = _cursorY + _cellHeight - 1;
if (cy < VPlasmaDevice.Height) if (cy < VPlasmaDevice.Height)
for (int i = 0; i < _cellWidth && cx + i < VPlasmaDevice.Width; ++i) for (int i = 0; i < _cellWidth && cx + i < VPlasmaDevice.Width; ++i)
g.FillRectangle(lit, Bezel + (cx + i) * _dotPitch, Bezel + cy * _dotPitch, _dotSize, _dotSize); g.FillRectangle(lit, Bezel + (cx + i) * _dotPitch, Bezel + cy * _dotPitch, _dotSize, _dotSize);
@@ -0,0 +1,37 @@
// AUTO-GENERATED from the PD01D221 firmware (tms27pc512.BIN) by PlasmaNew
// tooling: the real 10-screen demonstration program (jumper 6), extracted
// from the demo pointer table at $8000. See PlasmaNew/FIRMWARE.md.
namespace VPlasma.Core.Device;
/// <summary>The display's built-in demonstration program — the 10 command
/// scripts the firmware loops through when JP1 jumper 6 is installed.</summary>
public static class PlasmaFirmwareDemo
{
/// <summary>The 10 demo screens, each a raw wire command stream.</summary>
public static readonly byte[][] Screens =
{
// screen 0 (67 bytes)
new byte[] { 0x1B,0x47,0x00,0x1B,0x40,0x1B,0x4C,0x1B,0x52,0x04,0x59,0x4F,0x55,0x20,0x48,0x41,0x56,0x45,0x20,0x45,0x4E,0x41,0x42,0x4C,0x45,0x44,0x20,0x54,0x48,0x45,0x1B,0x52,0x25,0x50,0x44,0x30,0x31,0x2D,0x44,0x32,0x32,0x31,0x0A,0x1B,0x52,0x01,0x44,0x45,0x4D,0x4F,0x4E,0x53,0x54,0x52,0x41,0x54,0x49,0x4F,0x4E,0x20,0x50,0x52,0x4F,0x47,0x52,0x41,0x4D },
// screen 1 (99 bytes)
new byte[] { 0x1B,0x49,0x02,0x1B,0x40,0x1B,0x4C,0x1B,0x52,0x02,0x49,0x46,0x20,0x59,0x4F,0x55,0x20,0x44,0x4F,0x20,0x4E,0x4F,0x54,0x20,0x57,0x49,0x53,0x48,0x20,0x54,0x4F,0x1B,0x52,0x0A,0x56,0x49,0x45,0x57,0x20,0x54,0x48,0x49,0x53,0x20,0x50,0x52,0x4F,0x47,0x52,0x41,0x4D,0x2C,0x0A,0x1B,0x52,0x07,0x52,0x45,0x4D,0x4F,0x56,0x45,0x20,0x4A,0x55,0x4D,0x50,0x45,0x52,0x20,0x36,0x20,0x41,0x4E,0x44,0x0A,0x1B,0x52,0x0D,0x52,0x45,0x53,0x45,0x54,0x20,0x54,0x48,0x45,0x20,0x44,0x49,0x53,0x50,0x4C,0x41,0x59,0x1B,0x69,0x02 },
// screen 2 (38 bytes)
new byte[] { 0x1B,0x49,0x01,0x1B,0x40,0x1B,0x4B,0x04,0x1B,0x51,0x00,0x1B,0x52,0x0A,0x50,0x4C,0x41,0x53,0x4D,0x41,0x44,0x4F,0x54,0x1B,0x52,0x0A,0x50,0x44,0x30,0x31,0x2D,0x44,0x32,0x32,0x31,0x1B,0x69,0x01 },
// screen 3 (64 bytes)
new byte[] { 0x1B,0x49,0x00,0x1B,0x40,0x1B,0x4B,0x06,0x1B,0x51,0x00,0x1B,0x52,0x1D,0x41,0x20,0x43,0x4F,0x4D,0x50,0x4C,0x45,0x54,0x45,0x0A,0x1B,0x52,0x0F,0x44,0x49,0x53,0x50,0x4C,0x41,0x59,0x20,0x53,0x59,0x53,0x54,0x45,0x4D,0x0A,0x1B,0x52,0x0C,0x49,0x4E,0x20,0x4F,0x4E,0x45,0x20,0x50,0x41,0x43,0x4B,0x41,0x47,0x45,0x21,0x1B,0x69,0x00 },
// screen 4 (86 bytes)
new byte[] { 0x1B,0x49,0x01,0x1B,0x40,0x1B,0x4B,0x06,0x1B,0x4C,0x46,0x45,0x41,0x54,0x55,0x52,0x45,0x53,0x20,0x49,0x4E,0x43,0x4C,0x55,0x44,0x45,0x20,0x2D,0x1B,0x4B,0x03,0x1B,0x51,0x0C,0x1B,0x52,0x0A,0x4F,0x1B,0x4B,0x02,0x20,0x53,0x45,0x52,0x49,0x41,0x4C,0x20,0x49,0x4E,0x54,0x45,0x52,0x46,0x41,0x43,0x45,0x0A,0x1B,0x4B,0x03,0x1B,0x52,0x0A,0x4F,0x1B,0x4B,0x02,0x20,0x50,0x41,0x52,0x41,0x4C,0x4C,0x45,0x4C,0x20,0x50,0x4F,0x52,0x54,0x1B,0x69,0x01 },
// screen 5 (85 bytes)
new byte[] { 0x1B,0x49,0x02,0x1B,0x40,0x1B,0x4B,0x06,0x1B,0x4C,0x46,0x45,0x41,0x54,0x55,0x52,0x45,0x53,0x20,0x49,0x4E,0x43,0x4C,0x55,0x44,0x45,0x20,0x2D,0x1B,0x51,0x0C,0x1B,0x4B,0x03,0x1B,0x52,0x0A,0x4F,0x1B,0x4B,0x02,0x20,0x50,0x4F,0x57,0x45,0x52,0x20,0x53,0x55,0x50,0x50,0x4C,0x59,0x0A,0x1B,0x4B,0x03,0x1B,0x52,0x0A,0x4F,0x1B,0x4B,0x02,0x20,0x4B,0x45,0x59,0x50,0x41,0x44,0x20,0x49,0x4E,0x54,0x45,0x52,0x46,0x41,0x43,0x45,0x1B,0x69,0x02 },
// screen 6 (76 bytes)
new byte[] { 0x1B,0x49,0x00,0x1B,0x40,0x1B,0x4B,0x02,0x1B,0x51,0x00,0x1B,0x52,0x00,0x4D,0x49,0x58,0x20,0x54,0x45,0x58,0x54,0x20,0x41,0x4E,0x44,0x20,0x47,0x52,0x41,0x50,0x48,0x49,0x43,0x53,0x1B,0x52,0x18,0x55,0x53,0x49,0x4E,0x47,0x20,0x38,0x20,0x53,0x54,0x4F,0x52,0x45,0x44,0x0A,0x1B,0x52,0x0D,0x43,0x48,0x41,0x52,0x41,0x43,0x54,0x45,0x52,0x20,0x46,0x4F,0x4E,0x54,0x53,0x20,0x2D,0x1B,0x69,0x00 },
// screen 7 (63 bytes)
new byte[] { 0x1B,0x49,0x01,0x1B,0x40,0x1B,0x4B,0x02,0x1B,0x4C,0x49,0x4E,0x54,0x45,0x4C,0x4C,0x49,0x47,0x45,0x4E,0x54,0x20,0x49,0x4E,0x54,0x45,0x52,0x46,0x41,0x43,0x45,0x1B,0x52,0x19,0x48,0x41,0x53,0x20,0x44,0x4F,0x5A,0x45,0x4E,0x53,0x20,0x4F,0x46,0x0A,0x1B,0x52,0x25,0x43,0x4F,0x4D,0x4D,0x41,0x4E,0x44,0x53,0x2E,0x1B,0x69,0x01 },
// screen 8 (70 bytes)
new byte[] { 0x1B,0x49,0x02,0x1B,0x40,0x1B,0x4B,0x02,0x1B,0x4C,0x1B,0x52,0x19,0x43,0x48,0x4F,0x4F,0x53,0x45,0x20,0x45,0x49,0x54,0x48,0x45,0x52,0x0A,0x1B,0x52,0x19,0x48,0x4F,0x52,0x49,0x5A,0x4F,0x4E,0x54,0x41,0x4C,0x20,0x4F,0x52,0x0A,0x1B,0x52,0x04,0x56,0x45,0x52,0x54,0x49,0x43,0x41,0x4C,0x20,0x4F,0x52,0x49,0x45,0x4E,0x54,0x41,0x54,0x49,0x4F,0x4E,0x1B,0x69,0x02 },
// screen 9 (14 bytes)
new byte[] { 0x1B,0x49,0x00,0x1B,0x40,0x1B,0x5A,0x00,0x00,0x00,0x00,0x1B,0x69,0x00 },
};
/// <summary>Screen count.</summary>
public static int Count => Screens.Length;
}
-130
View File
@@ -1,130 +0,0 @@
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 '~'
};
}
+450
View File
@@ -0,0 +1,450 @@
// AUTO-GENERATED from the PD01D221 firmware (tms27pc512.BIN) by
// PlasmaNew tooling. The real Babcock character generator, all 8 fonts.
// Each glyph row is a ushort; bit 15 = leftmost pixel. See PlasmaNew/FIRMWARE.md.
namespace VPlasma.Core.Device;
/// <summary>The display's 8 built-in fonts, extracted from ROM.</summary>
public static class PlasmaFonts
{
/// <summary>One font face: character range, cell size, and packed glyph rows.</summary>
public sealed class Face
{
public int Index { get; }
public byte First { get; }
public byte Last { get; }
public int Width { get; }
public int Height { get; }
private readonly ushort[] _rows; // (c-First)*Height + row; bit15 = leftmost
public Face(int index, byte first, byte last, int width, int height, string hex)
{
Index = index; First = first; Last = last; Width = width; Height = height;
_rows = new ushort[hex.Length / 4];
for (int i = 0; i < _rows.Length; i++)
_rows[i] = (ushort)((HexNibble(hex[i*4]) << 12) | (HexNibble(hex[i*4+1]) << 8) | (HexNibble(hex[i*4+2]) << 4) | HexNibble(hex[i*4+3]));
}
private static int HexNibble(char c) => c <= '9' ? c - '0' : (c & 0x5F) - 'A' + 10;
/// <summary>True if this face has a glyph for <paramref name="code"/>.</summary>
public bool Has(byte code) => code >= First && code <= Last;
/// <summary>Row <paramref name="row"/> of glyph <paramref name="code"/> (bit15 = leftmost), or 0.</summary>
public ushort Row(byte code, int row)
=> Has(code) && (uint)row < (uint)Height ? _rows[(code - First) * Height + row] : (ushort)0;
}
// font 0: 6x8, chars 0x20-0xFF, 224 glyphs
private static readonly Face F0 = new(0, 0x20, 0xFF, 6, 8,
"00000000000000000000000000000000200020002000200000000000200000005000500050000000000000000000000050005000F8005000F8005000"
+ "5000000020007800A00070002800F00020000000C000C80010002000400098001800000060009000A0004000A8009000680000006000200040000000"
+ "0000000000000000100020004000400040002000100000004000200010001000100020004000000000002000A8007000A80020000000000000002000"
+ "2000F800200020000000000000000000000000006000200040000000000000000000F800000000000000000000000000000000000000600060000000"
+ "00000800100020004000800000000000700088009800A800C80088007000000020006000200020002000200070000000700088000800100020004000"
+ "F8000000F80010002000100008000800F00000001000300050009000F800100010000000F8008000F000080008000800F0000000300040008000F000"
+ "8800880070000000F8000800100020004000400040000000700088008800700088008800700000007000880088007800080010006000000000006000"
+ "600000006000600000000000000060006000000060002000400000001000200040008000400020001000000000000000F8000000F800000000000000"
+ "80004000200010002000400080000000700088000800100020000000200000007000880008006800A800A800700000007000880088008800F8008800"
+ "88000000F00088008800F00088008800F000000070008800800080008000880070000000E00090008800880088009000E0000000F80080008000F000"
+ "80008000F8000000F80080008000F0008000800080000000700088008000B8008800880070000000880088008800F800880088008800000070002000"
+ "2000200020002000700000003800100010001000100090006000000088009000A000C000A000900088000000800080008000800080008000F8000000"
+ "8800D800A800A800880088008800000088008800C800A800980088008800000070008800880088008800880070000000F00088008800F00080008000"
+ "800000007000880088008800A800900068000000F00088008800F000A000900088000000780080008000700008000800F0000000F800200020002000"
+ "20002000200000008800880088008800880088007000000088008800880088008800500020000000880088008800A800A800A8005000000088008800"
+ "50002000500088008800000088008800880050002000200020000000F80008001000200040008000F800000070004000400040004000400070000000"
+ "000080004000200010000800000000007000100010001000100010007000000020005000880000000000000000000000000000000000000000000000"
+ "FC000000600040002000000000000000000000000000000070000800780088007800000080008000B000C80088008800F00000000000000070008000"
+ "8000880070000000080008006800980088008800780000000000000070008800F8008000700000001000280020007000200020002000000000000000"
+ "780088007800080070000000800080008000B000C8008800880000000000200000002000200020002000000010000000300010001000900060000000"
+ "800080009000A000C000A000900000006000200020002000200020007000000000000000D000A800A80088008800000000000000B000C80088008800"
+ "88000000000000007000880088008800700000000000B000C8008800F0008000800000000000680098008800780008000800000000000000B000C800"
+ "8000800080000000000000007800800070000800F000000000002000F800200020002800100000000000000088008800880098006800000000000000"
+ "8800880088005000200000000000000088008800A800A800500000000000000088005000200050008800000000000000880050002000200040000000"
+ "00000000F800100020004000F80000003000400040008000400040003000000020002000200000002000200020000000600010001000080010001000"
+ "6000000008007000800000000000000000000000FC00FC00FC00FC00FC00FC00FC00FC00000000000000F80000000000000000002000200020002000"
+ "2000200020002000200020002000E0000000000000000000000000000000700020002000200020002000200020007000000000000000000000000000"
+ "0000E000200020002000200020002000200070002000200020002000200020002000E0002000200020002000200020002000F8002000200020002000"
+ "200020002000F8000000000000000000000000000000F800200020002000200000000000000008001000200020002000000000000000800040002000"
+ "200020002000200040008000000000000000000020002000100008000000000000000000600060006000F800F800600060006000000000000000F800"
+ "F80000000000000060006000600040006000600060007000600060006000E000E0000000000000000000000000007800780060006000700060006000"
+ "600078007800000000000000000000000000E000E00060006000600060006000600078007800600060006000600060006000E000E000600060006000"
+ "000020007000A80020002000200020000000000040002000F0002000400000000000000020004000F8004000200000000000000020002000E0000000"
+ "000000000000200020002000A80070002000000000002000500088005000500070000000000020007000F800700070007000000000004000F800F800"
+ "F800400000000000000090009000080008009000E00000000000000000000000E000A000E00000000000700040004000400000000000000000000000"
+ "0000400040004000C000000000000000000000008000400020000000000000000000C000C0000000000000000000F8000800F8000800100020000000"
+ "00000000F8000800300020004000000000000000100020006000A00020000000000000002000F8008800080030000000000000000000F80020002000"
+ "F8000000000000001000F8003000500090000000000000004000F8004800500040000000000000000000700010001000F800000000000000F0001000"
+ "F0001000F0000000000000000000A800A800080030000000000000000000F0000000000000000000F800080028003000200020004000000008001000"
+ "20006000A0002000200000002000F800880088000800100020000000F80020002000200020002000F80000001000F800100030005000900010000000"
+ "4000F8004800500048004800900000002000F8002000F800200020002000000000000000780050008800100020000000400078009000100010001000"
+ "200000000000F8000800080008000800F80000005000F800500050001000200040000000C0000000C800080008001000600000000000F80008001000"
+ "20005000880000004000F800C8005000400040003800000000008800880048000800100060000000000078005000A8001C001000600000001000E000"
+ "2000F80020002000400000000000A800A800A800080010002000000070000000F80020002000200040000000800080008000C000A000800080000000"
+ "20002000F80020002000400080000000000070000000000000000000F80000000000F8000800500020005000800000002000F800100020007000A800"
+ "20000000100010001000100010002000400000000000000040002000900090009000000080008000F800800080008000780000000000F80008000800"
+ "080010006000000000004000A000100008000800000000002000F80020002000A800A800200000000000F8000800080050002000100000000000E000"
+ "0000E0000000E0001000000000002000400080008800F80008000000000008000800500020005000800000000000F8004000F8004000400038000000"
+ "40004000F80048005000400040000000000070001000100010001000F80000000000F8000800F80008000800F800000070000000F800080010002000"
+ "40000000900090009000900010002000400000002000A000A000A000A800A8003000000080008000800088009000A000C000000000000000F8008800"
+ "88008800F80000000000F8008800080008001000200000000000C0000000080008001000E0000000000020009000400000000000000000000000E000"
+ "A000E00000000000000000002000000020004000800090006000000010002000700088008800F8008800000080004000700088008800F80088000000"
+ "50000000700088008800F800880000007000880080008000880070002000600010002000F0008000C0008000F000000080004000F0008000C0008000"
+ "F00000007000880070002000200020007000000050000000700088008800880070000000500000008800880088008800700000000800100070000800"
+ "780088007000000070008800700008007800880070000000800040007000080078008800700000005000000070000800780088007000000000007000"
+ "8000800088007000200060001000000070008800F8008000700000007000880070008800F0008000700000008000000070008800F000800070000000"
+ "080050000000C00040004000E00000007000A80000006000200020007000000080005000000030001000100038000000500020000000600020002000"
+ "700000006800B8000000B000C80088008800000008001000000070008800880070000000700088000000700088008800700000008000400000007000"
+ "880088007000000000005000000070008800880070000000F0008800B00088008800D000B00000002000400000008800880098006800000070008800"
+ "0000880088009800680000008000400000008800880098006800000000005000000088008800980068000000");
// font 1: 6x8, chars 0x40-0x7F, 64 glyphs
private static readonly Face F1 = new(1, 0x40, 0x7F, 6, 8,
"100020004000200010000000F8000000400020001000200040000000F800000020002000F800200020000000F8000000000020000000F80000002000"
+ "00000000200050005000500088008800F800000008001000F8002000F80040008000000020007000A800A800A800A80070002000F800400040004000"
+ "40004000E00000000000F8000000F8000000F80000000000080070008000080070008000000000000800700080000000F80000000000000004000400"
+ "4800A8002800100010000000200050005000500088008800880000007000880008006800980088009000600000000000880050002000500088000000"
+ "000020007000F800700020000000000020007000A800A000A8007000200000000000F800880088008800880088000000700088008800A80088008800"
+ "70000000000000005000A8005000000000000000F80040002000100020004000F8000000300048004800300000000000000000000000000000000000"
+ "000000000000000000000000000000000000000000000000000070008800880088005000D8000000F80000000000700000000000F8000000A800A800"
+ "A80070002000200020000000000010007000A800A800A8007000400000000000000000000000000000000000F4005C00540054005400000000000000"
+ "20007000A8002000200020002000200020002000200020002000A80070002000300048004000F00040004800B0000000000000000000680090009000"
+ "6800000070008800880070008800C800B000000000008000880050002000500088000800700088004000200050008800880070000000000070008800"
+ "6000880070000000000020007000A800A800A800700020000000000088008800500050007000200000000000B000C800880088008800080000000000"
+ "00006000200020003000000000001000A800A800A80070002000000000000000480050006000500048000000C0004000200010002800440044000000"
+ "0000000088008800C800B000800080000000000000008800880050002000000000000000700088008800880070000000880050002000F8002000F800"
+ "2000000000000000F80050005000500048000000700088008800F80088008800700000000000000070008800C800B000800080000000000078009000"
+ "8800880070000000000008007000A00020002000100000000000000000009000480048003000000000000000F80050008800A8005000000000000000"
+ "000050008800A80050000000400030004000300040004000380008002000A800A8007000200020002000000040003000400040004000400038000800"
+ "000010000800FC000800100000000000000020004000FC0040002000000000000000000000000000000000000000000020007000A80020002000A800"
+ "70002000");
// font 2: 6x10, chars 0x20-0xFF, 224 glyphs
private static readonly Face F2 = new(2, 0x20, 0xFF, 6, 10,
"000000000000000000000000000000000000000000002000200020002000000000002000000000000000500050005000000000000000000000000000"
+ "000050005000F8005000F8005000500000000000000020007800A00070002800F0002000000000000000C000C8001000200040009800180000000000"
+ "000060009000A0004000A800900068000000000000006000200040000000000000000000000000000000100020004000400040002000100000000000"
+ "0000400020001000100010002000400000000000000000002000A8007000A80020000000000000000000000020002000F80020002000000000000000"
+ "00000000000000000000000060002000400000000000000000000000F800000000000000000000000000000000000000000000006000600000000000"
+ "00000000080010002000400080000000000000000000700088009800A800C80088007000000000000000200060002000200020002000700000000000"
+ "0000700088000800100020004000F800000000000000F80010002000100008000800F0000000000000001000300050009000F8001000100000000000"
+ "0000F8008000F000080008000800F000000000000000300040008000F000880088007000000000000000F80008001000200040004000400000000000"
+ "000070008800880070008800880070000000000000007000880088007800080010006000000000000000000060006000000060006000000000000000"
+ "00000000600060000000600020004000000000000000100020004000800040002000100000000000000000000000F8000000F8000000000000000000"
+ "0000800040002000100020004000800000000000000070008800080010002000000020000000000000007000880008006800A800A800700000000000"
+ "00007000880088008800F80088008800000000000000F00088008800F00088008800F000000000000000700088008000800080008800700000000000"
+ "0000E00090008800880088009000E000000000000000F80080008000F00080008000F800000000000000F80080008000F00080008000800000000000"
+ "0000700088008000B800880088007000000000000000880088008800F800880088008800000000000000700020002000200020002000700000000000"
+ "0000380010001000100010009000600000000000000088009000A000C000A00090008800000000000000800080008000800080008000F80000000000"
+ "00008800D800A800A80088008800880000000000000088008800C800A800980088008800000000000000700088008800880088008800700000000000"
+ "0000F00088008800F0008000800080000000000000007000880088008800A80090006800000000000000F00088008800F000A0009000880000000000"
+ "0000780080008000700008000800F000000000000000F800200020002000200020002000000000000000880088008800880088008800700000000000"
+ "00008800880088008800880050002000000000000000880088008800A800A800A8005000000000000000880088005000200050008800880000000000"
+ "00008800880088005000200020002000000000000000F80008001000200040008000F800000000000000700040004000400040004000700000000000"
+ "000080004000200010000800000000000000000000007000100010001000100010007000000000000000200050008800000000000000000000000000"
+ "0000000000000000000000000000FC000000000000006000400020000000000000000000000000000000000000007000080078008800780000000000"
+ "000080008000B000C80088008800F0000000000000000000000070008000800088007000000000000000080008003400980088008800780000000000"
+ "00000000000070008800F800800070000000000000001000280020007000200020002000000000000000000000007800880088007800080008007000"
+ "0000800080008000B000C800880088000000000000000000200000002000200020002000000000000000100000003000100010001000100090006000"
+ "0000800080009000A000C000A0009000000000000000600020002000200020002000700000000000000000000000D000A800A8008800880000000000"
+ "000000000000B000C800880088008800000000000000000000007000880088008800700000000000000000000000B000C8008800F000800080008000"
+ "0000000000006800980088007800080008000800000000000000B000C800800080008000000000000000000000007800800070000800F00000000000"
+ "000000002000F80020002000280010000000000000000000000088008800880098006800000000000000000000008800880088005000200000000000"
+ "00000000000088008800A800A80050000000000000000000000088005000200050008800000000000000000000008800880088007800080008007000"
+ "000000000000F800100020004000F8000000000000003000400040008000400040003000000000000000200020002000000020002000200000000000"
+ "00006000100010000800100010006000000000000000080070008000000000000000000000000000FC00FC00FC00FC00FC00FC00FC00FC00FC00FC00"
+ "0000000000000000FC000000000000000000000010001000100010001000100010001000100010001000100010001000F00000000000000000000000"
+ "00000000000000001C001000100010001000100010001000100010001C00000000000000000000000000000000000000F00010001000100010001000"
+ "10001000100010001C00100010001000100010001000100010001000F000100010001000100010001000100010001000FC0010001000100010001000"
+ "1000100010001000FC00000000000000000000000000000000000000FC00100010001000100010000000000000000000040008001000100010001000"
+ "0000000000000000C000200010001000100010001000100010002000C000000000000000000000001000100010000800040000000000000000000000"
+ "380038003800FC00FC00FC003800380038003800000000000000FC00FC00FC0000000000000000003800380038003800380038003800380038003800"
+ "380038003800F800F800F80000000000000000000000000000003C003C003C0038003800380038003800380038003C003C003C000000000000000000"
+ "000000000000F800F800F80038003800380038003800380038003C003C003C003800380038003800380038003800F800F800F8003800380038003800"
+ "000000001000380054001000100010001000000000000000000020001000F8001000200000000000000000000000100020007C002000100000000000"
+ "00000000000010001000F000000000000000000000000000100010001000540038001000000000000000000010002800440028002800380000000000"
+ "00000000100038007C00380038003800000000000000000020007C00FC007C0020000000000000000000000048004800840084004800300000000000"
+ "000000000000000000007000500070000000000000000000380020002000200000000000000000000000000000000000200020002000E00000000000"
+ "00000000000000000000400020001000000000000000000000000000600060000000000000000000000000007C0004007C0004000800100000000000"
+ "0000000000007C00040018001000200000000000000000000000080010003000500010000000000000000000000010007C0044000400180000000000"
+ "00000000000000007C00100010007C000000000000000000000008007C001800280048000000000000000000000020007C0024002800200000000000"
+ "00000000000000003800080008007C000000000000000000000078000800780008007800000000000000000000000000540054000400180000000000"
+ "000000000000000078000000000000000000000000007C00040014001800100010002000000000000000040008001000300050001000100000000000"
+ "000010007C00440044000400080010000000000000007C00100010001000100010007C0000000000000008007C000800180028004800080000000000"
+ "000020007C002400240024002400480000000000000010007C0010007C00100010001000000000000000000000003C00240044000800100000000000"
+ "000020003C004800080008000800100000000000000000007C0004000400040004007C0000000000000028007C002800280008001000200000000000"
+ "0000600000006400040004000800300000000000000000007C000400080010002800440000000000000020007C0024002800200020001C0000000000"
+ "0000000044004400240004000800300000000000000000003C00240054000C00080030000000000000000800700010007C0010001000200000000000"
+ "00000000540054005400040008001000000000000000380000007C001000100010002000000000000000400040004000600050004000400000000000"
+ "0000100010007C0010001000200040000000000000000000380000000000000000007C0000000000000000007C000400280010002800400000000000"
+ "000010007C00080010003800540010000000000000000800080008000800080010002000000000000000000000002000100048004800480000000000"
+ "0000400040007C004000400040003C0000000000000000007C0004000400040008003000000000000000000020005000080004000400000000000000"
+ "000010007C001000100054005400100000000000000000007C0004000400280010000800000000000000000070000000700000007000080000000000"
+ "0000000010002000400044007C000400000000000000000004000400280010002800400000000000000000007C0020007C00200020001C0000000000"
+ "0000200020007C0024002800200020000000000000000000380008000800080008007C0000000000000000007C0004007C00040004007C0000000000"
+ "0000380000007C0004000800100020000000000000004800480048004800080010002000000000000000100050005000500054005400180000000000"
+ "00004000400040004400480050006000000000000000000000007C004400440044007C0000000000000000007C004400040004000800100000000000"
+ "000000006000000004000400080070000000000000000000100048002000000000000000000000000000000070005000700000000000000000000000"
+ "000010000000100020004000480030000000000008001000700088008800F800880088000000000080004000700088008800F8008800880000000000"
+ "50000000700088008800F8008800880000000000000070008800800080008000880070002000600008001000F8008000F00080008000F80000000000"
+ "80004000F8008000F00080008000F8000000000070008800700020002000200020007000000000005000000070008800880088008800700000000000"
+ "500000008800880088008800880070000000000008001000000070000800780088007800000000007000880000007000080078008800780000000000"
+ "800040000000700008007800880078000000000000005000000070000800780088007800000000000000000000007000800080008800700020006000"
+ "08001000000070008800F800800070000000000070008800000070008800F000800070000000000080004000000070008800F8008000700000000000"
+ "0800100040000000C00040004000E0000000000070008800200000006000200020007000000000008000400010000000300010001000380000000000"
+ "28000000100000003000100010003800000000006800B80000007000C800880088008800000000000800100000007000880088008800700000000000"
+ "700088000000700088008800880070000000000080004000000070008800880088007000000000000000500000007000880088008800700000000000"
+ "0000780044005800440044006400D8000000000010002000000088008800880098006800000000007000880000008800880088009800680000000000"
+ "80004000000088008800880098006800000000000000500000008800880088009800680000000000");
// font 3: 6x10, chars 0x40-0x7F, 64 glyphs
private static readonly Face F3 = new(3, 0x40, 0x7F, 6, 10,
"0000100020004000200010000000F800000000000000400020001000200040000000F80000000000000020002000F800200020000000F80000000000"
+ "0000000020000000F800000020000000000000000000200050005000500088008800F80000000000000008001000F8002000F8004000800000000000"
+ "20007000A800A800A800A800A8007000200000000000F80040004000400040004000E0000000000000000000F8000000F8000000F800000000000000"
+ "000008007000800008007000800000000000000000000800700080000000F800000000000000000000000400040004004800A8002800100010000000"
+ "0000200050005000500088008800880000000000000070008800080068008C0088009000600000000000000000008800500020005000880000000000"
+ "0000000020007000F80070002000000000000000000020007000A800A000A80070002000000000000000F80088008800880088008800880000000000"
+ "0000700088008800A800880088007000000000000000000000005000A800500000000000000000000000F80040002000100020004000F80000000000"
+ "000030004800480030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+ "0000000070008800880088005000D800000000000000F80000000000700000000000F800000000000000A800A800A800700020002000200000000000"
+ "0000100010007000A800A800A80070004000400000000000000000000000000000000000000000000000F4005C005400540000000000000000000000"
+ "20007000A80020002000200020002000200020002000200020002000200020002000A800700020000000300048004000F00040004800B00000000000"
+ "00000000000000006800900090006800000000000000700088008800F0008800C800B000800000000000000080008800500020005000880008000000"
+ "000070008800400020005000880088007000000000000000000070008800600088007000000000000000000020007000A800A800A800700020000000"
+ "0000000000008800880050005000700020002000000000000000B000C800880088008800080000000000000000000000600020002000300000000000"
+ "0000000000001000A800A800A80070002000000000000000000048005000600050004800000000000000C00040002000200050008800880000000000"
+ "00000000000088008800C800B00080008000000000000000000000008800880050002000000000000000000000007000880088008800700000000000"
+ "0000880050002000F8002000F800200000000000000000000000F8005000500050004800000000000000700088008800F80088008800700000000000"
+ "00000000000070008800C800B00080008000000000000000000078009000880088007000000000000000000008007000A00020002000100000000000"
+ "0000000000008000500048004800300000000000000000000000F80050008800A800500000000000000000000000000050008800A800500000000000"
+ "0000400030004000300040004000380008000000000000002000A800A800A80070002000200000000000400030004000400040004000380008000000"
+ "0000000010000800FC00080010000000000000000000000020004000FC00400020000000000000000000000000000000000000000000000000000000"
+ "1000380054001000100010001000540038001000");
// font 4: 12x16, chars 0x20-0x7F, 96 glyphs
private static readonly Face F4 = new(4, 0x20, 0x7F, 12, 16,
"00000000000000000000000000000000000000000000000000000000000000000C000C000C000C000C000C000C000C000C000C00000000000C000C00"
+ "0000000033003300330033003300330000000000000000000000000000000000000000003300330033003300FFC0FFC033003300FFC0FFC033003300"
+ "33003300000000000C000C003FC07FC0CC00CC007F003F800CC00CC0FF80FF000C000C0000000000F000F000F0C0F1C0038007000E001C0038007000"
+ "E3C0C3C003C003C0000000003C007E00C300C700CE00DC0078007800DCC0CFC0C700C7007EC03CC0000000001E001E00060006001C00180000000000"
+ "0000000000000000000000000000000000C001C0038007000E000C000C000C000C000E000700038001C000C000000000300038001C000E0007000300"
+ "03000300030007000E001C003800300000000000000000000C000C00CCC0EDC07F807F80EDC0CCC00C000C000000000000000000000000000C000C00"
+ "0C000C00FFC0FFC00C000C000C000C000000000000000000000000000000000000000000000000000F000F00030003000E000C000000000000000000"
+ "0000000000000000FFC0FFC00000000000000000000000000000000000000000000000000000000000000000000000000F000F000F000F0000000000"
+ "0000000000C001C0038007000E001C0038007000E000C00000000000000000003F007F80E1C0C0C0C1C0C2C0C4C0C8C0D0C0E0C0C0C0E1C07F803F00"
+ "000000000C001C003C000C000C000C000C000C000C000C000C000C003F003F00000000003F007F80E1C0C0C000C001C0038007000E001C0038007000"
+ "FFC0FFC000000000FFC0FFC0038007000E000E000700038001C000C000C001C0FF80FF000000000007000F001F003B0033006300E300C300FFC0FFC0"
+ "030003000300030000000000FFC0FFC0C000C000C000C000FF00FF8001C000C000C001C0FF80FF00000000000F001F0038007000E000C000FF00FF80"
+ "C1C0C0C0C0C0E1C07F803F0000000000FFC0FFC000C001C0038007000E001C00380030003000300030003000000000003F007F80E1C0C0C0C0C0E1C0"
+ "7F807F80E1C0C0C0C0C0E1C07F803F00000000003F007F80E1C0C0C0C0C0E0C07FC03FC000C001C0038007003E003C0000000000000000000F000F00"
+ "0F000F00000000000F000F000F000F000000000000000000000000000F000F000F000F00000000000F000F00030003000E000C000000000000C001C0"
+ "038007000E001C00380038001C000E000700038001C000C0000000000000000000000000FFC0FFC000000000FFC0FFC0000000000000000000000000"
+ "300038001C000E000700038001C001C0038007000E001C0038003000000000003F007F80E1C0C0C000C001C0038007000E000C00000000000C000C00"
+ "000000003F007F80E1C0C0C000C000C038C07CC0CCC0CCC0CCC0CCC07F803F00000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0FFC0FFC0C0C0C0C0"
+ "C0C0C0C000000000FF00FF80C1C0C0C0C0C0C1C0FF80FF80C1C0C0C0C0C0C1C0FF80FF00000000003F007F80E1C0C0C0C0C0C000C000C000C000C0C0"
+ "C0C0C1C07F803F0000000000FF00FF80C1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C1C0FF80FF0000000000FFC0FFC0C000C000C000C000FF00FF00"
+ "C000C000C000C000FFC0FFC000000000FFC0FFC0C000C000C000C000FF00FF00C000C000C000C000C000C000000000003F007F80E1C0C0C0C000C000"
+ "C000C7C0C7C0C0C0C0C0E1C07F803F0000000000C0C0C0C0C0C0C0C0C0C0C0C0FFC0FFC0C0C0C0C0C0C0C0C0C0C0C0C0000000003F003F000C000C00"
+ "0C000C000C000C000C000C000C000C003F003F00000000000FC00FC003000300030003000300030003000300C300E7007E003C0000000000C0C0C1C0"
+ "C380C700CE00DC00F800F800DC00CE00C700C380C1C0C0C000000000C000C000C000C000C000C000C000C000C000C000C000C000FFC0FFC000000000"
+ "C0C0E1C0F3C0F3C0DEC0CCC0CCC0C0C0C0C0C0C0C0C0C0C0C0C0C0C000000000C0C0C0C0C0C0E0C0F0C0F8C0DCC0CEC0C7C0C3C0C1C0C0C0C0C0C0C0"
+ "000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0E1C07F803F0000000000FF00FF80C1C0C0C0C0C0C1C0FF80FF00C000C000C000C000"
+ "C000C000000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C2C0E1C07F803F4000000000FF00FF80C1C0C0C0C0C0C1C0FF80FF00DC00CE00"
+ "C700C380C1C0C0C0000000003FC07FC0E000C000C000E0007F003F8001C000C000C001C0FF80FF0000000000FFC0FFC00C000C000C000C000C000C00"
+ "0C000C000C000C000C000C0000000000C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0E1C07F803F0000000000C0C0C0C0C0C0C0C0C0C0C0C0"
+ "C0C0C0C0C0C0E1C0738033001E000C0000000000C0C0C0C0C0C0C0C0CCC0CCC0CCC0CCC0CCC0CCC0CCC0CCC07F80330000000000C0C0C0C0C0C0E1C0"
+ "73803F001E001E003F007380E1C0C0C0C0C0C0C000000000C0C0C0C0C0C0C0C0C0C0E1C073803F001E000C000C000C000C000C0000000000FFC0FFC0"
+ "00C001C0038007000E001C0038007000E000C000FFC0FFC0000000003F003F0030003000300030003000300030003000300030003F003F0000000000"
+ "00000000C000E000700038001C000E000700038001C000C000000000000000003F003F0003000300030003000300030003000300030003003F003F00"
+ "000000000C001E003F007380E1C0C0C00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+ "FFC0FFC0000000000C000E000700038001C000C0000000000000000000000000000000000000000000000000000000003F003F8000C000C03FC07FC0"
+ "C0C0C0C07FC03FC000000000C000C000C000C000C000C000DF00FF80F1C0C0C0C0C0C1C0FF80FF000000000000000000000000003F007F80E1C0C0C0"
+ "C000C000C0C0E1C07F803F000000000000C000C000C000C000C000C03EC07FC0E3C0C0C0C0C0E0C07FC03FC00000000000000000000000003F007F80"
+ "C0C0C0C0FF80FF00C000C0007F003F00000000000F001F8039C030C030003000FC00FC00300030003000300030003000000000000000000000000000"
+ "3FC07FC0C0C0C0C07FC03FC000C000C0FF80FF0000000000C000C000C000C000C000C000DF00FF80E1C0C0C0C0C0C0C0C0C0C0C0000000000C000C00"
+ "000000003C003C000C000C000C000C000C000C003F003F000000000000C000C00000000000C000C000C000C000C000C030C039C01F800F0000000000"
+ "300030003000300030C031C0338037003E003E003700338031C030C0000000003C003C000C000C000C000C000C000C000C000C000C000C003F003F00"
+ "000000000000000000000000D300FF80FFC0CCC0CCC0CCC0CCC0CCC0CCC0CCC0000000000000000000000000CF00FF80F1C0C0C0C0C0C0C0C0C0C0C0"
+ "C0C0C0C00000000000000000000000003F007F80E1C0C0C0C0C0C0C0C0C0E1C07F803F000000000000000000DF00FF80F1C0C0C0C0C0C1C0FF80FF00"
+ "C000C000C000C00000000000000000003EC07FC0E3C0C0C0C0C0E0C07FC03FC000C000C000C000C0000000000000000000000000DF00FF80F1C0C0C0"
+ "C000C000C000C000C000C0000000000000000000000000003FC07FC0C000C0007F003F8000C000C0FF80FF000000000030003000FC00FC0030003000"
+ "300030003000300030C039C01F800F00000000000000000000000000C0C0C0C0C0C0C0C0C0C0C0C0C0C0E3C07FC03CC0000000000000000000000000"
+ "C0C0C0C0C0C0C0C0C0C0E1C0738033001E000C00000000000000000000000000C0C0C0C0C0C0C0C0C0C0CCC0CCC0CCC07F8033000000000000000000"
+ "00000000C0C0E1C073803F001E001E003F007380E1C0C0C0000000000000000000000000C0C0E1C073803F001E000C000C001C003800300000000000"
+ "0000000000000000FFC0FFC0030007000E001C0038007000FFC0FFC00000000000C001C003800300030006000C000C00060003000300038001C000C0"
+ "000000000C000C000C000C000C000C00000000000C000C000C000C000C000C0000000000300038001C000C000C0006000300030006000C000C001C00"
+ "380030000000000000C001C00F801F00380030000000000000000000000000000000000000000000FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0"
+ "FFF0FFF0FFF0FFF0FFF0FFF0");
// font 5: 12x20, chars 0x20-0x7F, 96 glyphs
private static readonly Face F5 = new(5, 0x20, 0x7F, 12, 20,
"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C000C000C000C000C000C000C000C00"
+ "0C000C00000000000C000C00000000000000000000000000330033003300330033003300000000000000000000000000000000000000000000000000"
+ "000000003300330033003300FFC0FFC033003300FFC0FFC033003300330033000000000000000000000000000C000C003FC07FC0CC00CC007F003F80"
+ "0CC00CC0FF80FF000C000C00000000000000000000000000F000F000F0C0F1C0038007000E001C0038007000E3C0C3C003C003C00000000000000000"
+ "000000003C007E00C300C700CE00DC0078007800DCC0CFC0C700C7007EC03CC00000000000000000000000001E001E00060006001C00180000000000"
+ "00000000000000000000000000000000000000000000000000C001C0038007000E000C000C000C000C000E000700038001C000C00000000000000000"
+ "00000000300038001C000E000700030003000300030007000E001C0038003000000000000000000000000000000000000C000C00CCC0EDC07F807F80"
+ "EDC0CCC00C000C0000000000000000000000000000000000000000000C000C000C000C00FFC0FFC00C000C000C000C00000000000000000000000000"
+ "000000000000000000000000000000000000000000000000000000000F000F00030003000E000C0000000000000000000000000000000000FFC0FFC0"
+ "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000F000F000F000F000000000000000000"
+ "000000000000000000C001C0038007000E001C0038007000E000C000000000000000000000000000000000003F007F80E1C0C0C0C1C0C2C0C4C0C8C0"
+ "D0C0E0C0C0C0E1C07F803F000000000000000000000000000C001C003C000C000C000C000C000C000C000C000C000C003F003F000000000000000000"
+ "000000003F007F80E1C0C0C000C001C0038007000E001C0038007000FFC0FFC0000000000000000000000000FFC0FFC0038007000E000E0007000380"
+ "01C000C000C001C0FF80FF0000000000000000000000000007000F001F003B0033006300E300C300FFC0FFC003000300030003000000000000000000"
+ "00000000FFC0FFC0C000C000C000C000FF00FF8001C000C000C001C0FF80FF000000000000000000000000000F001F0038007000E000C000FF00FF80"
+ "C1C0C0C0C0C0E1C07F803F00000000000000000000000000FFC0FFC000C001C0038007000E001C003800300030003000300030000000000000000000"
+ "000000003F007F80E1C0C0C0C0C0E1C07F807F80E1C0C0C0C0C0E1C07F803F000000000000000000000000003F007F80E1C0C0C0C0C0E0C07FC03FC0"
+ "00C001C0038007003E003C00000000000000000000000000000000000F000F000F000F00000000000F000F000F000F00000000000000000000000000"
+ "00000000000000000F000F000F000F00000000000F000F00030003000E000C0000000000000000000000000000C001C0038007000E001C0038003800"
+ "1C000E000700038001C000C00000000000000000000000000000000000000000FFC0FFC000000000FFC0FFC000000000000000000000000000000000"
+ "00000000300038001C000E000700038001C001C0038007000E001C00380030000000000000000000000000003F007F80E1C0C0C000C001C003800700"
+ "0E000C00000000000C000C000000000000000000000000003F007F80E1C0C0C000C000C038C07CC0CCC0CCC0CCC0CCC07F803F000000000000000000"
+ "000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0FFC0FFC0C0C0C0C0C0C0C0C0000000000000000000000000FF00FF80C1C0C0C0C0C0C1C0FF80FF80"
+ "C1C0C0C0C0C0C1C0FF80FF000000000000000000000000003F007F80E1C0C0C0C0C0C000C000C000C000C0C0C0C0C1C07F803F000000000000000000"
+ "00000000FF00FF80C1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C1C0FF80FF00000000000000000000000000FFC0FFC0C000C000C000C000FF00FF00"
+ "C000C000C000C000FFC0FFC0000000000000000000000000FFC0FFC0C000C000C000C000FF00FF00C000C000C000C000C000C0000000000000000000"
+ "000000003F007F80E1C0C0C0C000C000C000C7C0C7C0C0C0C0C0E1C07F803F00000000000000000000000000C0C0C0C0C0C0C0C0C0C0C0C0FFC0FFC0"
+ "C0C0C0C0C0C0C0C0C0C0C0C00000000000000000000000003F003F000C000C000C000C000C000C000C000C000C000C003F003F000000000000000000"
+ "000000000FC00FC003000300030003000300030003000300C300E7007E003C00000000000000000000000000C0C0C1C0C380C700CE00DC00F800F800"
+ "DC00CE00C700C380C1C0C0C0000000000000000000000000C000C000C000C000C000C000C000C000C000C000C000C000FFC0FFC00000000000000000"
+ "00000000C0C0E1C0F3C0F3C0DEC0CCC0CCC0C0C0C0C0C0C0C0C0C0C0C0C0C0C0000000000000000000000000C0C0C0C0C0C0E0C0F0C0F8C0DCC0CEC0"
+ "C7C0C3C0C1C0C0C0C0C0C0C00000000000000000000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0E1C07F803F000000000000000000"
+ "00000000FF00FF80C1C0C0C0C0C0C1C0FF80FF00C000C000C000C000C000C0000000000000000000000000003F007F80E1C0C0C0C0C0C0C0C0C0C0C0"
+ "C0C0C0C0C2C0E1C07F803F40000000000000000000000000FF00FF80C1C0C0C0C0C0C1C0FF80FF00DC00CE00C700C380C1C0C0C00000000000000000"
+ "000000003FC07FC0E000C000C000E0007F003F8001C000C000C001C0FF80FF00000000000000000000000000FFC0FFC00C000C000C000C000C000C00"
+ "0C000C000C000C000C000C00000000000000000000000000C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0E1C07F803F000000000000000000"
+ "00000000C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0E1C0738033001E000C00000000000000000000000000C0C0C0C0C0C0C0C0CCC0CCC0CCC0CCC0"
+ "CCC0CCC0CCC0CCC07F803300000000000000000000000000C0C0C0C0C0C0E1C073803F001E001E003F007380E1C0C0C0C0C0C0C00000000000000000"
+ "00000000C0C0C0C0C0C0C0C0C0C0E1C073803F001E000C000C000C000C000C00000000000000000000000000FFC0FFC000C001C0038007000E001C00"
+ "38007000E000C000FFC0FFC00000000000000000000000003F003F0030003000300030003000300030003000300030003F003F000000000000000000"
+ "0000000000000000C000E000700038001C000E000700038001C000C0000000000000000000000000000000003F003F00030003000300030003000300"
+ "03000300030003003F003F000000000000000000000000000C001E003F007380E1C0C0C0000000000000000000000000000000000000000000000000"
+ "0000000000000000000000000000000000000000000000000000000000000000FFC0FFC000000000000000000C000E000700038001C000C000000000"
+ "00000000000000000000000000000000000000000000000000000000000000003F003F8000C000C03FC07FC0C0C0C0C07FC03FC00000000000000000"
+ "00000000C000C000C000C000C000C000DF00FF80F1C0C0C0C0C0C1C0FF80FF0000000000000000000000000000000000000000003F007F80E1C0C0C0"
+ "C000C000C0C0E1C07F803F0000000000000000000000000000C000C000C000C000C000C03EC07FC0E3C0C0C0C0C0E0C07FC03FC00000000000000000"
+ "0000000000000000000000003F007F80C0C0C0C0FF80FF00C000C0007F003F000000000000000000000000000F001F8039C030C030003000FC00FC00"
+ "30003000300030003000300000000000000000000000000000000000000000003FC07FC0E0C0C0C0C0C0E0C07FC03FC000C000C000C001C03F803F00"
+ "00000000C000C000C000C000C000C000DF00FF80E1C0C0C0C0C0C0C0C0C0C0C00000000000000000000000000C000C00000000003C003C000C000C00"
+ "0C000C000C000C003F003F0000000000000000000000000003000300000000000F000F00030003000300030003000300C300C300C300E7007E003C00"
+ "00000000300030003000300030C031C0338037003E003E003700338031C030C00000000000000000000000003C003C000C000C000C000C000C000C00"
+ "0C000C000C000C003F003F000000000000000000000000000000000000000000D300FF80FFC0CCC0CCC0CCC0CCC0CCC0CCC0CCC00000000000000000"
+ "000000000000000000000000CF00FF80F1C0C0C0C0C0C0C0C0C0C0C0C0C0C0C000000000000000000000000000000000000000003F007F80E1C0C0C0"
+ "C0C0C0C0C0C0E1C07F803F000000000000000000000000000000000000000000CF00DF80F9C0F0C0E0C0C1C0FF80FF00C000C000C000C000C000C000"
+ "0000000000000000000000003CC07EC0E7C0C3C0C1C0E0C07FC03FC000C000C000C000C000C000C0000000000000000000000000DF00FF80F1C0C0C0"
+ "C000C000C000C000C000C00000000000000000000000000000000000000000003FC07FC0C000C0007F003F8000C000C0FF80FF000000000000000000"
+ "0000000030003000FC00FC0030003000300030003000300030C039C01F800F000000000000000000000000000000000000000000C0C0C0C0C0C0C0C0"
+ "C0C0C0C0C0C0E3C07FC03CC00000000000000000000000000000000000000000C0C0C0C0C0C0C0C0C0C0E1C0738033001E000C000000000000000000"
+ "000000000000000000000000C0C0C0C0C0C0C0C0C0C0CCC0CCC0CCC07F8033000000000000000000000000000000000000000000C0C0E1C073803F00"
+ "1E001E003F007380E1C0C0C00000000000000000000000000000000000000000C0C0C0C0C0C0C0C0C0C0E0C07FC03FC000C000C000C001C03F803F00"
+ "000000000000000000000000FFC0FFC0030007000E001C0038007000FFC0FFC000000000000000000000000000C001C003800300030006000C000C00"
+ "060003000300038001C000C00000000000000000000000000C000C000C000C000C000C00000000000C000C000C000C000C000C000000000000000000"
+ "00000000300038001C000C000C0006000300030006000C000C001C003800300000000000000000000000000000C001C00F801F003800300000000000"
+ "000000000000000000000000000000000000000000000000FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF0FFF000000000");
// font 6: 7x10, chars 0x20-0xFF, 224 glyphs
private static readonly Face F6 = new(6, 0x20, 0xFF, 7, 10,
"000000000000000000000000000000000000000000001000100010001000000000001000000000000000280028002800000000000000000000000000"
+ "0000280028007C0028007C002800280000000000000010003C0050003800140078001000000000000000600064000800100020004C000C0000000000"
+ "000030004800500020005400480034000000000000003000100020000000000000000000000000000000080010002000200020001000080000000000"
+ "0000200010000800080008001000200000000000000000001000540038005400100000000000000000000000100010007C0010001000000000000000"
+ "000000000000000000000000300010002000000000000000000000007C00000000000000000000000000000000000000000000003000300000000000"
+ "00000000040008001000200040000000000000000000380044004C005400640044003800000000000000100030001000100010001000380000000000"
+ "00003800440004000800100020007C000000000000007C0008001000080004000400780000000000000008001800280048007C000800080000000000"
+ "00007C0040007800040004000400780000000000000018002000400078004400440038000000000000007C0004000800100020002000200000000000"
+ "000038004400440038004400440038000000000000003800440044003C00040008003000000000000000000030003000000030003000000000000000"
+ "000000003000300000003000100020000000000000000800100020004000200010000800000000000000000000007C0000007C000000000000000000"
+ "000040002000100008001000200040000000000000003800440004000800100000001000000000000000380044000400340054005400380000000000"
+ "000038004400440044007C00440044000000000000007800440044007800440044007800000000000000380044004000400040004400380000000000"
+ "000078004400440044004400440078000000000000007C00400040007800400040007C000000000000007C0040004000780040004000400000000000"
+ "00003800440040005C004400440038000000000000004400440044007C00440044004400000000000000380010001000100010001000380000000000"
+ "00001C0008000800080008004800300000000000000044004800500060005000480044000000000000004000400040004000400040007C0000000000"
+ "000044006C005400540044004400440000000000000044004400640054004C0044004400000000000000380044004400440044004400380000000000"
+ "000078004400440078004000400040000000000000003800440044004400540048003400000000000000780044004400780050004800440000000000"
+ "00003C004000400038000400040078000000000000007C00100010001000100010001000000000000000440044004400440044004400380000000000"
+ "000044004400440044004400280010000000000000004400440044005400540054002800000000000000440044002800100028004400440000000000"
+ "000044004400440028001000100010000000000000007C00040008001000200040007C00000000000000380020002000200020002000380000000000"
+ "000040002000100008000400000000000000000000003800080008000800080008003800000000000000100028004400000000000000000000000000"
+ "000000000000000000000000000000000000FE000000300020001000000000000000000000000000000000000000380004003C0044003C0000000000"
+ "0000400040005800640044004400780000000000000000000000380040004000400038000000000000000400040034004C00440044003C0000000000"
+ "000000000000380044007C00400038000000000000001800240020007000200020002000000000000000000000003C00440044003C00040004003800"
+ "000040004000580064004400440044000000000000001000000030001000100010003800000000000000080000001800080008000800080048003000"
+ "000040004000480050006000500048000000000000003000100010001000100010003800000000000000000000006800540054004400440000000000"
+ "000000000000580064004400440044000000000000000000000038004400440044003800000000000000000000005800640044007800400040004000"
+ "00000000000034004C0044003C0004000400040000000000000058006400400040004000000000000000000000003C00400038000400780000000000"
+ "000000002000F8002000200028001000000000000000000000004400440044004C003400000000000000000000004400440044002800100000000000"
+ "000000000000440044005400540028000000000000000000000044002800100028004400000000000000000000004400440044003C00040004003800"
+ "0000000000007C000800100020007C000000000000000800100010002000100010000800000000000000100010001000000010001000100000000000"
+ "00002000100010000800100010002000000000000000040038004000000000000000000000000000FE00FE00FE00FE00FE00FE00FE00FE00FE00FE00"
+ "0000000000000000FE000000000000000000000008000800080008000800080008000800080008000800080008000800F80000000000000000000000"
+ "00000000000000000E000800080008000800080008000800080008000E00000000000000000000000000000000000000F80008000800080008000800"
+ "08000800080008000E00080008000800080008000800080008000800F800080008000800080008000800080008000800FE0008000800080008000800"
+ "0800080008000800FE00000000000000000000000000000000000000FE00080008000800080008000000000000000000020004000800080008000800"
+ "0000000000000000E000100008000800080008000800080008001000E000000000000000000000000800080008000400020000000000000000000000"
+ "1C001C001C00FE00FE00FE001C001C001C001C00000000000000FE00FE00FE0000000000000000001C001C001C001C001C001C001C001C001C001C00"
+ "1C001C001C00FC00FC00FC0000000000000000000000000000001E001E001E001C001C001C001C001C001C001C001E001E001E000000000000000000"
+ "000000000000FC00FC00FC001C001C001C001C001C001C001C001E001E001E001C001C001C001C001C001C001C00FC00FC00FC001C001C001C001C00"
+ "000000001000380054001000100010001000000000000000000020001000F8001000200000000000000000000000080010003E001000080000000000"
+ "00000000000008000800F8000000000000000000000008000800080008002A001C0008000000000000000000080014002200140014001C0000000000"
+ "0000000008001C003E001C001C001C00000000000000000010003E007E003E0010000000000000000000000054004400820082008200440038000000"
+ "000000000000000000007000500070000000000000001C00100010001000000000000000000000000000000000000000100010001000700000000000"
+ "00000000000000000000400020001000000000000000000000000000300030000000000000000000000000007C0004007C0004000800100000000000"
+ "0000000000007C00040018001000200000000000000000000000080010003000500010000000000000000000000010007C0044000400180000000000"
+ "00000000000000007C00100010007C000000000000000000000008007C001800280048000000000000000000000020007C0024002800200000000000"
+ "00000000000000003800080008007C000000000000000000000078000800780008007800000000000000000000000000540054000400180000000000"
+ "0000000000007C0000000000000000000000000000007C00040014001800100010002000000000000000040008001000300050001000100000000000"
+ "000010007C004400440004000800100000000000000000007C0010001000100010007C0000000000000008007C000800180028004800080000000000"
+ "000020007C002400240024002400480000000000000010007C0010007C0010001000100000000000000000003C002400440004000800300000000000"
+ "000020003C004800080008000800100000000000000000007C0004000400040004007C0000000000000028007C002800280008001000200000000000"
+ "0000000060000400640004000800700000000000000000007C000400080010002800440000000000000020007C0024002800200020001C0000000000"
+ "0000000044004400240004000800300000000000000000003C00240054000C00080030000000000000000800700010007C0010001000200000000000"
+ "00000000540054005400040008001000000000000000380000007C001000100010002000000000000000200020002000300028002000200000000000"
+ "0000100010007C0010001000200040000000000000000000380000000000000000007C0000000000000000007C000400280010002800400000000000"
+ "000010007C00080010003800540010000000000000000800080008000800080010002000000000000000000010000800440044004400440000000000"
+ "0000400040007C004000400040003C0000000000000000007C0004000400040008003000000000000000000020005000080004000400000000000000"
+ "000010007C001000100054005400100000000000000000007C0004000400280010000800000000000000000038000000380000003800040000000000"
+ "0000000010002000400044007C000400000000000000000004000400280010002800400000000000000000007C0020007C00200020001C0000000000"
+ "0000200020007C0024002800200020000000000000000000380008000800080008007C0000000000000000007C0004007C00040004007C0000000000"
+ "0000380000007C0004000400080010000000000000004800480048004800080010002000000000000000100050005000500054005400180000000000"
+ "0000400040004000440048005000600000000000000000007C0044004400440044007C0000000000000000007C004400440004000800100000000000"
+ "000000006000000004000400080070000000000000001000480020000000000000000000000000000000700050007000000000000000000000000000"
+ "0000100000001000200040004400380000000000040008003800440044007C004400440000000000400020003800440044007C004400440000000000"
+ "280000003800440044007C0044004400000000000000380044004000400040004400380010003000040008007C0040007800400040007C0000000000"
+ "400020007C0040007800400040007C000000000038004400380010001000100010003800000000002800000038004400440044004400380000000000"
+ "2800000044004400440044004400380000000000040008000000380004003C0044003C0000000000380044000000380004003C0044003C0000000000"
+ "400020000000380004003C0044003C0000000000000028000000380004003C0044003C00000000000000000000003800400040004400380010003000"
+ "040008000000380044007C004000380000000000380044000000380044007C004000380000000000400020000000380044007C004000380000000000"
+ "040008002000000060002000200070000000000038004400100000003000100010003800000000008000400010000000300010001000380000000000"
+ "280000001000000030001000100038000000000034005C00000058006400440044004400000000000400080000003800440044004400380000000000"
+ "380044000000380044004400440038000000000040002000000038004400440044003800000000000000280000003800440044004400380000000000"
+ "0000380044005800440044006400D800000000000800100000004400440044004C003400000000003800440000004400440044004C00340000000000"
+ "4000200000004400440044004C003400000000000000280000004400440044004C00340000000000");
// font 7: 7x10, chars 0x40-0x7F, 64 glyphs
private static readonly Face F7 = new(7, 0x40, 0x7F, 7, 10,
"00000800100020001000080000007C000000000000002000100008001000200000007C00000000000000100010007C001000100000007C0000000000"
+ "00000000100000007C000000100000000000000000001000280028002800440044007C00000000000000040008007C0010007C002000400000000000"
+ "100038005400540054005400540038001000000000007C0020002000200020002000700000000000000000007C0000007C0000007C00000000000000"
+ "0000040038004000040038004000000000000000000004003800400000007C00000000000000000000000400040004004800A8002800100010000000"
+ "0000100028002800280044004400440000000000000038004400040034004C0044004800300000000000000000004400280010002800440000000000"
+ "00000000100038007C0038001000000000000000000010003800540050005400380010000000000000007C0044004400440044004400440000000000"
+ "0000380044004400540044004400380000000000000000000000280054002800000000000000000000007C00200010000800100020007C0000000000"
+ "00001800240024001800000000000000000000000000380044009200BA0092004400380000000000000038004400AA009200AA004400380000000000"
+ "00000000380044004400440028006C000000000000007C00000000003800000000007C00000000000000540054005400380010001000100000000000"
+ "00003A0044008A009200A2004400B800000000000000380044009A00A2009A00440038000000000000007A002E002A002A0000000000000000000000"
+ "08001C002A00080008000800080008000800080008000800080008000800080008002A001C0008000000180024002000780020002400580000000000"
+ "000000000000340048004800340000000000000000003800440044007800440064004C00400000000000000040004400280010002800440004000000"
+ "000038004400200010002800440044003800000000000000000038004400300044003800000000000000000010003800540054005400380010000000"
+ "000000000000440044002800280038001000100000000000000058006400440044004400040000000000000000000000300010001000180000000000"
+ "000000000000080054005400540038001000000000000000000024002800300028002400000000000000600020001000100028004400440000000000"
+ "000000000000440044006400580040004000000000000000000044004400280028001000000000000000000000003800440044004400380000000000"
+ "00004400280010007C0010007C001000000000000000000000007C0028002800280024000000000000003800440044007C0044004400380000000000"
+ "00000000000038004400640058004000400000000000000000003C004800440044003800000000000000000004003800500010001000080000000000"
+ "00000000000040002800240024001800000000000000000000007C002800440054002800000000000000000000000000280044005400280000000000"
+ "00002000180020001800200020001C0004000000000000001000540054003800100010000000000000002000180020002000200020001C0004000000"
+ "00000000080004007E00040008000000000000000000000020004000FE00400020000000000000000000000028004400FE0044002800000000000000"
+ "08001C002A0008000800080008002A001C000800");
/// <summary>All 8 faces, indexed 0-7 (matches ESC K).</summary>
public static readonly Face[] All = { F0, F1, F2, F3, F4, F5, F6, F7 };
/// <summary>The default/power-on font (font 0: 6x8, full 0x20-0xFF range).</summary>
public static Face Default => F0;
}
+3 -2
View File
@@ -50,10 +50,11 @@ public static class PlasmaSelfTest
b.Add(PlasmaProtocol.LineFeed); b.Add(PlasmaProtocol.LineFeed);
Text(b, "128x32 PLASMA DISPLAY"); // exactly one 21-cell row Text(b, "128x32 PLASMA DISPLAY"); // exactly one 21-cell row
// Attributes are the low 4 bits: 1=half, 2=underline, 4=reverse, 8=flash.
Esc(b, PlasmaProtocol.CmdAttributes, 1); Text(b, "HALF "); Esc(b, PlasmaProtocol.CmdAttributes, 1); Text(b, "HALF ");
Esc(b, PlasmaProtocol.CmdAttributes, 2); Text(b, "UNDER "); Esc(b, PlasmaProtocol.CmdAttributes, 2); Text(b, "UNDER ");
Esc(b, PlasmaProtocol.CmdAttributes, 3); Text(b, "REV"); Esc(b, PlasmaProtocol.CmdAttributes, 4); Text(b, "REV");
Esc(b, PlasmaProtocol.CmdAttributes, 4); Text(b, " FLASH"); Esc(b, PlasmaProtocol.CmdAttributes, 8); Text(b, " FLASH");
Esc(b, PlasmaProtocol.CmdAttributes, PlasmaProtocol.OperandDefault); Esc(b, PlasmaProtocol.CmdAttributes, PlasmaProtocol.OperandDefault);
return b.ToArray(); return b.ToArray();
} }
+208 -100
View File
@@ -10,7 +10,7 @@ public enum PlasmaCursorMode
Flashing, Flashing,
} }
/// <summary>Text rendering attributes (set with <c>ESC H</c>).</summary> /// <summary>Text rendering attributes (set with <c>ESC H</c>, low 4 bits).</summary>
[Flags] [Flags]
public enum PlasmaAttributes : byte public enum PlasmaAttributes : byte
{ {
@@ -21,23 +21,40 @@ public enum PlasmaAttributes : byte
Flash = 8, Flash = 8,
} }
/// <summary>
/// Display orientation — the JP1 jumper-4 (PD5) strap the firmware reads at
/// boot (<c>PlasmaNew/README.md</c>). Horizontal is the normal 128×32
/// landscape; Vertical treats the panel as 32×128 and rotates content onto
/// the physical glass (for a portrait-mounted panel).
/// </summary>
public enum PlasmaOrientation
{
Horizontal,
Vertical,
}
/// <summary> /// <summary>
/// The plasma display proper: a 128×32 1bpp frame plus the text-mode state /// 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 /// (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 /// COM2. Feed raw wire bytes to <see cref="OnReceived"/>; the parser is a
/// state machine, so commands may arrive split across any chunk boundaries. /// state machine, so commands may arrive split across any chunk boundaries.
/// ///
/// <para>Pixels carry flags rather than a plain bit: graphics writes set /// <para>Grounded in the real firmware (<c>PlasmaNew/FIRMWARE.md</c>): the
/// full-intensity dots, while text can stamp half-intensity or flashing dots /// cursor is a <b>pixel</b> position — <c>ESC Q</c> sets its row (Y),
/// (<c>ESC H</c>); the UI renders <see cref="PixelHalf"/> dimmer and blinks /// <c>ESC R</c> its column (X) — and glyphs are drawn there and advance X by
/// <see cref="PixelFlash"/>. The command set itself is documented on /// the font's width. The eight fonts are the real ROM character generator
/// <see cref="PlasmaProtocol"/>.</para> /// (<see cref="PlasmaFonts"/>). Attributes are the low 4 bits of the
/// <c>ESC H</c> operand.</para>
/// ///
/// <para>Grid geometry: fonts 03 are the 5×7 set in a 6×8 cell (21 columns /// <para><see cref="Orientation"/> mirrors JP1 jumper 4: in Vertical the
/// × 4 rows), fonts 47 the same glyphs doubled into a 12×16 cell /// logical space is 32×128 and every dot is rotated onto the physical 128×32
/// (10 × 2). Which of the eight slots the real panel mapped to which face is /// glass. All drawing goes through <see cref="Plot"/>, so the same code serves
/// lost with the hardware; two sizes cover what the surviving software /// both orientations. <see cref="ShowTestPattern"/> lights the whole panel —
/// exercises.</para> /// the power-on dead-dot check that JP1 jumper 5 triggers on the real board.</para>
///
/// <para>Not yet folded in from the firmware (documented, deferred): the 10
/// double-buffered pages (<c>ESC I</c>/<c>ESC i</c>) and the vector-graphics
/// primitives (<c>ESC A</c><c>F</c>).</para>
/// ///
/// <para>Thread-safe: the serial reader feeds bytes while the UI snapshots /// <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> /// frames. Events are raised outside the lock, on the caller's thread.</para>
@@ -54,13 +71,15 @@ public sealed class VPlasmaDevice
public const byte PixelFlash = 0x04; public const byte PixelFlash = 0x04;
private readonly object _sync = new(); private readonly object _sync = new();
private readonly byte[] _pixels = new byte[Width * Height]; private readonly byte[] _pixels = new byte[Width * Height]; // always physical 128×32
// ---- text-mode state ------------------------------------------------- // ---- text-mode state -------------------------------------------------
private int _font; // 0..7 private int _font; // 0..7
private PlasmaFonts.Face _face = PlasmaFonts.Default;
private PlasmaAttributes _attributes; private PlasmaAttributes _attributes;
private int _col, _row; // cursor, in cells of the current grid private int _x, _y; // cursor, in logical pixels
private PlasmaCursorMode _cursorMode = PlasmaCursorMode.Steady; // power-on default; the game hides it private PlasmaCursorMode _cursorMode = PlasmaCursorMode.Steady; // power-on default; the game hides it
private PlasmaOrientation _orientation = PlasmaOrientation.Horizontal;
// ---- parser state ---------------------------------------------------- // ---- parser state ----------------------------------------------------
private enum State private enum State
@@ -99,16 +118,58 @@ public sealed class VPlasmaDevice
public int Font { get { lock (_sync) return _font; } } public int Font { get { lock (_sync) return _font; } }
public PlasmaAttributes Attributes { get { lock (_sync) return _attributes; } } public PlasmaAttributes Attributes { get { lock (_sync) return _attributes; } }
// Current font grid, for the UI's cursor overlay and status line. /// <summary>Cursor column X in logical pixels.</summary>
private int FontScale => _font >= 4 ? 2 : 1; public int CursorX { get { lock (_sync) return _x; } }
public int CellWidth { get { lock (_sync) return 6 * FontScale; } } /// <summary>Cursor row Y in logical pixels.</summary>
public int CellHeight { get { lock (_sync) return 8 * FontScale; } } public int CursorY { get { lock (_sync) return _y; } }
private int Columns => Width / (6 * FontScale); /// <summary>Current font cell width in pixels.</summary>
private int Rows => Height / (8 * FontScale); public int FontWidth { get { lock (_sync) return _face.Width; } }
public Point CursorCell { get { lock (_sync) return new Point(_col, _row); } } /// <summary>Current font cell height in pixels.</summary>
public int FontHeight { get { lock (_sync) return _face.Height; } }
/// <summary>A cursor cell position (avoids dragging in System.Drawing).</summary> /// <summary>Logical drawing width (128 horizontal, 32 vertical).</summary>
public readonly record struct Point(int Col, int Row); public int LogicalWidth { get { lock (_sync) return LogicalW; } }
/// <summary>Logical drawing height (32 horizontal, 128 vertical).</summary>
public int LogicalHeight { get { lock (_sync) return LogicalH; } }
private int LogicalW => _orientation == PlasmaOrientation.Horizontal ? Width : Height;
private int LogicalH => _orientation == PlasmaOrientation.Horizontal ? Height : Width;
/// <summary>
/// Display orientation (JP1 jumper 4). Changing it re-inits the panel —
/// clears the glass and homes the cursor, like the boot-time strap read.
/// </summary>
public PlasmaOrientation Orientation
{
get { lock (_sync) return _orientation; }
set
{
lock (_sync)
{
if (_orientation == value)
return;
_orientation = value;
Array.Clear(_pixels, 0, _pixels.Length);
_x = _y = 0;
_dirty = true;
}
FlushEvents();
}
}
/// <summary>
/// Set one logical dot, mapping to the physical 128×32 buffer per
/// <see cref="Orientation"/>. Out-of-range logical coordinates are ignored.
/// </summary>
private void Plot(int lx, int ly, byte flags)
{
if ((uint)lx >= (uint)LogicalW || (uint)ly >= (uint)LogicalH)
return;
int px, py;
if (_orientation == PlasmaOrientation.Horizontal) { px = lx; py = ly; }
else { px = ly; py = Height - 1 - lx; } // 90° rotation onto landscape glass
_pixels[py * Width + px] = flags;
}
/// <summary>Copy the frame into <paramref name="destination"/> (Width*Height flag bytes).</summary> /// <summary>Copy the frame into <paramref name="destination"/> (Width*Height flag bytes).</summary>
public void CopyFrame(byte[] destination) public void CopyFrame(byte[] destination)
@@ -119,14 +180,15 @@ public sealed class VPlasmaDevice
Buffer.BlockCopy(_pixels, 0, destination, 0, _pixels.Length); Buffer.BlockCopy(_pixels, 0, destination, 0, _pixels.Length);
} }
/// <summary>Power-on state: dark glass, home cursor, defaults.</summary> /// <summary>Power-on state: dark glass, home cursor, defaults (keeps orientation).</summary>
public void Reset() public void Reset()
{ {
lock (_sync) lock (_sync)
{ {
Array.Clear(_pixels, 0, _pixels.Length); Array.Clear(_pixels, 0, _pixels.Length);
_col = _row = 0; _x = _y = 0;
_font = 0; _font = 0;
_face = PlasmaFonts.Default;
_attributes = PlasmaAttributes.None; _attributes = PlasmaAttributes.None;
_cursorMode = PlasmaCursorMode.Steady; _cursorMode = PlasmaCursorMode.Steady;
_state = State.Text; _state = State.Text;
@@ -135,6 +197,41 @@ public sealed class VPlasmaDevice
FlushEvents(); FlushEvents();
} }
/// <summary>Number of diagnostic patterns the JP1 jumper-5 test cycles.</summary>
public const int TestPatternCount = 5;
/// <summary>Show the first test pattern (all dots lit).</summary>
public void ShowTestPattern() => ShowTestPattern(0);
/// <summary>
/// Paint diagnostic pattern <paramref name="index"/> (0..<see
/// cref="TestPatternCount"/>-1). The real display's power-on test (JP1
/// jumper 5, firmware <c>$B888</c>) runs through several drawing
/// sequences — a solid fill, a border+grid, and more, each held briefly —
/// to expose dead dots and addressing faults; these stand in for that.
/// </summary>
public void ShowTestPattern(int index)
{
lock (_sync)
{
for (int y = 0; y < Height; ++y)
for (int x = 0; x < Width; ++x)
_pixels[y * Width + x] = TestDot(index, x, y) ? PixelLit : (byte)0;
_dirty = true;
}
FlushEvents();
}
private static bool TestDot(int index, int x, int y) => index switch
{
0 => true, // all on
1 => x == 0 || x == Width - 1 || y == 0 || y == Height - 1 // frame
|| x % 16 == 0 || y % 8 == 0, // + grid
2 => (y / 2) % 2 == 0, // horizontal stripes
3 => (x / 4) % 2 == 0, // vertical stripes
_ => ((x / 8) + (y / 8)) % 2 == 0, // checkerboard
};
/// <summary>Feed <paramref name="count"/> received wire bytes.</summary> /// <summary>Feed <paramref name="count"/> received wire bytes.</summary>
public void OnReceived(byte[] buffer, int count) public void OnReceived(byte[] buffer, int count)
{ {
@@ -187,7 +284,7 @@ public sealed class VPlasmaDevice
return; return;
case PlasmaProtocol.BackSpace: case PlasmaProtocol.BackSpace:
if (_col > 0) _col--; _x = Math.Max(0, _x - _face.Width);
_dirty = true; _dirty = true;
return; return;
@@ -197,17 +294,18 @@ public sealed class VPlasmaDevice
return; return;
case PlasmaProtocol.LineFeed: case PlasmaProtocol.LineFeed:
_row = (_row + 1) % Rows; NextLine();
_dirty = true; _dirty = true;
return; return;
case PlasmaProtocol.VerticalTab: case PlasmaProtocol.VerticalTab:
_row = (_row + Rows - 1) % Rows; _y -= _face.Height;
if (_y < 0) _y = Math.Max(0, LogicalH - _face.Height);
_dirty = true; _dirty = true;
return; return;
case PlasmaProtocol.CarriageReturn: case PlasmaProtocol.CarriageReturn:
_col = 0; _x = 0;
_dirty = true; _dirty = true;
return; return;
} }
@@ -232,8 +330,9 @@ public sealed class VPlasmaDevice
{ {
case PlasmaProtocol.CmdClearScreen: case PlasmaProtocol.CmdClearScreen:
Array.Clear(_pixels, 0, _pixels.Length); Array.Clear(_pixels, 0, _pixels.Length);
_col = _row = 0; _x = _y = 0;
_font = 0; _font = 0;
_face = PlasmaFonts.Default;
_attributes = PlasmaAttributes.None; _attributes = PlasmaAttributes.None;
_dirty = true; _dirty = true;
Log("Clear screen (ESC @)"); Log("Clear screen (ESC @)");
@@ -241,7 +340,7 @@ public sealed class VPlasmaDevice
break; break;
case PlasmaProtocol.CmdHomeCursor: case PlasmaProtocol.CmdHomeCursor:
_col = _row = 0; _x = _y = 0;
_dirty = true; _dirty = true;
Log("Home cursor (ESC L)"); Log("Home cursor (ESC L)");
_graphicsLogArmed = true; _graphicsLogArmed = true;
@@ -250,6 +349,10 @@ public sealed class VPlasmaDevice
case PlasmaProtocol.CmdCursorMode: case PlasmaProtocol.CmdCursorMode:
case PlasmaProtocol.CmdFontSelect: case PlasmaProtocol.CmdFontSelect:
case PlasmaProtocol.CmdAttributes: case PlasmaProtocol.CmdAttributes:
case PlasmaProtocol.CmdSetRow:
case PlasmaProtocol.CmdSetColumn:
case PlasmaProtocol.CmdDrawPage:
case PlasmaProtocol.CmdDisplayPage:
_pendingCommand = b; _pendingCommand = b;
_state = State.Operand; _state = State.Operand;
break; break;
@@ -282,50 +385,58 @@ public sealed class VPlasmaDevice
break; break;
case PlasmaProtocol.CmdFontSelect: case PlasmaProtocol.CmdFontSelect:
_font = operand == PlasmaProtocol.OperandDefault ? 0 : operand & 0x07; // Firmware: operands 07 select a real font (8 fonts); it range-
// The cursor keeps its cell coordinates but the grid changed size. // checks and ignores anything larger, so ESC K FF is a no-op.
_col = Math.Min(_col, Columns - 1); if (operand < PlasmaFonts.All.Length)
_row = Math.Min(_row, Rows - 1); {
_dirty = true; _font = operand;
Log($"Font {_font}: {Columns}×{Rows} cells (ESC K {operand:X2})"); _face = PlasmaFonts.All[operand];
_x = Math.Min(_x, LogicalW - 1);
_y = Math.Min(_y, LogicalH - 1);
_dirty = true;
Log($"Font {_font}: {_face.Width}×{_face.Height} (ESC K {operand:X2})");
}
else
{
Log($"Font select ignored (ESC K {operand:X2} out of range)");
}
break; break;
case PlasmaProtocol.CmdAttributes: case PlasmaProtocol.CmdAttributes:
_attributes = DecodeAttributes(operand); // Firmware stores the low 4 bits directly as flags.
_attributes = (PlasmaAttributes)(operand & 0x0F);
Log($"Attributes {(_attributes == PlasmaAttributes.None ? "default" : _attributes.ToString())} (ESC H {operand:X2})"); Log($"Attributes {(_attributes == PlasmaAttributes.None ? "default" : _attributes.ToString())} (ESC H {operand:X2})");
break; break;
case PlasmaProtocol.CmdSetRow:
// ESC Q: set cursor row Y in logical pixels.
if (operand < LogicalH)
{
_y = operand;
_dirty = true;
Log($"Cursor row Y={operand} (ESC Q {operand:X2})");
}
break;
case PlasmaProtocol.CmdSetColumn:
// ESC R: set cursor column X in logical pixels.
if (operand < LogicalW)
{
_x = operand;
_dirty = true;
Log($"Cursor col X={operand} (ESC R {operand:X2})");
}
break;
case PlasmaProtocol.CmdDrawPage:
case PlasmaProtocol.CmdDisplayPage:
// Page select: consumed but not acted on (single-page model).
Log($"Page select (single-page: ignored) (ESC {(char)command} {operand:X2})");
break;
} }
_graphicsLogArmed = true; _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) ------------------------------------------- // ---- graphics writes (ESC P) -------------------------------------------
private void BeginGraphicsData() private void BeginGraphicsData()
@@ -353,18 +464,15 @@ public sealed class VPlasmaDevice
int y = _header[1] + rowOfBlock; int y = _header[1] + rowOfBlock;
int xByte = _header[2] + byteOfRow; 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; Plot maps them through the orientation.
// MSB is the leftmost pixel (L4PLASMA.CPP packs 0x80 first). int baseX = xByte * 8;
// Graphics dots are plain full intensity: overwriting text clears for (int bit = 0; bit < 8; ++bit)
// its half/flash flags, like repainting the glass. Plot(baseX + bit, y, (b & (0x80 >> bit)) != 0 ? PixelLit : (byte)0);
int offset = y * Width + xByte * 8; _dirty = true;
for (int bit = 0; bit < 8; ++bit) // Count only rows that land on the glass (a fully-clipped row is a no-op).
_pixels[offset + bit] = (b & (0x80 >> bit)) != 0 ? PixelLit : (byte)0; if (byteOfRow == w - 1 && (uint)y < (uint)LogicalH)
_dirty = true; _graphicsRows++;
if (byteOfRow == w - 1)
_graphicsRows++;
}
if (++_dataIndex >= _dataLength) if (++_dataIndex >= _dataLength)
_state = State.Text; _state = State.Text;
@@ -374,34 +482,28 @@ public sealed class VPlasmaDevice
private void DrawChar(byte code) private void DrawChar(byte code)
{ {
int scale = FontScale; // The firmware ignores a character outside the current font's range.
int cellW = 6 * scale, cellH = 8 * scale; if (!_face.Has(code))
int ox = _col * cellW, oy = _row * cellH; return;
Span<byte> columns = stackalloc byte[PlasmaFont.GlyphWidth];
PlasmaFont.GetColumns(code, columns);
int w = _face.Width, h = _face.Height;
bool reverse = (_attributes & PlasmaAttributes.Reverse) != 0; bool reverse = (_attributes & PlasmaAttributes.Reverse) != 0;
bool underline = (_attributes & PlasmaAttributes.Underline) != 0; bool underline = (_attributes & PlasmaAttributes.Underline) != 0;
byte litFlags = PixelLit; byte litFlags = PixelLit;
if ((_attributes & PlasmaAttributes.HalfIntensity) != 0) litFlags |= PixelHalf; if ((_attributes & PlasmaAttributes.HalfIntensity) != 0) litFlags |= PixelHalf;
if ((_attributes & PlasmaAttributes.Flash) != 0) litFlags |= PixelFlash; if ((_attributes & PlasmaAttributes.Flash) != 0) litFlags |= PixelFlash;
for (int cy = 0; cy < cellH; ++cy) for (int row = 0; row < h; ++row)
{ {
int glyphRow = cy / scale; // 0..7; row 7 is the gap/underline row ushort bits = _face.Row(code, row); // bit 15 = leftmost pixel
int rowOffset = (oy + cy) * Width + ox; for (int col = 0; col < w; ++col)
for (int cx = 0; cx < cellW; ++cx)
{ {
int glyphCol = cx / scale; // 0..5; column 5 is the gap column bool on = (bits & (0x8000 >> col)) != 0;
bool on = glyphCol < PlasmaFont.GlyphWidth if (underline && row == h - 1)
&& glyphRow < PlasmaFont.GlyphHeight
&& (columns[glyphCol] >> glyphRow & 1) != 0;
if (underline && glyphRow == 7)
on = true; on = true;
if (reverse) if (reverse)
on = !on; on = !on;
_pixels[rowOffset + cx] = on ? litFlags : (byte)0; Plot(_x + col, _y + row, on ? litFlags : (byte)0);
} }
} }
@@ -410,15 +512,21 @@ public sealed class VPlasmaDevice
AdvanceCursor(); AdvanceCursor();
} }
/// <summary>Advance the cursor one cell, wrapping at the right/bottom edges.</summary>
private void AdvanceCursor() private void AdvanceCursor()
{ {
if (++_col >= Columns) _x += _face.Width;
{ if (_x > LogicalW - _face.Width)
_col = 0; NextLine();
// No scroll on these panels: writing past the last row wraps to the top. }
if (++_row >= Rows)
_row = 0; private void NextLine()
} {
_x = 0;
_y += _face.Height;
// No scroll on these panels: past the last line wraps to the top.
if (_y > LogicalH - _face.Height)
_y = 0;
} }
// ---- event plumbing -------------------------------------------------------- // ---- event plumbing --------------------------------------------------------
@@ -0,0 +1,240 @@
using System.IO.Pipes;
namespace VPlasma.Core.Device;
/// <summary>
/// Serves a <see cref="VPlasmaDevice"/> over the named pipe that the
/// DOSBox-X fork's <c>serial2=namedpipe pipe:vplasma</c> backend connects
/// to — the com0com-free transport. We are the pipe <em>server</em> (the
/// display is always present); DOSBox-X is the client and background-retries
/// while the pipe is missing, so this service listens for the whole app
/// lifetime alongside the COM path.
///
/// <para>Framing is the contract shared with the fork (see VRio.Core's
/// <c>PipeFraming</c>, and <c>serialnamedpipe.h</c> on the fork side):
/// <c>0x00 len bytes</c> carries serial data, <c>0x01 bits</c> carries the
/// sender's DTR/RTS. Like the serial twin, the plasma is a pure listener —
/// we send one lines frame on connect (DTR+RTS high, "display present") and
/// then only ever read. The game may toggle its own lines around writes
/// (DOS console redirection flips RTS per byte); the display doesn't care,
/// so inbound lines frames are consumed silently.</para>
/// </summary>
public sealed class VPlasmaPipeService : IDisposable
{
/// <summary>The contract pipe name: DOSBox-X connects to <c>\\.\pipe\vplasma</c>.</summary>
public const string DefaultPipeName = "vplasma";
// Frame types and line bits, per the shared contract.
private const byte DataType = 0x00;
private const byte LinesType = 0x01;
private const byte LinesPresent = 0x03; // DTR + RTS
private readonly VPlasmaDevice _device;
private readonly string _pipeName;
private NamedPipeServerStream? _pipe;
private Thread? _server;
private volatile bool _running;
private volatile bool _clientConnected;
public VPlasmaPipeService(VPlasmaDevice device, string pipeName = DefaultPipeName)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
if (string.IsNullOrWhiteSpace(pipeName))
throw new ArgumentException("Pipe name is required.", nameof(pipeName));
_pipeName = pipeName;
}
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
public string PipeName => _pipeName;
/// <summary>True while the server is up (whether or not a client is connected).</summary>
public bool IsListening => _running;
/// <summary>True while a client is connected.</summary>
public bool IsClientConnected => _clientConnected;
/// <summary>A client connected (true) or went away (false).</summary>
public event Action<bool>? ClientChanged;
/// <summary>Pipe-level log lines (listen/connect/errors).</summary>
public event Action<string>? Logged;
/// <summary>Start listening (idempotent). Clients may come and go forever.</summary>
public void Start()
{
if (_running)
return;
_running = true;
_server = new Thread(ServerLoop) { IsBackground = true, Name = $"vPLASMA pipe server ({_pipeName})" };
_server.Start();
Logged?.Invoke($@"Listening on \\.\pipe\{_pipeName} — DOSBox-X connects when it boots");
}
/// <summary>Stop listening and drop any client (idempotent).</summary>
public void Stop()
{
if (!_running)
return;
_running = false;
NamedPipeServerStream? pipe = _pipe;
_pipe = null;
try { pipe?.Dispose(); }
catch (IOException) { }
// A WaitForConnection pending on the disposed stream can survive the
// Dispose on net48; a throwaway client connect releases it either way.
try
{
using var poke = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out);
poke.Connect(100);
}
catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) { }
_server?.Join(1000);
_server = null;
_clientConnected = false;
Logged?.Invoke("Pipe server stopped");
}
private void ServerLoop()
{
bool busyLogged = false; // log the name collision once, not per retry
while (_running)
{
NamedPipeServerStream pipe;
try
{
pipe = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Name already served — e.g. vRIO's built-in glass and the
// standalone vPLASMA both running. Whoever loses just waits.
if (!busyLogged)
{
busyLogged = true;
Logged?.Invoke($@"\\.\pipe\{_pipeName} is busy ({ex.Message.TrimEnd('.')}) — retrying until it frees up");
}
for (int i = 0; i < 20 && _running; i++)
Thread.Sleep(100);
continue;
}
busyLogged = false;
_pipe = pipe;
try
{
pipe.WaitForConnection();
if (_running)
{
// Our lines frame goes first: display present.
pipe.Write(new byte[] { LinesType, LinesPresent }, 0, 2);
pipe.Flush();
_clientConnected = true;
Logged?.Invoke("Pipe client connected — sent lines DTR+RTS (display present)");
ClientChanged?.Invoke(true);
ReadUntilDisconnect(pipe);
}
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
{
// Disposed by Stop(), or a client vanished mid-connect.
}
bool wasConnected = _clientConnected;
_clientConnected = false;
_pipe = null;
try { pipe.Dispose(); }
catch (IOException) { }
if (wasConnected && _running)
{
Logged?.Invoke("Pipe client disconnected");
ClientChanged?.Invoke(false);
}
}
}
private void ReadUntilDisconnect(NamedPipeServerStream pipe)
{
// Inline deframer (the listener-only subset of VRio.Core's
// PipeFrameDecoder): data payloads go to the device, lines frames are
// consumed silently, anything else is a protocol bug — log and drop
// the connection rather than resync.
const int StateType = 0, StateLength = 1, StatePayload = 2, StateLines = 3;
int state = StateType, remaining = 0;
var buffer = new byte[512];
var payload = new byte[byte.MaxValue];
int fill = 0;
while (_running)
{
int n;
try
{
n = pipe.Read(buffer, 0, buffer.Length);
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or OperationCanceledException)
{
if (_running)
Logged?.Invoke($"Pipe error: {ex.Message}");
return;
}
if (n == 0)
return; // client closed its end
for (int i = 0; i < n; i++)
{
byte b = buffer[i];
switch (state)
{
case StateType when b == DataType:
state = StateLength;
break;
case StateType when b == LinesType:
state = StateLines;
break;
case StateType:
Logged?.Invoke($"Pipe protocol violation: unknown frame type 0x{b:X2} — dropping the connection");
return;
case StateLength when b == 0:
Logged?.Invoke("Pipe protocol violation: zero-length data frame — dropping the connection");
return;
case StateLength:
remaining = b;
fill = 0;
state = StatePayload;
break;
case StatePayload:
payload[fill++] = b;
if (fill == remaining)
{
state = StateType;
_device.OnReceived(payload, remaining);
}
break;
case StateLines:
state = StateType; // the display ignores host line state
break;
}
}
}
}
public void Dispose() => Stop();
}
@@ -27,6 +27,14 @@ public sealed class VPlasmaSerialService : IDisposable
_device = device ?? throw new ArgumentNullException(nameof(device)); _device = device ?? throw new ArgumentNullException(nameof(device));
} }
/// <summary>
/// Bit rate to open the port at (default 9600). On the real display this is
/// the baud the two JP1 jumpers select (4800/9600/19.2K/38.4K); over a
/// virtual null-modem it's cosmetic, but the standalone app exposes the
/// jumpers so it's honored on the next open. Set before <see cref="Open"/>.
/// </summary>
public int Baud { get; set; } = PlasmaProtocol.BaudRate;
/// <summary>True while a COM port is open.</summary> /// <summary>True while a COM port is open.</summary>
public bool IsOpen => _port?.IsOpen == true; public bool IsOpen => _port?.IsOpen == true;
@@ -47,7 +55,7 @@ public sealed class VPlasmaSerialService : IDisposable
Close(); Close();
var port = new SerialPort(portName, PlasmaProtocol.BaudRate, Parity.None, 8, StopBits.One) var port = new SerialPort(portName, Baud, Parity.None, 8, StopBits.One)
{ {
Handshake = Handshake.None, Handshake = Handshake.None,
// Finite read timeout so the reader thread can notice shutdown. // Finite read timeout so the reader thread can notice shutdown.
@@ -65,7 +73,7 @@ public sealed class VPlasmaSerialService : IDisposable
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "vPLASMA serial reader" }; _reader = new Thread(ReadLoop) { IsBackground = true, Name = "vPLASMA serial reader" };
_reader.Start(); _reader.Start();
Logged?.Invoke($"Opened {portName} @ {PlasmaProtocol.BaudRate} 8N1 — listening for the host"); Logged?.Invoke($"Opened {portName} @ {Baud} 8N1 — listening for the host");
ConnectionChanged?.Invoke(true); ConnectionChanged?.Invoke(true);
} }
@@ -58,6 +58,17 @@ public static class PlasmaProtocol
public const byte CmdFontSelect = (byte)'K'; public const byte CmdFontSelect = (byte)'K';
public const byte CmdHomeCursor = (byte)'L'; public const byte CmdHomeCursor = (byte)'L';
public const byte CmdGraphicsWrite = (byte)'P'; public const byte CmdGraphicsWrite = (byte)'P';
// Cursor positioning, confirmed from the firmware dump (FIRMWARE.md): the
// cursor is a pixel position, ESC Q sets its row (Y, 0-31) and ESC R its
// column (X, 0-127) — the exact range checks the firmware enforces.
public const byte CmdSetRow = (byte)'Q';
public const byte CmdSetColumn = (byte)'R';
// Page select (the firmware's 10 double-buffered screens): ESC I picks the
// draw page, ESC i the displayed page. vPLASMA models a single page, so
// these are consumed (1 operand each) but not acted on — enough to replay
// the built-in demo, which uses them heavily.
public const byte CmdDrawPage = (byte)'I';
public const byte CmdDisplayPage = (byte)'i';
/// <summary>Operand meaning "restore the default" for ESC K / ESC H.</summary> /// <summary>Operand meaning "restore the default" for ESC K / ESC H.</summary>
public const byte OperandDefault = 0xFF; public const byte OperandDefault = 0xFF;
+96 -75
View File
@@ -10,34 +10,37 @@ 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 ports, device settings, and a live wire log. The panel's /// right — connection rows, device settings, and a live wire log. The
/// encoder strip also hosts the built-in plasma glass (the vPLASMA display /// panel's encoder strip also hosts the built-in plasma glass (the vPLASMA
/// emulator) on its own COM port. At startup the usual ports /// display emulator) on its own connection row. Each row's picker offers
/// (<see cref="PreferredPort"/>, <see cref="PreferredPlasmaPort"/>) are /// the device's named-pipe endpoint (the DOSBox-X fork's namedpipe backend
/// opened automatically when available; otherwise open them by hand. Point /// — the com0com-free path) alongside the machine's COM ports; nothing
/// RIOJoy at the other end of the null-modem pair, and every click here /// opens automatically — pick an endpoint and Open. Point RIOJoy at the
/// arrives at RIOJoy exactly as if the physical cockpit sent it; point the /// other end of a null-modem pair and every click here arrives exactly as
/// game's COM2 passthrough at the plasma pair and the glass lights up. /// if the physical cockpit sent it; point the game at
/// <see cref="RioPipeEndpoint"/>/<see cref="PlasmaPipeEndpoint"/> (or the
/// COM2 passthrough at the plasma pair) and the glass lights up.
/// </summary> /// </summary>
internal sealed class MainForm : Form internal sealed class MainForm : Form
{ {
/// <summary> /// <summary>
/// vRIO's usual port: the device end of the COM1⇄COM11 com0com pair. /// The RIO's pipe endpoint as listed in the picker. Matches the
/// Auto-opened at startup when present and free; the picker still allows /// DOSBox-X conf syntax: <c>serial1=namedpipe pipe:vrio</c>.
/// any other port.
/// </summary> /// </summary>
private const string PreferredPort = "COM11"; private static readonly string RioPipeEndpoint = $"pipe:{VRioPipeService.DefaultPipeName}";
/// <summary> /// <summary>
/// The built-in plasma's usual port: the device end of the COM2⇄COM12 /// The plasma's pipe endpoint as listed in the picker
/// com0com pair (the game's COM2 passthrough opens the other end). /// (<c>serial2=namedpipe pipe:vplasma</c>).
/// </summary> /// </summary>
private const string PreferredPlasmaPort = "COM12"; private static readonly string PlasmaPipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
private readonly VRioDevice _device = new(); private readonly VRioDevice _device = new();
private readonly VRioSerialService _service; private readonly VRioSerialService _service;
private readonly VRioPipeService _pipeService;
private readonly VPlasmaDevice _plasmaDevice = new(); private readonly VPlasmaDevice _plasmaDevice = new();
private readonly VPlasmaSerialService _plasmaService; private readonly VPlasmaSerialService _plasmaService;
private readonly VPlasmaPipeService _plasmaPipeService;
private readonly PanelCanvas _canvas = new(); private readonly PanelCanvas _canvas = new();
private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch) private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch)
{ {
@@ -190,6 +193,10 @@ internal sealed class MainForm : Form
_service = new VRioSerialService(_device); _service = new VRioSerialService(_device);
_plasmaService = new VPlasmaSerialService(_plasmaDevice); _plasmaService = new VPlasmaSerialService(_plasmaDevice);
// The named-pipe endpoints for DOSBox-X's namedpipe serial backend,
// offered in the connection pickers alongside the COM ports.
_pipeService = new VRioPipeService(_device);
_plasmaPipeService = new VPlasmaPipeService(_plasmaDevice);
_router = new InputRouter(_device); _router = new InputRouter(_device);
_lampMirror = new KeyboardLampMirror(_device); _lampMirror = new KeyboardLampMirror(_device);
@@ -221,16 +228,20 @@ internal sealed class MainForm : Form
_device.Logged += line => RunOnUi(() => PrependLog(line)); _device.Logged += line => RunOnUi(() => PrependLog(line));
_service.Logged += line => RunOnUi(() => PrependLog(line)); _service.Logged += line => RunOnUi(() => PrependLog(line));
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line)); _lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open)); _service.ConnectionChanged += _ => RunOnUi(UpdateRioEndpointUi);
_service.HostHandshake += high => RunOnUi(() => _service.HostHandshake += high => RunOnUi(() =>
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR")); PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
_pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}"));
_pipeService.HostHandshake += high => RunOnUi(() =>
PrependLog(high ? "Pipe host raised DTR (board-reset handshake)" : "Pipe host dropped DTR"));
_plasmaPipeService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA pipe: {line}"));
// Built-in plasma glass: a pure listener on its own port, same gestures // Built-in plasma glass: a pure listener on its own port, same gestures
// as the standalone vPLASMA — double-click cycles the self-test pages, // as the standalone vPLASMA — double-click cycles the self-test pages,
// right-click resets the display to its power-on state. // right-click resets the display to its power-on state.
_plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice)); _plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice));
_plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}")); _plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}"));
_plasmaService.ConnectionChanged += open => RunOnUi(() => OnPlasmaConnectionChanged(open)); _plasmaService.ConnectionChanged += _ => RunOnUi(UpdatePlasmaEndpointUi);
_plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest(); _plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest();
_plasmaCanvas.MouseClick += (_, e) => _plasmaCanvas.MouseClick += (_, e) =>
{ {
@@ -305,14 +316,16 @@ internal sealed class MainForm : Form
_rawKeyboard.Dispose(); _rawKeyboard.Dispose();
_service.Dispose(); _service.Dispose();
_plasmaService.Dispose(); _plasmaService.Dispose();
_pipeService.Dispose();
_plasmaPipeService.Dispose();
}; };
RefreshPorts(); RefreshPorts();
UpdateStatus(); UpdateStatus();
PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair."); PrependLog($"vRIO ready. Pick an endpoint per device — {RioPipeEndpoint} / {PlasmaPipeEndpoint} " +
"for DOSBox-X, or a COM port for RIOJoy — and Open.");
LoadBindings(); LoadBindings();
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame _plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
AutoOpenPreferredPorts();
} }
private Panel BuildControlStrip() private Panel BuildControlStrip()
@@ -359,111 +372,116 @@ internal sealed class MainForm : Form
return panel; return panel;
} }
// ---- Port handling ----------------------------------------------------- // ---- Endpoint handling ---------------------------------------------------
// Each device row serves one endpoint at a time — the RIO is a
// single-host device, so its pipe server and COM port never run together.
private bool RioOpen => _service.IsOpen || _pipeService.IsListening;
private bool PlasmaOpen => _plasmaService.IsOpen || _plasmaPipeService.IsListening;
private void RefreshPorts() private void RefreshPorts()
{ {
string[] names = SerialPort.GetPortNames().Distinct() string[] names = SerialPort.GetPortNames().Distinct()
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray(); .OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray();
// An open service's picker is disabled and shows the served port; // An open row's picker is disabled and shows the served endpoint;
// leave it alone so the display can't drift from reality. // leave it alone so the display can't drift from reality.
if (!_service.IsOpen) if (!RioOpen)
RefreshPicker(_portBox, names); RefreshPicker(_portBox, RioPipeEndpoint, names);
if (!_plasmaService.IsOpen) if (!PlasmaOpen)
RefreshPicker(_plasmaPortBox, names); RefreshPicker(_plasmaPortBox, PlasmaPipeEndpoint, names);
} }
private static void RefreshPicker(ComboBox box, string[] portNames) private static void RefreshPicker(ComboBox box, string pipeEndpoint, string[] portNames)
{ {
string? current = box.SelectedItem?.ToString(); string? current = box.SelectedItem?.ToString();
box.Items.Clear(); box.Items.Clear();
box.Items.Add(pipeEndpoint); // always present — a pipe server needs no hardware
foreach (string name in portNames) foreach (string name in portNames)
box.Items.Add(name); box.Items.Add(name);
if (box.Items.Count == 0)
return;
int idx = current is null ? -1 : box.Items.IndexOf(current); int idx = current is null ? -1 : box.Items.IndexOf(current);
box.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 (RioOpen)
_service.Close(); {
_service.Close(); // idempotent —
_pipeService.Stop(); // whichever was serving closes
UpdateRioEndpointUi();
}
else else
OpenFromPicker(_portBox, _service.Open); {
OpenFromPicker(_portBox, OpenRioEndpoint);
}
} }
private void TogglePlasmaOpen() private void TogglePlasmaOpen()
{ {
if (_plasmaService.IsOpen) if (PlasmaOpen)
{
_plasmaService.Close(); _plasmaService.Close();
_plasmaPipeService.Stop();
UpdatePlasmaEndpointUi();
}
else else
OpenFromPicker(_plasmaPortBox, _plasmaService.Open); {
OpenFromPicker(_plasmaPortBox, OpenPlasmaEndpoint);
}
}
private void OpenRioEndpoint(string endpoint)
{
if (endpoint == RioPipeEndpoint)
_pipeService.Start();
else
_service.Open(endpoint);
UpdateRioEndpointUi();
}
private void OpenPlasmaEndpoint(string endpoint)
{
if (endpoint == PlasmaPipeEndpoint)
_plasmaPipeService.Start();
else
_plasmaService.Open(endpoint);
UpdatePlasmaEndpointUi();
} }
private void OpenFromPicker(ComboBox box, Action<string> open) private void OpenFromPicker(ComboBox box, Action<string> open)
{ {
if (box.SelectedItem?.ToString() is not { } port) if (box.SelectedItem?.ToString() is not { } endpoint)
{ {
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " + MessageBox.Show(this, "No endpoint selected. Pick the device's named pipe (for " +
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information); "DOSBox-X's namedpipe backend) or a COM port (for RIOJoy through a com0com " +
"null-modem pair).", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
return; return;
} }
try try
{ {
open(port); open(endpoint);
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show(this, $"Could not open {port}:\n{ex.Message}", "vRIO", MessageBox.Show(this, $"Could not open {endpoint}:\n{ex.Message}", "vRIO",
MessageBoxButtons.OK, MessageBoxIcon.Warning); MessageBoxButtons.OK, MessageBoxIcon.Warning);
} }
} }
private void OnConnectionChanged(bool open) private void UpdateRioEndpointUi()
{ {
bool open = RioOpen;
_openClose.Text = open ? "Close" : "Open"; _openClose.Text = open ? "Close" : "Open";
_portBox.Enabled = !open; _portBox.Enabled = !open;
_rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray; _rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
UpdateStatus(); UpdateStatus();
} }
private void OnPlasmaConnectionChanged(bool open) private void UpdatePlasmaEndpointUi()
{ {
bool open = PlasmaOpen;
_plasmaOpenClose.Text = open ? "Close" : "Open"; _plasmaOpenClose.Text = open ? "Close" : "Open";
_plasmaPortBox.Enabled = !open; _plasmaPortBox.Enabled = !open;
_plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray; _plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
@@ -690,12 +708,15 @@ internal sealed class MainForm : Form
$"Analog polls dropped: {dropped}\n" + $"Analog polls dropped: {dropped}\n" +
$"Bad checksums: {bad}"; $"Bad checksums: {bad}";
_canvas.StatusText = _service.IsOpen string? endpoint = _service.IsOpen ? _service.PortName
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" + : _pipeService.IsListening ? RioPipeEndpoint
: null;
_canvas.StatusText = endpoint is not null
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {endpoint}\n" +
(wedged (wedged
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **" ? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)")) : $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
: "PORT CLOSED\nOpen a COM port to go live."; : "OFFLINE\nOpen an endpoint (named pipe or COM port) to go live.";
} }
private void PrependLog(string line) private void PrependLog(string line)
+134
View File
@@ -0,0 +1,134 @@
namespace VRio.Core.Device;
/// <summary>
/// The framing shared with the DOSBox-X fork's <c>namedpipe</c> serial
/// backend (its <c>serialnamedpipe.h</c> header comment is the contract's
/// source of truth on that side). A pipe is a plain byte stream, so serial
/// data and modem control lines are multiplexed as typed frames:
///
/// <code>
/// 0x00 &lt;len:u8&gt; &lt;len bytes&gt; serial data, len ≥ 1 (batching allowed)
/// 0x01 &lt;lines:u8&gt; the sender's OWN output lines (bit0 DTR,
/// bit1 RTS); the receiver applies the
/// null-modem cross: peer DTR → local DSR,
/// peer RTS → local CTS
/// </code>
///
/// Each side sends one lines frame immediately on connect; until it arrives
/// the peer's lines are assumed low, and a disconnect drops them low again.
/// Any other frame type is a protocol bug, not line noise — pipes don't drop
/// bytes — so the receiver logs it and drops the connection instead of
/// trying to resync.
/// </summary>
public static class PipeFraming
{
public const byte DataType = 0x00;
public const byte LinesType = 0x01;
/// <summary>Lines-frame bit: the sender's DTR output.</summary>
public const byte LineDtr = 0x01;
/// <summary>Lines-frame bit: the sender's RTS output.</summary>
public const byte LineRts = 0x02;
/// <summary>Both outputs asserted — what a present, powered device reports.</summary>
public const byte LinesPresent = LineDtr | LineRts;
/// <summary>Build a lines frame carrying <paramref name="lines"/>.</summary>
public static byte[] EncodeLines(byte lines) => new[] { LinesType, lines };
}
/// <summary>
/// Incremental decoder for <see cref="PipeFraming"/>: feed it raw pipe reads,
/// get one <see cref="Data"/> event per complete data frame and one
/// <see cref="Lines"/> event per lines frame. Frames may split across reads
/// at any byte boundary. A malformed stream (unknown type, zero-length data
/// frame) poisons the decoder: <see cref="Feed"/> returns false and
/// <see cref="Violation"/> says why — drop the connection and
/// <see cref="Reset"/> before serving the next one.
/// </summary>
public sealed class PipeFrameDecoder
{
private enum State { Type, Length, Payload, Lines }
private readonly byte[] _payload = new byte[byte.MaxValue];
private State _state;
private int _fill, _length;
/// <summary>
/// A complete data frame's payload as (buffer, count). The buffer is
/// reused across frames — consume it synchronously.
/// </summary>
public event Action<byte[], int>? Data;
/// <summary>A lines frame's bits (see <see cref="PipeFraming.LineDtr"/>).</summary>
public event Action<byte>? Lines;
/// <summary>Why the stream was rejected, or null while it is healthy.</summary>
public string? Violation { get; private set; }
/// <summary>Forget any partial frame and clear a violation (new connection).</summary>
public void Reset()
{
_state = State.Type;
_fill = _length = 0;
Violation = null;
}
/// <summary>
/// Consume <paramref name="count"/> received bytes from
/// <paramref name="buffer"/>. Returns false when the stream violates the
/// framing contract (see <see cref="Violation"/>); a poisoned decoder
/// keeps returning false until <see cref="Reset"/>.
/// </summary>
public bool Feed(byte[] buffer, int count)
{
if (Violation is not null)
return false;
for (int i = 0; i < count; i++)
{
byte b = buffer[i];
switch (_state)
{
case State.Type when b == PipeFraming.DataType:
_state = State.Length;
break;
case State.Type when b == PipeFraming.LinesType:
_state = State.Lines;
break;
case State.Type:
Violation = $"unknown frame type 0x{b:X2}";
return false;
case State.Length when b == 0:
Violation = "zero-length data frame";
return false;
case State.Length:
_length = b;
_fill = 0;
_state = State.Payload;
break;
case State.Payload:
_payload[_fill++] = b;
if (_fill == _length)
{
_state = State.Type;
Data?.Invoke(_payload, _length);
}
break;
case State.Lines:
_state = State.Type;
Lines?.Invoke(b);
break;
}
}
return true;
}
}
+392
View File
@@ -0,0 +1,392 @@
using System.Diagnostics;
using System.IO.Pipes;
using System.Runtime.InteropServices;
namespace VRio.Core.Device;
/// <summary>
/// Serves a <see cref="VRioDevice"/> over the named pipe that the DOSBox-X
/// fork's <c>serial1=namedpipe pipe:vrio</c> backend connects to — the
/// com0com-free transport. vRIO is the pipe <em>server</em> (the device is
/// always present); DOSBox-X is the client and background-retries while the
/// pipe is missing, so this service listens for the whole app lifetime
/// alongside the COM path at no cost. An unconnected pipe is an unplugged
/// cable: the device keeps running, transmit bytes fall on the floor.
///
/// <para>Framing per <see cref="PipeFraming"/>. On connect we immediately
/// send our lines frame (DTR+RTS high — "board present", the same lines the
/// serial path asserts via DtrEnable/RtsEnable); the peer's DTR edges surface
/// through <see cref="HostHandshake"/> exactly like the DSR blips through a
/// null modem, and a disconnect drops all lines low. The game hardware-resets
/// the RIO by pulsing DTR, and the in-band lines frames keep that edge's
/// position in the byte stream — the reason the contract multiplexes control
/// onto the data pipe instead of using a second one.</para>
///
/// <para>Outbound bytes are paced one per 10-bit frame time exactly like
/// <see cref="VRioSerialService"/> (see its remarks for the full rationale).
/// With the 9600-baud wire gone, this pacer is the only thing standing
/// between the host and an impossible burst, so each paced byte travels as
/// its own 3-byte data frame and the inter-byte timing survives the pipe.</para>
/// </summary>
public sealed class VRioPipeService : IDisposable
{
/// <summary>The contract pipe name: DOSBox-X connects to <c>\\.\pipe\vrio</c>.</summary>
public const string DefaultPipeName = "vrio";
// Pacing mirrors VRioSerialService: one byte per 10-bit frame at 9600
// baud; sleep until ~1.8 ms from the slot, then spin the remainder.
private static readonly long BytePeriodTicks = Stopwatch.Frequency * 10 / VRioSerialService.BaudRate;
private static readonly long SpinThresholdTicks = Stopwatch.Frequency * 18 / 10_000;
private readonly VRioDevice _device;
private readonly string _pipeName;
private readonly object _txGate = new();
private readonly Queue<byte> _txQueue = new();
private readonly PipeFrameDecoder _decoder = new();
private NamedPipeServerStream? _pipe;
private Thread? _server;
private Thread? _writer;
private volatile bool _running;
private volatile bool _clientConnected;
private bool _peerDtr, _peerRts;
private bool _timerResolutionRaised;
public VRioPipeService(VRioDevice device, string pipeName = DefaultPipeName)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
if (string.IsNullOrWhiteSpace(pipeName))
throw new ArgumentException("Pipe name is required.", nameof(pipeName));
_pipeName = pipeName;
_device.Transmit += Write;
_decoder.Data += (buffer, count) => _device.OnReceived(buffer, count);
_decoder.Lines += OnPeerLines;
}
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
public string PipeName => _pipeName;
/// <summary>True while the server is up (whether or not a client is connected).</summary>
public bool IsListening => _running;
/// <summary>True while a client is connected.</summary>
public bool IsClientConnected => _clientConnected;
/// <summary>A client connected (true) or went away (false).</summary>
public event Action<bool>? ClientChanged;
/// <summary>
/// The host's DTR line changed (the game pulses it to hardware-reset the
/// board). Same semantics as <see cref="VRioSerialService.HostHandshake"/>:
/// the argument is the new line state as seen on our DSR pin.
/// </summary>
public event Action<bool>? HostHandshake;
/// <summary>Pipe-level log lines (listen/connect/errors).</summary>
public event Action<string>? Logged;
/// <summary>Start listening (idempotent). Clients may come and go forever.</summary>
public void Start()
{
if (_running)
return;
_running = true;
lock (_txGate) _txQueue.Clear();
_server = new Thread(ServerLoop) { IsBackground = true, Name = $"vRIO pipe server ({_pipeName})" };
_server.Start();
_writer = new Thread(WriteLoop) { IsBackground = true, Name = $"vRIO pipe writer ({_pipeName})" };
_writer.Start();
Logged?.Invoke($@"Listening on \\.\pipe\{_pipeName} (TX paced at the wire rate) — DOSBox-X connects when it boots");
}
/// <summary>Stop listening and drop any client (idempotent).</summary>
public void Stop()
{
if (!_running)
return;
_running = false;
lock (_txGate)
{
_txQueue.Clear();
Monitor.PulseAll(_txGate); // wake the writer so it can exit
}
NamedPipeServerStream? pipe = _pipe;
_pipe = null;
try { pipe?.Dispose(); }
catch (IOException) { }
// A WaitForConnection pending on the disposed stream can survive the
// Dispose on net48; a throwaway client connect releases it either way.
try
{
using var poke = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out);
poke.Connect(100);
}
catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) { }
_server?.Join(1000);
_server = null;
_writer?.Join(1000);
_writer = null;
if (_timerResolutionRaised)
{
timeEndPeriod(1);
_timerResolutionRaised = false;
}
_clientConnected = false;
Logged?.Invoke("Pipe server stopped");
}
private void ServerLoop()
{
bool busyLogged = false; // log the name collision once, not per retry
while (_running)
{
NamedPipeServerStream pipe;
try
{
pipe = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Name already served — most likely a second vRIO instance.
if (!busyLogged)
{
busyLogged = true;
Logged?.Invoke($@"\\.\pipe\{_pipeName} is busy ({ex.Message.TrimEnd('.')}) — retrying until it frees up");
}
for (int i = 0; i < 20 && _running; i++)
Thread.Sleep(100);
continue;
}
busyLogged = false;
_pipe = pipe;
try
{
pipe.WaitForConnection();
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
{
// Disposed by Stop(), or a client vanished mid-connect.
_pipe = null;
try { pipe.Dispose(); }
catch (IOException) { }
continue;
}
if (!_running || !OnClientConnected(pipe))
{
_pipe = null;
try { pipe.Dispose(); }
catch (IOException) { }
continue;
}
ReadUntilDisconnect(pipe);
OnClientDisconnected();
_pipe = null;
try { pipe.Dispose(); }
catch (IOException) { }
}
}
/// <summary>Per-connection setup; false if the client died before it finished.</summary>
private bool OnClientConnected(NamedPipeServerStream pipe)
{
_decoder.Reset();
_peerDtr = _peerRts = false;
lock (_txGate) _txQueue.Clear();
// Our lines frame goes first, before the writer can interleave data
// (it only writes once _clientConnected flips below): DTR+RTS high,
// board present — the peer maps them to its DSR/CTS.
try
{
byte[] lines = PipeFraming.EncodeLines(PipeFraming.LinesPresent);
pipe.Write(lines, 0, lines.Length);
pipe.Flush();
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
{
return false;
}
// 1 ms system timer resolution while a client is connected, so the
// pacer's Thread.Sleep(1) actually sleeps ~1 ms.
_timerResolutionRaised = timeBeginPeriod(1) == 0;
_clientConnected = true;
Logged?.Invoke("Pipe client connected — sent lines DTR+RTS (board present)");
ClientChanged?.Invoke(true);
return true;
}
private void OnClientDisconnected()
{
_clientConnected = false;
lock (_txGate) _txQueue.Clear();
if (_timerResolutionRaised)
{
timeEndPeriod(1);
_timerResolutionRaised = false;
}
// Contract: a disconnect drops all lines low on the surviving side.
if (_peerDtr)
{
_peerDtr = false;
HostHandshake?.Invoke(false);
}
_peerRts = false;
if (_running)
{
Logged?.Invoke("Pipe client disconnected");
ClientChanged?.Invoke(false);
}
}
private void ReadUntilDisconnect(NamedPipeServerStream pipe)
{
var buffer = new byte[512];
while (_running)
{
int n;
try
{
n = pipe.Read(buffer, 0, buffer.Length);
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or OperationCanceledException)
{
if (_running)
Logged?.Invoke($"Pipe error: {ex.Message}");
return;
}
if (n == 0)
return; // client closed its end
if (!_decoder.Feed(buffer, n))
{
Logged?.Invoke($"Pipe protocol violation: {_decoder.Violation} — dropping the connection");
return;
}
}
}
private void OnPeerLines(byte lines)
{
_peerRts = (lines & PipeFraming.LineRts) != 0; // tracked; nothing consumes CTS today
bool dtr = (lines & PipeFraming.LineDtr) != 0;
if (dtr == _peerDtr)
return;
_peerDtr = dtr;
HostHandshake?.Invoke(dtr);
}
// The device's Transmit handler: queue the frame for the paced writer so
// the caller (UI click, reader thread mid-reply) never blocks on the pipe.
private void Write(byte[] data)
{
if (!_running || !_clientConnected)
return; // no host on the pipe — a real UART shifts into an unterminated line
lock (_txGate)
{
foreach (byte b in data)
_txQueue.Enqueue(b);
Monitor.Pulse(_txGate);
}
}
private void WriteLoop()
{
// One data frame per paced byte, so the pipe carries the same
// inter-byte spacing a 9600-baud wire would.
var frame = new byte[] { PipeFraming.DataType, 1, 0 };
long slot = Stopwatch.GetTimestamp();
while (_running)
{
lock (_txGate)
{
while (_txQueue.Count == 0)
{
if (!_running)
return;
Monitor.Wait(_txGate, 200); // timed, so a missed pulse can't wedge shutdown
}
frame[2] = _txQueue.Dequeue();
}
// This byte's wire slot: one frame after the previous byte, or now
// if the line has been idle (no burst "catch-up" debt).
slot = Math.Max(slot + BytePeriodTicks, Stopwatch.GetTimestamp());
PaceUntil(slot);
NamedPipeServerStream? pipe = _pipe;
if (pipe is null || !_clientConnected)
continue; // client left while this byte waited its slot — it's stale, drop it
try
{
pipe.Write(frame, 0, frame.Length);
pipe.Flush();
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
{
// The client died mid-write; the backlog is already stale.
// Drop it and keep serving — the server loop notices the EOF
// and cycles back to WaitForConnection.
lock (_txGate) _txQueue.Clear();
continue;
}
// If the wait overshot its slot, pace the next byte from the
// actual emission instead — a stall must not cause a catch-up burst.
long now = Stopwatch.GetTimestamp();
if (now > slot)
slot = now;
}
}
private static void PaceUntil(long slotTicks)
{
while (true)
{
long remaining = slotTicks - Stopwatch.GetTimestamp();
if (remaining <= 0)
return;
if (remaining > SpinThresholdTicks)
Thread.Sleep(1);
else
Thread.SpinWait(64);
}
}
[DllImport("winmm.dll")]
private static extern uint timeBeginPeriod(uint uMilliseconds);
[DllImport("winmm.dll")]
private static extern uint timeEndPeriod(uint uMilliseconds);
public void Dispose()
{
_device.Transmit -= Write;
Stop();
}
}
+150 -59
View File
@@ -53,7 +53,6 @@ public class VPlasmaDeviceTests
{ {
byte[] wire = GraphicsRow(3, Enumerable.Repeat((byte)0xFF, 16).ToArray()); 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) for (int split = 1; split < wire.Length; ++split)
{ {
var device = new VPlasmaDevice(); var device = new VPlasmaDevice();
@@ -71,7 +70,6 @@ public class VPlasmaDeviceTests
public void GraphicsBlock_MultipleRowsAdvanceY() public void GraphicsBlock_MultipleRowsAdvanceY()
{ {
var device = new VPlasmaDevice(); 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); 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, 10));
@@ -98,10 +96,8 @@ public class VPlasmaDeviceTests
{ {
var device = new VPlasmaDevice(); var device = new VPlasmaDevice();
Feed(device, GraphicsRow(40, Enumerable.Repeat((byte)0xFF, 16).ToArray())); Feed(device, GraphicsRow(40, Enumerable.Repeat((byte)0xFF, 16).ToArray()));
Feed(device, (byte)'!'); // parser must be back in text mode Feed(device, (byte)'H'); // 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(1, device.TextCharsDrawn);
Assert.Equal(0, device.GraphicsRows); Assert.Equal(0, device.GraphicsRows);
} }
@@ -110,64 +106,83 @@ public class VPlasmaDeviceTests
public void GraphicsWrite_OverwritesTextAttributes() public void GraphicsWrite_OverwritesTextAttributes()
{ {
var device = new VPlasmaDevice(); var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'H', 4); // flashing text Feed(device, Esc, (byte)'H', 8); // flashing text
Feed(device, (byte)'H'); // glyph col 0 is a full bar at x=0 Feed(device, (byte)'H'); // font-0 'H' lights (0,0)
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelFlash, Pixel(device, 0, 0)); Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelFlash, Pixel(device, 0, 0));
Feed(device, GraphicsRow(0, 0x80)); // repaint that row from the wire Feed(device, GraphicsRow(0, 0x80)); // repaint that row from the wire
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0)); Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
} }
// ---- text mode --------------------------------------------------------- // ---- text mode (real font 0 = 6×8) -----------------------------------
[Fact] [Fact]
public void Text_DrawsGlyphAndAdvancesCursor() public void Text_DrawsRealGlyphAndAdvancesByFontWidth()
{ {
var device = new VPlasmaDevice(); var device = new VPlasmaDevice();
Feed(device, (byte)'H'); // 5×7 'H': column 0 = 0x7F (all seven rows) Feed(device, (byte)'H'); // font-0 'H': row0 = pixels at x0 and x4
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0)); Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 6)); Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 4, 0));
Assert.Equal(0, Pixel(device, 0, 7)); // gap row Assert.Equal(0, Pixel(device, 1, 0));
Assert.Equal(0, Pixel(device, 5, 0)); // gap column Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 3)); // crossbar row
Assert.Equal(new VPlasmaDevice.Point(1, 0), device.CursorCell); Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 4, 3));
Assert.Equal(0, Pixel(device, 0, 7)); // blank bottom row
Assert.Equal(6, device.CursorX); // advanced one 6-px cell
Assert.Equal(0, device.CursorY);
Assert.Equal(1, device.TextCharsDrawn); Assert.Equal(1, device.TextCharsDrawn);
} }
[Fact] [Fact]
public void ControlChars_MoveTheCursor() public void ControlChars_MoveCursorByPixels()
{ {
var device = new VPlasmaDevice(); var device = new VPlasmaDevice(); // font 0: 6 wide, 8 tall
Feed(device, 0x09, 0x09, 0x09); // HT ×3 Feed(device, 0x09, 0x09, 0x09); // HT ×3 → x = 18
Assert.Equal(new VPlasmaDevice.Point(3, 0), device.CursorCell); Assert.Equal(18, device.CursorX);
Feed(device, 0x08); // BS Feed(device, 0x08); // BS → x = 12
Assert.Equal(new VPlasmaDevice.Point(2, 0), device.CursorCell); Assert.Equal(12, device.CursorX);
Feed(device, 0x0A); // LF Feed(device, 0x0A); // LF → y = 8
Assert.Equal(new VPlasmaDevice.Point(2, 1), device.CursorCell); Assert.Equal(8, device.CursorY);
Assert.Equal(0, device.CursorX);
Feed(device, 0x0D); // CR Feed(device, (byte)'H', 0x0D); // draw + CR → x back to 0
Assert.Equal(new VPlasmaDevice.Point(0, 1), device.CursorCell); Assert.Equal(0, device.CursorX);
Feed(device, 0x0B); // VT Feed(device, 0x0B); // VT → y back to 0
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell); Assert.Equal(0, device.CursorY);
Feed(device, 0x0B); // VT off the top wraps to the bottom row
Assert.Equal(new VPlasmaDevice.Point(0, 3), device.CursorCell);
} }
[Fact] [Fact]
public void Text_WrapsAtRowAndScreenEnd() public void EscQ_EscR_SetPixelCursor()
{ {
var device = new VPlasmaDevice(); 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, Esc, (byte)'Q', 20); // set row Y=20
Feed(device, Esc, (byte)'R', 37); // set col X=37
Assert.Equal(37, device.CursorX);
Assert.Equal(20, device.CursorY);
Feed(device, Enumerable.Repeat((byte)'X', 21 * 3 - 1)); // exactly to the end Feed(device, Esc, (byte)'Q', 40); // out of range (>31): ignored
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell); // wrapped to top Assert.Equal(20, device.CursorY);
Feed(device, Esc, (byte)'R', 200); // out of range (>127): ignored
Assert.Equal(37, device.CursorX);
}
[Fact]
public void Text_WrapsAtRightAndBottom()
{
var device = new VPlasmaDevice(); // 21 cells of 6px per line, 4 lines
Feed(device, Enumerable.Repeat((byte)'X', 21)); // fills first line, wraps
Assert.Equal(0, device.CursorX);
Assert.Equal(8, device.CursorY);
Feed(device, Enumerable.Repeat((byte)'X', 21 * 3)); // to the end, wraps to top
Assert.Equal(0, device.CursorX);
Assert.Equal(0, device.CursorY);
} }
[Fact] [Fact]
@@ -178,7 +193,8 @@ public class VPlasmaDeviceTests
Feed(device, Esc, (byte)'@'); Feed(device, Esc, (byte)'@');
Assert.Equal(0, Pixel(device, 0, 0)); Assert.Equal(0, Pixel(device, 0, 0));
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell); Assert.Equal(0, device.CursorX);
Assert.Equal(0, device.CursorY);
Assert.Equal(0, device.Font); Assert.Equal(0, device.Font);
Assert.Equal(PlasmaAttributes.None, device.Attributes); Assert.Equal(PlasmaAttributes.None, device.Attributes);
} }
@@ -189,7 +205,8 @@ public class VPlasmaDeviceTests
var device = new VPlasmaDevice(); var device = new VPlasmaDevice();
Feed(device, (byte)'H', Esc, (byte)'L'); Feed(device, (byte)'H', Esc, (byte)'L');
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell); Assert.Equal(0, device.CursorX);
Assert.Equal(0, device.CursorY);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0)); // glyph survives Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0)); // glyph survives
} }
@@ -206,42 +223,42 @@ public class VPlasmaDeviceTests
} }
[Fact] [Fact]
public void EscK_SelectsFontGrids() public void EscK_SelectsRealFonts()
{ {
var device = new VPlasmaDevice(); var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'K', 4); // large: 12×16 cells Feed(device, Esc, (byte)'K', 4); // large font: 12×16
Assert.Equal(4, device.Font); Assert.Equal(4, device.Font);
Assert.Equal(12, device.CellWidth); Assert.Equal(12, device.FontWidth);
Assert.Equal(16, device.CellHeight); Assert.Equal(16, device.FontHeight);
Feed(device, (byte)'A'); // large glyph: 'A' col 1 (0x11) row 0 → 2×2 dots at (2..3, 0..1) Feed(device, Esc, (byte)'K', 0); // back to font 0: 6×8
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 2, 0)); Assert.Equal(0, device.Font);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 3, 1)); Assert.Equal(6, device.FontWidth);
Assert.Equal(8, device.FontHeight);
Feed(device, Esc, (byte)'K', 0xFF); // FF → default font 0
Feed(device, Esc, (byte)'K', 0xFF); // out of range: firmware ignores it
Assert.Equal(0, device.Font); Assert.Equal(0, device.Font);
Assert.Equal(6, device.CellWidth);
} }
[Fact] [Fact]
public void EscH_AppliesAttributes() public void EscH_AppliesAttributesAsLowFourBits()
{ {
var device = new VPlasmaDevice(); var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'H', 1, (byte)'H'); // half intensity Feed(device, Esc, (byte)'H', 1, (byte)'H'); // half intensity
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelHalf, Pixel(device, 0, 0)); Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelHalf, Pixel(device, 0, 0));
Feed(device, Esc, (byte)'H', 2, (byte)'H'); // underline: gap row lit Feed(device, Esc, (byte)'L', Esc, (byte)'@'); // clear
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 6, 7)); Feed(device, Esc, (byte)'H', 2, (byte)'H'); // underline: bottom row (y=7) lit
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 7));
Feed(device, Esc, (byte)'H', 3, (byte)' '); // reverse: a space renders solid Feed(device, Esc, (byte)'@');
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 12, 0)); Feed(device, Esc, (byte)'H', 4, (byte)' '); // reverse: a space renders solid
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 17, 7)); Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 5, 7));
Feed(device, Esc, (byte)'H', 0xFF, (byte)'H'); // defaults restored Feed(device, Esc, (byte)'H', 0); // defaults restored
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 18, 0));
Assert.Equal(0, Pixel(device, 18, 7));
Assert.Equal(PlasmaAttributes.None, device.Attributes); Assert.Equal(PlasmaAttributes.None, device.Attributes);
} }
@@ -251,7 +268,7 @@ public class VPlasmaDeviceTests
public void UnknownEscape_IsConsumedAndTextResumes() public void UnknownEscape_IsConsumedAndTextResumes()
{ {
var device = new VPlasmaDevice(); var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'Z', (byte)'H'); Feed(device, Esc, (byte)'\\', (byte)'H'); // ESC '\' is not a command
Assert.Equal(1, device.TextCharsDrawn); Assert.Equal(1, device.TextCharsDrawn);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0)); Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
@@ -260,11 +277,10 @@ public class VPlasmaDeviceTests
[Fact] [Fact]
public void GameStartupSequence_HidesCursor() public void GameStartupSequence_HidesCursor()
{ {
// L4PLASMA.CPP's constructor sends exactly ESC 'G' 0x00.
var device = new VPlasmaDevice(); var device = new VPlasmaDevice();
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode); // power-on default Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode); // power-on default
Feed(device, 27, (byte)'G', 0x00); Feed(device, 27, (byte)'G', 0x00); // L4PLASMA.CPP's boot byte
Assert.Equal(PlasmaCursorMode.Hidden, device.CursorMode); Assert.Equal(PlasmaCursorMode.Hidden, device.CursorMode);
} }
@@ -290,6 +306,80 @@ public class VPlasmaDeviceTests
} }
} }
// ---- orientation + test pattern (JP1 jumpers 4 & 5) --------------------
[Fact]
public void Orientation_VerticalRotatesLogicalOntoPhysicalGlass()
{
var device = new VPlasmaDevice { Orientation = PlasmaOrientation.Vertical };
Assert.Equal(32, device.LogicalWidth);
Assert.Equal(128, device.LogicalHeight);
// Logical (0,0) maps to physical (px=ly=0, py=Height-1-lx=31).
Feed(device, Esc, (byte)'R', 0, Esc, (byte)'Q', 0, (byte)'H');
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 31)); // 'H' top-left dot
Assert.Equal(0, Pixel(device, 0, 0));
// ESC Q (row Y) now accepts up to the 128-tall logical height;
// ESC R (column X) is bounded by the 32-wide logical width.
Feed(device, Esc, (byte)'Q', 100);
Assert.Equal(100, device.CursorY);
Feed(device, Esc, (byte)'R', 40); // out of range for 32-wide vertical
Assert.NotEqual(40, device.CursorX);
}
[Fact]
public void Orientation_ChangeClearsAndHomes()
{
var device = new VPlasmaDevice();
Feed(device, (byte)'H');
Assert.NotEqual(0, Pixel(device, 0, 0));
device.Orientation = PlasmaOrientation.Vertical;
Assert.Equal(0, Pixel(device, 0, 0)); // cleared
Assert.Equal(0, device.CursorX);
Assert.Equal(0, device.CursorY);
}
[Fact]
public void ShowTestPattern_LightsEveryDot()
{
var device = new VPlasmaDevice();
device.ShowTestPattern();
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 127, 31));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 64, 16));
device.Reset();
Assert.Equal(0, Pixel(device, 64, 16));
}
[Fact]
public void FirmwareDemo_ReplaysAllScreensAndDrawsText()
{
Assert.Equal(10, PlasmaFirmwareDemo.Count);
var device = new VPlasmaDevice();
foreach (byte[] screen in PlasmaFirmwareDemo.Screens)
{
Assert.NotEmpty(screen);
device.OnReceived(screen, screen.Length);
}
// The 10-screen demo puts plenty of real text on the glass.
Assert.True(device.TextCharsDrawn > 0);
}
[Fact]
public void PageSelect_ConsumesItsOperand()
{
var device = new VPlasmaDevice();
// ESC I 2 (draw page) must swallow the '2'; then 'H' is the first glyph.
Feed(device, Esc, (byte)'I', 2, Esc, (byte)'i', 3, (byte)'H');
Assert.Equal(1, device.TextCharsDrawn);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
Assert.Equal(6, device.CursorX); // advanced exactly one cell (no stray chars)
}
[Fact] [Fact]
public void Reset_RestoresPowerOnState() public void Reset_RestoresPowerOnState()
{ {
@@ -302,6 +392,7 @@ public class VPlasmaDeviceTests
Assert.Equal(0, device.Font); Assert.Equal(0, device.Font);
Assert.Equal(PlasmaAttributes.None, device.Attributes); Assert.Equal(PlasmaAttributes.None, device.Attributes);
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode); Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode);
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell); Assert.Equal(0, device.CursorX);
Assert.Equal(0, device.CursorY);
} }
} }
@@ -0,0 +1,99 @@
using System.Diagnostics;
using System.IO.Pipes;
using VPlasma.Core.Device;
using Xunit;
namespace VPlasma.Core.Tests;
public class VPlasmaPipeServiceTests
{
private static string UniqueName() => $"vplasma-test-{Guid.NewGuid():N}";
private static void WaitFor(Func<bool> condition, int timeoutMs = 2000)
{
var clock = Stopwatch.StartNew();
while (!condition())
{
Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
Thread.Sleep(10);
}
}
private static NamedPipeClientStream Connect(string name)
{
var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
var clock = Stopwatch.StartNew();
while (true)
{
try
{
pipe.Connect(200);
return pipe;
}
catch (Exception ex) when (ex is IOException or TimeoutException)
{
if (clock.ElapsedMilliseconds >= 3000)
throw;
Thread.Sleep(25);
}
}
}
[Fact]
public void Framed_text_reaches_the_display_and_lines_are_ignored()
{
string name = UniqueName();
var device = new VPlasmaDevice();
using var service = new VPlasmaPipeService(device, name);
service.Start();
using var client = Connect(name);
var hello = new byte[2];
Assert.Equal(1, client.Read(hello, 0, 1)); // on-connect lines frame,
Assert.Equal(1, client.Read(hello, 1, 1)); // possibly split
Assert.Equal(new byte[] { 0x01, 0x03 }, hello); // DTR+RTS: display present
// RTS chatter (DOS console redirection toggles it per byte) then "HI",
// the data frame torn in half mid-payload.
client.Write(new byte[] { 0x01, 0x02, 0x00, 0x02, (byte)'H' }, 0, 5);
client.Flush();
Thread.Sleep(20);
client.Write(new byte[] { (byte)'I' }, 0, 1);
client.Flush();
WaitFor(() => device.BytesReceived == 2);
Assert.Equal(2, device.TextCharsDrawn);
}
[Fact]
public async Task Unknown_frame_type_drops_the_client_and_server_recovers()
{
string name = UniqueName();
var device = new VPlasmaDevice();
using var service = new VPlasmaPipeService(device, name);
service.Start();
using (var bad = Connect(name))
{
var hello = new byte[2];
bad.Read(hello, 0, 2);
bad.Write(new byte[] { 0x7F }, 0, 1); // not a frame type
bad.Flush();
// Contract: log + drop, no resync — our next read sees the close.
var eof = Task.Run(() =>
{
try { return bad.Read(new byte[1], 0, 1) == 0; }
catch (IOException) { return true; }
});
Assert.True(await Task.WhenAny(eof, Task.Delay(2000)) == eof,
"server did not drop the connection");
Assert.True(await eof);
}
using var good = Connect(name);
var again = new byte[2];
Assert.Equal(1, good.Read(again, 0, 1));
Assert.Equal(1, good.Read(again, 1, 1));
Assert.Equal(new byte[] { 0x01, 0x03 }, again);
}
}
+327
View File
@@ -0,0 +1,327 @@
using System.Diagnostics;
using System.IO.Pipes;
using VRio.Core.Device;
using VRio.Core.Protocol;
using Xunit;
namespace VRio.Core.Tests;
public class PipeFrameDecoderTests
{
[Fact]
public void Data_frame_delivers_payload()
{
var decoder = new PipeFrameDecoder();
byte[]? got = null;
decoder.Data += (buf, count) => got = buf.Take(count).ToArray();
Assert.True(decoder.Feed(new byte[] { 0x00, 0x03, 0xAA, 0xBB, 0xCC }, 5));
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, got);
}
[Fact]
public void Frames_survive_any_split_across_reads()
{
var decoder = new PipeFrameDecoder();
var data = new List<byte>();
var lines = new List<byte>();
decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
decoder.Lines += lines.Add;
// A lines frame, a 2-byte data frame, a 1-byte data frame — one byte at a time.
byte[] stream = { 0x01, 0x03, 0x00, 0x02, 0x81, 0x01, 0x00, 0x01, 0xFC };
foreach (byte b in stream)
Assert.True(decoder.Feed(new[] { b }, 1));
Assert.Equal(new byte[] { 0x03 }, lines);
Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, data);
}
[Fact]
public void Multiple_frames_in_one_read_all_deliver()
{
var decoder = new PipeFrameDecoder();
var data = new List<byte>();
var lines = new List<byte>();
decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
decoder.Lines += lines.Add;
byte[] chunk = { 0x00, 0x01, 0x42, 0x01, 0x00, 0x00, 0x01, 0x43 };
Assert.True(decoder.Feed(chunk, chunk.Length));
Assert.Equal(new byte[] { 0x42, 0x43 }, data);
Assert.Equal(new byte[] { 0x00 }, lines);
}
[Fact]
public void Unknown_frame_type_poisons_the_decoder()
{
var decoder = new PipeFrameDecoder();
Assert.False(decoder.Feed(new byte[] { 0x7F }, 1));
Assert.Contains("0x7F", decoder.Violation);
// Poisoned: even a healthy frame is rejected until Reset.
Assert.False(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
decoder.Reset();
Assert.Null(decoder.Violation);
Assert.True(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
}
[Fact]
public void Zero_length_data_frame_is_a_violation()
{
var decoder = new PipeFrameDecoder();
Assert.False(decoder.Feed(new byte[] { 0x00, 0x00 }, 2));
Assert.Contains("zero-length", decoder.Violation);
}
}
public class VRioPipeServiceTests
{
/// <summary>
/// A contract-speaking client: connects to the service's pipe, deframes
/// everything it receives (timestamping data bytes for the pacing test),
/// and sends raw frame bytes.
/// </summary>
private sealed class TestClient : IDisposable
{
private readonly NamedPipeClientStream _pipe;
private readonly Thread _reader;
private readonly object _sync = new();
private readonly List<byte> _data = new();
private readonly List<long> _dataTicks = new();
private readonly List<byte> _lines = new();
private volatile bool _closed;
public TestClient(string pipeName)
{
_pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut,
PipeOptions.Asynchronous);
// The server's listener may still be spinning up; retry briefly.
var connectClock = Stopwatch.StartNew();
while (true)
{
try
{
_pipe.Connect(200);
break;
}
catch (Exception ex) when (ex is IOException or TimeoutException)
{
if (connectClock.ElapsedMilliseconds >= 3000)
throw;
Thread.Sleep(25);
}
}
var decoder = new PipeFrameDecoder();
decoder.Data += (buf, count) =>
{
long now = Stopwatch.GetTimestamp();
lock (_sync)
for (int i = 0; i < count; i++)
{
_data.Add(buf[i]);
_dataTicks.Add(now);
}
};
decoder.Lines += b => { lock (_sync) _lines.Add(b); };
_reader = new Thread(() =>
{
var buffer = new byte[512];
while (true)
{
int n;
try { n = _pipe.Read(buffer, 0, buffer.Length); }
catch (Exception ex) when (ex is IOException or ObjectDisposedException) { break; }
if (n == 0 || !decoder.Feed(buffer, n))
break;
}
_closed = true;
})
{ IsBackground = true };
_reader.Start();
}
public bool Closed => _closed;
public byte[] Data { get { lock (_sync) return _data.ToArray(); } }
public long[] DataTicks { get { lock (_sync) return _dataTicks.ToArray(); } }
public byte[] Lines { get { lock (_sync) return _lines.ToArray(); } }
public void SendRaw(params byte[] bytes)
{
_pipe.Write(bytes, 0, bytes.Length);
_pipe.Flush();
}
public void SendData(byte[] payload)
{
var frame = new byte[2 + payload.Length];
frame[0] = PipeFraming.DataType;
frame[1] = (byte)payload.Length;
payload.CopyTo(frame, 2);
SendRaw(frame);
}
public void Dispose()
{
_pipe.Dispose();
_reader.Join(1000);
}
}
private static string UniqueName() => $"vrio-test-{Guid.NewGuid():N}";
private static void WaitFor(Func<bool> condition, int timeoutMs = 2000)
{
var clock = Stopwatch.StartNew();
while (!condition())
{
Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
Thread.Sleep(10);
}
}
[Fact]
public void Client_gets_board_present_lines_on_connect()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
service.Start();
using var client = new TestClient(name);
WaitFor(() => client.Lines.Length > 0);
Assert.Equal(PipeFraming.LinesPresent, client.Lines[0]); // DTR+RTS: board present
}
[Fact]
public void Peer_dtr_edges_surface_as_host_handshake()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
var edges = new List<bool>();
service.HostHandshake += high => { lock (edges) edges.Add(high); };
service.Start();
using var client = new TestClient(name);
client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr); // DTR up
client.SendRaw(PipeFraming.LinesType, 0x00); // DTR down (the reset pulse)
WaitFor(() => { lock (edges) return edges.Count == 2; });
lock (edges)
Assert.Equal(new[] { true, false }, edges);
}
[Fact]
public void Version_request_round_trips_ack_then_reply()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
service.Start();
using var client = new TestClient(name);
WaitFor(() => client.Lines.Length > 0); // fully connected
client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
byte[] expected = new[] { (byte)RioControl.Ack }
.Concat(PacketBuilder.VersionReply(4, 2)).ToArray();
WaitFor(() => client.Data.Length >= expected.Length);
Assert.Equal(expected, client.Data);
}
[Fact]
public void Reply_bytes_are_paced_at_the_wire_rate()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
service.Start();
using var client = new TestClient(name);
WaitFor(() => client.Lines.Length > 0);
client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
// ACK + 4-byte VersionReply = 5 bytes = 4 inter-byte gaps ≈ 4.2 ms at
// 9600 baud. An unpaced writer would land them in well under 1 ms.
WaitFor(() => client.Data.Length >= 5);
long[] ticks = client.DataTicks;
double spanMs = (ticks[4] - ticks[0]) * 1000.0 / Stopwatch.Frequency;
Assert.True(spanMs >= 2.5, $"5 reply bytes arrived in {spanMs:F2} ms — pipe TX is not paced");
}
[Fact]
public void Unknown_frame_type_drops_the_client_and_server_recovers()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
service.Start();
using (var bad = new TestClient(name))
{
WaitFor(() => bad.Lines.Length > 0);
bad.SendRaw(0x7F); // not a frame type
WaitFor(() => bad.Closed); // contract: log + drop, no resync
}
// The listener must come back for the next client.
using var good = new TestClient(name);
WaitFor(() => good.Lines.Length > 0);
Assert.Equal(PipeFraming.LinesPresent, good.Lines[0]);
}
[Fact]
public void Client_disconnect_drops_dtr_low()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
var edges = new List<bool>();
service.HostHandshake += high => { lock (edges) edges.Add(high); };
service.Start();
using (var client = new TestClient(name))
{
client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr);
WaitFor(() => { lock (edges) return edges.Count == 1; });
} // client leaves with DTR still high
WaitFor(() => { lock (edges) return edges.Count == 2; });
lock (edges)
Assert.Equal(new[] { true, false }, edges);
}
[Fact]
public void Data_split_mid_frame_still_reaches_the_device()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
var lamps = new List<(int Address, byte State)>();
device.LampChanged += (address, state) => { lock (lamps) lamps.Add((address, state)); };
service.Start();
using var client = new TestClient(name);
WaitFor(() => client.Lines.Length > 0);
// LampRequest 0x00 ← 0x01, framed, then torn in half mid-payload.
byte[] packet = PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x00, 0x01 });
var frame = new byte[] { PipeFraming.DataType, (byte)packet.Length }.Concat(packet).ToArray();
client.SendRaw(frame.Take(3).ToArray());
Thread.Sleep(20);
client.SendRaw(frame.Skip(3).ToArray());
WaitFor(() => { lock (lamps) return lamps.Count == 1; });
lock (lamps)
Assert.Equal((0x00, (byte)0x01), lamps[0]);
}
}
+182
View File
@@ -0,0 +1,182 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Drawing.Text;
using System.Text;
using VPlasma.Core.Device;
using VPlasma.Core.Protocol;
namespace VPlasma.Wire;
/// <summary>
/// Plasma bitmap authoring — ported from TeslaSuite's <c>PlasmaBitmaps</c>
/// (the console's Plasma Font Tool / Bitmap Decoder / Editor). Renders text in
/// a Windows font to a 1bpp panel bitmap, converts bitmaps to and from the
/// game's <c>bitmap=</c> hex encoding, and bridges either to an <c>ESC P</c>
/// wire stream the display (or vPLASMA / the replica) can show.
/// </summary>
internal static class PlasmaBitmap
{
public const string DefaultFont = "Microsoft Sans Serif";
/// <summary>
/// Render <paramref name="text"/> centered in a <paramref name="width"/>×
/// <paramref name="height"/> 1bpp image, auto-shrinking the font to fit —
/// the Plasma Font Tool's behavior.
/// </summary>
public static Bitmap RenderText(int width, int height, string fontName, string text)
{
var bmp = new Bitmap(width, height, PixelFormat.Format24bppRgb);
using var g = Graphics.FromImage(bmp);
g.SmoothingMode = SmoothingMode.None;
g.InterpolationMode = InterpolationMode.NearestNeighbor;
g.TextRenderingHint = TextRenderingHint.SingleBitPerPixelGridFit;
g.Clear(Color.Black);
var rect = new Rectangle(0, 0, width, height);
using var fmt = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
for (int size = 24; ; size = size <= 2 ? size / 2 : size - 2)
{
using var font = new Font(fontName, Math.Max(1, size));
SizeF sz = g.MeasureString(text, font);
if (size <= 1 || (sz.Width <= width && sz.Height <= height))
{
g.DrawString(text, font, Brushes.White, rect, fmt);
return bmp;
}
}
}
/// <summary>Threshold an image to a <paramref name="w"/>×<paramref name="h"/> on/off grid.</summary>
public static bool[,] ToGrid(Bitmap src, int w, int h, double threshold)
{
using var scaled = new Bitmap(w, h, PixelFormat.Format24bppRgb);
using (var g = Graphics.FromImage(scaled))
{
g.InterpolationMode = src.Width == w && src.Height == h
? InterpolationMode.NearestNeighbor : InterpolationMode.HighQualityBicubic;
g.DrawImage(src, 0, 0, w, h);
}
var grid = new bool[w, h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
grid[x, y] = scaled.GetPixel(x, y).GetBrightness() >= threshold;
return grid;
}
/// <summary>The game's <c>bitmap=</c> hex encoding (MSB-first, 8 px/byte).</summary>
public static string ToHexEncoding(bool[,] grid)
{
int w = grid.GetLength(0), h = grid.GetLength(1);
if (w % 8 != 0)
throw new ArgumentException("width must be a multiple of 8");
var sb = new StringBuilder();
for (int y = 0; y < h; y++)
{
sb.Append("bitmap=");
for (int j = 0; j < w / 8; j++)
{
byte b = 0;
for (int k = 0; k < 8; k++)
if (grid[j * 8 + k, y]) b |= (byte)(0x80 >> k);
sb.Append(b.ToString("X2"));
}
sb.Append('\n');
}
sb.Append($"x={w}\n");
sb.Append($"y={h}\n");
return sb.ToString();
}
/// <summary>Parse a <c>bitmap=</c>/<c>x=</c>/<c>y=</c> encoding back to a grid.</summary>
public static bool[,] FromHexEncoding(string text)
{
var rows = new List<byte[]>();
int declaredX = 0, declaredY = 0;
foreach (string raw in text.Split('\n'))
{
string line = raw.Trim();
if (line.StartsWith("bitmap=", StringComparison.OrdinalIgnoreCase))
{
string hex = line.Substring("bitmap=".Length).Trim();
var bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
rows.Add(bytes);
}
else if (line.StartsWith("x=", StringComparison.OrdinalIgnoreCase))
int.TryParse(line.Substring(2), out declaredX);
else if (line.StartsWith("y=", StringComparison.OrdinalIgnoreCase))
int.TryParse(line.Substring(2), out declaredY);
}
if (rows.Count == 0)
throw new ArgumentException("no 'bitmap=' rows found");
int w = declaredX > 0 ? declaredX : rows[0].Length * 8;
int h = declaredY > 0 ? declaredY : rows.Count;
var grid = new bool[w, h];
for (int y = 0; y < h && y < rows.Count; y++)
for (int j = 0; j < rows[y].Length && j * 8 < w; j++)
for (int k = 0; k < 8 && j * 8 + k < w; k++)
grid[j * 8 + k, y] = (rows[y][j] & (0x80 >> k)) != 0;
return grid;
}
/// <summary>
/// Pack a grid into an <c>ESC P</c> wire stream (clear + cursor-off + one
/// full-width row per line), the way L4PLASMA streams a frame.
/// </summary>
public static byte[] ToEscP(bool[,] grid)
{
int w = grid.GetLength(0), h = grid.GetLength(1);
int wbytes = (w + 7) / 8;
var b = new List<byte>
{
PlasmaProtocol.Esc, PlasmaProtocol.CmdCursorMode, 0x00, // hide cursor
PlasmaProtocol.Esc, PlasmaProtocol.CmdClearScreen,
};
for (int y = 0; y < h; y++)
{
b.Add(PlasmaProtocol.Esc);
b.Add(PlasmaProtocol.CmdGraphicsWrite);
b.Add(0); // screen
b.Add((byte)y); // y
b.Add(0); // x byte
b.Add((byte)wbytes); // width in bytes
b.Add(1); // one row
for (int j = 0; j < wbytes; j++)
{
byte v = 0;
for (int k = 0; k < 8; k++)
if (j * 8 + k < w && grid[j * 8 + k, y]) v |= (byte)(0x80 >> k);
b.Add(v);
}
}
return b.ToArray();
}
/// <summary>Render a grid to a plasma-orange PNG (for previews / decoding).</summary>
public static void SaveGridPng(bool[,] grid, string path, int scale)
{
int w = grid.GetLength(0), h = grid.GetLength(1);
int pitch = scale, dot = Math.Max(1, scale - 2);
using var bmp = new Bitmap(w * pitch, h * pitch, PixelFormat.Format24bppRgb);
using var g = Graphics.FromImage(bmp);
g.Clear(Color.FromArgb(26, 14, 6));
using var unlit = new SolidBrush(Color.FromArgb(46, 24, 10));
using var lit = new SolidBrush(Color.FromArgb(255, 106, 26));
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
g.FillRectangle(grid[x, y] ? lit : unlit, x * pitch, y * pitch, dot, dot);
bmp.Save(path, ImageFormat.Png);
}
/// <summary>Reduce a rendered vPLASMA frame (flag bytes) to an on/off grid.</summary>
public static bool[,] FrameToGrid(byte[] frame)
{
var grid = new bool[VPlasmaDevice.Width, VPlasmaDevice.Height];
for (int y = 0; y < VPlasmaDevice.Height; y++)
for (int x = 0; x < VPlasmaDevice.Width; x++)
grid[x, y] = (frame[y * VPlasmaDevice.Width + x] & VPlasmaDevice.PixelLit) != 0;
return grid;
}
}
+467
View File
@@ -0,0 +1,467 @@
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO.Ports;
using VPlasma.Core.Device;
using VPlasma.Core.Protocol;
namespace VPlasma.Wire;
/// <summary>
/// Differential-test helper for the plasma display work. Generates repeatable
/// command streams, replays them to any target (the real panel, the Matrix
/// Portal replica, or a virtual port), captures live traffic, and renders the
/// vPLASMA "golden image" for comparison. Feed the same stream to all three
/// and the glass should match.
///
/// <para>Commands:</para>
/// <code>
/// synth --kind selftest|demo|charset|banner --out stream.bin
/// render --in stream.bin --out frame.png [--scale 8] [--orient h|v]
/// replay --in stream.bin --port COM3 [--baud 9600] [--paced]
/// capture --port COM12 --out stream.bin [--tee COM3] [--seconds N]
/// </code>
/// </summary>
internal static class Program
{
private static int Main(string[] args)
{
if (args.Length == 0)
return Usage();
var a = new Args(args);
try
{
switch (args[0].ToLowerInvariant())
{
case "synth": return Synth(a);
case "render": return Render(a);
case "replay": return Replay(a);
case "capture": return Capture(a);
case "text": return TextCmd(a);
case "encode": return Encode(a);
case "decode": return Decode(a);
case "diff": return Diff(a);
case "-h" or "--help" or "help": return Usage();
default:
Console.Error.WriteLine($"Unknown command '{args[0]}'.");
return Usage();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
// ---- synth: build a repeatable command stream --------------------------
private static int Synth(Args a)
{
string kind = a.Get("kind", "selftest").ToLowerInvariant();
string outPath = a.Require("out");
var b = new List<byte>();
switch (kind)
{
case "selftest":
for (int p = 0; p < PlasmaSelfTest.PageCount; p++)
b.AddRange(PlasmaSelfTest.BuildPage(p));
break;
case "demo":
foreach (byte[] screen in PlasmaFirmwareDemo.Screens)
b.AddRange(screen);
break;
case "banner":
b.AddRange(PlasmaSelfTest.BuildPage(0));
break;
case "charset":
b.Add(PlasmaProtocol.Esc); b.Add(PlasmaProtocol.CmdClearScreen);
b.Add(PlasmaProtocol.Esc); b.Add(PlasmaProtocol.CmdCursorMode); b.Add(0x00);
for (int c = 0x20; c <= 0x7E; c++) b.Add((byte)c);
break;
default:
Console.Error.WriteLine("--kind must be selftest | demo | banner | charset");
return 1;
}
File.WriteAllBytes(outPath, b.ToArray());
Console.WriteLine($"Wrote {b.Count} bytes to {outPath} (kind={kind})");
return 0;
}
// ---- render: the vPLASMA golden image ----------------------------------
private static int Render(Args a)
{
string inPath = a.Require("in");
string outPath = a.Require("out");
int scale = a.GetInt("scale", 8);
bool vertical = a.Get("orient", "h").StartsWith("v", StringComparison.OrdinalIgnoreCase);
var device = new VPlasmaDevice();
if (vertical)
device.Orientation = PlasmaOrientation.Vertical;
byte[] data = File.ReadAllBytes(inPath);
device.OnReceived(data, data.Length);
var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
device.CopyFrame(frame);
RenderPng(frame, outPath, scale);
Console.WriteLine($"Fed {data.Length} bytes; rendered {VPlasmaDevice.Width}×{VPlasmaDevice.Height} " +
$"({device.TextCharsDrawn} chars, {device.GraphicsRows} graphics rows) → {outPath}");
return 0;
}
private static void RenderPng(byte[] frame, string outPath, int scale)
{
int pitch = scale;
int dot = Math.Max(1, scale - 2);
int W = VPlasmaDevice.Width, H = VPlasmaDevice.Height;
using var bmp = new Bitmap(W * pitch, H * pitch, PixelFormat.Format24bppRgb);
using var g = Graphics.FromImage(bmp);
g.Clear(Color.FromArgb(20, 18, 16));
using var glass = new SolidBrush(Color.FromArgb(26, 14, 6));
g.FillRectangle(glass, 0, 0, bmp.Width, bmp.Height);
using var unlit = new SolidBrush(Color.FromArgb(46, 24, 10));
using var lit = new SolidBrush(Color.FromArgb(255, 106, 26));
using var half = new SolidBrush(Color.FromArgb(150, 62, 15));
for (int y = 0; y < H; y++)
{
for (int x = 0; x < W; x++)
{
byte px = frame[y * W + x];
// Static image: render a flashing dot in its "on" phase.
Brush brush = (px & VPlasmaDevice.PixelLit) != 0
? ((px & VPlasmaDevice.PixelHalf) != 0 ? half : lit)
: unlit;
g.FillRectangle(brush, x * pitch, y * pitch, dot, dot);
}
}
bmp.Save(outPath, ImageFormat.Png);
}
// ---- replay: send a stream to a target port ----------------------------
private static int Replay(Args a)
{
string inPath = a.Require("in");
string portName = a.Require("port");
int baud = a.GetInt("baud", PlasmaProtocol.BaudRate);
bool paced = a.Has("paced");
byte[] data = File.ReadAllBytes(inPath);
using var port = new SerialPort(portName, baud, Parity.None, 8, StopBits.One)
{
Handshake = Handshake.None,
WriteTimeout = 5000,
DtrEnable = true,
RtsEnable = true,
};
port.Open();
if (paced)
{
// One byte per 10-bit frame time — for virtual ports with no UART.
long period = Stopwatch.Frequency * 10 / baud;
long slot = Stopwatch.GetTimestamp();
var one = new byte[1];
foreach (byte t in data)
{
slot = Math.Max(slot + period, Stopwatch.GetTimestamp());
while (Stopwatch.GetTimestamp() < slot) { /* spin */ }
one[0] = t;
port.Write(one, 0, 1);
}
}
else
{
// A real UART at `baud` paces itself; a USB-CDC target takes it fast.
port.Write(data, 0, data.Length);
}
Console.WriteLine($"Replayed {data.Length} bytes to {portName} @ {baud} 8N1{(paced ? " (paced)" : "")}.");
Thread.Sleep(200); // let the last bytes drain before closing
return 0;
}
// ---- capture: log live traffic (optionally teeing to the display) ------
private static int Capture(Args a)
{
string portName = a.Require("port");
string outPath = a.Require("out");
string? teeName = a.Get("tee", "");
int seconds = a.GetInt("seconds", 0);
using var port = new SerialPort(portName, a.GetInt("baud", PlasmaProtocol.BaudRate),
Parity.None, 8, StopBits.One) { Handshake = Handshake.None, ReadTimeout = 200 };
port.Open();
SerialPort? tee = null;
if (!string.IsNullOrWhiteSpace(teeName))
{
tee = new SerialPort(teeName, a.GetInt("baud", PlasmaProtocol.BaudRate), Parity.None, 8, StopBits.One)
{ Handshake = Handshake.None, DtrEnable = true, RtsEnable = true };
tee.Open();
}
using var outFile = File.Create(outPath);
var stop = new ManualResetEventSlim(false);
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Set(); };
var deadline = seconds > 0 ? Stopwatch.StartNew() : null;
Console.WriteLine($"Capturing {portName} → {outPath}" +
(tee != null ? $" (tee → {teeName})" : "") +
$". {(seconds > 0 ? $"{seconds}s or " : "")}Ctrl+C to stop.");
long total = 0;
var buf = new byte[512];
while (!stop.IsSet && (deadline is null || deadline.Elapsed.TotalSeconds < seconds))
{
int n;
try { n = port.Read(buf, 0, buf.Length); }
catch (TimeoutException) { continue; }
if (n <= 0) continue;
outFile.Write(buf, 0, n);
tee?.Write(buf, 0, n);
total += n;
Console.Write($"\r{total} bytes captured");
}
Console.WriteLine($"\nDone. {total} bytes → {outPath}");
tee?.Dispose();
return 0;
}
// ---- text: render text (Windows font) to a plasma bitmap ---------------
// (TeslaSuite's Plasma Font Tool)
private static int TextCmd(Args a)
{
string text = a.Require("text");
string fontName = a.Get("font", PlasmaBitmap.DefaultFont);
(int w, int h) = ParseSize(a.Get("size", "128x32"));
double thresh = a.GetDouble("threshold", 0.5);
using Bitmap bmp = PlasmaBitmap.RenderText(w, h, fontName, text);
bool[,] grid = PlasmaBitmap.ToGrid(bmp, w, h, thresh);
WriteBitmapOutputs(a, grid, defaultPng: null);
Console.WriteLine($"Rendered \"{text}\" ({fontName}) into {w}×{h}.");
return 0;
}
// ---- encode: image file → plasma bitmap encoding / ESC P ----------------
private static int Encode(Args a)
{
string inPath = a.Require("in");
(int w, int h) = ParseSize(a.Get("size", "128x32"));
double thresh = a.GetDouble("threshold", 0.5);
using var src = new Bitmap(inPath);
bool[,] grid = PlasmaBitmap.ToGrid(src, w, h, thresh);
WriteBitmapOutputs(a, grid, defaultPng: null);
Console.WriteLine($"Encoded {inPath} ({src.Width}×{src.Height}) → {w}×{h} plasma bitmap.");
return 0;
}
// ---- decode: plasma bitmap encoding → PNG / ESC P -----------------------
// (TeslaSuite's Plasma Bitmap Decoder)
private static int Decode(Args a)
{
string inPath = a.Require("in");
bool[,] grid = PlasmaBitmap.FromHexEncoding(File.ReadAllText(inPath));
int scale = a.GetInt("scale", 8);
string png = a.Get("png", "");
if (string.IsNullOrEmpty(png))
png = Path.ChangeExtension(inPath, ".png");
PlasmaBitmap.SaveGridPng(grid, png, scale);
if (a.Get("bin", "") is { Length: > 0 } bin)
File.WriteAllBytes(bin, PlasmaBitmap.ToEscP(grid));
Console.WriteLine($"Decoded {inPath} ({grid.GetLength(0)}×{grid.GetLength(1)}) → {png}");
return 0;
}
// Emit whichever of --png / --bin (ESC P) / --hex outputs were requested.
private static void WriteBitmapOutputs(Args a, bool[,] grid, string? defaultPng)
{
string png = a.Get("png", defaultPng ?? "");
if (!string.IsNullOrEmpty(png))
{
PlasmaBitmap.SaveGridPng(grid, png, a.GetInt("scale", 8));
Console.WriteLine($" png → {png}");
}
if (a.Get("bin", "") is { Length: > 0 } bin)
{
File.WriteAllBytes(bin, PlasmaBitmap.ToEscP(grid));
Console.WriteLine($" ESC P stream → {bin}");
}
if (a.Get("hex", "") is { Length: > 0 } hex)
{
File.WriteAllText(hex, PlasmaBitmap.ToHexEncoding(grid));
Console.WriteLine($" bitmap= encoding → {hex}");
}
}
// ---- diff: compare two frames / images against the golden ---------------
private static int Diff(Args a)
{
double thresh = a.GetDouble("threshold", 0.5);
bool[,] ga = LoadAsGrid(a.Require("a"), thresh);
bool[,] gb = LoadAsGrid(a.Require("b"), thresh);
int w = ga.GetLength(0), h = ga.GetLength(1);
if (gb.GetLength(0) != w || gb.GetLength(1) != h)
{
Console.Error.WriteLine($"Size mismatch: A is {w}×{h}, B is {gb.GetLength(0)}×{gb.GetLength(1)}.");
return 2;
}
int diff = 0, litA = 0, litB = 0;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
if (ga[x, y]) litA++;
if (gb[x, y]) litB++;
if (ga[x, y] != gb[x, y]) diff++;
}
int total = w * h;
double matchPct = 100.0 * (total - diff) / total;
Console.WriteLine($"A lit={litA} B lit={litB} differing dots={diff}/{total} match={matchPct:F2}%");
string outPng = a.Get("out", "");
if (!string.IsNullOrEmpty(outPng))
{
int scale = a.GetInt("scale", 8);
int pitch = scale, dot = Math.Max(1, scale - 2);
using var bmp = new Bitmap(w * pitch, h * pitch, PixelFormat.Format24bppRgb);
using var g = Graphics.FromImage(bmp);
g.Clear(Color.FromArgb(20, 12, 6));
using var match = new SolidBrush(Color.FromArgb(255, 106, 26)); // both lit
using var off = new SolidBrush(Color.FromArgb(40, 22, 10)); // both off
using var miss = new SolidBrush(Color.FromArgb(255, 40, 40)); // differ
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
Brush br = ga[x, y] != gb[x, y] ? miss : (ga[x, y] ? match : off);
g.FillRectangle(br, x * pitch, y * pitch, dot, dot);
}
bmp.Save(outPng, ImageFormat.Png);
Console.WriteLine($"diff image (red = differing dots) → {outPng}");
}
return diff == 0 ? 0 : 1;
}
// Load a .bin (ESC P stream → vPLASMA frame), an image, or a bitmap= .txt
// encoding, all reduced to a 128×32 on/off grid.
private static bool[,] LoadAsGrid(string path, double threshold)
{
string ext = Path.GetExtension(path).ToLowerInvariant();
switch (ext)
{
case ".bin":
{
var device = new VPlasmaDevice();
byte[] data = File.ReadAllBytes(path);
device.OnReceived(data, data.Length);
var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
device.CopyFrame(frame);
return PlasmaBitmap.FrameToGrid(frame);
}
case ".txt":
return PlasmaBitmap.FromHexEncoding(File.ReadAllText(path));
default: // an image (png/bmp/jpg): threshold to 128×32
using (var img = new Bitmap(path))
return PlasmaBitmap.ToGrid(img, VPlasmaDevice.Width, VPlasmaDevice.Height, threshold);
}
}
private static (int, int) ParseSize(string s)
{
string[] p = s.ToLowerInvariant().Split('x');
if (p.Length == 2 && int.TryParse(p[0], out int w) && int.TryParse(p[1], out int h))
return (w, h);
throw new ArgumentException($"--size must look like 128x32, got '{s}'");
}
// ---- tiny arg parser ---------------------------------------------------
private sealed class Args
{
private readonly Dictionary<string, string> _kv = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _flags = new(StringComparer.OrdinalIgnoreCase);
public Args(string[] args)
{
for (int i = 1; i < args.Length; i++)
{
if (!args[i].StartsWith("--")) continue;
string key = args[i].Substring(2);
if (i + 1 < args.Length && !args[i + 1].StartsWith("--"))
_kv[key] = args[++i];
else
_flags.Add(key);
}
}
public bool Has(string k) => _flags.Contains(k) || _kv.ContainsKey(k);
public string Get(string k, string dflt) => _kv.TryGetValue(k, out var v) ? v : dflt;
public int GetInt(string k, int dflt) => _kv.TryGetValue(k, out var v) && int.TryParse(v, out var n) ? n : dflt;
public double GetDouble(string k, double dflt) =>
_kv.TryGetValue(k, out var v) && double.TryParse(v, out var n) ? n : dflt;
public string Require(string k) => _kv.TryGetValue(k, out var v)
? v : throw new ArgumentException($"--{k} is required");
}
private static int Usage()
{
Console.WriteLine("""
vPLASMA wire tool differential-test helper.
synth --kind selftest|demo|banner|charset --out stream.bin
Build a repeatable command stream.
render --in stream.bin --out frame.png [--scale 8] [--orient h|v]
Feed the stream through the vPLASMA engine and save the
resulting 128×32 frame as a PNG (the golden image).
replay --in stream.bin --port COM3 [--baud 9600] [--paced]
Send the stream to a target: the real panel, the Matrix
Portal replica (USB-CDC), or a virtual port (--paced).
capture --port COM12 --out stream.bin [--tee COM3] [--seconds N]
Log live traffic to a file; --tee forwards it on to the
real display so capture is non-intrusive. Ctrl+C to stop.
text --text "HELLO" [--font "Microsoft Sans Serif"] [--size 128x32]
[--png p.png] [--bin s.bin] [--hex b.txt]
Render text (auto-sized Windows font) to a plasma bitmap;
emit a preview PNG, an ESC P wire stream, and/or the
game's bitmap= hex encoding. (TeslaSuite Plasma Font Tool)
encode --in img.png [--size 128x32] [--threshold 0.5]
[--bin s.bin] [--hex b.txt] [--png p.png]
Convert an image to a plasma bitmap (ESC P / bitmap= hex).
decode --in b.txt [--png out.png] [--bin s.bin] [--scale 8]
Render a bitmap= encoding back to a PNG (and/or ESC P).
(TeslaSuite Plasma Bitmap Decoder)
diff --a A --b B [--out diff.png] [--threshold 0.5] [--scale 8]
Compare two frames/images/encodings (.bin / .png / .txt),
each reduced to 128×32; report differing dots and write a
diff image (red = mismatch). Exit 1 if they differ.
Differential test: `synth` a stream once, `render` the golden PNG,
`replay` it to the replica and the real panel, photograph each, and
`diff` the (cropped) photos against the golden.
""");
return 0;
}
}
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AssemblyTitle>vPLASMA wire tool</AssemblyTitle>
<RootNamespace>VPlasma.Wire</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- System.Drawing for the golden-image PNG render. -->
<Reference Include="System.Drawing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\VPlasma.Core\VPlasma.Core.csproj" />
</ItemGroup>
</Project>