#!/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.""")