Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e3d1f367 | ||
|
|
78cd679b53 | ||
|
|
ebad6ee5fe | ||
|
|
ab36cdd826 | ||
|
|
79eb53253e | ||
|
|
95b701ae0a | ||
|
|
d8ab0ffe49 | ||
|
|
90a57ba2cf | ||
|
|
49e88fbe20 | ||
|
|
2dca8bfd8f | ||
|
|
697cf3129b | ||
|
|
e507f1740c | ||
|
|
0674cf5ba4 | ||
|
|
90bf6723dc |
@@ -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` DC1–DC4 | `$9A5C`/`$9B34`/`$9C09`/`$9CFC` | device controls (TBD) |
|
||||
| `0x0C` FF, `0x0E`–`0x10` | — | no handler |
|
||||
|
||||
### ESC commands (`$98AC`) — confirmed semantics
|
||||
|
||||
| Cmd | Handler | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `ESC @` | `$9F26` | **Clear** the active draw buffer (512 bytes = 128×32÷8) |
|
||||
| `ESC G n` | `$A42B` | **Cursor mode**, low nibble of `$B4` (n = 0–7) |
|
||||
| `ESC H n` | `$A44C` | **Text attributes**, low 4 bits of `$B1` (intensity/underline/reverse/flash) |
|
||||
| `ESC K n` | `$A3EA` | **Font select** (n = 0–9; 8 real fonts) |
|
||||
| `ESC L` | `$A556` | **Home** cursor (X=0, Y=0) |
|
||||
| `ESC Q n` | `$A51C` | **Set cursor row Y** (range-checked 0–31) |
|
||||
| `ESC R n` | `$A539` | **Set cursor column X** (range-checked 0–127) |
|
||||
| `ESC I n` | `$A473` | **Select DRAW page** 0–9 (sets `$BE` from `$A4E0` table) |
|
||||
| `ESC i n` | `$A4A2` | **Select DISPLAY page** 0–9 (page-flip; sets `$BC`) |
|
||||
| `ESC P …` | `$AAF1` | **Graphics bitmap write** (multi-operand: screen,y,x,w,h,data) |
|
||||
| `ESC A`–`ESC F` | `$9FB6`–`$A13C` | **Vector/graphics primitives** (line/point/move; pen state `$B6`, coords `$58/$59`, line routine `$A16C`) |
|
||||
| `ESC X n` | `$A748` | **Set graphics pen X** (multi-op, 0–127) |
|
||||
| `ESC Y n` | `$A644` | **Set graphics pen Y** (multi-op) |
|
||||
| `ESC J` | `$A4D4` | **Toggle** mode bit `$B7.7` (orientation/display — TBD) |
|
||||
|
||||
### ESC commands — populated but not yet decoded
|
||||
|
||||
`ESC 0`–`9` (`$9E27`+, set continuations — likely custom-char / numeric entry),
|
||||
`ESC : ; =` , `ESC < > W w _` (cluster `$AEBA–$AF00`), `ESC B C D E F` variants,
|
||||
`ESC M N O` (`$A5BD/$A5C5/$A5E0`), `ESC Z ^ z ~` (cluster `$AC73–$ACA1`),
|
||||
`ESC a`–`f`, `ESC h l n p q r x`. ~30 handlers remain to label — full list with
|
||||
addresses is dumped by the tooling below.
|
||||
|
||||
## Fonts
|
||||
|
||||
Font-pointer table at **`$BC03`** (10 slots) → 12-byte descriptors. **8 real
|
||||
fonts** (slots 8–9 are junk pointers, matching the demo's "8 STORED CHARACTER
|
||||
FONTS"):
|
||||
|
||||
| Font | First–Last | W×H | Notes |
|
||||
|------|-----------|-----|-------|
|
||||
| 0 | `0x20`–`0xFF` | 6×8 | base font, full range |
|
||||
| 1 | `0x40`–`0x7F` | 6×8 | uppercase-only |
|
||||
| 2 | `0x20`–`0xFF` | 6×10 | |
|
||||
| 3 | `0x40`–`0x7F` | 6×10 | |
|
||||
| 4 | `0x20`–`0x7F` | 12×16 | large |
|
||||
| 5 | `0x20`–`0x7F` | 12×20 | largest |
|
||||
| 6 | `0x20`–`0xFF` | 7×10 | |
|
||||
| 7 | `0x40`–`0x7F` | 7×10 | |
|
||||
|
||||
Glyph bitmaps live in ROM (`~$C000–$DFFF`). **Exact glyph base + encoding
|
||||
pending** — the renderer at `$9648` enqueues to a deferred rasterizer; tracing
|
||||
that (or brute-forcing the 'A' pattern at the known stride) will extract the
|
||||
real glyphs to replace vPLASMA's public-domain 5×7 stand-in.
|
||||
|
||||
## Demo program
|
||||
|
||||
Enabled by **jumper 6** (PD3) — confirms the [JP1 map](README.md). A 10-screen
|
||||
scripted demo; the 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` 0–7 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
@@ -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
|
||||
firmware polls at boot. Shunt to GND = logic 0.
|
||||
|
||||
| JP1 pos | HC11 pin | Function |
|
||||
|---------|----------|----------|
|
||||
| 1 | pin 24 / PA0 | Baud select bit 0 |
|
||||
| 2 | pin 22 / PA2 | Baud select bit 1 |
|
||||
| 3 | pin 21 / PA3 | Option (unknown) |
|
||||
| 4 | pin 15 / PD5 | Option (unknown) |
|
||||
| 5 | pin 14 / PD4 | Option (unknown) |
|
||||
| 6 | pin 13 / PD3 | Option (unknown) |
|
||||
| 7 | J2 SEL → +5 V | Parallel interface select |
|
||||
All decoded from the firmware's boot config routine at `$9190` (installed =
|
||||
grounded = logic 0):
|
||||
|
||||
Positions 1–2 = the datasheet's baud "JUMPER 1 / JUMPER 2." Positions 3–6
|
||||
are four unknown firmware option bits — candidates for a hidden factory
|
||||
self-test / diagnostic mode.
|
||||
| JP1 pos | HC11 pin | Function | Firmware |
|
||||
|---------|----------|----------|----------|
|
||||
| 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: PD0–PD5 = pins 10–15, PA0–PA7 =
|
||||
pins 24–17 (descending).
|
||||
@@ -200,7 +202,10 @@ CDC port satisfies perfectly. Confirm the current drive path.
|
||||
|
||||
## Status
|
||||
|
||||
**Parked pending a firmware dump.** The software emulator (vPLASMA) is built
|
||||
and released; this hardware/protocol thread is blocked on getting the U3
|
||||
EPROM image (or the Babcock programming manual). Resume at the dump plan
|
||||
above once a dump is in hand.
|
||||
**Firmware dump received — analysis underway.** The U3 EPROM was dumped
|
||||
(`tms27pc512.BIN`); see [`FIRMWARE.md`](FIRMWARE.md) for the disassembly
|
||||
findings: memory map, the full command dispatch tables (58 `ESC` commands +
|
||||
control chars), the 10-page double-buffered architecture, 8 fonts, and
|
||||
decoded command semantics. Remaining: extract the glyph bitmaps and label the
|
||||
~30 not-yet-decoded commands. The Babcock programming manual is no longer on
|
||||
the critical path — the firmware is the authoritative spec.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal-but-practical Motorola 68HC11 disassembler for the PD01D221 ROM.
|
||||
CPU address == ROM offset for $8000-$FFFF (EPROM upper 32KB maps 1:1)."""
|
||||
import sys
|
||||
|
||||
# addressing modes and their extra operand length (beyond opcode)
|
||||
INH='inh'; IMM8='imm8'; IMM16='imm16'; DIR='dir'; EXT='ext'; IDX='idx'; IDY='idy'; REL='rel'
|
||||
BITDIR='bitdir'; BITIDX='bitidx'; BITIDY='bitidy' # BSET/BCLR (mask)
|
||||
BRDIR='brdir'; BRIDX='bridx'; BRIDY='bridy' # BRSET/BRCLR (mask+rel)
|
||||
|
||||
# page 0
|
||||
P0 = {
|
||||
0x00:('TEST',INH),0x01:('NOP',INH),0x02:('IDIV',INH),0x03:('FDIV',INH),
|
||||
0x04:('LSRD',INH),0x05:('ASLD',INH),0x06:('TAP',INH),0x07:('TPA',INH),
|
||||
0x08:('INX',INH),0x09:('DEX',INH),0x0A:('CLV',INH),0x0B:('SEV',INH),
|
||||
0x0C:('CLC',INH),0x0D:('SEC',INH),0x0E:('CLI',INH),0x0F:('SEI',INH),
|
||||
0x10:('SBA',INH),0x11:('CBA',INH),0x12:('BRSET',BRDIR),0x13:('BRCLR',BRDIR),
|
||||
0x14:('BSET',BITDIR),0x15:('BCLR',BITDIR),0x16:('TAB',INH),0x17:('TBA',INH),
|
||||
0x19:('DAA',INH),0x1B:('ABA',INH),
|
||||
0x1C:('BSET',BITIDX),0x1D:('BCLR',BITIDX),0x1E:('BRSET',BRIDX),0x1F:('BRCLR',BRIDX),
|
||||
0x20:('BRA',REL),0x21:('BRN',REL),0x22:('BHI',REL),0x23:('BLS',REL),
|
||||
0x24:('BCC',REL),0x25:('BCS',REL),0x26:('BNE',REL),0x27:('BEQ',REL),
|
||||
0x28:('BVC',REL),0x29:('BVS',REL),0x2A:('BPL',REL),0x2B:('BMI',REL),
|
||||
0x2C:('BGE',REL),0x2D:('BLT',REL),0x2E:('BGT',REL),0x2F:('BLE',REL),
|
||||
0x30:('TSX',INH),0x31:('INS',INH),0x32:('PULA',INH),0x33:('PULB',INH),
|
||||
0x34:('DES',INH),0x35:('TXS',INH),0x36:('PSHA',INH),0x37:('PSHB',INH),
|
||||
0x38:('PULX',INH),0x39:('RTS',INH),0x3A:('ABX',INH),0x3B:('RTI',INH),
|
||||
0x3C:('PSHX',INH),0x3D:('MUL',INH),0x3E:('WAI',INH),0x3F:('SWI',INH),
|
||||
0x40:('NEGA',INH),0x43:('COMA',INH),0x44:('LSRA',INH),0x46:('RORA',INH),
|
||||
0x47:('ASRA',INH),0x48:('ASLA',INH),0x49:('ROLA',INH),0x4A:('DECA',INH),
|
||||
0x4C:('INCA',INH),0x4D:('TSTA',INH),0x4F:('CLRA',INH),
|
||||
0x50:('NEGB',INH),0x53:('COMB',INH),0x54:('LSRB',INH),0x56:('RORB',INH),
|
||||
0x57:('ASRB',INH),0x58:('ASLB',INH),0x59:('ROLB',INH),0x5A:('DECB',INH),
|
||||
0x5C:('INCB',INH),0x5D:('TSTB',INH),0x5F:('CLRB',INH),
|
||||
0x60:('NEG',IDX),0x63:('COM',IDX),0x64:('LSR',IDX),0x66:('ROR',IDX),
|
||||
0x67:('ASR',IDX),0x68:('ASL',IDX),0x69:('ROL',IDX),0x6A:('DEC',IDX),
|
||||
0x6C:('INC',IDX),0x6D:('TST',IDX),0x6E:('JMP',IDX),0x6F:('CLR',IDX),
|
||||
0x70:('NEG',EXT),0x73:('COM',EXT),0x74:('LSR',EXT),0x76:('ROR',EXT),
|
||||
0x77:('ASR',EXT),0x78:('ASL',EXT),0x79:('ROL',EXT),0x7A:('DEC',EXT),
|
||||
0x7C:('INC',EXT),0x7D:('TST',EXT),0x7E:('JMP',EXT),0x7F:('CLR',EXT),
|
||||
0x80:('SUBA',IMM8),0x81:('CMPA',IMM8),0x82:('SBCA',IMM8),0x83:('SUBD',IMM16),
|
||||
0x84:('ANDA',IMM8),0x85:('BITA',IMM8),0x86:('LDAA',IMM8),0x88:('EORA',IMM8),
|
||||
0x89:('ADCA',IMM8),0x8A:('ORAA',IMM8),0x8B:('ADDA',IMM8),0x8C:('CPX',IMM16),
|
||||
0x8D:('BSR',REL),0x8E:('LDS',IMM16),0x8F:('XGDX',INH),
|
||||
0x90:('SUBA',DIR),0x91:('CMPA',DIR),0x92:('SBCA',DIR),0x93:('SUBD',DIR),
|
||||
0x94:('ANDA',DIR),0x95:('BITA',DIR),0x96:('LDAA',DIR),0x97:('STAA',DIR),
|
||||
0x98:('EORA',DIR),0x99:('ADCA',DIR),0x9A:('ORAA',DIR),0x9B:('ADDA',DIR),
|
||||
0x9C:('CPX',DIR),0x9D:('JSR',DIR),0x9E:('LDS',DIR),0x9F:('STS',DIR),
|
||||
0xA0:('SUBA',IDX),0xA1:('CMPA',IDX),0xA2:('SBCA',IDX),0xA3:('SUBD',IDX),
|
||||
0xA4:('ANDA',IDX),0xA5:('BITA',IDX),0xA6:('LDAA',IDX),0xA7:('STAA',IDX),
|
||||
0xA8:('EORA',IDX),0xA9:('ADCA',IDX),0xAA:('ORAA',IDX),0xAB:('ADDA',IDX),
|
||||
0xAC:('CPX',IDX),0xAD:('JSR',IDX),0xAE:('LDS',IDX),0xAF:('STS',IDX),
|
||||
0xB0:('SUBA',EXT),0xB1:('CMPA',EXT),0xB2:('SBCA',EXT),0xB3:('SUBD',EXT),
|
||||
0xB4:('ANDA',EXT),0xB5:('BITA',EXT),0xB6:('LDAA',EXT),0xB7:('STAA',EXT),
|
||||
0xB8:('EORA',EXT),0xB9:('ADCA',EXT),0xBA:('ORAA',EXT),0xBB:('ADDA',EXT),
|
||||
0xBC:('CPX',EXT),0xBD:('JSR',EXT),0xBE:('LDS',EXT),0xBF:('STS',EXT),
|
||||
0xC0:('SUBB',IMM8),0xC1:('CMPB',IMM8),0xC2:('SBCB',IMM8),0xC3:('ADDD',IMM16),
|
||||
0xC4:('ANDB',IMM8),0xC5:('BITB',IMM8),0xC6:('LDAB',IMM8),0xC8:('EORB',IMM8),
|
||||
0xC9:('ADCB',IMM8),0xCA:('ORAB',IMM8),0xCB:('ADDB',IMM8),0xCC:('LDD',IMM16),
|
||||
0xCE:('LDX',IMM16),0xCF:('STOP',INH),
|
||||
0xD0:('SUBB',DIR),0xD1:('CMPB',DIR),0xD2:('SBCB',DIR),0xD3:('ADDD',DIR),
|
||||
0xD4:('ANDB',DIR),0xD5:('BITB',DIR),0xD6:('LDAB',DIR),0xD7:('STAB',DIR),
|
||||
0xD8:('EORB',DIR),0xD9:('ADCB',DIR),0xDA:('ORAB',DIR),0xDB:('ADDB',DIR),
|
||||
0xDC:('LDD',DIR),0xDD:('STD',DIR),0xDE:('LDX',DIR),0xDF:('STX',DIR),
|
||||
0xE0:('SUBB',IDX),0xE1:('CMPB',IDX),0xE2:('SBCB',IDX),0xE3:('ADDD',IDX),
|
||||
0xE4:('ANDB',IDX),0xE5:('BITB',IDX),0xE6:('LDAB',IDX),0xE7:('STAB',IDX),
|
||||
0xE8:('EORB',IDX),0xE9:('ADCB',IDX),0xEA:('ORAB',IDX),0xEB:('ADDB',IDX),
|
||||
0xEC:('LDD',IDX),0xED:('STD',IDX),0xEE:('LDX',IDX),0xEF:('STX',IDX),
|
||||
0xF0:('SUBB',EXT),0xF1:('CMPB',EXT),0xF2:('SBCB',EXT),0xF3:('ADDD',EXT),
|
||||
0xF4:('ANDB',EXT),0xF5:('BITB',EXT),0xF6:('LDAB',EXT),0xF7:('STAB',EXT),
|
||||
0xF8:('EORB',EXT),0xF9:('ADCB',EXT),0xFA:('ORAB',EXT),0xFB:('ADDB',EXT),
|
||||
0xFC:('LDD',EXT),0xFD:('STD',EXT),0xFE:('LDX',EXT),0xFF:('STX',EXT),
|
||||
}
|
||||
# page 1 ($18): Y versions
|
||||
P1 = {
|
||||
0x08:('INY',INH),0x09:('DEY',INH),0x1C:('BSET',BITIDY),0x1D:('BCLR',BITIDY),
|
||||
0x1E:('BRSET',BRIDY),0x1F:('BRCLR',BRIDY),0x30:('TSY',INH),0x35:('TYS',INH),
|
||||
0x38:('PULY',INH),0x3A:('ABY',INH),0x3C:('PSHY',INH),
|
||||
0x60:('NEG',IDY),0x63:('COM',IDY),0x64:('LSR',IDY),0x66:('ROR',IDY),
|
||||
0x67:('ASR',IDY),0x68:('ASL',IDY),0x69:('ROL',IDY),0x6A:('DEC',IDY),
|
||||
0x6C:('INC',IDY),0x6D:('TST',IDY),0x6E:('JMP',IDY),0x6F:('CLR',IDY),
|
||||
0x8C:('CPY',IMM16),0x8F:('XGDY',INH),0x9C:('CPY',DIR),
|
||||
0xA0:('SUBA',IDY),0xA1:('CMPA',IDY),0xA2:('SBCA',IDY),0xA3:('SUBD',IDY),
|
||||
0xA4:('ANDA',IDY),0xA5:('BITA',IDY),0xA6:('LDAA',IDY),0xA7:('STAA',IDY),
|
||||
0xA8:('EORA',IDY),0xA9:('ADCA',IDY),0xAA:('ORAA',IDY),0xAB:('ADDA',IDY),
|
||||
0xAC:('CPY',IDY),0xAD:('JSR',IDY),0xAE:('LDS',IDY),0xAF:('STS',IDY),
|
||||
0xBC:('CPY',EXT),0xCE:('LDY',IMM16),0xDE:('LDY',DIR),0xDF:('STY',DIR),
|
||||
0xE0:('SUBB',IDY),0xE1:('CMPB',IDY),0xE2:('SBCB',IDY),0xE3:('ADDD',IDY),
|
||||
0xE4:('ANDB',IDY),0xE5:('BITB',IDY),0xE6:('LDAB',IDY),0xE7:('STAB',IDY),
|
||||
0xE8:('EORB',IDY),0xE9:('ADCB',IDY),0xEA:('ORAB',IDY),0xEB:('ADDB',IDY),
|
||||
0xEC:('LDD',IDY),0xED:('STD',IDY),0xEE:('LDY',IDY),0xEF:('STY',IDY),
|
||||
0xFE:('LDY',EXT),0xFF:('STY',EXT),
|
||||
}
|
||||
# page 2 ($1A): CPD, and X/Y cross indexed
|
||||
P2 = {0x83:('CPD',IMM16),0x93:('CPD',DIR),0xA3:('CPD',IDX),0xB3:('CPD',EXT),
|
||||
0xAC:('CPY',IDX),0xEE:('LDY',IDX),0xEF:('STY',IDX)}
|
||||
# page 4 ($CD): X/Y cross indexed
|
||||
P4 = {0xA3:('CPD',IDY),0xAC:('CPX',IDY),0xEE:('LDX',IDY),0xEF:('STX',IDY)}
|
||||
|
||||
def disasm(rom, addr, count):
|
||||
out=[]
|
||||
for _ in range(count):
|
||||
start=addr
|
||||
op=rom[addr]; addr+=1
|
||||
tab=P0; pfx=''
|
||||
if op==0x18: pfx='18 '; op=rom[addr]; addr+=1; tab=P1
|
||||
elif op==0x1A: pfx='1A '; op=rom[addr]; addr+=1; tab=P2
|
||||
elif op==0xCD: pfx='CD '; op=rom[addr]; addr+=1; tab=P4
|
||||
ent=tab.get(op)
|
||||
if ent is None:
|
||||
out.append((start,f".byte ${op:02X}",'?')); continue
|
||||
mn,mode=ent; operand=''; tgt=None
|
||||
if mode==INH: pass
|
||||
elif mode==IMM8: operand=f"#${rom[addr]:02X}"; addr+=1
|
||||
elif mode==IMM16: operand=f"#${(rom[addr]<<8)|rom[addr+1]:04X}"; addr+=2
|
||||
elif mode==DIR: operand=f"${rom[addr]:02X}"; addr+=1
|
||||
elif mode==EXT:
|
||||
v=(rom[addr]<<8)|rom[addr+1]; addr+=2; operand=f"${v:04X}"; tgt=v
|
||||
elif mode==IDX: operand=f"${rom[addr]:02X},X"; addr+=1
|
||||
elif mode==IDY: operand=f"${rom[addr]:02X},Y"; addr+=1
|
||||
elif mode==REL:
|
||||
rel=rom[addr]; addr+=1; d=rel-256 if rel>127 else rel
|
||||
tgt=(addr+d)&0xFFFF; operand=f"${tgt:04X}"
|
||||
elif mode==BITDIR: operand=f"${rom[addr]:02X} #${rom[addr+1]:02X}"; addr+=2
|
||||
elif mode==BITIDX: operand=f"${rom[addr]:02X},X #${rom[addr+1]:02X}"; addr+=2
|
||||
elif mode==BITIDY: operand=f"${rom[addr]:02X},Y #${rom[addr+1]:02X}"; addr+=2
|
||||
elif mode==BRDIR:
|
||||
dd=rom[addr]; mk=rom[addr+1]; rel=rom[addr+2]; addr+=3
|
||||
s=rel-256 if rel>127 else rel; tgt=(addr+s)&0xFFFF
|
||||
operand=f"${dd:02X} #${mk:02X} ${tgt:04X}"
|
||||
elif mode==BRIDX:
|
||||
dd=rom[addr]; mk=rom[addr+1]; rel=rom[addr+2]; addr+=3
|
||||
s=rel-256 if rel>127 else rel; tgt=(addr+s)&0xFFFF
|
||||
operand=f"${dd:02X},X #${mk:02X} ${tgt:04X}"
|
||||
elif mode==BRIDY:
|
||||
dd=rom[addr]; mk=rom[addr+1]; rel=rom[addr+2]; addr+=3
|
||||
s=rel-256 if rel>127 else rel; tgt=(addr+s)&0xFFFF
|
||||
operand=f"${dd:02X},Y #${mk:02X} ${tgt:04X}"
|
||||
raw=' '.join(f'{rom[b]:02X}' for b in range(start,addr))
|
||||
out.append((start,f"{pfx}{mn} {operand}".strip(), raw, mn, tgt))
|
||||
return out
|
||||
|
||||
if __name__=='__main__':
|
||||
rom=open(__import__('os').path.join(__import__('os').path.dirname(__file__),'tms27pc512.BIN'),'rb').read()
|
||||
start=int(sys.argv[1],16); count=int(sys.argv[2]) if len(sys.argv)>2 else 40
|
||||
for row in disasm(rom,start,count):
|
||||
a=row[0]; txt=row[1]; raw=row[2]
|
||||
print(f"{a:04X}: {raw:<12} {txt}")
|
||||
@@ -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
@@ -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 0–63 = panel A, 64–127 = 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.
@@ -96,22 +96,54 @@ The two apps need a crossed serial link. Install a
|
||||
(e.g. `COM1 ⇄ COM11`), then:
|
||||
|
||||
1. Run `VRio.App`, pick `COM11`, **Open**.
|
||||
2. RIOJoy will always poit to `COM1`.
|
||||
2. RIOJoy will always point to `COM1`.
|
||||
|
||||
RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms
|
||||
analog polling drives the "analog polls served" counter, and every click on
|
||||
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
|
||||
a null-modem cable work the same way.
|
||||
|
||||
## Named pipes for DOSBox-X (no com0com)
|
||||
|
||||
When the game runs in the patched DOSBox-X (the fork's `namedpipe` serial
|
||||
backend), the virtual ports aren't needed at all: each device's connection
|
||||
picker lists its named-pipe endpoint — `pipe:vrio`, `pipe:vplasma` —
|
||||
alongside the machine's COM ports. Select it and **Open** to serve
|
||||
`\\.\pipe\vrio` / `\\.\pipe\vplasma`; DOSBox-X connects as the client,
|
||||
retrying in the background until the pipe exists:
|
||||
|
||||
```ini
|
||||
[serial]
|
||||
serial1 = namedpipe pipe:vrio rxpollus:100 rxburst:16
|
||||
serial2 = namedpipe pipe:vplasma
|
||||
```
|
||||
|
||||
A pipe is a plain byte stream, so serial data and modem lines travel as
|
||||
typed frames (`0x00 len bytes` data, `0x01 bits` DTR/RTS) — the game's DTR
|
||||
reset pulse keeps its exact position in the byte stream, and a disconnect
|
||||
reads as all-lines-low (cable unplugged). The contract lives in
|
||||
`src/VRio.Core/Device/PipeFraming.cs` on this side and `serialnamedpipe.h`
|
||||
on the fork's. TX is paced exactly like the COM path — with the 9600-baud
|
||||
wire gone, the pacer is the only thing between the host and an impossible
|
||||
burst. Each row serves one endpoint at a time — the RIO is a single-host
|
||||
device — and nothing opens automatically at startup; the COM path is
|
||||
unchanged for RIOJoy and real pods. vRIO's built-in glass and the
|
||||
standalone vPLASMA share the `vplasma` pipe name — whoever starts second
|
||||
retries until the name frees up.
|
||||
|
||||
## vPLASMA — the companion plasma display
|
||||
|
||||
The cockpit's second serial device is the **plasma display**: a 128×32
|
||||
dot-matrix panel on COM2 (9600 8N1, no flow control) that the game draws
|
||||
mission text and status graphics on. `VPlasma.App` is its software replica:
|
||||
a bare-glass window that opens **COM12** (the device end of the plasma's
|
||||
null-modem pair) on startup — retrying while the port is missing or busy,
|
||||
port status in the title bar — decodes the display's command stream, and
|
||||
renders the dot matrix in plasma orange, text mode included.
|
||||
mission text and status graphics on. The software replica comes in two
|
||||
forms. **Built into vRIO**: the panel's encoder strip hosts the glass at
|
||||
top left, with its own connection row in the control strip (label colour =
|
||||
connection status; picker offers `pipe:vplasma` and the COM ports). And standalone,
|
||||
`VPlasma.App`: a bare-glass window that opens **COM12** (the device end of
|
||||
the plasma's null-modem pair) on startup — retrying while the port is
|
||||
missing or busy, port status in the title bar. Both decode the display's
|
||||
command stream and render the dot matrix in plasma orange, text mode
|
||||
included; only one can hold COM12 at a time.
|
||||
|
||||
The command set was recovered from two Tesla 4.10 artifacts:
|
||||
`CODE\RP\MUNGA_L4\L4PLASMA.CPP` (the game's driver — it renders everything
|
||||
@@ -125,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,
|
||||
MSB = leftmost pixel. This is everything the game sends, so it is the
|
||||
wire path a pod's plasma actually sees.
|
||||
- **Text** — printable ASCII renders through a 5×7 font at a cursor:
|
||||
fonts 0–3 give a 21×4 cell grid, fonts 4–7 the same glyphs doubled to
|
||||
10×2. Half-intensity draws dimmer, reverse/underline render in the
|
||||
cell, flashing text (and the flashing cursor) blink on the glass. The
|
||||
panel's own ROM glyphs are lost with the hardware, so the classic
|
||||
public-domain 5×7 set stands in.
|
||||
- **Text** — printable ASCII renders at a **pixel-addressed cursor**
|
||||
(`ESC Q` sets row Y 0–31, `ESC R` sets column X 0–127) through the
|
||||
**real ROM character generator** — the eight Babcock fonts recovered
|
||||
from the firmware dump (`ESC K` 0–7): 6×8, 6×10, 7×10, and the large
|
||||
12×16 / 12×20. Attributes (`ESC H`, low 4 bits) — half-intensity
|
||||
draws dimmer, underline/reverse render in the cell, flashing text (and
|
||||
the flashing cursor) blink on the glass. Extracted from the U3 EPROM;
|
||||
see [`PlasmaNew/FIRMWARE.md`](PlasmaNew/FIRMWARE.md). (Deferred, and
|
||||
documented there: the 10 double-buffered pages and vector graphics.)
|
||||
- **Double-click** the glass to cycle three self-test pages (banner,
|
||||
charset, graphics pattern) through the same parser the wire feeds —
|
||||
useful without a host. **Right-click** resets the display to its
|
||||
@@ -146,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.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 |
|
||||
| `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 |
|
||||
|
||||
@@ -19,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.App", "src\VPlasma.
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Core.Tests", "tests\VPlasma.Core.Tests\VPlasma.Core.Tests.csproj", "{F31F1D86-546A-4B0C-A283-C04FAAC49F46}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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}.Release|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{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}
|
||||
{72D03B3A-7D4E-496C-8DA9-596DFC91704E} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
|
||||
{F31F1D86-546A-4B0C-A283-C04FAAC49F46} = {C4993638-7EB6-47A9-897C-976DB9939601}
|
||||
{CA883363-5610-4245-98B1-3192AE652F1A} = {01717774-2AD5-4CF5-A0D7-69AF81A534E8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
+336
-37
@@ -1,90 +1,342 @@
|
||||
using System.IO.Ports;
|
||||
using VPlasma.Core.Device;
|
||||
|
||||
namespace VPlasma.App;
|
||||
|
||||
/// <summary>
|
||||
/// vPLASMA main window: just the plasma glass, sized to the display. The
|
||||
/// device end is hardwired to <see cref="PortName"/> (the plasma side of the
|
||||
/// second com0com pair) and opened automatically — a retry timer keeps
|
||||
/// trying while the port is missing or busy, and reopens it if it dies. The
|
||||
/// title bar carries the port status; double-clicking the glass cycles the
|
||||
/// self-test pages (banner, charset, graphics) through the wire parser, and
|
||||
/// a right-click resets the display to its power-on state.
|
||||
/// vPLASMA main window: the plasma glass on the left, a config panel on the
|
||||
/// right. Connections mirror vRIO: pick an endpoint — the device's named pipe
|
||||
/// (<c>pipe:vplasma</c>, the DOSBox-X fork's namedpipe backend) or a COM port
|
||||
/// (for the game through a com0com null-modem pair) — and Open. Nothing opens
|
||||
/// automatically.
|
||||
///
|
||||
/// <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>
|
||||
internal sealed class MainForm : Form
|
||||
{
|
||||
/// <summary>The plasma's fixed port: the device end of the COM2 pair.</summary>
|
||||
private const string PortName = "COM12";
|
||||
/// <summary>The plasma's pipe endpoint in the picker (matches DOSBox-X's
|
||||
/// <c>serial2=namedpipe pipe:vplasma</c>).</summary>
|
||||
private static readonly string PipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
|
||||
|
||||
private readonly VPlasmaDevice _device = new();
|
||||
private readonly VPlasmaSerialService _service;
|
||||
private readonly VPlasmaPipeService _pipeService;
|
||||
private readonly PlasmaCanvas _canvas = new();
|
||||
|
||||
private readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 };
|
||||
private 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 _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()
|
||||
{
|
||||
Text = $"vPLASMA v{Application.ProductVersion} — Virtual plasma display";
|
||||
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle; // the glass is a fixed-size device
|
||||
MaximizeBox = false;
|
||||
ClientSize = _canvas.Size;
|
||||
ClientSize = new Size(_canvas.Width + 316, Math.Max(_canvas.Height + 8, 580));
|
||||
MinimumSize = new Size(_canvas.Width + 332, 500);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
|
||||
_service = new VPlasmaSerialService(_device);
|
||||
_pipeService = new VPlasmaPipeService(_device);
|
||||
|
||||
_canvas.Location = new Point(0, 0);
|
||||
Controls.Add(_canvas);
|
||||
var backdrop = new Panel { Dock = DockStyle.Fill, BackColor = Color.FromArgb(28, 28, 28) };
|
||||
_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));
|
||||
_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.MouseClick += (_, e) =>
|
||||
{
|
||||
if (e.Button == MouseButtons.Right)
|
||||
_device.Reset();
|
||||
};
|
||||
_canvas.MouseClick += (_, e) => { if (e.Button == MouseButtons.Right) ResetDisplay(); };
|
||||
_selfTest.Click += (_, _) => RunSelfTest();
|
||||
_clear.Click += (_, _) => ResetDisplay();
|
||||
_clearLog.Click += (_, _) => _logBox.Clear();
|
||||
|
||||
// Open at startup, retry while closed (port missing/busy, host restarts).
|
||||
_reconnectTimer.Tick += (_, _) => EnsureOpen();
|
||||
_reconnectTimer.Start();
|
||||
// Functional straps: baud (1+2), orientation (4), display test (5),
|
||||
// demo (6). Jumpers 3 and 7 are tracked toggles.
|
||||
_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 += (_, _) =>
|
||||
{
|
||||
_reconnectTimer.Dispose();
|
||||
_uiTimer.Dispose();
|
||||
_demoTimer.Dispose();
|
||||
_testTimer.Dispose();
|
||||
_service.Dispose();
|
||||
_pipeService.Dispose();
|
||||
};
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
if (_portBox.SelectedItem?.ToString() is not { } endpoint)
|
||||
{
|
||||
PrependLog("No endpoint selected — pick pipe:vplasma or a COM port.");
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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).
|
||||
string status = _service.IsOpen
|
||||
? $"{PortName} @ 9600 8N1"
|
||||
: $"{PortName} unavailable — retrying";
|
||||
Text = $"vPLASMA v{Application.ProductVersion} — {status}";
|
||||
(true, true) => 4800,
|
||||
(false, true) => 9600,
|
||||
(true, false) => 19200,
|
||||
(false, false) => 38400,
|
||||
};
|
||||
|
||||
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()
|
||||
{
|
||||
byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
|
||||
@@ -92,6 +344,53 @@ internal sealed class MainForm : Form
|
||||
_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)
|
||||
{
|
||||
if (IsDisposed || Disposing)
|
||||
|
||||
@@ -11,10 +11,14 @@ namespace VPlasma.App;
|
||||
/// </summary>
|
||||
internal sealed class PlasmaCanvas : Control
|
||||
{
|
||||
private const int DotPitch = 5; // 4 px dot + 1 px gap → a 640×160 dot field
|
||||
private const int DotSize = 4;
|
||||
private const int Bezel = 4;
|
||||
|
||||
// Dot geometry: an N px pitch renders (N−1) px dots with a 1 px gap. The
|
||||
// standalone glass uses the default pitch 5 (4 px dots, a 640×160 dot
|
||||
// field); vRIO embeds the same control at pitch 3 to fit its encoder strip.
|
||||
private readonly int _dotPitch;
|
||||
private readonly int _dotSize;
|
||||
|
||||
private static readonly Color BezelColor = Color.FromArgb(20, 18, 16);
|
||||
private static readonly Color GlassColor = Color.FromArgb(26, 14, 6);
|
||||
private static readonly Color UnlitDot = Color.FromArgb(46, 24, 10);
|
||||
@@ -26,18 +30,18 @@ internal sealed class PlasmaCanvas : Control
|
||||
private bool _blinkPhase = true;
|
||||
private bool _anythingBlinks;
|
||||
|
||||
private VPlasmaDevice.Point _cursor;
|
||||
private int _cursorX, _cursorY;
|
||||
private PlasmaCursorMode _cursorMode;
|
||||
private int _cellWidth = 6, _cellHeight = 8;
|
||||
|
||||
public PlasmaCanvas()
|
||||
public PlasmaCanvas(int dotPitch = 5)
|
||||
{
|
||||
_dotPitch = dotPitch;
|
||||
_dotSize = dotPitch - 1;
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.FixedWidth |
|
||||
ControlStyles.FixedHeight, true);
|
||||
Size = new Size(
|
||||
VPlasmaDevice.Width * DotPitch + 2 * Bezel,
|
||||
VPlasmaDevice.Height * DotPitch + 2 * Bezel);
|
||||
Size = SizeFor(dotPitch);
|
||||
BackColor = BezelColor;
|
||||
|
||||
_blinkTimer.Tick += (_, _) =>
|
||||
@@ -49,14 +53,20 @@ internal sealed class PlasmaCanvas : Control
|
||||
_blinkTimer.Start();
|
||||
}
|
||||
|
||||
/// <summary>Control size at a given dot pitch: the dot field plus bezel.</summary>
|
||||
public static Size SizeFor(int dotPitch) => new(
|
||||
VPlasmaDevice.Width * dotPitch + 2 * Bezel,
|
||||
VPlasmaDevice.Height * dotPitch + 2 * Bezel);
|
||||
|
||||
/// <summary>Snapshot the device state and repaint. Call on the UI thread.</summary>
|
||||
public void UpdateFrom(VPlasmaDevice device)
|
||||
{
|
||||
device.CopyFrame(_frame);
|
||||
_cursor = device.CursorCell;
|
||||
_cursorX = device.CursorX;
|
||||
_cursorY = device.CursorY;
|
||||
_cursorMode = device.CursorMode;
|
||||
_cellWidth = device.CellWidth;
|
||||
_cellHeight = device.CellHeight;
|
||||
_cellWidth = device.FontWidth;
|
||||
_cellHeight = device.FontHeight;
|
||||
|
||||
_anythingBlinks = _cursorMode == PlasmaCursorMode.Flashing;
|
||||
if (!_anythingBlinks)
|
||||
@@ -77,7 +87,7 @@ internal sealed class PlasmaCanvas : Control
|
||||
|
||||
using (var glass = new SolidBrush(GlassColor))
|
||||
g.FillRectangle(glass, Bezel - 4, Bezel - 4,
|
||||
VPlasmaDevice.Width * DotPitch + 7, VPlasmaDevice.Height * DotPitch + 7);
|
||||
VPlasmaDevice.Width * _dotPitch + 7, VPlasmaDevice.Height * _dotPitch + 7);
|
||||
|
||||
using var unlit = new SolidBrush(UnlitDot);
|
||||
using var lit = new SolidBrush(LitDot);
|
||||
@@ -86,7 +96,7 @@ internal sealed class PlasmaCanvas : Control
|
||||
for (int y = 0; y < VPlasmaDevice.Height; ++y)
|
||||
{
|
||||
int rowOffset = y * VPlasmaDevice.Width;
|
||||
int py = Bezel + y * DotPitch;
|
||||
int py = Bezel + y * _dotPitch;
|
||||
for (int x = 0; x < VPlasmaDevice.Width; ++x)
|
||||
{
|
||||
byte dot = _frame[rowOffset + x];
|
||||
@@ -96,7 +106,7 @@ internal sealed class PlasmaCanvas : Control
|
||||
{
|
||||
brush = (dot & VPlasmaDevice.PixelHalf) != 0 ? half : lit;
|
||||
}
|
||||
g.FillRectangle(brush, Bezel + x * DotPitch, py, DotSize, DotSize);
|
||||
g.FillRectangle(brush, Bezel + x * _dotPitch, py, _dotSize, _dotSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,11 +114,11 @@ internal sealed class PlasmaCanvas : Control
|
||||
if (_cursorMode == PlasmaCursorMode.Steady
|
||||
|| (_cursorMode == PlasmaCursorMode.Flashing && _blinkPhase))
|
||||
{
|
||||
int cx = _cursor.Col * _cellWidth;
|
||||
int cy = _cursor.Row * _cellHeight + _cellHeight - 1;
|
||||
int cx = _cursorX;
|
||||
int cy = _cursorY + _cellHeight - 1;
|
||||
if (cy < VPlasmaDevice.Height)
|
||||
for (int i = 0; i < _cellWidth && cx + i < VPlasmaDevice.Width; ++i)
|
||||
g.FillRectangle(lit, Bezel + (cx + i) * DotPitch, Bezel + cy * DotPitch, DotSize, DotSize);
|
||||
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;
|
||||
}
|
||||
@@ -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 '~'
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -50,10 +50,11 @@ public static class PlasmaSelfTest
|
||||
b.Add(PlasmaProtocol.LineFeed);
|
||||
Text(b, "128x32 PLASMA DISPLAY"); // exactly one 21-cell row
|
||||
|
||||
// Attributes are the low 4 bits: 1=half, 2=underline, 4=reverse, 8=flash.
|
||||
Esc(b, PlasmaProtocol.CmdAttributes, 1); Text(b, "HALF ");
|
||||
Esc(b, PlasmaProtocol.CmdAttributes, 2); Text(b, "UNDER ");
|
||||
Esc(b, PlasmaProtocol.CmdAttributes, 3); Text(b, "REV");
|
||||
Esc(b, PlasmaProtocol.CmdAttributes, 4); Text(b, " FLASH");
|
||||
Esc(b, PlasmaProtocol.CmdAttributes, 4); Text(b, "REV");
|
||||
Esc(b, PlasmaProtocol.CmdAttributes, 8); Text(b, " FLASH");
|
||||
Esc(b, PlasmaProtocol.CmdAttributes, PlasmaProtocol.OperandDefault);
|
||||
return b.ToArray();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public enum PlasmaCursorMode
|
||||
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]
|
||||
public enum PlasmaAttributes : byte
|
||||
{
|
||||
@@ -21,23 +21,40 @@ public enum PlasmaAttributes : byte
|
||||
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>
|
||||
/// The plasma display proper: a 128×32 1bpp frame plus the text-mode state
|
||||
/// (cursor, font, attributes), driven by the byte stream a host writes to
|
||||
/// COM2. Feed raw wire bytes to <see cref="OnReceived"/>; the parser is a
|
||||
/// state machine, so commands may arrive split across any chunk boundaries.
|
||||
///
|
||||
/// <para>Pixels carry flags rather than a plain bit: graphics writes set
|
||||
/// full-intensity dots, while text can stamp half-intensity or flashing dots
|
||||
/// (<c>ESC H</c>); the UI renders <see cref="PixelHalf"/> dimmer and blinks
|
||||
/// <see cref="PixelFlash"/>. The command set itself is documented on
|
||||
/// <see cref="PlasmaProtocol"/>.</para>
|
||||
/// <para>Grounded in the real firmware (<c>PlasmaNew/FIRMWARE.md</c>): the
|
||||
/// cursor is a <b>pixel</b> position — <c>ESC Q</c> sets its row (Y),
|
||||
/// <c>ESC R</c> its column (X) — and glyphs are drawn there and advance X by
|
||||
/// the font's width. The eight fonts are the real ROM character generator
|
||||
/// (<see cref="PlasmaFonts"/>). Attributes are the low 4 bits of the
|
||||
/// <c>ESC H</c> operand.</para>
|
||||
///
|
||||
/// <para>Grid geometry: fonts 0–3 are the 5×7 set in a 6×8 cell (21 columns
|
||||
/// × 4 rows), fonts 4–7 the same glyphs doubled into a 12×16 cell
|
||||
/// (10 × 2). Which of the eight slots the real panel mapped to which face is
|
||||
/// lost with the hardware; two sizes cover what the surviving software
|
||||
/// exercises.</para>
|
||||
/// <para><see cref="Orientation"/> mirrors JP1 jumper 4: in Vertical the
|
||||
/// logical space is 32×128 and every dot is rotated onto the physical 128×32
|
||||
/// glass. All drawing goes through <see cref="Plot"/>, so the same code serves
|
||||
/// both orientations. <see cref="ShowTestPattern"/> lights the whole panel —
|
||||
/// 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
|
||||
/// 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;
|
||||
|
||||
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 -------------------------------------------------
|
||||
private int _font; // 0..7
|
||||
private PlasmaFonts.Face _face = PlasmaFonts.Default;
|
||||
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 PlasmaOrientation _orientation = PlasmaOrientation.Horizontal;
|
||||
|
||||
// ---- parser state ----------------------------------------------------
|
||||
private enum State
|
||||
@@ -99,16 +118,58 @@ public sealed class VPlasmaDevice
|
||||
public int Font { get { lock (_sync) return _font; } }
|
||||
public PlasmaAttributes Attributes { get { lock (_sync) return _attributes; } }
|
||||
|
||||
// Current font grid, for the UI's cursor overlay and status line.
|
||||
private int FontScale => _font >= 4 ? 2 : 1;
|
||||
public int CellWidth { get { lock (_sync) return 6 * FontScale; } }
|
||||
public int CellHeight { get { lock (_sync) return 8 * FontScale; } }
|
||||
private int Columns => Width / (6 * FontScale);
|
||||
private int Rows => Height / (8 * FontScale);
|
||||
public Point CursorCell { get { lock (_sync) return new Point(_col, _row); } }
|
||||
/// <summary>Cursor column X in logical pixels.</summary>
|
||||
public int CursorX { get { lock (_sync) return _x; } }
|
||||
/// <summary>Cursor row Y in logical pixels.</summary>
|
||||
public int CursorY { get { lock (_sync) return _y; } }
|
||||
/// <summary>Current font cell width in pixels.</summary>
|
||||
public int FontWidth { get { lock (_sync) return _face.Width; } }
|
||||
/// <summary>Current font cell height in pixels.</summary>
|
||||
public int FontHeight { get { lock (_sync) return _face.Height; } }
|
||||
|
||||
/// <summary>A cursor cell position (avoids dragging in System.Drawing).</summary>
|
||||
public readonly record struct Point(int Col, int Row);
|
||||
/// <summary>Logical drawing width (128 horizontal, 32 vertical).</summary>
|
||||
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>
|
||||
public void CopyFrame(byte[] destination)
|
||||
@@ -119,14 +180,15 @@ public sealed class VPlasmaDevice
|
||||
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()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
Array.Clear(_pixels, 0, _pixels.Length);
|
||||
_col = _row = 0;
|
||||
_x = _y = 0;
|
||||
_font = 0;
|
||||
_face = PlasmaFonts.Default;
|
||||
_attributes = PlasmaAttributes.None;
|
||||
_cursorMode = PlasmaCursorMode.Steady;
|
||||
_state = State.Text;
|
||||
@@ -135,6 +197,41 @@ public sealed class VPlasmaDevice
|
||||
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>
|
||||
public void OnReceived(byte[] buffer, int count)
|
||||
{
|
||||
@@ -187,7 +284,7 @@ public sealed class VPlasmaDevice
|
||||
return;
|
||||
|
||||
case PlasmaProtocol.BackSpace:
|
||||
if (_col > 0) _col--;
|
||||
_x = Math.Max(0, _x - _face.Width);
|
||||
_dirty = true;
|
||||
return;
|
||||
|
||||
@@ -197,17 +294,18 @@ public sealed class VPlasmaDevice
|
||||
return;
|
||||
|
||||
case PlasmaProtocol.LineFeed:
|
||||
_row = (_row + 1) % Rows;
|
||||
NextLine();
|
||||
_dirty = true;
|
||||
return;
|
||||
|
||||
case PlasmaProtocol.VerticalTab:
|
||||
_row = (_row + Rows - 1) % Rows;
|
||||
_y -= _face.Height;
|
||||
if (_y < 0) _y = Math.Max(0, LogicalH - _face.Height);
|
||||
_dirty = true;
|
||||
return;
|
||||
|
||||
case PlasmaProtocol.CarriageReturn:
|
||||
_col = 0;
|
||||
_x = 0;
|
||||
_dirty = true;
|
||||
return;
|
||||
}
|
||||
@@ -232,8 +330,9 @@ public sealed class VPlasmaDevice
|
||||
{
|
||||
case PlasmaProtocol.CmdClearScreen:
|
||||
Array.Clear(_pixels, 0, _pixels.Length);
|
||||
_col = _row = 0;
|
||||
_x = _y = 0;
|
||||
_font = 0;
|
||||
_face = PlasmaFonts.Default;
|
||||
_attributes = PlasmaAttributes.None;
|
||||
_dirty = true;
|
||||
Log("Clear screen (ESC @)");
|
||||
@@ -241,7 +340,7 @@ public sealed class VPlasmaDevice
|
||||
break;
|
||||
|
||||
case PlasmaProtocol.CmdHomeCursor:
|
||||
_col = _row = 0;
|
||||
_x = _y = 0;
|
||||
_dirty = true;
|
||||
Log("Home cursor (ESC L)");
|
||||
_graphicsLogArmed = true;
|
||||
@@ -250,6 +349,10 @@ public sealed class VPlasmaDevice
|
||||
case PlasmaProtocol.CmdCursorMode:
|
||||
case PlasmaProtocol.CmdFontSelect:
|
||||
case PlasmaProtocol.CmdAttributes:
|
||||
case PlasmaProtocol.CmdSetRow:
|
||||
case PlasmaProtocol.CmdSetColumn:
|
||||
case PlasmaProtocol.CmdDrawPage:
|
||||
case PlasmaProtocol.CmdDisplayPage:
|
||||
_pendingCommand = b;
|
||||
_state = State.Operand;
|
||||
break;
|
||||
@@ -282,50 +385,58 @@ public sealed class VPlasmaDevice
|
||||
break;
|
||||
|
||||
case PlasmaProtocol.CmdFontSelect:
|
||||
_font = operand == PlasmaProtocol.OperandDefault ? 0 : operand & 0x07;
|
||||
// The cursor keeps its cell coordinates but the grid changed size.
|
||||
_col = Math.Min(_col, Columns - 1);
|
||||
_row = Math.Min(_row, Rows - 1);
|
||||
_dirty = true;
|
||||
Log($"Font {_font}: {Columns}×{Rows} cells (ESC K {operand:X2})");
|
||||
// Firmware: operands 0–7 select a real font (8 fonts); it range-
|
||||
// checks and ignores anything larger, so ESC K FF is a no-op.
|
||||
if (operand < PlasmaFonts.All.Length)
|
||||
{
|
||||
_font = operand;
|
||||
_face = PlasmaFonts.All[operand];
|
||||
_x = Math.Min(_x, 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;
|
||||
|
||||
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})");
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>ESC H</c>'s operand indexes the style list PLASMA.EXE enumerates
|
||||
/// (its <c>/s</c> option): the 17 intensity/underline/reverse/flash
|
||||
/// combos below, in the tool's own order. FF (and anything out of range)
|
||||
/// restores the defaults.
|
||||
/// </summary>
|
||||
private static PlasmaAttributes DecodeAttributes(byte operand) => operand switch
|
||||
{
|
||||
0 => PlasmaAttributes.None,
|
||||
1 => PlasmaAttributes.HalfIntensity,
|
||||
2 => PlasmaAttributes.Underline,
|
||||
3 => PlasmaAttributes.Reverse,
|
||||
4 => PlasmaAttributes.Flash,
|
||||
5 => PlasmaAttributes.Underline,
|
||||
6 => PlasmaAttributes.Reverse,
|
||||
7 => PlasmaAttributes.Flash,
|
||||
8 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline,
|
||||
9 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Reverse,
|
||||
10 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Flash,
|
||||
11 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
|
||||
12 => PlasmaAttributes.Underline | PlasmaAttributes.Flash,
|
||||
13 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
|
||||
14 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
|
||||
15 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Flash,
|
||||
16 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
|
||||
_ => PlasmaAttributes.None,
|
||||
};
|
||||
|
||||
// ---- graphics writes (ESC P) -------------------------------------------
|
||||
|
||||
private void BeginGraphicsData()
|
||||
@@ -353,18 +464,15 @@ public sealed class VPlasmaDevice
|
||||
|
||||
int y = _header[1] + rowOfBlock;
|
||||
int xByte = _header[2] + byteOfRow;
|
||||
if (y < Height && xByte < WidthBytes)
|
||||
{
|
||||
// MSB is the leftmost pixel (L4PLASMA.CPP packs 0x80 first).
|
||||
// Graphics dots are plain full intensity: overwriting text clears
|
||||
// its half/flash flags, like repainting the glass.
|
||||
int offset = y * Width + xByte * 8;
|
||||
for (int bit = 0; bit < 8; ++bit)
|
||||
_pixels[offset + bit] = (b & (0x80 >> bit)) != 0 ? PixelLit : (byte)0;
|
||||
_dirty = true;
|
||||
if (byteOfRow == w - 1)
|
||||
_graphicsRows++;
|
||||
}
|
||||
// MSB is the leftmost pixel (L4PLASMA.CPP packs 0x80 first). Graphics
|
||||
// dots are plain full intensity; Plot maps them through the orientation.
|
||||
int baseX = xByte * 8;
|
||||
for (int bit = 0; bit < 8; ++bit)
|
||||
Plot(baseX + bit, y, (b & (0x80 >> bit)) != 0 ? PixelLit : (byte)0);
|
||||
_dirty = true;
|
||||
// Count only rows that land on the glass (a fully-clipped row is a no-op).
|
||||
if (byteOfRow == w - 1 && (uint)y < (uint)LogicalH)
|
||||
_graphicsRows++;
|
||||
|
||||
if (++_dataIndex >= _dataLength)
|
||||
_state = State.Text;
|
||||
@@ -374,34 +482,28 @@ public sealed class VPlasmaDevice
|
||||
|
||||
private void DrawChar(byte code)
|
||||
{
|
||||
int scale = FontScale;
|
||||
int cellW = 6 * scale, cellH = 8 * scale;
|
||||
int ox = _col * cellW, oy = _row * cellH;
|
||||
|
||||
Span<byte> columns = stackalloc byte[PlasmaFont.GlyphWidth];
|
||||
PlasmaFont.GetColumns(code, columns);
|
||||
// The firmware ignores a character outside the current font's range.
|
||||
if (!_face.Has(code))
|
||||
return;
|
||||
|
||||
int w = _face.Width, h = _face.Height;
|
||||
bool reverse = (_attributes & PlasmaAttributes.Reverse) != 0;
|
||||
bool underline = (_attributes & PlasmaAttributes.Underline) != 0;
|
||||
byte litFlags = PixelLit;
|
||||
if ((_attributes & PlasmaAttributes.HalfIntensity) != 0) litFlags |= PixelHalf;
|
||||
if ((_attributes & PlasmaAttributes.Flash) != 0) litFlags |= PixelFlash;
|
||||
|
||||
for (int 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
|
||||
int rowOffset = (oy + cy) * Width + ox;
|
||||
for (int cx = 0; cx < cellW; ++cx)
|
||||
ushort bits = _face.Row(code, row); // bit 15 = leftmost pixel
|
||||
for (int col = 0; col < w; ++col)
|
||||
{
|
||||
int glyphCol = cx / scale; // 0..5; column 5 is the gap column
|
||||
bool on = glyphCol < PlasmaFont.GlyphWidth
|
||||
&& glyphRow < PlasmaFont.GlyphHeight
|
||||
&& (columns[glyphCol] >> glyphRow & 1) != 0;
|
||||
if (underline && glyphRow == 7)
|
||||
bool on = (bits & (0x8000 >> col)) != 0;
|
||||
if (underline && row == h - 1)
|
||||
on = true;
|
||||
if (reverse)
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Advance the cursor one cell, wrapping at the right/bottom edges.</summary>
|
||||
private void AdvanceCursor()
|
||||
{
|
||||
if (++_col >= Columns)
|
||||
{
|
||||
_col = 0;
|
||||
// No scroll on these panels: writing past the last row wraps to the top.
|
||||
if (++_row >= Rows)
|
||||
_row = 0;
|
||||
}
|
||||
_x += _face.Width;
|
||||
if (_x > LogicalW - _face.Width)
|
||||
NextLine();
|
||||
}
|
||||
|
||||
private void NextLine()
|
||||
{
|
||||
_x = 0;
|
||||
_y += _face.Height;
|
||||
// No scroll on these panels: past the last line wraps to the top.
|
||||
if (_y > LogicalH - _face.Height)
|
||||
_y = 0;
|
||||
}
|
||||
|
||||
// ---- 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));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public bool IsOpen => _port?.IsOpen == true;
|
||||
|
||||
@@ -47,7 +55,7 @@ public sealed class VPlasmaSerialService : IDisposable
|
||||
|
||||
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,
|
||||
// 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.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);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,17 @@ public static class PlasmaProtocol
|
||||
public const byte CmdFontSelect = (byte)'K';
|
||||
public const byte CmdHomeCursor = (byte)'L';
|
||||
public const byte CmdGraphicsWrite = (byte)'P';
|
||||
// Cursor positioning, confirmed from the firmware dump (FIRMWARE.md): the
|
||||
// cursor is a pixel position, ESC Q sets its row (Y, 0-31) and ESC R its
|
||||
// column (X, 0-127) — the exact range checks the firmware enforces.
|
||||
public const byte CmdSetRow = (byte)'Q';
|
||||
public const byte CmdSetColumn = (byte)'R';
|
||||
// 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>
|
||||
public const byte OperandDefault = 0xFF;
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Memory" Version="4.5.5" />
|
||||
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
|
||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
+190
-67
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Ports;
|
||||
using VPlasma.App;
|
||||
using VPlasma.Core.Device;
|
||||
using VRio.Core.Device;
|
||||
using VRio.Core.Input;
|
||||
|
||||
@@ -8,24 +10,43 @@ namespace VRio.App;
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// right — COM port, device settings, and a live wire log. At startup the
|
||||
/// usual port (<see cref="PreferredPort"/>) is opened automatically when it's
|
||||
/// available; otherwise open a COM port by hand. Point RIOJoy at the other
|
||||
/// end of the null-modem pair, and every click here arrives at RIOJoy exactly
|
||||
/// as if the physical cockpit sent it.
|
||||
/// right — connection rows, device settings, and a live wire log. The
|
||||
/// panel's encoder strip also hosts the built-in plasma glass (the vPLASMA
|
||||
/// display emulator) on its own connection row. Each row's picker offers
|
||||
/// the device's named-pipe endpoint (the DOSBox-X fork's namedpipe backend
|
||||
/// — the com0com-free path) alongside the machine's COM ports; nothing
|
||||
/// opens automatically — pick an endpoint and Open. Point RIOJoy at the
|
||||
/// other end of a null-modem pair and every click here arrives exactly as
|
||||
/// if the physical cockpit sent it; point the game at
|
||||
/// <see cref="RioPipeEndpoint"/>/<see cref="PlasmaPipeEndpoint"/> (or the
|
||||
/// COM2 passthrough at the plasma pair) and the glass lights up.
|
||||
/// </summary>
|
||||
internal sealed class MainForm : Form
|
||||
{
|
||||
/// <summary>
|
||||
/// vRIO's usual port: the device end of the COM1⇄COM11 com0com pair.
|
||||
/// Auto-opened at startup when present and free; the picker still allows
|
||||
/// any other port.
|
||||
/// The RIO's pipe endpoint as listed in the picker. Matches the
|
||||
/// DOSBox-X conf syntax: <c>serial1=namedpipe pipe:vrio</c>.
|
||||
/// </summary>
|
||||
private const string PreferredPort = "COM11";
|
||||
private static readonly string RioPipeEndpoint = $"pipe:{VRioPipeService.DefaultPipeName}";
|
||||
|
||||
/// <summary>
|
||||
/// The plasma's pipe endpoint as listed in the picker
|
||||
/// (<c>serial2=namedpipe pipe:vplasma</c>).
|
||||
/// </summary>
|
||||
private static readonly string PlasmaPipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
|
||||
|
||||
private readonly VRioDevice _device = new();
|
||||
private readonly VRioSerialService _service;
|
||||
private readonly VRioPipeService _pipeService;
|
||||
private readonly VPlasmaDevice _plasmaDevice = new();
|
||||
private readonly VPlasmaSerialService _plasmaService;
|
||||
private readonly VPlasmaPipeService _plasmaPipeService;
|
||||
private readonly PanelCanvas _canvas = new();
|
||||
private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch)
|
||||
{
|
||||
Location = PanelCanvas.PlasmaSlot,
|
||||
};
|
||||
private int _selfTestPage;
|
||||
private readonly InputRouter _router;
|
||||
private readonly XInputGamepad _gamepad = new();
|
||||
private readonly KeyboardLampMirror _lampMirror;
|
||||
@@ -34,6 +55,16 @@ internal sealed class MainForm : Form
|
||||
private readonly string _bindingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
|
||||
|
||||
// Port rows: one line per built-in device — a name label whose colour is
|
||||
// the port status (green = open, gray = closed), the port picker, and an
|
||||
// Open/Close button. The one Rescan button refreshes both pickers.
|
||||
private readonly Label _rioLabel = new()
|
||||
{
|
||||
Text = "vRIO",
|
||||
Location = new Point(12, 15),
|
||||
AutoSize = true,
|
||||
ForeColor = Color.Gray,
|
||||
};
|
||||
private readonly ComboBox _portBox = new()
|
||||
{
|
||||
Location = new Point(80, 12),
|
||||
@@ -42,13 +73,20 @@ internal sealed class MainForm : Form
|
||||
};
|
||||
private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 };
|
||||
private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 };
|
||||
private readonly Label _linkStatus = new()
|
||||
private readonly Label _plasmaLabel = new()
|
||||
{
|
||||
Text = "Port closed.",
|
||||
Location = new Point(12, 42),
|
||||
Text = "vPLASMA",
|
||||
Location = new Point(12, 45),
|
||||
AutoSize = true,
|
||||
ForeColor = Color.Gray,
|
||||
};
|
||||
private readonly ComboBox _plasmaPortBox = new()
|
||||
{
|
||||
Location = new Point(80, 42),
|
||||
Width = 128,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
};
|
||||
private readonly Button _plasmaOpenClose = new() { Text = "Open", Location = new Point(272, 41), Width = 46 };
|
||||
|
||||
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true };
|
||||
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 };
|
||||
@@ -112,7 +150,8 @@ internal sealed class MainForm : Form
|
||||
AutoSize = true,
|
||||
ForeColor = Color.Gray,
|
||||
Text = "Left-click a cell: momentary press. Right-click: latch it down. " +
|
||||
"Drag the X/Y box and the Z / L / R gauges to move the axes.",
|
||||
"Drag the X/Y box and the Z / L / R gauges to move the axes. " +
|
||||
"Double-click the plasma glass to cycle its self-test pages; right-click it to reset.",
|
||||
};
|
||||
|
||||
private readonly TextBox _logBox = new()
|
||||
@@ -153,11 +192,18 @@ internal sealed class MainForm : Form
|
||||
KeyPreview = true; // form-level key routing for the input bindings
|
||||
|
||||
_service = new VRioSerialService(_device);
|
||||
_plasmaService = new VPlasmaSerialService(_plasmaDevice);
|
||||
// The named-pipe endpoints for DOSBox-X's namedpipe serial backend,
|
||||
// offered in the connection pickers alongside the COM ports.
|
||||
_pipeService = new VRioPipeService(_device);
|
||||
_plasmaPipeService = new VPlasmaPipeService(_plasmaDevice);
|
||||
_router = new InputRouter(_device);
|
||||
_lampMirror = new KeyboardLampMirror(_device);
|
||||
|
||||
// Panel canvas, scrolled if the window is smaller than the grid.
|
||||
// Panel canvas, scrolled if the window is smaller than the grid. The
|
||||
// plasma glass rides along as a child in the encoder strip's left slot.
|
||||
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
||||
_canvas.Controls.Add(_plasmaCanvas);
|
||||
scroller.Controls.Add(_canvas);
|
||||
|
||||
Controls.Add(scroller);
|
||||
@@ -182,12 +228,30 @@ internal sealed class MainForm : Form
|
||||
_device.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
|
||||
_service.ConnectionChanged += _ => RunOnUi(UpdateRioEndpointUi);
|
||||
_service.HostHandshake += high => RunOnUi(() =>
|
||||
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
|
||||
_pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}"));
|
||||
_pipeService.HostHandshake += high => RunOnUi(() =>
|
||||
PrependLog(high ? "Pipe host raised DTR (board-reset handshake)" : "Pipe host dropped DTR"));
|
||||
_plasmaPipeService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA pipe: {line}"));
|
||||
|
||||
// Built-in plasma glass: a pure listener on its own port, same gestures
|
||||
// as the standalone vPLASMA — double-click cycles the self-test pages,
|
||||
// right-click resets the display to its power-on state.
|
||||
_plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice));
|
||||
_plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}"));
|
||||
_plasmaService.ConnectionChanged += _ => RunOnUi(UpdatePlasmaEndpointUi);
|
||||
_plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest();
|
||||
_plasmaCanvas.MouseClick += (_, e) =>
|
||||
{
|
||||
if (e.Button == MouseButtons.Right)
|
||||
_plasmaDevice.Reset();
|
||||
};
|
||||
|
||||
_rescan.Click += (_, _) => RefreshPorts();
|
||||
_openClose.Click += (_, _) => ToggleOpen();
|
||||
_plasmaOpenClose.Click += (_, _) => TogglePlasmaOpen();
|
||||
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
|
||||
_centerAxes.Click += (_, _) =>
|
||||
{
|
||||
@@ -251,13 +315,17 @@ internal sealed class MainForm : Form
|
||||
_lampMirror.Dispose();
|
||||
_rawKeyboard.Dispose();
|
||||
_service.Dispose();
|
||||
_plasmaService.Dispose();
|
||||
_pipeService.Dispose();
|
||||
_plasmaPipeService.Dispose();
|
||||
};
|
||||
|
||||
RefreshPorts();
|
||||
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();
|
||||
AutoOpenPreferredPort();
|
||||
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
|
||||
}
|
||||
|
||||
private Panel BuildControlStrip()
|
||||
@@ -275,11 +343,13 @@ internal sealed class MainForm : Form
|
||||
BorderStyle = BorderStyle.FixedSingle,
|
||||
};
|
||||
|
||||
panel.Controls.Add(new Label { Text = "COM port:", Location = new Point(12, 15), AutoSize = true });
|
||||
panel.Controls.Add(_rioLabel);
|
||||
panel.Controls.Add(_portBox);
|
||||
panel.Controls.Add(_rescan);
|
||||
panel.Controls.Add(_openClose);
|
||||
panel.Controls.Add(_linkStatus);
|
||||
panel.Controls.Add(_plasmaLabel);
|
||||
panel.Controls.Add(_plasmaPortBox);
|
||||
panel.Controls.Add(_plasmaOpenClose);
|
||||
|
||||
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) };
|
||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
|
||||
@@ -302,82 +372,132 @@ internal sealed class MainForm : Form
|
||||
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()
|
||||
{
|
||||
string? current = _portBox.SelectedItem?.ToString();
|
||||
_portBox.Items.Clear();
|
||||
foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
|
||||
_portBox.Items.Add(name);
|
||||
|
||||
if (_portBox.Items.Count == 0)
|
||||
return;
|
||||
int idx = current is null ? -1 : _portBox.Items.IndexOf(current);
|
||||
_portBox.SelectedIndex = idx >= 0 ? idx : 0;
|
||||
string[] names = SerialPort.GetPortNames().Distinct()
|
||||
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
// An open row's picker is disabled and shows the served endpoint;
|
||||
// leave it alone so the display can't drift from reality.
|
||||
if (!RioOpen)
|
||||
RefreshPicker(_portBox, RioPipeEndpoint, names);
|
||||
if (!PlasmaOpen)
|
||||
RefreshPicker(_plasmaPortBox, PlasmaPipeEndpoint, names);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startup convenience: if <see cref="PreferredPort"/> 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 picker in charge.
|
||||
/// </summary>
|
||||
private void AutoOpenPreferredPort()
|
||||
private static void RefreshPicker(ComboBox box, string pipeEndpoint, string[] portNames)
|
||||
{
|
||||
int idx = _portBox.Items.IndexOf(PreferredPort);
|
||||
if (idx < 0)
|
||||
{
|
||||
PrependLog($"{PreferredPort} not present — pick a port and open it manually.");
|
||||
return;
|
||||
}
|
||||
string? current = box.SelectedItem?.ToString();
|
||||
box.Items.Clear();
|
||||
box.Items.Add(pipeEndpoint); // always present — a pipe server needs no hardware
|
||||
foreach (string name in portNames)
|
||||
box.Items.Add(name);
|
||||
|
||||
_portBox.SelectedIndex = idx;
|
||||
try
|
||||
{
|
||||
_service.Open(PreferredPort);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
|
||||
{
|
||||
PrependLog($"{PreferredPort} is present but could not be opened ({ex.Message.TrimEnd('.')}) — open it manually once it frees up.");
|
||||
}
|
||||
int idx = current is null ? -1 : box.Items.IndexOf(current);
|
||||
box.SelectedIndex = idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
private void ToggleOpen()
|
||||
{
|
||||
if (_service.IsOpen)
|
||||
if (RioOpen)
|
||||
{
|
||||
_service.Close();
|
||||
return;
|
||||
_service.Close(); // idempotent —
|
||||
_pipeService.Stop(); // whichever was serving closes
|
||||
UpdateRioEndpointUi();
|
||||
}
|
||||
|
||||
if (_portBox.SelectedItem?.ToString() is not { } port)
|
||||
else
|
||||
{
|
||||
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " +
|
||||
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
OpenFromPicker(_portBox, OpenRioEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
private void TogglePlasmaOpen()
|
||||
{
|
||||
if (PlasmaOpen)
|
||||
{
|
||||
_plasmaService.Close();
|
||||
_plasmaPipeService.Stop();
|
||||
UpdatePlasmaEndpointUi();
|
||||
}
|
||||
else
|
||||
{
|
||||
OpenFromPicker(_plasmaPortBox, OpenPlasmaEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenRioEndpoint(string endpoint)
|
||||
{
|
||||
if (endpoint == RioPipeEndpoint)
|
||||
_pipeService.Start();
|
||||
else
|
||||
_service.Open(endpoint);
|
||||
UpdateRioEndpointUi();
|
||||
}
|
||||
|
||||
private void OpenPlasmaEndpoint(string endpoint)
|
||||
{
|
||||
if (endpoint == PlasmaPipeEndpoint)
|
||||
_plasmaPipeService.Start();
|
||||
else
|
||||
_plasmaService.Open(endpoint);
|
||||
UpdatePlasmaEndpointUi();
|
||||
}
|
||||
|
||||
private void OpenFromPicker(ComboBox box, Action<string> open)
|
||||
{
|
||||
if (box.SelectedItem?.ToString() is not { } endpoint)
|
||||
{
|
||||
MessageBox.Show(this, "No endpoint selected. Pick the device's named pipe (for " +
|
||||
"DOSBox-X's namedpipe backend) or a COM port (for RIOJoy through a com0com " +
|
||||
"null-modem pair).", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_service.Open(port);
|
||||
open(endpoint);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionChanged(bool open)
|
||||
private void UpdateRioEndpointUi()
|
||||
{
|
||||
bool open = RioOpen;
|
||||
_openClose.Text = open ? "Close" : "Open";
|
||||
_portBox.Enabled = _rescan.Enabled = !open;
|
||||
_linkStatus.Text = open ? $"Serving {_service.PortName} @ 9600 8N1." : "Port closed.";
|
||||
_linkStatus.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
||||
_portBox.Enabled = !open;
|
||||
_rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
||||
UpdateStatus();
|
||||
}
|
||||
|
||||
private void UpdatePlasmaEndpointUi()
|
||||
{
|
||||
bool open = PlasmaOpen;
|
||||
_plasmaOpenClose.Text = open ? "Close" : "Open";
|
||||
_plasmaPortBox.Enabled = !open;
|
||||
_plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed the next canned self-test page through the plasma's wire parser —
|
||||
/// the same end-to-end exercise the standalone vPLASMA runs on double-click.
|
||||
/// </summary>
|
||||
private void RunPlasmaSelfTest()
|
||||
{
|
||||
byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
|
||||
_plasmaDevice.OnReceived(bytes, bytes.Length);
|
||||
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
|
||||
}
|
||||
|
||||
// ---- Keyboard / gamepad input -------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
@@ -588,12 +708,15 @@ internal sealed class MainForm : Form
|
||||
$"Analog polls dropped: {dropped}\n" +
|
||||
$"Bad checksums: {bad}";
|
||||
|
||||
_canvas.StatusText = _service.IsOpen
|
||||
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" +
|
||||
string? endpoint = _service.IsOpen ? _service.PortName
|
||||
: _pipeService.IsListening ? RioPipeEndpoint
|
||||
: null;
|
||||
_canvas.StatusText = endpoint is not null
|
||||
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {endpoint}\n" +
|
||||
(wedged
|
||||
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
|
||||
: $"{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)
|
||||
|
||||
+44
-18
@@ -1,3 +1,4 @@
|
||||
using VPlasma.App;
|
||||
using VRio.Core.Device;
|
||||
using VRio.Core.Panel;
|
||||
using VRio.Core.Protocol;
|
||||
@@ -16,6 +17,10 @@ namespace VRio.App;
|
||||
/// fill toward full travel (negative on the wire for the throttle, positive
|
||||
/// for the pedals), and the stick covers ±80 around its center.
|
||||
///
|
||||
/// <para>The strip's left slot hosts the built-in plasma glass: MainForm parks
|
||||
/// its <see cref="PlasmaCanvas"/> child at <see cref="PlasmaSlot"/>, and the
|
||||
/// strip height and gauge positions derive from the glass size.</para>
|
||||
///
|
||||
/// <para>Left-click = momentary press (release on mouse-up). Right-click =
|
||||
/// latch the button down / release it (handy for testing holds).</para>
|
||||
/// </summary>
|
||||
@@ -24,16 +29,27 @@ internal sealed class PanelCanvas : Control
|
||||
// Cell geometry — identical to RIOJoy's PanelView so the two panels align.
|
||||
private const int CellW = 66;
|
||||
private const int CellH = 34;
|
||||
private const int TopStrip = 78; // encoder strip: gauges end at 74, small gap to the grid
|
||||
|
||||
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitLayout.Buttons();
|
||||
|
||||
// Encoder-strip geometry (same placement as RIOJoy's PanelView): Z gauge,
|
||||
// the pedal gauges L / R, and the X/Y stick box.
|
||||
// First grid row any group occupies (the shared layout leaves row 0
|
||||
// empty). Rendering shifts all rows up by this, so the button grid starts
|
||||
// right under the encoder strip instead of a blank cell row below it.
|
||||
private static readonly int FirstRow = CockpitLayout.Groups.Min(g => g.OriginRow);
|
||||
|
||||
// Encoder-strip geometry. The built-in plasma glass parks at the strip's
|
||||
// left edge (MainForm adds its PlasmaCanvas as a child at PlasmaSlot),
|
||||
// the axis gauges — Z, the pedal gauges L / R, and the X/Y stick box —
|
||||
// hug the grid's right edge, and the green status text fills the gap
|
||||
// between them. The strip is exactly as tall as the glass.
|
||||
internal const int PlasmaDotPitch = 3; // 2 px dots: the 128×32 glass fits the strip
|
||||
internal static readonly Size PlasmaSize = PlasmaCanvas.SizeFor(PlasmaDotPitch);
|
||||
private const int StripTop = 6;
|
||||
private const int StripH = 68;
|
||||
internal static readonly Point PlasmaSlot = new(6, StripTop);
|
||||
private static readonly int StripH = PlasmaSize.Height;
|
||||
private static readonly int TopStrip = StripTop + StripH + 6; // button grid starts under the strip
|
||||
private const int Bar = 30;
|
||||
private static readonly int BaseX = 6 * CellW - 100;
|
||||
private static readonly int BaseX = GridWidth() - 6 - 200; // the gauge cluster spans 200 px, right-aligned
|
||||
private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH);
|
||||
private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH);
|
||||
private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH);
|
||||
@@ -109,19 +125,27 @@ internal sealed class PanelCanvas : Control
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
// Split from GridSize so the static gauge layout can use the width
|
||||
// without touching TopStrip (which initializes after AllButtons).
|
||||
private static int GridWidth()
|
||||
{
|
||||
int maxCol = 0;
|
||||
foreach (PanelButton b in AllButtons)
|
||||
if (b.Col > maxCol) maxCol = b.Col;
|
||||
return (maxCol + 1) * CellW + 6;
|
||||
}
|
||||
|
||||
private static Size GridSize()
|
||||
{
|
||||
int maxCol = 0, maxRow = 0;
|
||||
int maxRow = 0;
|
||||
foreach (PanelButton b in AllButtons)
|
||||
{
|
||||
if (b.Col > maxCol) maxCol = b.Col;
|
||||
if (b.Row > maxRow) maxRow = b.Row;
|
||||
}
|
||||
return new Size((maxCol + 1) * CellW + 6, TopStrip + (maxRow + 2) * CellH);
|
||||
// The last used row plus a small margin under the lowest group frame.
|
||||
return new Size(GridWidth(), TopStrip + (maxRow - FirstRow + 1) * CellH + 6);
|
||||
}
|
||||
|
||||
private static Rectangle Cell(int col, int row) =>
|
||||
new(col * CellW + 2, TopStrip + row * CellH + 2, CellW - 4, CellH - 4);
|
||||
new(col * CellW + 2, TopStrip + (row - FirstRow) * CellH + 2, CellW - 4, CellH - 4);
|
||||
|
||||
// ---- Painting ----------------------------------------------------------
|
||||
|
||||
@@ -142,7 +166,7 @@ internal sealed class PanelCanvas : Control
|
||||
{
|
||||
var frame = new Rectangle(
|
||||
grp.OriginCol * CellW + 1,
|
||||
TopStrip + grp.OriginRow * CellH + 1,
|
||||
TopStrip + (grp.OriginRow - FirstRow) * CellH + 1,
|
||||
grp.Cols * CellW,
|
||||
(grp.Rows + 1) * CellH);
|
||||
g.DrawRectangle(groupPen, frame);
|
||||
@@ -288,10 +312,12 @@ internal sealed class PanelCanvas : Control
|
||||
new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom);
|
||||
|
||||
// The mockup's green status/instruction area, right of the gauges; the
|
||||
// live axis readout sits directly under the status lines (painted per
|
||||
// frame, so drags track).
|
||||
var statusRect = new Rectangle(BoxXY.Right + 16, StripTop, Width - BoxXY.Right - 24, TopStrip - StripTop - 6);
|
||||
// The mockup's green status/instruction area, between the plasma glass
|
||||
// and the gauges; the live axis readout sits directly under the status
|
||||
// lines (painted per frame, so drags track).
|
||||
var statusRect = new Rectangle(
|
||||
PlasmaSlot.X + PlasmaSize.Width + 12, StripTop,
|
||||
BoxZ.Left - PlasmaSlot.X - PlasmaSize.Width - 24, StripH);
|
||||
if (statusRect.Width > 60)
|
||||
{
|
||||
using var statusFont = new Font("Consolas", 8f);
|
||||
@@ -307,11 +333,11 @@ internal sealed class PanelCanvas : Control
|
||||
|
||||
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
|
||||
string readout =
|
||||
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3} " +
|
||||
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3}\n" +
|
||||
$"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}";
|
||||
TextRenderer.DrawText(g, readout, statusFont,
|
||||
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
|
||||
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.SingleLine);
|
||||
TextFormatFlags.Left | TextFormatFlags.Top);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VRio.Core\VRio.Core.csproj" />
|
||||
<!-- The built-in plasma glass: vPLASMA's device/protocol core, plus its
|
||||
canvas control compiled in directly (shared file, not a library —
|
||||
VPlasma.Core stays UI-free). -->
|
||||
<ProjectReference Include="..\VPlasma.Core\VPlasma.Core.csproj" />
|
||||
<Compile Include="..\VPlasma.App\PlasmaCanvas.cs" Link="PlasmaCanvas.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Stamp commit date + short sha into InformationalVersion; the window
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
namespace VRio.Core.Device;
|
||||
|
||||
/// <summary>
|
||||
/// The framing shared with the DOSBox-X fork's <c>namedpipe</c> serial
|
||||
/// backend (its <c>serialnamedpipe.h</c> header comment is the contract's
|
||||
/// source of truth on that side). A pipe is a plain byte stream, so serial
|
||||
/// data and modem control lines are multiplexed as typed frames:
|
||||
///
|
||||
/// <code>
|
||||
/// 0x00 <len:u8> <len bytes> serial data, len ≥ 1 (batching allowed)
|
||||
/// 0x01 <lines:u8> the sender's OWN output lines (bit0 DTR,
|
||||
/// bit1 RTS); the receiver applies the
|
||||
/// null-modem cross: peer DTR → local DSR,
|
||||
/// peer RTS → local CTS
|
||||
/// </code>
|
||||
///
|
||||
/// Each side sends one lines frame immediately on connect; until it arrives
|
||||
/// the peer's lines are assumed low, and a disconnect drops them low again.
|
||||
/// Any other frame type is a protocol bug, not line noise — pipes don't drop
|
||||
/// bytes — so the receiver logs it and drops the connection instead of
|
||||
/// trying to resync.
|
||||
/// </summary>
|
||||
public static class PipeFraming
|
||||
{
|
||||
public const byte DataType = 0x00;
|
||||
public const byte LinesType = 0x01;
|
||||
|
||||
/// <summary>Lines-frame bit: the sender's DTR output.</summary>
|
||||
public const byte LineDtr = 0x01;
|
||||
|
||||
/// <summary>Lines-frame bit: the sender's RTS output.</summary>
|
||||
public const byte LineRts = 0x02;
|
||||
|
||||
/// <summary>Both outputs asserted — what a present, powered device reports.</summary>
|
||||
public const byte LinesPresent = LineDtr | LineRts;
|
||||
|
||||
/// <summary>Build a lines frame carrying <paramref name="lines"/>.</summary>
|
||||
public static byte[] EncodeLines(byte lines) => new[] { LinesType, lines };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incremental decoder for <see cref="PipeFraming"/>: feed it raw pipe reads,
|
||||
/// get one <see cref="Data"/> event per complete data frame and one
|
||||
/// <see cref="Lines"/> event per lines frame. Frames may split across reads
|
||||
/// at any byte boundary. A malformed stream (unknown type, zero-length data
|
||||
/// frame) poisons the decoder: <see cref="Feed"/> returns false and
|
||||
/// <see cref="Violation"/> says why — drop the connection and
|
||||
/// <see cref="Reset"/> before serving the next one.
|
||||
/// </summary>
|
||||
public sealed class PipeFrameDecoder
|
||||
{
|
||||
private enum State { Type, Length, Payload, Lines }
|
||||
|
||||
private readonly byte[] _payload = new byte[byte.MaxValue];
|
||||
private State _state;
|
||||
private int _fill, _length;
|
||||
|
||||
/// <summary>
|
||||
/// A complete data frame's payload as (buffer, count). The buffer is
|
||||
/// reused across frames — consume it synchronously.
|
||||
/// </summary>
|
||||
public event Action<byte[], int>? Data;
|
||||
|
||||
/// <summary>A lines frame's bits (see <see cref="PipeFraming.LineDtr"/>).</summary>
|
||||
public event Action<byte>? Lines;
|
||||
|
||||
/// <summary>Why the stream was rejected, or null while it is healthy.</summary>
|
||||
public string? Violation { get; private set; }
|
||||
|
||||
/// <summary>Forget any partial frame and clear a violation (new connection).</summary>
|
||||
public void Reset()
|
||||
{
|
||||
_state = State.Type;
|
||||
_fill = _length = 0;
|
||||
Violation = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consume <paramref name="count"/> received bytes from
|
||||
/// <paramref name="buffer"/>. Returns false when the stream violates the
|
||||
/// framing contract (see <see cref="Violation"/>); a poisoned decoder
|
||||
/// keeps returning false until <see cref="Reset"/>.
|
||||
/// </summary>
|
||||
public bool Feed(byte[] buffer, int count)
|
||||
{
|
||||
if (Violation is not null)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
byte b = buffer[i];
|
||||
switch (_state)
|
||||
{
|
||||
case State.Type when b == PipeFraming.DataType:
|
||||
_state = State.Length;
|
||||
break;
|
||||
|
||||
case State.Type when b == PipeFraming.LinesType:
|
||||
_state = State.Lines;
|
||||
break;
|
||||
|
||||
case State.Type:
|
||||
Violation = $"unknown frame type 0x{b:X2}";
|
||||
return false;
|
||||
|
||||
case State.Length when b == 0:
|
||||
Violation = "zero-length data frame";
|
||||
return false;
|
||||
|
||||
case State.Length:
|
||||
_length = b;
|
||||
_fill = 0;
|
||||
_state = State.Payload;
|
||||
break;
|
||||
|
||||
case State.Payload:
|
||||
_payload[_fill++] = b;
|
||||
if (_fill == _length)
|
||||
{
|
||||
_state = State.Type;
|
||||
Data?.Invoke(_payload, _length);
|
||||
}
|
||||
break;
|
||||
|
||||
case State.Lines:
|
||||
_state = State.Type;
|
||||
Lines?.Invoke(b);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Memory" Version="4.5.5" />
|
||||
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
|
||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -53,7 +53,6 @@ public class VPlasmaDeviceTests
|
||||
{
|
||||
byte[] wire = GraphicsRow(3, Enumerable.Repeat((byte)0xFF, 16).ToArray());
|
||||
|
||||
// Split the same command at every possible boundary.
|
||||
for (int split = 1; split < wire.Length; ++split)
|
||||
{
|
||||
var device = new VPlasmaDevice();
|
||||
@@ -71,7 +70,6 @@ public class VPlasmaDeviceTests
|
||||
public void GraphicsBlock_MultipleRowsAdvanceY()
|
||||
{
|
||||
var device = new VPlasmaDevice();
|
||||
// screen 0, y=10, xbyte=0, 1 byte/row, 3 rows.
|
||||
Feed(device, Esc, (byte)'P', 0, 10, 0, 1, 3, 0x80, 0x80, 0x80);
|
||||
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 10));
|
||||
@@ -98,10 +96,8 @@ public class VPlasmaDeviceTests
|
||||
{
|
||||
var device = new VPlasmaDevice();
|
||||
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(0, device.GraphicsRows);
|
||||
}
|
||||
@@ -110,64 +106,83 @@ public class VPlasmaDeviceTests
|
||||
public void GraphicsWrite_OverwritesTextAttributes()
|
||||
{
|
||||
var device = new VPlasmaDevice();
|
||||
Feed(device, Esc, (byte)'H', 4); // flashing text
|
||||
Feed(device, (byte)'H'); // glyph col 0 is a full bar at x=0
|
||||
Feed(device, Esc, (byte)'H', 8); // flashing text
|
||||
Feed(device, (byte)'H'); // font-0 'H' lights (0,0)
|
||||
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelFlash, Pixel(device, 0, 0));
|
||||
|
||||
Feed(device, GraphicsRow(0, 0x80)); // repaint that row from the wire
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||
}
|
||||
|
||||
// ---- text mode ---------------------------------------------------------
|
||||
// ---- text mode (real font 0 = 6×8) -----------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Text_DrawsGlyphAndAdvancesCursor()
|
||||
public void Text_DrawsRealGlyphAndAdvancesByFontWidth()
|
||||
{
|
||||
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, 6));
|
||||
Assert.Equal(0, Pixel(device, 0, 7)); // gap row
|
||||
Assert.Equal(0, Pixel(device, 5, 0)); // gap column
|
||||
Assert.Equal(new VPlasmaDevice.Point(1, 0), device.CursorCell);
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 4, 0));
|
||||
Assert.Equal(0, Pixel(device, 1, 0));
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 3)); // crossbar row
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 4, 3));
|
||||
Assert.Equal(0, Pixel(device, 0, 7)); // blank bottom row
|
||||
Assert.Equal(6, device.CursorX); // advanced one 6-px cell
|
||||
Assert.Equal(0, device.CursorY);
|
||||
Assert.Equal(1, device.TextCharsDrawn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ControlChars_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
|
||||
Assert.Equal(new VPlasmaDevice.Point(3, 0), device.CursorCell);
|
||||
Feed(device, 0x09, 0x09, 0x09); // HT ×3 → x = 18
|
||||
Assert.Equal(18, device.CursorX);
|
||||
|
||||
Feed(device, 0x08); // BS
|
||||
Assert.Equal(new VPlasmaDevice.Point(2, 0), device.CursorCell);
|
||||
Feed(device, 0x08); // BS → x = 12
|
||||
Assert.Equal(12, device.CursorX);
|
||||
|
||||
Feed(device, 0x0A); // LF
|
||||
Assert.Equal(new VPlasmaDevice.Point(2, 1), device.CursorCell);
|
||||
Feed(device, 0x0A); // LF → y = 8
|
||||
Assert.Equal(8, device.CursorY);
|
||||
Assert.Equal(0, device.CursorX);
|
||||
|
||||
Feed(device, 0x0D); // CR
|
||||
Assert.Equal(new VPlasmaDevice.Point(0, 1), device.CursorCell);
|
||||
Feed(device, (byte)'H', 0x0D); // draw + CR → x back to 0
|
||||
Assert.Equal(0, device.CursorX);
|
||||
|
||||
Feed(device, 0x0B); // VT
|
||||
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
|
||||
|
||||
Feed(device, 0x0B); // VT off the top wraps to the bottom row
|
||||
Assert.Equal(new VPlasmaDevice.Point(0, 3), device.CursorCell);
|
||||
Feed(device, 0x0B); // VT → y back to 0
|
||||
Assert.Equal(0, device.CursorY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Text_WrapsAtRowAndScreenEnd()
|
||||
public void EscQ_EscR_SetPixelCursor()
|
||||
{
|
||||
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
|
||||
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell); // wrapped to top
|
||||
Feed(device, Esc, (byte)'Q', 40); // out of range (>31): ignored
|
||||
Assert.Equal(20, device.CursorY);
|
||||
Feed(device, Esc, (byte)'R', 200); // out of range (>127): ignored
|
||||
Assert.Equal(37, device.CursorX);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Text_WrapsAtRightAndBottom()
|
||||
{
|
||||
var device = new VPlasmaDevice(); // 21 cells of 6px per line, 4 lines
|
||||
|
||||
Feed(device, Enumerable.Repeat((byte)'X', 21)); // fills first line, wraps
|
||||
Assert.Equal(0, device.CursorX);
|
||||
Assert.Equal(8, device.CursorY);
|
||||
|
||||
Feed(device, Enumerable.Repeat((byte)'X', 21 * 3)); // to the end, wraps to top
|
||||
Assert.Equal(0, device.CursorX);
|
||||
Assert.Equal(0, device.CursorY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -178,7 +193,8 @@ public class VPlasmaDeviceTests
|
||||
Feed(device, Esc, (byte)'@');
|
||||
|
||||
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(PlasmaAttributes.None, device.Attributes);
|
||||
}
|
||||
@@ -189,7 +205,8 @@ public class VPlasmaDeviceTests
|
||||
var device = new VPlasmaDevice();
|
||||
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
|
||||
}
|
||||
|
||||
@@ -206,42 +223,42 @@ public class VPlasmaDeviceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscK_SelectsFontGrids()
|
||||
public void EscK_SelectsRealFonts()
|
||||
{
|
||||
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(12, device.CellWidth);
|
||||
Assert.Equal(16, device.CellHeight);
|
||||
Assert.Equal(12, device.FontWidth);
|
||||
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)
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 2, 0));
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 3, 1));
|
||||
|
||||
Feed(device, Esc, (byte)'K', 0xFF); // FF → default font 0
|
||||
Feed(device, Esc, (byte)'K', 0); // back to font 0: 6×8
|
||||
Assert.Equal(0, device.Font);
|
||||
Assert.Equal(6, device.FontWidth);
|
||||
Assert.Equal(8, device.FontHeight);
|
||||
|
||||
Feed(device, Esc, (byte)'K', 0xFF); // out of range: firmware ignores it
|
||||
Assert.Equal(0, device.Font);
|
||||
Assert.Equal(6, device.CellWidth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscH_AppliesAttributes()
|
||||
public void EscH_AppliesAttributesAsLowFourBits()
|
||||
{
|
||||
var device = new VPlasmaDevice();
|
||||
|
||||
Feed(device, Esc, (byte)'H', 1, (byte)'H'); // half intensity
|
||||
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelHalf, Pixel(device, 0, 0));
|
||||
|
||||
Feed(device, Esc, (byte)'H', 2, (byte)'H'); // underline: gap row lit
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 6, 7));
|
||||
Feed(device, Esc, (byte)'L', Esc, (byte)'@'); // clear
|
||||
Feed(device, Esc, (byte)'H', 2, (byte)'H'); // underline: bottom row (y=7) lit
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 7));
|
||||
|
||||
Feed(device, Esc, (byte)'H', 3, (byte)' '); // reverse: a space renders solid
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 12, 0));
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 17, 7));
|
||||
Feed(device, Esc, (byte)'@');
|
||||
Feed(device, Esc, (byte)'H', 4, (byte)' '); // reverse: a space renders solid
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 5, 7));
|
||||
|
||||
Feed(device, Esc, (byte)'H', 0xFF, (byte)'H'); // defaults restored
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 18, 0));
|
||||
Assert.Equal(0, Pixel(device, 18, 7));
|
||||
Feed(device, Esc, (byte)'H', 0); // defaults restored
|
||||
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
||||
}
|
||||
|
||||
@@ -251,7 +268,7 @@ public class VPlasmaDeviceTests
|
||||
public void UnknownEscape_IsConsumedAndTextResumes()
|
||||
{
|
||||
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(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||
@@ -260,11 +277,10 @@ public class VPlasmaDeviceTests
|
||||
[Fact]
|
||||
public void GameStartupSequence_HidesCursor()
|
||||
{
|
||||
// L4PLASMA.CPP's constructor sends exactly ESC 'G' 0x00.
|
||||
var device = new VPlasmaDevice();
|
||||
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode); // power-on default
|
||||
|
||||
Feed(device, 27, (byte)'G', 0x00);
|
||||
Feed(device, 27, (byte)'G', 0x00); // L4PLASMA.CPP's boot byte
|
||||
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]
|
||||
public void Reset_RestoresPowerOnState()
|
||||
{
|
||||
@@ -302,6 +392,7 @@ public class VPlasmaDeviceTests
|
||||
Assert.Equal(0, device.Font);
|
||||
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
||||
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode);
|
||||
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
|
||||
Assert.Equal(0, device.CursorX);
|
||||
Assert.Equal(0, device.CursorY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using VPlasma.Core.Device;
|
||||
using Xunit;
|
||||
|
||||
namespace VPlasma.Core.Tests;
|
||||
|
||||
public class VPlasmaPipeServiceTests
|
||||
{
|
||||
private static string UniqueName() => $"vplasma-test-{Guid.NewGuid():N}";
|
||||
|
||||
private static void WaitFor(Func<bool> condition, int timeoutMs = 2000)
|
||||
{
|
||||
var clock = Stopwatch.StartNew();
|
||||
while (!condition())
|
||||
{
|
||||
Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
private static NamedPipeClientStream Connect(string name)
|
||||
{
|
||||
var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
var clock = Stopwatch.StartNew();
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
pipe.Connect(200);
|
||||
return pipe;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or TimeoutException)
|
||||
{
|
||||
if (clock.ElapsedMilliseconds >= 3000)
|
||||
throw;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Framed_text_reaches_the_display_and_lines_are_ignored()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VPlasmaDevice();
|
||||
using var service = new VPlasmaPipeService(device, name);
|
||||
service.Start();
|
||||
|
||||
using var client = Connect(name);
|
||||
var hello = new byte[2];
|
||||
Assert.Equal(1, client.Read(hello, 0, 1)); // on-connect lines frame,
|
||||
Assert.Equal(1, client.Read(hello, 1, 1)); // possibly split
|
||||
Assert.Equal(new byte[] { 0x01, 0x03 }, hello); // DTR+RTS: display present
|
||||
|
||||
// RTS chatter (DOS console redirection toggles it per byte) then "HI",
|
||||
// the data frame torn in half mid-payload.
|
||||
client.Write(new byte[] { 0x01, 0x02, 0x00, 0x02, (byte)'H' }, 0, 5);
|
||||
client.Flush();
|
||||
Thread.Sleep(20);
|
||||
client.Write(new byte[] { (byte)'I' }, 0, 1);
|
||||
client.Flush();
|
||||
|
||||
WaitFor(() => device.BytesReceived == 2);
|
||||
Assert.Equal(2, device.TextCharsDrawn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_frame_type_drops_the_client_and_server_recovers()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VPlasmaDevice();
|
||||
using var service = new VPlasmaPipeService(device, name);
|
||||
service.Start();
|
||||
|
||||
using (var bad = Connect(name))
|
||||
{
|
||||
var hello = new byte[2];
|
||||
bad.Read(hello, 0, 2);
|
||||
bad.Write(new byte[] { 0x7F }, 0, 1); // not a frame type
|
||||
bad.Flush();
|
||||
// Contract: log + drop, no resync — our next read sees the close.
|
||||
var eof = Task.Run(() =>
|
||||
{
|
||||
try { return bad.Read(new byte[1], 0, 1) == 0; }
|
||||
catch (IOException) { return true; }
|
||||
});
|
||||
Assert.True(await Task.WhenAny(eof, Task.Delay(2000)) == eof,
|
||||
"server did not drop the connection");
|
||||
Assert.True(await eof);
|
||||
}
|
||||
|
||||
using var good = Connect(name);
|
||||
var again = new byte[2];
|
||||
Assert.Equal(1, good.Read(again, 0, 1));
|
||||
Assert.Equal(1, good.Read(again, 1, 1));
|
||||
Assert.Equal(new byte[] { 0x01, 0x03 }, again);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user