/* Headless C68K cycle harness -- an independent second opinion on every * 68000 cycle figure in FINDINGS 24-35. * * WHY. Every one of those numbers comes from ONE instrument: MAME 0.277's * Musashi core, timed host-side from manager.machine.time. A cycle table is a * hand-transcribed artefact; if Musashi's is wrong for our instruction mix, the * 833,333-cycle budget is wrong by the same amount and nothing in the tree * would show it. This runs the SAME decode.bin against the SAME * decode_data.bin under px68k's C68K core, which has a completely separate * cycle table (ORI_CLOCKS_* + EA_CLOCKS_* in c68kmacro.h) written by a * different author from the same Motorola manual. * * WHAT IT DOES AND DOES NOT SETTLE. C68K, like MAMEs x68000, charges NO * GVRAM wait states -- grep the px68k tree, there is no bus-timing model * anywhere in x68k/*.c. So this is the same LOWER BOUND, measured twice. It * cross-checks the cycle table. It says nothing about real-hardware wait * states; that needs XM6 TypeG or an actual X68000 (docs/BENCHMARK.md Tier 3). * * WHY NOT JUST RUN px68k. The decoder touches nothing but RAM, the control * block and GVRAM: no IPL, no CRTC, no MFP, no interrupts (the MAME rig masks * them with SR=$2700). Booting a whole emulated machine would add SDL, ROMs * and a 55Hz sampling clock to a measurement that wants none of them. Linking * the core alone also buys EXACTNESS: the stop cycle is captured inside the * write callback, so a frame's cost is known to within one instruction rather * than MAME's 1/55.46 s. That is why the anchors here run iter=1 -- decode.lua * only iterates to beat its own timing granularity. * * MEMORY MODEL mirrors px68k exactly, because the core requires it: RAM is * stored BYTE-SWAPPED (MEM[addr ^ 1], mem_wrap.c:420) so C68K's * READ_IMM_16() = *(UINT16 *)PC works with no swap on a little-endian host. * GVRAM word writes discard the high byte, as the hardware and MAME's * gvram_w case 0x0100 both do. * * The harness is self-validating: --dump writes the decoded screen and * verify_c68k.py checks it pixel-for-pixel against tools/encoder/dlx.py. If * the byte-swap or the memory map were wrong the decode could not come out * exact, so a green verify is what licenses the cycle numbers next to it. */ #include #include #include #include #include "c68k.h" /* c68k.c declares these extern and tests BusErrHandling every instruction. */ unsigned int BusErrHandling = 0; unsigned int BusErrAdr = 0; void Error(const char *s) { fprintf(stderr, "c68k: %s\n", s); exit(3); } void p6logd(const char *fmt, ...) { (void)fmt; } #define ADRMASK 0xFFFFFFu #define ARENA (16u << 20) #define RAMTOP 0x200000u #define GV_LO 0xC00000u #define GV_HI 0xC80000u #define FLAG 0x18000u #define LFLAG 0x18040u /* src/player/load.i's control block */ #define LHDR 0x18044u #define LDARK 0x18048u #define LMODE 0x18054u #define LITER 0x18058u #define GPAL 0xE82000u #define ITER 0x18008u #define NFR 0x1800Cu #define FPTR 0x18010u #define CB1 0x20000u #define CB4 0x22000u #define STREAM 0x30000u #define CODE 0x10000u #define STACK 0x8000u #define GVBASE 0xC00000u #define ROWBYTES 1024u #define CPUHZ 10000000.0 static unsigned char *buf; /* byte-swapped, px68k convention */ /* Data bus cycles the 68000 issues. Every callback below is exactly one * 68000 bus cycle -- C68K splits a long access into two word calls, which is * what the 16-bit bus does too -- so counting calls counts bus cycles. This * does NOT include instruction prefetch, which C68K reads straight through the * fetch pointer with no callback; the count is therefore a LOWER BOUND on the * CPU's bus occupancy, and the headroom it implies is an UPPER BOUND. * It is still the measurement that matters for FINDINGS 29.6: if the decoder's * data accesses alone left no room, a DMAC could not overlap with it at all. */ static long long bus_r, bus_w; static int in_exec = 0; /* Cycle capture. A single C68k_Exec slice runs the whole pass; the FLAG * writes inside it record where the timed region starts and ends, so the * count excludes nothing and includes no spin-loop tail. */ static long long slice; static long long cyc_start = -1, cyc_stop = -1; static int desync = 0; static unsigned char rd8 (unsigned int a){ if (in_exec) bus_r++; return buf[(a & ADRMASK) ^ 1]; } static unsigned short rd16(unsigned int a){ if (in_exec) bus_r++; a &= ADRMASK; return (unsigned short)(buf[a] | (buf[a+1] << 8)); } static unsigned short peek16(unsigned int a){ a &= ADRMASK; return (unsigned short)(buf[a] | (buf[a+1] << 8)); } static unsigned int rd32(unsigned int a){ return ((unsigned int)peek16(a) << 16) | peek16(a+2); } static void wr8(unsigned int a, unsigned char d) { if (in_exec) bus_w++; a &= ADRMASK; if (a >= GV_LO && a < GV_HI) { if (a & 1) buf[a ^ 1] = d; return; } /* high byte discarded */ buf[a ^ 1] = d; } /* Only writes made BY the 68000 mean anything here. The harness sets FLAG * itself during setup, and a `move.l` to FLAG arrives as two word writes, so * the hook sees a half-updated long in between -- clearing FLAG from $FF to 0 * momentarily reads back as $FF again. Without in_exec that transient * recorded a run's stop cycle before the run had started, and every frame * after the first came out as the whole slice. */ /* Which flag word the run watches. decode.s and stream.s use FLAG; the * load-time transforms of src/player/load.i use their own, so that a player * could eventually contain both without one clearing the other's state. The * VALUES mean the same thing in both (1 running, $FF done, $EE failed), which * is why one hook serves both. */ static unsigned int flag_adr = FLAG; static void note_flag(void) { unsigned int v = rd32(flag_adr); long long now = slice - C68K.ICount; if (!in_exec) return; if (v == 1 && cyc_start < 0) cyc_start = now; else if (v == 0xFF || v == 0xEE) { if (cyc_stop < 0) { cyc_stop = now; desync = (v == 0xEE); } C68K.ICount = 0; /* stop the slice; we keep our own count */ } } static void wr16(unsigned int a, unsigned short d) { if (in_exec) bus_w++; a &= ADRMASK; if (a >= GV_LO && a < GV_HI) { buf[a] = (unsigned char)d; buf[a+1] = 0; return; } buf[a] = (unsigned char)d; buf[a+1] = (unsigned char)(d >> 8); if (a >= flag_adr && a < flag_adr + 4) note_flag(); } static void wr32(unsigned int a, unsigned int d){ wr16(a, (unsigned short)(d >> 16)); wr16(a+2, (unsigned short)d); } static void push(unsigned int a, const unsigned char *s, size_t n) { for (size_t i = 0; i < n; i++) wr8((unsigned int)(a + i), s[i]); } /* Prime the screen exactly as decode.lua's setup() does: active area at index * 0, letterbox at the darkest palette entry. A SKIP block in frame 0 is a * claim about THIS, so it is part of the decode contract. Pass 2 re-primes, * because pass 1 left one frame's worth of residue on the screen and frame 0's * SKIP blocks would otherwise inherit it. */ static void prime(unsigned int W, unsigned int H, unsigned int yoff, unsigned int dark) { for (unsigned int y = 0; y < 256; y++) { unsigned short v = (y < yoff || y >= yoff + H) ? (unsigned short)dark : 0; for (unsigned int x = 0; x < W; x++) wr16(GVBASE + y*ROWBYTES + x*2, v); } } static unsigned char *slurp(const char *p, size_t *n) { FILE *f = fopen(p, "rb"); if (!f) { fprintf(stderr, "cannot open %s\n", p); exit(2); } fseek(f, 0, SEEK_END); long L = ftell(f); fseek(f, 0, SEEK_SET); unsigned char *b = malloc((size_t)L); if (fread(b, 1, (size_t)L, f) != (size_t)L) { fprintf(stderr, "short read %s\n", p); exit(2); } fclose(f); *n = (size_t)L; return b; } /* Run one pass and return its exact cycle count. */ static long long run(unsigned int off, unsigned int nfr, unsigned int iter) { cyc_start = cyc_stop = -1; desync = 0; bus_r = bus_w = 0; wr32(FLAG, 0); wr32(ITER, iter); wr32(NFR, nfr); wr32(FPTR, STREAM + off); C68k_Reset(&C68K); C68k_Set_Reg(&C68K, C68K_SR, 0x2700); /* supervisor, all IRQs masked */ C68k_Set_Reg(&C68K, C68K_A7, STACK); C68k_Set_Reg(&C68K, C68K_PC, CODE); slice = 2000000000LL; in_exec = 1; C68k_Exec(&C68K, (INT32)slice); in_exec = 0; if (cyc_stop < 0) { fprintf(stderr, "TIMEOUT off=%u nfr=%u -- decoder never set FLAG\n", off, nfr); exit(4); } if (desync) { fprintf(stderr, "BITSTREAM DESYNC off=%u nfr=%u\n", off, nfr); exit(5); } /* A runaway is not a slow frame. Without this a bad record walk reports a * two-billion-cycle "frame" as if it were a measurement. */ if (cyc_stop - cyc_start > 40LL * nfr * iter * 833333LL) { fprintf(stderr, "RUNAWAY off=%u nfr=%u: %lld cyc (start=%lld stop=%lld) " "PC=%06X FLAG=%08X SCR_N=%08X SCR_END=%08X len=%u\n", off, nfr, cyc_stop - cyc_start, cyc_start, cyc_stop, C68k_Get_Reg(&C68K, C68K_PC) & 0xFFFFFF, rd32(FLAG), rd32(0x18014), rd32(0x18018), rd32(STREAM + off)); exit(6); } return cyc_stop - cyc_start; } /* ---- the load-time transforms (ROADMAP P1+P2, FINDINGS 53) -------------- * The same question this harness asks of the decoder, asked of the loader: does * a SECOND 68000 core, with its own cycle table and its own memory model, * produce the same bytes and agree about what they cost? It also counts BUS * cycles, which MAME cannot report -- and the bus is the resource this project * established is the binding one (FINDINGS 38). */ static int run_load(const char *fcode, const char *fraw, const char *dump, unsigned int mode, unsigned int iter, unsigned int cb1_len, unsigned int cb4_len) { size_t nc, nr; unsigned char *code = slurp(fcode, &nc), *raw = slurp(fraw, &nr); push(STREAM, raw, nr); /* the RAW container header */ push(CODE, code, nc); /* Poison every destination, so that a transform which writes NOTHING * cannot pass by leaving the harness's own zeros in place. */ for (unsigned int a = CB1; a < CB1 + cb1_len; a += 2) wr16(a, 0xDEAD); for (unsigned int a = CB4; a < CB4 + cb4_len; a += 2) wr16(a, 0xDEAD); for (unsigned int c = 0; c < 256; c++) wr16(GPAL + c*2, 0xDEAD); wr32(LDARK, 0xFFFFFFFFu); /* The three scratch tables are poisoned only before a run that claims to * build them. A run that only PACKS the palette is entitled to find them * already built -- that is the point of pricing it separately -- so when * this process is asked for one, it does the boot pass first, untimed, * exactly as a player would have done at boot. Without that the pack runs * on zeros: every entry then takes the same branch and the darkest entry * comes out 0, which is a measurement of nothing. */ if (mode & 4) for (unsigned int a = 0x19000; a < 0x19340; a += 2) wr16(a, 0xDEAD); flag_adr = LFLAG; if ((mode & 2) && !(mode & 4)) { cyc_start = cyc_stop = -1; desync = 0; wr32(LFLAG, 0); wr32(LHDR, STREAM); wr32(LMODE, 4); wr32(LITER, 1); C68k_Reset(&C68K); C68k_Set_Reg(&C68K, C68K_SR, 0x2700); C68k_Set_Reg(&C68K, C68K_A7, STACK); C68k_Set_Reg(&C68K, C68K_PC, CODE); slice = 2000000000LL; in_exec = 1; C68k_Exec(&C68K, (INT32)slice); in_exec = 0; if (cyc_stop < 0) { fprintf(stderr, "TIMEOUT in the table pre-pass\n"); return 4; } } cyc_start = cyc_stop = -1; desync = 0; bus_r = bus_w = 0; wr32(LFLAG, 0); wr32(LHDR, STREAM); wr32(LMODE, mode); wr32(LITER, iter); C68k_Reset(&C68K); C68k_Set_Reg(&C68K, C68K_SR, 0x2700); C68k_Set_Reg(&C68K, C68K_A7, STACK); C68k_Set_Reg(&C68K, C68K_PC, CODE); slice = 2000000000LL; in_exec = 1; C68k_Exec(&C68K, (INT32)slice); in_exec = 0; if (cyc_stop < 0) { fprintf(stderr, "TIMEOUT -- loader never set LFLAG\n"); return 4; } if (desync) { fprintf(stderr, "BAD HEADER -- load.i found no 'DLX3' magic\n"); return 5; } long long cyc = (cyc_stop - cyc_start) / (iter ? iter : 1); fprintf(stderr, "[C68K] load mode %u: %lld cyc/pass (%.2f ms at 10MHz, " "%.1f%% of a 12fps frame), dark=%u\n", mode, cyc, cyc / 10000.0, 100.0 * cyc / (10000000.0 / 12), rd32(LDARK)); /* A 68000 bus cycle is 4 clocks. Prefetch is not counted (C68K reads * opcodes straight through the fetch pointer), so this is a LOWER bound on * occupancy and the headroom it implies is an UPPER bound -- same caveat as * the decoder's figure above. */ { double slots = (double)cyc / 4.0; double used = (double)(bus_r + bus_w) / (iter ? iter : 1); fprintf(stderr, "[C68K] data bus: %.0f reads + %.0f writes = %.0f of " "%.0f cycles = %.1f%% occupied (prefetch NOT counted)\n", (double)bus_r / iter, (double)bus_w / iter, used, slots, 100.0 * used / slots); } if (dump) { FILE *g = fopen(dump, "wb"); if (!g) { perror(dump); return 2; } for (unsigned int a = CB1; a < CB1 + cb1_len; a++) { unsigned char b = rd8(a); fwrite(&b,1,1,g); } for (unsigned int a = CB4; a < CB4 + cb4_len; a++) { unsigned char b = rd8(a); fwrite(&b,1,1,g); } for (unsigned int c = 0; c < 256; c++) { unsigned short w = rd16(GPAL + c*2); unsigned char b[2] = { (unsigned char)(w >> 8), (unsigned char)w }; fwrite(b, 1, 2, g); } fclose(g); fprintf(stderr, "[C68K] load output dumped to %s (%u B)\n", dump, cb1_len + cb4_len + 512); } return 0; } int main(int argc, char **argv) { const char *fcode = "tmp/decode.bin", *fdata = "tmp/decode_data.bin", *dump = NULL; unsigned int cb1_len=0, cb4_len=0, pal_len=0, stream_len=0, nframes=0, H=192, W=256, fps=12; unsigned int dark = 255; unsigned int anch[32]; int nanch = 0; const char *fraw = NULL, *loaddump = NULL; unsigned int loadmode = 7, loaditer = 1; for (int i = 1; i < argc; i++) { if (!strcmp(argv[i], "--code")) fcode = argv[++i]; else if (!strcmp(argv[i], "--data")) fdata = argv[++i]; else if (!strcmp(argv[i], "--dump")) dump = argv[++i]; else if (!strcmp(argv[i], "--cb1")) cb1_len = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--cb4")) cb4_len = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--pal")) pal_len = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--stream")) stream_len = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--nframes"))nframes = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--W")) W = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--H")) H = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--fps")) fps = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--dark")) dark = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--loadraw")) fraw = argv[++i]; else if (!strcmp(argv[i], "--loaddump")) loaddump = argv[++i]; else if (!strcmp(argv[i], "--loadmode")) loadmode = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--loaditer")) loaditer = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--anchor")) { if (nanch < 32) anch[nanch++] = (unsigned)strtoul(argv[++i], NULL, 10); } else { fprintf(stderr, "unknown arg %s\n", argv[i]); return 2; } } if (!fraw && (!nframes || !stream_len)) { fprintf(stderr, "need --nframes and --stream (from decode_meta.lua)\n"); return 2; } /* MAP_32BIT: C68K keeps its fetch base in a UINT32, so the arena must live * below 4 GB or every opcode fetch reads a truncated pointer. */ buf = mmap(NULL, ARENA, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_32BIT, -1, 0); if (buf == MAP_FAILED) { perror("mmap MAP_32BIT"); return 2; } fprintf(stderr, "[C68K] arena at %p\n", (void *)buf); if (fraw) { C68k_Init(&C68K); C68k_Set_ReadB (&C68K, rd8); C68k_Set_ReadW (&C68K, rd16); C68k_Set_WriteB(&C68K, wr8); C68k_Set_WriteW(&C68K, wr16); C68k_Set_Fetch (&C68K, 0x000000, 0xFFFFFF, (UINT32)(unsigned long)buf); return run_load(fcode, fraw, loaddump, loadmode, loaditer, cb1_len ? cb1_len : 8192, cb4_len ? cb4_len : 2048); } size_t nc, nd; unsigned char *code = slurp(fcode, &nc), *data = slurp(fdata, &nd); size_t need = (size_t)cb1_len + cb4_len + pal_len + stream_len; if (nd < need) { fprintf(stderr, "data blob %zu B < meta's %zu B\n", nd, need); return 2; } size_t o = 0; push(CB1, data + o, cb1_len); o += cb1_len; push(CB4, data + o, cb4_len); o += cb4_len; o += pal_len; /* palette: display only */ push(STREAM, data + o, stream_len); push(CODE, code, nc); /* Prime the screen exactly as decode.lua's setup() does: the active area * starts at index 0 and the letterbox gets the darkest palette entry. * A SKIP block in frame 0 is a claim about THIS, so it is part of the * decode contract, not decoration. */ unsigned int yoff = (256u - H) / 2; prime(W, H, yoff, dark); C68k_Init(&C68K); C68k_Set_ReadB (&C68K, rd8); C68k_Set_ReadW (&C68K, rd16); C68k_Set_WriteB(&C68K, wr8); C68k_Set_WriteW(&C68K, wr16); C68k_Set_Fetch (&C68K, 0x000000, 0xFFFFFF, (UINT32)(unsigned long)buf); double frame_budget = CPUHZ / fps; fprintf(stderr, "[C68K] %u frames, stream %u B, budget %.0f cyc/frame @ %u fps\n", nframes, stream_len, frame_budget, fps); /* Pass 1 -- every frame timed on its own. MAME could only afford eight * anchor frames because its clock is 1/55.46 s; here each frame is exact, * so the whole distribution comes out, which is what FINDINGS 31/35 score * against. Record layout: [u32 len][768 mode][payload], next record start * rounded up to 4 (FINDINGS 28.3). `len` counts the mode header TOO -- * decode.s sets SCR_END from the address AFTER the length word, so the * record is 4 + len bytes, not 4 + 768 + len. */ printf("frame,offset,cycles,pct_of_frame,bus_reads,bus_writes,bus_pct\n"); unsigned int off = 0; long long sum = 0, busr_tot = 0, busw_tot = 0; for (unsigned int f = 0; f < nframes; f++) { long long c = run(off, 1, 1); sum += c; long long br = bus_r, bw = bus_w; busr_tot += br; busw_tot += bw; printf("%u,%u,%lld,%.2f,%lld,%lld,%.2f\n", f, off, c, 100.0 * c / frame_budget, br, bw, 100.0 * 4.0 * (br + bw) / c); unsigned int len = rd32(STREAM + off); off = (off + 4 + len + 3) & ~3u; } fprintf(stderr, "[C68K] per-frame sum = %lld cyc, mean %.0f (%.1f%% of a %u fps frame)\n", sum, (double)sum / nframes, 100.0 * sum / nframes / frame_budget, fps); /* The number FINDINGS 29.6 needs. A 68000 bus cycle is 4 clocks, so a * frame of `sum/nframes` clocks has room for a quarter that many bus * cycles. What the decoder's DATA accesses do not use is the headroom a * DMAC could paint spans in -- minus instruction prefetch, which is not * counted here, so this OVERSTATES the headroom. */ { double mean_cyc = (double)sum / nframes; double slots = mean_cyc / 4.0; double used = (double)(busr_tot + busw_tot) / nframes; fprintf(stderr, "[C68K] data bus: %.0f reads + %.0f writes = %.0f cycles/frame " "of %.0f slots = %.1f%% occupied\n", (double)busr_tot / nframes, (double)busw_tot / nframes, used, slots, 100.0 * used / slots); fprintf(stderr, "[C68K] headroom >= %.0f bus cycles/frame " "(%.1f%%), MINUS instruction prefetch, which is not counted\n", slots - used, 100.0 * (slots - used) / slots); } /* Pass 2 -- one sequential run of the whole window. Two jobs: it is the * only honest correctness test (SKIP makes every frame a claim about the * one before it), and its total against pass 1's sum prices the outer * frame-loop overhead the per-frame runs each pay once. */ prime(W, H, yoff, dark); long long seq = run(0, nframes, 1); fprintf(stderr, "[C68K] sequential pass = %lld cyc, mean %.0f (%.1f%%); " "per-frame sum is %+.3f%% of it\n", seq, (double)seq / nframes, 100.0 * seq / nframes / frame_budget, 100.0 * (sum - seq) / seq); /* Dump BEFORE the anchors run. They decode single frames onto this same * screen, so anything after them is not the sequential reconstruction and * verify_c68k.py would report every pixel wrong. */ if (dump) { /* Active area only, one byte per pixel -- the low byte of each GVRAM * word, which is all the hardware keeps. */ FILE *g = fopen(dump, "wb"); if (!g) { perror(dump); return 2; } for (unsigned int y = 0; y < H; y++) for (unsigned int x = 0; x < W; x++) { unsigned char p = (unsigned char)rd16(GVBASE + (yoff + y)*ROWBYTES + x*2); fwrite(&p, 1, 1, g); } fclose(g); fprintf(stderr, "[C68K] screen dumped to %s (%ux%u indices)\n", dump, W, H); } /* Pass 3 -- decode.lua's timing anchors, at the same stream offsets, so the * two instruments are quoted on the same eight frames. The four synthetic * single-mode frames live past the end of the real stream and so are not * reachable by the record walk in pass 1; they are the ones that price the * modes separately (prep_dlx.py), which is where two cycle tables are most * likely to disagree. */ for (int i = 0; i < nanch; i++) { long long c = run(anch[i], 1, 1); fprintf(stderr, "[C68K] anchor off=%-8u %8lld cyc %5.1f%% of a %u fps frame\n", anch[i], c, 100.0 * c / frame_budget, fps); } return 0; }