Move the loader onto the 68000, and find 5,920 bytes nobody counted

src/player/load.i expands both codebooks to word-per-pixel form and packs the
palette to GGGGGRRRRRBBBBBI out of the RAW container header, byte-exact against
tools/bench/dlxload.py on both CPU cores.  The palette half is gated on words
read back out of the palette registers at $E82000, so "the words reached the
hardware" is part of what passes.  ROADMAP P1 is done; P2's encoder half (a
reserved black entry, 23.4) is not, and is a re-encode rather than an edit.

A scene change costs 18.96 ms of 68000 time, 22.8% of one 12 fps frame; boot
costs 24.70 ms.  The scratch tables describe the CRTC, not the scene, so
pal_tables is a separate entry point built once at boot -- 5.29 ms off every
scene change.

The one that moves something: the scene header is 5,920 B that no rate table in
this tree included, because it belongs to no frame record.  In FINDINGS 51.3's
currency it is divided by the surplus pipe - wire, so it is hypersensitive:
138 ms of extra refill climb at 488 KB/s and 1.099 s at 451.4 KB/s, for the
same bytes.  tools/analysis/22_scene_load.py prices it across explicit rates.

Recorded as open: the two CPU cores agree to <3% on every stage but the table
build, where they differ by 16.4%.  px68k's C68K charges a flat 50 clocks for
MULU/MULS (c68kmacro.h:1869) where the 68000 charges 38+2n, which explains
4,608 of the 8,703 clock gap.  4,095 clocks are unexplained.  Nothing else in
src/player/ multiplies, so no figure in FINDINGS 24-52 is affected.

decode.s and stream.s are untouched; decode.bin is still 1,296 B at the same
MD5.  check.sh gains a stage that gates byte-exactness on both cores and
deliberately does not gate the cycle counts -- MAME's clock is 1/55.46 s and a
wall timing would make the green light host-sensitive.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-24 20:20:40 -07:00
parent ed172c2da2
commit 7179339bd2
13 changed files with 1189 additions and 18 deletions
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""What a scene change costs, now that the loader runs on the 68000 (FINDINGS 53).
python3 tools/analysis/22_scene_load.py [container ...] --kbps R [R ...]
ROADMAP P1 asked for the codebook expansion to be priced "against the refill
climb, not treated as free setup", and that is the whole job of this file. A
scene change is the one moment where every cost in this project lands at once:
the ring is empty because of the seek, the header has to arrive before a single
frame can be drawn, and the 68000 cannot decode anything until it has expanded
the codebooks out of that header.
THREE COSTS, IN THREE DIFFERENT UNITS, and they are not interchangeable:
* BYTES. The container's header region -- palette, CB1, CB4 -- must be
delivered before frame 0 can be decoded. It is not part of any frame
record, so no rate table in this tree has ever counted it.
* CLOCKS. What src/player/load.i costs to turn that header into what the
block loop reads, MEASURED on the emulated 68000 by tools/bench/load.lua
and parsed out of its log rather than copied in here as a constant.
* ACCUMULATED SLACK. The bytes above are bytes the pipe did not spend
filling the ring, so they cost play-time at the surplus rate (pipe - wire),
which is the currency FINDINGS 51.3 established a branch point spends.
This is the one that compounds: it is charged on top of the seek itself.
`--kbps` is REQUIRED and takes no default, for the reason FINDINGS 50 gives.
Rates are decimal-KB per the rest of the tree's tooling; sizes are KiB.
"""
import sys, os, re, argparse
sys.path.insert(0, "tools/encoder")
import numpy as np
from dlx import DLX
import ratectl as RC
FPS = 12
CPUHZ = 10_000_000
def rig_cycles(path):
"""The measured per-stage cost, out of tools/bench/load.lua's own log.
Parsed rather than pasted: a constant copied in here would go stale the
first time load.i changed, and it would go stale SILENTLY -- the arithmetic
below would keep working and keep being wrong.
"""
if not os.path.exists(path):
sys.exit(f"no rig log at {path} -- run tools/bench/load_run.sh first")
out = {}
for line in open(path, "rb").read().decode("utf-8", "replace").splitlines():
m = re.search(r"^\[LOD\]\s+(\S.*?)\s{2,}(\d+) cyc", line)
if m:
out[m.group(1).strip()] = int(m.group(2))
need = ("SCENE CHANGE: codebooks + palette", "scratch tables only (boot, once)")
for k in need:
if k not in out:
sys.exit(f"{path} has no '{k}' line -- is it a load.lua summary?")
return out
ap = argparse.ArgumentParser()
ap.add_argument("containers", nargs="*", default=["tmp/rc_fr_singe_scsi_span.dlx"])
ap.add_argument("--kbps", type=float, nargs="+", required=True,
help="delivered pipe rates, KB/s. REQUIRED, no default (FINDINGS 50)")
ap.add_argument("--log", default="tmp/load_check.log",
help="tools/bench/load.lua's log, for the measured cycle counts")
a = ap.parse_args()
cyc = rig_cycles(a.log)
scene_cyc = cyc["SCENE CHANGE: codebooks + palette"]
boot_cyc = cyc["scratch tables only (boot, once)"]
frame_cyc = CPUHZ / FPS
print(f"measured on the emulated 68000 ({a.log}):")
print(f" per scene change {scene_cyc:>8,} clocks = {1000*scene_cyc/CPUHZ:6.2f} ms "
f"= {100*scene_cyc/frame_cyc:.1f}% of one {FPS}fps frame")
print(f" once at boot {boot_cyc:>8,} clocks = {1000*boot_cyc/CPUHZ:6.2f} ms "
f" (the three scratch tables: scene-independent)")
for path in a.containers:
d = DLX(path)
hdr = int.from_bytes(d.raw[28:32], "big")
rec = np.array([4 + n + (-(4 + n) % 4) for _, n in d.frames], np.int64)
wire = rec.mean() * FPS / 1024 + RC.AUDIO_KBPS
print(f"\n=== {path}: header region {hdr:,} B "
f"(pal 768 + cb1 {d.k1*16:,} + cb4 {d.k4*4:,} + 32), wire {wire:.1f} KB/s")
print(f"{'pipe':>6} {'header ms':>10} {'+load ms':>9} {'total':>7} "
f"{'frames':>7} {'surplus':>9} {'slack s':>9}")
for kbps in a.kbps:
hdr_ms = 1000 * hdr / (kbps * 1024)
load_ms = 1000 * scene_cyc / CPUHZ
total = hdr_ms + load_ms
surplus = kbps - wire
# What the header costs in the currency of 51.3: play-time at the
# surplus rate. A negative surplus means the container does not fit the
# pipe at all and no amount of play buys the bytes back.
slack = f"{hdr/(surplus*1024):8.3f}" if surplus > 0 else " NEVER"
print(f"{kbps:>6.0f} {hdr_ms:>10.2f} {load_ms:>9.2f} {total:>7.2f} "
f"{total/(1000/FPS):>7.2f} {surplus:>9.1f} {slack:>9}")
print("""
Reading it. The 'frames' column is the scene change's FIXED cost in 12fps
frame slots, before the ring has been given a single frame of lookahead -- so it
is a floor under the black gap at a branch point, not the gap itself. The
'slack s' column is the one that compounds with FINDINGS 51.3: the header's
bytes are bytes that did not go into the ring, so they lengthen the climb back
to the seek-slack ceiling by that much play-time, every time.""")
+124 -3
View File
@@ -55,6 +55,12 @@ void p6logd(const char *fmt, ...) { (void)fmt; }
#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
@@ -106,9 +112,16 @@ static void wr8(unsigned int a, unsigned char d)
* 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);
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;
@@ -124,7 +137,7 @@ static void wr16(unsigned int a, unsigned short d)
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 && a < FLAG + 4) note_flag();
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); }
@@ -185,12 +198,104 @@ static long long run(unsigned int off, unsigned int nfr, unsigned int iter)
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];
@@ -204,10 +309,15 @@ int main(int argc, char **argv)
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 (!nframes || !stream_len) { fprintf(stderr, "need --nframes and --stream (from decode_meta.lua)\n"); 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. */
@@ -216,6 +326,17 @@ int main(int argc, char **argv)
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;
+24
View File
@@ -270,4 +270,28 @@ grep -q "ceiling 8 frames" tmp/pace_check.log || {
grep -q "^OK" tmp/pace_check.log || { echo "FAIL: paced pass not pixel-exact";
tail -4 tmp/pace_check.log; exit 1; }
echo "--- session 21: the 68000 builds its own codebooks and palette (FINDINGS 53) ---"
# ROADMAP P1+P2. Until now tools/bench/dlxload.py expanded the codebooks and
# packed the palette HOST-SIDE and the rigs pushed the result into emulated RAM.
# A player has no host. src/player/load.i does both on the 68000, out of the RAW
# container header, and this gates it byte-for-byte against dlxload.py -- which
# stays the reference, because what changed is where the transforms RUN, not
# what they produce.
#
# Byte-for-byte and not "close enough": a wrong codebook byte is a wrong colour
# in every block that uses that codeword, and a wrong shared LSB is a slightly
# wrong colour that looks like a codec artefact rather than a loader bug.
# The palette half is read back out of the PALETTE REGISTERS at $E82000, so
# "the words reached the hardware" is part of what passes.
#
# NOT gated on the cycle counts, and the reason is NOT the one blit.s has. These
# are emulated time and reproduce exactly run to run; what they are not is
# sharp, because MAME samples them on a 1/55.46 s clock and the job takes
# milliseconds. Nothing in the tree's cost models depends on them either. A
# change in them is a re-derivation in FINDINGS 53, not a red light here.
bash tools/bench/load_run.sh "$DLX" > tmp/load_gate.log 2>&1 || {
echo "FAIL: the load-time transforms did not pass."; tail -12 tmp/load_gate.log
exit 1; }
grep -aE "^ *OK|both CPU cores|SCENE CHANGE" tmp/load_gate.log | sed 's/^ *//;s/^/ /'
echo "ALL GREEN"
+178
View File
@@ -0,0 +1,178 @@
-- Time and verify src/player/load.i on the emulated 68000 (ROADMAP P1+P2).
--
-- Two questions, one run, exactly as decode.lua asks them of the decoder:
-- 1. CORRECTNESS. Does the 68000 produce, out of the RAW container header,
-- byte for byte what tools/bench/dlxload.py produces host-side? The
-- expanded codebooks are read back out of RAM and the palette out of the
-- PALETTE REGISTERS -- not out of a RAM shadow, because "the words reached
-- $E82000" is the claim being tested. tools/bench/verify_load.py does the
-- comparison against dlxload.py, so the ground truth stays in one place.
-- 2. COST. How long does it take, split into the codebook expansion and the
-- palette pack, and what is that as a fraction of a 12 fps frame -- the
-- only unit this project prices anything in.
--
-- Nothing here is pre-chewed: the blob pushed into RAM is the first 5,920 bytes
-- of the container as they come off the disc. That is the whole point of the
-- exercise, and it is also, not incidentally, exactly the read a player has to
-- complete at a scene change before it can draw a single frame.
--
-- MEASUREMENT SCOPE, unchanged from decode.lua: MAME's memory carries no wait
-- states, so these are pure 68000 instruction cycles -- a LOWER BOUND on real
-- hardware. Interrupts are masked (SR=$2700). The host clock has 1/55.46 s
-- granularity and the job takes milliseconds, so each configuration is repeated
-- LITER times and divided; repeating is honest because do_load is not
-- temporally recursive -- every pass rewrites what the last one wrote, from the
-- same source bytes.
M = manager.machine
SP = M.devices[":maincpu"].spaces["program"]
local function findfile(n)
for _,p in ipairs{"../tools/bench/"..n, "tools/bench/"..n, n} do
local f = io.open(p,"rb"); if f then f:close(); return p end
end
error(n.." not found")
end
local MODE = loadfile(findfile("crtc_mode.lua"))()
local META = loadfile("load_meta.lua")()
local LFLAG, LHDR, LDARK = 0x18040, 0x18044, 0x18048
local LK1, LK4, LMODE, LITER = 0x1804C, 0x18050, 0x18054, 0x18058
local CB1, CB4, RAW = 0x20000, 0x22000, 0x30000
local GPAL = 0xE82000
local CPUHZ = 10000000 -- x68k.cpp:1133, 40_MHz_XTAL/4
local FPS = 12
local FRAME12 = CPUHZ / FPS
local ITER = tonumber(os.getenv("DLX_LOAD_ITER") or "40")
local code do local f=io.open("loadgate.bin","rb"); code=f:read("a"); f:close() end
local data do local f=io.open("load_data.bin","rb"); data=f:read("a"); f:close() end
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
local function P(s) print("[LOD] "..s) end
local function push(addr, s, from, len)
local i, n = from, len
while n >= 4 do
SP:write_u32(addr, (string.unpack(">I4", s, i)))
addr, i, n = addr+4, i+4, n-4
end
while n > 0 do
SP:write_u8(addr, string.byte(s,i)); addr, i, n = addr+1, i+1, n-1
end
end
-- Poison every destination before each run. Without this a stage that wrote
-- NOTHING would still compare equal to the previous stage's output, and the
-- palette-only run would "pass" the codebook check for free.
--
-- The three scratch tables are poisoned only before a run that CLAIMS to build
-- them (mode bit 2). They are scene-independent, so the palette-entry stage is
-- entitled to find them already there -- that is the whole point of measuring
-- it separately -- but a stage that says it builds them must be shown to.
local P6TAB, TABEND = 0x19000, 0x19340
local function poison(mode)
for a = CB1, CB1 + META.cb1_len - 2, 2 do SP:write_u16(a, 0xDEAD) end
for a = CB4, CB4 + META.cb4_len - 2, 2 do SP:write_u16(a, 0xDEAD) end
for c = 0, 255 do SP:write_u16(GPAL + c*2, 0xDEAD) end
SP:write_u32(LDARK, 0xFFFFFFFF)
if mode & 4 ~= 0 then
for a = P6TAB, TABEND - 2, 2 do SP:write_u16(a, 0xDEAD) end
end
end
local function setup()
MODE.apply(SP)
push(RAW, data, 1, META.raw_len)
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
P(string.format("loaded loadgate.bin=%d B, raw container header %d B at 0x%X",
#code, META.raw_len, RAW))
end
local function launch(mode, iter)
poison(mode)
SP:write_u32(LFLAG, 0)
SP:write_u32(LHDR, RAW)
SP:write_u32(LMODE, mode)
SP:write_u32(LITER, iter)
local cpu = M.devices[":maincpu"]
cpu.state["SR"].value = 0x2700 -- supervisor, ALL interrupts masked
cpu.state["SP"].value = 0x8000
cpu.state["PC"].value = 0x10000
end
-- Written after the mode-3 run, and only after it: it is the output of ONE
-- do_load call over the whole header, which is what the player does.
local function dump()
local out = io.open("load_out.bin", "wb")
for a = CB1, CB1 + META.cb1_len - 1 do out:write(string.char(SP:read_u8(a))) end
for a = CB4, CB4 + META.cb4_len - 1 do out:write(string.char(SP:read_u8(a))) end
for c = 0, 255 do out:write(string.pack(">I2", SP:read_u16(GPAL + c*2) & 0xFFFF)) end
out:close()
P(string.format("dumped %d B of 68000 output to tmp/load_out.bin",
META.cb1_len + META.cb4_len + 512))
P(string.format("DARK=%d (host-side dlxload.py says %d), K1=%d K4=%d",
SP:read_u32(LDARK), META.dark, SP:read_u32(LK1), SP:read_u32(LK4)))
end
-- Order matters: the scratch tables are built by the first stage and the
-- palette-entry stage runs on them, which is exactly how a player would be
-- arranged. The two stages that stand for real player events -- boot, and a
-- scene change -- come last, and the dump the verifier checks is taken from the
-- BOOT one, so the path that is proved correct is the one that builds
-- everything from nothing.
local PLAN = {
{name="scratch tables only (boot, once)", mode=4, iter=ITER},
{name="codebook expansion only (P1)", mode=1, iter=ITER},
{name="palette entries only (P2)", mode=2, iter=ITER},
{name="BOOT: tables + codebooks + palette", mode=7, iter=ITER, dump=true},
{name="SCENE CHANGE: codebooks + palette", mode=3, iter=ITER},
}
local step, st, t0 = 0, "boot", nil
local results = {}
SUB = emu.add_machine_frame_notifier(function()
local ok, err = pcall(function()
local t = T()
if st == "boot" then
if t < 3.0 then return end
setup(); step = 1; launch(PLAN[1].mode, PLAN[1].iter)
st, t0 = "running", nil; return
end
if st == "running" then
local fl = SP:read_u32(LFLAG)
if fl == 1 and not t0 then t0 = t; return end
if fl == 0xEE then
P("BAD HEADER -- load.i did not find the 'DLX3' magic at LHDR")
M:exit(); return
end
if fl == 0xFF then
local p = PLAN[step]
local dt = t - (t0 or t)
local cyc = dt * CPUHZ / p.iter
results[#results+1] = {name=p.name, cyc=cyc}
P(string.format("%s: %d passes in %.4f s -> %.0f cycles = %.1f%% of a "
.."%dfps frame (%.2f ms)", p.name, p.iter, dt, cyc,
100*cyc/FRAME12, FPS, 1000*cyc/CPUHZ))
if p.dump then dump() end
step = step + 1
if PLAN[step] then launch(PLAN[step].mode, PLAN[step].iter); st, t0 = "running", nil
else st = "finish" end
return
end
if t > 400 then P("TIMEOUT flag="..string.format("%08X",fl)); M:exit() end
return
end
if st == "finish" then
P("---- summary (instruction cycles only; real RAM adds wait states) ----")
for _,r in ipairs(results) do
P(string.format(" %-44s %8.0f cyc %5.1f%% of a frame %6.2f ms",
r.name, r.cyc, 100*r.cyc/FRAME12, 1000*r.cyc/CPUHZ))
end
P("done")
M:exit()
end
end)
if not ok then print("[LOD] LUA ERROR: "..tostring(err)); M:exit() end
end)
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# One load-time transform run: the 68000 builds its own codebooks and palette
# out of the RAW container header, on both CPU cores (ROADMAP P1+P2, FINDINGS
# 53).
#
# tools/bench/load_run.sh [container]
#
# Both instruments run the same loadgate.bin over the same header bytes:
# * MAME, which is the only one of the two with real PALETTE REGISTERS -- the
# packed words are read back out of $E82000, not out of a RAM shadow, so
# "the words reached the hardware" is part of what passes.
# * px68k's C68K, which is exact to the cycle and counts BUS cycles, and is a
# second opinion on the cost from a separately written cycle table.
# Both outputs are compared byte-for-byte against tools/bench/dlxload.py, which
# stays the reference: this code replaces where those transforms RUN, not what
# they produce.
set -e
cd "$(dirname "$0")/../.."
DLX=${1:-tmp/rc_fr_singe_scsi_span.dlx}
PX68K=${PX68K:-$HOME/src/px68k}
ITER=${DLX_LOAD_ITER:-40}
tools/vasm/vasmm68k_mot -Fbin -o tmp/loadgate.bin src/player/loadgate.s > /dev/null
python3 tools/bench/prep_load.py "$DLX" > tmp/prep_load.log
cat tmp/prep_load.log
# stdbuf -oL: without it a long MAME run is unobservable until it exits, and a
# run that is merely finishing looks exactly like one that is wedged (34.1).
( cd tmp && DLX_LOAD_ITER=$ITER SDL_VIDEODRIVER=dummy stdbuf -oL timeout -k 5 180 \
mame x68000 -bios ipl10 -ramsize 2M -video soft -window -sound none \
-nothrottle -plugins -autoboot_script ../tools/bench/load.lua \
-seconds_to_run 30 > load_check.log 2>&1 )
# A run that never reached the dump must fail as that, not as a byte mismatch.
grep -q "^\[LOD\] done" tmp/load_check.log || {
echo "FAIL: the load rig did not finish -- no completion marker."
tail -6 tmp/load_check.log; exit 1; }
grep -a "^\[LOD\]" tmp/load_check.log | sed -n '/summary/,$p' | sed 's/\[LOD\] / /'
python3 tools/bench/verify_load.py "$DLX"
if [ -f "$PX68K/m68000/c68k.c" ]; then
make -s -C tools/bench/c68k PX68K="$PX68K" 2>/dev/null
for M in 4 1 2 7 3; do
tools/bench/c68k/c68k_bench --code tmp/loadgate.bin --loadraw tmp/load_data.bin \
--loadmode $M --loaditer 1 --cb1 8192 --cb4 2048 \
$([ $M = 3 ] && echo "--loaddump tmp/load_c68k.bin") 2>&1 >/dev/null \
| grep -av arena | sed 's/\[C68K\] / /'
done
# The second core's bytes are held to the same standard as the first's.
cmp -s tmp/load_c68k.bin tmp/load_out.bin || {
echo "FAIL: the two CPU cores produced DIFFERENT load-time output."; exit 1; }
echo " OK both CPU cores produced the same $(stat -c%s tmp/load_out.bin) B"
else
echo " SKIPPED: no px68k at $PX68K (set PX68K= to point at a checkout)"
fi
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Lay out the LOAD-TIME test: raw container header in, expected results out.
python3 tools/bench/prep_load.py <in.dlx> [--out tmp/load]
src/player/load.i does on the 68000 what tools/bench/dlxload.py has been doing
host-side since session 1: expand the two codebooks to word-per-pixel form and
pack the 24-bit palette into GGGGGRRRRRBBBBBI with the shared LSB chosen per
entry (ROADMAP P1 and P2). This writes both halves of that comparison.
<out>_data.bin the container's HEADER REGION, byte for byte as it comes
off the disc: magic, geometry, the three section offsets,
the 768-byte palette, CB1 and CB4. Nothing is pre-chewed --
that is the entire point. It ends where the frame stream
begins, so it is also exactly what a player would have to
read before it could draw anything.
<out>_expect.bin what dlxload.py says the 68000 must produce: expanded CB1,
expanded CB4, then 256 big-endian palette words.
<out>_meta.lua sizes, k1/k4, and the expected darkest-entry index.
The expectation is generated by the SAME module the two decode rigs load
through, so this cannot pass by agreeing with a second copy of the maths.
"""
import sys, argparse
sys.path.insert(0, "tools/encoder")
sys.path.insert(0, "tools/bench")
from dlx import DLX
import dlxload as DL
ap = argparse.ArgumentParser()
ap.add_argument("container")
ap.add_argument("--out", default="tmp/load")
a = ap.parse_args()
d = DLX(a.container)
if d.version < 3:
sys.exit(f"{a.container} is DLX{d.version}: load.i checks for the 'DLX3' magic")
if d.idx_bytes != 1:
sys.exit("2-byte codebook indices: load.i expands one source byte per pixel")
off_frm = int.from_bytes(d.raw[28:32], "big")
raw = d.raw[:off_frm]
cb1, cb4 = DL.expand_codebooks(d)
palb, dark, _ = DL.pack_palette(d)
open(a.out + "_data.bin", "wb").write(raw)
open(a.out + "_expect.bin", "wb").write(cb1.tobytes() + cb4.tobytes() + palb.tobytes())
with open(a.out + "_meta.lua", "w") as fh:
fh.write("-- generated by tools/bench/prep_load.py -- do not edit\nreturn {\n")
fh.write(f" k1={d.k1}, k4={d.k4}, dark={dark},\n")
fh.write(f" raw_len={len(raw)}, cb1_len={cb1.nbytes}, cb4_len={cb4.nbytes},\n")
fh.write(f" pal_len={palb.nbytes},\n}}\n")
print(f"{a.container}: k1={d.k1} k4={d.k4}, header region {len(raw)} B "
f"(pal 768 + cb1 {d.k1*16} + cb4 {d.k4*4} + 32)")
print(f" the 68000 must produce {cb1.nbytes} + {cb4.nbytes} B of expanded "
f"codebook and {palb.nbytes} B of palette, darkest entry {dark}")
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Check the 68000's load-time output against tools/bench/dlxload.py, byte for byte.
python3 tools/bench/verify_load.py <in.dlx> [--out tmp/load]
The 68000 ran src/player/load.i over the RAW container header; tools/bench/
load.lua read the results back out of emulated RAM and out of the PALETTE
REGISTERS. This compares them with what the host-side transforms produce.
Byte-for-byte and not "close enough", for both halves:
* the codebooks are indices, so a single wrong byte is a wrong COLOUR in
every block that uses that codeword, in every frame of the scene.
* the palette words carry the shared LSB the encoder's 1.96 dB (FINDINGS
23.3) depends on, and a wrong choice of it is invisible in a diff of the
picture's SHAPE -- it is a slightly wrong colour, which is exactly the sort
of thing that gets attributed to the codec.
The darkest-entry index is checked too: it is what the letterbox is filled
with until the encoder reserves a black entry (23.4, still open), and it comes
out of an argmin whose tie-break has to match numpy's -- first index wins.
"""
import sys, argparse
sys.path.insert(0, "tools/encoder")
sys.path.insert(0, "tools/bench")
from dlx import DLX
import dlxload as DL
ap = argparse.ArgumentParser()
ap.add_argument("container")
ap.add_argument("--out", default="tmp/load")
ap.add_argument("--log", default="tmp/load_check.log",
help="the rig's log, for the DARK= line it printed")
a = ap.parse_args()
d = DLX(a.container)
cb1, cb4 = DL.expand_codebooks(d)
palb, dark, _ = DL.pack_palette(d)
want = cb1.tobytes() + cb4.tobytes() + palb.tobytes()
got = open(a.out + "_out.bin", "rb").read()
if len(got) != len(want):
sys.exit(f"FAIL: the 68000 produced {len(got)} B, expected {len(want)}")
n1, n4 = cb1.nbytes, cb4.nbytes
sections = (("CB1", 0, n1), ("CB4", n1, n1 + n4), ("palette", n1 + n4, len(want)))
bad = 0
for name, lo, hi in sections:
diff = [i for i in range(lo, hi) if got[i] != want[i]]
if diff:
bad += len(diff)
i = diff[0]
print(f"FAIL: {name}: {len(diff)}/{hi-lo} bytes differ; first at "
f"+{i-lo} (68000 {got[i]:#04x}, dlxload {want[i]:#04x})")
else:
print(f" OK {name}: {hi-lo} B identical to dlxload.py")
# The rig prints the index the 68000 chose; parse it rather than re-deriving,
# so a rig that failed to read LDARK cannot pass by silence.
got_dark = None
for line in open(a.log, "rb").read().decode("utf-8", "replace").splitlines():
if "DARK=" in line:
got_dark = int(line.split("DARK=")[1].split()[0].rstrip(","))
if got_dark is None:
sys.exit("FAIL: the rig printed no DARK= line -- it did not reach the dump")
if got_dark != dark:
sys.exit(f"FAIL: darkest palette entry: 68000 says {got_dark}, dlxload says {dark}")
print(f" OK darkest entry {dark}, chosen by the same argmin tie-break")
if bad:
sys.exit(f"FAIL: {bad} bytes differ in total")
print(f"OK the 68000 reproduced all {len(want)} B of load-time output exactly "
f"(P1 codebooks, P2 palette)")