FINDINGS 59.7. tools/analysis/15_bus_occupancy.py has always answered "what does each W cost" and never "what can the frame afford", and after 59.2 those are not the same question. It now answers both, and takes an optional --kbps for the auto-request rows -- the only rows whose cost depends on how long the record takes to arrive. On the gate container at 12 fps, decode term MEASURED: decode 68.5%, audio 1.25%, HEADROOM 30.2% = 6.74 clocks per byte at a 37,403 B record. Against that, P4a cut the transport from 391.7% of a frame to 40..95% -- four to ten times, the largest movement in this project's cost model since the decoder was written -- and it still does not fit. A dual-address byte is a 4-clock read of the device plus a 5-clock write to memory, so 9 clk/B is a FLOOR and the frame affords 6.74. No GCR share goes under it and no delivery rate goes under it: a share decides whether the channel sits at the floor or above it. At 460 KB/s max-rate totals 165.1% and LRAR at 50% totals 117.4%, and a 50% share tops out at 543 KB/s, above which the channel is the bottleneck and the rate falls back to exactly that floor. So 59.2's three bounds arrive in the budget as one sentence: the configurations this machine can run are the ones the frame cannot afford, and the one it can afford -- single address, 5 clk/B, 92.2% total, 7.8% spare -- needs the device to ACK the DMAC, which needs a request line MAME does not connect and the slot pinout does have at B36/B37. ROADMAP re-ranks accordingly. B3 stops being a constant to look up and becomes DOES THE CARD DRIVE #EXREQ, ahead of B1: B1 sets how much headroom the player has, B3 decides whether there is any. New E7 carries the other branch -- if the answer is no, the container must reach 27,995 B a frame, 328 KB/s of payload, against 438 now. The dependency diagram is redrawn around that fork. The scope is stated rather than buried: this is the GATE container, deliberately the heaviest thing the encoder emits, and the lighter cpufit family was NOT priced -- 15_bus_occupancy.py refuses it, correctly, because the C68K measurement it cross-checks against belongs to the gate container. E7 therefore begins with a harness re-run, and until then "34% too big" is a statement about the fixture and not about the project. check.sh ALL GREEN before and after. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
144 KiB
Status & next-session handoff — end of session 27 (2026-08-24)
Session 27: the DMAC drives the data phase, and auto-request is charged by time
Green light first and last: ./tools/bench/check.sh was ALL GREEN before any
of this and ALL GREEN after, plus one new stage.
ROADMAP P4a is DONE at the transport level. FINDINGS 59. Emulated —
MAME 0.277, x68000 -exp1 cz6bs1 -ramsize 2M. No real hardware ran.
1. THE CHANNEL DRIVES THE DATA PHASE. src/player/dma.i programs HD63450
channel 1 and src/player/dmagate.s reads the same 2,048 B at LBA 1000 three
ways, with the host comparing all three against its own copy of the image:
| bytes | MTC one instruction after START | CPU trips round its wait loop | |
|---|---|---|---|
| PIO, the path 58.2 measured | byte-exact | — | — |
DMA, bus HELD (DCR $00, OCR $81) |
byte-exact | 0 of 2048 | 1 |
DMA, cycle STEALING (DCR $80, OCR $80) |
byte-exact | 2048 of 2048 | 426 |
Both channels reported CSR = $E0, CER = $00, MTC = 0, MAR exactly
+2048.
2. THE EVIDENCE NEVER READS $EA0015, which is the whole design. 57.3 said
it could not: with the DMAC's OWN asserted, MAME cannot tell a CPU-driven byte
at that address from a DMAC-driven one. So the discriminator is the CPU's own
progress — MTC sampled by the instruction after the one that starts the
channel. Held, the whole transfer happened between two instructions. The gate
was checked against its own negative: with the stealing register pair in the
held slot, every byte still arrives and dma_run.sh goes RED. A counter that
cannot come out different is 58.3's vacuous "UNDERRUNS: 0/120" again.
3. THREE THINGS THIS MODEL CANNOT BE ASKED, read out of MAME's source: the
card has no request line to the DMAC (its flow control is DTACK, so external
request cannot be run); single address cannot be run either (only channel 0
has device callbacks); and only burst is modelled as held ((dcr & 0xc0) == 0, so cycle-steal-with-hold shares the not-held path). Of the four rows of the
W ladder, exactly one — dual address, held, 9 clk/B — has a code path here,
and it is the one demonstrated. The slot pinout has #EXREQ at B36, so a real
card plausibly drives it; that is ROADMAP B3, sharpened.
4. AUTO-REQUEST IS CHARGED BY TIME, NOT BY BYTE — and this outlives the
emulator. Every W in this project presumes the device asks for each byte. An
auto-requested channel spends its share of the bus whether or not a byte is
there, so a record's cost scales with how long it takes to arrive: halve the
rate and the CPU cost of the same record doubles. tools/analysis/ 28_autorequest_cost.py prices it from MC68450 §3.8/§5.2.3.3.2, gating its
formulas against Table 5-3's sixteen rows first. At 37,405 B and 460 KB/s, an
explicit rate: max rate costs the whole 95.3% of a frame; of the GCR's
four bus shares only BR = 00, 50%, carries the rate, at 10.61 clk/B and
47.6% of a frame. The GCR is a design lever nothing in this tree had named.
5. THE WINDOW IS REFUSED, so the re-encode is now a precondition. A channel
writes a contiguous run and cannot drop the 300 B in front of a record, so
sc_in_data refuses a windowed read when the data phase is the DMAC's
(SCE_WINDOW) rather than writing the neighbours into the ring (49.2). 58.3's
"sector-aligned records should join the bundle" has become "the transport will
not run without them".
6. WHAT IT ALL COSTS, and this is the part that re-ranks the roadmap.
15_bus_occupancy.py now answers "what can the frame afford" as well as "what
does each W cost". On the gate container at 12 fps, with the decode term
MEASURED: decode 68.5%, audio 1.25%, headroom 30.2% = 6.74 clocks per byte
at a 37,403 B record.
| transport | clk/B | video | total |
|---|---|---|---|
| PIO, measured (58.2) | 87.28 | 391.7% | 461.5% |
| dual address, held — the floor | 9 | 40.4% | 110.2% |
| single address, held | 5 | 22.4% | 92.2% |
P4a cut the transport from 391.7% to 40..95%, four to ten times — and it still
does not fit. A dual-address byte is 4 clocks of device read plus 5 of memory
write, so 9 is a floor and the frame affords 6.74; no GCR share and no
delivery rate goes under it. At 460 KB/s max-rate totals 165.1% and LRAR at 50%
totals 117.4%. So the fit turns on B3, which has stopped being a constant to
look up: does a real CZ-6BS1 drive #EXREQ? If yes, single address fits at
92.2% with 7.8% spare. If no, the container must reach 328 KB/s of payload
(new ROADMAP E7), against 438 now.
The scope matters and is stated in 59.7: this is the GATE container, the
heaviest thing the encoder emits. The lighter cpufit family was NOT priced —
the tool refuses it, correctly, because the C68K measurement belongs to the gate
container — so E7 begins with a harness re-run, and until then "34% too big" is
about the fixture, not about the project.
ONE COLLISION, AND THE BEFORE-HALF OF check.sh CAUGHT IT. DM_USE was
first placed at $18300, which is ring.i's XF_SLOT mailbox — scsi.i's
trace ends at $182FF and the next 160 bytes are the ring's. The ring rig's
first record request therefore wrote a non-zero word into what the transport
read as "use the DMAC", and the P4b stage, which this session did not touch,
went red. The map is now $18500. Green-before is not a formality: it is what
made a red in an untouched stage unambiguous. 59.6.
New in the tree: src/player/dma.i, src/player/dmagate.s,
tools/bench/dma.lua, tools/bench/dma_run.sh,
tools/analysis/27_dmac_config.py (decodes what the player programs, out of the
player's own source), tools/analysis/28_autorequest_cost.py,
tools/analysis/mc68450.py (the MC68450 field tables, now ONE copy, shared with
21_iplrom_dmac.py), and one check.sh stage. src/player/scsi.i gained the
DM_USE dispatch and SCE_WINDOW.
No decoder code changed. decode.bin is still 1,296 B at the same MD5.
Next: THE RE-ENCODE BUNDLE, and it is now the only thing between this tree and
M2. Four items sharing one re-measurement (ROADMAP P2): index-0 black,
--spans all as default, joint lam, and sector-aligned records, which
59.4 promoted from preference to precondition. After it, P4a's channel goes
behind ring.i's mailbox and the 120-frame pixel-exact pass runs with the CPU
free — which is the shape of M2.
Status & next-session handoff — end of session 26 (2026-08-24)
Session 26: the player runs off the disc, and PIO costs 87 clocks a byte
Green light first and last: ./tools/bench/check.sh was ALL GREEN before any
of this and ALL GREEN after, plus two new stages.
ROADMAP P4b is DONE. P4a is the only item left before M2. FINDINGS 58.
Emulated — MAME 0.277, x68000 -exp1 cz6bs1 -ramsize 2M. No real hardware
ran.
1. THE SEAM IS CLOSED. src/player/xfer.i sits behind src/player/ring.i's
XF_* mailbox in place of tools/bench/stream.lua's modelled transport. XF_GO
is answered by a real READ(10) to a real MB89352; XF_ACK is a word the 68000
bumps when the bytes have landed, not one a host synthesises from emulated time.
| 120 records fetched by the 68000 | one READ(10) each |
| 4,488,588 B into a 256 KB ring | pixel-exact over all 120 frames |
| a real mid-stream seek, pass 2 | pixel-exact after it too |
| 18 wraps, 14.7 KB mean hole | the SAME tiling as 49.4 and 55.4 |
The 18 wraps are the load-bearing assertion. A host producer made them in
session 18, ring.i made them against a modelled transport in session 23, and a
real SPC makes them now — ring.i cannot tell which side of the mailbox
answered it, which is what makes this a test of the transport rather than of a
new rig. 58.1.
The change above the seam is two bsrs, and the one in ring_seek's
quiet-wait is not optional: with the transport inside the machine, that loop
is the only thing that can retire an outstanding request, so without it a seek
issued with a request in flight spins forever. A host transport retired it on
its own time. That is exactly the kind of difference the seam exists to hide,
and it is the one it could not.
2. WHAT IT COSTS, AND THE NUMBER LEAVES THE EMULATOR. tools/bench/ xfer_cost.sh runs the same 120 frames twice — same ring, same stream.s, same
ring.i placing every record — and changes only which side answers:
| emulated | per frame | |
|---|---|---|
decode + ring_poll alone |
6.7737 s | 67.7% of a 12 fps frame |
| ...with the real transport | 45.9516 s | 459.5% |
| the transport alone | 391,779,000 clk | 391.8% |
87.28 clocks per delivered byte. The keep loop in scsi.i, priced against
the 68000's own cycle table, is 86 clocks, and the FIFO also carries the
dropped window bytes of 58.3 — 87.15 predicted, 87.28 measured, 0.2% apart.
So this is the instruction stream and not MAME's SPC model: the first
number this rig has produced that a real board would also pay. The machine's
own V-DISP clock agrees from the other end — 120 frames in 560 slots of a 12 fps
clock is 2.57 fps. 58.2.
Against the ladder (clocks charged to the CPU per delivered byte):
W=5 held is 22.4% of a frame, W=9 is 40.4%, W=19 — the IPL ROM's own disk
channel, 52.5 — is 85.3%, and PIO is 391.8%. P4a is worth 4.6x the worst
DMA configuration in this tree and 17.5x the best, where before this session
it was worth 9 against 19. W itself did not move by one clock.
3. "UNDERRUNS: 0/120" IS VACUOUS HERE, and the rig now says so in place. A
synchronous transport cannot underrun by construction — a frame cannot start
before its record has landed, because the decoder is the transport. The
counter that means something is NO IDLE: 119 of 120 frames found their slot
already open, worst overrun 441 whole ticks. Same class of error as 49.7.2's
free-running ring passing at 48 KB: a rig configuration in which the failure
being tested for cannot occur. The binding resource flipped too — against a
modelled 488 KB/s pipe this container was rate-bound and the ring never filled;
here the ring fills, 90 refusals for space, while the decoder starves for
time.
4. A RECORD IS NOT A SECTOR, and the cheapest fix is a re-encode. 117 of 120
records start part way into a sector. Reading whole sectors into the ring
corrupts the neighbouring records — the block loop has no bounds check
(49.2) — so scsi.i reads the covering sectors and stores only the window.
That is free in PIO because the CPU is already touching every byte, and it
stops being free the moment P4a succeeds: a channel writes a contiguous run
and cannot drop bytes. tools/analysis/26_sector_align.py prices the three ways
out: windowed PIO +1.34% wire, no DMA; bounce buffer +1.34% wire and
+5 clk/B of copy (22.4% of the frame — the cost aligned was chosen over
split to avoid, arriving by a different door); sector-aligned records
+0.43% wire and zero clocks. The last wins on both axes and is a container
change. ROADMAP now carries a four-item re-encode bundle (index-0 black,
--spans all, joint lam, sector alignment) and P4a should be attempted
against a sector-aligned container, not against this one. 58.3.
5. The rig refuses to be asked for a rate. DLX_XFER=scsi rejects a
non-zero --kbps outright rather than ignoring it, and stream.lua suppresses
CHANNEL IDLE, DEADLINE and REQUIRED PREFILL rather than printing them as
zeros — a zero there reads as "the channel never stopped", which would be a
claim about a medium this tree has never timed.
One rig bug found and fixed in the same sitting: the host's nsent is now
rebased at every seek. XS_NXFER is cumulative, so on a second pass it was
already >= nframes and the slack sampling never fired — the second pass
reported a ceiling of 0 frames and a build time of -1 ticks, which is an
empty series printing as a result.
New in the tree: src/player/xfer.i, tools/bench/mkvol.sh (one copy of
the volume layout, shared by both SCSI rigs), tools/bench/xfer_cost.sh,
tools/analysis/26_sector_align.py, DLX_XFER=scsi and DLX_SECONDS in
pace_run.sh, and two check.sh stages. src/player/scsi.i gained
sc_in_data (the three-loop windowed receive) and scsi_read_win; ring.i
gained the two transport hooks; stream.s includes scsi.i and xfer.i.
No decoder code changed. decode.bin is still 1,296 B at the same MD5.
Next: P4a, and it is now the whole of what is left before M2. A DMAC
configuration that HOLDS THE BUS, shown by evidence that does not come from
watching $EA0015 (57.3: with exown asserted, MAME cannot tell a CPU-driven
byte there from a DMAC-driven one). Do it against a sector-aligned container if
the re-encode bundle has been run by then, because 58.3 is otherwise a bounce
copy at +5 clk/B. P2's remaining half is unchanged and is now item 1 of
that bundle.
Status & next-session handoff — end of session 25 (2026-08-24)
Session 25: the 68000 reads the disc itself, and P4 was never blocked
Green light first and last: ./tools/bench/check.sh was ALL GREEN before any
of this and ALL GREEN after, plus a new SCSI stage.
ROADMAP P4 is HALF DONE. FINDINGS 57. Emulated — MAME 0.277,
x68000 -exp1 cz6bs1. No real hardware ran.
1. THE BLOCKER WAS A MISSING FILE, NOT A MISSING MODEL — and this tree already
knew. Session 21's handoff recorded, re-checked rather than assumed, that
"MAME's x68000 has no MB89352 path". It has one: -exp1 cz6bs1 instantiates a
Fujitsu MB89352 @ 5.00 MHz next to the HD63450, and FINDINGS 32.4 had read
that card's DMA glue in session 9. What is actually absent is the 8 KB
scsiexrom.bin, which MAME requires to instantiate the card and which the
player never executes — driving the SPC registers directly has been the plan
since docs/BENCHMARK.md item 4 in session 2. A zero-filled placeholder on a
separate rompath starts the machine; the user's romset is untouched. B3 is
untouched too: it wants that ROM's bytes disassembled, and a blank one has
none. 57.1.
2. THE RESULT. src/player/scsi.i on the 68000, no IOCS and no host in the
transfer path, selects the target and issues READ(10):
| 4,096 B from LBA 0 | byte-for-byte against the host's image |
| 2,048 B from LBA 1000 | byte-for-byte |
The non-zero LBA is the half that matters: a driver that emits a malformed LBA
field still passes LBA 0, because zero is what a malformed field usually is.
The volume is tmp/stream_disk.bin, the same file the ring rig reads, so the two
rigs cannot differ in what they are reading. 57.4.
3. The register map is measured, and it corrects MAME's own documentation.
The probe walks $EA0000..$EA003F one address at a time and survives each bus
error. 60 of 64 answer; the two holes are $EA0007 and $EA001F — exactly
the TMOD and EXBF the MB89352 omits and the MB87030 has. MAME leaves HOLES
and does not "shift subsequent indices", which its own device summary claims,
and that is what keeps DREG at index 10 and at $EA0015. TEMP took $A5 and
gave it back. 57.2.
4. THE DATA REGISTER IS DMA-ONLY, AND A PIO WRITE VANISHES. x68k_scsiext.cpp
glues $EA0015 and nothing else: with exown() asserted and DRQ low a write is
discarded silently — no error bit, no status change, no interrupt. Quieting
all four DMAC channels does not change it. So every transfer runs the SPC in DMA
mode and the CPU moves the bytes through $EA0015 itself, via dma_w/dma_r:
the CPU standing in for the DMAC, through the DMAC's own door.
The cost to the argument, stated because it is easy to overclaim: with
exown asserted at idle, MAME cannot distinguish a CPU-driven byte at
$EA0015 from a DMAC-driven one. This rig shows the DATA PATH and cannot, by
itself, show that the HD63450 is driving it — which is exactly what ROADMAP
calls P4's first job. Whether a real CZ-6BS1 also refuses PIO here is not
settled; it is a property of MAME's model. 57.3.
5. W DID NOT MOVE. Not by one clock. MAME's device models are functional,
not transfer-timing accurate, and 42.5 reads its DMAC configured in wall-clock
attotimes rather than per-operand cycles. W is still the project's largest
open number and still wants a board.
6. Five bugs, four of them silent (57.5), recorded because the pattern is the
finding: a chained-rol transfer counter that loaded ZERO; a FIFO byte mistaken
for a bus byte; a fixed phase sequence where the bus decides the order; the
discarded PIO write; and an initiator that must drop ACK and then release the
bus. The last only appeared once there were two reads — one read passed
byte-exact and the second could not select. A player issues one command per
record, so that failure would have been universal in the ring and invisible in a
one-read demonstration.
New in the tree: src/player/scsi.i (the driver), src/player/scsigate.s
(the probe and gate front-end), tools/bench/scsi.lua, tools/bench/scsi_run.sh,
and a check.sh stage that builds the volume with chdman, gates the register
window at 60 of 64 and both reads byte-exact, and is skipped when chdman is
absent.
No decoder code changed. decode.bin is still 1,296 B at the same MD5.
Next: P4's other half, in two pieces. (a) The HD63450 driving the data
phase in a configuration that HOLDS THE BUS — and 57.3 is a warning about how it
will have to be shown, because this apparatus cannot tell a DMAC-driven byte
from a CPU-driven one at $EA0015. (b) scsi.i behind ring.i's XF_*
mailbox in place of stream.lua's modelled transport, gated on the same
pixel-exact 120 frames — the seam is already there, and XF_ACK becomes
something the 68000 bumps when a transfer lands rather than something a host
synthesises. P2's remaining half (reserve index 0 as black) is unchanged and
still bundled with the two other re-encode-class questions from 55.
Status & next-session handoff — end of session 24 (2026-08-24)
Session 24: the scene graph is in, and the worst gap between two decision points is zero
Green light first and last: ./tools/bench/check.sh was ALL GREEN before any
of this and ALL GREEN after, plus a new import stage.
ROADMAP G1 is DONE. FINDINGS 56. G1 was scheduled early because it is a measurement input, and it paid for that immediately.
1. THE ANSWER: the worst gap is zero. Over 612 distinct transitions into a seek, taking the earliest instant each input window opens and chaining play across sequences the disc plays through without seeking:
| worst | p10 | median | p75 | p90 | best | |
|---|---|---|---|---|---|---|
| seconds of play between two seeks | 0.000 | 0.950 | 3.473 | 5.800 | 9.548 | 82.497 |
33 of the 612 (5.4%) are zero — an input window that opens at t=0 of a clip
the disc seeked to, so two seeks can fall back to back with no play at all
(flaming_ropes.enter_room -> fall_to_death: press right on the first frame and
you die). 51.2's slack rule can be answered NO by the content, not by the
buffer, and no amount of ring is a defence. 56.3.
2. Most branch points arrive before the ring has refilled, at every rate this tree has considered. 51.3's climb against the game's own gaps, gate container:
| ring KB | pipe KB/s | ceiling | climb s | branch points under the climb |
|---|---|---|---|---|
| 256 | 451.4 | 3 | 20.83 | 601/612 (98%) |
| 256 | 488.0 | 7 | 6.11 | 468/612 (76%) |
| 256 | 513.2 | 7 | 3.81 | 370/612 (60%) |
| 256 | 600.0 | 7 | 1.66 | 129/612 (21%) |
| 512 | 488.0 | 11 | 9.60 | 551/612 (90%) |
A bigger ring makes this worse and now content says so too: same rate, 76%
at 256 KB and 90% at 512 KB, because doubling the ceiling does not touch
pipe - wire. The ring is not the lever; the surplus is. 56.4.
3. It does not break — it removes margin. A branch on an empty ring costs the prefill, not the climb: 149.7 ms (1.80 frame slots) at 488 KB/s, 162.0 ms if it is a scene change carrying the 6,164-byte header. So the finding is not "this fails", it is "this design runs permanently at minimum lookahead, and the arcade content is what puts it there". The mechanical seek is still unmodelled (B1) and is charged on top. 56.5.
4. CORRECTION to FINDINGS 16: there is only one transcription. 16 cleared
two permissively licensed sources and planned to diff them "to catch
transcription errors". The SNES project's own data/events/README.md says its
chapters are "derived from DirkSimple game data" — a second copy, not a second
transcription. The diff runs anyway and catches conversion errors: durations
agree 388/505 within one frame, branch structure 470/505, and of the 35
differences 16 are renames and 18 of the other 19 are the SNES conversion
dropping the arcade's diagonals. Zero transcription discrepancies were found
and none could have been. 56.2, 56.6.
5. Two constraints on the input layer, free with the import. The arcade uses
eight directions plus action and start (diagonals are 21 of 906 windows —
droppable by a port that must, not by one aiming at the arcade), and the
shortest input window is 98 ms against a frame slot of 72.13 or 90.16 ms
(54.4). Input cannot be polled on the frame tick; clock.i's V-DISP
interrupt already runs 8.6x faster at 0.1% of the budget. 56.7.
6. The coupling to outside source is contained to one file (USER DECISION).
tools/import/scenegraph.py is the only file in this tree that knows those
projects exist — their paths, table names, timing formulas, constants — and it
writes DLXSCENE1, our own schema, into gitignored tmp/.
tools/analysis/25_scene_graph.py reads only that. Nothing is vendored, nothing
outside-derived is committed, and the generated table carries its own sources
attribution block. The split was made after the measurement and changed no
number in it.
New in the tree: tools/import/ (new directory, one file),
tools/analysis/25_scene_graph.py, and a check.sh stage that imports, gates
on 516 sequences / 906 input windows, and runs the analysis — skipped when there
is no checkout, like the px68k and IPL ROM stages.
No 68000 code ran and none changed. decode.bin is still 1,296 B at the
same MD5.
Next: P4 (drive the MB89352, settle W) still decides the project and
still needs hardware or a MAME that models the SPC. What 56 changes about it:
the transport now has a content requirement as well as a rate one — it has to
survive a branch with an empty ring at zero notice, 5.4% of the time. P2's
remaining half (reserve index 0 as black) is unchanged and still bundled with
the two other re-encode-class questions from 55: the delivered-rate rate point
and 54.4's short slot. All three are still one re-encode plus one
re-measurement, and still want deciding together.
Status & next-session handoff — end of session 23 (2026-08-24)
Session 23: the 68000 fills its own ring, and the player's request loop turns out to cost more than the medium does
Green light first and last: ./tools/bench/check.sh was ALL GREEN before any
of this and ALL GREEN after, 120/120 on both cores, no TRUNCATED, plus two
new ring stages.
ROADMAP P5 is DONE. FINDINGS 55. P5 was the last M2 item buildable in this tree, and it is the third and last policy to move off the host: the loader went in session 21, the frame clock in 22, and the ring producer now.
1. The container had to change, and it is the first format change since
session 12. aligned asks whether the next record fits before the end of the
ring — a question about a record's length asked before it is fetched — and
every reader in this tree answered it by WALKING the frame stream, which is
exactly what a player streaming off a disc cannot do. DLX4 carries
nframes u16 record lengths in the scene header. The frame payloads are
byte-identical to the DLX3 encode, asserted record for record, so no
constant fitted to the gate container moves; the scene header goes 5,920 →
6,164 B. dlx.py refuses a container whose index disagrees with its own
walk. 55.1.
2. src/player/ring.i reproduces the host producer exactly. Same 18 wraps,
same 14.7 KB mean hole, pixel-exact — a third independent implementation of
aligned landing on the same tiling (55.2). The host now audits every
placement instead of making it.
3. THE ONE THAT MOVES SOMETHING: the disc stands still whenever the player is not asking. A channel only moves bytes while it has a request and only the CPU can issue one, so there is a gap between every pair of records that is a property of the player's loop, not the medium — and no host-filled run could see it, so no rate table in this tree contains it. At 488 KB/s in a 256 KB ring, changing nothing but how many requests the player may have outstanding:
| queue | channel idle | underruns | slack ceiling | bound by |
|---|---|---|---|---|
| 1 request | 6.8% | 59/120 | 2 | rate |
| 2 requests | 3.4% | 0/120 | 5 | ring |
This container's surplus over the wire at 488 KB/s is 8.7% of the pipe, and a one-deep request loop spends 6.8% of it on nothing — most of the surplus 51.3's lookahead is accumulated out of. A second slot costs no clocks and the hardware has four channels (52.1). 55.3.
4. Prefill is the weaker lever, and now it has a number. Six records of prefill — half a second of black at every scene start — still leaves 24 underruns at a one-deep queue; a second queue slot leaves none. A prefill buys a one-off cushion a rate-bound pipe spends immediately; a queued request buys the rate back every frame. The shipped policy is 2 records, and it is not 1 because 51.2 measured that n resident records buy n-1 frame times. 55.4.
5. The slack rule and a seek are in the player. ring_may_seek is 51.2's
rule as arithmetic the player runs; ring_seek quiets the channel, takes record
j's disc address out of the index and empties the ring. Rehearsed as a second
pass: 240 records, seek at 12.87 s, refill from empty, 0 underruns after it
and the last frame of the second pass pixel-exact. The seek shows up as the
run's worst channel gap, 397.5 ms — disc idle, not mechanical seek, which is
still unmodelled. 55.5.
6. Three silent bugs and one instrument correction, all written up in 55.7, because every one of them produced wrong pixels or a desync rather than a fault: the read cursor's wrap rule not matching the writer's; the free-space test deciding a wrap before it knew whether the LIVE span was the wrapping one; the request queue gated on completion instead of retirement (only reachable above depth 1). The fourth is the rig's: MAME renders a screen line by line, so snapshotting the frame in which the decoder finished captures a tear — it looked exactly like a decoder bug and was not. The rig now settles one frame before capturing. No previously reported result is affected.
New in the tree: src/player/ring.i (the producer, the prefill, the slack
rule and the seek); tools/analysis/24_ring_owner.py (an independent model of
all of it, sharing no code with the rig). encode.py/dlx.py gain DLX4 and its
index cross-check; prep_stream.py emits the index and checks it against the
disk image; load.i accepts DLX3 or DLX4. stream.s gains the ring hooks and a
polling wait loop; stream.lua becomes a transport built out of memory taps
(exact issue and completion times — see 55.7 for why nothing in a tap may touch
the memory space); pace_run.sh gains DLX_RINGOWN, DLX_QDEPTH,
DLX_PREFILL_FR, DLX_ITER. check.sh gains two stages: the machine-owned
ring, and a seek with the decode after it.
decode.s and frame.i are unchanged, decode.bin still 1,296 B at the
same MD5, and a host-filled run executes none of the new code — so every
FINDINGS 49/51 figure stands.
Still open in P2: unchanged — the encoder does not reserve a black entry (23.4).
Next: M2 has no item left that this tree can build. P4 (drive the
MB89352, settle W) still decides the project and still needs hardware or a
MAME that models the SPC; 55.3 sharpens what to ask of it, because the ladder
now has a second axis — the clocks stolen per byte AND the fraction of the pipe
the player's own loop gives away. G1 (import the scene graph) is what would
let this tree ask the question 55.5 rehearsed but could not pose: what is the
worst gap between two real decision points, and does the refill climb survive
it. P7 (boot from the volume) is buildable but empty until P4.
A question 55.3 raises and does not answer: the encoder is fitted to a pipe that delivers continuously. It does not, and by 3-7% depending on the player's queue. Whether the rate point should be set against the delivered rate rather than the nominal one is the same class of change as the reserved black entry and the short-slot question from 54.4 — a re-encode plus a re-measurement, and all three should be decided together.
Status & next-session handoff — end of session 22 (2026-08-24)
Session 22: the frame clock moves onto the 68000, and the 12 fps frame turns out not to exist
Green light first and last: ./tools/bench/check.sh was ALL GREEN before any
of this and ALL GREEN after, 120/120 on both cores, no TRUNCATED, plus two
new frame-clock stages.
ROADMAP P3 is DONE. FINDINGS 54. P3 was one of the two items session 21's handoff called buildable here, and it is the one that touches every other number in the project — because the tick is what the word "frame" in "% of a frame" means.
1. Neither of the two sources P3 named can do it, and the enumeration is the
finding. The MFP's timer clock is 16 MHz/4 = 4 MHz, its prescalers are
{4,10,16,50,64,100,200} and its data register is 8 bits, so the slowest tick
any single timer can make is 78.125 Hz — 6.5x faster than a frame — and
4e6/12 = 333,333.33 is not an integer, so no setting reaches 12 Hz at all.
The raster has no whole divide near 12 either: 4 refreshes is 13.86 fps and 5 is
11.09. tools/analysis/23_frame_clock.py walks all 7x256 timer settings rather
than asserting it. 54.1, 54.2.
2. The clock is the raster with a remainder, and it is exact by construction.
src/player/clock.i takes the V-DISP falling edge on MFP GPIP4 — the start of
vertical blanking, which is when a player would present — and adds fps*VTOTAL
per edge to a 16-bit accumulator, emitting a tick at 31,500 and keeping the
remainder. Long-run rate is fps*VTOTAL/VTOTAL = 12.000000 fps exactly.
Both constants are read out of the CRTC at init, so the clock is derived from
the registers that generate the raster it counts. Measured: 3,000 interrupts,
649 ticks where 649.1429 were due. The gate is stated in ticks, not ppm — a
remainder is off by at most one tick over any window, so ppm would let a longer
window advertise a tighter clock for free. 54.2.
3. It costs 181.35 clocks per V-DISP; 838 per frame; 0.1006% of the budget.
The host cannot time this — its granularity is 17.64 ms and the interrupt is
microseconds — so the 68000 times it itself: a one-instruction loop over a
3,000-refresh window, run with the clock off and on, with the loop's own cost
calibrated rather than looked up. The calibration landed on 38.000002 clocks
per iteration, which is both the check that licenses the subtraction and an
independent confirmation of buscost.py's model. The 181.35 then decomposes
exactly, leaving 43.99 clocks for the interrupt exception — the textbook 44,
measured rather than recalled. A timer-based clock would have cost 3.6x this at
an arbitrary phase against the scan. 54.3.
4. THE ONE THAT MOVES SOMETHING: there is no 83.33 ms frame, and there never was. 12 fps on a 55.4577 Hz raster is 4.6215 refreshes, so a frame is shown for 4 refreshes (72.13 ms) or 5 (90.16 ms), 37.9% of them short. The 833,333-clock budget every figure in this project is priced against is the mean slot; the short one is 13.4% under it, and 10 of the gate container's 120 frames do not fit it.
And the cadence was already in the tree, unnamed. stream.lua's tick is
floor((t - t_rel) * fps), which looks uniform and is not: Lua only sees the
machine at frame boundaries, so its ticks land on refreshes and its gaps were
always 4 or 5. Every host-paced result in FINDINGS 49 and 51 already carried
this cadence. P3 did not introduce it; it moved who produces it onto the
machine and made it visible.
It is not a dropped frame. The pace gate says only "not before tick i", so an
overrun eats the next frame's idle and the clock recovers itself; the cost is one
frame presented a refresh late. On the gate container that is 4 frames of 120
with no idle left, against 1 for the nominal model — and the expensive one is
frame 0 at 111% of budget, because the first frame of a scene has nothing to
SKIP against. So the cost lands at a scene change, next to 53.2's 18.96 ms of
loader and the seek. stream.s counts this itself now, and the rig's count
matches an offline model of the divider exactly: 4/120, first at frame 1, on
both tick sources. 54.4.
5. An instrument correction the whole tree was reading. x68k_crtc.cpp refresh_mode() builds the frame period from scr.max_x * scr.max_y with
scr.max_x = m_htotal - 8 — one character cell short, an inclusive bound used as
a count. MAME's raster is fast by 368/360 = 2.2222%: 56.6901 Hz measured
against the registers' 55.4577, agreeing to six digits with the arithmetic. So
every "1/55.46 s granularity" note in this tree was wrong and is 1/56.69 s;
corrected in six files with the derivation put once in crtc_mode.lua. No
conclusion changes and no 68000 cycle figure moves — the CPU clock is unrelated
to the screen — but anything paced by the raster runs 2.22% fast under MAME, so
clock.lua reports both rates and de-skews, and the interrupt is priced against
the hardware refresh count. 54.5.
New in the tree: src/player/clock.i (the clock) and
src/player/clockgate.s (its measurement front-end); tools/bench/clock.lua,
clock_cost.py, clock_run.sh (the rig); tools/analysis/23_frame_clock.py
(the enumeration and the cadence pricing). stream.s gains CLKON and a
late-frame counter ahead of the wait loop; stream.lua gains DLX_PACE=2 and
takes its deadlines from the ticks the machine actually emitted rather than from
a host model; pace_run.sh gains DLX_PACE selection, with the default tag left
alone so pace_sweep.sh still finds its logs. check.sh gains two stages: the
clock's own measurement, and 120 frames decoded pixel-exact with nothing outside
the machine deciding when a frame may start.
decode.s and frame.i are unchanged. decode.bin is still 1,296 B at the
same MD5. The pace gate's wait loop is byte-for-byte the one FINDINGS 51
measured, and the free-running path executes none of the new code, so every
FINDINGS 49 figure stands.
Still open in P2: unchanged — the encoder does not reserve a black entry (23.4), so the letterbox still gets the palette's closest thing to black.
Next: P5 (per-record index, prefill policy, the accumulated-slack rule in the player rather than the rig) is buildable here and is now the last M2 item that is. G1 (import the scene graph) still needs fetching, and is still the one that would let this tree ask what the worst gap between consecutive decision points is. P4 still decides the project and still cannot be measured here.
A question 54.4 raises and does not answer: every rate-control and budget figure in this project is fitted to an 833,333-clock frame, and 37.9% of frames get 721,270. Whether the encoder should be fitted to the SHORT slot instead of the mean is a re-encode plus a re-measurement — the same class of change as the reserved black entry — and it should be decided with P5's numbers in hand, not before.
Status & next-session handoff — end of session 21 (2026-08-24)
Session 21: the loader moves onto the 68000, and a scene change gets a price
Green light first and last: ./tools/bench/check.sh was ALL GREEN before any
of this and ALL GREEN after, 120/120 on both cores, no TRUNCATED, plus a new
load-time stage.
ROADMAP P1 is DONE and P2 is half done. FINDINGS 53. Session 20's handoff
named P4 as the item that decides the project, and P4 is blocked in this
tree — re-checked, not assumed: there is still no scsiexrom.bin anywhere on
this machine (~/mame/roms/x68000.zip holds six files, four IPLs, a cgrom and
an sram), MAME's x68000 has no MB89352 path, and hd63450.cpp decodes no
DTYP. Nothing here can measure W. P1+P2 was the M2 item that could be built
here, and it is the one that touches an already-measured number: it lands at a
scene change, where FINDINGS 51.3's refill climb is.
1. The transforms are on the 68000 and they are byte-exact. src/player/ load.i expands both codebooks to word-per-pixel form and packs the palette to
GGGGGRRRRRBBBBBI with the shared LSB chosen per entry, out of the RAW
container header. Gated byte-for-byte against tools/bench/dlxload.py,
which stays the reference — what changed is where the transforms run, not what
they produce. The palette half is read back out of the palette registers at
$E82000, so "the words reached the hardware" is part of what passes. Both
CPU cores emit the same 10,752 B. 53.1.
2. A scene change costs 18.96 ms of 68000 time, 22.8% of one 12 fps frame. Boot costs 24.70 ms. Split: codebooks 92,609 clocks, palette entries 97,019, scratch tables 52,919. Cross-checked on px68k's C68K, which agrees to 1.4% on the scene-change figure. 53.2.
3. The scratch tables describe the machine, not the scene. They are the
CRTC's 6-to-8-bit rendering, its square, and the per-channel error difference —
so pal_tables is a separate entry point, built once at boot. 5.29 ms off
every scene change, 22% of what a naive port of dlxload.py would have cost
per scene. 53.3.
4. THE ONE THAT MOVES SOMETHING: the scene header is 5,920 bytes nothing has
ever counted. Palette + CB1 + CB4, and it must arrive before frame 0 can be
decoded. It is not part of any frame record, so no rate table in this tree
includes it. tools/analysis/22_scene_load.py prices it across explicit rates.
The whole fixed cost of a scene change is about a third of one frame slot —
but its cost in FINDINGS 51.3's currency, accumulated slack, is divided by the
surplus pipe - wire and so is hypersensitive: 138 ms of extra climb at 488
KB/s, and 1.099 s at 451.4 KB/s, for the same 5,920 bytes. 53.5.
5. An instrument disagreement worth carrying forward. The two CPU cores
agree to <3% on every stage except the table build, where they differ by 16.4%
— and that is the only code in this tree that multiplies. px68k's C68K charges
a flat 50 clocks for MULU/MULS regardless of operand (c68kmacro.h:1869),
where the 68000 charges 38+2n. That explains 4,608 of the 8,703 clock gap;
4,095 clocks are not explained and are recorded as open. Nothing in
src/player/ outside these three instructions multiplies (checked), so no
figure in FINDINGS 24-52 is affected — but the second opinion this tree leans on
over-charges multiplies, and a future measurement containing one must not be
taken from it uncorrected. 53.4.
6. Shipping pre-expanded codebooks was considered and refused. It would trade 9.26 ms of 68000 time for 5,120 more bytes in every scene header — 10.5 ms of pipe at 488 KB/s, and bytes that lengthen the climb. Close to a wash in milliseconds, not a wash in kind: the CPU is idle during a seek and the pipe is what this project is short of. Derived, not measured. 53.6.
New in the tree: src/player/load.i (the transforms) and
src/player/loadgate.s (its front-end, 488 B); tools/bench/prep_load.py,
load.lua, verify_load.py, load_run.sh (the rig, both cores);
tools/analysis/22_scene_load.py (the pricing). tools/bench/c68k/harness.c
gains a --loadraw mode, which also makes its flag-watch address a variable
rather than a constant. 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/56.69 s (1/55.46 when that was written; 54.5) and a wall timing would make
the green light host-sensitive, the
same reason blit.s and span.sh are not in it.
decode.s and stream.s are unchanged. Nothing in the per-frame path was
touched; decode.bin is still 1,296 B at the same MD5.
Still open in P2: the encoder does not reserve a black entry (23.4), so the letterbox still gets the palette's closest thing to black (index 255 here). That half is encoder-side, it changes the container, and it moves every constant fitted to the gate container — a re-encode plus a re-measurement, not an edit.
Next: P3 (a real frame clock from the MFP or VBL) and P5 (per-record index, prefill policy, the accumulated-slack rule in the player rather than the rig) are both buildable here. G1 (import the scene graph) is the one that would let this tree ask a question it currently cannot: what is the worst gap between consecutive decision points, and does the refill climb survive it. P4 still decides the project and still cannot be measured here.
Status & next-session handoff — end of session 20 (2026-08-24)
Session 20: the DMAC configuration was in the IPL ROM the whole time
Green light first and last: ./tools/bench/check.sh was ALL GREEN before any
of this and ALL GREEN after, 120/120 on both cores, no TRUNCATED, plus a new
ROM-evidence stage.
ROADMAP's "do this first" item is DONE. FINDINGS 52. It asked for the cheap
thing — put the ADPCM DMA stream on the bus and see what it does to the 86.7%
— and doing it needed a clocks-per-byte figure for the audio channel that the
tree did not have. 11_cpu_budget.py was charging audio bytes the disk's
rate, 5 clk/B, described in its own help text as "single-address, bus held".
Audio was being charged the favourable end of ROADMAP B3, an open question
worth 242 KB/s.
1. It never had to be a guess. The X68000 IPL ROM programs all four HD63450
channels itself, and the ROM is on this machine — MAME boots the rig with
-bios ipl10. tools/analysis/21_iplrom_dmac.py reads the configuration out of
the image and decodes the MC68450 fields. Eight (address, expected bytes,
meaning) sites; a mismatch or an unknown ROM revision exits non-zero. Needs no
emulator, runs in milliseconds, and is now in check.sh. Layer: a static read
of a shipping ROM image, not a running machine and not real hardware.
2. Audio is dual-address and cannot hold the bus: 16..19 clk/B. ch3
DCR = $80, OCR = $32: explicitly addressed (9 clocks, read 4 + write 5),
cycle steal without hold, REQG = 10 external request — the DMAC
arbitrates once per byte with no burst to amortise the 5..8 + 2 arbitration
over. The old debit was 3.2x..3.8x too small. 52.2.
3. And on the bus it is still nothing. 651.0 B/frame x 16..19 =
1.25%..1.48% of the frame, about 4% of what the decoder leaves. P6's bus
risk does not materialise. The unit worry was worth checking and nearly
right: 15.6 kHz = 8 MHz / 512 = 15,625 samples/s, two 4-bit samples to a byte =
7,812.5 B/s exactly; AUDIO_KBPS = 7.8 is that in decimal kB and was being
multiplied by 1024. 2.4% high, now derived from the sample rate. 52.3/52.4.
4. THE ONE THAT MOVES SOMETHING: the disk channel is programmed identically.
ch1 (SASI) is DCR = $80 too, and so is ch0 (FDC) — Sharp programs every
explicitly-addressed 8-bit device on this board the same way. By the same
arithmetic that is 16..19 clocks per delivered byte, where FINDINGS 42.4
brackets W at 5..12 and 42.5 reports W = 8 already missing 47/120 frames.
The only worked example of a disk DMA configuration on this machine sits above
the entire bracket, and at that price the design does not fit at any container
size. It is not scsiexrom.bin, so B3 stays open — what changed is that a
cheap configuration is now the thing that has to be shown. W <= 12 is a
requirement on the player's DMAC programming, not a range the hardware hands us.
This is now the largest open number in the project, ahead of the rate. 52.5.
5. An unforced cross-check fell out. 15_bus_occupancy.py now sweeps W over
the whole frame period. Its W = 8 row lands at 105.7% of the frame — agreeing
with 42.5's "misses 47/120", from mode histograms and bus clocks respectively,
two models that share no code. 52.5.
6. Audio outranks the disk at the arbiter. CPR: FDC 0, ADPCM 1, SASI 2,
_DMAMOVE 3. When both want the bus, ADPCM is served first — an audio byte
never waits, a video byte does. Relevant to 51's smooth-rate delivery model.
52.6.
New in the tree: tools/analysis/21_iplrom_dmac.py (the ROM decoder/gate);
15_bus_occupancy.py gains a "THE OTHER TWO MASTERS" section pricing both DMA
consumers against the frame period; buscost.py gains the ADPCM constants and
the split read/write DMAC timings; 11_cpu_budget.py charges audio and video
separately, with --adpcm-clocks-per-byte. Its stale "validated to within 1 pt"
line is corrected — the model reads 2..10 pt HIGH and by more as the frame gets
harder, which was already true at HEAD.
Also: the README now shows the thing working. tools/bench/stream.lua
gains DLX_SNAP_EVERY=1 (needs DLX_PACE; off by default and on no path
check.sh takes), which snapshots every frame tick instead of once at the end,
and tools/media/make_readme_media.py turns those PNGs into docs/img/. The
stills and both clips are MAME's own screen pixels, not a re-render.
Building it turned up something worth recording. 116 of 119 captured frames are
pixel-exact against tools/encoder/dlx.py; three are torn — frame n on
top, frame n-1 below the tear line — because MAME captured the screen while
the block loop was partway down it. decode.s writes straight to the displayed
page (one display path, 28.1), so a real player tears the same way; this is
the first time that consequence has been visible rather than argued. The script
ASSERTS the tear (every differing pixel must come from the previous frame) and
refuses to build otherwise, rather than trimming the frames and quietly
reporting "every frame I kept is exact".
Second correction the capture forced: the snapshot fires at the tick, before frame n is decoded, so the obvious reading is that snapshot n holds frame n-1. It does not — MAME renders the screen at the end of the machine frame, by which time the 68000 has finished frame n. Checked, not reasoned about.
decode.s and stream.s are unchanged. Nothing in src/player/ was
touched; decode.bin is still 1,296 B at the same MD5.
Next: P4 is the item that decides the project now, and 52.5 says so with a number. Everything else in M2 (P1, P2, P3, P5, P7) is still buildable here.
Status & next-session handoff — end of session 19 (2026-08-24)
Session 19: the ring rig gets a frame clock, and a branch point costs 4.83 s of play
Green light first and last: ./tools/bench/check.sh was ALL GREEN before any
of this and ALL GREEN after, 120/120 on both cores, no TRUNCATED, plus a new
paced-ring stage.
Item 4 is DONE. FINDINGS 51. Items 1, 2 and 3 were all still blocked on
hardware this tree does not have — a real board for 1 and 2, scsiexrom.bin
for 3 — and none of them moved. Item 4 was the one that could be built here.
src/player/stream.s had no frame clock, so it asked for record i the instant
it finished i-1, outran any finite pipe, and never let the ring back up. That
is why the 49.1 ring sweep passed at 48 KB. It now has PACE/PACEON
($18034/$18038) and the producer supplies a 12 fps tick, so
FR_HEAD-FR_TAIL finally means what it is read to mean: whole frames the
decoder could still draw with delivery stopped dead. PACEON=0 free-runs and
is what the wrap gate still uses, so every figure in FINDINGS 49 is unmoved.
1. Small rings carry almost nothing. Paced, on the gate container: 64 KB holds 2 frames, 96 KB holds 3, 128 KB holds 4–5, 256 KB holds 7–8, 512 KB holds 14–15. All pixel-exact, which is exactly why the unpaced sweep passed them. 51.1.
2. Tolerance is ceiling - 1, measured by cutting the pipe. With 7 records
resident, a 6-frame-time cut underruns nothing and a 7-frame-time cut underruns.
The last record pays for the pipe's restart (~0.9 frame times to place 36.5 KB).
256 KB buys 500 ms of dead pipe, not 583. 51.2.
3. THE ONE THAT MOVES SOMETHING: slack is accumulated, not owned. It is
built out of pipe - wire and a seek spends all of it. At 488 KB/s a 256 KB ring
takes 4.83 s of play to reach its 7-frame ceiling from empty; 512 KB takes
8.42 s to reach 14. A bigger ring raises the ceiling AND lengthens the climb.
So a branch point does not ask "is the buffer big enough", it asks "has there
been enough play since the last one" — and Dragon's Lair's decision points are
seconds apart. First statement in this tree about back-to-back branches. 51.3.
4. The rig now says which resource is binding. Rate refusals and ring refusals are counted separately: at 460 KB/s every ring from 192 KB to 512 KB is RATE-BOUND, ceiling 4, and never fills in 120 frames — larger rings are dead RAM in that scene. Clearing the arrival deadline (451.4 KB/s, 49.5) and being able to absorb a seek are different requirements, and the gap is large. 51.4.
5. Independent agreement, honestly bracketed. tools/analysis/20_seek_slack.py
is the model rewritten in Python from record sizes, sharing no code with the Lua
producer. 35/35 of the rig's ceilings fall inside its bracket, 33/35 at the
top. The bracket is one record wide because the pipe delivers ~one record per
slot, so the answer depends on sampling before or after that slot's delivery.
Both are reported rather than picking the one that matched — that would have
been fitting the model and calling it a cross-check. 51.5.
6. Pacing exposed two producer defects, both invisible free-running. The
RD_PTR cross-check was really testing how often reap() ran (it asserted per
retired record; RD_PTR names only tail-1), and reap() was skipped for the
whole duration of a cut, so the ring looked full through a seek. 51.6.
New in the tree: tools/bench/pace_run.sh (one paced run, pixel-verified),
tools/bench/pace_sweep.sh (ring x pipe grid), tools/analysis/20_seek_slack.py
(the independent sim). check.sh gains a paced stage that gates pixel-exactness,
zero underruns, and the 256 KB ceiling — the last one because a change in it is
a change in what a branch point can afford, and that should not slip past as a
log line.
decode.s is unchanged and still asserted: 1,296 bytes, same MD5. The pace
gate is in stream.s only (1,396 -> 1,418 B) and outside src/player/frame.i,
so no per-block or span constant moves.
Session 18 and earlier
Session 18: the streaming path exists, and the shipping rate does not fit the pipe
THE DELIVERY RATE HAS NO WORKING FIGURE — retired session 18 (USER DECISION). Sections below written before session 18 name a "4 Mbps" pipe constant and score tables against it. Read every one of those as history. It was never a bus measurement: user-supplied, no provenance, 10% of SCSI-1's asynchronous rating (FINDINGS 42.1), and FINDINGS 49.5 caught the shipping candidate exceeding it while nothing in the tree was comparing the two.
It is now gone as a default from every analysis tool and from
tools/bench/stream.lua—--bus/--kbps/DLX_STREAM_KBPSare required arguments with no fallback, so no table can be scored against a rate its own output does not state. The one survivor isGATE_SPAN_KBPSintools/bench/check.sh, which is a container recipe, not a delivery claim: the gate container was encoded with it and every per-block and span constant in FINDINGS 41/43/45/49 is fitted to that container, so changing it is a re-encode plus a re-measurement, not an edit.What to use instead:
tools/analysis/19_ring_stream.pyreports the zero-prefill pipe — the rate a medium must clear for a container to need no prefill. That is a requirement to measure a BlueSCSI against, not a constant to design on. For the session-14 candidate it is 513.2 KB/s.
Green light re-run first: ./tools/bench/check.sh was ALL GREEN before any
of this, 120/120 on both cores, no TRUNCATED.
Items 1 and 2 were both blocked in this tree, and checked rather than
assumed. Item 1 needs a real board. Item 2 needs the CZ-6BS1's
scsiexrom.bin: ~/mame/roms/x68000.zip holds six files — cgrom, four IPLs,
sram — and there is no SCSI ext ROM anywhere on this machine. MAME's
hd63450.cpp decodes no DTYP, so the emulator cannot stand in (48.4). USER
DECISION: build item 3.
Item 3 is DONE, and item 4 folded into it. FINDINGS 49.
src/player/stream.s + tools/bench/prep_stream.py + tools/bench/stream.lua
decode the gate container out of a bounded ring, container in a host file:
| ring | machine | result |
|---|---|---|
| 256 KB | stock 2 MB | 120/120, final frame pixel-exact |
| 128 / 96 / 80 / 64 / 48 KB | stock 2 MB | 120/120, pixel-exact |
The rig's RAM ceiling is gone. 45 raised RIG_RAM to 6 MB because 5,261,814 B
of stream did not fit 2 MB. The streaming rig holds ~256 KB and reads the rest
from the host, so the machine it runs on is now the machine the player
targets.
1. The constraint is CONTIGUITY, not byte count, and 09_buffer_sim could not
see it. The block loop and span chain read with a monotonically increasing
a0 and no bounds check anywhere, so the ring needs the whole next record
resident AND contiguous. 49.2.
2. aligned beats split and it is not close. Producer refuses to start a
record it cannot finish, leaves a hole: 9.1% of a 256 KB ring, zero clocks.
Letting records wrap and shadowing the ring head costs 5.57% of the frame
budget, forever — and the decoder is already at 91.1% at p90. (Both are
s14_d5_all1500's; the gate container makes it 5.7% of the ring against 3.64%
of the budget. The costs are per container, not universal.) aligned also
needs a per-record index, which a branching laserdisc game needs anyway. 49.3.
3. Two independent implementations agree exactly. The Python sim (from record sizes) and the Lua producer (driving a real 68000) both give 18 wraps, 14.7 KB mean hole, 94.3% usable. They share no code. 49.4.
4. THE ONE THAT MOVES SOMETHING: the shipping candidate does not fit the pipe.
s14_d5_all1500 is 496.7 KB/s; the pipe this tree has simulated against
since session 2 is 488 KB/s. Those two numbers had never been put side by
side. It is 8.7 KB/s over on the MEAN — not a burst a ring absorbs. The
deficit grows 523 KB per minute of play; no ring size fixes it.
It was never caught because 42.1 retired the pipe as the binding resource and built the rate controller to bind on clocks, with no pipe term at all — a defensible decision. What was not decided is that FINDINGS 21's buffer sizing and its "zero required prefill" would keep standing on a constant the design had stopped enforcing. That is what item 4 has been open since session 7 for.
The output is a requirement on the medium, not a verdict, because 488 is unmeasured folklore:
| container | wire | zero-prefill pipe |
|---|---|---|
s14_d5_all1500 (the candidate) |
496.7 KB/s | 513.2 KB/s |
rc_fr_singe_scsi_span (the gate) |
446.1 KB/s | 451.4 KB/s |
513.2 KB/s is now a hardware acceptance test — 33% of SCSI-1 async, 10% of sync. Very likely met; never shown to be met. 49.5.
5. The rig's first version measured the wrong thing, and it is worth knowing
why. stream.s has no frame clock, so it outruns any finite pipe and reported
91 of 120 frames "stalled" at a pipe the same run shows is fast enough. It
now records when each record becomes RESIDENT and checks that against a 12 fps
deadline: 1/120 late by 4.9 ms at 488, 0/120 at 520. 49.6.
decode.s is unchanged, and provably. The block loop and span chain moved to
src/player/frame.i and the constants to geom.i so both front-ends assemble
from literally the same bytes — every cycle constant in FINDINGS 24/30/40/41 is
fitted to those bytes. decode.s still assembles to 1,296 bytes, same MD5,
and prep_dlx.py still emits a byte-identical blob after the loader maths moved
to tools/bench/dlxload.py. Both are now asserted in check.sh, along with
the ring pass itself.
Session 17 and earlier
Session 17: the blanking question is not a tie, and it leans the wrong way
Green light re-run first: ./tools/bench/check.sh is ALL GREEN, 120/120 on
both cores, no TRUNCATED. Nothing in session 16's uncommitted tree has drifted.
FINDINGS 48. Session 16 filed 47.4 as "two emulators disagree, both readings plausible." That framing was too generous to the outcome we want.
-
px68k is silent, not dissenting. Read with
grep -a—gvram.cis EUC-JP, so a plaingrepsilently reports nothing — R20's high byte (CRTC_Regs[0x28]&8) appears in one file and six places, all address decode: three inGVRAM_Read, three inGVRAM_Write. No px68k display code reads the bit anywhere. It does not model buffer mode as non-blanking; it does not model the display side of buffer mode at all. MAME's blanking is a deliberate commented claim made twice, and it models bit 12 the same way for the text layer. An assertion and a silence are not a tie. -
Sharp's own register table names the bit MAME's way. R20 bit 11 G-MEM:
%0 表示用 / %1 バッファ用— "for display" / "for buffer" — andbit 10〜8 は無効, i.e. the colour-mode field goes invalid in buffer mode. COL is what the display side decodes a plane structure from. That is a mechanism for blanking, not just a name for it. The counter-reading survives only on the parenthetical (G-VRAM が 65536 色表示時と同じ構造になる), which describes the structure and does not say the screen goes dark. -
The MAME branch has no survivable partial-blank. The blank interval is the paint, not the frame — but the measured blit is 53.6% of budget, packed halves the words, so the picture is dark for ~27–54% of every frame at 12fps. That is a 12 Hz strobe over the whole image, and the packed layout has no page left to flip to, because both 256-colour pages carry picture. There is no version where the packing is merely expensive.
This is a shifted prior, not a result. It still needs the board. But it should be read before anyone spends a session building on 1.0 B/pixel.
Item 2 lost its cheap method, and the loss is informative. px68k does not
emulate the MB89352 at all — x68k/scsi.c is 81 lines that synthesise a fake
64-byte CZ-6BS1 boot ROM and trap IOCS $F5 on the host (SPCはエミュレートしない,
its own header). So the "second emulator agrees" method that carried 46/47 was
never available for single- vs dual-address. What item 2 actually wants is the
CZ-6BS1's scsiexrom.bin (8 KB, CRC 7be488de, not present here)
disassembled for the DCR it writes. The MC68450 datasheet pins the field:
DTYP 00/01 are "Explicitly Addressed" (dual), 10/11 are "Implicitly
Addressed" (single), and 3.6.1.2 names the two protocols "dual address" and
"single address" in so many words. 43.2's 5.0-vs-9.0 clocks/byte is two bits
in one byte the boot ROM writes at init. Not another emulator — and MAME's
hd63450.cpp decodes no DTYP at all, so it could never have answered.
FINDINGS 48.4.
One thing was confirmed rather than doubted. px68k's kaiseki.txt — the
author's own 2014 analysis notes, predating all of this — states the 256-colour
word interleave (Page0の(0,0), Page1の(0,0), Page0の(1,0)...) independently.
The packed layout's premise is solid from a third source; only its visibility
is in question. FINDINGS 48.5.
Where session 16 left it
The strongest test in the tree now covers the whole window, on both cores.
FINDINGS 45. The pixel-exact gate was auditing 37 of 120 frames; it is now
120/120 on MAME's 68000 and on px68k's C68K, and ./tools/bench/check.sh
is ALL GREEN with no TRUNCATED line.
Session 15 handed this over as "it needs the chunk-streaming rig, not a longer
pass." That was wrong, and cheaply so — it needed neither. The constraint
was the rig's memory, not the player's: prep_dlx.py preloads the whole
container into emulated RAM and check.sh ran the machine at -ramsize 2M, so
a 5,261,814 B stream was truncated to the prefix that fit. The gate runs under
DLX_VERIFY_ONLY=1, which drops the cost anchors entirely and asserts only
pixel-exactness, so the 2 MB was never load-bearing here — and preloading a
whole container is unlike the shipping player at any size, because the player
streams into a ring buffer. RIG_RAM=6 in check.sh covers all 120 frames.
The raise is licensed by measurement, not by convenience. Run the full
timing pass at 2M and at 6M and the five synthetic anchors come out
bit-identical — 40,729 / 921,187 / 1,376,881 / 1,229,883 / 506,533 cycles —
despite sitting at different addresses in the two layouts. MAME's cycle model
does not depend on -ramsize over this range, so every per-block constant in
FINDINGS 24/30/41 is unmoved.
And the 37-frame prefix was a biased sample. It overstated the mean cost of the window by 8.2%, and it never saw the quiet end:
| anchor | 37-frame prefix | full 120 |
|---|---|---|
| min non-SKIP | 25.1% of blocks, 61.9% of budget | 15.2%, 53.6% |
| median | 45.4%, 79.7% | 41.1%, 81.1% |
| p90 | 52.3%, 93.1% | 48.5%, 91.1% |
| max non-SKIP | 62.5%, 91.8% | 62.5%, 91.8% (same frame) |
| C68K sequential-pass mean | 693,886 cyc, 83.3% | 641,444 cyc, 77.0% |
The direction is lucky rather than designed: the prefix was pessimistic, so nothing downstream was flattered and no headroom claim was resting on the missing frames.
Item 2 is answered, and the answer is no. FINDINGS 46. There is no packed
256-colour write path — no graphics mode on this machine puts two horizontally
adjacent pixels in one 16-bit word. The sub-word fields are pages, not
pixels: page 0 and page 1 are the two bytes of one word at the same screen
coordinate (px68k derives line identically for both, and the $C80000 alias
lands on the adjacent byte), exactly as two independent documents describe. The
near-miss — scroll page 1 by one pixel and interleave — fails on byte count, not
on addressing, and 46.2 records why so it is not re-derived. FINDINGS 44.7
stands unchanged: the decoder-free player still needs 1,152 KB/s and 1.61 GB
and is still killed by the medium.
Chasing it opened one more surface, and the same session closed it. The 2:1
tax is a property of the graphics planes; the text plane is 4bpp planar —
four planes at 0x20000 stride, 1024x1024 bits each — so 0.5 bytes/pixel against
2.0, and an uncompressed 16-colour frame is 288.0 KB/s against the shipping
compressed 256-colour stream's 496.7. 42% cheaper on the wire, no decoder.
It costs 5.84 dB and that kills it. tools/analysis/18_text_plane_16col.py,
120 frames, generous to the 16-colour side (per-frame palettes, which the
256-colour path cannot use because its codebooks index a scene-wide palette):
| mean PSNR | |
|---|---|
| 256 colours, scene palette (the tree's) | 31.33 |
| 256 colours, per-frame palette | 34.08 |
| 16 colours, scene palette | 23.17 |
| 16 colours, per-frame palette | 25.49 |
Against the shipping container's 29.19 dB at 496.7 KB/s, a 16-colour literal
is 25.49 dB at 288.0 KB/s — 3.70 dB worse for 58% of the bitrate. The wire
saving does not pay for the colours. The user's call was to drop the 16-colour
direction outright and the number agrees, so it is closed, not parked.
FINDINGS 7's 256-colour claim now rests on a measurement. (One caveat recorded
in 46.3: a Floyd-Steinberg row came out bit-identical to the undithered one —
PIL ignored dither= under MEDIANCUT — so that row is void and excluded.)
Then the user said to keep chasing it, and the "no" turned out to be wrong.
FINDINGS 46.5. The masking is defeatable: CRTC R20 bit 11 ("G-VRAM set to
buffer") bypasses the depth switch and writes the full 16 bits unmasked.
MAME's gvram_w and px68k's GVRAM_Write both implement it — px68k's comment
names the shipping game that used it, 65536モードのVRAMアクセス(Nemesis用) —
so it is a mechanism, not an emulator quirk. The 2:1 tax is a property of the
default write path, not of the memory.
And the two 256-colour pages have independent scroll, which 46.1 also missed:
px68k's Grp_DrawLine8(int page, int opaq) indexes GrphScrollX[page*8],
selects the byte within the word by page, and takes an opacity flag. 46.2
dismissed interleaving assuming a 1-pixel scroll; scrolling by 128 makes the
used words contiguous:
- write words 0..127 of each row, unmasked, full 16 bits
- page 0 unscrolled → screen columns 0..127
- page 1 X-scrolled +128, opaque, above → screen columns 128..255
- page 1's storage past 128 lands off the edge of the real 256x256 mode already in use (FINDINGS 23)
128 contiguous words carry 256 pixels: 1.0 B/pixel against 2.0 — 576 KB/s and
0.81 GB, exactly the halving 44.7 said would reopen the whole design. movem-shaped,
no stride, no transparency mask to maintain.
This is a derivation, not a result — see 46.6 for the three things untested.
But unlike items that need a board, the tree can answer this one: it is a
register setup and a snapshot, and verify_frame256.py already compares
pixel-exactly.
The packed layout was then built and run on both emulators. It works. FINDINGS 47. 46.6 was a derivation; it is now a result:
| result | palette ceiling | |
|---|---|---|
MAME, verify_frame256.py |
256x192 pixel-exact, letterbox true black | 40.83 dB |
px68k, verify_gvpack.py |
256x192 index-exact | 40.83 dB |
Per-frame payload: 128 words/row x 192 rows = 49,152 bytes for 49,152 pixels —
1.0 B/pixel against 2.0. The write path was measured directly: masked, writing
AB5C leaves page0=5C page1=00 (the high byte destroyed); with R20 bit 11
set it leaves page0=5C page1=AB. That is the 2:1 tax and its off switch in
one table.
Four negative controls behave — bit 11 off, scroll removed, and the unpacked
control. tools/bench/gvpack links px68k's real x68k/gvram.c, the way
tools/bench/c68k links its CPU core.
But the two emulators disagree twice, and the second one decides everything.
- Priority register
0xE82500. At0x0000MAME hides page 1 (right half black) while px68k puts page 0 on top transparently and renders correctly. They agree at0x0002, which is what the layout uses — so the result stands, but on a register they model differently. - Does buffer mode BLANK the display?
probe_bit11_blank.luais the known-good 256-colour test with one line added. MAME: the screen goes fully black. px68k: it does not blank —Grp_DrawLine8never reads that bit. If MAME is right, the graphics layer is blanked for the whole time the CPU or DMAC is painting, and a 12fps player shows black for whatever fraction of each frame the paint takes. If px68k is right, the packing is free.
That is now the cheapest high-value hardware fact outstanding — cheaper than single-vs-dual-address, because one real board plus a two-line probe settles it and it moves more numbers.
What it is worth if it goes px68k's way (DERIVED, not measured): wire 1,152 →
576 KB/s, game 1.61 → 0.81 GB, DMAC device→GVRAM 59.0% → 29.5% of the
clock budget. And one reversal: 44.7 concluded "a CPU-painted full-frame literal
does not fit at any physically reachable price." Packed, one movem word carries
two pixels, so a pixel costs (9.143 + 2c)/2 = 4.571 + c — 9.57 clocks at c=5,
against a 16.95 budget. It fits, at c=5 and at c=9. Withdrawn conditionally on
the blanking question.
Reproduce:
python3 tools/bench/prep_frame.py tmp/fr_00020 tmp/frame256p.bin 0 --pack-transparent
( cd tmp && SDL_VIDEODRIVER=dummy mame x68000 -bios ipl10 -ramsize 2M -video soft \
-window -sound none -nothrottle -plugins -seconds_to_run 8 \
-autoboot_script ../tools/bench/show_frame256_packed.lua \
-snapshot_directory ./snap_pack -snapview native )
python3 tools/bench/verify_frame256.py tmp/snap_pack/x68000/0000.png tmp/frame256p.bin
make -s -C tools/bench/gvpack && tools/bench/gvpack/gvpack tmp/frame256p.bin tmp/gvpack_px68k.raw
python3 tools/bench/gvpack/verify_gvpack.py tmp/gvpack_px68k.raw tmp/frame256p.bin
Not wired into check.sh yet: it is a capability probe, not something the
shipping player depends on. It should join the green light the moment anything
in the tree starts relying on the packed layout.
Green light: ./tools/bench/check.sh ALL GREEN, before and after.
NEXT SESSION, in order
Item 4 is CLOSED (session 19, FINDINGS 51). The list below is otherwise unchanged, and items 1-3 are all still blocked on hardware this machine does not have. What session 19 adds to item 1 is a SECOND number to measure the medium against, and it is the harder one:
- 451.4 KB/s is the zero-prefill pipe for the gate container (49.5) — enough to arrive on time in a straight line.
- Absorbing a seek is a different requirement. At 460 KB/s every ring from
192 KB to 512 KB is rate-bound and never fills. The rate that makes a branch
point affordable is set by
pipe - wireand how long the scene runs between branches, not by the buffer — so measuring the BlueSCSI's seek time matters as much as its throughput, and neither is known. 51.3/51.4.
Item 5 (--spans all) now has a second reason to be careful: it spends every
profitable byte, which raises wire, which shrinks pipe - wire, which
lengthens the climb back to a full ring after every branch. That interaction is
not priced.
The list
-
Green light first.
./tools/bench/check.sh. It is slower again: it now also runs the 120-frame ring-buffer pass on a 2 MB machine, re-derivesprep_dlx.py's blob, and assertsdecode.bin's MD5. Do not run two MAME jobs at once — session 18 did, and twodecode.luaruns sharing one log file produced a 0-byte log and 15 wasted minutes. The warning is already in this document; it is there because it keeps happening. -
Measure the BlueSCSI. It is now the only thing that unblocks the I/O side. FINDINGS 50 retired the pipe constant outright (USER DECISION): it is gone as a default from every analysis tool and from
stream.lua, which now all REQUIRE an explicit rate. There is no working delivery figure in this repo any more, and that is deliberate.The consequence is that anything needing a delivery rate to mean something is now waiting on a measurement — which is the honest state, and it is why this is item 1.
19_ring_stream.pygives the threshold to measure against: 513.2 KB/s for the session-14 candidate, 451.4 KB/s for the gate container. Zero-prefill pipes, per container, from real record sizes.Do not substitute a guess. If the measurement is not available this session, run the tools at several explicit rates and report the sensitivity, rather than picking one and letting it become the next constant. That is precisely how the last one survived five sessions after 42.1 called it folklore.
If it clears 513.2, the 8.7 KB/s overrun of 49.5 is not one, and the rate controller can go on binding purely on clocks with that fact written down. If it does not, the two levers are giving the rate controller a pipe term (it has none — it binds on
decode + c*bytes) or re-encoding lower; the first is more informative and neither should be priced before the measurement exists. -
Settle whether buffer mode blanks the display. Unchanged, and still blocked on a real board. Read FINDINGS 48 first — session 17 shifted the prior toward MAME and toward "unusable."
tools/bench/probe_bit11_blank.luais written and settles it in minutes. Second, smaller, same sitting: the priority register0xE82500at0x0000(47.3). Do not pre-build on 1.0 B/pixel. -
Settle single-address vs dual-address. Unchanged, still the largest open number: 242 KB/s and 0.69 dB. Session 18 confirmed the artefact is not on this machine:
~/mame/roms/x68000.ziphascgrom, four IPLs andsram, and no SCSI ext ROM anywhere on the box. So this needsscsiexrom.bin(8 KB, CRC7be488de) sourced, then its DMAC init disassembled for DCR's DTYP field:10/11= single (5.0 clk/B),00/01= dual (9.0). FINDINGS 48.4. A board or schematic is the fallback, not the first move. -
DONE, session 19 — FINDINGS 51.
Pace the decoder, then measure branch-point stall tolerance.The rig has a frame clock, the ceilings are measured at seven ring sizes and five pipe rates, theceiling - 1tolerance was falsified by cutting the pipe, and the result that matters is that slack is accumulated: 4.83 s of play to refill 256 KB at 488 KB/s. The original statement of the gap, kept because it is still the right description of what was wrong: FINDINGS 49.7.2 is the sharpest gap the new rig leaves: becausestream.sfree-runs, the ring never backs up, so the ring-size sweep tests wrap correctness at each size and not buffering. 48 KB passes and is single-buffered — do not read it as a viable player buffer. A paced decoder (MFP timer, or Lua gating) turns the rig into the seek-tolerance test Dragon's Lair actually needs, and it is the last piece of the delivery story. -
Make
--spans allthe default. Still a recommendation rather than a measurement (43.6.1), and 44.3 sharpens it: it is the only lever on the encoder's byte side that changes anything. Note it interacts with item 1 —allspends every profitable byte, which is what put the candidate over the pipe in the first place. -
Re-derive span selection jointly with
lam(39.3). The selector ranks on net clocks;lamstill prices bytes against a KB/s target rather than against 5 clocks each — and if item 1 goes route (b),lamgets a real KB/s ceiling back and this question changes shape.
What session 15 settled
Session 14's item 2 was aimed at a lever that is not loaded. FINDINGS 44.
The bucket diagnosis of 43.5 is correct as a mechanism and worth one frame of
120 in practice, because at --spans all the byte side of the rate controller
does not bind at all:
--spans all, c=5, the 120-frame singe window |
KB/s | PSNR | over budget |
|---|---|---|---|
shipped, --bucket-frames 8 |
496.7 | 29.19 | 1/120 |
--bucket-frames 32 |
496.7 | 29.19 | 1/120 |
--bucket-frames 1 — no banking at all |
498.0 | 29.19 | 1/120 |
--rc-floor open — lam floor 1.0 |
503.7 | 29.21 | 1/120 |
A 32-frame bucket emits the same container byte for byte as an 8-frame one,
and lam never leaves its floor of 10.0 on any of 120 frames. The rate this
project reports is set by the span pass and by mu; --kbps and the bucket
are not the levers. Measure whether the lever is loaded before pulling it.
Two real unit inconsistencies were found, fixed, and defaulted OFF on
measurement — --joint-decide and --joint-bucket turn them on:
c=5, --spans all |
KB/s | PSNR | mean frame clocks | over |
|---|---|---|---|---|
| shipped | 496.7 | 29.19 | 740,049 | 1/120 |
--joint-decide — the lagrangian sees the disk |
482.5 | 29.17 | 745,438 | 1/120 |
--joint-bucket — the bucket may not lend clocks |
506.4 | 29.18 | 754,429 | 1/120 |
The first is the more interesting one even though it does nothing: priced per
delivered byte, a RAW block costs 400.4 + 16c and a V4 block 448.2 + 4c,
which cross at c = 3.98 — just under 43.1's hard floor of 4. So mu's
V4 -> RAW escape hatch, FINDINGS 28.8 and session 8's 0c, never existed on
real hardware: it spends 12 clocks of bus to save 47.8 of CPU. Correcting it
moves 0.3% of blocks and 0.02 dB.
Defaults are unchanged in effect: s14_d5_all1500 re-encodes to the same MD5.
An encode is 95% k-means, and it is now 2.7x faster, exactly. 60.6 s -> 29.4 s
for a 120-frame window, from three fixes to VQ.assign (a materialised C.T, a
cache-sized chunk, and a thread pool over the chunk loop). Bit-identical labels,
so every container still hashes the same. FINDINGS 44.5.
A decoder-free player fits the clocks and dies on the medium. Streaming raw preprocessed frames straight into video memory — DMAC device -> GVRAM, no decoder in the loop — fits at c=5 with 41% of the frame to spare, but needs 1,152 KB/s sustained (~79% of SCSI-1 async, nothing left for audio or seeks) and 1.61 GB for the game, against the 0.70 GB the shipping container needs. The cause is that 256-colour GVRAM is one pixel per word with the high byte discarded, so half of every byte pulled off the disk is thrown away by the hardware on arrival. FINDINGS 44.7. Session 16 confirmed that layout is not escapable on the graphics planes (FINDINGS 46) — so this stands.
What session 14 settled
Session 13's binding unknown W was in the wrong unit, and correcting it
costs the project 41% of its rate and 1.85 dB. FINDINGS 43.
W was charged per WORD of delivered stream. The MB89352 is an 8-bit SPC,
so the DMAC pays per BYTE. Every I/O debit in the project since FINDINGS 5 has
been charged at half rate, and the favourable end of 39.7's 5..12 bracket was
never physically reachable: a 68000 bus cycle is four clocks and the SPC
hands over one byte per cycle, so nothing can cost less than 4 clocks/byte.
5 clocks/word is 2.5 clocks/byte — 62% of one bus cycle, and a 4 MB/s DMA on a
link that runs at 1.5 MB/s asynchronous.
The datasheet, per byte, device-to-memory, bus held (MC68450 Fig 4-25):
| how the DMAC is programmed | clocks/byte |
|---|---|
| single address, D->M (sheet 2) | 5.0 |
| dual address, byte, no packing (sheet 4) | 9.0 |
| dual address, byte packed (sheet 3) | 16.5 |
Nothing session 13 emitted fits at any of these. s13_280p1500, the
0/120 candidate, is 44/120 at 5 clocks/byte and 120/120 at 9.
What survives, and it had to be re-encoded rather than re-scored
The encoder was making its decisions in the same wrong units: ratectl
bisected mu against 833,333 cycles of decode with no disk term, and
spans.select() admitted a run only if it beat the blocks on cycles alone,
explicitly ignoring the bytes it added. Both now work in one currency — clocks —
and --disk-clk-byte 0 re-emits session 13's container byte for byte, so the
change is the price and not the codec.
| KB/s | PSNR | over budget | span px | |
|---|---|---|---|---|
| s13's claim (2.5 clk/B) | 837.4 | 31.04 dB | 0/120 | 62.3% |
s14_d5_all1500 — single address |
496.7 | 29.19 dB | 1/120 | 30.7% |
s14_d9_all1500 — dual address |
255.0 | 28.50 dB | 1/120 | 3.9% |
The one frame over is frame 0 in both — the intra frame, which 28.5/31
established is emitted late on purpose. Every other frame lands at or under
100.0%. 17_span_delivered.py shares no code with the encoder's accounting and
reproduces both rows to the digit.
The remaining lever is not ours to pull. A span pixel costs 9.143 + 2c
clocks and a V1 block pixel 18.74 + 0.25c, so a span beats a block only for
c < 5.48 — which falls between the two rows above. Single-address needs the
CZ-6BS1 to drive the SPC's DACK from the bus's #EXACK (pin B37, which the bus
does have). That is the board's wiring, not our code, and MAME models only the
dual-address row.
Green light: ./tools/bench/check.sh ALL GREEN, re-run after the encoder
change — 0/120 rate-control drift under the joint cost, DLX3 round-trip exact.
session 14's list
Items 0, 2 and 4 are answered by FINDINGS 44; the rest carry forward, reordered, in the session-15 list at the top of this file.
-
Green light first.
./tools/bench/check.sh(~6 min, Blu-ray mounted). -
Settle single-address vs dual-address. It is worth 242 KB/s and 0.69 dB and it is the largest open number in the project. It is a hardware fact: the CZ-6BS1 schematic, or a real board, or a period service manual. MAME cannot answer it (
x68k_scsiext.cpphas no DACK path and no DRQ line from the slot to the DMAC at all, though the real bus has#EXREQ/#EXACK), so do not spend a session in the emulator on it. FINDINGS 43.2/43.3. -
Fix the rate controller's bucket. Diagnosed, not fixed (43.5): it banks bytes across 8 frames, and bytes are now clocks, which cannot be banked because there is no double buffer.
corr(bytes, frame clocks) = 0.989. The per-frame ceiling should be joint and hard; the bucket should smooth only what is left after the disk is paid. -
Get a 68000 to decode one of the new containers. Unchanged from session 13's item 3 and still the strongest claim resting on the weakest coverage — but narrower now:
s14_d5_all1500is 30.7% span coverage against the gate's 26.6%, where session 13's candidate was 62.3%. This is the cheapest it will ever be to close. A 496.7 KB/s stream is ~10 frames in a 2 MB machine, so it still wants a chunk-streaming rig, not a longer pass. -
Make
--spans allthe default (my recommendation, not a measurement): with a joint cost,needstops early and leaves profitable clock savings unbought — 275.8 KB/s / 28.92 dB / 2 frames over, againstall's 496.7 / 29.19 / 1. FINDINGS 43.6. -
Re-run the ring-buffer simulation at ~497 KB/s (29.5/30.7, open since session 7). Cheaper than the 837 KB/s it was going to have to survive.
-
Re-derive span selection jointly with
lam(39.3). Half-done: the selector now ranks on net clocks, butlamstill prices bytes against a KB/s target rather than against 5 clocks each.
Do not start by hand-optimising decode.s. Five sessions running, and more
true than ever: at c=5 the disk is 26.6% of the median frame and the decoder
is not what misses.
Do not treat 1/120 as settled either. FINDINGS 43.8 lists five things it does not establish, and 43.9 is about how the last favourable table survived a section written specifically to distrust it.
What session 13 settled
Session 12 ended by asking the user to choose a rate point. The user instead challenged the constant the question rested on — and it does not hold. The 488 KB/s figure was never a SCSI bus figure: SCSI-1 is 1.5 MB/s asynchronous and 5 MB/s synchronous (ANSI X3.131-1986), so the design has been running against 10% of the bus. Once bytes are that cheap the span pass simply saturates, and it does so on its own at ~837 KB/s.
| container | KB/s | span px | PSNR | over @5 | @8 | @12 |
|---|---|---|---|---|---|---|
| the session-12 gate | 487.7 | 26.6% | 29.63 dB | 11/120 | 34/120 | 78/120 |
s13_280p1500 — the candidate |
837.4 | 62.3% | 31.04 dB | 0/120 | 21/120 | 63/120 |
s13_lossless — a probe, not shippable |
934.6 | 70.4% | 31.19 dB | 0/120 | 47/120 | — |
0/120 is the first time anything here has fitted 12fps on every frame, and
it is simultaneously the best picture yet — 0.29 dB off a 31.33 dB palette
ceiling, i.e. off exact for this display. mu is never spent at these rates, so
FINDINGS 31's 0.62 dB CPU-fit cost is refunded. Quality and deadline have
stopped competing, which was true of no earlier session.
The mechanism: a v7 span pixel costs 2 wire bytes and 9.143 clocks, a RAW
block pixel costs 1 byte and 25.03. Spans win for any W < 31.7. The budget
is 16.95 clocks/pixel, so a full-frame literal fits iff W <= ~6.5.
Withdrawn, session 15. That threshold is per WORD: 6.5 clocks/word is 3.25 clocks/byte, under the 4-clock floor, so it was never reachable. In byte units the breakeven is c = 3.906 against a floor of 4.0 — a CPU-painted full-frame literal misses at every real price, by 1.1% at the floor itself. FINDINGS 44.7.
Which makes W the whole result
W clocks/word |
934.6 KB/s stream | over budget |
|---|---|---|
| 5 | median 83.0%, worst 91.0% | 0/120 |
| 6 | median 87.6%, worst 97.0% | 0/120 |
| 7 | median 92.2%, worst 103.0% | 21/120 |
| 8 | median 96.8%, worst 109.0% | 47/120 |
Moving W across FINDINGS 39.7's datasheet bracket costs more frames than
moving the rate from 280 to 935 KB/s wins. It has displaced the 4 Mbps figure as
the most load-bearing unmeasured number in the project.
Two things are known about W and they point the good way. MAME's CZ-6BS1
glue is cycle-steal with the bus held — x68k_scsiext.cpp:110-136 gates
#DTACK on DRQ while the DMAC's OWN is asserted — which is the 5 clk/word end
of the bracket, not the ~12 arbitrated end. And W decomposes into 5 clocks of MC68450 + however long the drive makes the DMAC wait; the deployment target has
been SD-backed SCSI since session 2 (FINDINGS 21.2), which collapses the wait
term. FINDINGS 42.5, 42.6.
Also corrected: "never x68ksupr" was never a hardware claim. Internal and
external are the same MB89352 and neither needs a driver (IOCS is in ROM); the
difference is that MAME has not implemented the internal DMA glue
(x68k.cpp:1176, // TODO). The external board is the way to test the DMA
path in MAME, and that is what item 1 below does.
Green light: ./tools/bench/check.sh ALL GREEN at the start of session 13,
unchanged since. Nothing in session 13 touched the encoder, the decoder or the
container — this session moved numbers and documents only.
Session 13's list is superseded — its items 1 and 2 are done and its answer did not survive them (FINDINGS 43); the rest are carried forward, reordered, in the session-14 list at the top of this file.
And its headline is withdrawn. The 0/120 above, the 31.04 dB, the saturation at 837 KB/s and "quality and deadline have stopped competing" were all scored with the disk debited per WORD to a byte-wide port. The reasoning in this section about the SCSI bus rating (42.1) stands; every number downstream of it was charged at half rate. Read FINDINGS 43.
What session 12 settled
Session 12 built v7 into the player. src/player/decode.s paints v7 literal
spans, and it is pixel-exact under both CPU cores over a container where every
frame carries 128-216 spans covering up to 38% of the picture. FINDINGS 41.
The container is DLX3: a span section between the mode header and the block
payload, {u32 GVRAM address, u16 coarse disp} per span with the fine
displacement mid-stream. tools/analysis/16_span_roundtrip.py gates it and is
in check.sh.
The measured cost transfers. Two synthetic all-SPAN anchors price v7 inside
decode.s at 151.2 and 225.6 clocks per 4x4 block, against FINDINGS 40's
table of 151 and 226 — 0.2% on both emulators.
The two things that were not on the list
1. There are TWO byte budgets, and FINDINGS 40's 18/120 was scored at the
wrong one. The scsi profile is 280 KB/s; 14_dmac_chain.py scores spans
against the 488 KB/s PIPE, which is 40,977 B/frame against 23,228. At the
profile rate the lam search has already spent the allowance and spans fire on 5
frames of 120. The profile is a chosen quality rate point; the pipe is hardware.
--kbps and --span-kbps are now separate, and spans run before mu because a
span pays in bytes and mu pays in picture. FINDINGS 41.2.
120-frame scsi window |
KB/s | over budget | PSNR |
|---|---|---|---|
| no spans | 278.3 | 86/120 | 29.27 dB |
| spans, profile budget only | 280.0 | 77/120 | 29.23 dB |
| spans on the 488 KB/s pipe | 487.7 | 34/120 | 29.63 dB |
2. C_SKIP_MIXED was never measured, and it was 18% low — 45.0, now 55.0.
It is the one constant in the cost table that came from a derivation, because
the synthetic frame that would measure it cannot exist (a byte needs a coded
block for its SKIP to be mixed). Four new bracketing anchors measure it on both
emulators, and with it corrected the model predicts a real spanned decode to
-0.06% mean / 0.09% worst, against -2.99% / 4.30% before. It matters here
because a span marks its run SKIP, so mixed SKIPs are the dominant
population in exactly the frames spans are judged on. FINDINGS 41.5.
And the metric everything has been quoted in is unstable. 34/120 delivered against 14's simulated 18/120 is a 1.4% difference in mean frame cost. 55 of 120 frames sit within 5% of the deadline because the rate controller aims there, so a 1% cost shift moves 22 frames. Quote the distribution, not the count. FINDINGS 41.6.
Green light: ./tools/bench/check.sh ALL GREEN, now gating on a span-heavy
DLX3 container.
What session 11 settled
Session 11 measured v7 in blit.s and left it there; session 12 built it into
the player. Items 0 and 1 of session 11's list are done (FINDINGS 40, 41) and
the rest are carried forward in the list above.
What session 10 settled
Session 10 cross-checked the whole cycle model against a second emulator, then found that the model was denominated in the wrong currency.
FINDINGS 38 is the headline: the project is BUS-bound, not CPU-bound. Nothing
since FINDINGS 24 had counted the 68000's local memory bus — one 4-clock cycle at
a time, carrying instruction prefetch as well as data. Measured, the decoder
occupies 86.7% of it, and prefetch is 62% of that traffic. Scoring the
scsi window with the bus as the shared resource, 52 of 53 missed frames are
bus-limited and one is CPU-limited. Every optimisation since 24 has been aimed
at the budget that is not binding.
The measurement is two sources checking each other: c68k_bench counts every bus
callback exactly, and a static walk of decode.lst adds the prefetch no emulator
here can report. The walk reproduces the measured data half to 0.04%, which
is what licenses its prefetch half.
The DMAC array-chain LOSES, on the datasheet. My first pass derived its cost from bus arithmetic — a read cycle plus a write cycle, 8 clocks a pixel — and scored it at 1/120 frames over budget against v6's 10/120. Then I read the MC68450 manual (Motorola Jul 1989, bitsavers). Fig 4-25 sheet 4: a dual-address word between two 16-bit ports is 9 clocks, because note 2 gives the DMAC 4-clock reads and 5-clock WRITES. The 68000 writes in 4.
| per pixel | clocks | source |
|---|---|---|
| DMAC dual-address word | 9.000 | MC68450 Fig 4-25 sheet 4 |
v6 movem chain |
9.152 | MEASURED, FINDINGS 30 |
Scored additively (see below), against the same mode maps:
| frames over budget | |
|---|---|
| today | 84/120 |
| v6 span as built | 55/120 |
| v6 with a finer chain tail — software only | 18/120 |
| DMAC chain | 12/120 |
86% of the DMAC's advantage is v6's 24-pixel padding quantum, which is a
property of v6's unrolled movem chain and fixable in software: add a second
chain of 2-register units for the tail and the padding drops from 23 pixels to
3, at no per-span cost. Recommendation (mine): fix the quantum, drop the
DMAC. Six frames of 120 does not buy a reserved channel, a two-region
container layout, and a timing dependency neither emulator here can verify.
FINDINGS 39.
The container work is not wasted either way: v6's record and an HD63450 chaining entry are both 6 bytes, so the chain array is the span table and nothing has to change if a hardware measurement later moves the DMAC's number. FINDINGS 39.1.
I also got FINDINGS 35 wrong before the datasheet corrected me. I argued its
flat CPU debit was too pessimistic and rescored the window at 53/120 using
max(CPU, bus). A 68000 has no cache and a two-word prefetch queue, so it
stalls the moment another master takes the bus, and the MC68450 hands the bus
over in slabs under limited-rate auto-request rather than interleaving per
operand. DMA is additive. 84/120 stands. What 86.7% bus occupancy really
says is that there is almost no room to overlap anything on this machine.
FINDINGS 38.3.
And the datasheet settled STATUS's own "most load-bearing unmeasured number". FINDINGS 5's 8 clocks/word for the SCSI DMA: Fig 4-25 sheet 3 gives single-address write 5 clocks, so it is 5 clk/word with the bus held and ~12 if the DMAC arbitrates per word. 8 is the midpoint of a bracket the datasheet supports. Which end applies is a player design decision — how the MB89352 drives REQ, and whether cycle-steal-with-hold is used — worth 7 clocks a word on a 480 KB/s stream. FINDINGS 39.7.
The decoder is now pixel-exact under two independent CPU cores.
tools/bench/c68k/ links px68k's C68K core into a headless harness and decodes
the same container to the same pixels. Cycle-table error against MAME is bounded
at 3.3%, and it runs against us. Two incidental results worth keeping: MAME
0.277's M68000 is the microcode core, not Musashi (m68000.lst +
m68000gen.py), so this is two structurally different timing models agreeing
rather than two tables; and FINDINGS 28.8's "V4 costs more than RAW" reproduces
independently. FINDINGS 37.
What session 10 put on the list
Kept for the reasoning, not the priorities: items 0 and 1 are done (FINDINGS 40) and the rest are carried forward, reordered, in the session-11 list at the top. Everything below this point is from session 9 or earlier and still stands unless a later section above says otherwise.
session 10's list
-
Green light first.
./tools/bench/check.sh(~5 min, Blu-ray mounted). Verified green at end of session 9. The gate container is nowtmp/rc_fr_singe_scsi_cpufit.dlx, and it decodes 80 of 120 frames, not 120: the rig loads the whole stream into a 2 MB machine and ascsiwindow is 2.84 MB, which overran the top of RAM by 940 KB and produced a failure that looked exactly like a decoder bug.prep_dlx.pynow truncates to what fits and says so;verify_decode.py --nframesreplays the same prefix; and the stage fails loudly if the sequential pass did not complete. FINDINGS 36.At 278.6 KB/s a 2 MB machine holds ~6.7 s of stream, so the strongest test in the tree can only ever audit a prefix of a window. Getting the coverage back means gating on more than one window, not one longer pass.
-
Measure the finer chain tail with
span.sh. It is the largest single win on the table — 55/120 to 18/120 — it is software only, and the tool that measures it already exists:blit.sgains a v7 with a 2-register tail chain,prep_spans.pygenerates the streams,span.shtimes all of it in ~25 s and asserts every config drew a pixel-exact frame. The 18/120 figure is DERIVED from a conservative 56-clocks-per-4-pixel-unit model and should not be believed until it is measured. FINDINGS 39.4. -
Make sure the player actually gets DMA. DMA-vs-PIO is a property of our code, not the board: the CZ-6BS1's DMA path is real and modelled (FINDINGS 32.4), but if the player reads through IOCS and IOCS does PIO we get the 120/120 row.
docs/BENCHMARK.mditem 4 (drive the MB89352 directly) is no longer an optimisation. Benchmarkx68000 -exp1 cz6bs1, neverx68ksupr— MAME's internal SCSI has no DMA glue at all (// TODO: duplicate DMA glue from CZ-6BS1) and would measure a PIO fallback the real machine does not have. -
Re-decide the framerate, now that it is the lever that pays for the disk. 10 fps absorbs the DMA steal on current estimates. This was item 5 and a quality question; it is now arithmetic. It is still the user's call.
-
Then spans, on the CPU. The format is fully specified by measurement (FINDINGS 30.2, costs 30.5, scene-cut arithmetic 30.6) and
decode.sdoes not implement them yet. Build the v7 tail from item 1 into it. The DMAC variant is costed and loses (39.5); the container is identical either way (39.1), so nothing is foreclosed. -
Re-run the ring-buffer simulation at the surviving rate and confirm the 488 KB/s figure's provenance (FINDINGS 29.5/30.7, still open).
Do not start by hand-optimising decode.s. The hand-derived timings agree
with the measurements to 0.5% on V1 and 1% on RAW (FINDINGS 28.4), and FINDINGS
34 confirms the model on a second container. The inner loop is close to what the
instruction set allows; the cycles to be won are in the budget, not the loop.
Three of this session's near-misses had the same shape: an unobservable run
almost produced a false finding. The 23-minute "hang" (FINDINGS 34.1), the
RAM overrun that looked like a 4x-slow decoder (FINDINGS 36), and the truncated
gate that reported 49,005 differing pixels. In each case the instrument was
broken, not the thing being measured. Always stdbuf -oL a MAME job that
prints progress. A file is
block-buffered too, so a long run is unobservable until it exits — and an
unobservable run that is merely finishing looks exactly like one that is wedged.
Session 8 lost this measurement to that; session 9 lost 23 minutes to it before
spending 25 seconds getting the answer with stdbuf. FINDINGS 34.1.
What session 8 settled
- The mode decision can see cycles, it is on by default, and it costs
0.26 dB.
decide(ctx, lam, mu)minimisesD + lam*bytes + mu*cycles;ratectlbisects mu per frame against a HARD 833,333-cycle ceiling (bytes bank in the ring buffer, cycles cannot — there is no double buffer to decode ahead into).sasi37/120 misses -> 1,scsi51 -> 1. Bitrate does not move: mu changes which modes are bought, not how many bytes. FINDINGS 31,tools/analysis/13_cpu_ratectl.py. 0b. 28.7's "11 frames are impossible" was too pessimistic — it is 1. That floor held the SKIP set fixed; the real decision can also move a block to SKIP, which above ~90% non-SKIP is the only lever left. FINDINGS 31.3. 0c. V4 collapses when cycles are priced, as 28.8 predicted: 25.2 -> 20.3% of blocks atsasiand 15.0 -> 5.3% atscsi, where RAW takes it. RAW is dearer in bytes and cheaper in cycles, so the byte lagrangian's preference inverts and only the byte-rich profile can take the escape. 0d. SKIP's price depends on its neighbours, and the way out is two cost functions: a ranking constant inside the per-block lagrangian, the exact clustered rule (vq_hybrid.cycles, validated to 1 point against the 68000) for the frame-level bisection. That function is now defined once and imported by11_cpu_budget.py. FINDINGS 31.4. 0e. Both controllers are gated against decoder drift.09_ratectl_drift.pyruns bytes-only AND bytes+cycles; both 0/120. - The span is measured: 43.7 cycles/span + 9.152/pixel, fitted to 0.3% over
eleven span lengths.
tools/bench/blit.sv5/v6,prep_spans.py,span.lua, driven bytools/bench/span.sh(~25 s, not incheck.shbecause it is a wall timing). FINDINGS 30. - Only in an encoder-assisted format.
{u32 absolute GVRAM address, u16 jump displacement}into an unrolled chain, versus(x, npix)and a decoder that works it out: 43.7 + 9.152 against 97.9 + 10.46. All the arithmetic a span decoder would do per frame is known at encode time. FINDINGS 30.2. - The per-pixel cost is a function of register pressure, which FINDINGS 24 could not have shown: 9.08 was a fixed blit with 12 registers free, v5 can spare 8 and pays 10.46, v6 gets 12 back by making the encoder hold the state.
- Short spans die in the remainder path, and the fix is padding. A 12-pixel span costs more than a 16-pixel one in v5. v6 has no remainder path: lengths are multiples of 24 pixels, padding is free of everything but bytes, and an overrun past the visible 256 lands in the invisible half of the 1024-byte line stride. FINDINGS 30.3.
- Odd-
xalignment is free (259.0 vs 261.8 cycles/span) — expected on a 16-bit bus, now measured rather than assumed. - The trade is smaller than 29 derived but the conclusion holds, including 29.4's reopening of the scene cut. All 23 timing configs also drew a pixel-exact frame, so nothing here was timed against a decoder that skipped work. FINDINGS 30.5/30.6.
What session 7 settled
- 68000 code parses a bitstream and draws frames, pixel-exact.
src/player/decode.s+tools/bench/decode.lua. 120 frames of the Singe window decoded in sequence, all four block modes, verified against the new reference decodertools/encoder/dlx.py. Because SKIP blocks are claims about the previous frame, the last frame is only right if all 120 were. Incheck.shnow. FINDINGS 28. - It does not fit. Mean 81.7% of a 12fps frame, p90 116.4%, worst 135.8%;
31% of frames miss at
sasi, 42% atscsi. Zero-wait-state floor, as ever. - The dual-display-path plan (FINDINGS 24.5/25.6) is withdrawn as incoherent
— the sixth false premise this project has caught. The compose path needs a
RAM copy of the previous reconstruction; the direct path's whole selling
point is that it keeps none. Mixing them displays stale pixels on 70 of 120
frames, worst frame 18.8% of the screen. Every coherent repair is worse
than not mixing.
tools/analysis/10_pathmix_drift.py, kept runnable as a counterexample and gated incheck.sh. FINDINGS 28.1. - 24.5 also compared a copy against a copy. Its 53.6% and 76.6% both come
from
blit.sand neither includes decoding. Compose = decode-into-RAM plus the 53.6% blit, so it is strictly dearer than decoding into GVRAM. There was never a crossover. The player has one path and no reference frame, which also gives back 96 KB. - The four block modes cost 300 / 448 / 400 cycles, not one number. V4 is
1.49x a V1 block while the mode decision charges it 4x the bytes. The 24.5
model is 2.03x optimistic at the median.
tools/analysis/11_cpu_budget.pyreproduces all four frames timed on the 68000 to within 1 point. FINDINGS 28.2. - The container is big-endian but not aligned, and on a 68000 that is an
address error, not a slow read. Frame records are variable-length and laid
end to end, so their boundaries land on odd addresses. Frame 0 decoded
perfectly, then the length read for frame 1 vectored into the IPL and sat
there for 59 emulated seconds looking like an infinite loop. Found by dumping
PC and the address registers — the code was right, the data layout was not.
FINDINGS 28.3. Encoder gap:
encode.pyshould pad records to 4 bytes. Measured cost 1.66 B/frame = 20 B/s against 110 KB/s. - A full frame does not fit at 12fps in any mode. All-V1 is 110.5%, all-V4 165.2%, all-RAW 147.6%. At most ~88% of the screen can change in one frame however cheaply it is coded, and scene cuts change 100%. FINDINGS 28.5.
What session 6 settled
- Rate control works, is wired in, and is ON by default.
encode.pybisects lam per frame under a leaky bucket;--fixed-lamrestores session 5 behaviour. FINDINGS 27. - Both overshoots are closed for under 1 dB. On the Singe window, totals
including audio:
sasi137.4 -> 109.5 KB/s (target 110) for -0.60 dB,scsi381.6 -> 280.0 KB/s (target 280) for -0.91 dB. Zero frames hit the lam=800 cliff at either profile. FINDINGS 27.2. - The FINDINGS 26 desync is gone by construction, not by tuning. The
encoder is frame-drivable (
vq_hybrid.frame_ctx/decide/paint) and rate control feeds back the frame it actually emitted. The regression testtools/analysis/09_ratectl_drift.pygoes 111/120 drifting frames -> 0, and it is now part of./tools/bench/check.sh. FINDINGS 27.1. - Rate control makes the display path cheaper. Raising lam moves blocks to
SKIP and V1, so there is less to write:
scsi's median display cost drops 53.6% -> 47.1%. The decoder conclusion of 25.6 is unaffected. FINDINGS 27.3. - FINDINGS 26.5 was wrong in both halves, and this is the fifth false premise
this project has caught.
_paintwas not the bottleneck (14% of a frame) and the ladder was never "minutes" (~18 s; the minutes were k-means inbuild). Vectorising it was still right — 17.1x — but what actually makes per-frame rate control affordable is thatVQ.assigndepends on neitherlamnorprev, so it is cached: a 12-step search over 120 frames costs 0.31 s against 49.1 s. FINDINGS 27.6. --prefillis a trap and defaults to 0. It buys a permission to overshoot of exactly bucket/nframes; at prefill=1.0 the Singe window goes to 116.3 KB/s against a 110 ceiling, and on a 14-frame clip it disables the controller outright. FINDINGS 27.4.- Fixed-lam
sasiwas already 5% over target on 00020, the clip everyone called easy — nothing noticed because the profile table quotes PSNR, not bitrate. FINDINGS 27.5. - 1.2-second clips cannot be used to judge rate control. The bucket's startup transient is bucket/nframes: 6% on a 10 s window, 20% on 00020. Same lesson as FINDINGS 25.3, different costume.
Start here: is the tree still green?
./tools/bench/check.sh
~4 min, needs the Blu-ray mounted. From source media it re-runs both display
regression tests, the rate-control drift test (session 6, now covering BOTH
controllers -- bytes, and bytes+cycles), the display-path coherency
counterexample and a 120-frame 68000 decode (session 7), then prints
ALL GREEN. Verified green at end of session 8.
Do not run two of these at once, and do not run one alongside a MAME timing
job: they share tmp/ snapshot directories and log files, and the second run
silently truncates the first one's output.
If it fails, fix that before doing anything else — everything downstream assumes
the display path is pixel-exact.
The two session-7 stages are worth knowing the shape of before they fail on you:
10_pathmix_drift.pyis expected to exit non-zero;check.shfails if it ever starts passing, because that would mean the counterexample behind the one-path decoder had stopped reproducing.- the decode stage needs
tmp/rc_fr_singe_sasi_rcprofile.dlxand will spend ~55 s encoding it if it is missing, nearly all of that k-means inH.build.
Decisions locked
| decision | value | why |
|---|---|---|
| Target CPU | 68000 @ 10MHz (stock) | hardest honest constraint |
| Display mode | 256 colors, 256x192 in 256x256 CRTC mode | every mode is 1 word-access/pixel, so 256c is free vs 16c |
| Double buffer | none — page 1 sacrificed | enables movem.l 24px bursts; delta coding needs a RAM reference frame anyway |
| Codec | hybrid VQ: SKIP / V1 4x4 / V4 four-2x2 / RAW, per-block rate-distortion | flat 4x4 VQ was measured and rejected — see FINDINGS 9-10 |
| Quality modes | one: scsi (USER DECISION, session 9 — sasi dropped) |
a SASI volume is 40 MB and the game is 146 MiB at the LOWEST rate this codec makes. FINDINGS 32 |
| Delivery medium | SD-backed SCSI (BlueSCSI / SCSI2SD), as locked in session 2 | capacity does not bind on SD at any rate this codec makes; it is what killed SASI, and it is what rules CD-ROM OUT (1.09 GiB at the session-13 candidate rate, against a CD's ~620 MiB and ~150 KB/s at 1x). FINDINGS 32.3, 42.7 |
| SCSI DMA handshake | UNDECIDED — and it decides the project | W, clocks stolen per word, bracketed 5..12 by MC68450 Fig 4-25. W<=6 fits 0/120 frames; W=8 misses 47/120. It is a property of how the player drives the MB89352, so it is ours to choose, not to receive. FINDINGS 42.4-42.6 |
| Profile axis | I/O bandwidth only | the profiles say nothing about CPU; both target the same stock 10MHz 68000, and the Super has SCSI at 10MHz. FINDINGS 28.7 |
| Framerate | 12 fps, explicit decimation | source has zero duplicate frames; no free "twos" win |
| Emulator | MAME 0.277 x68000 | accurate enough that measured cycles mean something |
| SNES project reuse | MIT — cleared | data/events/ scene graph is reusable with attribution |
The SASI/SCSI question is RESOLVED — and in session 9 SASI was DROPPED
Session 1 left "which machine do we target" open. Session 2's answer was ship
both, as two quality profiles. Session 9 retired sasi (USER DECISION) on
CAPACITY, not bandwidth: a SASI volume is limited to 40 MB, and the 22.8 minutes
of unique scene footage on the source Blu-ray is 146 MiB even at 110 KB/s —
more than the machine's whole 4-unit SASI address space. FINDINGS 32.
Everything below this line about two profiles is the session-2..8 record, kept because every measurement in FINDINGS 27-31 was taken against it. One profile ships:
| profile | target | lam | quality (00020 / 00146) | machine |
|---|---|---|---|---|
sasi |
RETIRED session 9 | |||
scsi |
280 KB/s | 10 (floor) | 39.4 / 32.3 dB | Super/XVI, or CZ-6BS1 board |
~The 110 KB/s rate point is not necessarily gone with the interface: a 1x
CD-ROM sustains FINDINGS 32.3.150 KB/s and CD-ROM is the only period medium with room for the
whole game (374 MiB at scsi, 600 MiB with spans). The user's call was to ship
one profile now and settle the medium when the pipe is measured.
SUPERSEDED by FINDINGS 42. There is no rate point left to choose: the span
pass saturates at ~837 KB/s and the candidate container is s13_280p1500,
837.4 KB/s = 1.09 GiB for the whole game. That is unremarkable on SD and
impossible anywhere period, so CD-ROM is closed, not parked. --kbps is now
a quality knob whose value barely matters above ~800 KB/s of span budget; the
number that matters is W.
| what | KB/s | whole game (1366.6 s) |
|---|---|---|
scsi profile as set |
280.0 | 0.36 GiB |
| session-12 gate | 487.7 | 0.64 GiB |
candidate s13_280p1500 |
837.4 | 1.09 GiB |
probe s13_lossless |
934.6 | 1.22 GiB |
That "machine" column is about the BUS, not the CPU. The profiles are an
I/O-bandwidth axis and say nothing about clock speed: the X68000 Super has
built-in SCSI at 10 MHz (x68k.cpp:1194, 40_MHz_XTAL/4, same as the base
machine), and only the XVI is faster. Both profiles target the same stock
10 MHz 68000, so both must fit the same 833,333-cycle frame budget — and as of
session 7 neither does. FINDINGS 28.7.
As of session 6 lam is a floor, not a setting. The target is a ceiling and
the encoder bisects lam per frame to stay under it; the profile's lam is the
best quality it is allowed to spend on a quiet frame. On the worst sustained
window that takes sasi from 137.4 to 109.5 KB/s and scsi from 381.6 to
280.0 KB/s, for -0.60 and -0.91 dB. FINDINGS 27.2.
Sized against the user's working figure of 4 Mbps = 488 KB/s sustained, on SD-backed SCSI (BlueSCSI / SCSI2SD) — so that rate is a bus-limited constant, not an average over seek latency.
Both profiles fit with room. Ring-buffer simulation on the real per-frame sizes gives zero required prefill for every scene at both profiles: the fill delivers 40.69 KB per frame time and only one measured frame (42.10 KB) exceeds that, recovered by the next. A 256 KB buffer carries ~1 s of stall tolerance, far more than an SD-backed seek needs. FINDINGS 21.
An earlier warning here said scsi did not fit because a frame peaked at 96.4%
of the pipe. That compared instantaneous demand to a sustained rate as if they
had to match frame-by-frame; with a buffer the test is cumulative, and it passes.
scsi is now within 0.5 dB of the palette ceiling on 00020. These were
initially set at 45 / 75 KB/s, which was 12% / 7% bus utilisation — read off the
RD curve rather than derived from the hardware. See FINDINGS 17.
Codebooks are k=256 with 1-byte indices in both profiles. k=1024 was measured
and rejected — see FINDINGS 14, it was a false-good result from a rate model
that undercharged the index. Do not ship past lam~800; FINDINGS 15 has the cliff.
Because of the RAW escape mode, lam=0 is pixel-exact against the palettised
frame (measured 0.00 dB loss). The profiles are two points on one continuous
rate-distortion curve, not two codecs.
What session 3 settled
- The display path works and is verified end to end. First real frame on an
emulated X68000 screen:
docs/images/x68k_first_frame_compare.png. Full write-up in FINDINGS 22. Everything before this session was Python-side or a headless-video nonerun, which cannot snapshot at all. - The render is pixel-exact, not merely close. With monitor contrast at 15,
all 256 palette entries render exactly as
GGGGGRRRRRBBBBBI+pal6bitpredicts. That exactness is the regression test — seetools/bench/verify_frame.py, which exits non-zero if it ever drifts. - Three hardware facts that were previously assumed are now confirmed from
MAME 0.277 source, not folklore: the palette word format, the 1024-byte
GVRAM line stride, and the 256-colour page aliasing in
HARDWARE.md. All three were already written down correctly; they are now cited. A new quality ceiling was measured — the 15-bit+I palette alone costs 38.88 dB.Superseded by session 4: that figure assumed the shared LSBIis always 1. Chosen per entry, the ceiling is 40.81 dB. FINDINGS 23.3.- Two shell traps that wedged session 2's background jobs are documented in the working-setup section below. They cost ~1.5 h of wall clock and a wedged CPU core, and one of them was hit again this session.
What session 5 settled
- 68000 code drew a frame, and the blit was measured.
tools/bench/blit.sblit.lua. The snapshot passesverify_frame256.pyunchanged — pixel-exact in the real 256x256 mode. FINDINGS 23.5 is closed: no longer "proven from Lua only".
- The 38% full-frame blit estimate is dead. It is 53.6%. And that is a zero-wait-state floor — MAME models no GVRAM wait states, so real hardware is worse. FINDINGS 24. Every variant was hand-derived from the MC68000 timing tables before being measured and the two agree to 0.006-0.43%, so this is not another MAME artefact.
- Reading the source frame is exactly half the blit cost (V1 53.6% vs a write-only floor V3 of 27.1%). That is what makes the architecture question below live.
- That number is now measured, and the answer is "implement both paths".
On the worst sustained window found on the disc, 30% of frames (
sasi) to 53% (scsi) sit above the 70% crossover and want the flat blit; the rest want direct-to-GVRAM. A player that picks per frame — the mode headers are parsed before any pixel is written, so the count is free — pays a median 37.0% and is capped at 53.6%. FINDINGS 25.6. - The sustained action sequence exists, was found by measurement, and breaks
both profiles.
tools/analysis/07_motion_survey.pyscans a whole stream for the hottest sliding window; on 00223 it is t=539.4s, the Singe endgame, at 2.01x the stream mean. There, fixed-lamsasiovershoots 110 -> 129.6 KB/s (+18%) andscsi280 -> 373.8 KB/s (+34%). Rate control is no longer insurance — it is required. FINDINGS 25.3. - The two largest streams on the disc are bonus material, not game footage. 00216 is the feature with a burned-in commentary PiP; 00215 is the commentary itself. 00223 (9.4 min) is the clean one. A size-ranked survey would have encoded live action. FINDINGS 25.1.
- Rate control is unsound as written, caught before wiring it up. The
lam-ladder in
ratectl.pypicks frames from independent temporal chains, so SKIP blocks reference reconstructions the decoder never saw: 111 of 120 frames drift, worst frame 43.4%, reported PSNR overstated 0.36 dB. Regression testtools/analysis/09_ratectl_drift.py. FINDINGS 26. - On hard content the scene palette, not the display, is the binding
ceiling — 31.33 dB on the Singe window against 39.90 dB on 00020 and 40.81
dB for the X68000 display.
scsiis already within 0.51 dB of it. FINDINGS 25.4.
Superseded within session 5
4a. The decoder architecture hinged on one unmeasured number. Writing
codewords straight into GVRAM costs 76.6% of the frame budget for a full
frame (V4 — the 1024-byte stride kills the movem.l burst), but scales with
the non-SKIP block fraction and needs no RAM reference frame at all,
because the previous frame is already in GVRAM. Compose-then-blit is a flat
53.6%. They cross at 70% of blocks changed. FINDINGS 24.5.
What session 4 settled
- A real 256x256 CRTC mode exists and is verified.
crtc_mode.lua, derived fromx68k_crtc.cpp's divisor ladder rather than recalled — the derivation is self-checking (368 = 1104/3 exactly, so the horizontal registers divide by three with no remainder). Snapshot is native 256x512, active area pixel-exact, letterbox true black. FINDINGS 23. The x=512 wrap of FINDINGS 22.5 is gone. - The palette ceiling was wrong by 2 dB, in our favour. The shared LSB
Imust be chosen per palette entry, not hardcoded to 1. Doing so lifts the display ceiling from 38.85 to 40.81 dB and is the only way to get true black at all (pal6bit(1) = 4). 102 of 256 entries wantI = 0. This supersedes FINDINGS 22.4 and givesscsi~2 dB more headroom than believed. The encoder does not do this yet — see the encoder-gaps list. - Letterboxing costs one palette entry. 255 colours + a reserved black at
index 0, with
I = 0on it.prep_frame.py --reserve-black. FINDINGS 23.4. - MAME's graphics double-scan is phase-shifted one raster line — pairs are
(1,2),(3,4),..., not (0,1), because
get_gfx_pixelhalves the absolute scanline andvbegin = 41is odd. Cost a false failure. FINDINGS 23.2.
What session 2 settled
- The critical-path question is answered. "Does VQ soften Bluth's linework
unacceptably?" — flat 4x4 VQ: yes, badly. The hybrid (SKIP/V1/V4/RAW): no.
Verified by eye, not just PSNR. See
docs/FINDINGS.md9-11 and the two images indocs/images/. Both profiles use k=256; see item 2b. - Session 1's 12fps bitrate was wrong (183 KB/s claimed, 340 KB/s measured). Halving the framerate does not halve the bitrate. FINDINGS 8. 2b. A fourth false-good result was produced and caught this session — k=1024 codebooks looked like a +2.4 dB free win because the rate model charged 1 byte for a 10-bit index. FINDINGS 14. The k=256 configuration ships.
- The 256-colour palettised frame is the real quality ceiling and it looks excellent. Judge the codec against that, not against 1080p.
- Encoder exists and produces a real bitstream:
tools/encoder/.
Encoder — working
python3 tools/encoder/extract.py 00020 /tmp/fr_00020 12 crop
python3 tools/encoder/encode.py /tmp/fr_00020 out.dlx --profile sasi --preview p.png
| file | role |
|---|---|
extract.py |
.m2ts -> 256x192 PNGs, 12fps, spatial-only denoise |
vq.py |
palette, blockify, hand-rolled k-means (no sklearn on this box), PSNR |
vq_hybrid.py |
the codec: 4 block modes + lagrangian mode decision |
ratectl.py |
SASI/SCSI profiles, leaky-bucket rate control |
encode.py |
CLI + DLX1 container writer |
DLX1 container layout is documented in the encode.py docstring. All
multi-byte fields are big-endian so the 68000 reads them with a plain move.
Known encoder gaps
Rate control is written but not yet wired intoDONE, session 6. It is on by default;encode.py.--fixed-lamrestores the old behaviour. Gated bytools/analysis/09_ratectl_drift.py, which is now incheck.sh.- Payload is deliberately NOT entropy-coded — deflate decode does not fit in the 68000's frame budget (FINDINGS 17.2). Do not "optimise" this later.
Frame records are not aligned.DONE. The container isDLX2andwrite_container(encode.py:139-156) pads both the frame table and every record to a 4-byte boundary, inside the rate controller's accounting rather than after it, andencode.pyreports the cost in B/frame and B/s. Unaligned is an ADDRESS ERROR on a 68000, not a slow read (FINDINGS 28.3);DLX1is still read, so the old unaligned containers have not been invalidated.The mode decision is blind to CPU cost.DONE, sessions 12-19.vq_hybrid.decide()(vq_hybrid.py:218) minimisesdistortion + lam*bytes + mu*(decode cycles + byte_clk*bytes)against the MEASURED per-mode cycle costs, andcycles()scores a whole frame with the exact clustered SKIP rule rather than a per-block constant. This entry sat in the gap list for several sessions after it was closed; the list is not self-maintaining, and stale entries here become phantom work in the roadmap.- Palette packing is not implemented in the encoder. It still emits 24-bit
palettes; the X68000 word packing happens Lua-side. Whatever writes real
palette words must pick
Iper entry by minimum squared error (FINDINGS 23.3, worth 1.96 dB) and reserve index 0 as black withI = 0(FINDINGS 23.4). - Codebooks are per-scene and rebuilt from scratch; no inter-scene reuse.
DONE, session 6 — vectorised, 17.1x. It was never the bottleneck, though:_paintis a Python per-block loop.VQ.assignis 78% of a frame andH.build's k-means is 51 s of a 55 s run. That k-means is now the thing to attack before the full-disc survey, not anything in the per-frame path. FINDINGS 27.6.
Working setup (unchanged from session 1, re-verified)
MAME ROMs — ~/mame/roms/x68000.zip. Must pass -bios ipl10.
mame x68000 -bios ipl10 -video none -sound none -nothrottle -seconds_to_run 3
Assembler — tools/vasm/vasmm68k_mot -Fbin -o out.bin in.s
Blu-ray — udisksctl loop-setup -r -f DRAGONS_LAIR.iso -> /media/reala-misaki/BDROM
(still mounted as of end of session 2).
MAME Lua harness — tools/bench/*.lua, working. Three gotchas (retain the
notifier subscription in a global; the stack register is SP not A7;
autoboot_script fires at PC=0 before boot) are documented in FINDINGS.
Two shell traps, both hit again this session:
- piping MAME (or any long job) through
grepblock-buffers — write to a file. pkill -f <pattern>matches your own shell and kills it (exit 144). Usepkill -xor kill by PID.pgrep -f <name> | xargs killkills your own shell too — exit 144. Same root cause as thepkill -ftrap above: the shell's own command line contains the pattern. Hit again in session 5, which makes it four times across three sessions. Kill by PID captured at launch ($!), or usepkill -x.until ! pgrep -f foo.py; do sleep; donewatcher loops never exit. The watching shell's own command line contains the stringfoo.py, sopgrep -fmatches the watcher itself and the loop spins forever. Session 2 left 11 of these wedged for over an hour. Wait on the PID (while kill -0 $PID) or on a sentinel file the job touches when it finishes -- never on a-fname match.timeout N mame ...does not kill MAME. MAME catches SIGTERM and, with an autoboot script blocked waiting on a flag that never arrives, never reaches its shutdown path.timeoutwithout-kthen waits forever while MAME burns a full core at-nothrottle. Alwaystimeout -k 5 N.
Disk throughput benchmark — still blocked, no longer gating
IOCS _B_READ returns -1 uniformly. Full diagnosis and the four untested
hypotheses are in session 1's notes (git history of this file, commit 65112b9);
the ordered plan for retrying is in docs/BENCHMARK.md.
Status changed twice this session — read this rather than the git history. It was briefly promoted to critical-path while the working bandwidth figure was misread as 4 MB/s. With the correct figure (4 Mbps = 488 KB/s) and the ring-buffer simulation showing zero required prefill for both profiles (FINDINGS 21), the design no longer hangs on it. Pixel-exact on SCSI is not available at 4 Mbps — it needs 92-97% of the pipe — so there is no longer a "measure it and maybe ship transparent" decision waiting.
What the benchmark is still worth doing for:
- Confirming the 4 Mbps figure. It is user-supplied and its provenance is not recorded. Every profile hangs off it.
- Confirming DMA is actually used. If transfers fall back to PIO the CPU cost rises far above the ~12-15% cycle-steal estimate and CPU becomes the binding constraint. This is the worst plausible outcome and the cheapest to check — do it first.
Do not try to get the bandwidth number out of MAME. Its SCSI/SASI devices are
functional models, not timing-accurate; a KB/s figure from MAME measures the
emulator's scheduler. docs/BENCHMARK.md covers the three-tier approach
(MAME validates the path, derivation bounds it, real hardware settles it).
Display path — VERIFIED (session 3), in a real mode (session 4), by 68000 code (session 5), by a 68000 DECODER (session 7).
The first real frame is on screen: docs/images/x68k_first_frame_compare.png.
Session 7 went from copying a frame to parsing one. src/player/decode.s
reads DLX1, dispatches all four block modes and writes straight into GVRAM;
120 frames decoded in sequence are pixel-exact against tools/encoder/dlx.py
(tools/bench/verify_decode.py, in check.sh). The blit numbers below are
still correct for what they measured — a copy — but they are no longer the
display-path budget: the decoder costs 300/448/400 cycles per V1/V4/RAW block
and misses the 12fps budget on 31% of frames. FINDINGS 28.
Session 5 closed the gap this paragraph used to describe. GVRAM is now
filled by 68000 instructions and the result is still pixel-exact, and the blit
cost is measured rather than estimated: 53.6% of a 12fps frame, not 38%
(FINDINGS 24). The remaining caveat is different and narrower: MAME models
no GVRAM wait states, so 53.6% is a floor and real hardware is worse.
Full write-up in FINDINGS 22. Harness: tools/bench/show_frame.lua +
tools/bench/prep_frame.py.
Three facts the player MUST honour, none of which were guessable:
| what | where | value |
|---|---|---|
| Un-hide the graphics layer | CRTC R20 $E80028 |
clear bit 11 ("G-VRAM set to buffer"); IPL leaves 0x0B16 |
| Colour setup (256c) | CRTC R20 bits 9-8 | 0x0100 |
| Monitor contrast | $E8E001 bits 3-0 |
IPL leaves 14; write 15 or everything renders 7% dark |
The R20 = 0x0116 value quoted here in session 3 is the 768-wide IPL timing
with the gate cleared. The shipping value is R20 = 0x0110 — see the mode
table in tools/bench/crtc_mode.lua, which is now the single source of truth
for all of R00-R08 and R20.
Bit 11 is the one that cost the most time: GVRAM writes land and read back
correctly while the layer is invisible, so the video controller looks guilty and
is not. Contrast 0 blanks the screen — free fade-to-black for transitions.
Palette format is now confirmed from MAME source, not assumed:
GGGGGRRRRRBBBBBI (G 15:11, R 10:6, B 5:1, shared LSB I), expanded as
pal6bit((field<<1)|I). With contrast at 15 the render is pixel-exact.
Ceiling: the 15-bit+I palette costs 40.81 dB against the 24-bit palettised
source, once I is chosen per entry (FINDINGS 23.3 — session 3's 38.88 dB
assumed I = 1). Still the same order as the scsi profile's own codec error
(39.4 dB), so scsi remains near display-transparent, with ~2 dB more headroom
than session 3 thought.
Snapshot recipe that works (-video none CANNOT snapshot):
SDL_VIDEODRIVER=dummy mame x68000 -bios ipl10 -video soft -window \
-sound none -nothrottle -plugins -autoboot_script <script>.lua \
-snapshot_directory ./snap -snapview native -seconds_to_run 6
-snapview native drops MAME's LED artwork and gives a clean 768x512 screen.
Next steps, in priority order
Measure the non-SKIP block fraction.DONE, session 5, and its answer WITHDRAWN in session 7 — FINDINGS 28.1/28.2. It concluded "implement both display paths and pick per frame, median 37.0%, capped at 53.6%". Mixing the paths is incoherent (the compose path needs a RAM reference the direct path never writes) and the two costs it compared were both copies with no decode in either. The shipping decoder has one path. The non-SKIP fraction is still reported byencode.pyand is still the right thing to look at — it is just no longer a switch. Original framing kept below, because its instruction to report the distribution rather than the mean is the part that held up: FINDINGS 24.5: compose-in-RAM-then-blit costs a flat 53.6% of the frame budget; decode-direct-to-GVRAM costs 76.6% x (fraction of blocks that are not SKIP) and needs no RAM reference frame. They cross at 70%. Which side of 70% the content sits on decides which decoder inner loop to write, so this must come before writing one. It needs no new machinery — the mode decision invq_hybrid.pyalready computes it per frame and simply never reports it. Add the histogram (SKIP / V1 / V4 / RAW counts per frame) toencode.pyoutput and run it over the clips already extracted. Report the distribution, not the mean: a scene-cut frame is ~100% non-SKIP and a held frame near 0%, and the mean of those two is a number describing no actual frame.
1b. Wire rate control into DONE, session 6. FINDINGS 27.
Both overshoots closed for under 1 dB, drift test at zero, encode.py.check.sh gates
it. The remaining rate-control question is not a defect: whether --rc-floor open is worth taking on quiet content. It measured as worth 0.00 dB on
the Singe window (no frame there is quiet enough to saturate the bucket), so
it needs a genuinely quiet scene to decide, and it is a quality-per-byte
judgement rather than a correctness one.
68000 decoder skeleton.DONE, session 7.src/player/decode.s, pixel-exact over 120 frames, gated incheck.sh. It answered the question it was written to answer, and the answer is no: it does not fit — mean 81.7% of a 12fps frame, 31% of frames over 100%. FINDINGS 28. The follow-on is priority 0 at the top of this file.
2a. Re-budget everything against the MEASURED per-mode costs, not 53.6% and
not 38%. Session 7 replaced the model twice over (FINDINGS 28.2): the display
path is not one number times a block fraction, and the median frame is 74.4%
rather than 36.6%. The original note is kept below because its warning about
downstream figures derived from a dead estimate is exactly what happened
again.
Re-budget everything against 53.6%, not 38%. Several downstream figures
were derived from the old estimate. The blit alone now eats over half the
frame at 12fps in the compose-then-blit design, before any decode, and MAME
models no GVRAM wait states so that is a floor. This may reopen questions
that were closed against the 38% number — check FINDINGS 17.2's entropy-coding
rejection, which was argued as "54% LZ4 with no room beside a 38% blit". The
conclusion gets stronger, not weaker, but the arithmetic should be restated.
2b. Pad frame records to 4 bytes in encode.py. Not optional: unaligned
records are an address error on a 68000 (FINDINGS 28.3), and prep_dlx.py
currently repairs it at load time, which the shipping player streaming from
disc cannot do. The padding is real bytes on disc, so it has to be inside
the rate controller's accounting, not added after it. 20 B/s at 12fps.
-
Full-disc survey. Now scoped by session 5 rather than open-ended: the worst sustained window is measured (FINDINGS 25), so what remains is the distribution over content, not the worst case.
- Classify content / menu / bonus — not just menu vs content. FINDINGS 25.1: the two largest streams are bonus material and look like content by size, duration and bitrate alike.
- Run
tools/analysis/07_motion_survey.pyper stream first; it is cheap (96x72 greyscale) and gives a hot-window shortlist so the expensive encode only runs where it matters. VectoriseDone. The cost to attack now is_paintbefore this run.H.build's k-means: 51 s of a 55 s run, and it runs once per scene.Do it after rate control (1b), or it measures an encoder nobody ships.Rate control is in, so the survey now measures the shipping encoder.
-
Confirm DMA vs PIO in MAME (see the benchmark section above) — cheap, and the only thing that could still move CPU into the binding position.
-
Resolve the framing question (FINDINGS 12: crop vs squash vs wide). Needs an eyeball against arcade reference, not a measurement.
-
Import the scene graph. SNES project
data/events/(MIT, cleared), cross-checked against DirkSimple (zlib) which transcribed the same data independently — diff them to catch transcription errors before committing any of it to 68000 tables. -
ADPCM audio. MSM6258, 15.6kHz mono, 7.8 KB/s — already budgeted in
ratectl.py, not yet extracted or encoded.
Explicitly abandoned — do not re-propose
Entropy-code the payload.Deflate decode is ~216% of the frame budget on a 68000; LZ4 is ~54% with no room beside a 38% blit (FINDINGS 17.2). All bitrates are raw payload. This also demotes the "247 KB/s lossless" figure in FINDINGS 8 to a compression upper bound, not a shippable design.k=1024 codebooks.False-good result from a rate model that charged 1 byte for a 10-bit index (FINDINGS 14). k=256 wins at every matched bitrate.Flat 4x4 VQ.Rejected by eye (FINDINGS 9).
Not yet started
- A player, as opposed to a decoder.
src/player/decode.sparses DLX1, dispatches all four block modes and draws pixel-exact frames, but it decodes from RAM that Lua pre-loaded. There is no disc streaming, no ring buffer, no audio, no timing against the VBL, and no scene branching. - Codebook expansion on the 68000.
prep_dlx.pydoes it host-side because it is a load-time cost and including it would flatter or damn the inner loop. The player must do it: 8 KB + 2 KB per scene. - ADPCM audio extraction/encoding
- Disk image packaging
- Game logic (scene branching, input windows, death clips)
Reproducing the 256x256 mode result (session 4)
python3 tools/encoder/extract.py 00020 tmp/fr_00020 12 crop
python3 tools/bench/prep_frame.py tmp/fr_00020 tmp/frame256.bin 0 --reserve-black
mkdir -p tmp/snap256 && cd tmp && SDL_VIDEODRIVER=dummy timeout -k 5 90 mame x68000 \
-bios ipl10 -video soft -window -sound none -nothrottle -plugins \
-autoboot_script ../tools/bench/show_frame256.lua \
-snapshot_directory ./snap256 -snapview native -seconds_to_run 6
cd .. && python3 tools/bench/verify_frame256.py
Exits non-zero on any drift. Expected: 256x512 native, double-scan exact, active 256x192 pixel-exact, letterbox true black, ceiling 40.81 dB.
Reproducing the display result
python3 tools/encoder/extract.py 00020 tmp/fr_00020 12 crop
python3 tools/bench/prep_frame.py tmp/fr_00020 tmp/frame.bin 0
mkdir -p tmp/snap_verify && cd tmp && SDL_VIDEODRIVER=dummy mame x68000 -bios ipl10 \
-video soft -window -sound none -nothrottle -plugins \
-autoboot_script ../tools/bench/show_frame.lua \
-snapshot_directory ./snap_verify -snapview native -seconds_to_run 6
cd .. && python3 tools/bench/verify_frame.py
Verified cold from the Blu-ray at end of session 3: exact match, 38.88 dB.
(That 38.88 is correct for this test: show_frame.lua still packs I = 1.
The 40.81 dB ceiling comes from show_frame256.lua, which picks I per entry.)
tmp/ is gitignored scratch. The frames are NOT in the repo — regenerate them
with extract.py; the earlier ones lived in /tmp and do not survive a reboot.
Reference material on this box (not in the repo)
- MC68450 DMAC manual:
~/src/mc68450.pdf(Motorola, Jul 1989, from bitsavers;curlit with a browser User-Agent or you get a 403). This is the primary source for FINDINGS 39 and it overturned two derived answers in one session. Fig 4-25 is the timing table — sheet 1 chaining, sheet 3 single-address, sheet 4 dual-address; note 2 (4-clock reads, 5-clock writes) is the one that mattered. Sects 4.5.2.1-3 are the arbitration overheads, 5.2.3.2 the limited-rate auto-request slabs.pdftotexthandles it. - px68k source:
~/src/px68k— onlym68000/c68k.cis used, bytools/bench/c68k/. - MAME 0.277 source:
~/src/mame-mame0277/(tarball~/src/mame0277.tar.gz). Downloaded this session to settle the graphics-layer question. The files that matter aresrc/mame/sharp/x68k_v.cpp,x68k_crtc.cpp,x68k_crtc.h,x68k.cpp. Read these before theorising about X68000 video behaviour — six register-poking attempts failed against a gate that one grep found. - Blu-ray mounted at
/media/reala-misaki/BDROMviaudisksctl loop-setup -r -f DRAGONS_LAIR.iso.
Parked ideas (not scheduled, not abandoned)
- Cliff Hanger, retitled as Lupin III (user, session 4). Stern's 1983
laserdisc game was cut from Castle of Cagliostro and Mystery of Mamo with
the Lupin branding stripped; a port would restore it. Technically cheaper
than this project: same content class (cel animation, flat colour, hard
cuts), ~13 min of footage vs Dragon's Lair's ~22, and flatter linework than
Bluth's, so fewer blocks should escape to V4/RAW. The codec, the display path,
and
crtc_mode.luawould all drop straight in. The real cost is media prep, not code: there is no clean master cut to Stern's scene boundaries the wayDRAGONS_LAIR.isois, so the footage would have to be sourced and cut to match. Not to be started until the CPU path is proven — it changes nothing about whether this design works.
Reproducing the blit measurement (session 5)
python3 tools/encoder/extract.py 00020 tmp/fr_00020 12 crop
python3 tools/bench/prep_frame.py tmp/fr_00020 tmp/frame256.bin 0 --reserve-black
tools/vasm/vasmm68k_mot -Fbin -o tmp/blit.bin tools/bench/blit.s
mkdir -p tmp/snap_blit && cd tmp && SDL_VIDEODRIVER=dummy timeout -k 5 900 mame x68000 \
-bios ipl10 -video soft -window -sound none -nothrottle -plugins \
-autoboot_script ../tools/bench/blit.lua \
-snapshot_directory ./snap_blit -snapview native -seconds_to_run 120
~25 s wall. Prints cycles/frame and % of a 12fps budget for V1-V4, and snapshots
V1's output. To check that snapshot is still pixel-exact:
sed 's|snap256|snap_blit|' tools/bench/verify_frame256.py | python3 -
Not added to check.sh: check.sh asserts pixel-exactness, and asserting wall
timings there would make the green-light check sensitive to host load.
Reproducing the streaming result (session 18)
The ring-buffer pass, on a stock 2 MB machine, container in a host file:
DLX=tmp/rc_fr_singe_scsi_span.dlx
tools/vasm/vasmm68k_mot -Fbin -o tmp/stream.bin src/player/stream.s
python3 tools/bench/prep_stream.py "$DLX"
mkdir -p tmp/snap_stream && rm -f tmp/snap_stream/x68000/*.png
( cd tmp && DLX_STREAM_KBPS=0 SDL_VIDEODRIVER=dummy stdbuf -oL \
timeout -k 5 600 mame x68000 -bios ipl10 -ramsize 2M -video soft -window \
-sound none -nothrottle -plugins -autoboot_script ../tools/bench/stream.lua \
-snapshot_directory ./snap_stream -snapview native -seconds_to_run 90 \
> stream_check.log 2>&1 )
python3 tools/bench/verify_decode.py "$DLX" --snap tmp/snap_stream
Env knobs: DLX_RING_KB (default 256), DLX_STREAM_KBPS (REQUIRED, no
default; 0 = unlimited, which isolates the WRAP question from the DELIVERY
one), DLX_PREFILL_KB (default 0).
19_ring_stream.py's --kbps is required too, as are --bus/--kbps on
12/14/16/17. None of them has a default any more — see the retirement note at
the top of this document.
DLX_STREAM_KBPS=0 is the right setting for a correctness gate: the wrap
policy is what is being tested and an unlimited pipe removes delivery as a
variable. Use a finite rate to get the DEADLINE/REQUIRED PREFILL lines.
The ring simulation, which needs no emulator and takes a second:
python3 tools/analysis/19_ring_stream.py # both containers
python3 tools/analysis/19_ring_stream.py <c.dlx> --kbps 512 --ring 128
It reports the zero-prefill pipe — the threshold a medium has to clear for the container to need no prefill at all — which is the number to measure a BlueSCSI against. It also warns explicitly when demand exceeds supply on the MEAN, because a "required prefill" figure for a sustained overrun is the most flattering possible way to state one.
Do not run this alongside another MAME job. They share tmp/ logs and
snapshot directories and the second run silently truncates the first.
Reproducing the decoder result (session 7)
python3 tools/encoder/encode.py tmp/fr_singe tmp/rc_fr_singe_sasi_rcprofile.dlx --profile sasi
python3 tools/bench/prep_dlx.py tmp/rc_fr_singe_sasi_rcprofile.dlx
tools/vasm/vasmm68k_mot -Fbin -o tmp/decode.bin src/player/decode.s
mkdir -p tmp/snap_decode && cd tmp && SDL_VIDEODRIVER=dummy timeout -k 5 900 mame x68000 \
-bios ipl10 -ramsize 2M -video soft -window -sound none -nothrottle -plugins \
-autoboot_script ../tools/bench/decode.lua \
-snapshot_directory ./snap_decode -snapview native -seconds_to_run 150
cd .. && python3 tools/bench/verify_decode.py tmp/rc_fr_singe_sasi_rcprofile.dlx
~90 s wall. Prints cycles/frame and % of a 12fps budget for four real frames spanning the non-SKIP distribution, four synthetic single-mode frames, and one full 120-frame pass; then verifies the last frame is pixel-exact. Expected: median 73.8%, p90 116.4%, max 135.8%, mean 81.7%; V1 299.9 / V4 448.2 / RAW 400.4 cycles per block.
-ramsize 2M here matters for a different reason than it used to. MAME defaults
to 4M and the locked target is a stock 2 MB machine, so a timing reproduction
quoting the target's memory should use it. But the rig preloads the whole
container, so at 2M this run is truncated to the prefix that fits — 37 of 120
frames on the current span-heavy container — and its real-frame anchors are
therefore a biased sample (session 16 measured the bias at 8.2% on the mean;
FINDINGS 45.3). The synthetic anchors are unaffected: they come out
bit-identical at 2M and 6M, which is what licenses check.sh running the
verify-only gate at RIG_RAM=6. For anchors over the whole window, run this
with --ram 0x600000 and -ramsize 6M.
DLX_VERIFY_ONLY=1 drops the timing anchors, which is how check.sh runs it.
Score a container against the measured costs without touching MAME:
python3 tools/analysis/11_cpu_budget.py tmp/rc_fr_singe_scsi_rcprofile.dlx
And re-demonstrate why there is only one display path (exits non-zero by design — it is the counterexample):
python3 tools/analysis/10_pathmix_drift.py # 70/120 frames corrupt
python3 tools/analysis/10_pathmix_drift.py --fix direct # clean, and cheapest
Reproducing the rate-control result (session 6)
python3 tools/encoder/extract.py 00223 tmp/fr_singe 12 crop 539.4 10.0
for prof in sasi scsi; do
python3 tools/encoder/encode.py tmp/fr_singe tmp/rc_$prof.dlx --profile $prof --fixed-lam
python3 tools/encoder/encode.py tmp/fr_singe tmp/rc_$prof.dlx --profile $prof
done
python3 tools/analysis/09_ratectl_drift.py # must exit 0, zero drifting frames
Expected, totals including the 7.8 KB/s audio allowance: sasi 137.4 -> 109.5
KB/s and 27.82 -> 27.22 dB; scsi 381.6 -> 280.0 KB/s and 30.81 -> 29.90 dB;
zero frames at the lam=800 cliff in either. ~55 s per encode, nearly all of it
k-means in H.build.
The block-mode map now renders the rate-controlled encoder by default:
python3 tools/analysis/08_mode_map.py tmp/fr_singe tmp/singe_modes_rc.webm \
--profile sasi --scale 2 # add --fixed-lam to compare
Do not judge rate control on tmp/fr_00020. It is 14 frames; the leaky
bucket's startup transient is bucket/nframes, so it lands 18% under target there
for reasons that have nothing to do with the content. FINDINGS 27.5.
Reproducing the sustained-action result (session 5)
python3 tools/analysis/07_motion_survey.py 00223 10 # -> hottest window t=539.4s
python3 tools/encoder/extract.py 00223 tmp/fr_singe 12 crop 539.4 10.0
python3 tools/encoder/encode.py tmp/fr_singe tmp/singe_sasi.dlx --profile sasi
python3 tools/encoder/encode.py tmp/fr_singe tmp/singe_scsi.dlx --profile scsi
python3 tools/analysis/08_mode_map.py tmp/fr_singe tmp/singe_modes.webm \
--profile sasi --scale 2
extract.py now takes optional [start_s] [dur_s] — needed because 00223 is
9.4 min and the windows that stress the codec are seconds long.
08_mode_map.py renders palettised source | decoded | block-mode map at 12fps.
Output format follows the extension; prefer .webm — GIF re-quantises to
256 colours, which is a poor fit for output whose subject is colour fidelity,
and runs larger. It uses yuv444p because the mode map is flat saturated colour
on a 4-pixel grid and chroma subsampling smears exactly those edges.