PlasmaNew/replica: Matrix Portal S3 firmware for the hardware replica

The hardware replacement for the failing Babcock PD01D221: an Adafruit Matrix
Portal S3 (ESP32-S3) reads the game's serial command stream over its native
USB-CDC port and renders it to two chained Adafruit 64x32 HUB75 RGB panels
(= 128x32) in neon-orange, via Adafruit Protomatter.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-16 20:22:06 -05:00
co-authored by Claude Opus 4.8
parent 79eb53253e
commit ab36cdd826
6 changed files with 1648 additions and 0 deletions
@@ -0,0 +1,156 @@
// MatrixPortalPlasma — a hardware replica of the Babcock PD01D221 cockpit
// plasma display, for the Adafruit Matrix Portal S3 driving two chained
// Adafruit 64x32 HUB75 RGB panels (= 128x32).
//
// It enumerates as a USB CDC serial port; point the game's plasma output at it
// (DOSBox-X: serial2=directserial realport:COMx) and it speaks the device side
// of the PD01D221 protocol, rendered in neon-orange to mimic the plasma. The
// command parser + fonts are ported verbatim from vRIO's vPLASMA emulator
// (the reference oracle) — see PlasmaNew/FIRMWARE.md.
//
// Onboard buttons (no host needed): UP = run the built-in firmware demo,
// DOWN = panel test pattern (all dots). BOOT/reset re-runs the power-on splash.
//
// Libraries: Adafruit Protomatter (+ Adafruit GFX). Board: "Adafruit Matrix
// Portal S3" (ESP32-S3). USB CDC On Boot: Enabled.
#include <Adafruit_Protomatter.h>
#include "PlasmaDisplay.h"
#include "demo_screens.h"
// ---- HUB75 pins for the Adafruit Matrix Portal S3 ------------------------
// These are Adafruit's published Matrix Portal S3 pins (Protomatter examples).
// Verify against your installed library version if the panel misbehaves.
static uint8_t rgbPins[] = {42, 41, 40, 38, 39, 37};
static uint8_t addrPins[] = {45, 36, 48, 35}; // A,B,C,D — 4 lines for 32-high (1/16 scan)
static uint8_t clockPin = 2;
static uint8_t latchPin = 47;
static uint8_t oePin = 14;
// 128 wide (two 64-wide panels chained), 4-bit color, one chain, double-buffered.
Adafruit_Protomatter matrix(128, 4, 1, rgbPins, 4, addrPins,
clockPin, latchPin, oePin, true);
PlasmaDisplay display;
// Plasma-orange palette (RGB565). Full-intensity, half-intensity, off.
static uint16_t COLOR_LIT, COLOR_HALF;
// Blink phase for flashing text / cursor (~3.8 Hz, matched to vPLASMA's 266 ms).
static const uint32_t BLINK_MS = 266;
static uint32_t lastBlink = 0;
static bool blinkPhase = true;
// Demo playback (UP button): loop the 10 firmware screens.
static bool demoRunning = false;
static int demoScreen = 0;
static uint32_t lastDemoStep = 0;
static const uint32_t DEMO_MS = 2800;
// ---- helpers -------------------------------------------------------------
static bool frameHasFlash = false;
static void renderFrame() {
const uint8_t *px = display.frame();
bool anyFlash = false;
for (int y = 0; y < PlasmaDisplay::HEIGHT; ++y) {
for (int x = 0; x < PlasmaDisplay::WIDTH; ++x) {
uint8_t dot = px[y * PlasmaDisplay::WIDTH + x];
uint16_t color = 0;
if (dot & PlasmaDisplay::PIX_LIT) {
if (dot & PlasmaDisplay::PIX_FLASH) {
anyFlash = true;
color = blinkPhase ? ((dot & PlasmaDisplay::PIX_HALF) ? COLOR_HALF : COLOR_LIT) : 0;
} else {
color = (dot & PlasmaDisplay::PIX_HALF) ? COLOR_HALF : COLOR_LIT;
}
}
matrix.drawPixel(x, y, color);
}
}
matrix.show();
frameHasFlash = anyFlash;
}
static void feedDemoScreen(int i) {
const PlasmaDemoScreen &s = plasmaDemo[i];
for (uint16_t j = 0; j < s.len; ++j)
display.feed(pgm_read_byte(&s.data[j]));
}
// ---- Arduino ------------------------------------------------------------
void setup() {
Serial.begin(9600); // USB CDC — the baud is cosmetic over USB
ProtomatterStatus status = matrix.begin();
// If begin() fails the wiring/pins are wrong; blink the onboard LED forever.
if (status != PROTOMATTER_OK) {
pinMode(LED_BUILTIN, OUTPUT);
for (;;) { digitalWrite(LED_BUILTIN, HIGH); delay(120); digitalWrite(LED_BUILTIN, LOW); delay(120); }
}
COLOR_LIT = matrix.color565(255, 96, 0); // neon orange
COLOR_HALF = matrix.color565(110, 40, 0); // dim orange
#ifdef BUTTON_UP
pinMode(BUTTON_UP, INPUT_PULLUP);
#endif
#ifdef BUTTON_DOWN
pinMode(BUTTON_DOWN, INPUT_PULLUP);
#endif
// Power-on splash: light every dot for ~1 s (confirms both panels), then clear.
display.showTestPattern();
renderFrame();
delay(1000);
display.reset();
renderFrame();
}
void loop() {
// 1) Drain the USB serial into the parser.
while (Serial.available() > 0)
display.feed((uint8_t)Serial.read());
// 2) Buttons (edge-detected).
#ifdef BUTTON_UP
static bool upPrev = HIGH;
bool up = digitalRead(BUTTON_UP);
if (upPrev == HIGH && up == LOW) { // pressed
demoRunning = !demoRunning;
if (demoRunning) { demoScreen = 0; feedDemoScreen(0); demoScreen = 1; lastDemoStep = millis(); }
else { display.reset(); }
}
upPrev = up;
#endif
#ifdef BUTTON_DOWN
static bool downPrev = HIGH;
bool down = digitalRead(BUTTON_DOWN);
if (downPrev == HIGH && down == LOW) { // pressed
demoRunning = false;
display.showTestPattern();
}
downPrev = down;
#endif
// 3) Advance the demo on its timer.
uint32_t now = millis();
if (demoRunning && now - lastDemoStep >= DEMO_MS) {
feedDemoScreen(demoScreen);
demoScreen = (demoScreen + 1) % plasmaDemoCount;
lastDemoStep = now;
}
// 4) Blink phase for flashing pixels.
if (now - lastBlink >= BLINK_MS) {
blinkPhase = !blinkPhase;
lastBlink = now;
if (frameHasFlash) renderFrame(); // only re-render if something blinks
}
// 5) Repaint when the parser changed the frame.
if (display.takeDirty())
renderFrame();
}
@@ -0,0 +1,208 @@
#include "PlasmaDisplay.h"
// Command bytes (see PlasmaProtocol.cs / FIRMWARE.md).
static const uint8_t ESC = 0x1B;
static const uint8_t BS = 0x08, HT = 0x09, LF = 0x0A, VT = 0x0B, CR = 0x0D;
static const uint8_t CMD_CLEAR = '@', CMD_CURSOR = 'G', CMD_ATTR = 'H';
static const uint8_t CMD_FONT = 'K', CMD_HOME = 'L', CMD_GRAPHICS = 'P';
static const uint8_t CMD_SETROW = 'Q', CMD_SETCOL = 'R';
static const uint8_t CMD_DRAWPAGE = 'I', CMD_DISPPAGE = 'i';
PlasmaDisplay::PlasmaDisplay() { reset(); }
void PlasmaDisplay::reset() {
memset(pixels_, 0, sizeof(pixels_));
cx_ = cy_ = 0;
font_ = 0;
face_ = &plasmaFonts[0];
attrs_ = 0;
cursorMode_ = CURSOR_STEADY; // power-on default; the game hides it
orient_ = HORIZONTAL;
state_ = TEXT;
headerFill_ = 0;
dataIndex_ = dataLength_ = 0;
dirty_ = true;
}
void PlasmaDisplay::setOrientation(Orientation o) {
if (orient_ == o) return;
orient_ = o;
memset(pixels_, 0, sizeof(pixels_));
cx_ = cy_ = 0;
dirty_ = true;
}
void PlasmaDisplay::showTestPattern() {
for (int i = 0; i < WIDTH * HEIGHT; ++i) pixels_[i] = PIX_LIT;
dirty_ = true;
}
// One logical dot → the physical 128x32 buffer, rotated per orientation.
void PlasmaDisplay::plot(int lx, int ly, uint8_t flags) {
if ((unsigned)lx >= (unsigned)logicalW() || (unsigned)ly >= (unsigned)logicalH()) return;
int px, py;
if (orient_ == HORIZONTAL) {
px = lx;
py = ly;
} else {
px = ly;
py = HEIGHT - 1 - lx; // 90° rotation onto landscape glass
}
pixels_[py * WIDTH + px] = flags;
}
void PlasmaDisplay::feed(const uint8_t *buf, size_t n) {
for (size_t i = 0; i < n; ++i) feed(buf[i]);
}
void PlasmaDisplay::feed(uint8_t b) {
switch (state_) {
case TEXT: stepText(b); break;
case ESCAPE: stepEscape(b); break;
case OPERAND:
state_ = TEXT;
applyOperand(pendingCmd_, b);
break;
case GHEADER:
header_[headerFill_++] = b;
if (headerFill_ == 5) beginGraphics();
break;
case GDATA: stepGraphics(b); break;
}
}
void PlasmaDisplay::stepText(uint8_t b) {
switch (b) {
case ESC: state_ = ESCAPE; return;
case BS: cx_ = max(0, cx_ - face_->width); dirty_ = true; return;
case HT: advanceCursor(); dirty_ = true; return;
case LF: nextLine(); dirty_ = true; return;
case VT:
cy_ -= face_->height;
if (cy_ < 0) cy_ = max(0, logicalH() - face_->height);
dirty_ = true;
return;
case CR: cx_ = 0; dirty_ = true; return;
}
if (b < 0x20) return; // any other control byte: swallow (matches firmware)
drawChar(b);
}
void PlasmaDisplay::stepEscape(uint8_t b) {
state_ = TEXT;
switch (b) {
case CMD_CLEAR:
memset(pixels_, 0, sizeof(pixels_));
cx_ = cy_ = 0;
font_ = 0;
face_ = &plasmaFonts[0];
attrs_ = 0;
dirty_ = true;
break;
case CMD_HOME:
cx_ = cy_ = 0;
dirty_ = true;
break;
case CMD_CURSOR:
case CMD_FONT:
case CMD_ATTR:
case CMD_SETROW:
case CMD_SETCOL:
case CMD_DRAWPAGE:
case CMD_DISPPAGE:
pendingCmd_ = b;
state_ = OPERAND;
break;
case CMD_GRAPHICS:
headerFill_ = 0;
state_ = GHEADER;
break;
default: break; // unknown command: ignored (does not consume an operand)
}
}
void PlasmaDisplay::applyOperand(uint8_t cmd, uint8_t op) {
switch (cmd) {
case CMD_CURSOR:
cursorMode_ = (op == 0x00 || op == 0xFF) ? CURSOR_HIDDEN
: (op & 0x02) ? CURSOR_FLASHING
: CURSOR_STEADY;
dirty_ = true;
break;
case CMD_FONT:
if (op < 8) { // 8 real fonts; firmware ignores larger operands
font_ = op;
face_ = &plasmaFonts[op];
if (cx_ > logicalW() - 1) cx_ = logicalW() - 1;
if (cy_ > logicalH() - 1) cy_ = logicalH() - 1;
dirty_ = true;
}
break;
case CMD_ATTR:
attrs_ = op & 0x0F; // low 4 bits: half/underline/reverse/flash
break;
case CMD_SETROW:
if (op < logicalH()) { cy_ = op; dirty_ = true; }
break;
case CMD_SETCOL:
if (op < logicalW()) { cx_ = op; dirty_ = true; }
break;
case CMD_DRAWPAGE:
case CMD_DISPPAGE:
break; // page select: consumed but single-page (see FIRMWARE.md)
}
}
void PlasmaDisplay::beginGraphics() {
int w = header_[3], h = header_[4];
dataLength_ = w * h;
dataIndex_ = 0;
state_ = dataLength_ > 0 ? GDATA : TEXT;
}
void PlasmaDisplay::stepGraphics(uint8_t b) {
int w = header_[3];
int rowOfBlock = dataIndex_ / w;
int byteOfRow = dataIndex_ % w;
int y = header_[1] + rowOfBlock;
int baseX = (header_[2] + byteOfRow) * 8;
// MSB = leftmost pixel (L4PLASMA.CPP packs 0x80 first).
for (int bit = 0; bit < 8; ++bit)
plot(baseX + bit, y, (b & (0x80 >> bit)) ? PIX_LIT : 0);
dirty_ = true;
if (++dataIndex_ >= dataLength_) state_ = TEXT;
}
void PlasmaDisplay::drawChar(uint8_t code) {
if (!faceHas(code)) return; // firmware ignores out-of-range chars
int w = face_->width, h = face_->height;
bool reverse = attrs_ & 0x04;
bool underline = attrs_ & 0x02;
uint8_t litFlags = PIX_LIT;
if (attrs_ & 0x01) litFlags |= PIX_HALF;
if (attrs_ & 0x08) litFlags |= PIX_FLASH;
for (int row = 0; row < h; ++row) {
uint16_t bits = faceRow(code, row); // bit15 = leftmost pixel
for (int col = 0; col < w; ++col) {
bool on = bits & (0x8000 >> col);
if (underline && row == h - 1) on = true;
if (reverse) on = !on;
plot(cx_ + col, cy_ + row, on ? litFlags : 0);
}
}
dirty_ = true;
advanceCursor();
}
void PlasmaDisplay::advanceCursor() {
cx_ += face_->width;
if (cx_ > logicalW() - face_->width) nextLine();
}
void PlasmaDisplay::nextLine() {
cx_ = 0;
cy_ += face_->height;
if (cy_ > logicalH() - face_->height) cy_ = 0; // wrap to top; no scroll
}
@@ -0,0 +1,87 @@
// PlasmaDisplay — the Babcock PD01D221 command parser + 128x32 framebuffer,
// ported line-for-line from vRIO's C# VPlasmaDevice (src/VPlasma.Core/Device/
// VPlasmaDevice.cs). Keep the two in sync: vPLASMA is the reference oracle.
//
// Feed received wire bytes to feed(); read the framebuffer with frame() and
// render it however the panel wants. Each pixel is a flag byte (PIX_LIT /
// PIX_HALF / PIX_FLASH), so the renderer can dim half-intensity dots and blink
// flashing ones. See PlasmaNew/FIRMWARE.md for the recovered command set.
#pragma once
#include <Arduino.h>
#include "plasma_fonts.h"
class PlasmaDisplay {
public:
static const int WIDTH = 128;
static const int HEIGHT = 32;
// Per-pixel flag bits in the framebuffer.
static const uint8_t PIX_LIT = 0x01;
static const uint8_t PIX_HALF = 0x02;
static const uint8_t PIX_FLASH = 0x04;
enum Orientation { HORIZONTAL, VERTICAL };
enum CursorMode { CURSOR_HIDDEN, CURSOR_STEADY, CURSOR_FLASHING };
PlasmaDisplay();
void reset(); // power-on: dark glass, home cursor
void feed(uint8_t b); // one received wire byte
void feed(const uint8_t *buf, size_t n);
void showTestPattern(); // all dots lit (JP1 jumper 5)
void setOrientation(Orientation o); // JP1 jumper 4
const uint8_t *frame() const { return pixels_; }
bool takeDirty() { // true (and clears) if the frame changed
bool d = dirty_;
dirty_ = false;
return d;
}
CursorMode cursorMode() const { return cursorMode_; }
int cursorX() const { return cx_; }
int cursorY() const { return cy_; }
int fontWidth() const { return face_->width; }
int fontHeight() const { return face_->height; }
Orientation orientation() const { return orient_; }
private:
uint8_t pixels_[WIDTH * HEIGHT]; // always physical 128x32
// Text-mode state.
int font_;
const PlasmaFace *face_;
uint8_t attrs_; // low 4 bits: 1=half 2=underline 4=reverse 8=flash
int cx_, cy_; // cursor, logical pixels
CursorMode cursorMode_;
Orientation orient_;
// Parser state.
enum State { TEXT, ESCAPE, OPERAND, GHEADER, GDATA };
State state_;
uint8_t pendingCmd_;
uint8_t header_[5]; // screen, y, x, w, h
int headerFill_;
int dataIndex_, dataLength_;
bool dirty_;
int logicalW() const { return orient_ == HORIZONTAL ? WIDTH : HEIGHT; }
int logicalH() const { return orient_ == HORIZONTAL ? HEIGHT : WIDTH; }
void plot(int lx, int ly, uint8_t flags);
void stepText(uint8_t b);
void stepEscape(uint8_t b);
void applyOperand(uint8_t cmd, uint8_t op);
void beginGraphics();
void stepGraphics(uint8_t b);
void drawChar(uint8_t code);
void advanceCursor();
void nextLine();
bool faceHas(uint8_t code) const { return code >= face_->first && code <= face_->last; }
uint16_t faceRow(uint8_t code, int row) const {
if (!faceHas(code) || (unsigned)row >= (unsigned)face_->height) return 0;
return pgm_read_word(&face_->rows[(code - face_->first) * face_->height + row]);
}
};
@@ -0,0 +1,88 @@
// AUTO-GENERATED from the PD01D221 firmware (tms27pc512.BIN): the real
// 10-screen demonstration program (the display's built-in demo, JP1
// jumper 6), extracted from the demo pointer table at $8000. In PROGMEM.
#pragma once
#include <Arduino.h>
static const uint8_t PROGMEM plasmaDemo0[] = {
0x1B,0x47,0x00,0x1B,0x40,0x1B,0x4C,0x1B,0x52,0x04,0x59,0x4F,0x55,0x20,0x48,0x41,
0x56,0x45,0x20,0x45,0x4E,0x41,0x42,0x4C,0x45,0x44,0x20,0x54,0x48,0x45,0x1B,0x52,
0x25,0x50,0x44,0x30,0x31,0x2D,0x44,0x32,0x32,0x31,0x0A,0x1B,0x52,0x01,0x44,0x45,
0x4D,0x4F,0x4E,0x53,0x54,0x52,0x41,0x54,0x49,0x4F,0x4E,0x20,0x50,0x52,0x4F,0x47,
0x52,0x41,0x4D
};
static const uint8_t PROGMEM plasmaDemo1[] = {
0x1B,0x49,0x02,0x1B,0x40,0x1B,0x4C,0x1B,0x52,0x02,0x49,0x46,0x20,0x59,0x4F,0x55,
0x20,0x44,0x4F,0x20,0x4E,0x4F,0x54,0x20,0x57,0x49,0x53,0x48,0x20,0x54,0x4F,0x1B,
0x52,0x0A,0x56,0x49,0x45,0x57,0x20,0x54,0x48,0x49,0x53,0x20,0x50,0x52,0x4F,0x47,
0x52,0x41,0x4D,0x2C,0x0A,0x1B,0x52,0x07,0x52,0x45,0x4D,0x4F,0x56,0x45,0x20,0x4A,
0x55,0x4D,0x50,0x45,0x52,0x20,0x36,0x20,0x41,0x4E,0x44,0x0A,0x1B,0x52,0x0D,0x52,
0x45,0x53,0x45,0x54,0x20,0x54,0x48,0x45,0x20,0x44,0x49,0x53,0x50,0x4C,0x41,0x59,
0x1B,0x69,0x02
};
static const uint8_t PROGMEM plasmaDemo2[] = {
0x1B,0x49,0x01,0x1B,0x40,0x1B,0x4B,0x04,0x1B,0x51,0x00,0x1B,0x52,0x0A,0x50,0x4C,
0x41,0x53,0x4D,0x41,0x44,0x4F,0x54,0x1B,0x52,0x0A,0x50,0x44,0x30,0x31,0x2D,0x44,
0x32,0x32,0x31,0x1B,0x69,0x01
};
static const uint8_t PROGMEM plasmaDemo3[] = {
0x1B,0x49,0x00,0x1B,0x40,0x1B,0x4B,0x06,0x1B,0x51,0x00,0x1B,0x52,0x1D,0x41,0x20,
0x43,0x4F,0x4D,0x50,0x4C,0x45,0x54,0x45,0x0A,0x1B,0x52,0x0F,0x44,0x49,0x53,0x50,
0x4C,0x41,0x59,0x20,0x53,0x59,0x53,0x54,0x45,0x4D,0x0A,0x1B,0x52,0x0C,0x49,0x4E,
0x20,0x4F,0x4E,0x45,0x20,0x50,0x41,0x43,0x4B,0x41,0x47,0x45,0x21,0x1B,0x69,0x00
};
static const uint8_t PROGMEM plasmaDemo4[] = {
0x1B,0x49,0x01,0x1B,0x40,0x1B,0x4B,0x06,0x1B,0x4C,0x46,0x45,0x41,0x54,0x55,0x52,
0x45,0x53,0x20,0x49,0x4E,0x43,0x4C,0x55,0x44,0x45,0x20,0x2D,0x1B,0x4B,0x03,0x1B,
0x51,0x0C,0x1B,0x52,0x0A,0x4F,0x1B,0x4B,0x02,0x20,0x53,0x45,0x52,0x49,0x41,0x4C,
0x20,0x49,0x4E,0x54,0x45,0x52,0x46,0x41,0x43,0x45,0x0A,0x1B,0x4B,0x03,0x1B,0x52,
0x0A,0x4F,0x1B,0x4B,0x02,0x20,0x50,0x41,0x52,0x41,0x4C,0x4C,0x45,0x4C,0x20,0x50,
0x4F,0x52,0x54,0x1B,0x69,0x01
};
static const uint8_t PROGMEM plasmaDemo5[] = {
0x1B,0x49,0x02,0x1B,0x40,0x1B,0x4B,0x06,0x1B,0x4C,0x46,0x45,0x41,0x54,0x55,0x52,
0x45,0x53,0x20,0x49,0x4E,0x43,0x4C,0x55,0x44,0x45,0x20,0x2D,0x1B,0x51,0x0C,0x1B,
0x4B,0x03,0x1B,0x52,0x0A,0x4F,0x1B,0x4B,0x02,0x20,0x50,0x4F,0x57,0x45,0x52,0x20,
0x53,0x55,0x50,0x50,0x4C,0x59,0x0A,0x1B,0x4B,0x03,0x1B,0x52,0x0A,0x4F,0x1B,0x4B,
0x02,0x20,0x4B,0x45,0x59,0x50,0x41,0x44,0x20,0x49,0x4E,0x54,0x45,0x52,0x46,0x41,
0x43,0x45,0x1B,0x69,0x02
};
static const uint8_t PROGMEM plasmaDemo6[] = {
0x1B,0x49,0x00,0x1B,0x40,0x1B,0x4B,0x02,0x1B,0x51,0x00,0x1B,0x52,0x00,0x4D,0x49,
0x58,0x20,0x54,0x45,0x58,0x54,0x20,0x41,0x4E,0x44,0x20,0x47,0x52,0x41,0x50,0x48,
0x49,0x43,0x53,0x1B,0x52,0x18,0x55,0x53,0x49,0x4E,0x47,0x20,0x38,0x20,0x53,0x54,
0x4F,0x52,0x45,0x44,0x0A,0x1B,0x52,0x0D,0x43,0x48,0x41,0x52,0x41,0x43,0x54,0x45,
0x52,0x20,0x46,0x4F,0x4E,0x54,0x53,0x20,0x2D,0x1B,0x69,0x00
};
static const uint8_t PROGMEM plasmaDemo7[] = {
0x1B,0x49,0x01,0x1B,0x40,0x1B,0x4B,0x02,0x1B,0x4C,0x49,0x4E,0x54,0x45,0x4C,0x4C,
0x49,0x47,0x45,0x4E,0x54,0x20,0x49,0x4E,0x54,0x45,0x52,0x46,0x41,0x43,0x45,0x1B,
0x52,0x19,0x48,0x41,0x53,0x20,0x44,0x4F,0x5A,0x45,0x4E,0x53,0x20,0x4F,0x46,0x0A,
0x1B,0x52,0x25,0x43,0x4F,0x4D,0x4D,0x41,0x4E,0x44,0x53,0x2E,0x1B,0x69,0x01
};
static const uint8_t PROGMEM plasmaDemo8[] = {
0x1B,0x49,0x02,0x1B,0x40,0x1B,0x4B,0x02,0x1B,0x4C,0x1B,0x52,0x19,0x43,0x48,0x4F,
0x4F,0x53,0x45,0x20,0x45,0x49,0x54,0x48,0x45,0x52,0x0A,0x1B,0x52,0x19,0x48,0x4F,
0x52,0x49,0x5A,0x4F,0x4E,0x54,0x41,0x4C,0x20,0x4F,0x52,0x0A,0x1B,0x52,0x04,0x56,
0x45,0x52,0x54,0x49,0x43,0x41,0x4C,0x20,0x4F,0x52,0x49,0x45,0x4E,0x54,0x41,0x54,
0x49,0x4F,0x4E,0x1B,0x69,0x02
};
static const uint8_t PROGMEM plasmaDemo9[] = {
0x1B,0x49,0x00,0x1B,0x40,0x1B,0x5A,0x00,0x00,0x00,0x00,0x1B,0x69,0x00
};
struct PlasmaDemoScreen { const uint8_t *data; uint16_t len; };
static const PlasmaDemoScreen plasmaDemo[10] = {
{ plasmaDemo0, 67 },
{ plasmaDemo1, 99 },
{ plasmaDemo2, 38 },
{ plasmaDemo3, 64 },
{ plasmaDemo4, 86 },
{ plasmaDemo5, 85 },
{ plasmaDemo6, 76 },
{ plasmaDemo7, 63 },
{ plasmaDemo8, 70 },
{ plasmaDemo9, 14 },
};
static const int plasmaDemoCount = 10;
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
# Plasma display replica — Adafruit Matrix Portal S3 + HUB75
A hardware replacement for the failing Babcock **PD01D221** cockpit plasma
display ([../README.md](../README.md), [../FIRMWARE.md](../FIRMWARE.md)). A
modern microcontroller reads the same serial command stream the game sends and
renders it to a modern LED matrix — a drop-in from the host's point of view,
with none of the plasma physics or high voltage.
The firmware's command parser and fonts are **ported verbatim from vRIO's
vPLASMA emulator** (`src/VPlasma.Core`), which is the reference oracle. Feed
the replica and vPLASMA the same byte stream and they produce the same frame.
## Bill of materials
| Part | Notes |
|------|-------|
| **Adafruit Matrix Portal S3** | ESP32-S3 controller; plugs into the HUB75 header; native USB-C = the virtual COM port |
| **2 × Adafruit 64×32 RGB LED Matrix** (P-pitch to taste; 1/16 scan) | chained → **128×32**, the panel's native resolution |
| **5 V power supply, ≥ 4 A** | the panels are the load; USB cannot power them |
| USB-C cable | data (and logic power) from the host PC |
An amber-leaning pitch and diffuser best mimic the neon-orange plasma; the
firmware already renders in orange (`255,96,0` full / `110,40,0` half).
## Wiring
1. **Chain the panels**: panel A `OUT` → panel B `IN`, left-to-right, so the
pair reads as one 128-wide canvas (x 063 = panel A, 64127 = panel B). If
the image comes out swapped, reverse the chain order.
2. **Mount the Matrix Portal S3** onto panel A's `IN` HUB75 header.
3. **Power**: 5 V ≥ 4 A into the Matrix Portal's screw terminals; run the
panels' power pigtails from the same 5 V. USB-C carries data (and powers the
S3 logic) — do **not** rely on USB for panel current.
## Build & flash
Arduino IDE (or `arduino-cli`):
1. **Boards Manager** → install **esp32** (Espressif). Select board **"Adafruit
Matrix Portal S3"**. Set **USB CDC On Boot: Enabled** (so `Serial` is the
USB port the game opens).
2. **Library Manager** → install **Adafruit Protomatter** (pulls in Adafruit
GFX / BusIO).
3. Open `MatrixPortalPlasma/MatrixPortalPlasma.ino` (keep `PlasmaDisplay.*`,
`plasma_fonts.h`, `demo_screens.h` beside it) and Upload.
The HUB75 pin arrays at the top of the `.ino` are Adafruit's published Matrix
Portal S3 values; if the panel garbles, verify them against your installed
Protomatter version.
## Using it with the game
The Matrix Portal enumerates as a **USB CDC COM port**. In Windows Device
Manager you can pin it to the COM number the host expects. Point the game's
plasma output at it — under the DOSBox-X fork:
```
serial2 = directserial realport:COMx
```
Baud is cosmetic over USB CDC (the `9600` line-coding is accepted as a no-op),
so the display keeps up regardless. The link is one-way (the game writes, the
display listens), exactly as the cockpit drove the real panel.
## No-host testing (onboard buttons)
- **UP** — toggle the built-in **firmware demonstration** (the real 10-screen
PLASMADOT demo, `demo_screens.h`, extracted from the ROM).
- **DOWN** — the **panel test pattern** (all dots lit; a dead-dot check).
- **Power-on** briefly lights every dot (confirms both panels), then clears.
## Files
| File | |
|------|--|
| `MatrixPortalPlasma/MatrixPortalPlasma.ino` | sketch: Protomatter init, USB serial, render loop, buttons |
| `MatrixPortalPlasma/PlasmaDisplay.h/.cpp` | the PD01D221 parser + 128×32 framebuffer — a C++ port of `VPlasmaDevice` |
| `MatrixPortalPlasma/plasma_fonts.h` | the 8 real ROM fonts (PROGMEM), generated from `tms27pc512.BIN` |
| `MatrixPortalPlasma/demo_screens.h` | the 10 firmware demo screens (PROGMEM) |
## Keeping it faithful
`PlasmaDisplay` is a line-for-line port of `VPlasmaDevice`; keep the two in
sync (same commands, fonts, orientation/`plot` mapping). To validate the
replica, run the **differential test**: send identical byte streams to the
hardware and to vPLASMA and compare the glass. The real panel still works, so
it can be the third reference.
Still deferred (as in vPLASMA, documented in `../FIRMWARE.md`): the 10
double-buffered pages (`ESC I`/`ESC i` are consumed but single-page) and the
vector-graphics primitives (`ESC A``F`).