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
+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)")