- Connection: drop the hardwired COM12; replicate vRIO's endpoint picker — a combo of pipe:vplasma (the DOSBox-X namedpipe backend) + the COM ports, with Rescan and Open/Close. Nothing opens automatically; baud straps drive a COM open. - Demo (jumper 6) now runs the REAL firmware demonstration, not the vPLASMA self-test. The 10 demo screens are extracted verbatim from the ROM demo pointer table ($8000) into PlasmaFirmwareDemo.cs and looped through the parser. Added ESC I / ESC i (draw/display page select) as 1-operand commands — consumed but not acted on in the single-page model — so the demo's page commands don't desync the stream. - Orientation (jumper 4 / PD5) polarity fixed: unstrapped = horizontal 128x32 (the normal cockpit setup, now the default), installed = vertical. Verified: 29 unit tests pass (2 new: demo replay, page-select operand); the real demo screens render with the correct text/positioning/fonts, and the picker lists pipe:vplasma + COM ports with no auto-open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
399 lines
14 KiB
C#
399 lines
14 KiB
C#
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);
|
||
}
|
||
}
|
||
|
||
// ---- orientation + test pattern (JP1 jumpers 4 & 5) --------------------
|
||
|
||
[Fact]
|
||
public void Orientation_VerticalRotatesLogicalOntoPhysicalGlass()
|
||
{
|
||
var device = new VPlasmaDevice { Orientation = PlasmaOrientation.Vertical };
|
||
Assert.Equal(32, device.LogicalWidth);
|
||
Assert.Equal(128, device.LogicalHeight);
|
||
|
||
// Logical (0,0) maps to physical (px=ly=0, py=Height-1-lx=31).
|
||
Feed(device, Esc, (byte)'R', 0, Esc, (byte)'Q', 0, (byte)'H');
|
||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 31)); // 'H' top-left dot
|
||
Assert.Equal(0, Pixel(device, 0, 0));
|
||
|
||
// ESC Q (row Y) now accepts up to the 128-tall logical height;
|
||
// ESC R (column X) is bounded by the 32-wide logical width.
|
||
Feed(device, Esc, (byte)'Q', 100);
|
||
Assert.Equal(100, device.CursorY);
|
||
Feed(device, Esc, (byte)'R', 40); // out of range for 32-wide vertical
|
||
Assert.NotEqual(40, device.CursorX);
|
||
}
|
||
|
||
[Fact]
|
||
public void Orientation_ChangeClearsAndHomes()
|
||
{
|
||
var device = new VPlasmaDevice();
|
||
Feed(device, (byte)'H');
|
||
Assert.NotEqual(0, Pixel(device, 0, 0));
|
||
|
||
device.Orientation = PlasmaOrientation.Vertical;
|
||
Assert.Equal(0, Pixel(device, 0, 0)); // cleared
|
||
Assert.Equal(0, device.CursorX);
|
||
Assert.Equal(0, device.CursorY);
|
||
}
|
||
|
||
[Fact]
|
||
public void ShowTestPattern_LightsEveryDot()
|
||
{
|
||
var device = new VPlasmaDevice();
|
||
device.ShowTestPattern();
|
||
|
||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 127, 31));
|
||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 64, 16));
|
||
|
||
device.Reset();
|
||
Assert.Equal(0, Pixel(device, 64, 16));
|
||
}
|
||
|
||
[Fact]
|
||
public void FirmwareDemo_ReplaysAllScreensAndDrawsText()
|
||
{
|
||
Assert.Equal(10, PlasmaFirmwareDemo.Count);
|
||
var device = new VPlasmaDevice();
|
||
foreach (byte[] screen in PlasmaFirmwareDemo.Screens)
|
||
{
|
||
Assert.NotEmpty(screen);
|
||
device.OnReceived(screen, screen.Length);
|
||
}
|
||
// The 10-screen demo puts plenty of real text on the glass.
|
||
Assert.True(device.TextCharsDrawn > 0);
|
||
}
|
||
|
||
[Fact]
|
||
public void PageSelect_ConsumesItsOperand()
|
||
{
|
||
var device = new VPlasmaDevice();
|
||
// ESC I 2 (draw page) must swallow the '2'; then 'H' is the first glyph.
|
||
Feed(device, Esc, (byte)'I', 2, Esc, (byte)'i', 3, (byte)'H');
|
||
Assert.Equal(1, device.TextCharsDrawn);
|
||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||
Assert.Equal(6, device.CursorX); // advanced exactly one cell (no stray chars)
|
||
}
|
||
|
||
[Fact]
|
||
public void Reset_RestoresPowerOnState()
|
||
{
|
||
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);
|
||
}
|
||
}
|