Files
VRIO/tests/VPlasma.Core.Tests/VPlasmaDeviceTests.cs
T
CydandClaude Opus 4.8 49e88fbe20 vPLASMA: fold in the real ROM fonts and firmware-confirmed commands
Replaces the guessed font/cursor model with what the firmware dump proved:

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:39:07 -05:00

325 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using VPlasma.Core.Device;
using Xunit;
namespace VPlasma.Core.Tests;
public class VPlasmaDeviceTests
{
private const byte Esc = 0x1B;
private static void Feed(VPlasmaDevice device, params byte[] bytes)
=> device.OnReceived(bytes, bytes.Length);
private static void Feed(VPlasmaDevice device, IEnumerable<byte> bytes)
{
byte[] arr = bytes.ToArray();
device.OnReceived(arr, arr.Length);
}
private static byte Pixel(VPlasmaDevice device, int x, int y)
{
var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
device.CopyFrame(frame);
return frame[y * VPlasmaDevice.Width + x];
}
/// <summary>A full-width ESC P row the way L4PLASMA.CPP sends one.</summary>
private static byte[] GraphicsRow(int y, params byte[] data)
{
var row = new List<byte> { Esc, (byte)'P', 0, (byte)y, 0, (byte)data.Length, 1 };
row.AddRange(data);
return row.ToArray();
}
// ---- graphics writes -------------------------------------------------
[Fact]
public void GraphicsRow_SetsPixelsMsbFirst()
{
var device = new VPlasmaDevice();
Feed(device, GraphicsRow(5, 0x80, 0x01)); // xbyte 0..1
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 5)); // MSB of byte 0
Assert.Equal(0, Pixel(device, 1, 5));
Assert.Equal(0, Pixel(device, 14, 5));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 15, 5)); // LSB of byte 1
Assert.Equal(0, Pixel(device, 0, 4));
Assert.Equal(0, Pixel(device, 0, 6));
Assert.Equal(1, device.GraphicsRows);
}
[Fact]
public void GraphicsRow_SurvivesAnyChunkBoundary()
{
byte[] wire = GraphicsRow(3, Enumerable.Repeat((byte)0xFF, 16).ToArray());
for (int split = 1; split < wire.Length; ++split)
{
var device = new VPlasmaDevice();
device.OnReceived(wire, split);
byte[] rest = wire.Skip(split).ToArray();
device.OnReceived(rest, rest.Length);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 3));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 127, 3));
Assert.Equal(1, device.GraphicsRows);
}
}
[Fact]
public void GraphicsBlock_MultipleRowsAdvanceY()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'P', 0, 10, 0, 1, 3, 0x80, 0x80, 0x80);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 10));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 11));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 12));
Assert.Equal(0, Pixel(device, 0, 13));
Assert.Equal(3, device.GraphicsRows);
}
[Fact]
public void GraphicsWrite_HonorsByteColumnOffset()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'P', 0, 0, 2, 1, 1, 0xFF); // xbyte=2 → x 16..23
Assert.Equal(0, Pixel(device, 15, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 16, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 23, 0));
Assert.Equal(0, Pixel(device, 24, 0));
}
[Fact]
public void GraphicsWrite_OutOfRangeRowIsConsumedNotDrawn()
{
var device = new VPlasmaDevice();
Feed(device, GraphicsRow(40, Enumerable.Repeat((byte)0xFF, 16).ToArray()));
Feed(device, (byte)'H'); // parser must be back in text mode
Assert.Equal(1, device.TextCharsDrawn);
Assert.Equal(0, device.GraphicsRows);
}
[Fact]
public void GraphicsWrite_OverwritesTextAttributes()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'H', 8); // flashing text
Feed(device, (byte)'H'); // font-0 'H' lights (0,0)
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelFlash, Pixel(device, 0, 0));
Feed(device, GraphicsRow(0, 0x80)); // repaint that row from the wire
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
}
// ---- text mode (real font 0 = 6×8) -----------------------------------
[Fact]
public void Text_DrawsRealGlyphAndAdvancesByFontWidth()
{
var device = new VPlasmaDevice();
Feed(device, (byte)'H'); // font-0 'H': row0 = pixels at x0 and x4
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 4, 0));
Assert.Equal(0, Pixel(device, 1, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 3)); // crossbar row
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 4, 3));
Assert.Equal(0, Pixel(device, 0, 7)); // blank bottom row
Assert.Equal(6, device.CursorX); // advanced one 6-px cell
Assert.Equal(0, device.CursorY);
Assert.Equal(1, device.TextCharsDrawn);
}
[Fact]
public void ControlChars_MoveCursorByPixels()
{
var device = new VPlasmaDevice(); // font 0: 6 wide, 8 tall
Feed(device, 0x09, 0x09, 0x09); // HT ×3 → x = 18
Assert.Equal(18, device.CursorX);
Feed(device, 0x08); // BS → x = 12
Assert.Equal(12, device.CursorX);
Feed(device, 0x0A); // LF → y = 8
Assert.Equal(8, device.CursorY);
Assert.Equal(0, device.CursorX);
Feed(device, (byte)'H', 0x0D); // draw + CR → x back to 0
Assert.Equal(0, device.CursorX);
Feed(device, 0x0B); // VT → y back to 0
Assert.Equal(0, device.CursorY);
}
[Fact]
public void EscQ_EscR_SetPixelCursor()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'Q', 20); // set row Y=20
Feed(device, Esc, (byte)'R', 37); // set col X=37
Assert.Equal(37, device.CursorX);
Assert.Equal(20, device.CursorY);
Feed(device, Esc, (byte)'Q', 40); // out of range (>31): ignored
Assert.Equal(20, device.CursorY);
Feed(device, Esc, (byte)'R', 200); // out of range (>127): ignored
Assert.Equal(37, device.CursorX);
}
[Fact]
public void Text_WrapsAtRightAndBottom()
{
var device = new VPlasmaDevice(); // 21 cells of 6px per line, 4 lines
Feed(device, Enumerable.Repeat((byte)'X', 21)); // fills first line, wraps
Assert.Equal(0, device.CursorX);
Assert.Equal(8, device.CursorY);
Feed(device, Enumerable.Repeat((byte)'X', 21 * 3)); // to the end, wraps to top
Assert.Equal(0, device.CursorX);
Assert.Equal(0, device.CursorY);
}
[Fact]
public void EscAt_ClearsScreenAndResetsTextState()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'K', 4, Esc, (byte)'H', 1, (byte)'H');
Feed(device, Esc, (byte)'@');
Assert.Equal(0, Pixel(device, 0, 0));
Assert.Equal(0, device.CursorX);
Assert.Equal(0, device.CursorY);
Assert.Equal(0, device.Font);
Assert.Equal(PlasmaAttributes.None, device.Attributes);
}
[Fact]
public void EscL_HomesCursorWithoutClearing()
{
var device = new VPlasmaDevice();
Feed(device, (byte)'H', Esc, (byte)'L');
Assert.Equal(0, device.CursorX);
Assert.Equal(0, device.CursorY);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0)); // glyph survives
}
[Theory]
[InlineData(0x00, PlasmaCursorMode.Hidden)] // the game's cursor-off
[InlineData(0xFF, PlasmaCursorMode.Hidden)] // the test tool's hide
[InlineData(0x01, PlasmaCursorMode.Steady)]
[InlineData(0x03, PlasmaCursorMode.Flashing)]
public void EscG_SetsCursorMode(byte operand, PlasmaCursorMode expected)
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'G', operand);
Assert.Equal(expected, device.CursorMode);
}
[Fact]
public void EscK_SelectsRealFonts()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'K', 4); // large font: 12×16
Assert.Equal(4, device.Font);
Assert.Equal(12, device.FontWidth);
Assert.Equal(16, device.FontHeight);
Feed(device, Esc, (byte)'K', 0); // back to font 0: 6×8
Assert.Equal(0, device.Font);
Assert.Equal(6, device.FontWidth);
Assert.Equal(8, device.FontHeight);
Feed(device, Esc, (byte)'K', 0xFF); // out of range: firmware ignores it
Assert.Equal(0, device.Font);
}
[Fact]
public void EscH_AppliesAttributesAsLowFourBits()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'H', 1, (byte)'H'); // half intensity
Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelHalf, Pixel(device, 0, 0));
Feed(device, Esc, (byte)'L', Esc, (byte)'@'); // clear
Feed(device, Esc, (byte)'H', 2, (byte)'H'); // underline: bottom row (y=7) lit
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 7));
Feed(device, Esc, (byte)'@');
Feed(device, Esc, (byte)'H', 4, (byte)' '); // reverse: a space renders solid
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 5, 7));
Feed(device, Esc, (byte)'H', 0); // defaults restored
Assert.Equal(PlasmaAttributes.None, device.Attributes);
}
// ---- robustness ----------------------------------------------------------
[Fact]
public void UnknownEscape_IsConsumedAndTextResumes()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'\\', (byte)'H'); // ESC '\' is not a command
Assert.Equal(1, device.TextCharsDrawn);
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
}
[Fact]
public void GameStartupSequence_HidesCursor()
{
var device = new VPlasmaDevice();
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode); // power-on default
Feed(device, 27, (byte)'G', 0x00); // L4PLASMA.CPP's boot byte
Assert.Equal(PlasmaCursorMode.Hidden, device.CursorMode);
}
[Fact]
public void SelfTestPages_ParseWithoutUnknownCommands()
{
for (int page = 0; page < PlasmaSelfTest.PageCount; ++page)
{
var device = new VPlasmaDevice();
var complaints = new List<string>();
device.Logged += line =>
{
if (line.StartsWith("Unknown", StringComparison.Ordinal)
|| line.StartsWith("Unhandled", StringComparison.Ordinal))
complaints.Add(line);
};
byte[] bytes = PlasmaSelfTest.BuildPage(page);
device.OnReceived(bytes, bytes.Length);
Assert.Empty(complaints);
Assert.True(device.BytesReceived > 0);
}
}
[Fact]
public void Reset_RestoresPowerOnState()
{
var device = new VPlasmaDevice();
Feed(device, Esc, (byte)'K', 5, Esc, (byte)'H', 4, Esc, (byte)'G', 0, (byte)'H');
device.Reset();
Assert.Equal(0, Pixel(device, 0, 0));
Assert.Equal(0, device.Font);
Assert.Equal(PlasmaAttributes.None, device.Attributes);
Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode);
Assert.Equal(0, device.CursorX);
Assert.Equal(0, device.CursorY);
}
}