// PluginRunner — loads a WMF plugin binary into emulated TSConfig memory,
// emulates WC kernel startup (P8000 dispatch), runs the Z80 CPU,
// and provides analysis/logging infrastructure.
//
// The test_data directory is used as disk root for file I/O emulation.
// VGM/VGZ files placed there can be loaded by the plugin as if they
// were on a real SD card.
using System;
using System.IO;
using System.Text;
using System.Collections.Generic;
using System.Linq;
using ZXMAK2.Engine.Cpu;
using ZXMAK2.Engine.Cpu.Processor;
namespace InflateDebugger
{
public class PluginRunner
{
// ── TSConfig page assignments ─────────────────────────────────
const byte PLGPG = 0x61; // plugin page base
const byte TVBPG = 0x20; // megabuffer base
const byte MTXPG = 0x00; // main text page
const byte SYS_PAGE = 0x05; // WC system / FAT engine page
const byte FONT_PAGE = 0x01; // font page
const byte RESB_PAGE = 0x1D; // window save buffer page
// Plugin page indices (relative; phys = PLGPG + idx)
const byte PLG_CODE_PAGE = 0; // page 0: CODE+DATA (0x8000–0xBFFF)
// Extra pages appended by build.bat:
// page 1,2: (unused in current build)
// page 3: freq_tables.bin
// page 4: inflate.bin
// page 5: cmdblocks.bin
// ── Ports ─────────────────────────────────────────────────────
const ushort PORT_BORDER = 0x00FE; // ZX border
const ushort PORT_TSCONF_BORDER = 0x0FAF;
// Debug ports (same as inflate debugger)
const ushort DBG_STATUS = 0xFFAF;
const ushort DBG_SRC = 0xFEAF;
const ushort DBG_DST = 0xFDAF;
// OPL3 ports
const ushort PORT_OPL3_ADDR0 = 0x0008; // OPL3 address bank 0
const ushort PORT_OPL3_DATA = 0x0009; // OPL3 data
const ushort PORT_OPL3_ADDR1 = 0x000A; // OPL3 address bank 1
// AY ports
const ushort PORT_AY_ADDR = 0xFFFD;
const ushort PORT_AY_DATA = 0xBFFD;
// INT position ports (TSConfig raster comparator)
const ushort PORT_HSINT = 0x22AF; // HCNT[8:1], 0–223
const ushort PORT_VSINTL = 0x23AF; // VCNT[7:0]
const ushort PORT_VSINTH = 0x24AF; // VCNT[8] (bit 0 only)
const ushort PORT_INTMASK = 0x2AAF;
// ── Configuration ─────────────────────────────────────────────
public long MaxCycles { get; set; } = 2_000_000_000; // 2B cycles
public long WatchdogLimit { get; set; } = 500_000_000; // 500M without WC call = hang
public bool VerboseWcCalls { get; set; } = true;
public bool VerbosePageSwitch { get; set; } = false;
public bool DumpOnExit { get; set; } = true;
public string DumpDir { get; set; }
public string DiskRootPath { get; set; } // test_data directory
// File to auto-open for the plugin (relative to DiskRootPath)
public string TestFileName { get; set; }
// ── Emulator state ────────────────────────────────────────────
public TsMemory Mem { get; private set; }
public CpuUnit Cpu { get; private set; }
public WcKernel Wc { get; private set; }
public CpuTrace Trace { get; private set; }
private bool _finished;
private long _lastWcCallTact;
private int _exitCode;
// ISR emulation state
private const ushort ISR_STUB_ADDR = 0x5BF4; // IM2 stub handler (EI+RETI)
private int _isrFireCount;
// ── Raster-based INT position emulation ──────────────────────
// TSConfig frame: 320 lines × 224 T-states/line = 71680 T at 3.5 MHz
// Raster runs at 3.5 MHz regardless of CPU turbo mode.
// We track raster in fixed-point: units = base_T × RASTER_SCALE.
// This keeps all arithmetic integer across turbo modes.
//
// Turbo multipliers (raster_advance = cpu_delta × mult):
// 3.5 MHz → mult=20 (20/20 = 1.0 raster T per CPU T)
// 7 MHz → mult=10 (10/20 = 0.5)
// 10 MHz → mult=7 ( 7/20 = 0.35, ≈14 MHz + wait states)
// 28 MHz → mult=5 ( 5/20 = 0.25)
private const int RASTER_SCALE = 20;
private const int LINE_TACTS = 224; // T per line at 3.5 MHz
private const int LINES_PER_FRAME = 320;
private const long FRAME_RASTER = (long)LINE_TACTS * LINES_PER_FRAME * RASTER_SCALE; // 1 433 600
private long _rasterPos; // current raster position (fixed-point)
private long _prevTact; // previous Cpu.Tact for delta calc
private int _rasterMult = RASTER_SCALE; // multiplier for current turbo
// Programmed INT position (written by ISR via OUTI to ports)
private int _intLine; // 9-bit VCNT: VSINTH[0]:VSINTL[7:0]
private int _intHPos; // 8-bit HCNT: HSINT
private long _intTargetRaster; // target in fixed-point units
private bool _intFired; // one-shot per target crossing
// CPU frequency / turbo mode
// SYSCONF[1:0]: 0=3.5, 1=7, 2=14, 3=28 MHz
private int _turboMode;
private static int GetRasterMult(int turbo) => turbo switch
{
0 => 20, // 3.5 MHz → 1:1
1 => 10, // 7 MHz → 2:1
2 => 7, // ~10 MHz (≈14 MHz + wait)
3 => 5, // 28 MHz → 4:1
_ => 20,
};
/// Recalculate INT target from current VSINT/HSINT values.
private void RecalcIntTarget()
{
long baseTact = (long)_intLine * LINE_TACTS + _intHPos;
_intTargetRaster = baseTact * RASTER_SCALE;
_intFired = false; // re-arm for new target
}
// OPL3 register tracking
private byte _opl3Addr0, _opl3Addr1;
private int _opl3WriteCount;
// Debug port state
private byte _lastDbgStatus;
// ── Function breakpoints (addresses from vgmplay.noi) ─────────
private static readonly Dictionary _breakpoints = new()
{
{ 0x8874, "load_vgm" },
{ 0xAEF5, "inflate_vgz" },
{ 0xAF68, "copy_pages_to_tap" },
{ 0x8DE6, "vgm_parse_header" },
{ 0x8AC1, "main" },
};
private readonly HashSet _hitBreakpoints = new();
private bool _postCptLogged;
private bool _inflateResultLogged;
private bool _parseHeaderResultLogged;
// ── megabuf page 0 write tracker (first 16 bytes) ─────────────
private struct MbWriteInfo { public byte Value; public long Cycle; public ushort Z80Addr; public ushort PC; }
private MbWriteInfo[] _mbPg0First = new MbWriteInfo[16];
private MbWriteInfo[] _mbPg0Last = new MbWriteInfo[16];
private MbWriteInfo[] _mbPg0Prev = new MbWriteInfo[16]; // second-to-last write
private int[] _mbPg0Cnt = new int[16];
// ── statistics ────────────────────────────────────────────────
public long TotalCycles => Cpu?.Tact ?? 0;
public int WcCallCount => Wc?.CallLog.Count ?? 0;
public int ViolationCount => Mem?.Violations.Count ?? 0;
// ── Run ───────────────────────────────────────────────────────
///
/// Load plugin binary and run it with WC emulation.
/// Returns exit code (0 = normal exit, >0 = error/abort).
///
public int Run(string wmfPath, string testFile = null)
{
Console.WriteLine("═══════════════════════════════════════════════════");
Console.WriteLine(" WC Plugin Runner — Full Emulation");
Console.WriteLine("═══════════════════════════════════════════════════");
// ── Setup ──────────────────────────────────
Mem = new TsMemory();
Cpu = new CpuUnit();
Wc = new WcKernel(Mem, Cpu);
Trace = new CpuTrace(2000);
// Wire memory write watcher for megabuf page 0, first 16 bytes
bool _corruptionTrapped = false;
Mem.OnWrite = (addr, page, offset, val) =>
{
if (page == TVBPG && offset < 16)
{
var info = new MbWriteInfo { Value = val, Cycle = Cpu.Tact, Z80Addr = addr, PC = Cpu.regs.PC };
if (_mbPg0Cnt[offset] == 0)
_mbPg0First[offset] = info;
_mbPg0Prev[offset] = _mbPg0Last[offset];
_mbPg0Last[offset] = info;
_mbPg0Cnt[offset]++;
// Trap: write to page 0x20 via Win1 (addr 0x4000-0x7FFF) — the corruption!
if (addr >= 0x4000 && addr < 0x8000 && offset == 0 && !_corruptionTrapped)
{
_corruptionTrapped = true;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n *** CORRUPTION TRAP at T={Cpu.Tact} ***");
Console.WriteLine($" PC=0x{Cpu.regs.PC:X4} z80Addr=0x{addr:X4} val=0x{val:X2}");
Console.WriteLine($" AF={Cpu.regs.AF:X4} BC={Cpu.regs.BC:X4} DE={Cpu.regs.DE:X4} HL={Cpu.regs.HL:X4}");
Console.WriteLine($" SP={Cpu.regs.SP:X4} IX={Cpu.regs.IX:X4} IY={Cpu.regs.IY:X4}");
Console.WriteLine($" Win=[{Mem.GetWindow(0):X2},{Mem.GetWindow(1):X2},{Mem.GetWindow(2):X2},{Mem.GetWindow(3):X2}]");
// Also show what's at the LDIR source (HL) if this is during LDIR at 0xC60E
Console.WriteLine($" alt AF'={Cpu.regs._AF:X4} BC'={Cpu.regs._BC:X4} DE'={Cpu.regs._DE:X4} HL'={Cpu.regs._HL:X4}");
// Dump a few bytes of stack
Console.Write(" Stack: ");
for (int si = 0; si < 8; si++)
Console.Write($"{Mem.Read((ushort)(Cpu.regs.SP + si)):X2} ");
Console.WriteLine();
Console.ResetColor();
}
}
};
if (!string.IsNullOrEmpty(DiskRootPath))
Wc.DiskRootPath = DiskRootPath;
if (VerboseWcCalls)
Wc.Log = msg => Console.WriteLine(msg);
if (!string.IsNullOrEmpty(testFile))
TestFileName = testFile;
// ── 1. Load WMF binary ─────────────────────
if (!File.Exists(wmfPath))
{
Console.Error.WriteLine($"WMF file not found: {wmfPath}");
return 1;
}
byte[] wmfData = File.ReadAllBytes(wmfPath);
Console.WriteLine($"WMF file: {wmfPath}");
Console.WriteLine($"WMF size: {wmfData.Length} bytes");
LoadPlugin(wmfData);
// ── 2. Set up WC environment ───────────────
SetupWcEnvironment();
// ── 3. Set up file for the plugin ──────────
SetupTestFile();
// ── 4. Set up CPU ──────────────────────────
SetupCpu();
// ── 5. Set up page protection ──────────────
SetupProtection();
// ── 6. Run ─────────────────────────────────
Console.WriteLine();
Console.WriteLine("─── Execution Start ─────────────────────────────");
_exitCode = RunCpu();
// ── 7. Analysis ────────────────────────────
Console.WriteLine();
Console.WriteLine("─── Post-Run Analysis ───────────────────────────");
PrintAnalysis();
if (DumpOnExit)
DumpState("exit");
return _exitCode;
}
// ═══════════════════════════════════════════════════════════════
// Plugin loading
// ═══════════════════════════════════════════════════════════════
private void LoadPlugin(byte[] wmfData)
{
Console.WriteLine();
Console.WriteLine("─── Loading Plugin ──────────────────────────────");
// WMF format (doc/02_Plugin_Header_Structure.md):
// +0: 16 bytes reserved
// +16: 16 bytes "WildCommanderMDL" signature
// +32: 1 byte version (0x10)
// +33: 1 byte reserved
// +34: 1 byte pages count
// +35: 1 byte start page number (relative, loaded into Win2)
// +36: 12 bytes data blocks (6 × 2: page_index, size_in_512b_sectors)
// +512: data blocks follow
const int HEADER_SIZE = 512;
// Detect signature at offset 16
bool hasSignature = wmfData.Length > 48 &&
wmfData[16] == (byte)'W' && wmfData[17] == (byte)'i' &&
wmfData[18] == (byte)'l' && wmfData[19] == (byte)'d';
if (!hasSignature)
{
Console.WriteLine(" WARNING: No WildCommanderMDL signature found!");
Console.WriteLine(" Loading as raw pages starting from PLGPG");
int rawPages = (wmfData.Length + TsMemory.PageSize - 1) / TsMemory.PageSize;
for (int p = 0; p < rawPages; p++)
{
int srcOff = p * TsMemory.PageSize;
int len = Math.Min(TsMemory.PageSize, wmfData.Length - srcOff);
Array.Copy(wmfData, srcOff, Mem.GetPage(PLGPG + p), 0, len);
}
}
else
{
byte version = wmfData[32];
byte pagesCount = wmfData[34];
byte startPage = wmfData[35];
Console.WriteLine($" Signature: WildCommanderMDL v0x{version:X2}");
Console.WriteLine($" Pages: {pagesCount}, start page: {startPage}");
// Parse data blocks
int dataOffset = HEADER_SIZE;
for (int blk = 0; blk < 6; blk++)
{
byte pageIdx = wmfData[36 + blk * 2];
byte sectors = wmfData[37 + blk * 2];
if (sectors == 0) continue;
int blkSize = sectors * 512;
int physPage = PLGPG + pageIdx;
Console.WriteLine($" Block {blk}: page {pageIdx} (phys 0x{physPage:X2}), " +
$"{sectors}×512 = {blkSize} bytes");
int len = Math.Min(blkSize, wmfData.Length - dataOffset);
if (len > 0)
{
byte[] dest = Mem.GetPage(physPage);
Array.Copy(wmfData, dataOffset, dest, 0, Math.Min(len, TsMemory.PageSize));
// If block > 16KB (multiple pages), handle overflow
if (len > TsMemory.PageSize)
{
Console.WriteLine($" WARNING: Block exceeds 16KB, truncated");
}
}
dataOffset += blkSize;
}
// Show extensions
Console.Write(" Extensions: ");
for (int ext = 0; ext < 32; ext++)
{
int off = 64 + ext * 3;
if (wmfData[off] == 0) break;
if (ext > 0) Console.Write(", ");
Console.Write(System.Text.Encoding.ASCII.GetString(wmfData, off, 3));
}
Console.WriteLine();
// Show plugin name
string name = System.Text.Encoding.ASCII.GetString(wmfData, 165, 32).TrimEnd('\0', ' ');
Console.WriteLine($" Name: \"{name}\"");
}
// Verify: show first bytes of code page at 0x8000
Mem.SetWindow(2, PLGPG); // Win2 = plugin page 0
Console.Write(" Code @0x8000: ");
for (int i = 0; i < 16; i++)
Console.Write($"{Mem.Read((ushort)(0x8000 + i)):X2} ");
Console.WriteLine();
}
// ═══════════════════════════════════════════════════════════════
// WC environment setup
// ═══════════════════════════════════════════════════════════════
private void SetupWcEnvironment()
{
Console.WriteLine();
Console.WriteLine("─── WC Environment ──────────────────────────────");
// Window mapping (as WC P8000 sets up before CALL 0x8000):
// Win0 = TXT page (0x00)
// Win1 = WC system page (0x05)
// Win2 = Plugin code page (PLGPG = 0x61)
// Win3 = TXT page (0x00) — WC always sets this before calling plugin
Mem.SetWindow(0, MTXPG);
Mem.SetWindow(1, SYS_PAGE);
Mem.SetWindow(2, PLGPG);
Mem.SetWindow(3, MTXPG);
// Initialize WC system variables
Wc.SysVarPage = SYS_PAGE;
Wc.InitSysVars();
// Set up IM2 vector area at 0x5BFF (in Win1 = SYS_PAGE)
// 0x5BFF = Win1 offset 0x1BFF
// We'll put a dummy ISR address there for now
// Set up the JP instruction at 0x6006 (in Win1 = SYS_PAGE)
// 0x6006 = Win1 offset 0x2006
// JP to itself (we trap this before execution)
var sysPage = Mem.GetPage(SYS_PAGE);
sysPage[0x2006] = 0xC3; // JP
sysPage[0x2007] = 0x06; // lo
sysPage[0x2008] = 0x60; // hi → JP 0x6006 (infinite loop, but we intercept)
// Fill font page with dummy data (printable chars)
// Font is at FONT_PAGE (0x01), character bitmaps
// We just leave it zeroed — text output is stubbed anyway
Console.WriteLine($" Windows: W0=0x{MTXPG:X2} W1=0x{SYS_PAGE:X2} W2=0x{PLGPG:X2} W3=0x{MTXPG:X2}");
Console.WriteLine($" SysVars @0x6000 initialized");
}
// ═══════════════════════════════════════════════════════════════
// Test file setup
// ═══════════════════════════════════════════════════════════════
private void SetupTestFile()
{
if (string.IsNullOrEmpty(TestFileName)) return;
Console.WriteLine();
Console.WriteLine("─── Test File ───────────────────────────────────");
string fullPath = TestFileName;
if (!Path.IsPathRooted(fullPath) && !string.IsNullOrEmpty(DiskRootPath))
fullPath = Path.Combine(DiskRootPath, TestFileName);
if (!File.Exists(fullPath))
{
Console.Error.WriteLine($" Test file not found: {fullPath}");
return;
}
byte[] fileData = File.ReadAllBytes(fullPath);
Wc.SetFileData(fileData);
Console.WriteLine($" File: {TestFileName}");
Console.WriteLine($" Size: {fileData.Length} bytes ({(fileData.Length + 16383) / 16384} pages)");
// Detect VGZ
if (fileData.Length > 2 && fileData[0] == 0x1F && fileData[1] == 0x8B)
Console.WriteLine(" Format: VGZ (gzip-compressed VGM)");
else if (fileData.Length > 4 && fileData[0] == 0x56 && fileData[1] == 0x67 &&
fileData[2] == 0x6D && fileData[3] == 0x20)
Console.WriteLine(" Format: VGM (raw)");
}
// ═══════════════════════════════════════════════════════════════
// CPU setup
// ═══════════════════════════════════════════════════════════════
private void SetupCpu()
{
Console.WriteLine();
Console.WriteLine("─── CPU Setup ───────────────────────────────────");
// Wire CPU bus
Cpu.RDMEM = addr => Mem.Read(addr);
Cpu.RDMEM_M1 = addr => Mem.Read(addr);
Cpu.WRMEM = (addr, val) => Mem.Write(addr, val);
Cpu.RDPORT = HandlePortRead;
Cpu.WRPORT = HandlePortWrite;
Cpu.RDNOMREQ = addr => { };
Cpu.WRNOMREQ = addr => { };
Cpu.RESET = () => { };
Cpu.NMIACK_M1 = () => { };
// Clear INT on acknowledge — prevents re-trigger since we use pulse semantics
Cpu.INTACK_M1 = () => { Cpu.INT = false; };
Cpu.SCANSIG = () => { };
// Initial CPU state as WC P8000 sets before CALL 0x8000:
// A = call type (0x00 = by file extension)
// BC = pointer to filename string (in Win2 area)
// HL = file size low word
// DE = file size high word
// IX = panel structure pointer (we'll use a dummy)
// SP = WC stack (somewhere in Win1 area, ~0x5BF0)
// Prepare filename in plugin data area (Win2, 0x8000–0xBFFF)
// We'll put it at end of DATA area, say 0xBF00
string fname = TestFileName ?? "test.vgm";
byte[] nameBytes = System.Text.Encoding.ASCII.GetBytes(fname);
ushort nameAddr = 0xBF00;
for (int i = 0; i < nameBytes.Length && i < 60; i++)
Mem.WriteUnprotected((ushort)(nameAddr + i), nameBytes[i]);
Mem.WriteUnprotected((ushort)(nameAddr + Math.Min(nameBytes.Length, 60)), 0);
uint fileSize = Wc.FileSize;
Cpu.regs.A = 0x00; // call type = by extension
Cpu.regs.BC = nameAddr;
Cpu.regs.HL = (ushort)(fileSize & 0xFFFF);
Cpu.regs.DE = (ushort)((fileSize >> 16) & 0xFFFF);
Cpu.regs.IX = 0xBE00; // dummy panel ptr
Cpu.regs.SP = 0x5BF0; // WC stack area
Cpu.regs.PC = 0x8000; // plugin entry point
// Set A' = file extension index (0)
// _AF is ushort: A' in high byte, F' in low byte
Cpu.regs._AF = 0x0000;
// Set DE' = number of files (say 10), HL' = current file number (1)
Cpu.regs._DE = 10;
Cpu.regs._HL = 1;
// Push return address (sentinel 0x0000) so RET from main() goes to 0x0000
Cpu.regs.SP -= 2;
Mem.WriteUnprotected(Cpu.regs.SP, 0x00);
Mem.WriteUnprotected((ushort)(Cpu.regs.SP + 1), 0x00);
// Enable interrupts (plugin expects them for ISR)
Cpu.IFF1 = false; // Will be enabled by the plugin itself (EI after isr_init)
Cpu.IFF2 = false;
Cpu.IM = 2; // IM 2 (plugin uses IM 2 for ISR)
Cpu.INT = false;
Cpu.NMI = false;
Cpu.RST = false;
Cpu.HALTED = false;
// ── IM2 vector table setup ────────────────────────────────
// WC uses I=0x5B. On INT, Z80 reads vector from (I*256+databus).
// Data bus = 0xFF → vector at address 0x5BFF.
// We place a stub ISR handler (EI + RETI) at 0x5BF4 and point
// the vector there. This prevents uninitialized-vector crashes
// when copy_pages_to_tap or inflate_vgz does EI before the
// plugin installs its real ISR handler.
Cpu.regs.IR = (ushort)(0x5B << 8); // I = 0x5B, R = 0
{
var sysP = Mem.GetPage(SYS_PAGE);
// Stub handler at 0x5BF4 (offset 0x1BF4 in SYS_PAGE)
sysP[0x1BF4] = 0xFB; // EI
sysP[0x1BF5] = 0xED; // RETI prefix
sysP[0x1BF6] = 0x4D; // RETI
// Vector at 0x5BFF → lo=0xF4, hi=0x5B → handler at 0x5BF4
sysP[0x1BFF] = 0xF4; // lo byte of handler address
// 0x5C00 = offset 0x1C00, but that's page boundary
// 0x5C00 is still in Win1 (0x4000–0x7FFF), offset 0x1C00 in SYS_PAGE
sysP[0x1C00] = 0x5B; // hi byte of handler address
}
Console.WriteLine(" IM2 stub handler at 0x5BF4 (EI+RETI), vector at 0x5BFF→0x5BF4");
Cpu.Tact = 0;
Cpu.FX = CpuModeIndex.None;
Cpu.XFX = CpuModeEx.None;
Console.WriteLine($" PC=0x{Cpu.regs.PC:X4} SP=0x{Cpu.regs.SP:X4}");
Console.WriteLine($" A=0x{Cpu.regs.A:X2} BC=0x{Cpu.regs.BC:X4} DE=0x{Cpu.regs.DE:X4} HL=0x{Cpu.regs.HL:X4}");
Console.WriteLine($" File: \"{fname}\" ({fileSize} bytes) @ 0x{nameAddr:X4}");
}
// ═══════════════════════════════════════════════════════════════
// Page protection
// ═══════════════════════════════════════════════════════════════
private void SetupProtection()
{
Console.WriteLine();
Console.WriteLine("─── Page Protection ─────────────────────────────");
// NOTE: WC system page (0x05) contains the stack at ~0x5BF0,
// so we cannot write-protect it. The plugin legitimately writes there.
// We only protect pages that should NEVER be written by the plugin.
// Protect font page (read-only, plugin should never write fonts)
Mem.ProtectPage(FONT_PAGE);
Console.WriteLine($" Protected: page 0x{FONT_PAGE:X2} (font)");
// Hook for violation logging
Mem.OnViolation = v =>
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($" !! {v} at tact={Cpu.Tact} PC=0x{Cpu.regs.PC:X4}");
Console.ResetColor();
};
}
// ═══════════════════════════════════════════════════════════════
// Port I/O handlers
// ═══════════════════════════════════════════════════════════════
private byte HandlePortRead(ushort port)
{
// ZX keyboard ports (0xFE with specific A8-A15)
// Return 0xFF = no key pressed (all bits high)
if ((port & 0xFF) == 0xFE) return 0xFF;
// AY register read
if (port == PORT_AY_ADDR) return 0xFF;
return 0xFF;
}
private void HandlePortWrite(ushort port, byte value)
{
// TSConfig page ports
if (Mem.HandlePort(port, value))
{
if (VerbosePageSwitch)
Console.WriteLine($" [{Cpu.Tact,10}] Page port 0x{port:X4} = 0x{value:X2}");
return;
}
switch (port)
{
// ZX border
case PORT_BORDER:
break;
case PORT_TSCONF_BORDER:
break;
// OPL3 ports
case PORT_OPL3_ADDR0:
_opl3Addr0 = value;
break;
case PORT_OPL3_DATA:
_opl3WriteCount++;
break;
case PORT_OPL3_ADDR1:
_opl3Addr1 = value;
break;
// AY ports
case PORT_AY_ADDR:
case PORT_AY_DATA:
break;
// INT position ports (ISR uses OUTI to these)
case PORT_VSINTH: // 0x24AF — VCNT[8] (bit 0)
_intLine = (_intLine & 0xFF) | ((value & 0x01) << 8);
RecalcIntTarget();
break;
case PORT_VSINTL: // 0x23AF — VCNT[7:0]
_intLine = (_intLine & 0x100) | value;
RecalcIntTarget();
break;
case PORT_HSINT: // 0x22AF — HCNT[8:1], 0–223
_intHPos = value;
RecalcIntTarget();
break;
case PORT_INTMASK: // 0x2AAF
break;
// Debug ports
case DBG_STATUS:
_lastDbgStatus = value;
break;
case DBG_SRC:
case DBG_DST:
break;
// TSConfig system ports
case 0x20AF: // SYSCONF (turbo bits [1:0])
{
int newTurbo = value & 0x03;
if (newTurbo != _turboMode)
{
int oldMult = _rasterMult;
_turboMode = newTurbo;
_rasterMult = GetRasterMult(newTurbo);
Console.WriteLine($" [TURBO] port 0x20AF = 0x{value:X2} mode {_turboMode} rasterMult {oldMult} → {_rasterMult}");
}
break;
}
case 0x2BAF: // CACHECONF
break;
default:
// Silently ignore unknown ports
break;
}
}
// ═══════════════════════════════════════════════════════════════
// Main execution loop
// ═══════════════════════════════════════════════════════════════
private int RunCpu()
{
_finished = false;
_lastWcCallTact = 0;
_rasterPos = 0;
_prevTact = 0;
_intFired = false;
RecalcIntTarget();
long progressTact = 0;
bool inWin1Warned = false; // only warn once about Win1 stray
while (!_finished)
{
ushort pc = Cpu.regs.PC;
// ── Sentinel: plugin returned to 0x0000 ──
if (pc == 0x0000)
{
byte exitA = Cpu.regs.A;
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"\n Plugin returned at cycle {Cpu.Tact}");
Console.WriteLine($" A = 0x{exitA:X2} (exit code: {ExitCodeName(exitA)})");
Console.ResetColor();
// Dump stack area & VGM header
Console.WriteLine($" SP=0x{Cpu.regs.SP:X4}");
Console.Write(" Stack area [SP-8..SP+8]: ");
for (int si = -8; si < 8; si++)
Console.Write($"{Mem.Read((ushort)(Cpu.regs.SP + si)):X2} ");
Console.WriteLine();
// Dump megabuffer page 0 header (VGM signature area)
Console.Write(" Megabuf pg0 [0..15]: ");
var mbPage = Mem.GetPage(TVBPG);
for (int mi = 0; mi < 16; mi++)
Console.Write($"{mbPage[mi]:X2} ");
Console.WriteLine();
Console.Write(" Win3 page 0xC000 [0..15]: ");
byte w3now = Mem.GetWindow(3);
var w3pg = Mem.GetPage(w3now);
for (int mi = 0; mi < 16; mi++)
Console.Write($"{w3pg[mi]:X2} ");
Console.WriteLine($" (Win3=0x{w3now:X2})");
// Show hit/missed breakpoints
Console.Write(" Breakpoints hit: ");
foreach (var bp in _breakpoints)
Console.Write($"{bp.Value}={(_hitBreakpoints.Contains(bp.Key) ? "YES" : "NO")} ");
Console.WriteLine();
_finished = true;
return 0;
}
// ── PC range check: detect stray into Win0/Win1 ──
if (pc >= 0x4000 && pc < 0x8000 && pc != WcKernel.WC_ENTRY
&& pc != ISR_STUB_ADDR && pc != ISR_STUB_ADDR + 1 && pc != ISR_STUB_ADDR + 2
&& !inWin1Warned)
{
// PC wandered into Win1 (system page) — NOT the WC_ENTRY
inWin1Warned = true;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n !! STRAY PC: 0x{pc:X4} (Win1 area) at cycle {Cpu.Tact}");
Console.ResetColor();
PrintCpuState();
Console.WriteLine(" Trace (last 20):");
Trace.Dump(20);
DumpState("stray_win1");
return 7;
}
if (pc < 0x4000 && pc != 0x0000 && !inWin1Warned)
{
inWin1Warned = true;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n !! STRAY PC: 0x{pc:X4} (Win0 area) at cycle {Cpu.Tact}");
Console.ResetColor();
PrintCpuState();
Console.WriteLine(" Trace (last 20):");
Trace.Dump(20);
DumpState("stray_win0");
return 7;
}
// ── WC API trap ──
if (Wc.TryIntercept())
{
_lastWcCallTact = Cpu.Tact;
if (VerboseWcCalls && Wc.CallLog.Count > 0)
{
var last = Wc.CallLog[^1];
Console.WriteLine($" [{Cpu.Tact,10}] WC 0x{last.FuncNum:X2} ({WcKernel.GetFuncName(last.FuncNum)})");
// After MNGCVPL: show what Win3 now maps to and first 4 bytes at 0xC000
if (last.FuncNum == WcKernel.FN_MNGCVPL)
{
byte w3 = Mem.GetWindow(3);
var pg = Mem.GetPage(w3);
Console.WriteLine($" → Win3=0x{w3:X2} @C000: {pg[0]:X2} {pg[1]:X2} {pg[2]:X2} {pg[3]:X2}");
}
}
continue;
}
// ── Function breakpoints ──
if (_breakpoints.TryGetValue(pc, out string? bpName) && _hitBreakpoints.Add(pc))
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"\n ► ENTER {bpName}() at cycle {Cpu.Tact}");
Console.WriteLine($" A=0x{Cpu.regs.A:X2} HL=0x{Cpu.regs.HL:X4} DE=0x{Cpu.regs.DE:X4} BC=0x{Cpu.regs.BC:X4} SP=0x{Cpu.regs.SP:X4}");
Console.WriteLine($" Win: [{Mem.GetWindow(0):X2} {Mem.GetWindow(1):X2} {Mem.GetWindow(2):X2} {Mem.GetWindow(3):X2}]");
// For copy_pages_to_tap / inflate_vgz: show stack (return address)
Console.Write(" Stack: ");
for (int si = 0; si < 12; si++)
Console.Write($"{Mem.Read((ushort)(Cpu.regs.SP + si)):X2} ");
Console.WriteLine();
Console.ResetColor();
}
// ── Post copy_pages_to_tap trace (0x8903-0x8920) ──
if (pc >= 0x8903 && pc <= 0x8920 && !_postCptLogged)
{
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.Write($" [T{Cpu.Tact}] PC={pc:X4} op={Mem.Read(pc):X2}");
Console.Write($" AF={Cpu.regs.AF:X4} BC={Cpu.regs.BC:X4} DE={Cpu.regs.DE:X4} HL={Cpu.regs.HL:X4} SP={Cpu.regs.SP:X4}");
Console.WriteLine($" W=[{Mem.GetWindow(0):X2},{Mem.GetWindow(1):X2},{Mem.GetWindow(2):X2},{Mem.GetWindow(3):X2}]");
Console.ResetColor();
if (pc == 0x8920) _postCptLogged = true; // stop after range
}
// ── Post inflate_vgz result (0x8917 = or a,a after call _inflate_vgz) ──
if (pc == 0x8917 && !_inflateResultLogged)
{
_inflateResultLogged = true;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"\n ◄ inflate_vgz() returned A=0x{Cpu.regs.A:X2} (0=OK) at cycle {Cpu.Tact}");
// Dump megabuf page 0 first 128 bytes (decompressed VGM header)
byte[] mbPg0 = Mem.GetPage(TVBPG);
Console.WriteLine(" Decompressed VGM header (megabuf pg0, first 128 bytes):");
for (int row = 0; row < 8; row++)
{
Console.Write($" {row * 16:X4}: ");
for (int col = 0; col < 16; col++)
Console.Write($"{mbPg0[row * 16 + col]:X2} ");
Console.WriteLine();
}
// Write tracker for first 16 bytes
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine(" Megabuf pg0 write tracker (first 16 bytes):");
for (int i = 0; i < 16; i++)
{
if (_mbPg0Cnt[i] == 0)
Console.WriteLine($" [{i:X2}] NEVER WRITTEN (current=0x{mbPg0[i]:X2})");
else
{
Console.Write($" [{i:X2}] writes={_mbPg0Cnt[i]}");
Console.Write($" first=(0x{_mbPg0First[i].Value:X2} @T{_mbPg0First[i].Cycle} z80=0x{_mbPg0First[i].Z80Addr:X4} PC=0x{_mbPg0First[i].PC:X4})");
if (_mbPg0Cnt[i] >= 2)
Console.Write($" prev=(0x{_mbPg0Prev[i].Value:X2} @T{_mbPg0Prev[i].Cycle} z80=0x{_mbPg0Prev[i].Z80Addr:X4} PC=0x{_mbPg0Prev[i].PC:X4})");
Console.Write($" last=(0x{_mbPg0Last[i].Value:X2} @T{_mbPg0Last[i].Cycle} z80=0x{_mbPg0Last[i].Z80Addr:X4} PC=0x{_mbPg0Last[i].PC:X4})");
Console.WriteLine();
}
}
Console.ResetColor();
}
// ── Post vgm_parse_header result (0x8BBA = or a,a after call _vgm_parse_header) ──
if (pc == 0x8BBA && !_parseHeaderResultLogged)
{
_parseHeaderResultLogged = true;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"\n ◄ vgm_parse_header() returned A=0x{Cpu.regs.A:X2} (0=VGM_OK) at cycle {Cpu.Tact}");
// If error, dump the Win3 view of header (should be megabuf pg0 mapped by mngcvpl(0))
if (Cpu.regs.A != 0)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(" !! VGM PARSE HEADER FAILED — dumping Win3 (megabuf pg0):");
byte w3 = Mem.GetWindow(3);
byte[] pg = Mem.GetPage(w3);
for (int row = 0; row < 16; row++)
{
Console.Write($" {row * 16:X4}: ");
for (int col = 0; col < 16; col++)
Console.Write($"{pg[row * 16 + col]:X2} ");
Console.WriteLine();
}
}
Console.ResetColor();
}
// ── Record trace entry ──
{
var entry = new TraceEntry
{
Tact = Cpu.Tact,
PC = pc,
Opcode = Mem.Read(pc),
AF = Cpu.regs.AF, BC = Cpu.regs.BC,
DE = Cpu.regs.DE, HL = Cpu.regs.HL,
AF2 = Cpu.regs._AF, BC2 = Cpu.regs._BC,
DE2 = Cpu.regs._DE, HL2 = Cpu.regs._HL,
IX = Cpu.regs.IX, IY = Cpu.regs.IY,
SP = Cpu.regs.SP,
Win0 = Mem.GetWindow(0), Win1 = Mem.GetWindow(1),
Win2 = Mem.GetWindow(2), Win3 = Mem.GetWindow(3),
WriteAddr = 0xFFFF,
WcFuncNum = 0xFF
};
Trace.Record(ref entry);
}
// ── Execute one instruction ──
Cpu.ExecCycle();
// ── Raster-based INT emulation ──
// Advance raster by CPU T-states consumed, scaled to 3.5 MHz base.
// Fire INT when raster crosses programmed VSINT/HSINT position.
{
long cpuDelta = Cpu.Tact - _prevTact;
_prevTact = Cpu.Tact;
long prevRaster = _rasterPos;
_rasterPos += cpuDelta * _rasterMult;
bool crossed = false;
if (_rasterPos >= FRAME_RASTER)
{
// Old-frame tail: check with previous _intFired state
if (!_intFired && _intTargetRaster > prevRaster)
crossed = true;
_rasterPos %= FRAME_RASTER;
_intFired = false; // new frame → re-arm
// New-frame start: target near raster origin
if (!crossed && _intTargetRaster <= _rasterPos)
crossed = true;
}
else
{
crossed = !_intFired &&
_intTargetRaster > prevRaster && _intTargetRaster <= _rasterPos;
}
if (crossed)
{
_intFired = true;
Cpu.INT = true;
_isrFireCount++;
if (_isrFireCount <= 5 || _isrFireCount % 100000 == 0)
{
int line = (int)(_intTargetRaster / RASTER_SCALE / LINE_TACTS);
int hpos = (int)(_intTargetRaster / RASTER_SCALE % LINE_TACTS);
Console.WriteLine($" [ISR #{_isrFireCount}] T={Cpu.Tact} line={line} hpos={hpos} PC=0x{Cpu.regs.PC:X4}");
}
}
}
if (_lastWcCallTact > 0 && (Cpu.Tact - _lastWcCallTact) > WatchdogLimit)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n WATCHDOG: {Cpu.Tact - _lastWcCallTact} cycles since last WC call");
Console.WriteLine($" PC=0x{pc:X4} SP=0x{Cpu.regs.SP:X4}");
Console.ResetColor();
PrintCpuState();
return 4;
}
// ── Max cycles ──
if (Cpu.Tact > MaxCycles)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n MAX CYCLES ({MaxCycles}) exceeded");
Console.ResetColor();
return 5;
}
// ── HALT detection ──
if (Cpu.HALTED)
{
// In IM2, HALT waits for next INT — that's normal for ISR idle
// But if interrupts are disabled + HALT, it's a deadlock
if (!Cpu.IFF1)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n DEADLOCK: HALT with DI at PC=0x{pc:X4}");
Console.ResetColor();
return 6;
}
}
// ── Progress indicator every 100M cycles ──
if (Cpu.Tact - progressTact > 100_000_000)
{
progressTact = Cpu.Tact;
Console.Write($"\r Cycles: {Cpu.Tact / 1_000_000}M WC calls: {Wc.CallLog.Count} OPL3 writes: {_opl3WriteCount} ");
}
}
return 0;
}
// ═══════════════════════════════════════════════════════════════
// Analysis and dumps
// ═══════════════════════════════════════════════════════════════
private void PrintCpuState()
{
var r = Cpu.regs;
Console.WriteLine($" AF={r.AF:X4} BC={r.BC:X4} DE={r.DE:X4} HL={r.HL:X4}");
Console.WriteLine($" AF'={r._AF:X4} BC'={r._BC:X4} DE'={r._DE:X4} HL'={r._HL:X4}");
Console.WriteLine($" IX={r.IX:X4} IY={r.IY:X4} SP={r.SP:X4} PC={r.PC:X4}");
Console.WriteLine($" IFF1={Cpu.IFF1} IFF2={Cpu.IFF2} IM={Cpu.IM} HALTED={Cpu.HALTED}");
Console.WriteLine($" Win0=0x{Mem.GetWindow(0):X2} Win1=0x{Mem.GetWindow(1):X2} Win2=0x{Mem.GetWindow(2):X2} Win3=0x{Mem.GetWindow(3):X2}");
Console.Write(" Code @PC: ");
for (int i = 0; i < 8; i++)
Console.Write($"{Mem.Read((ushort)(r.PC + i)):X2} ");
Console.WriteLine();
Console.Write(" Stack: ");
for (int i = 0; i < 8; i++)
Console.Write($"{Mem.Read((ushort)(r.SP + i)):X2} ");
Console.WriteLine();
}
private void PrintAnalysis()
{
Console.WriteLine($" Total cycles: {Cpu.Tact:N0}");
Console.WriteLine($" WC API calls: {Wc.CallLog.Count}");
Console.WriteLine($" OPL3 writes: {_opl3WriteCount}");
Console.WriteLine($" Violations: {Mem.Violations.Count}");
Console.WriteLine($" Trace entries: {Trace.Count}");
Console.WriteLine($" Dirty pages: {Mem.DirtyPages.Count}");
// WC function frequency
if (Wc.CallLog.Count > 0)
{
Console.WriteLine();
Console.WriteLine(" WC function frequency:");
var freq = Wc.CallLog
.GroupBy(c => c.FuncNum)
.OrderByDescending(g => g.Count())
.Take(15);
foreach (var g in freq)
Console.WriteLine($" 0x{g.Key:X2} ({WcKernel.GetFuncName(g.Key),-12}): {g.Count()}");
}
// Protection violations summary
if (Mem.Violations.Count > 0)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine();
Console.WriteLine($" !! {Mem.Violations.Count} PROTECTION VIOLATIONS:");
foreach (var v in Mem.Violations.Take(10))
Console.WriteLine($" {v}");
if (Mem.Violations.Count > 10)
Console.WriteLine($" ... and {Mem.Violations.Count - 10} more");
Console.ResetColor();
}
}
private void DumpState(string reason)
{
if (string.IsNullOrEmpty(DumpDir)) return;
try
{
Directory.CreateDirectory(DumpDir);
// CPU trace
Trace.DumpToFile(Path.Combine(DumpDir, $"{reason}_trace.txt"));
Console.WriteLine($" Dumped: {reason}_trace.txt");
// WC call log
File.WriteAllText(
Path.Combine(DumpDir, $"{reason}_wc_calls.txt"),
Wc.DumpCallLog(200));
Console.WriteLine($" Dumped: {reason}_wc_calls.txt");
// CPU state
using (var sw = new StreamWriter(Path.Combine(DumpDir, $"{reason}_cpu_state.txt")))
{
var r = Cpu.regs;
sw.WriteLine($"Reason: {reason}");
sw.WriteLine($"Tact: {Cpu.Tact}");
sw.WriteLine($"AF={r.AF:X4} BC={r.BC:X4} DE={r.DE:X4} HL={r.HL:X4}");
sw.WriteLine($"AF'={r._AF:X4} BC'={r._BC:X4} DE'={r._DE:X4} HL'={r._HL:X4}");
sw.WriteLine($"IX={r.IX:X4} IY={r.IY:X4} SP={r.SP:X4} PC={r.PC:X4}");
sw.WriteLine($"Wins: {Mem.GetWindow(0):X2} {Mem.GetWindow(1):X2} {Mem.GetWindow(2):X2} {Mem.GetWindow(3):X2}");
sw.WriteLine($"IFF1={Cpu.IFF1} IM={Cpu.IM}");
}
Console.WriteLine($" Dumped: {reason}_cpu_state.txt");
// Violations
if (Mem.Violations.Count > 0)
{
using var vw = new StreamWriter(Path.Combine(DumpDir, $"{reason}_violations.txt"));
foreach (var v in Mem.Violations)
vw.WriteLine(v);
Console.WriteLine($" Dumped: {reason}_violations.txt ({Mem.Violations.Count} violations)");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($" Dump error: {ex.Message}");
}
}
private static string ExitCodeName(byte code) => code switch
{
0 => "ESC/Exit",
1 => "Unrecognized (pass to next plugin)",
2 => "Next file",
3 => "Reload directory",
4 => "Previous file",
_ => $"unknown ({code})"
};
}
}