// 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})" }; } }