Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
697cf3129b | ||
|
|
e507f1740c | ||
|
|
0674cf5ba4 | ||
|
|
90bf6723dc | ||
|
|
359e1c0a40 | ||
|
|
c6e0522408 | ||
|
|
9a1c8478bf |
Binary file not shown.
@@ -0,0 +1,206 @@
|
|||||||
|
# PlasmaNew — reverse-engineering the real cockpit plasma display
|
||||||
|
|
||||||
|
Working notes and reference material for the cockpit plasma display.
|
||||||
|
|
||||||
|
**End goal: a hardware replica.** The original Babcock plasma panels are
|
||||||
|
starting to fail and are effectively irreplaceable. The plan is to drive a
|
||||||
|
modern **128 × 32 LED array** with a **modern microcontroller** that reads
|
||||||
|
the same RS-232 serial bus and speaks the same command protocol as the
|
||||||
|
original PD01D221 — a drop-in replacement, functionally identical from the
|
||||||
|
host's side, with none of the plasma physics or high voltage.
|
||||||
|
|
||||||
|
[vPLASMA](../src/VPlasma.App/) (the C# app in this repo) is the software
|
||||||
|
counterpart and serves the replica directly: it is an **executable
|
||||||
|
specification** of the display's behavior and a **test oracle**. Every
|
||||||
|
command semantic pinned down in `VPlasmaDevice` ports straight to the
|
||||||
|
replica's firmware, and the same differential-test rig (real panel vs.
|
||||||
|
vPLASMA) validates the replica. vPLASMA today is built from *observed
|
||||||
|
traffic* (the game's driver + a factory test tool); grounding it in the
|
||||||
|
*actual hardware* — protocol, fonts, and timing — feeds both the emulator
|
||||||
|
and the replacement firmware.
|
||||||
|
|
||||||
|
## What the display is
|
||||||
|
|
||||||
|
A **commercial off-the-shelf Babcock Display Products Division PD01D221** —
|
||||||
|
"128 × 32 dot-matrix, gas-plasma display with controller and DC-DC
|
||||||
|
converter," with an RS-232C serial interface and a dedicated microprocessor
|
||||||
|
for refresh and the user interface. Built by **Cherry** (PCB assembly
|
||||||
|
**4317-C**, Made in Taiwan, © 1994). See [`PD01D221.pdf`](PD01D221.pdf)
|
||||||
|
(Babcock doc 9200-0109 Rev A).
|
||||||
|
|
||||||
|
Product family (the suffix letter = how much is on the board):
|
||||||
|
|
||||||
|
| Model | Contents |
|
||||||
|
|-------|----------|
|
||||||
|
| PD01**B**22B | 128×32 panel + driver electronics only (host refreshes it) |
|
||||||
|
| PD01**F**221 | + on-board DC-DC converter |
|
||||||
|
| PD01**D**221 | **+ controller: RS-232C, dedicated microprocessor** ← this unit |
|
||||||
|
|
||||||
|
**VWE used it stock — no custom fonts or bitmaps were installed.** So the
|
||||||
|
display's behavior is entirely the standard Babcock PD-series firmware, and
|
||||||
|
the `ESC P` "graphics" the game drew were rendered at runtime by the game,
|
||||||
|
not preloaded. Nothing on the display is VWE-specific.
|
||||||
|
|
||||||
|
## Board inventory
|
||||||
|
|
||||||
|
Chip IDs read from the photos below.
|
||||||
|
|
||||||
|
| Ref | Part | Role |
|
||||||
|
|-----|------|------|
|
||||||
|
| U1 | **Motorola MC68HC11D0** (44-pin QFP, mask 1C17F, wk 28/94) | ROMless HC11 MCU — the controller. Runs from external bus in expanded mode. |
|
||||||
|
| U3 | **TI TMS27PC512** (PLCC-32, −150 ns, Singapore) | **64 KB OTP EPROM = the firmware** (stock Babcock code + fonts). Standard 27C512. |
|
||||||
|
| U2 | QFP ~100-pin, label **"35GWP004 REV A 3994"** | Custom Cherry display/scan **ASIC** (wk 39/94). Drives the HV stage. *Not* the firmware. |
|
||||||
|
| U4 | **Mosel MS62256L-10** | 32 KB SRAM — frame buffer / scratch. |
|
||||||
|
| U7 | **Supertex HV7708** | 32-channel high-voltage plasma driver (more HV off-frame). |
|
||||||
|
| U5 | **Maxim MAX202CWE** | RS-232 transceiver — the serial interface. |
|
||||||
|
| — | **MAX707** | Reset / watchdog supervisor. |
|
||||||
|
| Y1 | **7.3728 MHz** crystal | E-clock = 1.8432 MHz; gives exact standard baud rates. |
|
||||||
|
|
||||||
|
Memory picture: ROMless HC11 + external 64 KB EPROM (code + fonts) + 32 KB
|
||||||
|
SRAM + custom scan ASIC + HV drivers. A 64 KB program EPROM for a 128×32
|
||||||
|
panel implies far more feature set than the game ever used.
|
||||||
|
|
||||||
|
## Reference photos
|
||||||
|
|
||||||
|
| File | Shows |
|
||||||
|
|------|-------|
|
||||||
|
| [`mpul-2026-07-07-152834.jpeg`](mpul-2026-07-07-152834.jpeg) | Controller overview: MC68HC11D0 (U1), the "35GWP004" ASIC (U2), HV7708 (U7), MAX202, MAX707. |
|
||||||
|
| [`silkscreenl-2026-07-07-152841.jpeg`](silkscreenl-2026-07-07-152841.jpeg) | Cherry silkscreen: PCB **4317-C**, © 1994, "Made in Taiwan". |
|
||||||
|
| [`unknown-2026-07-07-153818.jpeg`](unknown-2026-07-07-153818.jpeg) | The **TMS27PC512 EPROM** (U3, initially unidentified), Mosel SRAM (U4), HC11. |
|
||||||
|
| [`jumpers-2026-07-07-163733.jpeg`](jumpers-2026-07-07-163733.jpeg) | The **JP1** config header next to the HC11. |
|
||||||
|
|
||||||
|
## Datasheet-confirmed facts (`PD01D221.pdf`, doc 9200-0109 Rev A)
|
||||||
|
|
||||||
|
- Serial format **8N1**, baud **jumper-selectable 4800 / 9600 / 19.2K /
|
||||||
|
38.4K** (the game uses 9600).
|
||||||
|
- "Choice of standard fonts and styles" (= `ESC K` / `ESC H`); "program
|
||||||
|
custom characters" (a custom-char download command — **exists but VWE
|
||||||
|
didn't use it**); "graphic input commands / overlays" (= `ESC P`).
|
||||||
|
- Serial is **bidirectional**. Connector **J1**: pin 2 TxD (display→host),
|
||||||
|
pin 3 RxD (host→display), pin 4 CTS, pin 8 DTR ("display ready"), pin 5
|
||||||
|
GND. The game drove it write-only (flow control disabled, TxD ignored),
|
||||||
|
so vPLASMA's listen-only model is faithful.
|
||||||
|
- Also carries an 8-bit **parallel** port (J2), unused by the game.
|
||||||
|
- **The datasheet does *not* contain the `ESC` command table.** That's a
|
||||||
|
separate Babcock programming/user manual, which is **not available online**
|
||||||
|
(checked general web, datasheetarchive, bitsavers, archive.org, resellers;
|
||||||
|
only this datasheet was ever digitized). Sources for it: ask Babcock
|
||||||
|
directly (La Mirada CA, (714) 994-6500, babcockinc.com), or reconstruct it
|
||||||
|
from the dump + the sources we already have.
|
||||||
|
|
||||||
|
## Command protocol recovered so far
|
||||||
|
|
||||||
|
From the game driver (`TeslaRel410\CODE\RP\MUNGA_L4\L4PLASMA.CPP`) and the
|
||||||
|
factory test tool (`…\VWETEST\VGLTEST\PLASMA.EXE`). Full grammar lives in
|
||||||
|
[`../src/VPlasma.Core/Protocol/PlasmaProtocol.cs`](../src/VPlasma.Core/Protocol/PlasmaProtocol.cs).
|
||||||
|
|
||||||
|
| Bytes | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `ESC @` | Clear screen, reset text state |
|
||||||
|
| `ESC L` | Home cursor |
|
||||||
|
| `ESC G n` | Cursor mode (00/FF hidden, 01 steady, 03 flashing) |
|
||||||
|
| `ESC K n` | Font select (0–7; FF = default) |
|
||||||
|
| `ESC H n` | Text attributes (intensity / underline / reverse / flash) |
|
||||||
|
| `ESC P s y x w h data…` | Graphics write: MSB = leftmost pixel |
|
||||||
|
| BS / HT / LF / VT / CR | Cursor motion |
|
||||||
|
|
||||||
|
The Babcock manual (or a firmware dump) would fill in exact operand
|
||||||
|
encodings, tab stops, the `ESC P` "screen" byte, and any commands the game
|
||||||
|
never used.
|
||||||
|
|
||||||
|
## JP1 configuration header
|
||||||
|
|
||||||
|
Traced pin-by-pin (see the jumper photo). **JP1 is firmware-read
|
||||||
|
configuration, not CPU mode select** — each shunt ties a GP port pin the
|
||||||
|
firmware polls at boot. Shunt to GND = logic 0.
|
||||||
|
|
||||||
|
| JP1 pos | HC11 pin | Function |
|
||||||
|
|---------|----------|----------|
|
||||||
|
| 1 | pin 24 / PA0 | Baud select bit 0 |
|
||||||
|
| 2 | pin 22 / PA2 | Baud select bit 1 |
|
||||||
|
| 3 | pin 21 / PA3 | Option (unknown) |
|
||||||
|
| 4 | pin 15 / PD5 | Option (unknown) |
|
||||||
|
| 5 | pin 14 / PD4 | Option (unknown) |
|
||||||
|
| 6 | pin 13 / PD3 | Option (unknown) |
|
||||||
|
| 7 | J2 SEL → +5 V | Parallel interface select |
|
||||||
|
|
||||||
|
Positions 1–2 = the datasheet's baud "JUMPER 1 / JUMPER 2." Positions 3–6
|
||||||
|
are four unknown firmware option bits — candidates for a hidden factory
|
||||||
|
self-test / diagnostic mode.
|
||||||
|
|
||||||
|
HC11 pin map cross-checked while tracing: PD0–PD5 = pins 10–15, PA0–PA7 =
|
||||||
|
pins 24–17 (descending).
|
||||||
|
|
||||||
|
**MODA/MODB are hardwired high (expanded mode) through a diode to +5 V — not
|
||||||
|
jumper-selectable.** So bootstrap mode cannot be entered by moving a jumper;
|
||||||
|
it needs a mode-pin override. (Exact diode circuit still to be characterized.)
|
||||||
|
|
||||||
|
## Firmware-dump plan
|
||||||
|
|
||||||
|
Goal: get the 64 KB EPROM image, disassemble the HC11 code to recover the
|
||||||
|
full command table + font bitmaps + timing, then differential-test vPLASMA
|
||||||
|
against the real panel on identical byte streams. The recovered spec feeds
|
||||||
|
**both** vPLASMA and the replacement firmware.
|
||||||
|
|
||||||
|
1. **Free, no-solder — hunt for a diagnostic mode.** Capture J1 TxD while
|
||||||
|
power-cycling normally (may emit a banner/version), then step the four
|
||||||
|
unknown config jumpers (PA3, PD5, PD4, PD3) through combinations watching
|
||||||
|
TxD for a factory self-test or ROM dump.
|
||||||
|
2. **Serial bootstrap (conditional).** Bootstrap needs MODA = MODB = 0 at the
|
||||||
|
reset edge; they're pulled to +5 V via a diode. If that circuit has a
|
||||||
|
series resistor (or a diode-OR node), pull both low during a reset pulse
|
||||||
|
and run the standard **Motorola AN1060** dump loader out J1 — no cutting.
|
||||||
|
If hard-tied, a single trace cut/lift is needed. *Blocked on the diode
|
||||||
|
details.*
|
||||||
|
3. **Reliable fallback — read the EPROM directly.** PLCC-32 test clip on U3
|
||||||
|
with the HC11 held in reset, or hot-air U3 off and read it in a 27C512
|
||||||
|
adapter. Guaranteed image.
|
||||||
|
|
||||||
|
Safety: the panel runs on a few hundred volts from the on-board DC-DC. Keep
|
||||||
|
all work in the logic corner (HC11 / EPROM / MAX202); never probe the HV
|
||||||
|
section or the panel connector while powered.
|
||||||
|
|
||||||
|
## Open items
|
||||||
|
|
||||||
|
- Characterize the MODA/MODB diode circuit → decide if serial bootstrap is a
|
||||||
|
tack-a-wire job or needs a trace cut.
|
||||||
|
- Capture J1 TxD across config-jumper combinations (path 1).
|
||||||
|
- Obtain the Babcock PD01D programming manual, **or** dump the U3 EPROM.
|
||||||
|
- Once we have the command table + fonts: fold into `VPlasmaDevice`, replace
|
||||||
|
the public-domain 5×7 stand-in with the real Babcock glyphs, and
|
||||||
|
differential-test against the hardware.
|
||||||
|
- **Prototype the replica.** A modern MCU (RP2040 / ESP32 / Teensy) reads the
|
||||||
|
command stream into the same command parser and drives a 128×32 LED matrix
|
||||||
|
from the same frame buffer — the per-pixel lit / half-intensity / flash
|
||||||
|
flags in `VPlasmaDevice` map directly onto PWM brightness + blink. An amber
|
||||||
|
matrix best mimics the neon-orange plasma; for a true cockpit swap, match
|
||||||
|
the original active area (~12.75" × 3.15", ~0.1" pitch = 128×32).
|
||||||
|
|
||||||
|
## Replica interface — USB, not RS-232
|
||||||
|
|
||||||
|
The cockpit PCs are now **Win x64**, so the replica likely needs **no real
|
||||||
|
serial port**: a native-USB MCU presenting as a **USB CDC virtual COM port**
|
||||||
|
is transparent — the host opens `COMx` and can't tell it isn't a UART. This
|
||||||
|
deletes the RS-232 transceiver and connector from the BOM. Consequences:
|
||||||
|
|
||||||
|
- **Baud is cosmetic** over USB CDC (the 9600/… setting is accepted as a
|
||||||
|
no-op; the two baud-select jumpers need no hardware equivalent).
|
||||||
|
- **Timing becomes instant** rather than ~1 ms/byte — harmless for a display,
|
||||||
|
and vPLASMA can still throttle to mimic the original for differential tests.
|
||||||
|
- **Pin the COM number** the host expects (original was COM2) in Device
|
||||||
|
Manager so it drops in with no host-side config change.
|
||||||
|
- **DTR/RTS still cross** the CDC link if any host logic ever needs them (the
|
||||||
|
game didn't use flow control).
|
||||||
|
- **Power gotcha:** USB alone can't drive the LED array at full brightness —
|
||||||
|
use USB for data + a **separate DC feed** for the LEDs (or USB-C PD).
|
||||||
|
|
||||||
|
Transparency assumes the host reaches the display as a Windows `COMx`
|
||||||
|
endpoint — e.g. DOSBox-X `serial2=directserial realport:COMx`, which a USB
|
||||||
|
CDC port satisfies perfectly. Confirm the current drive path.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Parked pending a firmware dump.** The software emulator (vPLASMA) is built
|
||||||
|
and released; this hardware/protocol thread is blocked on getting the U3
|
||||||
|
EPROM image (or the Babcock programming manual). Resume at the dump plan
|
||||||
|
above once a dump is in hand.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 675 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 896 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 943 KiB |
@@ -96,22 +96,54 @@ The two apps need a crossed serial link. Install a
|
|||||||
(e.g. `COM1 ⇄ COM11`), then:
|
(e.g. `COM1 ⇄ COM11`), then:
|
||||||
|
|
||||||
1. Run `VRio.App`, pick `COM11`, **Open**.
|
1. Run `VRio.App`, pick `COM11`, **Open**.
|
||||||
2. RIOJoy will always poit to `COM1`.
|
2. RIOJoy will always point to `COM1`.
|
||||||
|
|
||||||
RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms
|
RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms
|
||||||
analog polling drives the "analog polls served" counter, and every click on
|
analog polling drives the "analog polls served" counter, and every click on
|
||||||
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
|
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
|
||||||
a null-modem cable work the same way.
|
a null-modem cable work the same way.
|
||||||
|
|
||||||
|
## Named pipes for DOSBox-X (no com0com)
|
||||||
|
|
||||||
|
When the game runs in the patched DOSBox-X (the fork's `namedpipe` serial
|
||||||
|
backend), the virtual ports aren't needed at all: each device's connection
|
||||||
|
picker lists its named-pipe endpoint — `pipe:vrio`, `pipe:vplasma` —
|
||||||
|
alongside the machine's COM ports. Select it and **Open** to serve
|
||||||
|
`\\.\pipe\vrio` / `\\.\pipe\vplasma`; DOSBox-X connects as the client,
|
||||||
|
retrying in the background until the pipe exists:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[serial]
|
||||||
|
serial1 = namedpipe pipe:vrio rxpollus:100 rxburst:16
|
||||||
|
serial2 = namedpipe pipe:vplasma
|
||||||
|
```
|
||||||
|
|
||||||
|
A pipe is a plain byte stream, so serial data and modem lines travel as
|
||||||
|
typed frames (`0x00 len bytes` data, `0x01 bits` DTR/RTS) — the game's DTR
|
||||||
|
reset pulse keeps its exact position in the byte stream, and a disconnect
|
||||||
|
reads as all-lines-low (cable unplugged). The contract lives in
|
||||||
|
`src/VRio.Core/Device/PipeFraming.cs` on this side and `serialnamedpipe.h`
|
||||||
|
on the fork's. TX is paced exactly like the COM path — with the 9600-baud
|
||||||
|
wire gone, the pacer is the only thing between the host and an impossible
|
||||||
|
burst. Each row serves one endpoint at a time — the RIO is a single-host
|
||||||
|
device — and nothing opens automatically at startup; the COM path is
|
||||||
|
unchanged for RIOJoy and real pods. vRIO's built-in glass and the
|
||||||
|
standalone vPLASMA share the `vplasma` pipe name — whoever starts second
|
||||||
|
retries until the name frees up.
|
||||||
|
|
||||||
## vPLASMA — the companion plasma display
|
## 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. `VPlasma.App` is its software replica:
|
mission text and status graphics on. The software replica comes in two
|
||||||
a bare-glass window that opens **COM12** (the device end of the plasma's
|
forms. **Built into vRIO**: the panel's encoder strip hosts the glass at
|
||||||
null-modem pair) on startup — retrying while the port is missing or busy,
|
top left, with its own connection row in the control strip (label colour =
|
||||||
port status in the title bar — decodes the display's command stream, and
|
connection status; picker offers `pipe:vplasma` and the COM ports). And standalone,
|
||||||
renders the dot matrix in plasma orange, text mode included.
|
`VPlasma.App`: a bare-glass window that opens **COM12** (the device end of
|
||||||
|
the plasma's null-modem pair) on startup — retrying while the port is
|
||||||
|
missing or busy, port status in the title bar. Both decode the display's
|
||||||
|
command stream and render the dot matrix in plasma orange, text mode
|
||||||
|
included; only one can hold COM12 at a time.
|
||||||
|
|
||||||
The command set was recovered from two Tesla 4.10 artifacts:
|
The command set was recovered from two Tesla 4.10 artifacts:
|
||||||
`CODE\RP\MUNGA_L4\L4PLASMA.CPP` (the game's driver — it renders everything
|
`CODE\RP\MUNGA_L4\L4PLASMA.CPP` (the game's driver — it renders everything
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ namespace VPlasma.App;
|
|||||||
/// device end is hardwired to <see cref="PortName"/> (the plasma side of the
|
/// device end is hardwired to <see cref="PortName"/> (the plasma side of the
|
||||||
/// second com0com pair) and opened automatically — a retry timer keeps
|
/// second com0com pair) and opened automatically — a retry timer keeps
|
||||||
/// trying while the port is missing or busy, and reopens it if it dies. The
|
/// trying while the port is missing or busy, and reopens it if it dies. The
|
||||||
|
/// DOSBox-X fork's named-pipe transport (<c>\\.\pipe\vplasma</c>) is served
|
||||||
|
/// permanently alongside it (if vRIO's built-in glass already holds the
|
||||||
|
/// pipe name, the server just retries until it frees up). The
|
||||||
/// title bar carries the port status; double-clicking the glass cycles the
|
/// title bar carries the port status; double-clicking the glass cycles the
|
||||||
/// self-test pages (banner, charset, graphics) through the wire parser, and
|
/// self-test pages (banner, charset, graphics) through the wire parser, and
|
||||||
/// a right-click resets the display to its power-on state.
|
/// a right-click resets the display to its power-on state.
|
||||||
@@ -18,6 +21,7 @@ internal sealed class MainForm : Form
|
|||||||
|
|
||||||
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 _reconnectTimer = new() { Interval = 3000 };
|
||||||
@@ -32,6 +36,7 @@ internal sealed class MainForm : Form
|
|||||||
StartPosition = FormStartPosition.CenterScreen;
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
|
|
||||||
_service = new VPlasmaSerialService(_device);
|
_service = new VPlasmaSerialService(_device);
|
||||||
|
_pipeService = new VPlasmaPipeService(_device);
|
||||||
|
|
||||||
_canvas.Location = new Point(0, 0);
|
_canvas.Location = new Point(0, 0);
|
||||||
Controls.Add(_canvas);
|
Controls.Add(_canvas);
|
||||||
@@ -39,6 +44,7 @@ internal sealed class MainForm : Form
|
|||||||
// Device / service events arrive on the serial reader thread; marshal to the UI.
|
// Device / service events arrive on the serial reader thread; marshal to the UI.
|
||||||
_device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device));
|
_device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device));
|
||||||
_service.ConnectionChanged += _ => RunOnUi(UpdateTitle);
|
_service.ConnectionChanged += _ => RunOnUi(UpdateTitle);
|
||||||
|
_pipeService.ClientChanged += _ => RunOnUi(UpdateTitle);
|
||||||
|
|
||||||
_canvas.DoubleClick += (_, _) => RunSelfTest();
|
_canvas.DoubleClick += (_, _) => RunSelfTest();
|
||||||
_canvas.MouseClick += (_, e) =>
|
_canvas.MouseClick += (_, e) =>
|
||||||
@@ -55,10 +61,12 @@ internal sealed class MainForm : Form
|
|||||||
{
|
{
|
||||||
_reconnectTimer.Dispose();
|
_reconnectTimer.Dispose();
|
||||||
_service.Dispose();
|
_service.Dispose();
|
||||||
|
_pipeService.Dispose();
|
||||||
};
|
};
|
||||||
|
|
||||||
_canvas.UpdateFrom(_device);
|
_canvas.UpdateFrom(_device);
|
||||||
EnsureOpen();
|
EnsureOpen();
|
||||||
|
_pipeService.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureOpen()
|
private void EnsureOpen()
|
||||||
@@ -82,6 +90,8 @@ internal sealed class MainForm : Form
|
|||||||
string status = _service.IsOpen
|
string status = _service.IsOpen
|
||||||
? $"{PortName} @ 9600 8N1"
|
? $"{PortName} @ 9600 8N1"
|
||||||
: $"{PortName} unavailable — retrying";
|
: $"{PortName} unavailable — retrying";
|
||||||
|
if (_pipeService.IsClientConnected)
|
||||||
|
status += " • pipe host connected";
|
||||||
Text = $"vPLASMA v{Application.ProductVersion} — {status}";
|
Text = $"vPLASMA v{Application.ProductVersion} — {status}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ namespace VPlasma.App;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class PlasmaCanvas : Control
|
internal sealed class PlasmaCanvas : Control
|
||||||
{
|
{
|
||||||
private const int DotPitch = 5; // 4 px dot + 1 px gap → a 640×160 dot field
|
|
||||||
private const int DotSize = 4;
|
|
||||||
private const int Bezel = 4;
|
private const int Bezel = 4;
|
||||||
|
|
||||||
|
// Dot geometry: an N px pitch renders (N−1) px dots with a 1 px gap. The
|
||||||
|
// standalone glass uses the default pitch 5 (4 px dots, a 640×160 dot
|
||||||
|
// field); vRIO embeds the same control at pitch 3 to fit its encoder strip.
|
||||||
|
private readonly int _dotPitch;
|
||||||
|
private readonly int _dotSize;
|
||||||
|
|
||||||
private static readonly Color BezelColor = Color.FromArgb(20, 18, 16);
|
private static readonly Color BezelColor = Color.FromArgb(20, 18, 16);
|
||||||
private static readonly Color GlassColor = Color.FromArgb(26, 14, 6);
|
private static readonly Color GlassColor = Color.FromArgb(26, 14, 6);
|
||||||
private static readonly Color UnlitDot = Color.FromArgb(46, 24, 10);
|
private static readonly Color UnlitDot = Color.FromArgb(46, 24, 10);
|
||||||
@@ -30,14 +34,14 @@ internal sealed class PlasmaCanvas : Control
|
|||||||
private PlasmaCursorMode _cursorMode;
|
private PlasmaCursorMode _cursorMode;
|
||||||
private int _cellWidth = 6, _cellHeight = 8;
|
private int _cellWidth = 6, _cellHeight = 8;
|
||||||
|
|
||||||
public PlasmaCanvas()
|
public PlasmaCanvas(int dotPitch = 5)
|
||||||
{
|
{
|
||||||
|
_dotPitch = dotPitch;
|
||||||
|
_dotSize = dotPitch - 1;
|
||||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.FixedWidth |
|
ControlStyles.OptimizedDoubleBuffer | ControlStyles.FixedWidth |
|
||||||
ControlStyles.FixedHeight, true);
|
ControlStyles.FixedHeight, true);
|
||||||
Size = new Size(
|
Size = SizeFor(dotPitch);
|
||||||
VPlasmaDevice.Width * DotPitch + 2 * Bezel,
|
|
||||||
VPlasmaDevice.Height * DotPitch + 2 * Bezel);
|
|
||||||
BackColor = BezelColor;
|
BackColor = BezelColor;
|
||||||
|
|
||||||
_blinkTimer.Tick += (_, _) =>
|
_blinkTimer.Tick += (_, _) =>
|
||||||
@@ -49,6 +53,11 @@ internal sealed class PlasmaCanvas : Control
|
|||||||
_blinkTimer.Start();
|
_blinkTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Control size at a given dot pitch: the dot field plus bezel.</summary>
|
||||||
|
public static Size SizeFor(int dotPitch) => new(
|
||||||
|
VPlasmaDevice.Width * dotPitch + 2 * Bezel,
|
||||||
|
VPlasmaDevice.Height * dotPitch + 2 * Bezel);
|
||||||
|
|
||||||
/// <summary>Snapshot the device state and repaint. Call on the UI thread.</summary>
|
/// <summary>Snapshot the device state and repaint. Call on the UI thread.</summary>
|
||||||
public void UpdateFrom(VPlasmaDevice device)
|
public void UpdateFrom(VPlasmaDevice device)
|
||||||
{
|
{
|
||||||
@@ -77,7 +86,7 @@ internal sealed class PlasmaCanvas : Control
|
|||||||
|
|
||||||
using (var glass = new SolidBrush(GlassColor))
|
using (var glass = new SolidBrush(GlassColor))
|
||||||
g.FillRectangle(glass, Bezel - 4, Bezel - 4,
|
g.FillRectangle(glass, Bezel - 4, Bezel - 4,
|
||||||
VPlasmaDevice.Width * DotPitch + 7, VPlasmaDevice.Height * DotPitch + 7);
|
VPlasmaDevice.Width * _dotPitch + 7, VPlasmaDevice.Height * _dotPitch + 7);
|
||||||
|
|
||||||
using var unlit = new SolidBrush(UnlitDot);
|
using var unlit = new SolidBrush(UnlitDot);
|
||||||
using var lit = new SolidBrush(LitDot);
|
using var lit = new SolidBrush(LitDot);
|
||||||
@@ -86,7 +95,7 @@ internal sealed class PlasmaCanvas : Control
|
|||||||
for (int y = 0; y < VPlasmaDevice.Height; ++y)
|
for (int y = 0; y < VPlasmaDevice.Height; ++y)
|
||||||
{
|
{
|
||||||
int rowOffset = y * VPlasmaDevice.Width;
|
int rowOffset = y * VPlasmaDevice.Width;
|
||||||
int py = Bezel + y * DotPitch;
|
int py = Bezel + y * _dotPitch;
|
||||||
for (int x = 0; x < VPlasmaDevice.Width; ++x)
|
for (int x = 0; x < VPlasmaDevice.Width; ++x)
|
||||||
{
|
{
|
||||||
byte dot = _frame[rowOffset + x];
|
byte dot = _frame[rowOffset + x];
|
||||||
@@ -96,7 +105,7 @@ internal sealed class PlasmaCanvas : Control
|
|||||||
{
|
{
|
||||||
brush = (dot & VPlasmaDevice.PixelHalf) != 0 ? half : lit;
|
brush = (dot & VPlasmaDevice.PixelHalf) != 0 ? half : lit;
|
||||||
}
|
}
|
||||||
g.FillRectangle(brush, Bezel + x * DotPitch, py, DotSize, DotSize);
|
g.FillRectangle(brush, Bezel + x * _dotPitch, py, _dotSize, _dotSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +117,7 @@ internal sealed class PlasmaCanvas : Control
|
|||||||
int cy = _cursor.Row * _cellHeight + _cellHeight - 1;
|
int cy = _cursor.Row * _cellHeight + _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,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();
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="System.Memory" Version="4.5.5" />
|
<PackageReference Include="System.Memory" Version="4.5.5" />
|
||||||
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
|
|
||||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
+196
-37
@@ -1,5 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO.Ports;
|
using System.IO.Ports;
|
||||||
|
using VPlasma.App;
|
||||||
|
using VPlasma.Core.Device;
|
||||||
using VRio.Core.Device;
|
using VRio.Core.Device;
|
||||||
using VRio.Core.Input;
|
using VRio.Core.Input;
|
||||||
|
|
||||||
@@ -8,15 +10,43 @@ namespace VRio.App;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// vRIO main window: the interactive cockpit panel on the left (the same
|
/// vRIO main window: the interactive cockpit panel on the left (the same
|
||||||
/// functional map RIOJoy's profile editor shows) and a control strip on the
|
/// functional map RIOJoy's profile editor shows) and a control strip on the
|
||||||
/// right — COM port, device settings, and a live wire log. Open the COM port,
|
/// right — connection rows, device settings, and a live wire log. The
|
||||||
/// point RIOJoy at the other end of the null-modem pair, and every click here
|
/// panel's encoder strip also hosts the built-in plasma glass (the vPLASMA
|
||||||
/// arrives at RIOJoy exactly as if the physical cockpit sent it.
|
/// display emulator) on its own connection row. Each row's picker offers
|
||||||
|
/// the device's named-pipe endpoint (the DOSBox-X fork's namedpipe backend
|
||||||
|
/// — the com0com-free path) alongside the machine's COM ports; nothing
|
||||||
|
/// opens automatically — pick an endpoint and Open. Point RIOJoy at the
|
||||||
|
/// other end of a null-modem pair and every click here arrives exactly as
|
||||||
|
/// if the physical cockpit sent it; point the game at
|
||||||
|
/// <see cref="RioPipeEndpoint"/>/<see cref="PlasmaPipeEndpoint"/> (or the
|
||||||
|
/// COM2 passthrough at the plasma pair) and the glass lights up.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class MainForm : Form
|
internal sealed class MainForm : Form
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The RIO's pipe endpoint as listed in the picker. Matches the
|
||||||
|
/// DOSBox-X conf syntax: <c>serial1=namedpipe pipe:vrio</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string RioPipeEndpoint = $"pipe:{VRioPipeService.DefaultPipeName}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The plasma's pipe endpoint as listed in the picker
|
||||||
|
/// (<c>serial2=namedpipe pipe:vplasma</c>).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string PlasmaPipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
|
||||||
|
|
||||||
private readonly VRioDevice _device = new();
|
private readonly VRioDevice _device = new();
|
||||||
private readonly VRioSerialService _service;
|
private readonly VRioSerialService _service;
|
||||||
|
private readonly VRioPipeService _pipeService;
|
||||||
|
private readonly VPlasmaDevice _plasmaDevice = new();
|
||||||
|
private readonly VPlasmaSerialService _plasmaService;
|
||||||
|
private readonly VPlasmaPipeService _plasmaPipeService;
|
||||||
private readonly PanelCanvas _canvas = new();
|
private readonly PanelCanvas _canvas = new();
|
||||||
|
private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch)
|
||||||
|
{
|
||||||
|
Location = PanelCanvas.PlasmaSlot,
|
||||||
|
};
|
||||||
|
private int _selfTestPage;
|
||||||
private readonly InputRouter _router;
|
private readonly InputRouter _router;
|
||||||
private readonly XInputGamepad _gamepad = new();
|
private readonly XInputGamepad _gamepad = new();
|
||||||
private readonly KeyboardLampMirror _lampMirror;
|
private readonly KeyboardLampMirror _lampMirror;
|
||||||
@@ -25,6 +55,16 @@ internal sealed class MainForm : Form
|
|||||||
private readonly string _bindingsPath = Path.Combine(
|
private readonly string _bindingsPath = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
|
||||||
|
|
||||||
|
// Port rows: one line per built-in device — a name label whose colour is
|
||||||
|
// the port status (green = open, gray = closed), the port picker, and an
|
||||||
|
// Open/Close button. The one Rescan button refreshes both pickers.
|
||||||
|
private readonly Label _rioLabel = new()
|
||||||
|
{
|
||||||
|
Text = "vRIO",
|
||||||
|
Location = new Point(12, 15),
|
||||||
|
AutoSize = true,
|
||||||
|
ForeColor = Color.Gray,
|
||||||
|
};
|
||||||
private readonly ComboBox _portBox = new()
|
private readonly ComboBox _portBox = new()
|
||||||
{
|
{
|
||||||
Location = new Point(80, 12),
|
Location = new Point(80, 12),
|
||||||
@@ -33,13 +73,20 @@ internal sealed class MainForm : Form
|
|||||||
};
|
};
|
||||||
private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 };
|
private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 };
|
||||||
private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 };
|
private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 };
|
||||||
private readonly Label _linkStatus = new()
|
private readonly Label _plasmaLabel = new()
|
||||||
{
|
{
|
||||||
Text = "Port closed.",
|
Text = "vPLASMA",
|
||||||
Location = new Point(12, 42),
|
Location = new Point(12, 45),
|
||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
ForeColor = Color.Gray,
|
ForeColor = Color.Gray,
|
||||||
};
|
};
|
||||||
|
private readonly ComboBox _plasmaPortBox = new()
|
||||||
|
{
|
||||||
|
Location = new Point(80, 42),
|
||||||
|
Width = 128,
|
||||||
|
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||||
|
};
|
||||||
|
private readonly Button _plasmaOpenClose = new() { Text = "Open", Location = new Point(272, 41), Width = 46 };
|
||||||
|
|
||||||
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true };
|
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true };
|
||||||
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 };
|
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 };
|
||||||
@@ -103,7 +150,8 @@ internal sealed class MainForm : Form
|
|||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
ForeColor = Color.Gray,
|
ForeColor = Color.Gray,
|
||||||
Text = "Left-click a cell: momentary press. Right-click: latch it down. " +
|
Text = "Left-click a cell: momentary press. Right-click: latch it down. " +
|
||||||
"Drag the X/Y box and the Z / L / R gauges to move the axes.",
|
"Drag the X/Y box and the Z / L / R gauges to move the axes. " +
|
||||||
|
"Double-click the plasma glass to cycle its self-test pages; right-click it to reset.",
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly TextBox _logBox = new()
|
private readonly TextBox _logBox = new()
|
||||||
@@ -144,11 +192,18 @@ internal sealed class MainForm : Form
|
|||||||
KeyPreview = true; // form-level key routing for the input bindings
|
KeyPreview = true; // form-level key routing for the input bindings
|
||||||
|
|
||||||
_service = new VRioSerialService(_device);
|
_service = new VRioSerialService(_device);
|
||||||
|
_plasmaService = new VPlasmaSerialService(_plasmaDevice);
|
||||||
|
// 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);
|
||||||
|
|
||||||
// Panel canvas, scrolled if the window is smaller than the grid.
|
// Panel canvas, scrolled if the window is smaller than the grid. The
|
||||||
|
// plasma glass rides along as a child in the encoder strip's left slot.
|
||||||
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
||||||
|
_canvas.Controls.Add(_plasmaCanvas);
|
||||||
scroller.Controls.Add(_canvas);
|
scroller.Controls.Add(_canvas);
|
||||||
|
|
||||||
Controls.Add(scroller);
|
Controls.Add(scroller);
|
||||||
@@ -173,12 +228,30 @@ 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
|
||||||
|
// as the standalone vPLASMA — double-click cycles the self-test pages,
|
||||||
|
// right-click resets the display to its power-on state.
|
||||||
|
_plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice));
|
||||||
|
_plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}"));
|
||||||
|
_plasmaService.ConnectionChanged += _ => RunOnUi(UpdatePlasmaEndpointUi);
|
||||||
|
_plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest();
|
||||||
|
_plasmaCanvas.MouseClick += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Button == MouseButtons.Right)
|
||||||
|
_plasmaDevice.Reset();
|
||||||
|
};
|
||||||
|
|
||||||
_rescan.Click += (_, _) => RefreshPorts();
|
_rescan.Click += (_, _) => RefreshPorts();
|
||||||
_openClose.Click += (_, _) => ToggleOpen();
|
_openClose.Click += (_, _) => ToggleOpen();
|
||||||
|
_plasmaOpenClose.Click += (_, _) => TogglePlasmaOpen();
|
||||||
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
|
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
|
||||||
_centerAxes.Click += (_, _) =>
|
_centerAxes.Click += (_, _) =>
|
||||||
{
|
{
|
||||||
@@ -242,12 +315,17 @@ internal sealed class MainForm : Form
|
|||||||
_lampMirror.Dispose();
|
_lampMirror.Dispose();
|
||||||
_rawKeyboard.Dispose();
|
_rawKeyboard.Dispose();
|
||||||
_service.Dispose();
|
_service.Dispose();
|
||||||
|
_plasmaService.Dispose();
|
||||||
|
_pipeService.Dispose();
|
||||||
|
_plasmaPipeService.Dispose();
|
||||||
};
|
};
|
||||||
|
|
||||||
RefreshPorts();
|
RefreshPorts();
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair.");
|
PrependLog($"vRIO ready. Pick an endpoint per device — {RioPipeEndpoint} / {PlasmaPipeEndpoint} " +
|
||||||
|
"for DOSBox-X, or a COM port for RIOJoy — and Open.");
|
||||||
LoadBindings();
|
LoadBindings();
|
||||||
|
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
|
||||||
}
|
}
|
||||||
|
|
||||||
private Panel BuildControlStrip()
|
private Panel BuildControlStrip()
|
||||||
@@ -265,11 +343,13 @@ internal sealed class MainForm : Form
|
|||||||
BorderStyle = BorderStyle.FixedSingle,
|
BorderStyle = BorderStyle.FixedSingle,
|
||||||
};
|
};
|
||||||
|
|
||||||
panel.Controls.Add(new Label { Text = "COM port:", Location = new Point(12, 15), AutoSize = true });
|
panel.Controls.Add(_rioLabel);
|
||||||
panel.Controls.Add(_portBox);
|
panel.Controls.Add(_portBox);
|
||||||
panel.Controls.Add(_rescan);
|
panel.Controls.Add(_rescan);
|
||||||
panel.Controls.Add(_openClose);
|
panel.Controls.Add(_openClose);
|
||||||
panel.Controls.Add(_linkStatus);
|
panel.Controls.Add(_plasmaLabel);
|
||||||
|
panel.Controls.Add(_plasmaPortBox);
|
||||||
|
panel.Controls.Add(_plasmaOpenClose);
|
||||||
|
|
||||||
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) };
|
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) };
|
||||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
|
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
|
||||||
@@ -292,56 +372,132 @@ internal sealed class MainForm : Form
|
|||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Port handling -----------------------------------------------------
|
// ---- Endpoint handling ---------------------------------------------------
|
||||||
|
// Each device row serves one endpoint at a time — the RIO is a
|
||||||
|
// single-host device, so its pipe server and COM port never run together.
|
||||||
|
|
||||||
|
private bool RioOpen => _service.IsOpen || _pipeService.IsListening;
|
||||||
|
private bool PlasmaOpen => _plasmaService.IsOpen || _plasmaPipeService.IsListening;
|
||||||
|
|
||||||
private void RefreshPorts()
|
private void RefreshPorts()
|
||||||
{
|
{
|
||||||
string? current = _portBox.SelectedItem?.ToString();
|
string[] names = SerialPort.GetPortNames().Distinct()
|
||||||
_portBox.Items.Clear();
|
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
|
// An open row's picker is disabled and shows the served endpoint;
|
||||||
_portBox.Items.Add(name);
|
// leave it alone so the display can't drift from reality.
|
||||||
|
if (!RioOpen)
|
||||||
|
RefreshPicker(_portBox, RioPipeEndpoint, names);
|
||||||
|
if (!PlasmaOpen)
|
||||||
|
RefreshPicker(_plasmaPortBox, PlasmaPipeEndpoint, names);
|
||||||
|
}
|
||||||
|
|
||||||
if (_portBox.Items.Count == 0)
|
private static void RefreshPicker(ComboBox box, string pipeEndpoint, string[] portNames)
|
||||||
return;
|
{
|
||||||
int idx = current is null ? -1 : _portBox.Items.IndexOf(current);
|
string? current = box.SelectedItem?.ToString();
|
||||||
_portBox.SelectedIndex = idx >= 0 ? idx : 0;
|
box.Items.Clear();
|
||||||
|
box.Items.Add(pipeEndpoint); // always present — a pipe server needs no hardware
|
||||||
|
foreach (string name in portNames)
|
||||||
|
box.Items.Add(name);
|
||||||
|
|
||||||
|
int idx = current is null ? -1 : box.Items.IndexOf(current);
|
||||||
|
box.SelectedIndex = idx >= 0 ? idx : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleOpen()
|
private void ToggleOpen()
|
||||||
{
|
{
|
||||||
if (_service.IsOpen)
|
if (RioOpen)
|
||||||
{
|
{
|
||||||
_service.Close();
|
_service.Close(); // idempotent —
|
||||||
return;
|
_pipeService.Stop(); // whichever was serving closes
|
||||||
|
UpdateRioEndpointUi();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
if (_portBox.SelectedItem?.ToString() is not { } port)
|
|
||||||
{
|
{
|
||||||
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " +
|
OpenFromPicker(_portBox, OpenRioEndpoint);
|
||||||
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TogglePlasmaOpen()
|
||||||
|
{
|
||||||
|
if (PlasmaOpen)
|
||||||
|
{
|
||||||
|
_plasmaService.Close();
|
||||||
|
_plasmaPipeService.Stop();
|
||||||
|
UpdatePlasmaEndpointUi();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
OpenFromPicker(_plasmaPortBox, OpenPlasmaEndpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenRioEndpoint(string endpoint)
|
||||||
|
{
|
||||||
|
if (endpoint == RioPipeEndpoint)
|
||||||
|
_pipeService.Start();
|
||||||
|
else
|
||||||
|
_service.Open(endpoint);
|
||||||
|
UpdateRioEndpointUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenPlasmaEndpoint(string endpoint)
|
||||||
|
{
|
||||||
|
if (endpoint == PlasmaPipeEndpoint)
|
||||||
|
_plasmaPipeService.Start();
|
||||||
|
else
|
||||||
|
_plasmaService.Open(endpoint);
|
||||||
|
UpdatePlasmaEndpointUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenFromPicker(ComboBox box, Action<string> open)
|
||||||
|
{
|
||||||
|
if (box.SelectedItem?.ToString() is not { } endpoint)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "No endpoint selected. Pick the device's named pipe (for " +
|
||||||
|
"DOSBox-X's namedpipe backend) or a COM port (for RIOJoy through a com0com " +
|
||||||
|
"null-modem pair).", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_service.Open(port);
|
open(endpoint);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
MessageBox.Show(this, $"Could not open {port}:\n{ex.Message}", "vRIO",
|
MessageBox.Show(this, $"Could not open {endpoint}:\n{ex.Message}", "vRIO",
|
||||||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnConnectionChanged(bool open)
|
private void UpdateRioEndpointUi()
|
||||||
{
|
{
|
||||||
|
bool open = RioOpen;
|
||||||
_openClose.Text = open ? "Close" : "Open";
|
_openClose.Text = open ? "Close" : "Open";
|
||||||
_portBox.Enabled = _rescan.Enabled = !open;
|
_portBox.Enabled = !open;
|
||||||
_linkStatus.Text = open ? $"Serving {_service.PortName} @ 9600 8N1." : "Port closed.";
|
_rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
||||||
_linkStatus.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdatePlasmaEndpointUi()
|
||||||
|
{
|
||||||
|
bool open = PlasmaOpen;
|
||||||
|
_plasmaOpenClose.Text = open ? "Close" : "Open";
|
||||||
|
_plasmaPortBox.Enabled = !open;
|
||||||
|
_plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feed the next canned self-test page through the plasma's wire parser —
|
||||||
|
/// the same end-to-end exercise the standalone vPLASMA runs on double-click.
|
||||||
|
/// </summary>
|
||||||
|
private void RunPlasmaSelfTest()
|
||||||
|
{
|
||||||
|
byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
|
||||||
|
_plasmaDevice.OnReceived(bytes, bytes.Length);
|
||||||
|
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Keyboard / gamepad input -------------------------------------------
|
// ---- Keyboard / gamepad input -------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -552,12 +708,15 @@ internal sealed class MainForm : Form
|
|||||||
$"Analog polls dropped: {dropped}\n" +
|
$"Analog polls dropped: {dropped}\n" +
|
||||||
$"Bad checksums: {bad}";
|
$"Bad checksums: {bad}";
|
||||||
|
|
||||||
_canvas.StatusText = _service.IsOpen
|
string? endpoint = _service.IsOpen ? _service.PortName
|
||||||
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" +
|
: _pipeService.IsListening ? RioPipeEndpoint
|
||||||
|
: null;
|
||||||
|
_canvas.StatusText = endpoint is not null
|
||||||
|
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {endpoint}\n" +
|
||||||
(wedged
|
(wedged
|
||||||
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
|
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
|
||||||
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
|
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
|
||||||
: "PORT CLOSED\nOpen a COM port to go live.";
|
: "OFFLINE\nOpen an endpoint (named pipe or COM port) to go live.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PrependLog(string line)
|
private void PrependLog(string line)
|
||||||
|
|||||||
+44
-18
@@ -1,3 +1,4 @@
|
|||||||
|
using VPlasma.App;
|
||||||
using VRio.Core.Device;
|
using VRio.Core.Device;
|
||||||
using VRio.Core.Panel;
|
using VRio.Core.Panel;
|
||||||
using VRio.Core.Protocol;
|
using VRio.Core.Protocol;
|
||||||
@@ -16,6 +17,10 @@ namespace VRio.App;
|
|||||||
/// fill toward full travel (negative on the wire for the throttle, positive
|
/// fill toward full travel (negative on the wire for the throttle, positive
|
||||||
/// for the pedals), and the stick covers ±80 around its center.
|
/// for the pedals), and the stick covers ±80 around its center.
|
||||||
///
|
///
|
||||||
|
/// <para>The strip's left slot hosts the built-in plasma glass: MainForm parks
|
||||||
|
/// its <see cref="PlasmaCanvas"/> child at <see cref="PlasmaSlot"/>, and the
|
||||||
|
/// strip height and gauge positions derive from the glass size.</para>
|
||||||
|
///
|
||||||
/// <para>Left-click = momentary press (release on mouse-up). Right-click =
|
/// <para>Left-click = momentary press (release on mouse-up). Right-click =
|
||||||
/// latch the button down / release it (handy for testing holds).</para>
|
/// latch the button down / release it (handy for testing holds).</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -24,16 +29,27 @@ internal sealed class PanelCanvas : Control
|
|||||||
// Cell geometry — identical to RIOJoy's PanelView so the two panels align.
|
// Cell geometry — identical to RIOJoy's PanelView so the two panels align.
|
||||||
private const int CellW = 66;
|
private const int CellW = 66;
|
||||||
private const int CellH = 34;
|
private const int CellH = 34;
|
||||||
private const int TopStrip = 78; // encoder strip: gauges end at 74, small gap to the grid
|
|
||||||
|
|
||||||
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitLayout.Buttons();
|
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitLayout.Buttons();
|
||||||
|
|
||||||
// Encoder-strip geometry (same placement as RIOJoy's PanelView): Z gauge,
|
// First grid row any group occupies (the shared layout leaves row 0
|
||||||
// the pedal gauges L / R, and the X/Y stick box.
|
// empty). Rendering shifts all rows up by this, so the button grid starts
|
||||||
|
// right under the encoder strip instead of a blank cell row below it.
|
||||||
|
private static readonly int FirstRow = CockpitLayout.Groups.Min(g => g.OriginRow);
|
||||||
|
|
||||||
|
// Encoder-strip geometry. The built-in plasma glass parks at the strip's
|
||||||
|
// left edge (MainForm adds its PlasmaCanvas as a child at PlasmaSlot),
|
||||||
|
// the axis gauges — Z, the pedal gauges L / R, and the X/Y stick box —
|
||||||
|
// hug the grid's right edge, and the green status text fills the gap
|
||||||
|
// between them. The strip is exactly as tall as the glass.
|
||||||
|
internal const int PlasmaDotPitch = 3; // 2 px dots: the 128×32 glass fits the strip
|
||||||
|
internal static readonly Size PlasmaSize = PlasmaCanvas.SizeFor(PlasmaDotPitch);
|
||||||
private const int StripTop = 6;
|
private const int StripTop = 6;
|
||||||
private const int StripH = 68;
|
internal static readonly Point PlasmaSlot = new(6, StripTop);
|
||||||
|
private static readonly int StripH = PlasmaSize.Height;
|
||||||
|
private static readonly int TopStrip = StripTop + StripH + 6; // button grid starts under the strip
|
||||||
private const int Bar = 30;
|
private const int Bar = 30;
|
||||||
private static readonly int BaseX = 6 * CellW - 100;
|
private static readonly int BaseX = GridWidth() - 6 - 200; // the gauge cluster spans 200 px, right-aligned
|
||||||
private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH);
|
private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH);
|
||||||
private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH);
|
private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH);
|
||||||
private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH);
|
private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH);
|
||||||
@@ -109,19 +125,27 @@ internal sealed class PanelCanvas : Control
|
|||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split from GridSize so the static gauge layout can use the width
|
||||||
|
// without touching TopStrip (which initializes after AllButtons).
|
||||||
|
private static int GridWidth()
|
||||||
|
{
|
||||||
|
int maxCol = 0;
|
||||||
|
foreach (PanelButton b in AllButtons)
|
||||||
|
if (b.Col > maxCol) maxCol = b.Col;
|
||||||
|
return (maxCol + 1) * CellW + 6;
|
||||||
|
}
|
||||||
|
|
||||||
private static Size GridSize()
|
private static Size GridSize()
|
||||||
{
|
{
|
||||||
int maxCol = 0, maxRow = 0;
|
int maxRow = 0;
|
||||||
foreach (PanelButton b in AllButtons)
|
foreach (PanelButton b in AllButtons)
|
||||||
{
|
|
||||||
if (b.Col > maxCol) maxCol = b.Col;
|
|
||||||
if (b.Row > maxRow) maxRow = b.Row;
|
if (b.Row > maxRow) maxRow = b.Row;
|
||||||
}
|
// The last used row plus a small margin under the lowest group frame.
|
||||||
return new Size((maxCol + 1) * CellW + 6, TopStrip + (maxRow + 2) * CellH);
|
return new Size(GridWidth(), TopStrip + (maxRow - FirstRow + 1) * CellH + 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Rectangle Cell(int col, int row) =>
|
private static Rectangle Cell(int col, int row) =>
|
||||||
new(col * CellW + 2, TopStrip + row * CellH + 2, CellW - 4, CellH - 4);
|
new(col * CellW + 2, TopStrip + (row - FirstRow) * CellH + 2, CellW - 4, CellH - 4);
|
||||||
|
|
||||||
// ---- Painting ----------------------------------------------------------
|
// ---- Painting ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -142,7 +166,7 @@ internal sealed class PanelCanvas : Control
|
|||||||
{
|
{
|
||||||
var frame = new Rectangle(
|
var frame = new Rectangle(
|
||||||
grp.OriginCol * CellW + 1,
|
grp.OriginCol * CellW + 1,
|
||||||
TopStrip + grp.OriginRow * CellH + 1,
|
TopStrip + (grp.OriginRow - FirstRow) * CellH + 1,
|
||||||
grp.Cols * CellW,
|
grp.Cols * CellW,
|
||||||
(grp.Rows + 1) * CellH);
|
(grp.Rows + 1) * CellH);
|
||||||
g.DrawRectangle(groupPen, frame);
|
g.DrawRectangle(groupPen, frame);
|
||||||
@@ -288,10 +312,12 @@ internal sealed class PanelCanvas : Control
|
|||||||
new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro,
|
new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro,
|
||||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom);
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom);
|
||||||
|
|
||||||
// The mockup's green status/instruction area, right of the gauges; the
|
// The mockup's green status/instruction area, between the plasma glass
|
||||||
// live axis readout sits directly under the status lines (painted per
|
// and the gauges; the live axis readout sits directly under the status
|
||||||
// frame, so drags track).
|
// lines (painted per frame, so drags track).
|
||||||
var statusRect = new Rectangle(BoxXY.Right + 16, StripTop, Width - BoxXY.Right - 24, TopStrip - StripTop - 6);
|
var statusRect = new Rectangle(
|
||||||
|
PlasmaSlot.X + PlasmaSize.Width + 12, StripTop,
|
||||||
|
BoxZ.Left - PlasmaSlot.X - PlasmaSize.Width - 24, StripH);
|
||||||
if (statusRect.Width > 60)
|
if (statusRect.Width > 60)
|
||||||
{
|
{
|
||||||
using var statusFont = new Font("Consolas", 8f);
|
using var statusFont = new Font("Consolas", 8f);
|
||||||
@@ -307,11 +333,11 @@ internal sealed class PanelCanvas : Control
|
|||||||
|
|
||||||
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
|
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
|
||||||
string readout =
|
string readout =
|
||||||
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3} " +
|
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3}\n" +
|
||||||
$"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}";
|
$"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}";
|
||||||
TextRenderer.DrawText(g, readout, statusFont,
|
TextRenderer.DrawText(g, readout, statusFont,
|
||||||
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
|
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
|
||||||
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.SingleLine);
|
TextFormatFlags.Left | TextFormatFlags.Top);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\VRio.Core\VRio.Core.csproj" />
|
<ProjectReference Include="..\VRio.Core\VRio.Core.csproj" />
|
||||||
|
<!-- The built-in plasma glass: vPLASMA's device/protocol core, plus its
|
||||||
|
canvas control compiled in directly (shared file, not a library —
|
||||||
|
VPlasma.Core stays UI-free). -->
|
||||||
|
<ProjectReference Include="..\VPlasma.Core\VPlasma.Core.csproj" />
|
||||||
|
<Compile Include="..\VPlasma.App\PlasmaCanvas.cs" Link="PlasmaCanvas.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Stamp commit date + short sha into InformationalVersion; the window
|
<!-- Stamp commit date + short sha into InformationalVersion; the window
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
namespace VRio.Core.Device;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The framing shared with the DOSBox-X fork's <c>namedpipe</c> serial
|
||||||
|
/// backend (its <c>serialnamedpipe.h</c> header comment is the contract's
|
||||||
|
/// source of truth on that side). A pipe is a plain byte stream, so serial
|
||||||
|
/// data and modem control lines are multiplexed as typed frames:
|
||||||
|
///
|
||||||
|
/// <code>
|
||||||
|
/// 0x00 <len:u8> <len bytes> serial data, len ≥ 1 (batching allowed)
|
||||||
|
/// 0x01 <lines:u8> the sender's OWN output lines (bit0 DTR,
|
||||||
|
/// bit1 RTS); the receiver applies the
|
||||||
|
/// null-modem cross: peer DTR → local DSR,
|
||||||
|
/// peer RTS → local CTS
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// Each side sends one lines frame immediately on connect; until it arrives
|
||||||
|
/// the peer's lines are assumed low, and a disconnect drops them low again.
|
||||||
|
/// Any other frame type is a protocol bug, not line noise — pipes don't drop
|
||||||
|
/// bytes — so the receiver logs it and drops the connection instead of
|
||||||
|
/// trying to resync.
|
||||||
|
/// </summary>
|
||||||
|
public static class PipeFraming
|
||||||
|
{
|
||||||
|
public const byte DataType = 0x00;
|
||||||
|
public const byte LinesType = 0x01;
|
||||||
|
|
||||||
|
/// <summary>Lines-frame bit: the sender's DTR output.</summary>
|
||||||
|
public const byte LineDtr = 0x01;
|
||||||
|
|
||||||
|
/// <summary>Lines-frame bit: the sender's RTS output.</summary>
|
||||||
|
public const byte LineRts = 0x02;
|
||||||
|
|
||||||
|
/// <summary>Both outputs asserted — what a present, powered device reports.</summary>
|
||||||
|
public const byte LinesPresent = LineDtr | LineRts;
|
||||||
|
|
||||||
|
/// <summary>Build a lines frame carrying <paramref name="lines"/>.</summary>
|
||||||
|
public static byte[] EncodeLines(byte lines) => new[] { LinesType, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Incremental decoder for <see cref="PipeFraming"/>: feed it raw pipe reads,
|
||||||
|
/// get one <see cref="Data"/> event per complete data frame and one
|
||||||
|
/// <see cref="Lines"/> event per lines frame. Frames may split across reads
|
||||||
|
/// at any byte boundary. A malformed stream (unknown type, zero-length data
|
||||||
|
/// frame) poisons the decoder: <see cref="Feed"/> returns false and
|
||||||
|
/// <see cref="Violation"/> says why — drop the connection and
|
||||||
|
/// <see cref="Reset"/> before serving the next one.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PipeFrameDecoder
|
||||||
|
{
|
||||||
|
private enum State { Type, Length, Payload, Lines }
|
||||||
|
|
||||||
|
private readonly byte[] _payload = new byte[byte.MaxValue];
|
||||||
|
private State _state;
|
||||||
|
private int _fill, _length;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A complete data frame's payload as (buffer, count). The buffer is
|
||||||
|
/// reused across frames — consume it synchronously.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<byte[], int>? Data;
|
||||||
|
|
||||||
|
/// <summary>A lines frame's bits (see <see cref="PipeFraming.LineDtr"/>).</summary>
|
||||||
|
public event Action<byte>? Lines;
|
||||||
|
|
||||||
|
/// <summary>Why the stream was rejected, or null while it is healthy.</summary>
|
||||||
|
public string? Violation { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Forget any partial frame and clear a violation (new connection).</summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_state = State.Type;
|
||||||
|
_fill = _length = 0;
|
||||||
|
Violation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Consume <paramref name="count"/> received bytes from
|
||||||
|
/// <paramref name="buffer"/>. Returns false when the stream violates the
|
||||||
|
/// framing contract (see <see cref="Violation"/>); a poisoned decoder
|
||||||
|
/// keeps returning false until <see cref="Reset"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool Feed(byte[] buffer, int count)
|
||||||
|
{
|
||||||
|
if (Violation is not null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
byte b = buffer[i];
|
||||||
|
switch (_state)
|
||||||
|
{
|
||||||
|
case State.Type when b == PipeFraming.DataType:
|
||||||
|
_state = State.Length;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.Type when b == PipeFraming.LinesType:
|
||||||
|
_state = State.Lines;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.Type:
|
||||||
|
Violation = $"unknown frame type 0x{b:X2}";
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case State.Length when b == 0:
|
||||||
|
Violation = "zero-length data frame";
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case State.Length:
|
||||||
|
_length = b;
|
||||||
|
_fill = 0;
|
||||||
|
_state = State.Payload;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.Payload:
|
||||||
|
_payload[_fill++] = b;
|
||||||
|
if (_fill == _length)
|
||||||
|
{
|
||||||
|
_state = State.Type;
|
||||||
|
Data?.Invoke(_payload, _length);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State.Lines:
|
||||||
|
_state = State.Type;
|
||||||
|
Lines?.Invoke(b);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace VRio.Core.Device;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves a <see cref="VRioDevice"/> over the named pipe that the DOSBox-X
|
||||||
|
/// fork's <c>serial1=namedpipe pipe:vrio</c> backend connects to — the
|
||||||
|
/// com0com-free transport. vRIO is the pipe <em>server</em> (the device is
|
||||||
|
/// always present); DOSBox-X is the client and background-retries while the
|
||||||
|
/// pipe is missing, so this service listens for the whole app lifetime
|
||||||
|
/// alongside the COM path at no cost. An unconnected pipe is an unplugged
|
||||||
|
/// cable: the device keeps running, transmit bytes fall on the floor.
|
||||||
|
///
|
||||||
|
/// <para>Framing per <see cref="PipeFraming"/>. On connect we immediately
|
||||||
|
/// send our lines frame (DTR+RTS high — "board present", the same lines the
|
||||||
|
/// serial path asserts via DtrEnable/RtsEnable); the peer's DTR edges surface
|
||||||
|
/// through <see cref="HostHandshake"/> exactly like the DSR blips through a
|
||||||
|
/// null modem, and a disconnect drops all lines low. The game hardware-resets
|
||||||
|
/// the RIO by pulsing DTR, and the in-band lines frames keep that edge's
|
||||||
|
/// position in the byte stream — the reason the contract multiplexes control
|
||||||
|
/// onto the data pipe instead of using a second one.</para>
|
||||||
|
///
|
||||||
|
/// <para>Outbound bytes are paced one per 10-bit frame time exactly like
|
||||||
|
/// <see cref="VRioSerialService"/> (see its remarks for the full rationale).
|
||||||
|
/// With the 9600-baud wire gone, this pacer is the only thing standing
|
||||||
|
/// between the host and an impossible burst, so each paced byte travels as
|
||||||
|
/// its own 3-byte data frame and the inter-byte timing survives the pipe.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VRioPipeService : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>The contract pipe name: DOSBox-X connects to <c>\\.\pipe\vrio</c>.</summary>
|
||||||
|
public const string DefaultPipeName = "vrio";
|
||||||
|
|
||||||
|
// Pacing mirrors VRioSerialService: one byte per 10-bit frame at 9600
|
||||||
|
// baud; sleep until ~1.8 ms from the slot, then spin the remainder.
|
||||||
|
private static readonly long BytePeriodTicks = Stopwatch.Frequency * 10 / VRioSerialService.BaudRate;
|
||||||
|
private static readonly long SpinThresholdTicks = Stopwatch.Frequency * 18 / 10_000;
|
||||||
|
|
||||||
|
private readonly VRioDevice _device;
|
||||||
|
private readonly string _pipeName;
|
||||||
|
private readonly object _txGate = new();
|
||||||
|
private readonly Queue<byte> _txQueue = new();
|
||||||
|
private readonly PipeFrameDecoder _decoder = new();
|
||||||
|
|
||||||
|
private NamedPipeServerStream? _pipe;
|
||||||
|
private Thread? _server;
|
||||||
|
private Thread? _writer;
|
||||||
|
private volatile bool _running;
|
||||||
|
private volatile bool _clientConnected;
|
||||||
|
private bool _peerDtr, _peerRts;
|
||||||
|
private bool _timerResolutionRaised;
|
||||||
|
|
||||||
|
public VRioPipeService(VRioDevice device, string pipeName = DefaultPipeName)
|
||||||
|
{
|
||||||
|
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||||
|
if (string.IsNullOrWhiteSpace(pipeName))
|
||||||
|
throw new ArgumentException("Pipe name is required.", nameof(pipeName));
|
||||||
|
_pipeName = pipeName;
|
||||||
|
|
||||||
|
_device.Transmit += Write;
|
||||||
|
_decoder.Data += (buffer, count) => _device.OnReceived(buffer, count);
|
||||||
|
_decoder.Lines += OnPeerLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
|
||||||
|
public string PipeName => _pipeName;
|
||||||
|
|
||||||
|
/// <summary>True while the server is up (whether or not a client is connected).</summary>
|
||||||
|
public bool IsListening => _running;
|
||||||
|
|
||||||
|
/// <summary>True while a client is connected.</summary>
|
||||||
|
public bool IsClientConnected => _clientConnected;
|
||||||
|
|
||||||
|
/// <summary>A client connected (true) or went away (false).</summary>
|
||||||
|
public event Action<bool>? ClientChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The host's DTR line changed (the game pulses it to hardware-reset the
|
||||||
|
/// board). Same semantics as <see cref="VRioSerialService.HostHandshake"/>:
|
||||||
|
/// the argument is the new line state as seen on our DSR pin.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<bool>? HostHandshake;
|
||||||
|
|
||||||
|
/// <summary>Pipe-level log lines (listen/connect/errors).</summary>
|
||||||
|
public event Action<string>? Logged;
|
||||||
|
|
||||||
|
/// <summary>Start listening (idempotent). Clients may come and go forever.</summary>
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_running = true;
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
|
||||||
|
_server = new Thread(ServerLoop) { IsBackground = true, Name = $"vRIO pipe server ({_pipeName})" };
|
||||||
|
_server.Start();
|
||||||
|
_writer = new Thread(WriteLoop) { IsBackground = true, Name = $"vRIO pipe writer ({_pipeName})" };
|
||||||
|
_writer.Start();
|
||||||
|
|
||||||
|
Logged?.Invoke($@"Listening on \\.\pipe\{_pipeName} (TX paced at the wire rate) — DOSBox-X connects when it boots");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stop listening and drop any client (idempotent).</summary>
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (!_running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_running = false;
|
||||||
|
lock (_txGate)
|
||||||
|
{
|
||||||
|
_txQueue.Clear();
|
||||||
|
Monitor.PulseAll(_txGate); // wake the writer so it can exit
|
||||||
|
}
|
||||||
|
|
||||||
|
NamedPipeServerStream? pipe = _pipe;
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe?.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
|
||||||
|
// A WaitForConnection pending on the disposed stream can survive the
|
||||||
|
// Dispose on net48; a throwaway client connect releases it either way.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var poke = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out);
|
||||||
|
poke.Connect(100);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) { }
|
||||||
|
|
||||||
|
_server?.Join(1000);
|
||||||
|
_server = null;
|
||||||
|
_writer?.Join(1000);
|
||||||
|
_writer = null;
|
||||||
|
|
||||||
|
if (_timerResolutionRaised)
|
||||||
|
{
|
||||||
|
timeEndPeriod(1);
|
||||||
|
_timerResolutionRaised = false;
|
||||||
|
}
|
||||||
|
_clientConnected = false;
|
||||||
|
|
||||||
|
Logged?.Invoke("Pipe server stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ServerLoop()
|
||||||
|
{
|
||||||
|
bool busyLogged = false; // log the name collision once, not per retry
|
||||||
|
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
NamedPipeServerStream pipe;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
|
||||||
|
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Name already served — most likely a second vRIO instance.
|
||||||
|
if (!busyLogged)
|
||||||
|
{
|
||||||
|
busyLogged = true;
|
||||||
|
Logged?.Invoke($@"\\.\pipe\{_pipeName} is busy ({ex.Message.TrimEnd('.')}) — retrying until it frees up");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 20 && _running; i++)
|
||||||
|
Thread.Sleep(100);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
busyLogged = false;
|
||||||
|
|
||||||
|
_pipe = pipe;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe.WaitForConnection();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Disposed by Stop(), or a client vanished mid-connect.
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_running || !OnClientConnected(pipe))
|
||||||
|
{
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadUntilDisconnect(pipe);
|
||||||
|
|
||||||
|
OnClientDisconnected();
|
||||||
|
_pipe = null;
|
||||||
|
try { pipe.Dispose(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-connection setup; false if the client died before it finished.</summary>
|
||||||
|
private bool OnClientConnected(NamedPipeServerStream pipe)
|
||||||
|
{
|
||||||
|
_decoder.Reset();
|
||||||
|
_peerDtr = _peerRts = false;
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
|
||||||
|
// Our lines frame goes first, before the writer can interleave data
|
||||||
|
// (it only writes once _clientConnected flips below): DTR+RTS high,
|
||||||
|
// board present — the peer maps them to its DSR/CTS.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] lines = PipeFraming.EncodeLines(PipeFraming.LinesPresent);
|
||||||
|
pipe.Write(lines, 0, lines.Length);
|
||||||
|
pipe.Flush();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 ms system timer resolution while a client is connected, so the
|
||||||
|
// pacer's Thread.Sleep(1) actually sleeps ~1 ms.
|
||||||
|
_timerResolutionRaised = timeBeginPeriod(1) == 0;
|
||||||
|
_clientConnected = true;
|
||||||
|
|
||||||
|
Logged?.Invoke("Pipe client connected — sent lines DTR+RTS (board present)");
|
||||||
|
ClientChanged?.Invoke(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClientDisconnected()
|
||||||
|
{
|
||||||
|
_clientConnected = false;
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
|
||||||
|
if (_timerResolutionRaised)
|
||||||
|
{
|
||||||
|
timeEndPeriod(1);
|
||||||
|
_timerResolutionRaised = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contract: a disconnect drops all lines low on the surviving side.
|
||||||
|
if (_peerDtr)
|
||||||
|
{
|
||||||
|
_peerDtr = false;
|
||||||
|
HostHandshake?.Invoke(false);
|
||||||
|
}
|
||||||
|
_peerRts = false;
|
||||||
|
|
||||||
|
if (_running)
|
||||||
|
{
|
||||||
|
Logged?.Invoke("Pipe client disconnected");
|
||||||
|
ClientChanged?.Invoke(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReadUntilDisconnect(NamedPipeServerStream pipe)
|
||||||
|
{
|
||||||
|
var buffer = new byte[512];
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
int n;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
n = pipe.Read(buffer, 0, buffer.Length);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
if (_running)
|
||||||
|
Logged?.Invoke($"Pipe error: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n == 0)
|
||||||
|
return; // client closed its end
|
||||||
|
|
||||||
|
if (!_decoder.Feed(buffer, n))
|
||||||
|
{
|
||||||
|
Logged?.Invoke($"Pipe protocol violation: {_decoder.Violation} — dropping the connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerLines(byte lines)
|
||||||
|
{
|
||||||
|
_peerRts = (lines & PipeFraming.LineRts) != 0; // tracked; nothing consumes CTS today
|
||||||
|
bool dtr = (lines & PipeFraming.LineDtr) != 0;
|
||||||
|
if (dtr == _peerDtr)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_peerDtr = dtr;
|
||||||
|
HostHandshake?.Invoke(dtr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The device's Transmit handler: queue the frame for the paced writer so
|
||||||
|
// the caller (UI click, reader thread mid-reply) never blocks on the pipe.
|
||||||
|
private void Write(byte[] data)
|
||||||
|
{
|
||||||
|
if (!_running || !_clientConnected)
|
||||||
|
return; // no host on the pipe — a real UART shifts into an unterminated line
|
||||||
|
|
||||||
|
lock (_txGate)
|
||||||
|
{
|
||||||
|
foreach (byte b in data)
|
||||||
|
_txQueue.Enqueue(b);
|
||||||
|
Monitor.Pulse(_txGate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteLoop()
|
||||||
|
{
|
||||||
|
// One data frame per paced byte, so the pipe carries the same
|
||||||
|
// inter-byte spacing a 9600-baud wire would.
|
||||||
|
var frame = new byte[] { PipeFraming.DataType, 1, 0 };
|
||||||
|
long slot = Stopwatch.GetTimestamp();
|
||||||
|
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
lock (_txGate)
|
||||||
|
{
|
||||||
|
while (_txQueue.Count == 0)
|
||||||
|
{
|
||||||
|
if (!_running)
|
||||||
|
return;
|
||||||
|
Monitor.Wait(_txGate, 200); // timed, so a missed pulse can't wedge shutdown
|
||||||
|
}
|
||||||
|
frame[2] = _txQueue.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This byte's wire slot: one frame after the previous byte, or now
|
||||||
|
// if the line has been idle (no burst "catch-up" debt).
|
||||||
|
slot = Math.Max(slot + BytePeriodTicks, Stopwatch.GetTimestamp());
|
||||||
|
PaceUntil(slot);
|
||||||
|
|
||||||
|
NamedPipeServerStream? pipe = _pipe;
|
||||||
|
if (pipe is null || !_clientConnected)
|
||||||
|
continue; // client left while this byte waited its slot — it's stale, drop it
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipe.Write(frame, 0, frame.Length);
|
||||||
|
pipe.Flush();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// The client died mid-write; the backlog is already stale.
|
||||||
|
// Drop it and keep serving — the server loop notices the EOF
|
||||||
|
// and cycles back to WaitForConnection.
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the wait overshot its slot, pace the next byte from the
|
||||||
|
// actual emission instead — a stall must not cause a catch-up burst.
|
||||||
|
long now = Stopwatch.GetTimestamp();
|
||||||
|
if (now > slot)
|
||||||
|
slot = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PaceUntil(long slotTicks)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
long remaining = slotTicks - Stopwatch.GetTimestamp();
|
||||||
|
if (remaining <= 0)
|
||||||
|
return;
|
||||||
|
if (remaining > SpinThresholdTicks)
|
||||||
|
Thread.Sleep(1);
|
||||||
|
else
|
||||||
|
Thread.SpinWait(64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("winmm.dll")]
|
||||||
|
private static extern uint timeBeginPeriod(uint uMilliseconds);
|
||||||
|
|
||||||
|
[DllImport("winmm.dll")]
|
||||||
|
private static extern uint timeEndPeriod(uint uMilliseconds);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_device.Transmit -= Write;
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="System.Memory" Version="4.5.5" />
|
<PackageReference Include="System.Memory" Version="4.5.5" />
|
||||||
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
|
|
||||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
@@ -0,0 +1,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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user