Compare commits
15
Commits
v2026.07.11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ccb6a1a04 | ||
|
|
a048b7c69b | ||
|
|
0ed6d9495e | ||
|
|
68e3d1f367 | ||
|
|
78cd679b53 | ||
|
|
ebad6ee5fe | ||
|
|
ab36cdd826 | ||
|
|
79eb53253e | ||
|
|
95b701ae0a | ||
|
|
d8ab0ffe49 | ||
|
|
90a57ba2cf | ||
|
|
49e88fbe20 | ||
|
|
2dca8bfd8f | ||
|
|
697cf3129b | ||
|
|
e507f1740c |
Binary file not shown.
@@ -1,206 +0,0 @@
|
|||||||
# PlasmaNew — reverse-engineering the real cockpit plasma display
|
|
||||||
|
|
||||||
Working notes and reference material for the cockpit plasma display.
|
|
||||||
|
|
||||||
**End goal: a hardware replica.** The original Babcock plasma panels are
|
|
||||||
starting to fail and are effectively irreplaceable. The plan is to drive a
|
|
||||||
modern **128 × 32 LED array** with a **modern microcontroller** that reads
|
|
||||||
the same RS-232 serial bus and speaks the same command protocol as the
|
|
||||||
original PD01D221 — a drop-in replacement, functionally identical from the
|
|
||||||
host's side, with none of the plasma physics or high voltage.
|
|
||||||
|
|
||||||
[vPLASMA](../src/VPlasma.App/) (the C# app in this repo) is the software
|
|
||||||
counterpart and serves the replica directly: it is an **executable
|
|
||||||
specification** of the display's behavior and a **test oracle**. Every
|
|
||||||
command semantic pinned down in `VPlasmaDevice` ports straight to the
|
|
||||||
replica's firmware, and the same differential-test rig (real panel vs.
|
|
||||||
vPLASMA) validates the replica. vPLASMA today is built from *observed
|
|
||||||
traffic* (the game's driver + a factory test tool); grounding it in the
|
|
||||||
*actual hardware* — protocol, fonts, and timing — feeds both the emulator
|
|
||||||
and the replacement firmware.
|
|
||||||
|
|
||||||
## What the display is
|
|
||||||
|
|
||||||
A **commercial off-the-shelf Babcock Display Products Division PD01D221** —
|
|
||||||
"128 × 32 dot-matrix, gas-plasma display with controller and DC-DC
|
|
||||||
converter," with an RS-232C serial interface and a dedicated microprocessor
|
|
||||||
for refresh and the user interface. Built by **Cherry** (PCB assembly
|
|
||||||
**4317-C**, Made in Taiwan, © 1994). See [`PD01D221.pdf`](PD01D221.pdf)
|
|
||||||
(Babcock doc 9200-0109 Rev A).
|
|
||||||
|
|
||||||
Product family (the suffix letter = how much is on the board):
|
|
||||||
|
|
||||||
| Model | Contents |
|
|
||||||
|-------|----------|
|
|
||||||
| PD01**B**22B | 128×32 panel + driver electronics only (host refreshes it) |
|
|
||||||
| PD01**F**221 | + on-board DC-DC converter |
|
|
||||||
| PD01**D**221 | **+ controller: RS-232C, dedicated microprocessor** ← this unit |
|
|
||||||
|
|
||||||
**VWE used it stock — no custom fonts or bitmaps were installed.** So the
|
|
||||||
display's behavior is entirely the standard Babcock PD-series firmware, and
|
|
||||||
the `ESC P` "graphics" the game drew were rendered at runtime by the game,
|
|
||||||
not preloaded. Nothing on the display is VWE-specific.
|
|
||||||
|
|
||||||
## Board inventory
|
|
||||||
|
|
||||||
Chip IDs read from the photos below.
|
|
||||||
|
|
||||||
| Ref | Part | Role |
|
|
||||||
|-----|------|------|
|
|
||||||
| U1 | **Motorola MC68HC11D0** (44-pin QFP, mask 1C17F, wk 28/94) | ROMless HC11 MCU — the controller. Runs from external bus in expanded mode. |
|
|
||||||
| U3 | **TI TMS27PC512** (PLCC-32, −150 ns, Singapore) | **64 KB OTP EPROM = the firmware** (stock Babcock code + fonts). Standard 27C512. |
|
|
||||||
| U2 | QFP ~100-pin, label **"35GWP004 REV A 3994"** | Custom Cherry display/scan **ASIC** (wk 39/94). Drives the HV stage. *Not* the firmware. |
|
|
||||||
| U4 | **Mosel MS62256L-10** | 32 KB SRAM — frame buffer / scratch. |
|
|
||||||
| U7 | **Supertex HV7708** | 32-channel high-voltage plasma driver (more HV off-frame). |
|
|
||||||
| U5 | **Maxim MAX202CWE** | RS-232 transceiver — the serial interface. |
|
|
||||||
| — | **MAX707** | Reset / watchdog supervisor. |
|
|
||||||
| Y1 | **7.3728 MHz** crystal | E-clock = 1.8432 MHz; gives exact standard baud rates. |
|
|
||||||
|
|
||||||
Memory picture: ROMless HC11 + external 64 KB EPROM (code + fonts) + 32 KB
|
|
||||||
SRAM + custom scan ASIC + HV drivers. A 64 KB program EPROM for a 128×32
|
|
||||||
panel implies far more feature set than the game ever used.
|
|
||||||
|
|
||||||
## Reference photos
|
|
||||||
|
|
||||||
| File | Shows |
|
|
||||||
|------|-------|
|
|
||||||
| [`mpul-2026-07-07-152834.jpeg`](mpul-2026-07-07-152834.jpeg) | Controller overview: MC68HC11D0 (U1), the "35GWP004" ASIC (U2), HV7708 (U7), MAX202, MAX707. |
|
|
||||||
| [`silkscreenl-2026-07-07-152841.jpeg`](silkscreenl-2026-07-07-152841.jpeg) | Cherry silkscreen: PCB **4317-C**, © 1994, "Made in Taiwan". |
|
|
||||||
| [`unknown-2026-07-07-153818.jpeg`](unknown-2026-07-07-153818.jpeg) | The **TMS27PC512 EPROM** (U3, initially unidentified), Mosel SRAM (U4), HC11. |
|
|
||||||
| [`jumpers-2026-07-07-163733.jpeg`](jumpers-2026-07-07-163733.jpeg) | The **JP1** config header next to the HC11. |
|
|
||||||
|
|
||||||
## Datasheet-confirmed facts (`PD01D221.pdf`, doc 9200-0109 Rev A)
|
|
||||||
|
|
||||||
- Serial format **8N1**, baud **jumper-selectable 4800 / 9600 / 19.2K /
|
|
||||||
38.4K** (the game uses 9600).
|
|
||||||
- "Choice of standard fonts and styles" (= `ESC K` / `ESC H`); "program
|
|
||||||
custom characters" (a custom-char download command — **exists but VWE
|
|
||||||
didn't use it**); "graphic input commands / overlays" (= `ESC P`).
|
|
||||||
- Serial is **bidirectional**. Connector **J1**: pin 2 TxD (display→host),
|
|
||||||
pin 3 RxD (host→display), pin 4 CTS, pin 8 DTR ("display ready"), pin 5
|
|
||||||
GND. The game drove it write-only (flow control disabled, TxD ignored),
|
|
||||||
so vPLASMA's listen-only model is faithful.
|
|
||||||
- Also carries an 8-bit **parallel** port (J2), unused by the game.
|
|
||||||
- **The datasheet does *not* contain the `ESC` command table.** That's a
|
|
||||||
separate Babcock programming/user manual, which is **not available online**
|
|
||||||
(checked general web, datasheetarchive, bitsavers, archive.org, resellers;
|
|
||||||
only this datasheet was ever digitized). Sources for it: ask Babcock
|
|
||||||
directly (La Mirada CA, (714) 994-6500, babcockinc.com), or reconstruct it
|
|
||||||
from the dump + the sources we already have.
|
|
||||||
|
|
||||||
## Command protocol recovered so far
|
|
||||||
|
|
||||||
From the game driver (`TeslaRel410\CODE\RP\MUNGA_L4\L4PLASMA.CPP`) and the
|
|
||||||
factory test tool (`…\VWETEST\VGLTEST\PLASMA.EXE`). Full grammar lives in
|
|
||||||
[`../src/VPlasma.Core/Protocol/PlasmaProtocol.cs`](../src/VPlasma.Core/Protocol/PlasmaProtocol.cs).
|
|
||||||
|
|
||||||
| Bytes | Meaning |
|
|
||||||
|-------|---------|
|
|
||||||
| `ESC @` | Clear screen, reset text state |
|
|
||||||
| `ESC L` | Home cursor |
|
|
||||||
| `ESC G n` | Cursor mode (00/FF hidden, 01 steady, 03 flashing) |
|
|
||||||
| `ESC K n` | Font select (0–7; FF = default) |
|
|
||||||
| `ESC H n` | Text attributes (intensity / underline / reverse / flash) |
|
|
||||||
| `ESC P s y x w h data…` | Graphics write: MSB = leftmost pixel |
|
|
||||||
| BS / HT / LF / VT / CR | Cursor motion |
|
|
||||||
|
|
||||||
The Babcock manual (or a firmware dump) would fill in exact operand
|
|
||||||
encodings, tab stops, the `ESC P` "screen" byte, and any commands the game
|
|
||||||
never used.
|
|
||||||
|
|
||||||
## JP1 configuration header
|
|
||||||
|
|
||||||
Traced pin-by-pin (see the jumper photo). **JP1 is firmware-read
|
|
||||||
configuration, not CPU mode select** — each shunt ties a GP port pin the
|
|
||||||
firmware polls at boot. Shunt to GND = logic 0.
|
|
||||||
|
|
||||||
| JP1 pos | HC11 pin | Function |
|
|
||||||
|---------|----------|----------|
|
|
||||||
| 1 | pin 24 / PA0 | Baud select bit 0 |
|
|
||||||
| 2 | pin 22 / PA2 | Baud select bit 1 |
|
|
||||||
| 3 | pin 21 / PA3 | Option (unknown) |
|
|
||||||
| 4 | pin 15 / PD5 | Option (unknown) |
|
|
||||||
| 5 | pin 14 / PD4 | Option (unknown) |
|
|
||||||
| 6 | pin 13 / PD3 | Option (unknown) |
|
|
||||||
| 7 | J2 SEL → +5 V | Parallel interface select |
|
|
||||||
|
|
||||||
Positions 1–2 = the datasheet's baud "JUMPER 1 / JUMPER 2." Positions 3–6
|
|
||||||
are four unknown firmware option bits — candidates for a hidden factory
|
|
||||||
self-test / diagnostic mode.
|
|
||||||
|
|
||||||
HC11 pin map cross-checked while tracing: PD0–PD5 = pins 10–15, PA0–PA7 =
|
|
||||||
pins 24–17 (descending).
|
|
||||||
|
|
||||||
**MODA/MODB are hardwired high (expanded mode) through a diode to +5 V — not
|
|
||||||
jumper-selectable.** So bootstrap mode cannot be entered by moving a jumper;
|
|
||||||
it needs a mode-pin override. (Exact diode circuit still to be characterized.)
|
|
||||||
|
|
||||||
## Firmware-dump plan
|
|
||||||
|
|
||||||
Goal: get the 64 KB EPROM image, disassemble the HC11 code to recover the
|
|
||||||
full command table + font bitmaps + timing, then differential-test vPLASMA
|
|
||||||
against the real panel on identical byte streams. The recovered spec feeds
|
|
||||||
**both** vPLASMA and the replacement firmware.
|
|
||||||
|
|
||||||
1. **Free, no-solder — hunt for a diagnostic mode.** Capture J1 TxD while
|
|
||||||
power-cycling normally (may emit a banner/version), then step the four
|
|
||||||
unknown config jumpers (PA3, PD5, PD4, PD3) through combinations watching
|
|
||||||
TxD for a factory self-test or ROM dump.
|
|
||||||
2. **Serial bootstrap (conditional).** Bootstrap needs MODA = MODB = 0 at the
|
|
||||||
reset edge; they're pulled to +5 V via a diode. If that circuit has a
|
|
||||||
series resistor (or a diode-OR node), pull both low during a reset pulse
|
|
||||||
and run the standard **Motorola AN1060** dump loader out J1 — no cutting.
|
|
||||||
If hard-tied, a single trace cut/lift is needed. *Blocked on the diode
|
|
||||||
details.*
|
|
||||||
3. **Reliable fallback — read the EPROM directly.** PLCC-32 test clip on U3
|
|
||||||
with the HC11 held in reset, or hot-air U3 off and read it in a 27C512
|
|
||||||
adapter. Guaranteed image.
|
|
||||||
|
|
||||||
Safety: the panel runs on a few hundred volts from the on-board DC-DC. Keep
|
|
||||||
all work in the logic corner (HC11 / EPROM / MAX202); never probe the HV
|
|
||||||
section or the panel connector while powered.
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
|
|
||||||
- Characterize the MODA/MODB diode circuit → decide if serial bootstrap is a
|
|
||||||
tack-a-wire job or needs a trace cut.
|
|
||||||
- Capture J1 TxD across config-jumper combinations (path 1).
|
|
||||||
- Obtain the Babcock PD01D programming manual, **or** dump the U3 EPROM.
|
|
||||||
- Once we have the command table + fonts: fold into `VPlasmaDevice`, replace
|
|
||||||
the public-domain 5×7 stand-in with the real Babcock glyphs, and
|
|
||||||
differential-test against the hardware.
|
|
||||||
- **Prototype the replica.** A modern MCU (RP2040 / ESP32 / Teensy) reads the
|
|
||||||
command stream into the same command parser and drives a 128×32 LED matrix
|
|
||||||
from the same frame buffer — the per-pixel lit / half-intensity / flash
|
|
||||||
flags in `VPlasmaDevice` map directly onto PWM brightness + blink. An amber
|
|
||||||
matrix best mimics the neon-orange plasma; for a true cockpit swap, match
|
|
||||||
the original active area (~12.75" × 3.15", ~0.1" pitch = 128×32).
|
|
||||||
|
|
||||||
## Replica interface — USB, not RS-232
|
|
||||||
|
|
||||||
The cockpit PCs are now **Win x64**, so the replica likely needs **no real
|
|
||||||
serial port**: a native-USB MCU presenting as a **USB CDC virtual COM port**
|
|
||||||
is transparent — the host opens `COMx` and can't tell it isn't a UART. This
|
|
||||||
deletes the RS-232 transceiver and connector from the BOM. Consequences:
|
|
||||||
|
|
||||||
- **Baud is cosmetic** over USB CDC (the 9600/… setting is accepted as a
|
|
||||||
no-op; the two baud-select jumpers need no hardware equivalent).
|
|
||||||
- **Timing becomes instant** rather than ~1 ms/byte — harmless for a display,
|
|
||||||
and vPLASMA can still throttle to mimic the original for differential tests.
|
|
||||||
- **Pin the COM number** the host expects (original was COM2) in Device
|
|
||||||
Manager so it drops in with no host-side config change.
|
|
||||||
- **DTR/RTS still cross** the CDC link if any host logic ever needs them (the
|
|
||||||
game didn't use flow control).
|
|
||||||
- **Power gotcha:** USB alone can't drive the LED array at full brightness —
|
|
||||||
use USB for data + a **separate DC feed** for the LEDs (or USB-C PD).
|
|
||||||
|
|
||||||
Transparency assumes the host reaches the display as a Windows `COMx`
|
|
||||||
endpoint — e.g. DOSBox-X `serial2=directserial realport:COMx`, which a USB
|
|
||||||
CDC port satisfies perfectly. Confirm the current drive path.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
**Parked pending a firmware dump.** The software emulator (vPLASMA) is built
|
|
||||||
and released; this hardware/protocol thread is blocked on getting the U3
|
|
||||||
EPROM image (or the Babcock programming manual). Resume at the dump plan
|
|
||||||
above once a dump is in hand.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 675 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 896 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 943 KiB |
@@ -103,14 +103,42 @@ analog polling drives the "analog polls served" counter, and every click on
|
|||||||
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
|
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
|
||||||
a null-modem cable work the same way.
|
a null-modem cable work the same way.
|
||||||
|
|
||||||
|
## Named pipes for DOSBox-X (no com0com)
|
||||||
|
|
||||||
|
When the game runs in the patched DOSBox-X (the fork's `namedpipe` serial
|
||||||
|
backend), the virtual ports aren't needed at all: each device's connection
|
||||||
|
picker lists its named-pipe endpoint — `pipe:vrio`, `pipe:vplasma` —
|
||||||
|
alongside the machine's COM ports. Select it and **Open** to serve
|
||||||
|
`\\.\pipe\vrio` / `\\.\pipe\vplasma`; DOSBox-X connects as the client,
|
||||||
|
retrying in the background until the pipe exists:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[serial]
|
||||||
|
serial1 = namedpipe pipe:vrio rxpollus:100 rxburst:16
|
||||||
|
serial2 = namedpipe pipe:vplasma
|
||||||
|
```
|
||||||
|
|
||||||
|
A pipe is a plain byte stream, so serial data and modem lines travel as
|
||||||
|
typed frames (`0x00 len bytes` data, `0x01 bits` DTR/RTS) — the game's DTR
|
||||||
|
reset pulse keeps its exact position in the byte stream, and a disconnect
|
||||||
|
reads as all-lines-low (cable unplugged). The contract lives in
|
||||||
|
`src/VRio.Core/Device/PipeFraming.cs` on this side and `serialnamedpipe.h`
|
||||||
|
on the fork's. TX is paced exactly like the COM path — with the 9600-baud
|
||||||
|
wire gone, the pacer is the only thing between the host and an impossible
|
||||||
|
burst. Each row serves one endpoint at a time — the RIO is a single-host
|
||||||
|
device — and nothing opens automatically at startup; the COM path is
|
||||||
|
unchanged for RIOJoy and real pods. vRIO's built-in glass and the
|
||||||
|
standalone vPLASMA share the `vplasma` pipe name — whoever starts second
|
||||||
|
retries until the name frees up.
|
||||||
|
|
||||||
## vPLASMA — the companion plasma display
|
## vPLASMA — the companion plasma display
|
||||||
|
|
||||||
The cockpit's second serial device is the **plasma display**: a 128×32
|
The cockpit's second serial device is the **plasma display**: a 128×32
|
||||||
dot-matrix panel on COM2 (9600 8N1, no flow control) that the game draws
|
dot-matrix panel on COM2 (9600 8N1, no flow control) that the game draws
|
||||||
mission text and status graphics on. The software replica comes in two
|
mission text and status graphics on. The software replica comes in two
|
||||||
forms. **Built into vRIO**: the panel's encoder strip hosts the glass at
|
forms. **Built into vRIO**: the panel's encoder strip hosts the glass at
|
||||||
top left, with its own port row in the control strip (label colour = port
|
top left, with its own connection row in the control strip (label colour =
|
||||||
status; auto-opens **COM12** at startup when present). And standalone,
|
connection status; picker offers `pipe:vplasma` and the COM ports). And standalone,
|
||||||
`VPlasma.App`: a bare-glass window that opens **COM12** (the device end of
|
`VPlasma.App`: a bare-glass window that opens **COM12** (the device end of
|
||||||
the plasma's null-modem pair) on startup — retrying while the port is
|
the plasma's null-modem pair) on startup — retrying while the port is
|
||||||
missing or busy, port status in the title bar. Both decode the display's
|
missing or busy, port status in the title bar. Both decode the display's
|
||||||
@@ -129,12 +157,15 @@ recovered grammar lives in `src/VPlasma.Core/Protocol/PlasmaProtocol.cs`.
|
|||||||
- **Graphics** — `ESC P screen y xbyte width rows` + packed 1bpp row data,
|
- **Graphics** — `ESC P screen y xbyte width rows` + packed 1bpp row data,
|
||||||
MSB = leftmost pixel. This is everything the game sends, so it is the
|
MSB = leftmost pixel. This is everything the game sends, so it is the
|
||||||
wire path a pod's plasma actually sees.
|
wire path a pod's plasma actually sees.
|
||||||
- **Text** — printable ASCII renders through a 5×7 font at a cursor:
|
- **Text** — printable ASCII renders at a **pixel-addressed cursor**
|
||||||
fonts 0–3 give a 21×4 cell grid, fonts 4–7 the same glyphs doubled to
|
(`ESC Q` sets row Y 0–31, `ESC R` sets column X 0–127) through the
|
||||||
10×2. Half-intensity draws dimmer, reverse/underline render in the
|
**real ROM character generator** — the eight Babcock fonts recovered
|
||||||
cell, flashing text (and the flashing cursor) blink on the glass. The
|
from the firmware dump (`ESC K` 0–7): 6×8, 6×10, 7×10, and the large
|
||||||
panel's own ROM glyphs are lost with the hardware, so the classic
|
12×16 / 12×20. Attributes (`ESC H`, low 4 bits) — half-intensity
|
||||||
public-domain 5×7 set stands in.
|
draws dimmer, underline/reverse render in the cell, flashing text (and
|
||||||
|
the flashing cursor) blink on the glass. Extracted from the U3 EPROM;
|
||||||
|
see `restoration/PlasmaNew/FIRMWARE.md` in the TeslaRel410 repo. (Deferred, and
|
||||||
|
documented there: the 10 double-buffered pages and vector graphics.)
|
||||||
- **Double-click** the glass to cycle three self-test pages (banner,
|
- **Double-click** the glass to cycle three self-test pages (banner,
|
||||||
charset, graphics pattern) through the same parser the wire feeds —
|
charset, graphics pattern) through the same parser the wire feeds —
|
||||||
useful without a host. **Right-click** resets the display to its
|
useful without a host. **Right-click** resets the display to its
|
||||||
@@ -150,7 +181,7 @@ recovered grammar lives in `src/VPlasma.Core/Protocol/PlasmaProtocol.cs`.
|
|||||||
|------|----------|
|
|------|----------|
|
||||||
| `src/VRio.Core` | Protocol framing/builder/parser, the `VRioDevice` state machine, serial pump, panel layout data (class library) |
|
| `src/VRio.Core` | Protocol framing/builder/parser, the `VRioDevice` state machine, serial pump, panel layout data (class library) |
|
||||||
| `src/VRio.App` | WinForms panel UI |
|
| `src/VRio.App` | WinForms panel UI |
|
||||||
| `src/VPlasma.Core` | Plasma command-stream parser, the `VPlasmaDevice` display state machine, 5×7 font, serial listener (class library) |
|
| `src/VPlasma.Core` | Plasma command-stream parser, the `VPlasmaDevice` display state machine, the 8 real ROM fonts (`PlasmaFonts`), serial listener (class library) |
|
||||||
| `src/VPlasma.App` | WinForms dot-matrix display UI |
|
| `src/VPlasma.App` | WinForms dot-matrix display UI |
|
||||||
| `pkg` | Sparse-package manifest + registration script: grants VRio.App.exe the package identity Dynamic Lighting's background-control list requires |
|
| `pkg` | Sparse-package manifest + registration script: grants VRio.App.exe the package identity Dynamic Lighting's background-control list requires |
|
||||||
| `tests/VRio.Core.Tests` | xUnit tests for the protocol + device engine |
|
| `tests/VRio.Core.Tests` | xUnit tests for the protocol + device engine |
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.App", "src\VPlasma.
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Core.Tests", "tests\VPlasma.Core.Tests\VPlasma.Core.Tests.csproj", "{F31F1D86-546A-4B0C-A283-C04FAAC49F46}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Core.Tests", "tests\VPlasma.Core.Tests\VPlasma.Core.Tests.csproj", "{F31F1D86-546A-4B0C-A283-C04FAAC49F46}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{01717774-2AD5-4CF5-A0D7-69AF81A534E8}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Wire", "tools\VPlasma.Wire\VPlasma.Wire.csproj", "{CA883363-5610-4245-98B1-3192AE652F1A}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -52,6 +56,10 @@ Global
|
|||||||
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.Build.0 = Release|Any CPU
|
{F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CA883363-5610-4245-98B1-3192AE652F1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CA883363-5610-4245-98B1-3192AE652F1A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CA883363-5610-4245-98B1-3192AE652F1A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CA883363-5610-4245-98B1-3192AE652F1A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{80312F43-09BF-4F09-A0FA-A60FDF86274D} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
|
{80312F43-09BF-4F09-A0FA-A60FDF86274D} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
|
||||||
@@ -60,5 +68,6 @@ Global
|
|||||||
{39E7C28F-8B07-495C-A887-21F2F6AF9A86} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
|
{39E7C28F-8B07-495C-A887-21F2F6AF9A86} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
|
||||||
{72D03B3A-7D4E-496C-8DA9-596DFC91704E} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
|
{72D03B3A-7D4E-496C-8DA9-596DFC91704E} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
|
||||||
{F31F1D86-546A-4B0C-A283-C04FAAC49F46} = {C4993638-7EB6-47A9-897C-976DB9939601}
|
{F31F1D86-546A-4B0C-A283-C04FAAC49F46} = {C4993638-7EB6-47A9-897C-976DB9939601}
|
||||||
|
{CA883363-5610-4245-98B1-3192AE652F1A} = {01717774-2AD5-4CF5-A0D7-69AF81A534E8}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
+336
-37
@@ -1,89 +1,341 @@
|
|||||||
|
using System.IO.Ports;
|
||||||
using VPlasma.Core.Device;
|
using VPlasma.Core.Device;
|
||||||
|
|
||||||
namespace VPlasma.App;
|
namespace VPlasma.App;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// vPLASMA main window: just the plasma glass, sized to the display. The
|
/// vPLASMA main window: the plasma glass on the left, a config panel on the
|
||||||
/// device end is hardwired to <see cref="PortName"/> (the plasma side of the
|
/// right. Connections mirror vRIO: pick an endpoint — the device's named pipe
|
||||||
/// second com0com pair) and opened automatically — a retry timer keeps
|
/// (<c>pipe:vplasma</c>, the DOSBox-X fork's namedpipe backend) or a COM port
|
||||||
/// trying while the port is missing or busy, and reopens it if it dies. The
|
/// (for the game through a com0com null-modem pair) — and Open. Nothing opens
|
||||||
/// title bar carries the port status; double-clicking the glass cycles the
|
/// automatically.
|
||||||
/// self-test pages (banner, charset, graphics) through the wire parser, and
|
///
|
||||||
/// a right-click resets the display to its power-on state.
|
/// <para>The panel mirrors the real PD01D221's <b>JP1 configuration jumpers</b>
|
||||||
|
/// (recovered in <c>TeslaRel410/restoration/PlasmaNew/FIRMWARE.md</c>): the baud straps (1+2) drive the
|
||||||
|
/// COM baud, orientation (4), display test (5), and the demonstration program
|
||||||
|
/// (6) — which replays the real 10-screen firmware demo. Plus display
|
||||||
|
/// controls, live counters, and a wire log. Double-click the glass to cycle
|
||||||
|
/// the self-test pages; right-click to reset.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class MainForm : Form
|
internal sealed class MainForm : Form
|
||||||
{
|
{
|
||||||
/// <summary>The plasma's fixed port: the device end of the COM2 pair.</summary>
|
/// <summary>The plasma's pipe endpoint in the picker (matches DOSBox-X's
|
||||||
private const string PortName = "COM12";
|
/// <c>serial2=namedpipe pipe:vplasma</c>).</summary>
|
||||||
|
private static readonly string PipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
|
||||||
|
|
||||||
private readonly VPlasmaDevice _device = new();
|
private readonly VPlasmaDevice _device = new();
|
||||||
private readonly VPlasmaSerialService _service;
|
private readonly VPlasmaSerialService _service;
|
||||||
|
private readonly VPlasmaPipeService _pipeService;
|
||||||
private readonly PlasmaCanvas _canvas = new();
|
private readonly PlasmaCanvas _canvas = new();
|
||||||
|
|
||||||
private readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 };
|
private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 };
|
||||||
|
private readonly System.Windows.Forms.Timer _demoTimer = new() { Interval = 2800 };
|
||||||
|
private readonly System.Windows.Forms.Timer _testTimer = new() { Interval = 1200 };
|
||||||
private int _selfTestPage;
|
private int _selfTestPage;
|
||||||
|
private int _demoScreen;
|
||||||
|
private int _testPattern;
|
||||||
|
|
||||||
|
// ---- config panel controls -------------------------------------------
|
||||||
|
private readonly ComboBox _portBox = new() { Width = 168, DropDownStyle = ComboBoxStyle.DropDownList };
|
||||||
|
private readonly Button _rescan = new() { Text = "Rescan", Width = 54, Height = 22 };
|
||||||
|
private readonly Button _openClose = new() { Text = "Open", Width = 54, Height = 22 };
|
||||||
|
private readonly Label _linkStatus = new()
|
||||||
|
{
|
||||||
|
AutoSize = true,
|
||||||
|
MaximumSize = new Size(286, 0),
|
||||||
|
ForeColor = Color.Gray,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The seven JP1 jumpers, as toggles. Checked = shunt installed.
|
||||||
|
private readonly CheckBox _jp1 = Jumper("1 — Baud select A (PA0)");
|
||||||
|
private readonly CheckBox _jp2 = Jumper("2 — Baud select B (PA2)");
|
||||||
|
private readonly CheckBox _jp3 = Jumper("3 — HW config line (PA3)");
|
||||||
|
private readonly CheckBox _jp4 = Jumper("4 — Orientation H/V (PD5)");
|
||||||
|
private readonly CheckBox _jp5 = Jumper("5 — Display test (PD4)");
|
||||||
|
private readonly CheckBox _jp6 = Jumper("6 — Demo program (PD3)");
|
||||||
|
private readonly CheckBox _jp7 = Jumper("7 — Parallel select (J2 SEL)");
|
||||||
|
private readonly Label _baudLabel = new() { AutoSize = true, ForeColor = Color.DimGray };
|
||||||
|
|
||||||
|
private readonly Button _selfTest = new() { Text = "Self test", Width = 130, Height = 26 };
|
||||||
|
private readonly Button _clear = new() { Text = "Clear display", Width = 130, Height = 26 };
|
||||||
|
|
||||||
|
private readonly Label _counters = new() { AutoSize = true, Font = new Font("Consolas", 8f) };
|
||||||
|
|
||||||
|
private readonly TextBox _logBox = new()
|
||||||
|
{
|
||||||
|
Multiline = true,
|
||||||
|
ReadOnly = true,
|
||||||
|
ScrollBars = ScrollBars.Both,
|
||||||
|
BackColor = Color.FromArgb(24, 24, 24),
|
||||||
|
ForeColor = Color.Gainsboro,
|
||||||
|
Font = new Font("Consolas", 8f),
|
||||||
|
WordWrap = false,
|
||||||
|
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
|
||||||
|
};
|
||||||
|
private readonly Button _clearLog = new() { Text = "Clear log", Width = 80, Height = 24, Anchor = AnchorStyles.Bottom | AnchorStyles.Left };
|
||||||
|
private readonly ToolTip _tips = new();
|
||||||
|
|
||||||
|
private static CheckBox Jumper(string text) => new() { Text = text, AutoSize = true };
|
||||||
|
|
||||||
|
private bool IsOpen => _service.IsOpen || _pipeService.IsListening;
|
||||||
|
|
||||||
public MainForm()
|
public MainForm()
|
||||||
{
|
{
|
||||||
|
Text = $"vPLASMA v{Application.ProductVersion} — Virtual plasma display";
|
||||||
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
|
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
|
||||||
FormBorderStyle = FormBorderStyle.FixedSingle; // the glass is a fixed-size device
|
ClientSize = new Size(_canvas.Width + 316, Math.Max(_canvas.Height + 8, 580));
|
||||||
MaximizeBox = false;
|
MinimumSize = new Size(_canvas.Width + 332, 500);
|
||||||
ClientSize = _canvas.Size;
|
|
||||||
StartPosition = FormStartPosition.CenterScreen;
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
|
|
||||||
_service = new VPlasmaSerialService(_device);
|
_service = new VPlasmaSerialService(_device);
|
||||||
|
_pipeService = new VPlasmaPipeService(_device);
|
||||||
|
|
||||||
_canvas.Location = new Point(0, 0);
|
var backdrop = new Panel { Dock = DockStyle.Fill, BackColor = Color.FromArgb(28, 28, 28) };
|
||||||
Controls.Add(_canvas);
|
_canvas.Location = new Point(8, 8);
|
||||||
|
backdrop.Controls.Add(_canvas);
|
||||||
|
Controls.Add(backdrop);
|
||||||
|
Controls.Add(BuildConfigPanel());
|
||||||
|
|
||||||
// Device / service events arrive on the serial reader thread; marshal to the UI.
|
// Device / service events arrive on worker threads; marshal to the UI.
|
||||||
_device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device));
|
_device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device));
|
||||||
_service.ConnectionChanged += _ => RunOnUi(UpdateTitle);
|
_device.Logged += line => RunOnUi(() => PrependLog(line));
|
||||||
|
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
||||||
|
_service.ConnectionChanged += _ => RunOnUi(UpdateStatus);
|
||||||
|
_pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}"));
|
||||||
|
_pipeService.ClientChanged += _ => RunOnUi(UpdateStatus);
|
||||||
|
|
||||||
|
_rescan.Click += (_, _) => RefreshPorts();
|
||||||
|
_openClose.Click += (_, _) => ToggleOpen();
|
||||||
_canvas.DoubleClick += (_, _) => RunSelfTest();
|
_canvas.DoubleClick += (_, _) => RunSelfTest();
|
||||||
_canvas.MouseClick += (_, e) =>
|
_canvas.MouseClick += (_, e) => { if (e.Button == MouseButtons.Right) ResetDisplay(); };
|
||||||
{
|
_selfTest.Click += (_, _) => RunSelfTest();
|
||||||
if (e.Button == MouseButtons.Right)
|
_clear.Click += (_, _) => ResetDisplay();
|
||||||
_device.Reset();
|
_clearLog.Click += (_, _) => _logBox.Clear();
|
||||||
};
|
|
||||||
|
|
||||||
// Open at startup, retry while closed (port missing/busy, host restarts).
|
// Functional straps: baud (1+2), orientation (4), display test (5),
|
||||||
_reconnectTimer.Tick += (_, _) => EnsureOpen();
|
// demo (6). Jumpers 3 and 7 are tracked toggles.
|
||||||
_reconnectTimer.Start();
|
_jp1.CheckedChanged += (_, _) => ApplyBaud();
|
||||||
|
_jp2.CheckedChanged += (_, _) => ApplyBaud();
|
||||||
|
_jp4.CheckedChanged += (_, _) => ApplyOrientation();
|
||||||
|
_jp5.CheckedChanged += (_, _) => ApplyTestPattern();
|
||||||
|
_jp6.CheckedChanged += (_, _) => ApplyDemo();
|
||||||
|
_demoTimer.Tick += (_, _) => StepDemo();
|
||||||
|
_testTimer.Tick += (_, _) => StepTestPattern();
|
||||||
|
|
||||||
|
// Default straps: jumper 2 installed = 9600 (the game's rate). Jumper 4
|
||||||
|
// unstrapped = horizontal (the normal cockpit orientation).
|
||||||
|
_jp2.Checked = true;
|
||||||
|
|
||||||
|
_uiTimer.Tick += (_, _) => UpdateCounters();
|
||||||
|
_uiTimer.Start();
|
||||||
|
|
||||||
FormClosed += (_, _) =>
|
FormClosed += (_, _) =>
|
||||||
{
|
{
|
||||||
_reconnectTimer.Dispose();
|
_uiTimer.Dispose();
|
||||||
|
_demoTimer.Dispose();
|
||||||
|
_testTimer.Dispose();
|
||||||
_service.Dispose();
|
_service.Dispose();
|
||||||
|
_pipeService.Dispose();
|
||||||
};
|
};
|
||||||
|
|
||||||
_canvas.UpdateFrom(_device);
|
_canvas.UpdateFrom(_device);
|
||||||
EnsureOpen();
|
_service.Baud = SelectedBaud();
|
||||||
|
RefreshPorts();
|
||||||
|
UpdateStatus();
|
||||||
|
UpdateCounters();
|
||||||
|
PrependLog("vPLASMA ready. Pick an endpoint (pipe:vplasma or a COM port) and Open. Jumper 6 runs the firmware demo.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureOpen()
|
private Panel BuildConfigPanel()
|
||||||
{
|
{
|
||||||
if (_service.IsOpen)
|
var panel = new Panel { Dock = DockStyle.Right, Width = 300, BackColor = SystemColors.Control };
|
||||||
|
|
||||||
|
panel.Controls.Add(new Label { Text = "Connection:", Location = new Point(12, 14), AutoSize = true });
|
||||||
|
_portBox.Location = new Point(12, 34);
|
||||||
|
_rescan.Location = new Point(186, 34);
|
||||||
|
_openClose.Location = new Point(244, 34);
|
||||||
|
_linkStatus.Location = new Point(12, 62);
|
||||||
|
panel.Controls.Add(_portBox);
|
||||||
|
panel.Controls.Add(_rescan);
|
||||||
|
panel.Controls.Add(_openClose);
|
||||||
|
panel.Controls.Add(_linkStatus);
|
||||||
|
|
||||||
|
var jumpers = new GroupBox { Text = "JP1 configuration jumpers", Location = new Point(12, 90), Size = new Size(276, 176) };
|
||||||
|
CheckBox[] jp = { _jp1, _jp2, _jp3, _jp4, _jp5, _jp6, _jp7 };
|
||||||
|
for (int i = 0; i < jp.Length; i++)
|
||||||
|
{
|
||||||
|
jp[i].Location = new Point(12, 20 + i * 20);
|
||||||
|
jumpers.Controls.Add(jp[i]);
|
||||||
|
}
|
||||||
|
_baudLabel.Location = new Point(150, 20);
|
||||||
|
jumpers.Controls.Add(_baudLabel);
|
||||||
|
panel.Controls.Add(jumpers);
|
||||||
|
|
||||||
|
_tips.SetToolTip(_portBox, "pipe:vplasma serves the DOSBox-X namedpipe backend; a COM port pairs with the game via com0com.");
|
||||||
|
_tips.SetToolTip(_jp1, "Baud select, low bit. With jumper 2: 4800/9600/19.2K/38.4K.");
|
||||||
|
_tips.SetToolTip(_jp2, "Baud select, high bit. Installed alone = 9600 (the game's rate).");
|
||||||
|
_tips.SetToolTip(_jp3, "Sets flag B7.2, driving the PA5 hardware line to a fixed level (board config line; exact effect board-dependent).");
|
||||||
|
_tips.SetToolTip(_jp4, "Display orientation: removed = horizontal 128×32 (normal), installed = vertical 32×128.");
|
||||||
|
_tips.SetToolTip(_jp5, "Installed at power-on runs the panel pixel test pattern (a walking-bit dead-dot check).");
|
||||||
|
_tips.SetToolTip(_jp6, "Install to run the built-in demonstration program (the real 10-screen firmware demo).");
|
||||||
|
_tips.SetToolTip(_jp7, "Selects the parallel interface on the real board; no effect here (serial only).");
|
||||||
|
|
||||||
|
var display = new GroupBox { Text = "Display", Location = new Point(12, 276), Size = new Size(276, 62) };
|
||||||
|
_selfTest.Location = new Point(12, 22);
|
||||||
|
_clear.Location = new Point(146, 22);
|
||||||
|
display.Controls.Add(_selfTest);
|
||||||
|
display.Controls.Add(_clear);
|
||||||
|
panel.Controls.Add(display);
|
||||||
|
|
||||||
|
_counters.Location = new Point(12, 348);
|
||||||
|
panel.Controls.Add(_counters);
|
||||||
|
|
||||||
|
var logLabel = new Label { Text = "Wire log:", Location = new Point(12, 434), AutoSize = true };
|
||||||
|
panel.Controls.Add(logLabel);
|
||||||
|
_logBox.SetBounds(12, 452, 276, 0);
|
||||||
|
panel.Controls.Add(_logBox);
|
||||||
|
panel.Controls.Add(_clearLog);
|
||||||
|
|
||||||
|
panel.Resize += (_, _) =>
|
||||||
|
{
|
||||||
|
_logBox.Size = new Size(276, Math.Max(40, panel.ClientSize.Height - _logBox.Top - 40));
|
||||||
|
_clearLog.Location = new Point(12, panel.ClientSize.Height - 32);
|
||||||
|
};
|
||||||
|
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- connection (mirrors vRIO's endpoint picker) -----------------------
|
||||||
|
|
||||||
|
private void RefreshPorts()
|
||||||
|
{
|
||||||
|
if (IsOpen)
|
||||||
|
return; // an open row is disabled and shows the served endpoint
|
||||||
|
string? current = _portBox.SelectedItem?.ToString();
|
||||||
|
_portBox.Items.Clear();
|
||||||
|
_portBox.Items.Add(PipeEndpoint); // always present — a pipe server needs no hardware
|
||||||
|
foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
|
||||||
|
_portBox.Items.Add(name);
|
||||||
|
int idx = current is null ? -1 : _portBox.Items.IndexOf(current);
|
||||||
|
_portBox.SelectedIndex = idx >= 0 ? idx : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleOpen()
|
||||||
|
{
|
||||||
|
if (IsOpen)
|
||||||
|
{
|
||||||
|
_service.Close();
|
||||||
|
_pipeService.Stop();
|
||||||
|
UpdateStatus();
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_portBox.SelectedItem?.ToString() is not { } endpoint)
|
||||||
|
{
|
||||||
|
PrependLog("No endpoint selected — pick pipe:vplasma or a COM port.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_service.Open(PortName);
|
if (endpoint == PipeEndpoint)
|
||||||
|
{
|
||||||
|
_pipeService.Start();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_service.Baud = SelectedBaud();
|
||||||
|
_service.Open(endpoint);
|
||||||
|
}
|
||||||
|
UpdateStatus();
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
|
||||||
{
|
{
|
||||||
UpdateTitle(); // stays closed; the timer tries again
|
PrependLog($"Could not open {endpoint}: {ex.Message}");
|
||||||
|
UpdateStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateTitle()
|
// ---- jumper behaviour --------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Baud from the two straps (installed = logic 0), per the datasheet.</summary>
|
||||||
|
private int SelectedBaud() => (_jp1.Checked, _jp2.Checked) switch
|
||||||
{
|
{
|
||||||
// ProductVersion carries the git stamp (see StampGitVersion in the csproj).
|
(true, true) => 4800,
|
||||||
string status = _service.IsOpen
|
(false, true) => 9600,
|
||||||
? $"{PortName} @ 9600 8N1"
|
(true, false) => 19200,
|
||||||
: $"{PortName} unavailable — retrying";
|
(false, false) => 38400,
|
||||||
Text = $"vPLASMA v{Application.ProductVersion} — {status}";
|
};
|
||||||
|
|
||||||
|
private void ApplyBaud()
|
||||||
|
{
|
||||||
|
int baud = SelectedBaud();
|
||||||
|
_service.Baud = baud;
|
||||||
|
if (_service.IsOpen && _portBox.SelectedItem?.ToString() is { } port && port != PipeEndpoint)
|
||||||
|
{
|
||||||
|
PrependLog($"Baud straps → {baud} 8N1 (reopening {port})");
|
||||||
|
_service.Close();
|
||||||
|
try { _service.Open(port); }
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { PrependLog($"Reopen failed: {ex.Message}"); }
|
||||||
}
|
}
|
||||||
|
UpdateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyOrientation()
|
||||||
|
{
|
||||||
|
// Jumper 4 unstrapped (unchecked) = horizontal 128×32; installed = vertical.
|
||||||
|
_device.Orientation = _jp4.Checked ? PlasmaOrientation.Vertical : PlasmaOrientation.Horizontal;
|
||||||
|
PrependLog($"Jumper 4 → {(_jp4.Checked ? "vertical 32×128" : "horizontal 128×32")} orientation");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyTestPattern()
|
||||||
|
{
|
||||||
|
if (_jp5.Checked)
|
||||||
|
{
|
||||||
|
PrependLog("Jumper 5 installed — panel test (cycling diagnostic patterns)");
|
||||||
|
_testPattern = 0;
|
||||||
|
_device.ShowTestPattern(0);
|
||||||
|
_testTimer.Start();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_testTimer.Stop();
|
||||||
|
_device.Reset();
|
||||||
|
PrependLog("Jumper 5 removed — test pattern cleared");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StepTestPattern()
|
||||||
|
{
|
||||||
|
_testPattern = (_testPattern + 1) % VPlasmaDevice.TestPatternCount;
|
||||||
|
_device.ShowTestPattern(_testPattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyDemo()
|
||||||
|
{
|
||||||
|
if (_jp6.Checked)
|
||||||
|
{
|
||||||
|
PrependLog("Jumper 6 installed — running the firmware demonstration program");
|
||||||
|
_demoScreen = 0;
|
||||||
|
StepDemo();
|
||||||
|
_demoTimer.Start();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_demoTimer.Stop();
|
||||||
|
PrependLog("Jumper 6 removed — demo stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Play the next real firmware demo screen (loops 0..9).</summary>
|
||||||
|
private void StepDemo()
|
||||||
|
{
|
||||||
|
byte[] screen = PlasmaFirmwareDemo.Screens[_demoScreen];
|
||||||
|
_device.OnReceived(screen, screen.Length);
|
||||||
|
_demoScreen = (_demoScreen + 1) % PlasmaFirmwareDemo.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- display controls --------------------------------------------------
|
||||||
|
|
||||||
private void RunSelfTest()
|
private void RunSelfTest()
|
||||||
{
|
{
|
||||||
@@ -92,6 +344,53 @@ internal sealed class MainForm : Form
|
|||||||
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
|
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ResetDisplay()
|
||||||
|
{
|
||||||
|
_device.Reset();
|
||||||
|
PrependLog("Display reset to power-on state");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- status / counters / log ------------------------------------------
|
||||||
|
|
||||||
|
private void UpdateStatus()
|
||||||
|
{
|
||||||
|
_openClose.Text = IsOpen ? "Close" : "Open";
|
||||||
|
_portBox.Enabled = !IsOpen;
|
||||||
|
_rescan.Enabled = !IsOpen;
|
||||||
|
_baudLabel.Text = $"{SelectedBaud()} baud";
|
||||||
|
|
||||||
|
string line;
|
||||||
|
if (_service.IsOpen)
|
||||||
|
line = $"Serving {_service.PortName} @ {_service.Baud} 8N1.";
|
||||||
|
else if (_pipeService.IsListening)
|
||||||
|
line = $"Serving \\\\.\\pipe\\{_pipeService.PipeName}" + (_pipeService.IsClientConnected ? " — host connected." : " — waiting for host.");
|
||||||
|
else
|
||||||
|
line = "Not connected.";
|
||||||
|
_linkStatus.Text = line;
|
||||||
|
_linkStatus.ForeColor = IsOpen ? Color.ForestGreen : Color.Gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCounters()
|
||||||
|
{
|
||||||
|
_counters.Text =
|
||||||
|
$"Bytes received: {_device.BytesReceived}\n" +
|
||||||
|
$"Graphics rows: {_device.GraphicsRows}\n" +
|
||||||
|
$"Chars drawn: {_device.TextCharsDrawn}\n" +
|
||||||
|
$"Font: {_device.Font} ({_device.FontWidth}×{_device.FontHeight})\n" +
|
||||||
|
$"Cursor: X {_device.CursorX}, Y {_device.CursorY} ({_device.CursorMode})\n" +
|
||||||
|
$"Attributes: {(_device.Attributes == PlasmaAttributes.None ? "default" : _device.Attributes.ToString())}\n" +
|
||||||
|
$"Orientation: {_device.Orientation} ({_device.LogicalWidth}×{_device.LogicalHeight})";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PrependLog(string line)
|
||||||
|
{
|
||||||
|
string stamped = $"{DateTime.Now:HH:mm:ss.fff} {line}";
|
||||||
|
string text = _logBox.TextLength == 0 ? stamped : stamped + Environment.NewLine + _logBox.Text;
|
||||||
|
if (text.Length > 20000)
|
||||||
|
text = text.Substring(0, 20000);
|
||||||
|
_logBox.Text = text;
|
||||||
|
}
|
||||||
|
|
||||||
private void RunOnUi(Action action)
|
private void RunOnUi(Action action)
|
||||||
{
|
{
|
||||||
if (IsDisposed || Disposing)
|
if (IsDisposed || Disposing)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ internal sealed class PlasmaCanvas : Control
|
|||||||
private bool _blinkPhase = true;
|
private bool _blinkPhase = true;
|
||||||
private bool _anythingBlinks;
|
private bool _anythingBlinks;
|
||||||
|
|
||||||
private VPlasmaDevice.Point _cursor;
|
private int _cursorX, _cursorY;
|
||||||
private PlasmaCursorMode _cursorMode;
|
private PlasmaCursorMode _cursorMode;
|
||||||
private int _cellWidth = 6, _cellHeight = 8;
|
private int _cellWidth = 6, _cellHeight = 8;
|
||||||
|
|
||||||
@@ -62,10 +62,11 @@ internal sealed class PlasmaCanvas : Control
|
|||||||
public void UpdateFrom(VPlasmaDevice device)
|
public void UpdateFrom(VPlasmaDevice device)
|
||||||
{
|
{
|
||||||
device.CopyFrame(_frame);
|
device.CopyFrame(_frame);
|
||||||
_cursor = device.CursorCell;
|
_cursorX = device.CursorX;
|
||||||
|
_cursorY = device.CursorY;
|
||||||
_cursorMode = device.CursorMode;
|
_cursorMode = device.CursorMode;
|
||||||
_cellWidth = device.CellWidth;
|
_cellWidth = device.FontWidth;
|
||||||
_cellHeight = device.CellHeight;
|
_cellHeight = device.FontHeight;
|
||||||
|
|
||||||
_anythingBlinks = _cursorMode == PlasmaCursorMode.Flashing;
|
_anythingBlinks = _cursorMode == PlasmaCursorMode.Flashing;
|
||||||
if (!_anythingBlinks)
|
if (!_anythingBlinks)
|
||||||
@@ -113,8 +114,8 @@ internal sealed class PlasmaCanvas : Control
|
|||||||
if (_cursorMode == PlasmaCursorMode.Steady
|
if (_cursorMode == PlasmaCursorMode.Steady
|
||||||
|| (_cursorMode == PlasmaCursorMode.Flashing && _blinkPhase))
|
|| (_cursorMode == PlasmaCursorMode.Flashing && _blinkPhase))
|
||||||
{
|
{
|
||||||
int cx = _cursor.Col * _cellWidth;
|
int cx = _cursorX;
|
||||||
int cy = _cursor.Row * _cellHeight + _cellHeight - 1;
|
int cy = _cursorY + _cellHeight - 1;
|
||||||
if (cy < VPlasmaDevice.Height)
|
if (cy < VPlasmaDevice.Height)
|
||||||
for (int i = 0; i < _cellWidth && cx + i < VPlasmaDevice.Width; ++i)
|
for (int i = 0; i < _cellWidth && cx + i < VPlasmaDevice.Width; ++i)
|
||||||
g.FillRectangle(lit, Bezel + (cx + i) * _dotPitch, Bezel + cy * _dotPitch, _dotSize, _dotSize);
|
g.FillRectangle(lit, Bezel + (cx + i) * _dotPitch, Bezel + cy * _dotPitch, _dotSize, _dotSize);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// AUTO-GENERATED from the PD01D221 firmware (tms27pc512.BIN) by PlasmaNew
|
||||||
|
// tooling: the real 10-screen demonstration program (jumper 6), extracted
|
||||||
|
// from the demo pointer table at $8000. See TeslaRel410/restoration/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,451 @@
|
|||||||
|
// 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 TeslaRel410/restoration/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);
|
b.Add(PlasmaProtocol.LineFeed);
|
||||||
Text(b, "128x32 PLASMA DISPLAY"); // exactly one 21-cell row
|
Text(b, "128x32 PLASMA DISPLAY"); // exactly one 21-cell row
|
||||||
|
|
||||||
|
// Attributes are the low 4 bits: 1=half, 2=underline, 4=reverse, 8=flash.
|
||||||
Esc(b, PlasmaProtocol.CmdAttributes, 1); Text(b, "HALF ");
|
Esc(b, PlasmaProtocol.CmdAttributes, 1); Text(b, "HALF ");
|
||||||
Esc(b, PlasmaProtocol.CmdAttributes, 2); Text(b, "UNDER ");
|
Esc(b, PlasmaProtocol.CmdAttributes, 2); Text(b, "UNDER ");
|
||||||
Esc(b, PlasmaProtocol.CmdAttributes, 3); Text(b, "REV");
|
Esc(b, PlasmaProtocol.CmdAttributes, 4); Text(b, "REV");
|
||||||
Esc(b, PlasmaProtocol.CmdAttributes, 4); Text(b, " FLASH");
|
Esc(b, PlasmaProtocol.CmdAttributes, 8); Text(b, " FLASH");
|
||||||
Esc(b, PlasmaProtocol.CmdAttributes, PlasmaProtocol.OperandDefault);
|
Esc(b, PlasmaProtocol.CmdAttributes, PlasmaProtocol.OperandDefault);
|
||||||
return b.ToArray();
|
return b.ToArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public enum PlasmaCursorMode
|
|||||||
Flashing,
|
Flashing,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Text rendering attributes (set with <c>ESC H</c>).</summary>
|
/// <summary>Text rendering attributes (set with <c>ESC H</c>, low 4 bits).</summary>
|
||||||
[Flags]
|
[Flags]
|
||||||
public enum PlasmaAttributes : byte
|
public enum PlasmaAttributes : byte
|
||||||
{
|
{
|
||||||
@@ -21,23 +21,40 @@ public enum PlasmaAttributes : byte
|
|||||||
Flash = 8,
|
Flash = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display orientation — the JP1 jumper-4 (PD5) strap the firmware reads at
|
||||||
|
/// boot (<c>TeslaRel410/restoration/PlasmaNew/README.md</c>). Horizontal is the normal 128×32
|
||||||
|
/// landscape; Vertical treats the panel as 32×128 and rotates content onto
|
||||||
|
/// the physical glass (for a portrait-mounted panel).
|
||||||
|
/// </summary>
|
||||||
|
public enum PlasmaOrientation
|
||||||
|
{
|
||||||
|
Horizontal,
|
||||||
|
Vertical,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The plasma display proper: a 128×32 1bpp frame plus the text-mode state
|
/// The plasma display proper: a 128×32 1bpp frame plus the text-mode state
|
||||||
/// (cursor, font, attributes), driven by the byte stream a host writes to
|
/// (cursor, font, attributes), driven by the byte stream a host writes to
|
||||||
/// COM2. Feed raw wire bytes to <see cref="OnReceived"/>; the parser is a
|
/// COM2. Feed raw wire bytes to <see cref="OnReceived"/>; the parser is a
|
||||||
/// state machine, so commands may arrive split across any chunk boundaries.
|
/// state machine, so commands may arrive split across any chunk boundaries.
|
||||||
///
|
///
|
||||||
/// <para>Pixels carry flags rather than a plain bit: graphics writes set
|
/// <para>Grounded in the real firmware (<c>TeslaRel410/restoration/PlasmaNew/FIRMWARE.md</c>): the
|
||||||
/// full-intensity dots, while text can stamp half-intensity or flashing dots
|
/// cursor is a <b>pixel</b> position — <c>ESC Q</c> sets its row (Y),
|
||||||
/// (<c>ESC H</c>); the UI renders <see cref="PixelHalf"/> dimmer and blinks
|
/// <c>ESC R</c> its column (X) — and glyphs are drawn there and advance X by
|
||||||
/// <see cref="PixelFlash"/>. The command set itself is documented on
|
/// the font's width. The eight fonts are the real ROM character generator
|
||||||
/// <see cref="PlasmaProtocol"/>.</para>
|
/// (<see cref="PlasmaFonts"/>). Attributes are the low 4 bits of the
|
||||||
|
/// <c>ESC H</c> operand.</para>
|
||||||
///
|
///
|
||||||
/// <para>Grid geometry: fonts 0–3 are the 5×7 set in a 6×8 cell (21 columns
|
/// <para><see cref="Orientation"/> mirrors JP1 jumper 4: in Vertical the
|
||||||
/// × 4 rows), fonts 4–7 the same glyphs doubled into a 12×16 cell
|
/// logical space is 32×128 and every dot is rotated onto the physical 128×32
|
||||||
/// (10 × 2). Which of the eight slots the real panel mapped to which face is
|
/// glass. All drawing goes through <see cref="Plot"/>, so the same code serves
|
||||||
/// lost with the hardware; two sizes cover what the surviving software
|
/// both orientations. <see cref="ShowTestPattern"/> lights the whole panel —
|
||||||
/// exercises.</para>
|
/// the power-on dead-dot check that JP1 jumper 5 triggers on the real board.</para>
|
||||||
|
///
|
||||||
|
/// <para>Not yet folded in from the firmware (documented, deferred): the 10
|
||||||
|
/// double-buffered pages (<c>ESC I</c>/<c>ESC i</c>) and the vector-graphics
|
||||||
|
/// primitives (<c>ESC A</c>–<c>F</c>).</para>
|
||||||
///
|
///
|
||||||
/// <para>Thread-safe: the serial reader feeds bytes while the UI snapshots
|
/// <para>Thread-safe: the serial reader feeds bytes while the UI snapshots
|
||||||
/// frames. Events are raised outside the lock, on the caller's thread.</para>
|
/// frames. Events are raised outside the lock, on the caller's thread.</para>
|
||||||
@@ -54,13 +71,15 @@ public sealed class VPlasmaDevice
|
|||||||
public const byte PixelFlash = 0x04;
|
public const byte PixelFlash = 0x04;
|
||||||
|
|
||||||
private readonly object _sync = new();
|
private readonly object _sync = new();
|
||||||
private readonly byte[] _pixels = new byte[Width * Height];
|
private readonly byte[] _pixels = new byte[Width * Height]; // always physical 128×32
|
||||||
|
|
||||||
// ---- text-mode state -------------------------------------------------
|
// ---- text-mode state -------------------------------------------------
|
||||||
private int _font; // 0..7
|
private int _font; // 0..7
|
||||||
|
private PlasmaFonts.Face _face = PlasmaFonts.Default;
|
||||||
private PlasmaAttributes _attributes;
|
private PlasmaAttributes _attributes;
|
||||||
private int _col, _row; // cursor, in cells of the current grid
|
private int _x, _y; // cursor, in logical pixels
|
||||||
private PlasmaCursorMode _cursorMode = PlasmaCursorMode.Steady; // power-on default; the game hides it
|
private PlasmaCursorMode _cursorMode = PlasmaCursorMode.Steady; // power-on default; the game hides it
|
||||||
|
private PlasmaOrientation _orientation = PlasmaOrientation.Horizontal;
|
||||||
|
|
||||||
// ---- parser state ----------------------------------------------------
|
// ---- parser state ----------------------------------------------------
|
||||||
private enum State
|
private enum State
|
||||||
@@ -99,16 +118,58 @@ public sealed class VPlasmaDevice
|
|||||||
public int Font { get { lock (_sync) return _font; } }
|
public int Font { get { lock (_sync) return _font; } }
|
||||||
public PlasmaAttributes Attributes { get { lock (_sync) return _attributes; } }
|
public PlasmaAttributes Attributes { get { lock (_sync) return _attributes; } }
|
||||||
|
|
||||||
// Current font grid, for the UI's cursor overlay and status line.
|
/// <summary>Cursor column X in logical pixels.</summary>
|
||||||
private int FontScale => _font >= 4 ? 2 : 1;
|
public int CursorX { get { lock (_sync) return _x; } }
|
||||||
public int CellWidth { get { lock (_sync) return 6 * FontScale; } }
|
/// <summary>Cursor row Y in logical pixels.</summary>
|
||||||
public int CellHeight { get { lock (_sync) return 8 * FontScale; } }
|
public int CursorY { get { lock (_sync) return _y; } }
|
||||||
private int Columns => Width / (6 * FontScale);
|
/// <summary>Current font cell width in pixels.</summary>
|
||||||
private int Rows => Height / (8 * FontScale);
|
public int FontWidth { get { lock (_sync) return _face.Width; } }
|
||||||
public Point CursorCell { get { lock (_sync) return new Point(_col, _row); } }
|
/// <summary>Current font cell height in pixels.</summary>
|
||||||
|
public int FontHeight { get { lock (_sync) return _face.Height; } }
|
||||||
|
|
||||||
/// <summary>A cursor cell position (avoids dragging in System.Drawing).</summary>
|
/// <summary>Logical drawing width (128 horizontal, 32 vertical).</summary>
|
||||||
public readonly record struct Point(int Col, int Row);
|
public int LogicalWidth { get { lock (_sync) return LogicalW; } }
|
||||||
|
/// <summary>Logical drawing height (32 horizontal, 128 vertical).</summary>
|
||||||
|
public int LogicalHeight { get { lock (_sync) return LogicalH; } }
|
||||||
|
|
||||||
|
private int LogicalW => _orientation == PlasmaOrientation.Horizontal ? Width : Height;
|
||||||
|
private int LogicalH => _orientation == PlasmaOrientation.Horizontal ? Height : Width;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display orientation (JP1 jumper 4). Changing it re-inits the panel —
|
||||||
|
/// clears the glass and homes the cursor, like the boot-time strap read.
|
||||||
|
/// </summary>
|
||||||
|
public PlasmaOrientation Orientation
|
||||||
|
{
|
||||||
|
get { lock (_sync) return _orientation; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_orientation == value)
|
||||||
|
return;
|
||||||
|
_orientation = value;
|
||||||
|
Array.Clear(_pixels, 0, _pixels.Length);
|
||||||
|
_x = _y = 0;
|
||||||
|
_dirty = true;
|
||||||
|
}
|
||||||
|
FlushEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set one logical dot, mapping to the physical 128×32 buffer per
|
||||||
|
/// <see cref="Orientation"/>. Out-of-range logical coordinates are ignored.
|
||||||
|
/// </summary>
|
||||||
|
private void Plot(int lx, int ly, byte flags)
|
||||||
|
{
|
||||||
|
if ((uint)lx >= (uint)LogicalW || (uint)ly >= (uint)LogicalH)
|
||||||
|
return;
|
||||||
|
int px, py;
|
||||||
|
if (_orientation == PlasmaOrientation.Horizontal) { px = lx; py = ly; }
|
||||||
|
else { px = ly; py = Height - 1 - lx; } // 90° rotation onto landscape glass
|
||||||
|
_pixels[py * Width + px] = flags;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Copy the frame into <paramref name="destination"/> (Width*Height flag bytes).</summary>
|
/// <summary>Copy the frame into <paramref name="destination"/> (Width*Height flag bytes).</summary>
|
||||||
public void CopyFrame(byte[] destination)
|
public void CopyFrame(byte[] destination)
|
||||||
@@ -119,14 +180,15 @@ public sealed class VPlasmaDevice
|
|||||||
Buffer.BlockCopy(_pixels, 0, destination, 0, _pixels.Length);
|
Buffer.BlockCopy(_pixels, 0, destination, 0, _pixels.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Power-on state: dark glass, home cursor, defaults.</summary>
|
/// <summary>Power-on state: dark glass, home cursor, defaults (keeps orientation).</summary>
|
||||||
public void Reset()
|
public void Reset()
|
||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
Array.Clear(_pixels, 0, _pixels.Length);
|
Array.Clear(_pixels, 0, _pixels.Length);
|
||||||
_col = _row = 0;
|
_x = _y = 0;
|
||||||
_font = 0;
|
_font = 0;
|
||||||
|
_face = PlasmaFonts.Default;
|
||||||
_attributes = PlasmaAttributes.None;
|
_attributes = PlasmaAttributes.None;
|
||||||
_cursorMode = PlasmaCursorMode.Steady;
|
_cursorMode = PlasmaCursorMode.Steady;
|
||||||
_state = State.Text;
|
_state = State.Text;
|
||||||
@@ -135,6 +197,41 @@ public sealed class VPlasmaDevice
|
|||||||
FlushEvents();
|
FlushEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of diagnostic patterns the JP1 jumper-5 test cycles.</summary>
|
||||||
|
public const int TestPatternCount = 5;
|
||||||
|
|
||||||
|
/// <summary>Show the first test pattern (all dots lit).</summary>
|
||||||
|
public void ShowTestPattern() => ShowTestPattern(0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paint diagnostic pattern <paramref name="index"/> (0..<see
|
||||||
|
/// cref="TestPatternCount"/>-1). The real display's power-on test (JP1
|
||||||
|
/// jumper 5, firmware <c>$B888</c>) runs through several drawing
|
||||||
|
/// sequences — a solid fill, a border+grid, and more, each held briefly —
|
||||||
|
/// to expose dead dots and addressing faults; these stand in for that.
|
||||||
|
/// </summary>
|
||||||
|
public void ShowTestPattern(int index)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < Height; ++y)
|
||||||
|
for (int x = 0; x < Width; ++x)
|
||||||
|
_pixels[y * Width + x] = TestDot(index, x, y) ? PixelLit : (byte)0;
|
||||||
|
_dirty = true;
|
||||||
|
}
|
||||||
|
FlushEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TestDot(int index, int x, int y) => index switch
|
||||||
|
{
|
||||||
|
0 => true, // all on
|
||||||
|
1 => x == 0 || x == Width - 1 || y == 0 || y == Height - 1 // frame
|
||||||
|
|| x % 16 == 0 || y % 8 == 0, // + grid
|
||||||
|
2 => (y / 2) % 2 == 0, // horizontal stripes
|
||||||
|
3 => (x / 4) % 2 == 0, // vertical stripes
|
||||||
|
_ => ((x / 8) + (y / 8)) % 2 == 0, // checkerboard
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Feed <paramref name="count"/> received wire bytes.</summary>
|
/// <summary>Feed <paramref name="count"/> received wire bytes.</summary>
|
||||||
public void OnReceived(byte[] buffer, int count)
|
public void OnReceived(byte[] buffer, int count)
|
||||||
{
|
{
|
||||||
@@ -187,7 +284,7 @@ public sealed class VPlasmaDevice
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
case PlasmaProtocol.BackSpace:
|
case PlasmaProtocol.BackSpace:
|
||||||
if (_col > 0) _col--;
|
_x = Math.Max(0, _x - _face.Width);
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -197,17 +294,18 @@ public sealed class VPlasmaDevice
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
case PlasmaProtocol.LineFeed:
|
case PlasmaProtocol.LineFeed:
|
||||||
_row = (_row + 1) % Rows;
|
NextLine();
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case PlasmaProtocol.VerticalTab:
|
case PlasmaProtocol.VerticalTab:
|
||||||
_row = (_row + Rows - 1) % Rows;
|
_y -= _face.Height;
|
||||||
|
if (_y < 0) _y = Math.Max(0, LogicalH - _face.Height);
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case PlasmaProtocol.CarriageReturn:
|
case PlasmaProtocol.CarriageReturn:
|
||||||
_col = 0;
|
_x = 0;
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -232,8 +330,9 @@ public sealed class VPlasmaDevice
|
|||||||
{
|
{
|
||||||
case PlasmaProtocol.CmdClearScreen:
|
case PlasmaProtocol.CmdClearScreen:
|
||||||
Array.Clear(_pixels, 0, _pixels.Length);
|
Array.Clear(_pixels, 0, _pixels.Length);
|
||||||
_col = _row = 0;
|
_x = _y = 0;
|
||||||
_font = 0;
|
_font = 0;
|
||||||
|
_face = PlasmaFonts.Default;
|
||||||
_attributes = PlasmaAttributes.None;
|
_attributes = PlasmaAttributes.None;
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
Log("Clear screen (ESC @)");
|
Log("Clear screen (ESC @)");
|
||||||
@@ -241,7 +340,7 @@ public sealed class VPlasmaDevice
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case PlasmaProtocol.CmdHomeCursor:
|
case PlasmaProtocol.CmdHomeCursor:
|
||||||
_col = _row = 0;
|
_x = _y = 0;
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
Log("Home cursor (ESC L)");
|
Log("Home cursor (ESC L)");
|
||||||
_graphicsLogArmed = true;
|
_graphicsLogArmed = true;
|
||||||
@@ -250,6 +349,10 @@ public sealed class VPlasmaDevice
|
|||||||
case PlasmaProtocol.CmdCursorMode:
|
case PlasmaProtocol.CmdCursorMode:
|
||||||
case PlasmaProtocol.CmdFontSelect:
|
case PlasmaProtocol.CmdFontSelect:
|
||||||
case PlasmaProtocol.CmdAttributes:
|
case PlasmaProtocol.CmdAttributes:
|
||||||
|
case PlasmaProtocol.CmdSetRow:
|
||||||
|
case PlasmaProtocol.CmdSetColumn:
|
||||||
|
case PlasmaProtocol.CmdDrawPage:
|
||||||
|
case PlasmaProtocol.CmdDisplayPage:
|
||||||
_pendingCommand = b;
|
_pendingCommand = b;
|
||||||
_state = State.Operand;
|
_state = State.Operand;
|
||||||
break;
|
break;
|
||||||
@@ -282,50 +385,58 @@ public sealed class VPlasmaDevice
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case PlasmaProtocol.CmdFontSelect:
|
case PlasmaProtocol.CmdFontSelect:
|
||||||
_font = operand == PlasmaProtocol.OperandDefault ? 0 : operand & 0x07;
|
// Firmware: operands 0–7 select a real font (8 fonts); it range-
|
||||||
// The cursor keeps its cell coordinates but the grid changed size.
|
// checks and ignores anything larger, so ESC K FF is a no-op.
|
||||||
_col = Math.Min(_col, Columns - 1);
|
if (operand < PlasmaFonts.All.Length)
|
||||||
_row = Math.Min(_row, Rows - 1);
|
{
|
||||||
|
_font = operand;
|
||||||
|
_face = PlasmaFonts.All[operand];
|
||||||
|
_x = Math.Min(_x, LogicalW - 1);
|
||||||
|
_y = Math.Min(_y, LogicalH - 1);
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
Log($"Font {_font}: {Columns}×{Rows} cells (ESC K {operand:X2})");
|
Log($"Font {_font}: {_face.Width}×{_face.Height} (ESC K {operand:X2})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log($"Font select ignored (ESC K {operand:X2} out of range)");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlasmaProtocol.CmdAttributes:
|
case PlasmaProtocol.CmdAttributes:
|
||||||
_attributes = DecodeAttributes(operand);
|
// Firmware stores the low 4 bits directly as flags.
|
||||||
|
_attributes = (PlasmaAttributes)(operand & 0x0F);
|
||||||
Log($"Attributes {(_attributes == PlasmaAttributes.None ? "default" : _attributes.ToString())} (ESC H {operand:X2})");
|
Log($"Attributes {(_attributes == PlasmaAttributes.None ? "default" : _attributes.ToString())} (ESC H {operand:X2})");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdSetRow:
|
||||||
|
// ESC Q: set cursor row Y in logical pixels.
|
||||||
|
if (operand < LogicalH)
|
||||||
|
{
|
||||||
|
_y = operand;
|
||||||
|
_dirty = true;
|
||||||
|
Log($"Cursor row Y={operand} (ESC Q {operand:X2})");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdSetColumn:
|
||||||
|
// ESC R: set cursor column X in logical pixels.
|
||||||
|
if (operand < LogicalW)
|
||||||
|
{
|
||||||
|
_x = operand;
|
||||||
|
_dirty = true;
|
||||||
|
Log($"Cursor col X={operand} (ESC R {operand:X2})");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PlasmaProtocol.CmdDrawPage:
|
||||||
|
case PlasmaProtocol.CmdDisplayPage:
|
||||||
|
// Page select: consumed but not acted on (single-page model).
|
||||||
|
Log($"Page select (single-page: ignored) (ESC {(char)command} {operand:X2})");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
_graphicsLogArmed = true;
|
_graphicsLogArmed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <c>ESC H</c>'s operand indexes the style list PLASMA.EXE enumerates
|
|
||||||
/// (its <c>/s</c> option): the 17 intensity/underline/reverse/flash
|
|
||||||
/// combos below, in the tool's own order. FF (and anything out of range)
|
|
||||||
/// restores the defaults.
|
|
||||||
/// </summary>
|
|
||||||
private static PlasmaAttributes DecodeAttributes(byte operand) => operand switch
|
|
||||||
{
|
|
||||||
0 => PlasmaAttributes.None,
|
|
||||||
1 => PlasmaAttributes.HalfIntensity,
|
|
||||||
2 => PlasmaAttributes.Underline,
|
|
||||||
3 => PlasmaAttributes.Reverse,
|
|
||||||
4 => PlasmaAttributes.Flash,
|
|
||||||
5 => PlasmaAttributes.Underline,
|
|
||||||
6 => PlasmaAttributes.Reverse,
|
|
||||||
7 => PlasmaAttributes.Flash,
|
|
||||||
8 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline,
|
|
||||||
9 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Reverse,
|
|
||||||
10 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Flash,
|
|
||||||
11 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
|
|
||||||
12 => PlasmaAttributes.Underline | PlasmaAttributes.Flash,
|
|
||||||
13 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
|
|
||||||
14 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
|
|
||||||
15 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Flash,
|
|
||||||
16 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
|
|
||||||
_ => PlasmaAttributes.None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- graphics writes (ESC P) -------------------------------------------
|
// ---- graphics writes (ESC P) -------------------------------------------
|
||||||
|
|
||||||
private void BeginGraphicsData()
|
private void BeginGraphicsData()
|
||||||
@@ -353,18 +464,15 @@ public sealed class VPlasmaDevice
|
|||||||
|
|
||||||
int y = _header[1] + rowOfBlock;
|
int y = _header[1] + rowOfBlock;
|
||||||
int xByte = _header[2] + byteOfRow;
|
int xByte = _header[2] + byteOfRow;
|
||||||
if (y < Height && xByte < WidthBytes)
|
// MSB is the leftmost pixel (L4PLASMA.CPP packs 0x80 first). Graphics
|
||||||
{
|
// dots are plain full intensity; Plot maps them through the orientation.
|
||||||
// MSB is the leftmost pixel (L4PLASMA.CPP packs 0x80 first).
|
int baseX = xByte * 8;
|
||||||
// Graphics dots are plain full intensity: overwriting text clears
|
|
||||||
// its half/flash flags, like repainting the glass.
|
|
||||||
int offset = y * Width + xByte * 8;
|
|
||||||
for (int bit = 0; bit < 8; ++bit)
|
for (int bit = 0; bit < 8; ++bit)
|
||||||
_pixels[offset + bit] = (b & (0x80 >> bit)) != 0 ? PixelLit : (byte)0;
|
Plot(baseX + bit, y, (b & (0x80 >> bit)) != 0 ? PixelLit : (byte)0);
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
if (byteOfRow == w - 1)
|
// 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++;
|
_graphicsRows++;
|
||||||
}
|
|
||||||
|
|
||||||
if (++_dataIndex >= _dataLength)
|
if (++_dataIndex >= _dataLength)
|
||||||
_state = State.Text;
|
_state = State.Text;
|
||||||
@@ -374,34 +482,28 @@ public sealed class VPlasmaDevice
|
|||||||
|
|
||||||
private void DrawChar(byte code)
|
private void DrawChar(byte code)
|
||||||
{
|
{
|
||||||
int scale = FontScale;
|
// The firmware ignores a character outside the current font's range.
|
||||||
int cellW = 6 * scale, cellH = 8 * scale;
|
if (!_face.Has(code))
|
||||||
int ox = _col * cellW, oy = _row * cellH;
|
return;
|
||||||
|
|
||||||
Span<byte> columns = stackalloc byte[PlasmaFont.GlyphWidth];
|
|
||||||
PlasmaFont.GetColumns(code, columns);
|
|
||||||
|
|
||||||
|
int w = _face.Width, h = _face.Height;
|
||||||
bool reverse = (_attributes & PlasmaAttributes.Reverse) != 0;
|
bool reverse = (_attributes & PlasmaAttributes.Reverse) != 0;
|
||||||
bool underline = (_attributes & PlasmaAttributes.Underline) != 0;
|
bool underline = (_attributes & PlasmaAttributes.Underline) != 0;
|
||||||
byte litFlags = PixelLit;
|
byte litFlags = PixelLit;
|
||||||
if ((_attributes & PlasmaAttributes.HalfIntensity) != 0) litFlags |= PixelHalf;
|
if ((_attributes & PlasmaAttributes.HalfIntensity) != 0) litFlags |= PixelHalf;
|
||||||
if ((_attributes & PlasmaAttributes.Flash) != 0) litFlags |= PixelFlash;
|
if ((_attributes & PlasmaAttributes.Flash) != 0) litFlags |= PixelFlash;
|
||||||
|
|
||||||
for (int cy = 0; cy < cellH; ++cy)
|
for (int row = 0; row < h; ++row)
|
||||||
{
|
{
|
||||||
int glyphRow = cy / scale; // 0..7; row 7 is the gap/underline row
|
ushort bits = _face.Row(code, row); // bit 15 = leftmost pixel
|
||||||
int rowOffset = (oy + cy) * Width + ox;
|
for (int col = 0; col < w; ++col)
|
||||||
for (int cx = 0; cx < cellW; ++cx)
|
|
||||||
{
|
{
|
||||||
int glyphCol = cx / scale; // 0..5; column 5 is the gap column
|
bool on = (bits & (0x8000 >> col)) != 0;
|
||||||
bool on = glyphCol < PlasmaFont.GlyphWidth
|
if (underline && row == h - 1)
|
||||||
&& glyphRow < PlasmaFont.GlyphHeight
|
|
||||||
&& (columns[glyphCol] >> glyphRow & 1) != 0;
|
|
||||||
if (underline && glyphRow == 7)
|
|
||||||
on = true;
|
on = true;
|
||||||
if (reverse)
|
if (reverse)
|
||||||
on = !on;
|
on = !on;
|
||||||
_pixels[rowOffset + cx] = on ? litFlags : (byte)0;
|
Plot(_x + col, _y + row, on ? litFlags : (byte)0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,15 +512,21 @@ public sealed class VPlasmaDevice
|
|||||||
AdvanceCursor();
|
AdvanceCursor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Advance the cursor one cell, wrapping at the right/bottom edges.</summary>
|
||||||
private void AdvanceCursor()
|
private void AdvanceCursor()
|
||||||
{
|
{
|
||||||
if (++_col >= Columns)
|
_x += _face.Width;
|
||||||
{
|
if (_x > LogicalW - _face.Width)
|
||||||
_col = 0;
|
NextLine();
|
||||||
// No scroll on these panels: writing past the last row wraps to the top.
|
|
||||||
if (++_row >= Rows)
|
|
||||||
_row = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void NextLine()
|
||||||
|
{
|
||||||
|
_x = 0;
|
||||||
|
_y += _face.Height;
|
||||||
|
// No scroll on these panels: past the last line wraps to the top.
|
||||||
|
if (_y > LogicalH - _face.Height)
|
||||||
|
_y = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- event plumbing --------------------------------------------------------
|
// ---- event plumbing --------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
|
||||||
|
namespace VPlasma.Core.Device;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves a <see cref="VPlasmaDevice"/> over the named pipe that the
|
||||||
|
/// DOSBox-X fork's <c>serial2=namedpipe pipe:vplasma</c> backend connects
|
||||||
|
/// to — the com0com-free transport. We are the pipe <em>server</em> (the
|
||||||
|
/// display is always present); DOSBox-X is the client and background-retries
|
||||||
|
/// while the pipe is missing, so this service listens for the whole app
|
||||||
|
/// lifetime alongside the COM path.
|
||||||
|
///
|
||||||
|
/// <para>Framing is the contract shared with the fork (see VRio.Core's
|
||||||
|
/// <c>PipeFraming</c>, and <c>serialnamedpipe.h</c> on the fork side):
|
||||||
|
/// <c>0x00 len bytes</c> carries serial data, <c>0x01 bits</c> carries the
|
||||||
|
/// sender's DTR/RTS. Like the serial twin, the plasma is a pure listener —
|
||||||
|
/// we send one lines frame on connect (DTR+RTS high, "display present") and
|
||||||
|
/// then only ever read. The game may toggle its own lines around writes
|
||||||
|
/// (DOS console redirection flips RTS per byte); the display doesn't care,
|
||||||
|
/// so inbound lines frames are consumed silently.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VPlasmaPipeService : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>The contract pipe name: DOSBox-X connects to <c>\\.\pipe\vplasma</c>.</summary>
|
||||||
|
public const string DefaultPipeName = "vplasma";
|
||||||
|
|
||||||
|
// Frame types and line bits, per the shared contract.
|
||||||
|
private const byte DataType = 0x00;
|
||||||
|
private const byte LinesType = 0x01;
|
||||||
|
private const byte LinesPresent = 0x03; // DTR + RTS
|
||||||
|
|
||||||
|
private readonly VPlasmaDevice _device;
|
||||||
|
private readonly string _pipeName;
|
||||||
|
|
||||||
|
private NamedPipeServerStream? _pipe;
|
||||||
|
private Thread? _server;
|
||||||
|
private volatile bool _running;
|
||||||
|
private volatile bool _clientConnected;
|
||||||
|
|
||||||
|
public VPlasmaPipeService(VPlasmaDevice device, string pipeName = DefaultPipeName)
|
||||||
|
{
|
||||||
|
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||||
|
if (string.IsNullOrWhiteSpace(pipeName))
|
||||||
|
throw new ArgumentException("Pipe name is required.", nameof(pipeName));
|
||||||
|
_pipeName = pipeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
|
||||||
|
public string PipeName => _pipeName;
|
||||||
|
|
||||||
|
/// <summary>True while the server is up (whether or not a client is connected).</summary>
|
||||||
|
public bool IsListening => _running;
|
||||||
|
|
||||||
|
/// <summary>True while a client is connected.</summary>
|
||||||
|
public bool IsClientConnected => _clientConnected;
|
||||||
|
|
||||||
|
/// <summary>A client connected (true) or went away (false).</summary>
|
||||||
|
public event Action<bool>? ClientChanged;
|
||||||
|
|
||||||
|
/// <summary>Pipe-level log lines (listen/connect/errors).</summary>
|
||||||
|
public event Action<string>? Logged;
|
||||||
|
|
||||||
|
/// <summary>Start listening (idempotent). Clients may come and go forever.</summary>
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_running = true;
|
||||||
|
_server = new Thread(ServerLoop) { IsBackground = true, Name = $"vPLASMA pipe server ({_pipeName})" };
|
||||||
|
_server.Start();
|
||||||
|
|
||||||
|
Logged?.Invoke($@"Listening on \\.\pipe\{_pipeName} — DOSBox-X connects when it boots");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stop listening and drop any client (idempotent).</summary>
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (!_running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_running = false;
|
||||||
|
NamedPipeServerStream? pipe = _pipe;
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe?.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
|
||||||
|
// A WaitForConnection pending on the disposed stream can survive the
|
||||||
|
// Dispose on net48; a throwaway client connect releases it either way.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var poke = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out);
|
||||||
|
poke.Connect(100);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) { }
|
||||||
|
|
||||||
|
_server?.Join(1000);
|
||||||
|
_server = null;
|
||||||
|
_clientConnected = false;
|
||||||
|
|
||||||
|
Logged?.Invoke("Pipe server stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ServerLoop()
|
||||||
|
{
|
||||||
|
bool busyLogged = false; // log the name collision once, not per retry
|
||||||
|
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
NamedPipeServerStream pipe;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
|
||||||
|
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Name already served — e.g. vRIO's built-in glass and the
|
||||||
|
// standalone vPLASMA both running. Whoever loses just waits.
|
||||||
|
if (!busyLogged)
|
||||||
|
{
|
||||||
|
busyLogged = true;
|
||||||
|
Logged?.Invoke($@"\\.\pipe\{_pipeName} is busy ({ex.Message.TrimEnd('.')}) — retrying until it frees up");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 20 && _running; i++)
|
||||||
|
Thread.Sleep(100);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
busyLogged = false;
|
||||||
|
|
||||||
|
_pipe = pipe;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe.WaitForConnection();
|
||||||
|
|
||||||
|
if (_running)
|
||||||
|
{
|
||||||
|
// Our lines frame goes first: display present.
|
||||||
|
pipe.Write(new byte[] { LinesType, LinesPresent }, 0, 2);
|
||||||
|
pipe.Flush();
|
||||||
|
|
||||||
|
_clientConnected = true;
|
||||||
|
Logged?.Invoke("Pipe client connected — sent lines DTR+RTS (display present)");
|
||||||
|
ClientChanged?.Invoke(true);
|
||||||
|
|
||||||
|
ReadUntilDisconnect(pipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Disposed by Stop(), or a client vanished mid-connect.
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wasConnected = _clientConnected;
|
||||||
|
_clientConnected = false;
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
|
||||||
|
if (wasConnected && _running)
|
||||||
|
{
|
||||||
|
Logged?.Invoke("Pipe client disconnected");
|
||||||
|
ClientChanged?.Invoke(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReadUntilDisconnect(NamedPipeServerStream pipe)
|
||||||
|
{
|
||||||
|
// Inline deframer (the listener-only subset of VRio.Core's
|
||||||
|
// PipeFrameDecoder): data payloads go to the device, lines frames are
|
||||||
|
// consumed silently, anything else is a protocol bug — log and drop
|
||||||
|
// the connection rather than resync.
|
||||||
|
const int StateType = 0, StateLength = 1, StatePayload = 2, StateLines = 3;
|
||||||
|
int state = StateType, remaining = 0;
|
||||||
|
|
||||||
|
var buffer = new byte[512];
|
||||||
|
var payload = new byte[byte.MaxValue];
|
||||||
|
int fill = 0;
|
||||||
|
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
int n;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
n = pipe.Read(buffer, 0, buffer.Length);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
if (_running)
|
||||||
|
Logged?.Invoke($"Pipe error: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n == 0)
|
||||||
|
return; // client closed its end
|
||||||
|
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
byte b = buffer[i];
|
||||||
|
switch (state)
|
||||||
|
{
|
||||||
|
case StateType when b == DataType:
|
||||||
|
state = StateLength;
|
||||||
|
break;
|
||||||
|
case StateType when b == LinesType:
|
||||||
|
state = StateLines;
|
||||||
|
break;
|
||||||
|
case StateType:
|
||||||
|
Logged?.Invoke($"Pipe protocol violation: unknown frame type 0x{b:X2} — dropping the connection");
|
||||||
|
return;
|
||||||
|
|
||||||
|
case StateLength when b == 0:
|
||||||
|
Logged?.Invoke("Pipe protocol violation: zero-length data frame — dropping the connection");
|
||||||
|
return;
|
||||||
|
case StateLength:
|
||||||
|
remaining = b;
|
||||||
|
fill = 0;
|
||||||
|
state = StatePayload;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case StatePayload:
|
||||||
|
payload[fill++] = b;
|
||||||
|
if (fill == remaining)
|
||||||
|
{
|
||||||
|
state = StateType;
|
||||||
|
_device.OnReceived(payload, remaining);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case StateLines:
|
||||||
|
state = StateType; // the display ignores host line state
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Stop();
|
||||||
|
}
|
||||||
@@ -27,6 +27,14 @@ public sealed class VPlasmaSerialService : IDisposable
|
|||||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bit rate to open the port at (default 9600). On the real display this is
|
||||||
|
/// the baud the two JP1 jumpers select (4800/9600/19.2K/38.4K); over a
|
||||||
|
/// virtual null-modem it's cosmetic, but the standalone app exposes the
|
||||||
|
/// jumpers so it's honored on the next open. Set before <see cref="Open"/>.
|
||||||
|
/// </summary>
|
||||||
|
public int Baud { get; set; } = PlasmaProtocol.BaudRate;
|
||||||
|
|
||||||
/// <summary>True while a COM port is open.</summary>
|
/// <summary>True while a COM port is open.</summary>
|
||||||
public bool IsOpen => _port?.IsOpen == true;
|
public bool IsOpen => _port?.IsOpen == true;
|
||||||
|
|
||||||
@@ -47,7 +55,7 @@ public sealed class VPlasmaSerialService : IDisposable
|
|||||||
|
|
||||||
Close();
|
Close();
|
||||||
|
|
||||||
var port = new SerialPort(portName, PlasmaProtocol.BaudRate, Parity.None, 8, StopBits.One)
|
var port = new SerialPort(portName, Baud, Parity.None, 8, StopBits.One)
|
||||||
{
|
{
|
||||||
Handshake = Handshake.None,
|
Handshake = Handshake.None,
|
||||||
// Finite read timeout so the reader thread can notice shutdown.
|
// Finite read timeout so the reader thread can notice shutdown.
|
||||||
@@ -65,7 +73,7 @@ public sealed class VPlasmaSerialService : IDisposable
|
|||||||
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "vPLASMA serial reader" };
|
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "vPLASMA serial reader" };
|
||||||
_reader.Start();
|
_reader.Start();
|
||||||
|
|
||||||
Logged?.Invoke($"Opened {portName} @ {PlasmaProtocol.BaudRate} 8N1 — listening for the host");
|
Logged?.Invoke($"Opened {portName} @ {Baud} 8N1 — listening for the host");
|
||||||
ConnectionChanged?.Invoke(true);
|
ConnectionChanged?.Invoke(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ public static class PlasmaProtocol
|
|||||||
public const byte CmdFontSelect = (byte)'K';
|
public const byte CmdFontSelect = (byte)'K';
|
||||||
public const byte CmdHomeCursor = (byte)'L';
|
public const byte CmdHomeCursor = (byte)'L';
|
||||||
public const byte CmdGraphicsWrite = (byte)'P';
|
public const byte CmdGraphicsWrite = (byte)'P';
|
||||||
|
// Cursor positioning, confirmed from the firmware dump (FIRMWARE.md): the
|
||||||
|
// cursor is a pixel position, ESC Q sets its row (Y, 0-31) and ESC R its
|
||||||
|
// column (X, 0-127) — the exact range checks the firmware enforces.
|
||||||
|
public const byte CmdSetRow = (byte)'Q';
|
||||||
|
public const byte CmdSetColumn = (byte)'R';
|
||||||
|
// Page select (the firmware's 10 double-buffered screens): ESC I picks the
|
||||||
|
// draw page, ESC i the displayed page. vPLASMA models a single page, so
|
||||||
|
// these are consumed (1 operand each) but not acted on — enough to replay
|
||||||
|
// the built-in demo, which uses them heavily.
|
||||||
|
public const byte CmdDrawPage = (byte)'I';
|
||||||
|
public const byte CmdDisplayPage = (byte)'i';
|
||||||
|
|
||||||
/// <summary>Operand meaning "restore the default" for ESC K / ESC H.</summary>
|
/// <summary>Operand meaning "restore the default" for ESC K / ESC H.</summary>
|
||||||
public const byte OperandDefault = 0xFF;
|
public const byte OperandDefault = 0xFF;
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace VRio.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the PnP device instance IDs behind every attached gamepad/joystick,
|
||||||
|
/// for handing to <see cref="HidHideControl"/>. Enumeration rides on Raw
|
||||||
|
/// Input's HID list (usage page 1, usages 4/5/8 — joystick, gamepad,
|
||||||
|
/// multi-axis), the same census games take.
|
||||||
|
///
|
||||||
|
/// <para>For XInput-class pads the HID collection is only the DirectInput
|
||||||
|
/// face; XInput itself reads the <em>parent</em> XUSB/GIP devnode. Those pads
|
||||||
|
/// are recognizable by the <c>IG_</c> marker Microsoft puts in their HID
|
||||||
|
/// hardware IDs, and both the HID instance and its parent are returned —
|
||||||
|
/// cloaking just one of them leaves the other visible to half the world.
|
||||||
|
/// Plain HID pads (DualShock and friends) contribute only their HID instance;
|
||||||
|
/// their parent is often a composite device with audio and such that must not
|
||||||
|
/// be hidden.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class GamepadDeviceLocator
|
||||||
|
{
|
||||||
|
// --- Win32 interop -------------------------------------------------------
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct RAWINPUTDEVICELIST
|
||||||
|
{
|
||||||
|
public IntPtr hDevice;
|
||||||
|
public uint dwType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The RID_DEVICE_INFO union flattened to its HID arm; the keyboard arm is
|
||||||
|
// the largest (24 bytes), so the tail pad keeps cbSize honest at 32.
|
||||||
|
[StructLayout(LayoutKind.Explicit)]
|
||||||
|
private struct RID_DEVICE_INFO
|
||||||
|
{
|
||||||
|
[FieldOffset(0)] public uint cbSize;
|
||||||
|
[FieldOffset(4)] public uint dwType;
|
||||||
|
[FieldOffset(8)] public uint hidVendorId;
|
||||||
|
[FieldOffset(12)] public uint hidProductId;
|
||||||
|
[FieldOffset(16)] public uint hidVersionNumber;
|
||||||
|
[FieldOffset(20)] public ushort hidUsagePage;
|
||||||
|
[FieldOffset(22)] public ushort hidUsage;
|
||||||
|
[FieldOffset(28)] public uint KeyboardArmTail; // pads the union to its true 24-byte size
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern uint GetRawInputDeviceList(
|
||||||
|
[In, Out] RAWINPUTDEVICELIST[]? pRawInputDeviceList, ref uint puiNumDevices, uint cbSize);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern uint GetRawInputDeviceInfoW(
|
||||||
|
IntPtr hDevice, uint uiCommand, IntPtr pData, ref uint pcbSize);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern uint GetRawInputDeviceInfoW(
|
||||||
|
IntPtr hDevice, uint uiCommand, ref RID_DEVICE_INFO pData, ref uint pcbSize);
|
||||||
|
|
||||||
|
[DllImport("cfgmgr32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int CM_Locate_DevNodeW(out uint pdnDevInst, string pDeviceID, uint ulFlags);
|
||||||
|
|
||||||
|
[DllImport("cfgmgr32.dll")]
|
||||||
|
private static extern int CM_Get_Parent(out uint pdnDevInst, uint dnDevInst, uint ulFlags);
|
||||||
|
|
||||||
|
[DllImport("cfgmgr32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int CM_Get_Device_IDW(uint dnDevInst, StringBuilder buffer, int bufferLen, uint ulFlags);
|
||||||
|
|
||||||
|
private const uint Err = 0xFFFFFFFF;
|
||||||
|
private const uint RIM_TYPEHID = 2;
|
||||||
|
private const uint RIDI_DEVICENAME = 0x20000007;
|
||||||
|
private const uint RIDI_DEVICEINFO = 0x2000000B;
|
||||||
|
private const ushort UsagePageGeneric = 0x01;
|
||||||
|
private const ushort UsageJoystick = 0x04;
|
||||||
|
private const ushort UsageGamepad = 0x05;
|
||||||
|
private const ushort UsageMultiAxis = 0x08;
|
||||||
|
private const int CrSuccess = 0;
|
||||||
|
private const int MaxDeviceIdLen = 200;
|
||||||
|
|
||||||
|
// --- Enumeration ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Device instance IDs of every attached gamepad/joystick: the HID
|
||||||
|
/// instances, plus the parent devnode of each <c>IG_</c> (XInput) pad.
|
||||||
|
/// Empty when no pad is attached.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> FindGamepadInstances()
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
uint listSize = (uint)Marshal.SizeOf<RAWINPUTDEVICELIST>();
|
||||||
|
uint count = 0;
|
||||||
|
if (GetRawInputDeviceList(null, ref count, listSize) == Err || count == 0)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var devices = new RAWINPUTDEVICELIST[count];
|
||||||
|
uint got = GetRawInputDeviceList(devices, ref count, listSize);
|
||||||
|
if (got == Err)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
for (int i = 0; i < got; i++)
|
||||||
|
{
|
||||||
|
if (devices[i].dwType != RIM_TYPEHID || !IsGamepad(devices[i].hDevice))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string? path = InterfacePath(devices[i].hDevice);
|
||||||
|
string? instance = path is null ? null : InstanceIdFromInterfacePath(path);
|
||||||
|
if (instance is null || result.Contains(instance, StringComparer.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
result.Add(instance);
|
||||||
|
if (instance.IndexOf("IG_", StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|
&& ParentInstanceId(instance) is { } parent
|
||||||
|
&& !result.Contains(parent, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
result.Add(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGamepad(IntPtr hDevice)
|
||||||
|
{
|
||||||
|
var info = new RID_DEVICE_INFO { cbSize = (uint)Marshal.SizeOf<RID_DEVICE_INFO>() };
|
||||||
|
uint size = info.cbSize;
|
||||||
|
if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICEINFO, ref info, ref size) == Err)
|
||||||
|
return false;
|
||||||
|
return info.dwType == RIM_TYPEHID
|
||||||
|
&& info.hidUsagePage == UsagePageGeneric
|
||||||
|
&& info.hidUsage is UsageJoystick or UsageGamepad or UsageMultiAxis;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? InterfacePath(IntPtr hDevice)
|
||||||
|
{
|
||||||
|
uint chars = 0;
|
||||||
|
if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, IntPtr.Zero, ref chars) == Err || chars == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
IntPtr buffer = Marshal.AllocHGlobal((int)chars * sizeof(char));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, buffer, ref chars) == Err)
|
||||||
|
return null;
|
||||||
|
return Marshal.PtrToStringUni(buffer);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>\\?\HID#VID_x&PID_y#instance#{guid}</c> → <c>HID\VID_x&PID_y\instance</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string InstanceIdFromInterfacePath(string path)
|
||||||
|
{
|
||||||
|
string s = path;
|
||||||
|
if (s.StartsWith(@"\\?\", StringComparison.Ordinal) || s.StartsWith(@"\\.\", StringComparison.Ordinal))
|
||||||
|
s = s.Substring(4);
|
||||||
|
int guid = s.LastIndexOf("#{", StringComparison.Ordinal);
|
||||||
|
if (guid >= 0)
|
||||||
|
s = s.Substring(0, guid);
|
||||||
|
return s.Replace('#', '\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ParentInstanceId(string instanceId)
|
||||||
|
{
|
||||||
|
if (CM_Locate_DevNodeW(out uint node, instanceId, 0) != CrSuccess)
|
||||||
|
return null;
|
||||||
|
if (CM_Get_Parent(out uint parent, node, 0) != CrSuccess)
|
||||||
|
return null;
|
||||||
|
var buffer = new StringBuilder(MaxDeviceIdLen);
|
||||||
|
return CM_Get_Device_IDW(parent, buffer, buffer.Capacity, 0) == CrSuccess ? buffer.ToString() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
namespace VRio.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal client for the HidHide filter driver's control device
|
||||||
|
/// (<c>\\.\HidHide</c>) — the same wire protocol Nefarius's
|
||||||
|
/// <c>HidHideControlService</c> speaks, reduced to what the exclusive-gamepad
|
||||||
|
/// toggle needs. HidHide cloaks blocked device instances from every process
|
||||||
|
/// except the allowlisted applications, which is the only robust way to keep
|
||||||
|
/// a second app (DOSBox-X, Steam, the sim itself) from also reacting to the
|
||||||
|
/// cockpit gamepad: XInput has no exclusive mode of its own.
|
||||||
|
///
|
||||||
|
/// <para>Every operation opens the control device fresh, mirroring the
|
||||||
|
/// reference implementation; the driver treats each set as a full list
|
||||||
|
/// replace, so add/remove is read-modify-write here. All methods throw
|
||||||
|
/// <see cref="Win32Exception"/> (driver refused / not reachable) or
|
||||||
|
/// <see cref="InvalidOperationException"/> (unconvertible path) — callers
|
||||||
|
/// surface the message and carry on.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class HidHideControl
|
||||||
|
{
|
||||||
|
private const string ControlDevice = @"\\.\HidHide";
|
||||||
|
|
||||||
|
// CTL_CODE(0x8001, 2048+n, METHOD_BUFFERED, FILE_READ_DATA) — the
|
||||||
|
// device type / function numbers HidHide has used since v1.0.
|
||||||
|
private const uint IoctlGetWhitelist = 0x80016000;
|
||||||
|
private const uint IoctlSetWhitelist = 0x80016004;
|
||||||
|
private const uint IoctlGetBlacklist = 0x80016008;
|
||||||
|
private const uint IoctlSetBlacklist = 0x8001600C;
|
||||||
|
private const uint IoctlGetActive = 0x80016010;
|
||||||
|
private const uint IoctlSetActive = 0x80016014;
|
||||||
|
|
||||||
|
private const uint GenericRead = 0x80000000;
|
||||||
|
private const uint GenericWrite = 0x40000000;
|
||||||
|
private const uint ShareReadWrite = 0x00000003;
|
||||||
|
private const uint OpenExisting = 3;
|
||||||
|
private const int ErrorFileNotFound = 2;
|
||||||
|
private const int ErrorPathNotFound = 3;
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern SafeFileHandle CreateFileW(
|
||||||
|
string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes,
|
||||||
|
uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool DeviceIoControl(
|
||||||
|
SafeFileHandle hDevice, uint dwIoControlCode,
|
||||||
|
byte[]? lpInBuffer, uint nInBufferSize,
|
||||||
|
byte[]? lpOutBuffer, uint nOutBufferSize,
|
||||||
|
out uint lpBytesReturned, IntPtr lpOverlapped);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern uint QueryDosDeviceW(string lpDeviceName, StringBuilder lpTargetPath, int ucchMax);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the HidHide driver is present (its control device exists),
|
||||||
|
/// even if a later operation might still be refused by its ACL.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsInstalled()
|
||||||
|
{
|
||||||
|
using SafeFileHandle handle = CreateFileW(ControlDevice, GenericRead | GenericWrite,
|
||||||
|
ShareReadWrite, IntPtr.Zero, OpenExisting, 0, IntPtr.Zero);
|
||||||
|
if (!handle.IsInvalid)
|
||||||
|
return true;
|
||||||
|
return Marshal.GetLastWin32Error() is not (ErrorFileNotFound or ErrorPathNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the cloak is currently up (blocked devices hidden).</summary>
|
||||||
|
public static bool GetActive()
|
||||||
|
{
|
||||||
|
using SafeFileHandle handle = Open();
|
||||||
|
var buffer = new byte[1];
|
||||||
|
Ioctl(handle, IoctlGetActive, null, buffer);
|
||||||
|
return buffer[0] != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetActive(bool active)
|
||||||
|
{
|
||||||
|
using SafeFileHandle handle = Open();
|
||||||
|
Ioctl(handle, IoctlSetActive, new[] { active ? (byte)1 : (byte)0 }, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Device instance IDs currently cloaked (e.g. <c>HID\VID_045E…\…</c>).</summary>
|
||||||
|
public static List<string> GetBlockedInstances() => GetList(IoctlGetBlacklist);
|
||||||
|
|
||||||
|
/// <summary>Replace the cloaked-instance list wholesale.</summary>
|
||||||
|
public static void SetBlockedInstances(IReadOnlyCollection<string> instanceIds) =>
|
||||||
|
SetList(IoctlSetBlacklist, instanceIds);
|
||||||
|
|
||||||
|
/// <summary>Allowlisted applications, in dos-device form (<c>\Device\HarddiskVolumeN\…</c>).</summary>
|
||||||
|
public static List<string> GetAllowedApps() => GetList(IoctlGetWhitelist);
|
||||||
|
|
||||||
|
/// <summary>Replace the application allowlist wholesale.</summary>
|
||||||
|
public static void SetAllowedApps(IReadOnlyCollection<string> dosDevicePaths) =>
|
||||||
|
SetList(IoctlSetWhitelist, dosDevicePaths);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert a normal path (<c>C:\dir\app.exe</c>) to the dos-device form
|
||||||
|
/// the driver stores allowlist entries in
|
||||||
|
/// (<c>\Device\HarddiskVolume3\dir\app.exe</c>). Drive-letter paths only —
|
||||||
|
/// the driver compares against the kernel's image path, which never has a
|
||||||
|
/// letter.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToDosDevicePath(string path)
|
||||||
|
{
|
||||||
|
string full = Path.GetFullPath(path);
|
||||||
|
string? root = Path.GetPathRoot(full);
|
||||||
|
if (root is null || root.Length < 2 || root[1] != ':')
|
||||||
|
throw new InvalidOperationException($"cannot allowlist \"{full}\" — only local drive paths are supported.");
|
||||||
|
|
||||||
|
string drive = root.Substring(0, 2); // "C:"
|
||||||
|
var target = new StringBuilder(512);
|
||||||
|
if (QueryDosDeviceW(drive, target, target.Capacity) == 0)
|
||||||
|
throw new Win32Exception();
|
||||||
|
return target.ToString() + full.Substring(drive.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wire helpers --------------------------------------------------------
|
||||||
|
|
||||||
|
private static SafeFileHandle Open()
|
||||||
|
{
|
||||||
|
SafeFileHandle handle = CreateFileW(ControlDevice, GenericRead | GenericWrite,
|
||||||
|
ShareReadWrite, IntPtr.Zero, OpenExisting, 0, IntPtr.Zero);
|
||||||
|
if (handle.IsInvalid)
|
||||||
|
throw new Win32Exception();
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint Ioctl(SafeFileHandle handle, uint code, byte[]? input, byte[]? output)
|
||||||
|
{
|
||||||
|
if (!DeviceIoControl(handle, code, input, (uint)(input?.Length ?? 0),
|
||||||
|
output, (uint)(output?.Length ?? 0), out uint returned, IntPtr.Zero))
|
||||||
|
throw new Win32Exception();
|
||||||
|
return returned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> GetList(uint code)
|
||||||
|
{
|
||||||
|
using SafeFileHandle handle = Open();
|
||||||
|
// Called with no output buffer the driver reports the required size
|
||||||
|
// (in bytes) instead of failing; a second call fetches the data.
|
||||||
|
uint needed = Ioctl(handle, code, null, null);
|
||||||
|
if (needed == 0)
|
||||||
|
return new List<string>();
|
||||||
|
var buffer = new byte[needed];
|
||||||
|
uint got = Ioctl(handle, code, null, buffer);
|
||||||
|
return DecodeMultiSz(buffer, (int)Math.Min(got, (uint)buffer.Length));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetList(uint code, IEnumerable<string> items)
|
||||||
|
{
|
||||||
|
using SafeFileHandle handle = Open();
|
||||||
|
Ioctl(handle, code, EncodeMultiSz(items), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>UTF-16 multi-sz: each string null-terminated, double null at the end.</summary>
|
||||||
|
private static byte[] EncodeMultiSz(IEnumerable<string> items)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (string item in items)
|
||||||
|
{
|
||||||
|
sb.Append(item);
|
||||||
|
sb.Append('\0');
|
||||||
|
}
|
||||||
|
sb.Append('\0');
|
||||||
|
if (sb.Length == 1)
|
||||||
|
sb.Append('\0'); // an empty multi-sz is two terminators
|
||||||
|
return Encoding.Unicode.GetBytes(sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> DecodeMultiSz(byte[] buffer, int length)
|
||||||
|
{
|
||||||
|
string text = Encoding.Unicode.GetString(buffer, 0, length);
|
||||||
|
var result = new List<string>();
|
||||||
|
foreach (string s in text.Split('\0'))
|
||||||
|
{
|
||||||
|
if (s.Length > 0)
|
||||||
|
result.Add(s);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+281
-84
@@ -10,34 +10,37 @@ namespace VRio.App;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// vRIO main window: the interactive cockpit panel on the left (the same
|
/// vRIO main window: the interactive cockpit panel on the left (the same
|
||||||
/// functional map RIOJoy's profile editor shows) and a control strip on the
|
/// functional map RIOJoy's profile editor shows) and a control strip on the
|
||||||
/// right — COM ports, device settings, and a live wire log. The panel's
|
/// right — connection rows, device settings, and a live wire log. The
|
||||||
/// encoder strip also hosts the built-in plasma glass (the vPLASMA display
|
/// panel's encoder strip also hosts the built-in plasma glass (the vPLASMA
|
||||||
/// emulator) on its own COM port. At startup the usual ports
|
/// display emulator) on its own connection row. Each row's picker offers
|
||||||
/// (<see cref="PreferredPort"/>, <see cref="PreferredPlasmaPort"/>) are
|
/// the device's named-pipe endpoint (the DOSBox-X fork's namedpipe backend
|
||||||
/// opened automatically when available; otherwise open them by hand. Point
|
/// — the com0com-free path) alongside the machine's COM ports; nothing
|
||||||
/// RIOJoy at the other end of the null-modem pair, and every click here
|
/// opens automatically — pick an endpoint and Open. Point RIOJoy at the
|
||||||
/// arrives at RIOJoy exactly as if the physical cockpit sent it; point the
|
/// other end of a null-modem pair and every click here arrives exactly as
|
||||||
/// game's COM2 passthrough at the plasma pair and the glass lights up.
|
/// if the physical cockpit sent it; point the game at
|
||||||
|
/// <see cref="RioPipeEndpoint"/>/<see cref="PlasmaPipeEndpoint"/> (or the
|
||||||
|
/// COM2 passthrough at the plasma pair) and the glass lights up.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class MainForm : Form
|
internal sealed class MainForm : Form
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// vRIO's usual port: the device end of the COM1⇄COM11 com0com pair.
|
/// The RIO's pipe endpoint as listed in the picker. Matches the
|
||||||
/// Auto-opened at startup when present and free; the picker still allows
|
/// DOSBox-X conf syntax: <c>serial1=namedpipe pipe:vrio</c>.
|
||||||
/// any other port.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const string PreferredPort = "COM11";
|
private static readonly string RioPipeEndpoint = $"pipe:{VRioPipeService.DefaultPipeName}";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The built-in plasma's usual port: the device end of the COM2⇄COM12
|
/// The plasma's pipe endpoint as listed in the picker
|
||||||
/// com0com pair (the game's COM2 passthrough opens the other end).
|
/// (<c>serial2=namedpipe pipe:vplasma</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const string PreferredPlasmaPort = "COM12";
|
private static readonly string PlasmaPipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
|
||||||
|
|
||||||
private readonly VRioDevice _device = new();
|
private readonly VRioDevice _device = new();
|
||||||
private readonly VRioSerialService _service;
|
private readonly VRioSerialService _service;
|
||||||
|
private readonly VRioPipeService _pipeService;
|
||||||
private readonly VPlasmaDevice _plasmaDevice = new();
|
private readonly VPlasmaDevice _plasmaDevice = new();
|
||||||
private readonly VPlasmaSerialService _plasmaService;
|
private readonly VPlasmaSerialService _plasmaService;
|
||||||
|
private readonly VPlasmaPipeService _plasmaPipeService;
|
||||||
private readonly PanelCanvas _canvas = new();
|
private readonly PanelCanvas _canvas = new();
|
||||||
private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch)
|
private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch)
|
||||||
{
|
{
|
||||||
@@ -126,6 +129,23 @@ internal sealed class MainForm : Form
|
|||||||
Width = 286,
|
Width = 286,
|
||||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||||
};
|
};
|
||||||
|
private readonly CheckBox _padExclusive = new()
|
||||||
|
{
|
||||||
|
Text = "Exclusive gamepad (needs HidHide)",
|
||||||
|
Location = new Point(10, 190),
|
||||||
|
AutoSize = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exclusive-gamepad state: the instance IDs we cloaked (null = off), the
|
||||||
|
// driver's active flag before we touched it, a re-entrancy guard for
|
||||||
|
// programmatic checkbox updates, a post-enable countdown proving vRIO can
|
||||||
|
// still see the pad through the cloak, and last tick's connect state for
|
||||||
|
// hot-plug detection.
|
||||||
|
private List<string>? _hiddenPadInstances;
|
||||||
|
private bool _hidHideWasActive;
|
||||||
|
private bool _padExclusiveUpdating;
|
||||||
|
private int _exclusiveGraceTicks;
|
||||||
|
private bool _padWasConnected;
|
||||||
|
|
||||||
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
|
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
|
||||||
private sealed record KbChoice(string? Id, string Name)
|
private sealed record KbChoice(string? Id, string Name)
|
||||||
@@ -135,14 +155,14 @@ internal sealed class MainForm : Form
|
|||||||
|
|
||||||
private readonly Label _counters = new()
|
private readonly Label _counters = new()
|
||||||
{
|
{
|
||||||
Location = new Point(12, 372),
|
Location = new Point(12, 386),
|
||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
Font = new Font("Consolas", 8f),
|
Font = new Font("Consolas", 8f),
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly Label _help = new()
|
private readonly Label _help = new()
|
||||||
{
|
{
|
||||||
Location = new Point(12, 428),
|
Location = new Point(12, 442),
|
||||||
MaximumSize = new Size(306, 0),
|
MaximumSize = new Size(306, 0),
|
||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
ForeColor = Color.Gray,
|
ForeColor = Color.Gray,
|
||||||
@@ -153,7 +173,7 @@ internal sealed class MainForm : Form
|
|||||||
|
|
||||||
private readonly TextBox _logBox = new()
|
private readonly TextBox _logBox = new()
|
||||||
{
|
{
|
||||||
Location = new Point(12, 498),
|
Location = new Point(12, 512),
|
||||||
Multiline = true,
|
Multiline = true,
|
||||||
ReadOnly = true,
|
ReadOnly = true,
|
||||||
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
|
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
|
||||||
@@ -183,13 +203,17 @@ internal sealed class MainForm : Form
|
|||||||
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
|
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
|
||||||
// Fit the window to its content: the cockpit canvas plus the 330px
|
// Fit the window to its content: the cockpit canvas plus the 330px
|
||||||
// control strip, with just enough height for the strip's log area.
|
// control strip, with just enough height for the strip's log area.
|
||||||
ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640));
|
ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 654));
|
||||||
MinimumSize = new Size(1000, 620);
|
MinimumSize = new Size(1000, 634);
|
||||||
StartPosition = FormStartPosition.CenterScreen;
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
KeyPreview = true; // form-level key routing for the input bindings
|
KeyPreview = true; // form-level key routing for the input bindings
|
||||||
|
|
||||||
_service = new VRioSerialService(_device);
|
_service = new VRioSerialService(_device);
|
||||||
_plasmaService = new VPlasmaSerialService(_plasmaDevice);
|
_plasmaService = new VPlasmaSerialService(_plasmaDevice);
|
||||||
|
// The named-pipe endpoints for DOSBox-X's namedpipe serial backend,
|
||||||
|
// offered in the connection pickers alongside the COM ports.
|
||||||
|
_pipeService = new VRioPipeService(_device);
|
||||||
|
_plasmaPipeService = new VPlasmaPipeService(_plasmaDevice);
|
||||||
_router = new InputRouter(_device);
|
_router = new InputRouter(_device);
|
||||||
_lampMirror = new KeyboardLampMirror(_device);
|
_lampMirror = new KeyboardLampMirror(_device);
|
||||||
|
|
||||||
@@ -221,16 +245,20 @@ internal sealed class MainForm : Form
|
|||||||
_device.Logged += line => RunOnUi(() => PrependLog(line));
|
_device.Logged += line => RunOnUi(() => PrependLog(line));
|
||||||
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
||||||
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
|
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
|
||||||
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
|
_service.ConnectionChanged += _ => RunOnUi(UpdateRioEndpointUi);
|
||||||
_service.HostHandshake += high => RunOnUi(() =>
|
_service.HostHandshake += high => RunOnUi(() =>
|
||||||
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
|
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
|
||||||
|
_pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}"));
|
||||||
|
_pipeService.HostHandshake += high => RunOnUi(() =>
|
||||||
|
PrependLog(high ? "Pipe host raised DTR (board-reset handshake)" : "Pipe host dropped DTR"));
|
||||||
|
_plasmaPipeService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA pipe: {line}"));
|
||||||
|
|
||||||
// Built-in plasma glass: a pure listener on its own port, same gestures
|
// Built-in plasma glass: a pure listener on its own port, same gestures
|
||||||
// as the standalone vPLASMA — double-click cycles the self-test pages,
|
// as the standalone vPLASMA — double-click cycles the self-test pages,
|
||||||
// right-click resets the display to its power-on state.
|
// right-click resets the display to its power-on state.
|
||||||
_plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice));
|
_plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice));
|
||||||
_plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}"));
|
_plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}"));
|
||||||
_plasmaService.ConnectionChanged += open => RunOnUi(() => OnPlasmaConnectionChanged(open));
|
_plasmaService.ConnectionChanged += _ => RunOnUi(UpdatePlasmaEndpointUi);
|
||||||
_plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest();
|
_plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest();
|
||||||
_plasmaCanvas.MouseClick += (_, e) =>
|
_plasmaCanvas.MouseClick += (_, e) =>
|
||||||
{
|
{
|
||||||
@@ -284,6 +312,11 @@ internal sealed class MainForm : Form
|
|||||||
_rawKeyboard.KeyUp += name => RunOnUi(() => _router.KeyUp(name));
|
_rawKeyboard.KeyUp += name => RunOnUi(() => _router.KeyUp(name));
|
||||||
_rawKeyboard.Logged += line => RunOnUi(() => PrependLog(line));
|
_rawKeyboard.Logged += line => RunOnUi(() => PrependLog(line));
|
||||||
_rawKeyboard.KeyboardsChanged += list => RunOnUi(() => RebuildInputKeyboardPicker(list));
|
_rawKeyboard.KeyboardsChanged += list => RunOnUi(() => RebuildInputKeyboardPicker(list));
|
||||||
|
_padExclusive.CheckedChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
if (!_padExclusiveUpdating)
|
||||||
|
SetPadExclusive(_padExclusive.Checked);
|
||||||
|
};
|
||||||
_kbInputTarget.SelectedIndexChanged += (_, _) => ApplyInputSource();
|
_kbInputTarget.SelectedIndexChanged += (_, _) => ApplyInputSource();
|
||||||
_kbInputTarget.DropDown += (_, _) => _rawKeyboard.RefreshKeyboards();
|
_kbInputTarget.DropDown += (_, _) => _rawKeyboard.RefreshKeyboards();
|
||||||
_kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
|
_kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
|
||||||
@@ -299,20 +332,27 @@ internal sealed class MainForm : Form
|
|||||||
|
|
||||||
FormClosed += (_, _) =>
|
FormClosed += (_, _) =>
|
||||||
{
|
{
|
||||||
|
// Drop the cloak on the way out — a hidden controller must not
|
||||||
|
// outlive the app that could still see it. Best effort: if the
|
||||||
|
// driver refuses, HidHide's own client can clean up.
|
||||||
|
try { DisablePadExclusive(); }
|
||||||
|
catch { }
|
||||||
_uiTimer.Dispose();
|
_uiTimer.Dispose();
|
||||||
_inputTimer.Dispose();
|
_inputTimer.Dispose();
|
||||||
_lampMirror.Dispose();
|
_lampMirror.Dispose();
|
||||||
_rawKeyboard.Dispose();
|
_rawKeyboard.Dispose();
|
||||||
_service.Dispose();
|
_service.Dispose();
|
||||||
_plasmaService.Dispose();
|
_plasmaService.Dispose();
|
||||||
|
_pipeService.Dispose();
|
||||||
|
_plasmaPipeService.Dispose();
|
||||||
};
|
};
|
||||||
|
|
||||||
RefreshPorts();
|
RefreshPorts();
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair.");
|
PrependLog($"vRIO ready. Pick an endpoint per device — {RioPipeEndpoint} / {PlasmaPipeEndpoint} " +
|
||||||
|
"for DOSBox-X, or a COM port for RIOJoy — and Open.");
|
||||||
LoadBindings();
|
LoadBindings();
|
||||||
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
|
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
|
||||||
AutoOpenPreferredPorts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Panel BuildControlStrip()
|
private Panel BuildControlStrip()
|
||||||
@@ -342,13 +382,13 @@ internal sealed class MainForm : Form
|
|||||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
|
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
|
||||||
panel.Controls.Add(device);
|
panel.Controls.Add(device);
|
||||||
|
|
||||||
var input = new GroupBox { Text = "Input", Location = new Point(12, 162), Size = new Size(306, 202) };
|
var input = new GroupBox { Text = "Input", Location = new Point(12, 162), Size = new Size(306, 216) };
|
||||||
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget });
|
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget, _padExclusive });
|
||||||
panel.Controls.Add(input);
|
panel.Controls.Add(input);
|
||||||
|
|
||||||
panel.Controls.Add(_counters);
|
panel.Controls.Add(_counters);
|
||||||
panel.Controls.Add(_help);
|
panel.Controls.Add(_help);
|
||||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 480), AutoSize = true });
|
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 494), AutoSize = true });
|
||||||
|
|
||||||
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
|
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
|
||||||
panel.Controls.Add(_logBox);
|
panel.Controls.Add(_logBox);
|
||||||
@@ -359,111 +399,116 @@ internal sealed class MainForm : Form
|
|||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Port handling -----------------------------------------------------
|
// ---- Endpoint handling ---------------------------------------------------
|
||||||
|
// Each device row serves one endpoint at a time — the RIO is a
|
||||||
|
// single-host device, so its pipe server and COM port never run together.
|
||||||
|
|
||||||
|
private bool RioOpen => _service.IsOpen || _pipeService.IsListening;
|
||||||
|
private bool PlasmaOpen => _plasmaService.IsOpen || _plasmaPipeService.IsListening;
|
||||||
|
|
||||||
private void RefreshPorts()
|
private void RefreshPorts()
|
||||||
{
|
{
|
||||||
string[] names = SerialPort.GetPortNames().Distinct()
|
string[] names = SerialPort.GetPortNames().Distinct()
|
||||||
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray();
|
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
// An open service's picker is disabled and shows the served port;
|
// An open row's picker is disabled and shows the served endpoint;
|
||||||
// leave it alone so the display can't drift from reality.
|
// leave it alone so the display can't drift from reality.
|
||||||
if (!_service.IsOpen)
|
if (!RioOpen)
|
||||||
RefreshPicker(_portBox, names);
|
RefreshPicker(_portBox, RioPipeEndpoint, names);
|
||||||
if (!_plasmaService.IsOpen)
|
if (!PlasmaOpen)
|
||||||
RefreshPicker(_plasmaPortBox, names);
|
RefreshPicker(_plasmaPortBox, PlasmaPipeEndpoint, names);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RefreshPicker(ComboBox box, string[] portNames)
|
private static void RefreshPicker(ComboBox box, string pipeEndpoint, string[] portNames)
|
||||||
{
|
{
|
||||||
string? current = box.SelectedItem?.ToString();
|
string? current = box.SelectedItem?.ToString();
|
||||||
box.Items.Clear();
|
box.Items.Clear();
|
||||||
|
box.Items.Add(pipeEndpoint); // always present — a pipe server needs no hardware
|
||||||
foreach (string name in portNames)
|
foreach (string name in portNames)
|
||||||
box.Items.Add(name);
|
box.Items.Add(name);
|
||||||
|
|
||||||
if (box.Items.Count == 0)
|
|
||||||
return;
|
|
||||||
int idx = current is null ? -1 : box.Items.IndexOf(current);
|
int idx = current is null ? -1 : box.Items.IndexOf(current);
|
||||||
box.SelectedIndex = idx >= 0 ? idx : 0;
|
box.SelectedIndex = idx >= 0 ? idx : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Startup convenience: if a device's usual port exists, select it and try
|
|
||||||
/// to open it. Failures (port missing, or busy because another vRIO/app
|
|
||||||
/// holds it) just log — no modal box at launch — and leave the manual
|
|
||||||
/// pickers in charge.
|
|
||||||
/// </summary>
|
|
||||||
private void AutoOpenPreferredPorts()
|
|
||||||
{
|
|
||||||
AutoOpen(_portBox, PreferredPort, _service.Open);
|
|
||||||
AutoOpen(_plasmaPortBox, PreferredPlasmaPort, _plasmaService.Open);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AutoOpen(ComboBox box, string port, Action<string> open)
|
|
||||||
{
|
|
||||||
int idx = box.Items.IndexOf(port);
|
|
||||||
if (idx < 0)
|
|
||||||
{
|
|
||||||
PrependLog($"{port} not present — pick a port and open it manually.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
box.SelectedIndex = idx;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
open(port);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
|
|
||||||
{
|
|
||||||
PrependLog($"{port} is present but could not be opened ({ex.Message.TrimEnd('.')}) — open it manually once it frees up.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleOpen()
|
private void ToggleOpen()
|
||||||
{
|
{
|
||||||
if (_service.IsOpen)
|
if (RioOpen)
|
||||||
_service.Close();
|
{
|
||||||
|
_service.Close(); // idempotent —
|
||||||
|
_pipeService.Stop(); // whichever was serving closes
|
||||||
|
UpdateRioEndpointUi();
|
||||||
|
}
|
||||||
else
|
else
|
||||||
OpenFromPicker(_portBox, _service.Open);
|
{
|
||||||
|
OpenFromPicker(_portBox, OpenRioEndpoint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TogglePlasmaOpen()
|
private void TogglePlasmaOpen()
|
||||||
{
|
{
|
||||||
if (_plasmaService.IsOpen)
|
if (PlasmaOpen)
|
||||||
|
{
|
||||||
_plasmaService.Close();
|
_plasmaService.Close();
|
||||||
|
_plasmaPipeService.Stop();
|
||||||
|
UpdatePlasmaEndpointUi();
|
||||||
|
}
|
||||||
else
|
else
|
||||||
OpenFromPicker(_plasmaPortBox, _plasmaService.Open);
|
{
|
||||||
|
OpenFromPicker(_plasmaPortBox, OpenPlasmaEndpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenRioEndpoint(string endpoint)
|
||||||
|
{
|
||||||
|
if (endpoint == RioPipeEndpoint)
|
||||||
|
_pipeService.Start();
|
||||||
|
else
|
||||||
|
_service.Open(endpoint);
|
||||||
|
UpdateRioEndpointUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenPlasmaEndpoint(string endpoint)
|
||||||
|
{
|
||||||
|
if (endpoint == PlasmaPipeEndpoint)
|
||||||
|
_plasmaPipeService.Start();
|
||||||
|
else
|
||||||
|
_plasmaService.Open(endpoint);
|
||||||
|
UpdatePlasmaEndpointUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenFromPicker(ComboBox box, Action<string> open)
|
private void OpenFromPicker(ComboBox box, Action<string> open)
|
||||||
{
|
{
|
||||||
if (box.SelectedItem?.ToString() is not { } port)
|
if (box.SelectedItem?.ToString() is not { } endpoint)
|
||||||
{
|
{
|
||||||
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " +
|
MessageBox.Show(this, "No endpoint selected. Pick the device's named pipe (for " +
|
||||||
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
"DOSBox-X's namedpipe backend) or a COM port (for RIOJoy through a com0com " +
|
||||||
|
"null-modem pair).", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
open(port);
|
open(endpoint);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
MessageBox.Show(this, $"Could not open {port}:\n{ex.Message}", "vRIO",
|
MessageBox.Show(this, $"Could not open {endpoint}:\n{ex.Message}", "vRIO",
|
||||||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnConnectionChanged(bool open)
|
private void UpdateRioEndpointUi()
|
||||||
{
|
{
|
||||||
|
bool open = RioOpen;
|
||||||
_openClose.Text = open ? "Close" : "Open";
|
_openClose.Text = open ? "Close" : "Open";
|
||||||
_portBox.Enabled = !open;
|
_portBox.Enabled = !open;
|
||||||
_rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
_rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPlasmaConnectionChanged(bool open)
|
private void UpdatePlasmaEndpointUi()
|
||||||
{
|
{
|
||||||
|
bool open = PlasmaOpen;
|
||||||
_plasmaOpenClose.Text = open ? "Close" : "Open";
|
_plasmaOpenClose.Text = open ? "Close" : "Open";
|
||||||
_plasmaPortBox.Enabled = !open;
|
_plasmaPortBox.Enabled = !open;
|
||||||
_plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
_plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
||||||
@@ -602,8 +647,14 @@ internal sealed class MainForm : Form
|
|||||||
|
|
||||||
_router.Tick(dt);
|
_router.Tick(dt);
|
||||||
|
|
||||||
|
if (_hiddenPadInstances is not null && _padInput.Checked)
|
||||||
|
MaintainPadExclusive();
|
||||||
|
_padWasConnected = _gamepad.Connected;
|
||||||
|
|
||||||
string status = _padInput.Checked
|
string status = _padInput.Checked
|
||||||
? _gamepad.Connected ? $"Controller #{_gamepad.UserIndex + 1} connected." : "No controller detected."
|
? _gamepad.Connected
|
||||||
|
? $"Controller #{_gamepad.UserIndex + 1} connected." + (_hiddenPadInstances is not null ? " Exclusive." : "")
|
||||||
|
: "No controller detected."
|
||||||
: "Gamepad input off.";
|
: "Gamepad input off.";
|
||||||
if (_padStatus.Text != status)
|
if (_padStatus.Text != status)
|
||||||
{
|
{
|
||||||
@@ -612,6 +663,149 @@ internal sealed class MainForm : Form
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Exclusive gamepad (HidHide) ----------------------------------------
|
||||||
|
// XInput has no exclusive acquisition, so "only vRIO gets the pad" is done
|
||||||
|
// one level down: the HidHide filter driver cloaks the pad's device
|
||||||
|
// instances from every process except allowlisted ones. vRIO allowlists
|
||||||
|
// itself, cloaks the pad (both the HID face and, for IG_/XInput pads, the
|
||||||
|
// parent devnode XInput actually reads), and reverts on toggle-off or
|
||||||
|
// exit. Not persisted across runs on purpose — an invisible controller
|
||||||
|
// with nobody holding the reveal switch is a support call.
|
||||||
|
|
||||||
|
private void SetPadExclusive(bool on)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (on)
|
||||||
|
EnablePadExclusive();
|
||||||
|
else
|
||||||
|
DisablePadExclusive();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
PrependLog($"HidHide: {ex.Message}");
|
||||||
|
SetPadExclusiveChecked(_hiddenPadInstances is not null); // reflect what actually happened
|
||||||
|
MessageBox.Show(this, $"Exclusive gamepad mode failed:\n{ex.Message}", "vRIO",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnablePadExclusive()
|
||||||
|
{
|
||||||
|
if (_hiddenPadInstances is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!HidHideControl.IsInstalled())
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"the HidHide filter driver is not installed. Install it from " +
|
||||||
|
"github.com/nefarius/HidHide/releases and reconnect the pad, then try again.");
|
||||||
|
|
||||||
|
List<string> pads = GamepadDeviceLocator.FindGamepadInstances();
|
||||||
|
if (pads.Count == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"no gamepad or joystick device found to cloak — connect the controller first.");
|
||||||
|
|
||||||
|
// vRIO must be able to see through the cloak before it goes up.
|
||||||
|
string self = HidHideControl.ToDosDevicePath(Application.ExecutablePath);
|
||||||
|
List<string> apps = HidHideControl.GetAllowedApps();
|
||||||
|
if (!apps.Contains(self, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
apps.Add(self);
|
||||||
|
HidHideControl.SetAllowedApps(apps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track only the instances *we* add: toggle-off must not strip
|
||||||
|
// entries some other tool (DS4Windows, say) is relying on.
|
||||||
|
bool wasActive = HidHideControl.GetActive();
|
||||||
|
List<string> blocked = HidHideControl.GetBlockedInstances();
|
||||||
|
var added = pads.Where(p => !blocked.Contains(p, StringComparer.OrdinalIgnoreCase)).ToList();
|
||||||
|
if (added.Count > 0)
|
||||||
|
HidHideControl.SetBlockedInstances(blocked.Concat(added).ToList());
|
||||||
|
if (!wasActive)
|
||||||
|
HidHideControl.SetActive(true);
|
||||||
|
|
||||||
|
_hidHideWasActive = wasActive;
|
||||||
|
_hiddenPadInstances = added;
|
||||||
|
// The cloak filters device *opens*, so vRIO's already-open XInput
|
||||||
|
// handle rides through — but so does everyone else's until the pad is
|
||||||
|
// replugged. Watch our own visibility for a few seconds regardless:
|
||||||
|
// if the pad drops out, the allowlist didn't take.
|
||||||
|
_exclusiveGraceTicks = _gamepad.Connected ? 180 : 0;
|
||||||
|
|
||||||
|
PrependLog($"HidHide: cloaked {pads.Count} gamepad device instance(s); vRIO is allowlisted.");
|
||||||
|
foreach (string id in pads)
|
||||||
|
PrependLog($"HidHide: {id}");
|
||||||
|
PrependLog("HidHide: apps already holding the pad keep it until it's replugged.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisablePadExclusive()
|
||||||
|
{
|
||||||
|
if (_hiddenPadInstances is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
List<string> blocked = HidHideControl.GetBlockedInstances();
|
||||||
|
List<string> keep = blocked
|
||||||
|
.Where(b => !_hiddenPadInstances.Contains(b, StringComparer.OrdinalIgnoreCase)).ToList();
|
||||||
|
if (keep.Count != blocked.Count)
|
||||||
|
HidHideControl.SetBlockedInstances(keep);
|
||||||
|
if (!_hidHideWasActive)
|
||||||
|
HidHideControl.SetActive(false);
|
||||||
|
|
||||||
|
_hiddenPadInstances = null;
|
||||||
|
_exclusiveGraceTicks = 0;
|
||||||
|
PrependLog("HidHide: gamepad visible to all applications again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-tick upkeep while exclusive mode is on: the post-enable visibility
|
||||||
|
/// watch, then cloaking any pad that hot-plugs in later (a fresh pad is a
|
||||||
|
/// fresh instance ID — without this it would bypass the cloak entirely).
|
||||||
|
/// </summary>
|
||||||
|
private void MaintainPadExclusive()
|
||||||
|
{
|
||||||
|
if (_exclusiveGraceTicks > 0)
|
||||||
|
{
|
||||||
|
if (_gamepad.Connected)
|
||||||
|
{
|
||||||
|
_exclusiveGraceTicks--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PrependLog("HidHide: vRIO lost sight of the pad right after cloaking — " +
|
||||||
|
"the allowlist didn't take. Reverting.");
|
||||||
|
_exclusiveGraceTicks = 0;
|
||||||
|
SetPadExclusiveChecked(false);
|
||||||
|
SetPadExclusive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_gamepad.Connected && !_padWasConnected)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<string> pads = GamepadDeviceLocator.FindGamepadInstances();
|
||||||
|
List<string> blocked = HidHideControl.GetBlockedInstances();
|
||||||
|
var fresh = pads.Where(p => !blocked.Contains(p, StringComparer.OrdinalIgnoreCase)).ToList();
|
||||||
|
if (fresh.Count == 0)
|
||||||
|
return;
|
||||||
|
HidHideControl.SetBlockedInstances(blocked.Concat(fresh).ToList());
|
||||||
|
_hiddenPadInstances!.AddRange(fresh);
|
||||||
|
PrependLog($"HidHide: cloaked {fresh.Count} newly connected gamepad device instance(s).");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
PrependLog($"HidHide: could not cloak the new pad — {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Move the checkbox without tripping its change handler.</summary>
|
||||||
|
private void SetPadExclusiveChecked(bool value)
|
||||||
|
{
|
||||||
|
_padExclusiveUpdating = true;
|
||||||
|
_padExclusive.Checked = value;
|
||||||
|
_padExclusiveUpdating = false;
|
||||||
|
}
|
||||||
|
|
||||||
private void LoadBindings()
|
private void LoadBindings()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -690,12 +884,15 @@ internal sealed class MainForm : Form
|
|||||||
$"Analog polls dropped: {dropped}\n" +
|
$"Analog polls dropped: {dropped}\n" +
|
||||||
$"Bad checksums: {bad}";
|
$"Bad checksums: {bad}";
|
||||||
|
|
||||||
_canvas.StatusText = _service.IsOpen
|
string? endpoint = _service.IsOpen ? _service.PortName
|
||||||
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" +
|
: _pipeService.IsListening ? RioPipeEndpoint
|
||||||
|
: null;
|
||||||
|
_canvas.StatusText = endpoint is not null
|
||||||
|
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {endpoint}\n" +
|
||||||
(wedged
|
(wedged
|
||||||
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
|
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
|
||||||
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
|
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
|
||||||
: "PORT CLOSED\nOpen a COM port to go live.";
|
: "OFFLINE\nOpen an endpoint (named pipe or COM port) to go live.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PrependLog(string line)
|
private void PrependLog(string line)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,6 @@ public class VPlasmaDeviceTests
|
|||||||
{
|
{
|
||||||
byte[] wire = GraphicsRow(3, Enumerable.Repeat((byte)0xFF, 16).ToArray());
|
byte[] wire = GraphicsRow(3, Enumerable.Repeat((byte)0xFF, 16).ToArray());
|
||||||
|
|
||||||
// Split the same command at every possible boundary.
|
|
||||||
for (int split = 1; split < wire.Length; ++split)
|
for (int split = 1; split < wire.Length; ++split)
|
||||||
{
|
{
|
||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice();
|
||||||
@@ -71,7 +70,6 @@ public class VPlasmaDeviceTests
|
|||||||
public void GraphicsBlock_MultipleRowsAdvanceY()
|
public void GraphicsBlock_MultipleRowsAdvanceY()
|
||||||
{
|
{
|
||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice();
|
||||||
// screen 0, y=10, xbyte=0, 1 byte/row, 3 rows.
|
|
||||||
Feed(device, Esc, (byte)'P', 0, 10, 0, 1, 3, 0x80, 0x80, 0x80);
|
Feed(device, Esc, (byte)'P', 0, 10, 0, 1, 3, 0x80, 0x80, 0x80);
|
||||||
|
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 10));
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 10));
|
||||||
@@ -98,10 +96,8 @@ public class VPlasmaDeviceTests
|
|||||||
{
|
{
|
||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice();
|
||||||
Feed(device, GraphicsRow(40, Enumerable.Repeat((byte)0xFF, 16).ToArray()));
|
Feed(device, GraphicsRow(40, Enumerable.Repeat((byte)0xFF, 16).ToArray()));
|
||||||
Feed(device, (byte)'!'); // parser must be back in text mode
|
Feed(device, (byte)'H'); // parser must be back in text mode
|
||||||
|
|
||||||
var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
|
|
||||||
device.CopyFrame(frame);
|
|
||||||
Assert.Equal(1, device.TextCharsDrawn);
|
Assert.Equal(1, device.TextCharsDrawn);
|
||||||
Assert.Equal(0, device.GraphicsRows);
|
Assert.Equal(0, device.GraphicsRows);
|
||||||
}
|
}
|
||||||
@@ -110,64 +106,83 @@ public class VPlasmaDeviceTests
|
|||||||
public void GraphicsWrite_OverwritesTextAttributes()
|
public void GraphicsWrite_OverwritesTextAttributes()
|
||||||
{
|
{
|
||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice();
|
||||||
Feed(device, Esc, (byte)'H', 4); // flashing text
|
Feed(device, Esc, (byte)'H', 8); // flashing text
|
||||||
Feed(device, (byte)'H'); // glyph col 0 is a full bar at x=0
|
Feed(device, (byte)'H'); // font-0 'H' lights (0,0)
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelFlash, Pixel(device, 0, 0));
|
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelFlash, Pixel(device, 0, 0));
|
||||||
|
|
||||||
Feed(device, GraphicsRow(0, 0x80)); // repaint that row from the wire
|
Feed(device, GraphicsRow(0, 0x80)); // repaint that row from the wire
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- text mode ---------------------------------------------------------
|
// ---- text mode (real font 0 = 6×8) -----------------------------------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Text_DrawsGlyphAndAdvancesCursor()
|
public void Text_DrawsRealGlyphAndAdvancesByFontWidth()
|
||||||
{
|
{
|
||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice();
|
||||||
Feed(device, (byte)'H'); // 5×7 'H': column 0 = 0x7F (all seven rows)
|
Feed(device, (byte)'H'); // font-0 'H': row0 = pixels at x0 and x4
|
||||||
|
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 6));
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 4, 0));
|
||||||
Assert.Equal(0, Pixel(device, 0, 7)); // gap row
|
Assert.Equal(0, Pixel(device, 1, 0));
|
||||||
Assert.Equal(0, Pixel(device, 5, 0)); // gap column
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 3)); // crossbar row
|
||||||
Assert.Equal(new VPlasmaDevice.Point(1, 0), device.CursorCell);
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 4, 3));
|
||||||
|
Assert.Equal(0, Pixel(device, 0, 7)); // blank bottom row
|
||||||
|
Assert.Equal(6, device.CursorX); // advanced one 6-px cell
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
Assert.Equal(1, device.TextCharsDrawn);
|
Assert.Equal(1, device.TextCharsDrawn);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ControlChars_MoveTheCursor()
|
public void ControlChars_MoveCursorByPixels()
|
||||||
{
|
{
|
||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice(); // font 0: 6 wide, 8 tall
|
||||||
|
|
||||||
Feed(device, 0x09, 0x09, 0x09); // HT ×3
|
Feed(device, 0x09, 0x09, 0x09); // HT ×3 → x = 18
|
||||||
Assert.Equal(new VPlasmaDevice.Point(3, 0), device.CursorCell);
|
Assert.Equal(18, device.CursorX);
|
||||||
|
|
||||||
Feed(device, 0x08); // BS
|
Feed(device, 0x08); // BS → x = 12
|
||||||
Assert.Equal(new VPlasmaDevice.Point(2, 0), device.CursorCell);
|
Assert.Equal(12, device.CursorX);
|
||||||
|
|
||||||
Feed(device, 0x0A); // LF
|
Feed(device, 0x0A); // LF → y = 8
|
||||||
Assert.Equal(new VPlasmaDevice.Point(2, 1), device.CursorCell);
|
Assert.Equal(8, device.CursorY);
|
||||||
|
Assert.Equal(0, device.CursorX);
|
||||||
|
|
||||||
Feed(device, 0x0D); // CR
|
Feed(device, (byte)'H', 0x0D); // draw + CR → x back to 0
|
||||||
Assert.Equal(new VPlasmaDevice.Point(0, 1), device.CursorCell);
|
Assert.Equal(0, device.CursorX);
|
||||||
|
|
||||||
Feed(device, 0x0B); // VT
|
Feed(device, 0x0B); // VT → y back to 0
|
||||||
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
|
Assert.Equal(0, device.CursorY);
|
||||||
|
|
||||||
Feed(device, 0x0B); // VT off the top wraps to the bottom row
|
|
||||||
Assert.Equal(new VPlasmaDevice.Point(0, 3), device.CursorCell);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Text_WrapsAtRowAndScreenEnd()
|
public void EscQ_EscR_SetPixelCursor()
|
||||||
{
|
{
|
||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice();
|
||||||
Feed(device, Enumerable.Repeat((byte)'X', 22)); // one full 21-cell row + 1
|
|
||||||
|
|
||||||
Assert.Equal(new VPlasmaDevice.Point(1, 1), device.CursorCell);
|
Feed(device, Esc, (byte)'Q', 20); // set row Y=20
|
||||||
|
Feed(device, Esc, (byte)'R', 37); // set col X=37
|
||||||
|
Assert.Equal(37, device.CursorX);
|
||||||
|
Assert.Equal(20, device.CursorY);
|
||||||
|
|
||||||
Feed(device, Enumerable.Repeat((byte)'X', 21 * 3 - 1)); // exactly to the end
|
Feed(device, Esc, (byte)'Q', 40); // out of range (>31): ignored
|
||||||
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell); // wrapped to top
|
Assert.Equal(20, device.CursorY);
|
||||||
|
Feed(device, Esc, (byte)'R', 200); // out of range (>127): ignored
|
||||||
|
Assert.Equal(37, device.CursorX);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Text_WrapsAtRightAndBottom()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice(); // 21 cells of 6px per line, 4 lines
|
||||||
|
|
||||||
|
Feed(device, Enumerable.Repeat((byte)'X', 21)); // fills first line, wraps
|
||||||
|
Assert.Equal(0, device.CursorX);
|
||||||
|
Assert.Equal(8, device.CursorY);
|
||||||
|
|
||||||
|
Feed(device, Enumerable.Repeat((byte)'X', 21 * 3)); // to the end, wraps to top
|
||||||
|
Assert.Equal(0, device.CursorX);
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -178,7 +193,8 @@ public class VPlasmaDeviceTests
|
|||||||
Feed(device, Esc, (byte)'@');
|
Feed(device, Esc, (byte)'@');
|
||||||
|
|
||||||
Assert.Equal(0, Pixel(device, 0, 0));
|
Assert.Equal(0, Pixel(device, 0, 0));
|
||||||
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
|
Assert.Equal(0, device.CursorX);
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
Assert.Equal(0, device.Font);
|
Assert.Equal(0, device.Font);
|
||||||
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
||||||
}
|
}
|
||||||
@@ -189,7 +205,8 @@ public class VPlasmaDeviceTests
|
|||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice();
|
||||||
Feed(device, (byte)'H', Esc, (byte)'L');
|
Feed(device, (byte)'H', Esc, (byte)'L');
|
||||||
|
|
||||||
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
|
Assert.Equal(0, device.CursorX);
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0)); // glyph survives
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0)); // glyph survives
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,42 +223,42 @@ public class VPlasmaDeviceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EscK_SelectsFontGrids()
|
public void EscK_SelectsRealFonts()
|
||||||
{
|
{
|
||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice();
|
||||||
|
|
||||||
Feed(device, Esc, (byte)'K', 4); // large: 12×16 cells
|
Feed(device, Esc, (byte)'K', 4); // large font: 12×16
|
||||||
Assert.Equal(4, device.Font);
|
Assert.Equal(4, device.Font);
|
||||||
Assert.Equal(12, device.CellWidth);
|
Assert.Equal(12, device.FontWidth);
|
||||||
Assert.Equal(16, device.CellHeight);
|
Assert.Equal(16, device.FontHeight);
|
||||||
|
|
||||||
Feed(device, (byte)'A'); // large glyph: 'A' col 1 (0x11) row 0 → 2×2 dots at (2..3, 0..1)
|
Feed(device, Esc, (byte)'K', 0); // back to font 0: 6×8
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 2, 0));
|
Assert.Equal(0, device.Font);
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 3, 1));
|
Assert.Equal(6, device.FontWidth);
|
||||||
|
Assert.Equal(8, device.FontHeight);
|
||||||
Feed(device, Esc, (byte)'K', 0xFF); // FF → default font 0
|
|
||||||
|
Feed(device, Esc, (byte)'K', 0xFF); // out of range: firmware ignores it
|
||||||
Assert.Equal(0, device.Font);
|
Assert.Equal(0, device.Font);
|
||||||
Assert.Equal(6, device.CellWidth);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EscH_AppliesAttributes()
|
public void EscH_AppliesAttributesAsLowFourBits()
|
||||||
{
|
{
|
||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice();
|
||||||
|
|
||||||
Feed(device, Esc, (byte)'H', 1, (byte)'H'); // half intensity
|
Feed(device, Esc, (byte)'H', 1, (byte)'H'); // half intensity
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelHalf, Pixel(device, 0, 0));
|
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelHalf, Pixel(device, 0, 0));
|
||||||
|
|
||||||
Feed(device, Esc, (byte)'H', 2, (byte)'H'); // underline: gap row lit
|
Feed(device, Esc, (byte)'L', Esc, (byte)'@'); // clear
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 6, 7));
|
Feed(device, Esc, (byte)'H', 2, (byte)'H'); // underline: bottom row (y=7) lit
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 7));
|
||||||
|
|
||||||
Feed(device, Esc, (byte)'H', 3, (byte)' '); // reverse: a space renders solid
|
Feed(device, Esc, (byte)'@');
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 12, 0));
|
Feed(device, Esc, (byte)'H', 4, (byte)' '); // reverse: a space renders solid
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 17, 7));
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 5, 7));
|
||||||
|
|
||||||
Feed(device, Esc, (byte)'H', 0xFF, (byte)'H'); // defaults restored
|
Feed(device, Esc, (byte)'H', 0); // defaults restored
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 18, 0));
|
|
||||||
Assert.Equal(0, Pixel(device, 18, 7));
|
|
||||||
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +268,7 @@ public class VPlasmaDeviceTests
|
|||||||
public void UnknownEscape_IsConsumedAndTextResumes()
|
public void UnknownEscape_IsConsumedAndTextResumes()
|
||||||
{
|
{
|
||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice();
|
||||||
Feed(device, Esc, (byte)'Z', (byte)'H');
|
Feed(device, Esc, (byte)'\\', (byte)'H'); // ESC '\' is not a command
|
||||||
|
|
||||||
Assert.Equal(1, device.TextCharsDrawn);
|
Assert.Equal(1, device.TextCharsDrawn);
|
||||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
@@ -260,11 +277,10 @@ public class VPlasmaDeviceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void GameStartupSequence_HidesCursor()
|
public void GameStartupSequence_HidesCursor()
|
||||||
{
|
{
|
||||||
// L4PLASMA.CPP's constructor sends exactly ESC 'G' 0x00.
|
|
||||||
var device = new VPlasmaDevice();
|
var device = new VPlasmaDevice();
|
||||||
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode); // power-on default
|
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode); // power-on default
|
||||||
|
|
||||||
Feed(device, 27, (byte)'G', 0x00);
|
Feed(device, 27, (byte)'G', 0x00); // L4PLASMA.CPP's boot byte
|
||||||
Assert.Equal(PlasmaCursorMode.Hidden, device.CursorMode);
|
Assert.Equal(PlasmaCursorMode.Hidden, device.CursorMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +306,80 @@ public class VPlasmaDeviceTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- orientation + test pattern (JP1 jumpers 4 & 5) --------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Orientation_VerticalRotatesLogicalOntoPhysicalGlass()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice { Orientation = PlasmaOrientation.Vertical };
|
||||||
|
Assert.Equal(32, device.LogicalWidth);
|
||||||
|
Assert.Equal(128, device.LogicalHeight);
|
||||||
|
|
||||||
|
// Logical (0,0) maps to physical (px=ly=0, py=Height-1-lx=31).
|
||||||
|
Feed(device, Esc, (byte)'R', 0, Esc, (byte)'Q', 0, (byte)'H');
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 31)); // 'H' top-left dot
|
||||||
|
Assert.Equal(0, Pixel(device, 0, 0));
|
||||||
|
|
||||||
|
// ESC Q (row Y) now accepts up to the 128-tall logical height;
|
||||||
|
// ESC R (column X) is bounded by the 32-wide logical width.
|
||||||
|
Feed(device, Esc, (byte)'Q', 100);
|
||||||
|
Assert.Equal(100, device.CursorY);
|
||||||
|
Feed(device, Esc, (byte)'R', 40); // out of range for 32-wide vertical
|
||||||
|
Assert.NotEqual(40, device.CursorX);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Orientation_ChangeClearsAndHomes()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
Feed(device, (byte)'H');
|
||||||
|
Assert.NotEqual(0, Pixel(device, 0, 0));
|
||||||
|
|
||||||
|
device.Orientation = PlasmaOrientation.Vertical;
|
||||||
|
Assert.Equal(0, Pixel(device, 0, 0)); // cleared
|
||||||
|
Assert.Equal(0, device.CursorX);
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShowTestPattern_LightsEveryDot()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
device.ShowTestPattern();
|
||||||
|
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 127, 31));
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 64, 16));
|
||||||
|
|
||||||
|
device.Reset();
|
||||||
|
Assert.Equal(0, Pixel(device, 64, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FirmwareDemo_ReplaysAllScreensAndDrawsText()
|
||||||
|
{
|
||||||
|
Assert.Equal(10, PlasmaFirmwareDemo.Count);
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
foreach (byte[] screen in PlasmaFirmwareDemo.Screens)
|
||||||
|
{
|
||||||
|
Assert.NotEmpty(screen);
|
||||||
|
device.OnReceived(screen, screen.Length);
|
||||||
|
}
|
||||||
|
// The 10-screen demo puts plenty of real text on the glass.
|
||||||
|
Assert.True(device.TextCharsDrawn > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PageSelect_ConsumesItsOperand()
|
||||||
|
{
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
// ESC I 2 (draw page) must swallow the '2'; then 'H' is the first glyph.
|
||||||
|
Feed(device, Esc, (byte)'I', 2, Esc, (byte)'i', 3, (byte)'H');
|
||||||
|
Assert.Equal(1, device.TextCharsDrawn);
|
||||||
|
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||||
|
Assert.Equal(6, device.CursorX); // advanced exactly one cell (no stray chars)
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Reset_RestoresPowerOnState()
|
public void Reset_RestoresPowerOnState()
|
||||||
{
|
{
|
||||||
@@ -302,6 +392,7 @@ public class VPlasmaDeviceTests
|
|||||||
Assert.Equal(0, device.Font);
|
Assert.Equal(0, device.Font);
|
||||||
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
Assert.Equal(PlasmaAttributes.None, device.Attributes);
|
||||||
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode);
|
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode);
|
||||||
Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
|
Assert.Equal(0, device.CursorX);
|
||||||
|
Assert.Equal(0, device.CursorY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using VPlasma.Core.Device;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace VPlasma.Core.Tests;
|
||||||
|
|
||||||
|
public class VPlasmaPipeServiceTests
|
||||||
|
{
|
||||||
|
private static string UniqueName() => $"vplasma-test-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
private static void WaitFor(Func<bool> condition, int timeoutMs = 2000)
|
||||||
|
{
|
||||||
|
var clock = Stopwatch.StartNew();
|
||||||
|
while (!condition())
|
||||||
|
{
|
||||||
|
Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
|
||||||
|
Thread.Sleep(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NamedPipeClientStream Connect(string name)
|
||||||
|
{
|
||||||
|
var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||||
|
var clock = Stopwatch.StartNew();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe.Connect(200);
|
||||||
|
return pipe;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or TimeoutException)
|
||||||
|
{
|
||||||
|
if (clock.ElapsedMilliseconds >= 3000)
|
||||||
|
throw;
|
||||||
|
Thread.Sleep(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Framed_text_reaches_the_display_and_lines_are_ignored()
|
||||||
|
{
|
||||||
|
string name = UniqueName();
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
using var service = new VPlasmaPipeService(device, name);
|
||||||
|
service.Start();
|
||||||
|
|
||||||
|
using var client = Connect(name);
|
||||||
|
var hello = new byte[2];
|
||||||
|
Assert.Equal(1, client.Read(hello, 0, 1)); // on-connect lines frame,
|
||||||
|
Assert.Equal(1, client.Read(hello, 1, 1)); // possibly split
|
||||||
|
Assert.Equal(new byte[] { 0x01, 0x03 }, hello); // DTR+RTS: display present
|
||||||
|
|
||||||
|
// RTS chatter (DOS console redirection toggles it per byte) then "HI",
|
||||||
|
// the data frame torn in half mid-payload.
|
||||||
|
client.Write(new byte[] { 0x01, 0x02, 0x00, 0x02, (byte)'H' }, 0, 5);
|
||||||
|
client.Flush();
|
||||||
|
Thread.Sleep(20);
|
||||||
|
client.Write(new byte[] { (byte)'I' }, 0, 1);
|
||||||
|
client.Flush();
|
||||||
|
|
||||||
|
WaitFor(() => device.BytesReceived == 2);
|
||||||
|
Assert.Equal(2, device.TextCharsDrawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_frame_type_drops_the_client_and_server_recovers()
|
||||||
|
{
|
||||||
|
string name = UniqueName();
|
||||||
|
var device = new VPlasmaDevice();
|
||||||
|
using var service = new VPlasmaPipeService(device, name);
|
||||||
|
service.Start();
|
||||||
|
|
||||||
|
using (var bad = Connect(name))
|
||||||
|
{
|
||||||
|
var hello = new byte[2];
|
||||||
|
bad.Read(hello, 0, 2);
|
||||||
|
bad.Write(new byte[] { 0x7F }, 0, 1); // not a frame type
|
||||||
|
bad.Flush();
|
||||||
|
// Contract: log + drop, no resync — our next read sees the close.
|
||||||
|
var eof = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try { return bad.Read(new byte[1], 0, 1) == 0; }
|
||||||
|
catch (IOException) { return true; }
|
||||||
|
});
|
||||||
|
Assert.True(await Task.WhenAny(eof, Task.Delay(2000)) == eof,
|
||||||
|
"server did not drop the connection");
|
||||||
|
Assert.True(await eof);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var good = Connect(name);
|
||||||
|
var again = new byte[2];
|
||||||
|
Assert.Equal(1, good.Read(again, 0, 1));
|
||||||
|
Assert.Equal(1, good.Read(again, 1, 1));
|
||||||
|
Assert.Equal(new byte[] { 0x01, 0x03 }, again);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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