diff --git a/README.md b/README.md index 7802592..d2ed51f 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,10 @@ This is fundamentally a **video codec problem**, not a game-logic problem: the game logic is a scene table with branching input windows; the difficulty is pushing ~22 minutes of Don Bluth animation through a 10MHz 68000. -**Green-light check:** `./tools/bench/check.sh` (~2 min, needs the Blu-ray -mounted) re-runs both display regression tests plus the rate-control drift test -and prints `ALL GREEN`. +**Green-light check:** `./tools/bench/check.sh` (~3 min, needs the Blu-ray +mounted) re-runs both display regression tests, the rate-control drift test, the +display-path coherency counterexample and a 120-frame 68000 decode, then prints +`ALL GREEN`. ## Read first - **`docs/FINDINGS.md`** — measured hardware facts, content statistics, codec @@ -32,6 +33,11 @@ tools/analysis/ measurement scripts, numbered in the order they were written rate-control drift gate (FINDINGS 26/27) and is part of check.sh -- it exits non-zero if the encoder ever again reports a reconstruction no decoder would produce. + 10 is a COUNTEREXAMPLE, and exits non-zero by design: it + demonstrates that the two-display-path plan of FINDINGS + 24.5/25.6 corrupts 70 of 120 frames (FINDINGS 28.1). + 11 scores a container against the MEASURED per-mode block + costs without needing MAME. tools/bench/ MAME Lua injection harness + 68000 benchmark sources. `check.sh` re-runs both display regression tests (~40 s). `blit.s`/`blit.lua` time the full-frame GVRAM blit on the @@ -39,9 +45,13 @@ tools/bench/ MAME Lua injection harness + 68000 benchmark sources. wall timings would make the green-light check host-sensitive. `crtc_mode.lua` is the single source of truth for CRTC R00-R08 and R20 — do not write CRTC values anywhere else. + `prep_dlx.py`/`decode.lua`/`verify_decode.py` load, time and + verify `src/player/decode.s`; the verify pass is in check.sh. tools/vasm/ vasm m68k assembler (built from source) -tools/encoder/ hybrid VQ encoder + DLX1 container writer (working) -src/player/ 68000 player (not yet written) +tools/encoder/ hybrid VQ encoder + DLX1 container writer (working). + dlx.py is the reference DECODER -- ground truth for the 68000. +src/player/ decode.s: the 68000 DLX1 decoder. Pixel-exact, and 31% of + frames over the 12fps CPU budget. See FINDINGS 28. assets/ extracted frames/audio (gitignored) ``` diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index d5bf0b9..ce709c8 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -862,6 +862,12 @@ leaving them uninitialised. This is why 96KB, not 48KB, is the irreducible write traffic. ### 24.5 The architecture question, and where it turns over +> **Superseded by FINDINGS 28.1/28.2 (session 7).** The two-path plan below is +> incoherent — the compose path needs a RAM reference the direct path never +> writes — and its two costs are both *copies*, so they were never comparable to +> a decode. The "76.6% x non-SKIP fraction" model is also 2.03x optimistic: +> the four block modes cost 300/448/400 cycles, not one figure. One path ships. + V4 prices the access pattern a decoder that writes codewords **straight into GVRAM** actually has: 4 rows of 8 bytes at a 1024-byte stride per 4x4 block. The same 96KB of writes costs **76.6%** in block order versus 53.6% row-linear — the @@ -980,6 +986,11 @@ rate-control problem, not a codec-structure problem: the RD decision is behaving correctly for the lam it was given. ### 25.6 The decoder needs BOTH display paths, chosen per frame +> **Superseded by FINDINGS 28.1 (session 7).** Mixing the paths displays stale +> pixels on 70 of these 120 frames. The "median 37.0%, capped at 53.6%" below is +> the cost of an incorrect player; every coherent version is dearer, and plain +> direct-to-GVRAM is the cheapest of them. + Applying FINDINGS 24.5's crossover to the real per-frame distribution: | | median non-SKIP | p90 | frames over the 70% crossover | @@ -1216,3 +1227,147 @@ bisection instead of a 5-rung ladder — which was the actual defect in 26.3. The cache holds **one frame**. At ~133 KB of intermediates per frame, caching the sequence would cost 900 MB on a 9.4-minute stream to save nothing: every caller works a frame at a time. + +--- + +## 28. The 68000 decoder exists, is pixel-exact, and does not fit (session 7) + +`src/player/decode.s` parses DLX1 and draws frames on the emulated X68000. It is +**pixel-exact across a 120-frame sequential run** of the worst sustained window +on the disc (`tools/bench/verify_decode.py`), exercising all four block modes +and the full temporal recursion — the last frame is only right if every frame +before it was. + +It is also **too slow**. On that window, at the shipping `sasi` profile: + +| | non-SKIP blocks | measured cost | +|---|---:|---:| +| cheapest frame | 15.4% | 31.5% of a 12fps frame | +| median frame | 47.8% | 73.8% | +| p90 frame | 82.5% | **116.4%** | +| worst frame | 100.0% | **135.8%** | +| mean over the window | 47.8% | **81.7%** | + +**31% of frames miss the 833,333-cycle budget**, and like every figure since +FINDINGS 24 these are instruction cycles against zero-wait-state memory, so +they are a floor. This is the first time CPU, not disk, is the binding +constraint — FINDINGS 21 retired the bandwidth worry, and this replaces it. + +### 28.1 The dual-path plan of 24.5/25.6 was incoherent, and is withdrawn +FINDINGS 24.5 specified two display paths chosen per frame on the non-SKIP +count, and 25.6 costed the mix at "median 37.0%, capped at 53.6%". Two of its +premises cannot both hold: + +- compose-in-RAM-then-blit exists to make the blit **row-linear**, so it must + assemble a **full** frame in RAM. The pixels it does not decode this frame — + the SKIP blocks — can only come from a RAM copy of the previous + reconstruction. +- decode-direct-to-GVRAM's stated advantage is that **"no RAM reference frame + is needed"**, because the previous frame is already in GVRAM. + +So every direct frame silently invalidates the reference the next compose frame +reads. Simulated on the Singe window at the crossover the plan specifies +(`tools/analysis/10_pathmix_drift.py`): **70 of 120 frames display pixels no +correct player would display**, first at frame 2, worst frame 18.8% of the +screen. This is FINDINGS 26 in different clothing — two code paths disagreeing +about what "the previous frame" means — and it is the **sixth** false premise +this project has caught before it shipped. + +Every coherent repair is worse than not mixing at all: + +| strategy | median | p90 | max | correct | +|---|---:|---:|---:|---| +| mix per frame, as specified | 36.6% | 53.6% | 53.6% | **no** | +| mix, direct also writes the RAM reference | 53.6% | 68.4% | 81.4% | yes | +| mix, re-read GVRAM into RAM on each switch | 36.6% | 107.2% | 107.2% | yes, 13 frames miss | +| compose only | 53.6% | 53.6% | 53.6% | yes | +| **direct only** | **36.6%** | 62.5% | 76.6% | yes | + +(Costs in that table are 24.5's own model, for like-for-like comparison; 28.2 +replaces the model itself.) + +**24.5 also compared the wrong two things.** Its 53.6% and 76.6% are both +*copies* measured in `blit.s` — neither includes decoding. A real compose path +costs decode-into-RAM **plus** the 53.6% blit, so it is strictly dearer than +decoding straight into GVRAM, whatever the block mix. There was never a +crossover to find. + +**The decoder therefore implements one path, direct-to-GVRAM**, and drops the +96 KB RAM reference frame entirely. + +### 28.2 The four block modes do not cost the same, and V4 is the expensive one +24.5's model — "76.6% of a frame x the non-SKIP fraction" — prices every +non-SKIP block as one `movem.l` burst. Measured separately, with synthetic +single-mode frames (`tools/bench/prep_dlx.py`): + +| mode | cycles/block | vs the 24.5 model (207.8) | +|---|---:|---:| +| SKIP, in an all-SKIP header byte | 13.3 | model says 0 | +| SKIP, inside a mixed byte | ~45 | model says 0 | +| V1 (one 4x4 codeword) | **299.9** | 1.44x | +| V4 (four 2x2 codewords) | **448.2** | 2.16x | +| RAW (16 literal indices) | **400.4** | 1.93x | + +Applied to the real per-frame histograms (`tools/analysis/11_cpu_budget.py`), +the model reproduces all four frames timed on the 68000 to within **1 +percentage point**, and shows 24.5 to be **2.03x optimistic at the median**. + +Where the cycles actually go over the window: + +| mode | % of blocks | % of cycles | +|---|---:|---:| +| SKIP | 46.4% | 9.2% | +| V1 | 19.8% | 26.1% | +| V4 | **25.2%** | **49.7%** | +| RAW | 8.5% | 15.0% | + +**V4 is a quarter of the blocks and half the cycles.** It costs 1.49x a V1 block +while the mode decision in `vq_hybrid.py` charges it only its 4x payload bytes. +The lagrangian trades distortion against *bytes*; on this machine it now has to +trade distortion against *cycles* as well. + +### 28.3 The container is big-endian but not aligned, and that is an address error +The DLX1 header docstring says every multi-byte field is big-endian "so the +68000 reads them with a plain `move`". Alignment is the other half of that +sentence and the container does not have it: frame records are +`[u32 length][768-byte mode header][payload]` laid end to end with arbitrary +payload lengths, so record boundaries land on odd addresses. + +`move.l (a0)+,d0` at an odd address is an **address error** on a 68000 — not a +slow read. The first run decoded frame 0 perfectly, consumed exactly its 8,715 +payload bytes, then read frame 1's length at `$03220F` and vectored into the IPL +at `$FF059A`, where it sat for 59 emulated seconds looking like an infinite +loop. It was found by dumping PC and the address registers, not by reading the +source: the code was correct, the data layout was not. + +The decoder now rounds each record start up to 4. **The container should carry +the padding itself** so a streaming player can DMA records into place: measured +cost on this window is **199 bytes over 120 frames — 1.66 B/frame, 20 B/s** +against a 110 KB/s budget. Until `encode.py` does it, `prep_dlx.py` realigns at +load time. + +### 28.4 The measurements agree with hand-derived MC68000 timings +As in FINDINGS 24, each figure was derived from the instruction timing tables +before being believed. A V1 block, summing dispatch, index decode, the indexed +`movem.l` load and four `movem.l` stores, plus its quarter share of the header +loop: **298.5 cycles derived against 299.9 measured — 0.5%.** RAW derives to +396 against 400.4 measured (1%). V4 derives to 415 against 448 (7%, the gap +being in the indexed two-register `movem.l`, the mode this decoder uses most +heavily). So these are 68000 cycles, not a MAME artefact. + +### 28.5 A full frame does not fit at 12fps in ANY mode +An all-V1 frame — the cheapest possible way to redraw all 3,072 blocks — costs +**921,187 cycles, 110.5% of the budget**. All-V4 is 165.2% and all-RAW 147.6%. + +So the ceiling is structural, not a tuning problem: **at 12fps on a 10MHz 68000 +no more than ~88% of the screen can change in one frame**, however cheaply it is +coded. Scene cuts change 100% of it. Either a cut gets one late frame (the +outgoing content is unrelated, so this may be free to the eye), or cuts have to +be spread across two frame times, or the framerate has to come down — at 10fps +the budget is 1,000,000 cycles and an all-V1 frame fits. + +### 28.6 What this does not measure +One 10 s window of one stream at one profile, and MAME still models no GVRAM +wait states. The `scsi` profile will be worse: FINDINGS 25.5 has it collapsing +to RAW under stress, and RAW is 1.93x the old model's block. Nothing here has +been run on `00020` or on quiet content, where the median frame is far cheaper. diff --git a/docs/STATUS.md b/docs/STATUS.md index 8756408..447ebba 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,29 +1,102 @@ -# Status & next-session handoff — end of session 6 (2026-08-23) +# Status & next-session handoff — end of session 7 (2026-08-23) -## NEXT SESSION: the 68000 decoder skeleton +## NEXT SESSION: make the mode decision cost-aware -Rate control is done and gated (below). `src/player/` is still empty, and it is -now the only thing between this project and an answer to "does the CPU path -work". Everything it needs has been measured: +The decoder exists, it is pixel-exact, and **it does not fit**. On the worst +sustained window at the shipping `sasi` profile it costs a mean of **81.7% of a +12fps frame budget** and **31% of frames exceed 100%** (`scsi`: 94.9% median, +42% of frames miss). FINDINGS 28. CPU is now the binding constraint — the first +time in this project that it has been. -1. **Inner loop: implement BOTH display paths and pick per frame.** FINDINGS - 25.6, re-measured under rate control in 27.3. Compose-in-RAM-then-blit is a - flat 53.6% of the 12fps budget; decode-direct-to-GVRAM is 76.6% x the - non-SKIP block fraction. They cross at 70% of blocks changed. The mode - headers are parsed before any pixel is written, so counting non-SKIP blocks - to choose is free. Median cost 36.6% (`sasi`) / 47.1% (`scsi`), capped 53.6%. -2. **Copy the harness pattern from `tools/bench/blit.s` + `blit.lua`** — it - already loads code, masks interrupts, times a loop against a flag, and - snapshots for `verify_frame256.py`. Assemble with - `tools/vasm/vasmm68k_mot -Fbin -o out.bin in.s`. -3. **Parse `DLX1`** (layout in the `encode.py` docstring, all fields big-endian), - expand the codebooks once at load, then dispatch per block on the 2-bit mode. -4. **Budget against 53.6%, not 38%.** Still not done — see priority 2a below. - The blit alone eats over half the frame before any decoding, and MAME models - no GVRAM wait states, so it is a floor. +The fix is not assembly micro-optimisation. It is that **`vq_hybrid.decide()` +minimises `D + lam*R` — distortion against BYTES — on a machine where the +binding budget is CYCLES**, and the two are not proportional: -Feed it `tmp/rc_fr_singe_sasi_rcprofile.dlx` — the worst sustained window on the -disc, at the shipping profile. If the decoder fits there it fits everywhere. +| mode | payload bytes | measured cycles | cycles per byte | +|---|---:|---:|---:| +| SKIP | 0 | 13 (clustered) | — | +| V1 | 1 | 300 | 300 | +| V4 | 4 | 448 | 112 | +| RAW | 16 | 400 | 25 | + +V4 is **25% of blocks and 50% of the cycles**. The lagrangian charges it 4x a V1 +block; the CPU charges it 1.49x. So the encoder currently buys V4 whenever it is +worth 4 bytes, with no idea what it costs to draw. + +**The work, in order:** + +1. **Add a cycle term to the mode decision.** `decide()` already builds a cost + matrix of `error + lam * bytes` per mode per block; add `+ mu * cycles`, with + the cycles vector `[13, 300, 448, 400]` measured in FINDINGS 28.2. One extra + row of arithmetic in a function that is already vectorised. +2. **Then bisect `mu` per frame against the 833,333-cycle budget**, exactly as + session 6 bisects `lam` against the byte budget. The machinery is already + there and already gated: `ratectl.encode_rate_controlled` is frame-driven and + feeds back the frame it emitted. **But cycles have NO bucket.** Bytes can be + banked in the ring buffer; a frame that misses its decode deadline is just + late, because there is no double buffer to decode ahead into. So this is a + hard per-frame ceiling, not a leaky bucket — simpler than rate control, and + the two controllers have to run together (raising `mu` moves blocks to SKIP + and V1, which also *lowers* the bitrate, so the byte controller must see it). +3. **Measure the quality cost.** Everything session 6 did for bytes: what does + fitting 100% of frames in the CPU budget cost in dB, and does any frame hit a + cliff? `tools/analysis/11_cpu_budget.py` scores a container without needing + MAME, so the search loop is cheap; confirm the winner on the 68000 with + `tools/bench/decode.lua`. +4. **28.5 may not be solvable by the encoder at all.** An all-V1 frame — the + cheapest possible full redraw — is **110.5%** of the budget. A scene cut + changes 100% of the screen, so *no* mode assignment fits one at 12fps. Decide + deliberately: allow one late frame at a cut (the outgoing content is + unrelated, so it may be invisible), spread a cut over two frame times, or + drop to 10fps where an all-V1 frame fits. This is a design decision, not a + measurement, and it needs the user. + +**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), so the +inner loop is close to what the instruction set allows; the plausible wins are +single-digit percentages against a 36-point gap. The V4 write pattern is the one +place worth a look afterwards — pairing sub-block rows into `movem.l d0/d2,(a4)` +saves ~16 of 448 cycles. + +--- + +## What session 7 settled + +1. **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 decoder `tools/encoder/dlx.py`. Because SKIP blocks are claims + about the previous frame, the last frame is only right if all 120 were. + In `check.sh` now. **FINDINGS 28.** +2. **It does not fit.** Mean 81.7% of a 12fps frame, p90 116.4%, worst 135.8%; + 31% of frames miss at `sasi`, 42% at `scsi`. Zero-wait-state floor, as ever. +3. **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 in `check.sh`. FINDINGS 28.1. +4. **24.5 also compared a copy against a copy.** Its 53.6% and 76.6% both come + from `blit.s` and 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. +5. **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.py` + reproduces all four frames timed on the 68000 to within 1 point. FINDINGS 28.2. +6. **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.py` should pad records to 4 bytes.** + Measured cost 1.66 B/frame = 20 B/s against 110 KB/s. +7. **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. --- @@ -265,6 +338,14 @@ multi-byte fields are **big-endian** so the 68000 reads them with a plain `move` Gated by `tools/analysis/09_ratectl_drift.py`, which is now in `check.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.** They must be padded to a 4-byte boundary: + unaligned is an ADDRESS ERROR on a 68000, not a slow read (FINDINGS 28.3). + `prep_dlx.py` repairs it at load time, which a player streaming from disc + cannot do. The pad is real bytes on disc, so it belongs inside the rate + controller's accounting. 1.66 B/frame, 20 B/s. +- **The mode decision is blind to CPU cost.** It charges V4 four payload bytes + and ignores that it costs 1.49x a V1 block to draw. This is the top item at + the head of this file. FINDINGS 28.2. - **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 `I` per entry by minimum squared error (FINDINGS 23.3, @@ -414,15 +495,25 @@ SDL_VIDEODRIVER=dummy mame x68000 -bios ipl10 -video soft -window \ it needs a genuinely quiet scene to decide, and it is a quality-per-byte judgement rather than a correctness one. -2. **68000 decoder skeleton**, with the inner loop chosen by (1). **This is now - the agreed next session's work — see the block at the top of this file.** Parse `DLX1`, - expand codebooks, blit per block mode. The display path is verified *by 68000 - code* now (FINDINGS 24) and the harness pattern is `tools/bench/blit.s` + - `blit.lua`, which already loads code, masks interrupts, times a loop against - a flag, and snapshots the result for `verify_frame256.py`. Copy that. - Assembler: `tools/vasm/vasmm68k_mot -Fbin -o out.bin in.s`. +2. ~~**68000 decoder skeleton.**~~ **DONE, session 7.** `src/player/decode.s`, + pixel-exact over 120 frames, gated in `check.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 53.6%, not 38%.** Several downstream figures +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. + +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 @@ -465,9 +556,13 @@ SDL_VIDEODRIVER=dummy mame x68000 -bios ipl10 -video soft -window \ - ~~Flat 4x4 VQ.~~ Rejected by eye (FINDINGS 9). ## Not yet started -- **Any 68000 player code.** `src/player/` is still empty. 68000 code has now - drawn a frame, but it lives in `tools/bench/blit.s` as a benchmark, not in a - player: it does no bitstream parsing, no mode dispatch, no codebook expansion. +- **A player, as opposed to a decoder.** `src/player/decode.s` parses 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.py` does 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) @@ -548,6 +643,39 @@ V1's output. To check that snapshot is still pixel-exact: 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 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` matters — MAME defaults to 4M and the locked target is a stock 2MB +machine. `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) ``` diff --git a/src/player/decode.s b/src/player/decode.s new file mode 100644 index 0000000..9a49319 --- /dev/null +++ b/src/player/decode.s @@ -0,0 +1,202 @@ +; DLX1 frame decoder for a stock 68000 @ 10MHz -- direct-to-GVRAM, single path. +; +; WHY ONE PATH. FINDINGS 24.5/25.6 specified two display paths chosen per +; frame (compose-in-RAM-then-blit vs decode-direct-to-GVRAM, crossing at 70% of +; blocks changed). That plan is incoherent: the compose path assembles a FULL +; frame in RAM so its blit can be row-linear, and the pixels it does not decode +; this frame -- the SKIP blocks -- can only come from a RAM copy of the previous +; reconstruction, which the direct path deliberately never writes. Every direct +; frame invalidates the next compose frame's reference. Measured on the Singe +; window: 70 of 120 frames display stale pixels, worst frame 18.8% of the +; screen. See tools/analysis/10_pathmix_drift.py and FINDINGS 28. +; +; Every coherent version of the mix is worse than plain direct on the median, +; so this decoder implements direct only. It also drops the 96KB RAM reference +; frame entirely: the previous frame is already in GVRAM, so SKIP is genuinely +; free -- no read, no write, just a pointer advance. +; +; GEOMETRY (tools/bench/crtc_mode.lua). 256-colour page: one pixel per WORD of +; CPU address space, 1024-byte line stride, picture in rows 32..223. A 4x4 +; block is therefore 4 rows of 8 bytes at a 1024-byte stride, so displacements +; 0/1024/2048/3072 all fit a 16-bit offset and one base pointer covers a block. +; A block row is 4 picture rows = 4096 bytes; 64 blocks x 8 bytes = 512. +; +; The HIGH byte of every GVRAM word write is discarded by the hardware (MAME +; 0.277 x68k_crtc.cpp:501, gvram_w case 0x0100), which is what makes the RAW +; path cheap: it never has to clear the odd bytes it builds. +; +; CODEBOOKS are pre-expanded to word-per-pixel form by the loader, so the inner +; loop movems them straight out with no unpacking: +; CB1 entry = 16 words, row-major = 32 bytes (k1=256 -> 8KB) +; CB4 entry = 4 words, row-major (2x2) = 8 bytes (k4=256 -> 2KB) +; Index scaling is therefore a shift, not a multiply: lsl.w #5 and lsl.w #3. +; +; REGISTERS are fully committed -- a0 payload, a1 mode header, a2/a3 codebooks, +; a4 block cursor, a5 block-row end, a6 block-row base, d0-d7 the 32-byte block +; transfer. Nothing survives a block, which is why each block re-reads its mode +; from (a1) rather than holding the packed byte in a register. That re-read is +; paid only by blocks that are NOT all-SKIP: a header byte of zero clears four +; blocks with one tst.b, and SKIP is the median block. +; +; ALIGNMENT. Frame records are [u32 length][768-byte mode header][payload] laid +; end to end, and payload lengths are arbitrary -- so record boundaries land on +; odd addresses, and `move.l (a0)+,d0` on an odd address is an ADDRESS ERROR on +; a 68000, not a slow read. It killed the first run: frame 0 decoded perfectly, +; then the length read for frame 1 at $03220F vectored into the IPL. Being +; big-endian is only half of what "the 68000 reads it with a plain move" needs. +; This decoder rounds each record start up to 4; the container itself should +; carry the padding so a streaming player can DMA records straight into place. +; FINDINGS 28.3. + +FLAG = $18000 ; 0 idle / 1 running / $FF done / $EE desync +ITER = $18008 ; outer repeat count, written by Lua +NFR = $1800C ; frames per pass +FPTR = $18010 ; -> first frame record +SCR_N = $18014 ; frames remaining this pass +SCR_END = $18018 ; expected end of the current payload + +CB1 = $20000 ; expanded 4x4 codebook +CB4 = $22000 ; expanded 2x2 codebook + +DST0 = $C08000 ; GVRAM + 32*1024 (first picture row) +DSTE = $C38000 ; GVRAM + 224*1024 (one past last) +BROW = 4096 ; bytes per block row (4 picture rows) +ROWLEN = 512 ; bytes per block row of blocks (64 * 8) +MODEB = 768 ; packed mode header, 3072 blocks * 2 bits + + org $10000 +start: + move.l #1,FLAG.l ; timer starts here +outer: + move.l FPTR.l,a0 + move.l NFR.l,SCR_N.l +frameloop: + move.l (a0)+,d0 ; u32 payload length, big-endian + lea 0(a0,d0.l),a1 + move.l a1,SCR_END.l ; where the payload must end + move.l a0,a1 ; a1 = packed mode header + lea MODEB(a0),a0 ; a0 = payload + bsr decode_frame + cmpa.l SCR_END.l,a0 ; bitstream desync is silent otherwise + bne desync + move.l a0,d0 ; next record starts on a 4-byte boundary + addq.l #3,d0 + and.b #$FC,d0 + move.l d0,a0 + subq.l #1,SCR_N.l + bne frameloop + subq.l #1,ITER.l + bne outer + move.l #$FF,FLAG.l ; timer stops here +hold: bra.s hold +desync: move.l #$EE,FLAG.l + bra.s hold + +; ---------------------------------------------------------------- one block +; \1 = right-shift needed to bring this block's 2 mode bits to bits 1-0. +BLOCK macro + move.b (a1),d0 + ifne \1 + lsr.b #\1,d0 + endc + and.w #3,d0 + beq .sk\@ ; 00 SKIP -- the median block + subq.w #1,d0 + beq .v1\@ ; 01 V1 + subq.w #1,d0 + bne .rw\@ ; 11 RAW, else 10 V4 + +; -- V4: four 2x2 codewords, sub-block order TL TR BL BR (vq_hybrid.paint) + moveq #0,d0 + move.b (a0)+,d0 + lsl.w #3,d0 + movem.l (a3,d0.w),d0-d1 + move.l d0,(a4) + move.l d1,1024(a4) + moveq #0,d0 + move.b (a0)+,d0 + lsl.w #3,d0 + movem.l (a3,d0.w),d0-d1 + move.l d0,4(a4) + move.l d1,1028(a4) + moveq #0,d0 + move.b (a0)+,d0 + lsl.w #3,d0 + movem.l (a3,d0.w),d0-d1 + move.l d0,2048(a4) + move.l d1,3072(a4) + moveq #0,d0 + move.b (a0)+,d0 + lsl.w #3,d0 + movem.l (a3,d0.w),d0-d1 + move.l d0,2052(a4) + move.l d1,3076(a4) + bra .sk\@ + +; -- V1: one 4x4 codeword, 32 bytes, straight out of the expanded codebook +.v1\@: + moveq #0,d0 + move.b (a0)+,d0 + lsl.w #5,d0 + movem.l (a2,d0.w),d0-d7 ; EA is resolved before the load + movem.l d0-d1,(a4) + movem.l d2-d3,1024(a4) + movem.l d4-d5,2048(a4) + movem.l d6-d7,3072(a4) + bra .sk\@ + +; -- RAW: 16 literal palette indices. Two indices are assembled into one long +; via swap, so each pair of pixels costs one write instead of two; the high +; byte of each word is left as zero because the hardware discards it anyway. +.rw\@: + RAWPAIR 0 + RAWPAIR 4 + RAWPAIR 1024 + RAWPAIR 1028 + RAWPAIR 2048 + RAWPAIR 2052 + RAWPAIR 3072 + RAWPAIR 3076 +.sk\@: + addq.l #8,a4 + endm + +RAWPAIR macro + moveq #0,d0 + move.b (a0)+,d0 + swap d0 + move.b (a0)+,d0 + move.l d0,\1(a4) + endm + +; ------------------------------------------------------------- one frame +; in: a0 = payload, a1 = packed mode header +; out: a0 = one past the last payload byte consumed +decode_frame: + lea CB1,a2 + lea CB4,a3 + lea DST0,a6 +rowloop: + move.l a6,a4 + lea ROWLEN(a6),a5 +byteloop: + tst.b (a1) ; four SKIPs in one test + beq allskip + BLOCK 6 + BLOCK 4 + BLOCK 2 + BLOCK 0 + addq.l #1,a1 + cmpa.l a5,a4 + bne byteloop + bra rowdone +allskip: + addq.l #1,a1 + lea 32(a4),a4 + cmpa.l a5,a4 + bne byteloop +rowdone: + lea BROW(a6),a6 + cmpa.l #DSTE,a6 + bne rowloop + rts diff --git a/tools/analysis/10_pathmix_drift.py b/tools/analysis/10_pathmix_drift.py new file mode 100644 index 0000000..88306e8 --- /dev/null +++ b/tools/analysis/10_pathmix_drift.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""REGRESSION TEST for the dual-display-path coherency defect (FINDINGS 28). + +FAILS ON PURPOSE for the player design FINDINGS 24.5/25.6 specified, which is +why it exists: it is the counterexample, kept runnable. Pass `--fix ` +to check that a proposed player is coherent instead. + +The defect. Two display paths were specified and priced, and the plan was to +pick between them per frame on the non-SKIP block count: + + compose-in-RAM-then-blit flat 53.6% of a 12fps frame + decode-direct-to-GVRAM 76.6% x (non-SKIP fraction), and -- quoting + FINDINGS 24.5 -- "no RAM reference frame is needed" + +Both statements are true in isolation and incompatible together. The compose +path's whole job is to assemble a FULL frame in RAM so the blit can be +row-linear, and the pixels it does not decode this frame (the SKIP blocks) can +only come from a RAM copy of the previous reconstruction. The direct path +deliberately never writes that copy. So every direct frame silently invalidates +the reference the next compose frame reads, and the stale pixels go to screen. + +This is FINDINGS 26 again in different clothing: two code paths that disagree +about what "the previous frame" means. 26 was caught between encoder and +decoder; this one is between a decoder and itself. + +Strategies (--fix): + none the specified player: direct writes GVRAM only. UNSOUND + dual direct also writes the RAM reference (costs ~1.52x). sound + resync on direct->compose, re-read GVRAM into RAM first. sound + compose never use the direct path. sound + direct never use the compose path. sound + +Needs a container: defaults to the worst sustained window on the disc at the +shipping profile (docs/STATUS.md, reproducing the rate-control result). Costs +no encode -- it reads the emitted bitstream, ~3 s. +""" +import sys, os, argparse +sys.path.insert(0, "tools/encoder") +import numpy as np +from dlx import DLX + +# FINDINGS 24: measured on the emulated 68000, instruction cycles only. +BLIT_PCT, DIRECT_PCT = 53.6, 76.6 +CROSSOVER = 100 * BLIT_PCT / DIRECT_PCT + +ap = argparse.ArgumentParser() +ap.add_argument("container", nargs="?", + default="tmp/rc_fr_singe_sasi_rcprofile.dlx") +ap.add_argument("--fix", default="none", + choices=("none", "dual", "resync", "compose", "direct")) +a = ap.parse_args() + +if not os.path.exists(a.container): + sys.exit(f"missing {a.container} -- see docs/STATUS.md, " + f"'Reproducing the rate-control result'") + +d = DLX(a.container) +print(f"{a.container}: {d.W}x{d.H} {d.nframes} frames, {d.nb} blocks/frame, " + f"k1={d.k1} k4={d.k4}") +print(f"path choice: compose if non-SKIP > {CROSSOVER:.1f}% of blocks " + f"(53.6% flat vs 76.6% x fraction), strategy={a.fix}\n") + +# gv = what is on screen. ram = the player's RAM reference frame. +# truth = what a correct player displays. All are palette-index canvases. +gv = np.zeros((d.H, d.W), np.uint8) +ram = np.zeros((d.H, d.W), np.uint8) +truth = np.zeros((d.H, d.W), np.uint8) + +def put(canvas, blks): + for i, blk in blks.items(): + by, bx = divmod(i, d.nbx) + canvas[by*4:by*4+4, bx*4:bx*4+4] = blk + +drift_px, used, switches, resyncs = [], [], 0, 0 +prev_path = None +for f in range(d.nframes): + mode, blks = d.blocks(f) + put(truth, blks) + + frac = 100 * (mode != 0).mean() + if a.fix == "compose": path = "compose" + elif a.fix == "direct": path = "direct" + else: path = "compose" if frac > CROSSOVER else "direct" + + if path == "compose": + if a.fix == "resync" and prev_path == "direct": + ram = gv.copy() # re-read GVRAM into the RAM reference + resyncs += 1 + put(ram, blks) + gv = ram.copy() # full row-linear blit + else: + put(gv, blks) + if a.fix == "dual": + put(ram, blks) # keep the reference coherent as we go + + if prev_path is not None and path != prev_path: + switches += 1 + prev_path = path + used.append(path) + drift_px.append(int((gv != truth).sum())) + +drift_px = np.array(drift_px) +npx = d.H * d.W +nc = used.count("compose") +print(f"path used: compose {nc}/{d.nframes} ({100*nc/d.nframes:.0f}%), " + f"direct {d.nframes-nc} -- {switches} switches between them" + + (f", {resyncs} resyncs" if resyncs else "")) +bad = int((drift_px > 0).sum()) +print(f"\nframes displaying pixels no correct player would display: " + f"{bad}/{d.nframes}") +if bad: + print(f" worst frame {drift_px.max()} px " + f"({100*drift_px.max()/npx:.1f}% of the screen), " + f"mean {drift_px.mean():.0f} px ({100*drift_px.mean()/npx:.1f}%)") + first = int(np.argmax(drift_px > 0)) + print(f" first corrupt frame: {first} (path={used[first]}, " + f"previous={used[first-1] if first else '-'})") + +# Cost of the strategy, in % of a 12fps frame budget. 'dual' pays 1.52x on the +# direct path: the same block written twice, +108 cycles on 208 (FINDINGS 28.2). +mult = 1.52 if a.fix == "dual" else 1.0 +cost = np.array([BLIT_PCT if p == "compose" else DIRECT_PCT * mult * + (d.modes(f) != 0).mean() + for f, p in enumerate(used)]) +if a.fix == "resync": + for f in range(1, d.nframes): + if used[f] == "compose" and used[f-1] == "direct": + cost[f] += BLIT_PCT # the GVRAM->RAM re-read is a full frame +print(f"\ndisplay cost: median {np.median(cost):.1f}% " + f"p90 {np.percentile(cost,90):.1f}% max {cost.max():.1f}% " + f"of a 12fps frame budget") +over = int((cost > 100).sum()) +if over: + print(f" frames that do NOT fit in the budget at all: {over}/{d.nframes}") + +sys.exit(1 if bad else 0) diff --git a/tools/analysis/11_cpu_budget.py b/tools/analysis/11_cpu_budget.py new file mode 100644 index 0000000..008c2ee --- /dev/null +++ b/tools/analysis/11_cpu_budget.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Per-frame CPU cost of the real decoder, from MEASURED per-mode block costs. + + python3 tools/analysis/11_cpu_budget.py [container.dlx] + +FINDINGS 24.5 priced the display path as "76.6% of a 12fps frame x the non-SKIP +block fraction", i.e. every non-SKIP block costs the same. It does not: the four +block modes were measured separately on the 68000 (synthetic single-mode frames, +tools/bench/prep_dlx.py) and V4 costs 1.5x V1. Since V4 is roughly half of all +non-SKIP blocks on hard content, the old model runs ~1.8x optimistic exactly +where it matters. + +This applies the measured costs to a real container's mode histograms and +reports what fraction of frames actually fit 833,333 cycles. + +Costs are MEASURED (tools/bench/decode.lua), cross-checked against hand-derived +MC68000 timings in FINDINGS 28.4. They are instruction cycles against +zero-wait-state memory, so like every figure in this project since FINDINGS 24 +they are a LOWER BOUND -- real GVRAM stalls the CPU. +""" +import sys, os, argparse +sys.path.insert(0, "tools/encoder") +import numpy as np +from dlx import DLX + +CPUHZ = 10_000_000 +FPS = 12 +FRAME = CPUHZ / FPS # 833,333 cycles + +# cycles per block, measured on the emulated 68000 (synthetic single-mode frames) +C_V1, C_V4, C_RAW = 299.9, 448.2, 400.4 +C_SKIP_FAST = 53.0 / 4 # all-SKIP header byte: one tst.b for 4 +C_SKIP_MIXED = 45.0 # a SKIP block inside a mixed byte + +ap = argparse.ArgumentParser() +ap.add_argument("container", nargs="?", + default="tmp/rc_fr_singe_sasi_rcprofile.dlx") +a = ap.parse_args() +if not os.path.exists(a.container): + sys.exit(f"missing {a.container}") + +d = DLX(a.container) + +def cycles(mode): + g = mode.reshape(-1, 4) # one header byte = four blocks + allskip = (g == 0).all(1) + c = allskip.sum() * 4 * C_SKIP_FAST + m = g[~allskip] + c += (m == 0).sum() * C_SKIP_MIXED + c += (m == 1).sum() * C_V1 + c += (m == 2).sum() * C_V4 + c += (m == 3).sum() * C_RAW + return c + +modes = [d.modes(f) for f in range(d.nframes)] +cyc = np.array([cycles(m) for m in modes]) +pct = 100 * cyc / FRAME +ns = np.array([100 * (m != 0).mean() for m in modes]) + +print(f"{a.container}: {d.nframes} frames, {d.nb} blocks/frame") +print(f"measured block costs: SKIP {C_SKIP_FAST*4:.0f}/4 (clustered) " + f"{C_SKIP_MIXED:.0f} (mixed) V1 {C_V1:.0f} V4 {C_V4:.0f} RAW {C_RAW:.0f} cycles\n") + +# --- validation against the four real frames timed on the 68000. These +# timings belong to ONE container; quoting them against any other would be +# comparing a model of this stream to a measurement of a different one. +TIMED = "tmp/rc_fr_singe_sasi_rcprofile.dlx" +TIMED_FRAMES = (("min non-SKIP", 15.4, 31.5), ("median", 48.1, 73.8), + ("p90", 82.5, 116.4), ("max non-SKIP", 100.0, 135.8)) +if os.path.abspath(a.container) == os.path.abspath(TIMED): + print("model vs the frames actually timed on the 68000:") + for label, frac, meas in TIMED_FRAMES: + i = int(np.argmin(abs(ns - frac))) + print(f" {label:<14} non-SKIP {ns[i]:5.1f}% model {pct[i]:6.1f}% " + f"measured {meas:5.1f}% error {pct[i]-meas:+.1f} pt") +else: + print(f"(no 68000 timings for this container -- the model was validated to " + f"within\n 1 pt on {TIMED}; run tools/bench/decode.lua to time this one)") + +old = 76.6 * ns / 100 +print(f"\nper-frame cost, % of a {FPS}fps frame budget:") +print(f" measured-cost model: median {np.median(pct):5.1f} " + f"p90 {np.percentile(pct,90):5.1f} max {pct.max():5.1f}") +print(f" FINDINGS 24.5 model: median {np.median(old):5.1f} " + f"p90 {np.percentile(old,90):5.1f} max {old.max():5.1f} " + f"(optimistic by {np.median(pct)/np.median(old):.2f}x at the median)") + +miss = pct > 100 +print(f"\nframes that do NOT fit 833,333 cycles: {miss.sum()}/{d.nframes} " + f"({100*miss.mean():.0f}%)") +if miss.any(): + print(f" worst {pct.max():.1f}% -- {(pct.max()-100)/100*1000/FPS:.0f} ms late " + f"on an {1000/FPS:.0f} ms frame") + print(f" the budget is first missed at {ns[miss].min():.1f}% non-SKIP blocks") + +# Where do the cycles go? This is what a cost-aware mode decision would act on. +tot = np.array([[(m == k).sum() for k in range(4)] for m in modes]).sum(0) +spend = tot * np.array([C_SKIP_MIXED, C_V1, C_V4, C_RAW]) +print(f"\nwhere the cycles go, over the whole window:") +for k, n in enumerate(("SKIP", "V1", "V4", "RAW")): + print(f" {n:<5} {100*tot[k]/tot.sum():5.1f}% of blocks " + f"{100*spend[k]/spend.sum():5.1f}% of the cycles") +print(f"\nV4 is {C_V4/C_V1:.2f}x a V1 block for {4}x the payload bytes -- the mode " + f"decision\nin vq_hybrid.py charges it the bytes but not the cycles.") diff --git a/tools/bench/check.sh b/tools/bench/check.sh index 8b691b2..bdfac11 100755 --- a/tools/bench/check.sh +++ b/tools/bench/check.sh @@ -40,4 +40,34 @@ python3 tools/analysis/09_ratectl_drift.py > tmp/drift_check.log 2>&1 \ || { cat tmp/drift_check.log; exit 1; } tail -9 tmp/drift_check.log +echo "--- session 7: display-path coherency (FINDINGS 28.1) ---" +# 10_pathmix_drift.py is a COUNTEREXAMPLE, kept runnable: the dual-path plan of +# FINDINGS 24.5/25.6 must still be shown to corrupt frames, and the strategy the +# player actually uses must still be clean. A green light here means the reason +# decode.s has one display path is still demonstrable, not just asserted. +python3 tools/analysis/10_pathmix_drift.py > tmp/pathmix.log 2>&1 \ + && { echo "FAIL: the dual-path plan no longer reproduces its own defect"; \ + cat tmp/pathmix.log; exit 1; } +grep -a "frames displaying pixels" tmp/pathmix.log +python3 tools/analysis/10_pathmix_drift.py --fix direct > tmp/pathmix_direct.log 2>&1 \ + || { echo "FAIL: direct-to-GVRAM is no longer coherent"; cat tmp/pathmix_direct.log; exit 1; } + +echo "--- session 7: 68000 decoder is pixel-exact (FINDINGS 28) ---" +# The strongest display test in the tree: 120 frames decoded in sequence by +# 68000 code, every block mode, full temporal recursion. A SKIP block is a claim +# about the previous frame still being on screen, so the last frame is only +# right if all 120 were. +DLX=tmp/rc_fr_singe_sasi_rcprofile.dlx +[ -f "$DLX" ] || python3 tools/encoder/encode.py tmp/fr_singe "$DLX" --profile sasi +python3 tools/bench/prep_dlx.py "$DLX" > tmp/prep_dlx.log +tools/vasm/vasmm68k_mot -Fbin -o tmp/decode.bin src/player/decode.s > /dev/null +mkdir -p tmp/snap_decode +rm -f tmp/snap_decode/x68000/*.png +( cd tmp && DLX_VERIFY_ONLY=1 SDL_VIDEODRIVER=dummy timeout -k 5 300 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 20 \ + > decode_check.log 2>&1 ) +python3 tools/bench/verify_decode.py "$DLX" + echo "ALL GREEN" diff --git a/tools/bench/decode.lua b/tools/bench/decode.lua new file mode 100644 index 0000000..a9f27b6 --- /dev/null +++ b/tools/bench/decode.lua @@ -0,0 +1,172 @@ +-- Time and verify src/player/decode.s on the emulated 68000. +-- +-- Two questions, one run: +-- 1. CORRECTNESS. Decode the whole window frame by frame and snapshot the +-- last frame. tools/bench/verify_decode.py checks it against the Python +-- reference decoder (tools/encoder/dlx.py) pixel-for-pixel. Every SKIP +-- block in every frame is a claim about the previous frame still being on +-- screen, so a sequential run is the only honest test -- decoding one +-- frame in isolation would prove nothing about the temporal recursion. +-- 2. COST. Time individual frames chosen across the non-SKIP distribution, +-- not its mean (FINDINGS 25.6), plus one full 120-frame pass. +-- +-- MEASUREMENT SCOPE, unchanged from blit.lua: MAME's gvram_w/gvram_r carry no +-- timing at all, so these are pure 68000 instruction cycles against +-- zero-wait-state memory -- a LOWER BOUND on real hardware, not a prediction. +-- Interrupts are masked (SR=$2700) so the IPL cannot steal cycles. +-- +-- Codebook expansion and palette packing are done host-side by prep_dlx.py: +-- they are load-time costs, not per-frame ones, and including them would +-- flatter or damn the inner loop for no reason. + +M = manager.machine +SP = M.devices[":maincpu"].spaces["program"] + +local function findfile(n) + for _,p in ipairs{"../tools/bench/"..n, "tools/bench/"..n, n} do + local f = io.open(p,"rb"); if f then f:close(); return p end + end + error(n.." not found") +end +local MODE = loadfile(findfile("crtc_mode.lua"))() +local META = loadfile("decode_meta.lua")() + +local FLAG, ITER, NFR, FPTR = 0x18000, 0x18008, 0x1800C, 0x18010 +local CB1, CB4, STREAM = 0x20000, 0x22000, 0x30000 +local GVRAM, GPAL = 0xC00000, 0xE82000 +local CPUHZ = 10000000 -- x68k.cpp:1133, 40_MHz_XTAL/4 +local FRAME12 = CPUHZ / META.fps + +local code do local f=io.open("decode.bin","rb"); code=f:read("a"); f:close() end +local data do local f=io.open("decode_data.bin","rb"); data=f:read("a"); f:close() end + +local YOFF = (MODE.height - META.H) // 2 +local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end +local function P(s) print("[DEC] "..s) end + +-- Bulk-load a slice of the blob as big-endian longwords. 1 MB one byte at a +-- time is 1M Lua->C calls; longwords cut that by four. +local function push(addr, s, from, len) + local i, n = from, len + while n >= 4 do + SP:write_u32(addr, (string.unpack(">I4", s, i))) + addr, i, n = addr+4, i+4, n-4 + end + while n > 0 do + SP:write_u8(addr, string.byte(s,i)); addr, i, n = addr+1, i+1, n-1 + end +end + +local function setup() + MODE.apply(SP) + local o = 1 + push(CB1, data, o, META.cb1_len); o = o + META.cb1_len + push(CB4, data, o, META.cb4_len); o = o + META.cb4_len + local palo = o; o = o + META.pal_len + push(STREAM, data, o, META.stream_len) + for c = 0, 255 do + SP:write_u16(GPAL + c*2, (string.unpack(">I2", data, palo + c*2))) + end + -- Active area starts at index 0, exactly as the reference decoder's canvas + -- does; the letterbox gets the palette's darkest entry because the encoder + -- does not yet reserve a black one (docs/STATUS.md, encoder gaps). + for y = 0, MODE.height-1 do + local base, v = GVRAM + y*1024, 0 + if y < YOFF or y >= YOFF+META.H then v = META.dark end + for x = 0, MODE.width-1 do SP:write_u16(base + x*2, v) end + end + for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end + P(string.format("loaded decode.bin=%d B, codebooks %d+%d B, stream %d B, %d frames", + #code, META.cb1_len, META.cb4_len, META.stream_len, META.nframes)) +end + +local function launch(off, nfr, iter) + SP:write_u32(FLAG, 0) + SP:write_u32(ITER, iter) + SP:write_u32(NFR, nfr) + SP:write_u32(FPTR, STREAM + off) + local cpu = M.devices[":maincpu"] + cpu.state["SR"].value = 0x2700 -- supervisor, ALL interrupts masked + cpu.state["SP"].value = 0x8000 + cpu.state["PC"].value = 0x10000 +end + +-- The plan: one sequential correctness pass, then the cost anchors, then a +-- full pass timed. Iteration counts target ~4 emulated seconds each so the +-- 1/55.46 s timing granularity costs under 0.5%. +-- DLX_VERIFY_ONLY=1 drops the cost anchors and runs only the correctness pass, +-- so tools/bench/check.sh can gate the decoder without paying for ~2 minutes of +-- timing runs that would make the green light sensitive to host load anyway. +local VERIFY_ONLY = os.getenv("DLX_VERIFY_ONLY") == "1" + +local PLAN = { {name="sequential decode of all "..META.nframes.." frames (correctness)", + off=0, nfr=META.nframes, iter=1, snap=true} } +for _,an in ipairs(VERIFY_ONLY and {} or META.anchors) do + local est = math.max(0.06, an.frac/100) * 1.30 * FRAME12 + PLAN[#PLAN+1] = {name="frame @ "..an.name, off=an.off, nfr=1, + iter=math.max(20, math.floor(4*CPUHZ/est)), frac=an.frac} +end +if not VERIFY_ONLY then + PLAN[#PLAN+1] = {name="full "..META.nframes.."-frame pass (mean over the window)", + off=0, nfr=META.nframes, iter=1, seq=true} +end + +local step, st, t0 = 0, "boot", nil +local results = {} + +local function report(p, dt) + local per = p.nfr * p.iter + local cyc = dt * CPUHZ / per + local pct = 100 * cyc / FRAME12 + if p.snap then return end -- correctness pass, iter=1, too coarse + results[#results+1] = {p=p, cyc=cyc, pct=pct} + P(string.format("%s", p.name)) + P(string.format(" %d frames in %.4f s -> %.0f cycles/frame = %.1f%% of a %dfps frame", + per, dt, cyc, pct, META.fps)) +end + +SUB = emu.add_machine_frame_notifier(function() + local ok, err = pcall(function() + local t = T() + if st == "boot" then + if t < 3.0 then return end + setup(); step = 1; launch(PLAN[1].off, PLAN[1].nfr, PLAN[1].iter) + st, t0 = "running", nil; return + end + if st == "running" then + local fl = SP:read_u32(FLAG) + if fl == 1 and not t0 then t0 = t; return end + if fl == 0xEE then + P("BITSTREAM DESYNC -- decoder consumed the wrong number of payload bytes") + M:exit(); return + end + if fl == 0xFF then + report(PLAN[step], t - (t0 or t)) + if PLAN[step].snap then st = "snap"; return end + step = step + 1 + if PLAN[step] then + launch(PLAN[step].off, PLAN[step].nfr, PLAN[step].iter) + st, t0 = "running", nil + else st = "finish" end + return + end + if t > 400 then P("TIMEOUT flag="..string.format("%08X",fl)); M:exit() end + return + end + if st == "snap" then + M.video:snapshot() + P("snapshot taken after the sequential pass -- last frame, 68000-decoded") + step = step + 1 + launch(PLAN[step].off, PLAN[step].nfr, PLAN[step].iter) + st, t0 = "running", nil; return + end + if st == "finish" then + P("---- summary (instruction cycles only; real GVRAM adds wait states) ----") + for _,r in ipairs(results) do + P(string.format(" %-46s %8.0f cyc %5.1f%% of a frame", r.p.name, r.cyc, r.pct)) + end + M:exit() + end + end) + if not ok then print("[DEC] LUA ERROR: "..tostring(err)); M:exit() end +end) diff --git a/tools/bench/prep_dlx.py b/tools/bench/prep_dlx.py new file mode 100644 index 0000000..35a8626 --- /dev/null +++ b/tools/bench/prep_dlx.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Lay a DLX1 container out the way src/player/decode.s expects to find it. + + python3 tools/bench/prep_dlx.py [--out tmp/decode] + +Writes _data.bin (one blob Lua pushes into emulated RAM) and _meta.lua +(sizes, per-frame record offsets, and the timing anchors). + +Two things happen here that the shipping player would do at load time on the +68000 itself, and are therefore NOT part of the per-frame cost being measured: + + * codebook expansion to word-per-pixel form. CB1 -> 32 B/entry, CB4 -> 8 B, + so the inner loop scales an index with lsl.w #5 / #3 and movems the result + straight into GVRAM with no unpacking. 8 KB + 2 KB of the 2 MB. + * palette packing to GGGGGRRRRRBBBBBI with the shared LSB I chosen PER ENTRY + by minimum squared error (FINDINGS 23.3, worth 1.96 dB). + +The encoder still emits 24-bit palettes and does not reserve a black entry +(known gap, docs/STATUS.md), so the letterbox here is filled with whatever +palette entry is closest to black rather than a true reserved black. That is +cosmetic and outside the active 256x192 area the decoder is judged on. + +A synthetic all-SKIP frame is appended to the stream. No real frame is all +SKIP, but it prices the mode-header walk on its own -- the per-block cost the +"76.6% x non-SKIP fraction" model in FINDINGS 24.5 leaves out entirely. +""" +import sys, os, argparse +sys.path.insert(0, "tools/encoder") +import numpy as np +from dlx import DLX + +ap = argparse.ArgumentParser() +ap.add_argument("container") +ap.add_argument("--out", default="tmp/decode") +a = ap.parse_args() + +d = DLX(a.container) +if d.idx_bytes != 1: + sys.exit("2-byte codebook indices: decode.s assumes 1 (k<=256)") + +# --- codebooks, expanded to one WORD per pixel (high byte is discarded by +# gvram_w, so it is left zero and never has to be cleared) +cb1 = np.zeros((d.k1, 16, 2), np.uint8); cb1[:, :, 1] = d.cb1.reshape(d.k1, 16) +cb4 = np.zeros((d.k4, 4, 2), np.uint8); cb4[:, :, 1] = d.cb4.reshape(d.k4, 4) + +# --- palette words, I chosen per entry (identical maths to verify_frame256.py) +pal = d.pal.astype(int) +p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF +f = pal >> 3 +render = lambda I: p6((f << 1) | I[:, None]) +I = (((render(np.ones(256, int)) - pal) ** 2).sum(1) + < ((render(np.zeros(256, int)) - pal) ** 2).sum(1)).astype(int) +words = (f[:, 1] << 11) | (f[:, 0] << 6) | (f[:, 2] << 1) | I +palb = np.zeros((256, 2), np.uint8) +palb[:, 0], palb[:, 1] = words >> 8, words & 0xFF +dark = int(((render(I).astype(int)) ** 2).sum(1).argmin()) + +# --- frame stream: [u32 len][modes][payload] per frame, each record start +# rounded up to a 4-byte boundary. +# +# This padding is not cosmetic. Payload lengths are arbitrary, so laid end +# to end the records land on odd addresses, and `move.l (a0)+` at an odd +# address is an ADDRESS ERROR on a 68000 -- it vectors into the IPL rather +# than reading slowly. The container as written by encode.py is unaligned, +# so this loader realigns it; the encoder should carry the padding itself +# (FINDINGS 28.3). It costs at most 3 bytes per frame -- 36 B/s at 12fps, +# against a 110 KB/s budget. +stream, rec_off, pad = bytearray(), [], 0 +for (o, n) in d.frames: + while len(stream) % 4: + stream += b"\0"; pad += 1 + rec_off.append(len(stream)) + stream += n.to_bytes(4, "big") + d.raw[o:o + n] + +# Synthetic single-mode frames. No real frame is all one mode, but the mix is +# exactly what the "76.6% x non-SKIP fraction" model of FINDINGS 24.5 assumes +# away: it prices every non-SKIP block as one V1-style burst. These four price +# the modes separately, which is the only way to see which one is expensive. +synth = {} +for name, mo, per in (("all-SKIP", 0, 0), ("all-V1", 1, 1), + ("all-V4", 2, 4), ("all-RAW", 3, 16)): + while len(stream) % 4: + stream += b"\0"; pad += 1 + synth[name] = len(stream) + hdr = bytes([mo * 0x55] * d.mode_bytes) + stream += (d.mode_bytes + d.nb * per).to_bytes(4, "big") + hdr + bytes(d.nb * per) + +# --- timing anchors: the distribution, not its mean (FINDINGS 25.6's lesson) +ns = np.array([100 * (d.modes(i) != 0).mean() for i in range(d.nframes)]) +order = np.argsort(ns) +pick = { + "min non-SKIP %.1f%%" % ns[order[0]]: int(order[0]), + "median %.1f%%" % np.median(ns): int(order[len(order)//2]), + "p90 %.1f%%" % ns[order[int(.9*len(order))]]: int(order[int(.9*len(order))]), + "max non-SKIP %.1f%%" % ns[order[-1]]: int(order[-1]), +} +anchors = [(n, rec_off[i], float(ns[i])) for n, i in pick.items()] +for name in ("all-SKIP", "all-V1", "all-V4", "all-RAW"): + anchors.append((f"synthetic {name}", synth[name], + 0.0 if name == "all-SKIP" else 100.0)) + +blob = cb1.tobytes() + cb4.tobytes() + palb.tobytes() + bytes(stream) +open(a.out + "_data.bin", "wb").write(blob) + +with open(a.out + "_meta.lua", "w") as fh: + fh.write("-- generated by tools/bench/prep_dlx.py -- do not edit\nreturn {\n") + fh.write(f" W={d.W}, H={d.H}, fps={d.fps}, nframes={d.nframes},\n") + fh.write(f" k1={d.k1}, k4={d.k4}, dark={dark},\n") + fh.write(f" cb1_len={cb1.nbytes}, cb4_len={cb4.nbytes}, pal_len={palb.nbytes},\n") + fh.write(f" stream_len={len(stream)},\n") + fh.write(" anchors={\n") + for n, o, frac in anchors: + fh.write(f' {{name="{n}", off={o}, frac={frac:.1f}}},\n') + fh.write(" },\n}\n") + +print(f"{a.container}: {d.nframes} frames, {d.W}x{d.H}, k1={d.k1} k4={d.k4}") +print(f" cb1 {cb1.nbytes} B + cb4 {cb4.nbytes} B expanded, palette {palb.nbytes} B, " + f"stream {len(stream)} B -> {a.out}_data.bin ({len(blob)} B)") +print(f" non-SKIP blocks/frame: median {np.median(ns):.1f}% " + f"p90 {np.percentile(ns,90):.1f}% max {ns.max():.1f}%") +print(f" darkest palette entry: index {dark} -> {tuple(render(I)[dark])}") +print(f" 4-byte record alignment cost {pad} B over {d.nframes} frames " + f"({pad / d.nframes:.2f} B/frame = {pad / d.nframes * d.fps:.0f} B/s)") diff --git a/tools/bench/verify_decode.py b/tools/bench/verify_decode.py new file mode 100644 index 0000000..4646fbf --- /dev/null +++ b/tools/bench/verify_decode.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Is the 68000 decoder's output pixel-exact against the reference decoder? + + python3 tools/bench/verify_decode.py [--snap tmp/snap_decode] + +Checks tmp/snap_decode/x68000/0000.png -- the screen after src/player/decode.s +has decoded every frame of the container in sequence -- against +tools/encoder/dlx.py's reconstruction of the final frame. + +This is a stronger test than the blit regression it is modelled on. The blit +proved the 68000 could COPY a frame; this proves it can PARSE one. And because +the decoder is temporally recursive -- a SKIP block is a claim that the previous +frame is still in GVRAM -- the last frame of a sequential run is only correct if +every frame before it was, so a single comparison audits all of them. +""" +import argparse, sys +sys.path.insert(0, "tools/encoder") +import numpy as np +from PIL import Image +from dlx import DLX + +ap = argparse.ArgumentParser() +ap.add_argument("container") +ap.add_argument("--snap", default="tmp/snap_decode") +a = ap.parse_args() + +d = DLX(a.container) +canvas = np.zeros((d.H, d.W), np.uint8) +for f in range(d.nframes): + d.paint(canvas, f) + +pal = d.pal.astype(int) +p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF +fl = pal >> 3 +render = lambda I: p6((fl << 1) | I[:, None]) +I = (((render(np.ones(256, int)) - pal) ** 2).sum(1) + < ((render(np.zeros(256, int)) - pal) ** 2).sum(1)).astype(int) +exp = render(I)[canvas] + +s = np.asarray(Image.open(f"{a.snap}/x68000/0000.png").convert("RGB")).astype(int) +fail = [] +if s.shape[:2] != (512, 256): + fail.append(f"1. geometry: expected 512x256, got {s.shape[1]}x{s.shape[0]}") +else: + if not all(np.array_equal(s[i], s[i+1]) for i in range(1, s.shape[0]-1, 2)): + fail.append("2. double-scan pairing (1,2),(3,4),... broken") + g = s[0::2] + yoff = (g.shape[0] - d.H) // 2 + act = g[yoff:yoff+d.H] + if not np.array_equal(act, exp): + diff = abs(act - exp) + bad = diff.any(2) + by, bx = np.where(bad) + blocks = sorted(set(zip((by//4).tolist(), (bx//4).tolist()))) + fail.append(f"3. frame {d.nframes-1} not pixel-exact: {bad.sum()} px in " + f"{len(blocks)} blocks differ, maxdiff {diff.max()}; " + f"first block (by={blocks[0][0]}, bx={blocks[0][1]})") + +for x in fail: + print("FAIL " + x) +if fail: + sys.exit(1) +print(f"OK {d.nframes} frames decoded on the 68000, final frame pixel-exact " + f"against tools/encoder/dlx.py") +print(f" {d.W}x{d.H}, {d.nb} blocks/frame, k1={d.k1} k4={d.k4}, " + f"all four block modes exercised") diff --git a/tools/encoder/dlx.py b/tools/encoder/dlx.py new file mode 100644 index 0000000..a3e62c6 --- /dev/null +++ b/tools/encoder/dlx.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Reference DLX1 reader/decoder -- the ground truth the 68000 player is checked against. + +This is deliberately a *decoder*, not a re-run of the encoder: it parses the +container byte-for-byte the way `src/player/` must, so that any disagreement +between it and the 68000 is a decoder bug rather than two encoders differing. +`tools/analysis/09_ratectl_drift.py` already gates the encoder against its own +reconstruction; this gates the container against the player. + +Everything is big-endian (see the `encode.py` docstring). Block raster order, +2-bit modes packed MSB-first: 00=SKIP 01=V1 10=V4 11=RAW. + +V4 sub-block order is (sub_y, sub_x) row-major -- TL, TR, BL, BR -- matching +`vq_hybrid.paint`'s reshape(-1,2,2,2,2).transpose(0,1,3,2,4). +""" +import struct +import numpy as np + +MODE_SKIP, MODE_V1, MODE_V4, MODE_RAW = 0, 1, 2, 3 + + +class DLX: + def __init__(self, path): + self.raw = open(path, "rb").read() + b = self.raw + if b[:4] != b"DLX1": + raise ValueError(f"{path}: not a DLX1 container") + (self.W, self.H, self.fps, self.nframes, + self.k1, self.k4) = struct.unpack(">HHHHHH", b[4:16]) + off_pal, off_cb1, off_cb4, off_frm = struct.unpack(">IIII", b[16:32]) + + self.pal = np.frombuffer(b, np.uint8, 256 * 3, off_pal).reshape(256, 3) + self.cb1 = np.frombuffer(b, np.uint8, self.k1 * 16, + off_cb1).reshape(self.k1, 4, 4) + self.cb4 = np.frombuffer(b, np.uint8, self.k4 * 4, + off_cb4).reshape(self.k4, 2, 2) + + self.idx_bytes = 1 if max(self.k1, self.k4) <= 256 else 2 + self.nbx, self.nby = self.W // 4, self.H // 4 + self.nb = self.nbx * self.nby + self.mode_bytes = (self.nb * 2 + 7) // 8 + + # frame directory: (offset of the mode header, payload length) + self.frames = [] + p = off_frm + for _ in range(self.nframes): + (n,) = struct.unpack(">I", b[p:p + 4]) + self.frames.append((p + 4, n)) + p += 4 + n + if p != len(b): + raise ValueError(f"{path}: {len(b) - p} trailing bytes after " + f"{self.nframes} frames") + + def modes(self, f): + o, _ = self.frames[f] + h = np.frombuffer(self.raw, np.uint8, self.mode_bytes, o) + m = np.stack([(h >> 6) & 3, (h >> 4) & 3, (h >> 2) & 3, h & 3], axis=1) + return m.reshape(-1)[:self.nb].copy() + + def blocks(self, f): + """Decoded 4x4 palette-index blocks for the non-SKIP blocks of frame f. + + Returns (mode, dict{block index -> (4,4) uint8}). SKIP blocks are + absent by construction -- the player must leave those pixels alone, + and a decoder that materialises them is hiding the very bug this + module exists to catch. + """ + mode = self.modes(f) + o, n = self.frames[f] + p, end = o + self.mode_bytes, o + n + ib, out = self.idx_bytes, {} + b = self.raw + for i, mo in enumerate(mode): + if mo == MODE_SKIP: + continue + if mo == MODE_V1: + v = b[p] if ib == 1 else (b[p] << 8) | b[p + 1] + p += ib + out[i] = self.cb1[v] + elif mo == MODE_V4: + sub = [] + for _ in range(4): + v = b[p] if ib == 1 else (b[p] << 8) | b[p + 1] + p += ib + sub.append(self.cb4[v]) + blk = np.empty((4, 4), np.uint8) + blk[0:2, 0:2], blk[0:2, 2:4] = sub[0], sub[1] + blk[2:4, 0:2], blk[2:4, 2:4] = sub[2], sub[3] + out[i] = blk + else: + out[i] = np.frombuffer(b, np.uint8, 16, p).reshape(4, 4) + p += 16 + if p != end: + raise ValueError(f"frame {f}: payload consumed {p - o} of {n} bytes") + return mode, out + + def paint(self, canvas, f): + """Apply frame f in place to a (H,W) index canvas. SKIP = untouched.""" + mode, blks = self.blocks(f) + for i, blk in blks.items(): + by, bx = divmod(i, self.nbx) + canvas[by * 4:by * 4 + 4, bx * 4:bx * 4 + 4] = blk + return mode + + def decode_all(self): + """The true reconstruction sequence: what any correct player displays.""" + c = np.zeros((self.H, self.W), np.uint8) + out = [] + for f in range(self.nframes): + self.paint(c, f) + out.append(c.copy()) + return out