# Findings — session 1 (2026-08-23) All numbers here are MEASURED unless marked ESTIMATE or FOLKLORE. --- ## 1. Source material `DRAGONS_LAIR.iso` — 16 GB, UDF 2.x, **decrypted** (no AACS dir). Loop-mounted read-only at `/media/reala-misaki/BDROM` via `udisksctl loop-setup -r -f`. (7-Zip cannot read UDF 2.x; use the loop mount.) - **224 `.m2ts` streams**, 1920x1080, **MPEG-2, progressive, 23.976 fps** - Size histogram: 47 <5MB, 138 5-50MB, 22 50-150MB, 14 150-400MB, 3 >400MB - The 185 sub-50MB streams are the **arcade branching scenes already split into individual clips** — we get scene boundaries for free. - Big streams are full-feature playthroughs: 00215 (1376s), 00216 (1151s), 00223 (566s) - Typical scene clip ~60s (00203/00205/00199), some ~100s (00164/00212) **Gotcha:** clip durations vary wildly. Always read `format=duration` and seek relative to it. Seeking to a fixed offset silently yields 0 frames on short clips. --- ## 2. GVRAM layout [verified — see HARDWARE.md for source] **One 16-bit word per pixel position in EVERY color mode.** Bit depth does not change VRAM bandwidth; it only subdivides the word. `addr = page_base + y*1024 + x*2` — adjacent pixels are 2 bytes apart in all modes. Consequence: low bit depth buys **no speed**. 16-color mode is strictly worse than 256-color (same bus traffic, 1/16 the palette). Page-alias writes are hardware auto-masked, so 16-color needs no software read-modify-write — but it's still one word-access per pixel. **Chosen: 256 colors, 256x192 active area.** In 256-color mode P0=low byte, P1=high byte of each word. Sacrificing page 1 as a double-buffer lets a `move.l` cover two pixel positions, enabling `movem.l` bursts (12 regs = 48 bytes = 24 pixels). Identical blit cost to 65536-color mode but **half the on-disk data**. --- ## 3. Content measurements (8 scenes sampled, 5s each at 40% into each clip) | metric | mean | p90 | |---|---|---| | pixels changed / frame | 20.1% | 30.2% | | **blit cost** | **~64k cycles** | **~97k cycles** | | naive delta+RLE frame size | 15.5 KB | 19.6 KB | Budget is **833,333 cycles/frame** @ 12fps on a 10MHz 68000. ### => THE CPU IS NOT THE BOTTLENECK. I/O IS. Blit uses **under 8%** of budget. The naive row-span+RLE codec achieves only **3.2:1**, giving **365 KB/s / 470 MB** at 24fps (~183 KB/s / 235 MB at 12fps). Per-scene variance is extreme: static dialogue ~30 KB/s, action ~700 KB/s. Any codec needs a hard bitrate ceiling, not just a good average. ### "Shot on twos" — ASSUMPTION FAILED Dedupe found **zero** duplicate frames across all 8 scenes (`uniq=120/120`, 24.0 fps effective). This Blu-ray is a restoration where every frame is unique. We do NOT get halved data for free. **Decimation to 12fps must be explicit.** A weak alternation signature does exist (even-index pairs 40.7% vs odd 27.5%, ratio 1.5x, with occasional true-duplicate pairs at 0.03-0.19%), but it is irregular — Bluth mixed ones and twos; action is animated on ones. --- ## 4. MEASUREMENT TRAPS — read before trusting any pipeline number Three separate false results were produced and caught this session. All three looked plausible. Guard against them: 1. **Per-frame Floyd-Steinberg dithering destroys temporal coherence.** Error diffusion is chaotic: a +/-1 input change cascades across the row and produces a completely different index pattern. First run reported 31.5% pixels changed with near-zero variance (median 31.6, p90 32.3, max 32.7) while source mean-abs-diff was 0.09 — i.e. visually identical frames. That flat variance is the tell: **real animation has scene-dependent variance; noise does not.** Use no dithering (cel art is flat) or ordered/Bayer (spatially fixed, temporally stable). 2. **Temporal denoise smears motion.** `hqdn3d=4:3:6:4` — the `6:4` are temporal params. It flattened real motion, which then measured as "no motion" and produced an absurd 0.8 fps / 4 MB result. **Use spatial-only: `hqdn3d=4:3:0:0`.** 3. **Exact-match dedupe fails on a noisy source.** MPEG-2 grain means near-duplicate frames differ by +/-1 and are never bit-exact. Use a threshold on "% pixels differing by more than N levels", and pick the threshold from the observed distribution, not a guess. A 2% threshold ate genuine animation when mean consecutive change was only 0.9%. **Sanity rule: if a result has suspiciously low variance, or is suspiciously good, it is probably an artifact of the measurement, not a property of the content.** Scripts kept in `tools/analysis/` — 01 and 02 are marked BROKEN deliberately as regression references; 03 and 04 are the correct ones. --- ## 5. Storage interface — the SASI/SCSI split > **SUPERSEDED IN PART.** The claim below that DMA means streaming "costs > essentially no CPU" is **wrong** — see 19. The bandwidth figures here are > folklore; the working figure is now **4 Mbps = 488 KB/s** (21). [Yasuma, X68030 internal SCSI controller] - Interface: **SCSI-1**, 50-pin, 5 MB/s bus spec - Controller: **Fujitsu MB89352** SPC - Transfer mode: **DMA** (via **HD63450** DMAC) - Bus: X68000 original bus, **16-bit @ 10MHz** **Even on the X68030, SCSI runs at 10MHz 16-bit DMA.** Storage bandwidth does NOT scale with CPU — the controller sits on the original bus. HD63450's 12.5MHz official ceiling is why the X68030 runs at 25MHz. An "HSCSI" TSR forces PIO/FIFO transfer instead of DMA but was marginal even at 25MHz. Because it's DMA, **streaming costs essentially no CPU** — this stacks with the 8% blit utilisation. The 68000 really is nearly idle. ### Model split — IMPORTANT **The 10MHz models (original X68000, ACE, PRO, EXPERT) use SASI, not SCSI.** Built-in SCSI starts at the X68000 **Super** (1990) and continues through XVI, Compact, X68030. SCSI on earlier machines needs the **Sharp CZ-6BS1** board in an I/O slot (MAME models this: `-exp1 cz6bs1`). | target | bandwidth | naive codec (365 KB/s) | VQ codec (~30 KB/s) | |---|---|---|---| | SASI (stock ACE/EXPERT) | ~300-500 KB/s FOLKLORE | infeasible | comfortable | | SCSI (Super+, or CZ-6BS1) | ~1 MB/s FOLKLORE | tight but viable | trivial | Derived bounds (ESTIMATE): 16-bit @10MHz with 4-clock bus cycle = 5 MB/s absolute ceiling; HD63450 single-address DMA ~8 clocks/word => ~2.5 MB/s practical ceiling, before SCSI-1 async handshake and drive latency. **No measured benchmark was obtained — see STATUS.md.** The ~300-500 KB/s and ~1 MB/s figures are folklore-grade; I could not find a primary measurement. --- ## 6. Codec decision: vector quantization (Cinepak-style) Given ~8x CPU headroom and an I/O ceiling, spend CPU to buy bandwidth. - Split frame into 4x4 blocks, encode each as a 1-byte index into a per-scene codebook - Decode = 16-byte copy from a lookup table: nearly free - A **full** frame = 256*192/16 = **3,072 bytes** — a hard 16:1 floor before delta - Add block-level delta on top; action scenes ~2-3 KB/frame - => roughly **30 KB/s, ~40 MB total**, with a *deterministic* bitrate ceiling Divergence from the SNES project (below): use a **per-scene codebook with delta updates**, not a per-frame rebuild. We trade adaptivity for bandwidth because we have 2MB RAM to keep a codebook resident and CPU to spare. **Risk not yet evaluated:** 4x4 VQ with a 256-entry codebook will visibly soften detail. Bluth's fine ink linework is what suffers. Prototype and eyeball before committing. --- ## 7. Comparison: astrobleem/SNES-SuperDragonsLairArcade Reached the **same core architecture independently** — "512 tiles per frame" is vector quantization (8x8 codebook + tilemap). Good validation. But: the SNES PPU has **no bitmap mode**, so tiles are forced on them by display hardware. The X68000 has a real linear framebuffer, so VQ is a *compression choice* we can tune or drop per-scene. **MSU-1 is a bandwidth cheat we don't have.** It's a modern flash-cart coprocessor giving memory-mapped streaming the real SNES never had. Their budget: 512 tiles x 32 bytes (4bpp 8x8) + tilemap ~= 18 KB/frame => **~430 KB/s** at 23.976fps. That's *higher* than the 365 KB/s we'd reject on SASI. (ESTIMATE: my arithmetic on their stated tile budget, not a measured figure.) Where we're ahead: 256 simultaneous colors from a 65536 palette vs their 4bpp sub-palettes needing a tile-aware palette optimizer plus a spatial smoothing pass to hide 8x8 palette seams. That problem doesn't exist for us. Plus 68000@10MHz vs 65816@3.58MHz, and 2MB vs 128KB. **Most valuable thing in that repo is NOT the codec — it's `data/events/`:** 516 chapter definitions across 29 scenes as XML, plus `data/chapter_event_inventory.md`. That's the arcade scene graph and input-timing structure, entirely hardware-independent — the whole game-logic layer we'd otherwise reverse-engineer from the arcade ROM. **TODO: check their license before planning to reuse it.** Their 516 chapters are finer-grained than our 224 Blu-ray streams, so mapping their event table onto our footage means subdividing streams by timecode. Caveat: all of the above is from README/repo-tree summaries, not their source. --- --- # Findings — session 2 (2026-08-23) ## 8. CORRECTION to session 1: halving the framerate does NOT halve the bitrate > **PARTLY SUPERSEDED.** The framerate correction stands. The > "changed-spans + deflate = 247 KB/s" figure is a **compression upper bound, > not a shippable design** — deflate decode does not fit the 68000's frame > budget. See 17.2. Session 1 measured 365 KB/s for naive delta+RLE at 24 fps and wrote "(~183 KB/s at 12fps)". **That extrapolation is wrong.** Decimating to 12 fps roughly doubles the per-frame delta, so the *rate* stays nearly flat. Re-measured directly on 12 fps decimated frames (4 scenes, 66 frames): | codec (all LOSSLESS w.r.t. the 256-colour frame) | B/frame | KB/s @12 | 22 min | ratio | |---|---|---|---|---| | raw 8bpp 256x192 | 49152 | 576 | 743 MB | 1.0:1 | | session 1 row-span + RLE | 29055 | 340 | 439 MB | 1.7:1 | | XOR vs prev + deflate | 30196 | 354 | 456 MB | 1.6:1 | | **changed-spans + deflate** | **21110** | **247** | **319 MB** | **2.3:1** | | changed-spans + LZMA | 18759 | 220 | 283 MB | 2.6:1 | Session 1's own RLE re-measured at 12 fps gives **340 KB/s, not 183**. Any plan that assumed 183 KB/s was based on a bad number. Deflate-class entropy coding on top of the span payload is worth **1.4x** over hand-rolled RLE, and LZ decode is cheap on a 68000 (byte copies), so the lossless floor is ~247 KB/s / 319 MB. That is **infeasible on SASI** and **tight but real on SCSI**. ## 9. Flat 4x4 VQ at k=256 is NOT acceptable — confirmed by eye The risk flagged in 6 is real. At k=256, 4x4: | scene | palette-only PSNR | after VQ | VQ loss | |---|---|---|---| | 00010 | 38.35 | 29.68 | 8.67 dB | | 00020 | 39.90 | 32.67 | 7.22 dB | | 00146 | 35.25 | 29.35 | 5.89 dB | | 00181 | 41.92 | 32.87 | 9.05 dB | Visually: Dirk's face disintegrates, teeth and eyes turn to mush, ink outlines break into 4-pixel stair-steps, colour bleeds across block boundaries. ![flat 4x4 VQ failure](images/flat_vq_failure_00010.png) *Left: 1080p source. Middle: 256-colour palettised 256x192 — the quality ceiling, and it is excellent. Right: flat 4x4 VQ at k=256. This is the result that killed the flat-VQ architecture.* **Crucially, the 256-colour palettised frame itself looks excellent.** Flat cel art with a per-scene median-cut palette and no dithering is near-transparent (35-42 dB). So the palette is not the problem and 256 colours is not the problem — **block VQ is**. The quality ceiling we should hold ourselves to is the palettised frame, not the 1080p source. ## 10. Hybrid VQ (Cinepak V1/V4 + SKIP) — this is the codec Per 4x4 block, choose by rate-distortion: SKIP (reuse previous frame), V1 (one 4x4 codeword, 1 byte), or V4 (four 2x2 codewords, 4 bytes), with a 2-bit-per-block mode header. `lam` is the lagrangian rate knob. Measured, k1=k4=256, 4 scenes (mean of the per-scene table in the session log): | lam | PSNR | loss vs palette | SKIP% | V1% | V4% | B/frame | KB/s @12 | |---|---|---|---|---|---|---|---| | 0 (max quality) | 33.9 | 4.9 | 30.8 | 18.5 | 50.8 | 7574 | 88.8 | | 200 | 31.9 | 5.9 | 44.0 | 37.6 | 18.4 | 4183 | 49.0 | | 1000 | 31.6 | 7.3 | 47.4 | 47.7 | 4.9 | 2841 | 33.3 | | 5000 | 25.5 | 13.3 | 55.6 | 44.4 | 0.0 | 2134 | 25.0 | At a **matched ~30 KB/s** the hybrid beats flat 4x4 VQ by ~1 dB, and unlike flat VQ it keeps scaling: at 89 KB/s it reaches within **4.9 dB of the palette ceiling**, which flat VQ cannot reach at any bitrate. Note V4% collapses to 0 at lam=5000 — that is the knob doing exactly what it should: under a hard ceiling, detail blocks are the first thing sacrificed. ## 11. Codebook size sweep (flat 4x4, for reference) > **SUPERSEDED.** The k=1024 result below is an artifact of a rate model that > charged 1 byte for a 10-bit index. k=256 ships. See 14. | block | k | PSNR | loss | key B | changed% | KB/s @12 | codebook RAM | |---|---|---|---|---|---|---|---| | 4x4 | 256 | 30.46 | 8.39 | 3072 | 52.7 | 28.5 | 8K | | 4x4 | 1024 | 32.89 | 5.96 | 3840 | 56.6 | 35.6 | 32K | +2.4 dB for 24K more RAM and 7 KB/s. With 2 MB of RAM, a 1024-entry codebook is cheap and clearly worth it. (RAM figure is the word-expanded form the blitter wants: k * 16 px * 2 bytes.) ## 12. Source framing — OPEN The Blu-ray is **full-frame 1920x1080 16:9 with no pillarboxing**. The arcade original is 4:3. The extractor currently centre-crops 1440x1080, which is the arcade-faithful choice but discards image the 2006 remaster added. Options are `crop` (default), `squash`, `wide` in `tools/encoder/extract.py`. **Not yet decided; needs an eyeball comparison against arcade reference.** ## 13. Stream inventory correction Session 1 said "typical scene clip ~60s". Sampled directly: the ~3-5 MB streams are **1.2-1.7 s** clips — these are the individual arcade death/action moments, which is exactly the granularity the game logic needs. Some 60 s streams (e.g. 00203) are **menu screens, not content**. Any survey must classify menu vs content before averaging, or the bitrate numbers are diluted by static menus. ## 14. A FOURTH false-good result — and the correction Add this to the 4 list. The mechanism was new but the shape was identical. **The false result:** flat and hybrid VQ both showed **+2.4 dB for k=1024 over k=256** at an apparently similar bitrate, which made a 1024-entry codebook look like an obvious win. The k=1024 quality ladder rendered from that run looked great at "45 KB/s". **The bug:** the rate-distortion model in `vq_hybrid.encode()` charged **1 byte** per codebook index unconditionally. A 1024-entry codebook needs a **10-bit index, stored as 2 bytes**. So every k=1024 measurement understated the V1 and V4 payload by exactly 2x, *and* the lagrangian mode decision was choosing V4 on the belief that four codewords cost 4 bytes when they cost 8. **After charging the true index cost** (`idx_bytes` is now explicit and defaults from the codebook size), matched-bitrate comparison on scene 00020: | KB/s | k=256 (1-byte idx) | k=1024 (2-byte idx) | |---|---|---| | ~32-42 | **33.87 dB** @ 32.5 | 28.91 dB @ 42.3 | | ~44-52 | **34.80 dB** @ 44.1 | 35.13 dB @ 52.5 | | ~72-86 | **35.87 dB** @ 72.2 | 36.51 dB @ 86.0 | k=1024 buys +0.3 to +0.6 dB for +19% bitrate — a wash at best — and at the low end where the SASI profile lives it is **5 dB worse**, because the 2-byte index floor dominates once V4 is priced out. **k=256 with 1-byte indices is the shipping choice.** It is also the better decoder: a plain `move.b` index with no alignment case, and an 8 KB codebook instead of 32 KB. **The general lesson, again:** the comparison was not wrong about VQ, it was wrong about *cost*. When a knob looks like a free win, check that the rate model is charging for it. Same failure family as 4.1-4.3: a plausible number produced by a pipeline that was not measuring what it claimed to measure. ## 15. Rate-distortion curve of the shipping codec (k=256, corrected) Scene 00020 (Dirk screaming, close-up face — the hardest case for linework), and 00146. Includes the 2-bit-per-block mode header. No entropy coding yet. | lam | 00020 PSNR | 00020 KB/s | 00146 PSNR | 00146 KB/s | SKIP | V1 | V4 | RAW | |---|---|---|---|---|---|---|---|---| | 25 | 38.68 | 182.2 | 31.04 | 193.5 | ~37% | ~24% | ~13% | ~26% | | 100 | 35.87 | 72.2 | 29.04 | 72.5 | ~41% | ~34% | ~21% | ~4% | | 300 | 34.80 | 44.1 | 28.28 | 44.4 | ~44% | ~42% | ~14% | 0% | | 800 | 33.87 | 32.5 | 27.77 | 36.1 | ~46% | ~48% | ~5% | 0% | | 2000 | 27.57 | 25.5 | 24.88 | 30.2 | ~50% | ~49% | ~1% | 0% | Palette ceilings: 00020 = 39.90 dB, 00146 = 35.25 dB. ![quality ladder](images/quality_ladder_00020.png) *The shipping codec across the rate knob. Top: source, palette ceiling, lam=25. Bottom: lam=100 (`scsi` profile), lam=300 (`sasi` profile), lam=800. Both shipping profiles hold Bluth's linework; the failure only starts past lam=800.* Two things to read off this table: - **The cliff is between lam=800 and lam=2000.** That is where V4 is priced out entirely and detail blocks have nowhere to go. Do not ship past lam~800. - **RAW is doing real work at high bitrate** (26% of blocks at lam=25) and vanishes by lam=300. It is what makes the top of the curve reach the palette ceiling, and it costs the decoder nothing — RAW is the cheapest mode to blit. ## 16. Licences cleared for the game-logic layer Both checked this session: - **astrobleem/SNES-SuperDragonsLairArcade — MIT**, "Copyright (c) 2026 Chad Doebelin". `data/events/` holds 516 XML chapter definitions with timing and event data. Reusable with attribution. - **icculus/DirkSimple — zlib.** Independent from-scratch reimplementation of the game logic in Lua, scene/timing tables in `game.lua`. Also permissive. Having **two independent permissively-licensed transcriptions** of the arcade scene graph is better than one: they can be diffed against each other to catch transcription errors before any of it is committed to 68000 tables. ## 17. The profiles were set far too low — and entropy coding is a CPU trap > **PARTLY SUPERSEDED.** 17.1's diagnosis (the profiles were not derived from > hardware) and 17.2's CPU analysis both stand. But 17 reasoned against a > misread bandwidth of 4 MB/s; the correct figure is **4 Mbps = 488 KB/s**, so > the "ship pixel-exact if SCSI sustains >=800 KB/s" conclusion in 17.5 is > **not available**. See 18 and 21. Prompted by the user asking why the SCSI profile was only 75 KB/s. It should not have been. Two separate errors, one of them serious. ### 17.1 The profile bitrates were not derived from the hardware at all They were read off the knee of the rate-distortion curve and then presented as though bandwidth-driven. Against the (folklore) bus figures from 5: | profile | was | bus figure | utilisation | |---|---|---|---| | `sasi` | 45 KB/s | ~300-500 KB/s | **12%** | | `scsi` | 75 KB/s | ~1 MB/s | **7%** | Nothing justified leaving 90% of the pipe unused. Raised to `sasi` 110 KB/s (lam=60) and `scsi` 280 KB/s (lam=10), which is 35% and 28% utilisation — still conservative, because the bus figures are folklore. ### 17.2 CPU is NOT the reason to stay low — but entropy coding would be Budget is 833,333 cycles/frame at 12 fps. At session 1's measured ~6.5 cycles per GVRAM pixel write: | work | cycles | % of budget | |---|---|---| | blit 20.1% of pixels (session 1's 24fps figure) | 64k | 7.7% | | blit 40% of pixels (the same content at 12fps) | 128k | 15.3% | | **blit the FULL frame, every frame** | **319k** | **38.3%** | | deflate decode, ~30 KB/frame output | 1,800k | **216%** | | LZ4/LZSS decode, ~30 KB/frame output | 450k | **54%** | Two conclusions, and the second one corrects 8: - **Raising the VQ bitrate is nearly free on CPU.** Even a full-frame pixel-exact blit fits in 38% of budget, and VQ decode is table copies — RAW, the mode that dominates at high bitrate, is the *cheapest* mode to blit, not the dearest. - **The 247 KB/s "lossless changed-spans + deflate" figure in 8 is a compression upper bound, NOT a shippable design.** Deflate's Huffman decode is bitwise and costs about 2.2x the entire frame budget on a 68000. Even byte-oriented LZ4 at 54% leaves too little beside a 38% blit. **Do not plan on entropy coding.** All profile bitrates are raw payload. This inverts session 1's "the CPU is idle, I/O is the ceiling" for the *decode* path specifically: the blit is cheap, but any bit-oriented decompressor is not. VQ is the right architecture precisely because its decode is a table copy. ### 17.3 The hybrid at lam=0 IS the lossless codec Measured, un-entropy-coded raw payload, and deflated for reference only: | scene | lam=0 raw | lam=0 deflated | lossless changed-spans+deflate | PSNR | |---|---|---|---|---| | 00020 | 442.1 KB/s | 274.5 KB/s | 267.3 KB/s | 39.90 = ceiling | | 00146 | 467.6 KB/s | 223.2 KB/s | 219.1 KB/s | 35.25 = ceiling | The hybrid at `lam=0` converges to within 3% of the purpose-built lossless coder. That confirms the architecture unifies: there is no separate lossless path to maintain, just the same bitstream with the knob open. ### 17.4 Full curve in raw (shippable) bytes | lam | 00020 PSNR | 00020 KB/s | 00146 PSNR | 00146 KB/s | RAW% | |---|---|---|---|---|---| | 0 | **39.90** (exact) | 442.1 | **35.25** (exact) | 467.6 | ~76% | | 10 | 39.38 | 248.1 | 32.27 | 305.2 | ~44% | | 25 | 38.68 | 182.2 | 31.04 | 193.5 | ~26% | | 60 | 36.94 | 108.0 | 29.61 | 103.1 | ~10% | | 150 | 35.31 | 55.6 | 28.63 | 56.1 | ~1% | | 300 | 34.80 | 44.1 | 28.28 | 44.4 | 0% | ### 17.5 This makes the blocked disk benchmark critical-path Session 1 judged it "NOT on the critical path" because VQ at 30 KB/s was correct whether SASI did 300 or 600 KB/s. That reasoning no longer holds. The profiles now sit at 110 and 280 KB/s, close enough to the folklore ceilings that the error bars matter, and **if SCSI sustains >=800 KB/s the correct `scsi` profile is lam=0 — pixel-exact video.** Whether this port ships transparent or lossy on SCSI is now waiting on one measurement. ## 18. Peak-to-mean burstiness — the mean was hiding the problem > **SUPERSEDED — DO NOT ACT ON THIS SECTION.** The peak-vs-sustained comparison > below is the **wrong test**. With a ring buffer the correct test is cumulative > demand vs cumulative supply, and both profiles pass it with **zero required > prefill**. `scsi` at lam=10 ships. See 21. The per-frame peak numbers > themselves are still valid data; only the conclusion drawn from them is wrong. Prompted by the user clarifying that the bandwidth figure is **4 Mbps = 488 KB/s**, not 4 MB/s. That is ~8x tighter than what 17 was reasoning against, and it changes the answer. Per-frame instantaneous rate (video + 7.8 KB/s audio), 12 fps: | scene | lam | mean | p90 | **max** | peak/mean | max as % of 488 KB/s | |---|---|---|---|---|---|---| | 00010 | 60 | 95.0 | 127.3 | 138.8 | 1.46 | 28.4% | | 00010 | 10 | 198.9 | 266.1 | 284.0 | 1.43 | 58.2% | | 00020 | 60 | 115.8 | 155.4 | 222.3 | 1.92 | 45.5% | | 00020 | 10 | 255.9 | 391.2 | **470.8** | 1.84 | **96.4%** | **The `scsi` profile as committed in f0f2f80 does not fit 4 Mbps.** Its mean is a comfortable 52% of the pipe, but it peaks at 96.4% — and a frame that arrives late is a *dropped frame*, not a slow one. Sizing a real-time stream on the mean is the mistake; peak/mean is 1.4-1.9x on 1.2-1.7s clips and will be worse across a full scene. Two ways out, and only one is good: - Size for the peak: `lam=25`, mean 194 KB/s. Costs a full step of quality. - **Rate-control to the mean and carry a leaky bucket:** `lam=10` fits, and buys back +0.7 dB (00020) / +1.2 dB (00146). `ratectl.py` was written in session 2 but **never wired into `encode.py`**. This demotes that from a loose end to the highest-value unfinished work in the repo. ## 19. Cycle-stealing DMA is not free DMA — 5 was wrong FINDINGS 5 concluded "because it's DMA, streaming costs essentially no CPU — this stacks with the 8% blit utilisation. The 68000 really is nearly idle." The HD63450 steals bus cycles from the 68000 at roughly 8 clocks per 16-bit word: | stream | words/s | clocks/s | CPU stolen | + full-frame blit | |---|---|---|---|---| | 110 KB/s | 56,320 | 450,560 | 4.5% | 42.8% | | 250 KB/s | 128,000 | 1,024,000 | 10.2% | 48.5% | | 450 KB/s | 230,400 | 1,843,200 | 18.4% | 56.7% | | 488 KB/s | 249,856 | 1,998,848 | 20.0% | 58.3% | At the rates the profiles now use, streaming costs **10-20% of the machine**. Still affordable — nothing here breaks — but **bandwidth and CPU are one budget, not two**, and any future headroom argument has to spend from both. The "nearly idle" framing should not be reused. (The 8 clocks/word figure is session 1's ESTIMATE from HD63450 timing, not a measurement. It is the weakest link in this table.) ## 20. Where the profiles should come from `tools/encoder/profile_gen.py` now derives lam from a bandwidth figure rather than from the shape of the RD curve, accounting for audio, peak/mean, and reporting DMA steal. Full benchmarking methodology — and why MAME cannot answer the bandwidth question — is in `docs/BENCHMARK.md`. The 4 Mbps figure itself is **user-supplied and its provenance is not recorded**. Every profile now hangs off it, so it is worth pinning down. ## 21. Correction to 18 — the peak test was the wrong test 18 flagged that `scsi` "does not fit 4 Mbps" because a frame peaked at 96.4% of the sustained rate. **That was the wrong comparison**, and the user was right to push back. It measured instantaneous frame demand against a sustained rate as if they had to match frame-by-frame. They do not: the disk keeps filling *during* the frame, and any shortfall is absorbed by a ring buffer. The correct test is whether **cumulative** demand ever outruns cumulative supply. Simulated at a constant 488 KB/s fill, 12 fps, using the real per-frame sizes: | scene | lam | mean KB/s | worst frame | **required prefill** | stall tolerance @256KB | |---|---|---|---|---|---| | 00010 | 10 | 198.9 | 23.67 KB | **0.0 KB** | 15.4 frames | | 00020 | 10 | 255.9 | 39.23 KB | **0.0 KB** | 12.0 frames | | 00146 | 10 | **313.0** | 42.10 KB | **0.0 KB** | 9.8 frames | | 00181 | 10 | 211.1 | 25.25 KB | **0.0 KB** | 14.6 frames | | (all) | 60 | 95-116 | 11-19 KB | **0.0 KB** | 26-32 frames | Fill delivers **40.69 KB per frame time**. Only one measured frame exceeds that (00146, 42.10 KB) and it is recovered by the following frame. **No scene needs any prefill at all**, and a 256 KB buffer — 12.5% of RAM — carries ~1 second of stall tolerance at `lam=10`, which is orders of magnitude more than an SD-backed seek requires. `scsi` at `lam=10` stands. The hardest scene sampled (00146) runs 313 KB/s mean, 64% of the pipe, with zero underrun risk. ### Why SD-backed changes the sizing rule The deployment target is BlueSCSI / SCSI2SD, not a period spinning drive. That was noted as a caveat in 5 but its consequence was not carried through: - The sustained rate is a **bus-limited constant**, not an average over variable seek latency. There is no long tail to leave margin for. - Seek is ~microseconds, so branch-point stalls are a non-issue against a buffer measured in whole seconds. - Therefore we can size much closer to the ceiling than spinning-disk practice would suggest. Conservative margins here are cargo-culted from a constraint this deployment does not have. **The SASI/SCSI split is about BUS PROTOCOL, not media.** SD emulation removes seek latency from both, but a SASI bus is still slower than a SCSI one. Two profiles remain the right design; both are now predictable constants rather than distributions. ### What rate control is actually for now Its value drops from load-bearing to **insurance**. Intra-scene peaks are a non-problem. But we have measured **4 clips of 1.2-1.7s** out of 224 streams, and 00146 already runs 23% hotter than 00020. A sustained action sequence could plausibly exceed the pipe where a 1.7s clip does not. Rate control gives a *deterministic ceiling* across content we have not measured yet — which was the original reason for choosing VQ over a lossless delta in the first place. Still worth wiring in. No longer a blocker for shipping `scsi` at `lam=10`. ## 22. The display path, measured — first real frame on the X68000 Everything before this section was Python-side or a headless `-video none` run. This is the first time pixels reached an emulated X68000 screen, and it produced four hardware facts and one blocker that no amount of reasoning would have found. **Scope — read this before quoting the result.** The X68000's *video* hardware did the rendering: CRTC, GVRAM page decoding and the 15-bit+I palette lookup are all genuinely emulated, which is why the output is bit-exact against the hardware's colour math. But the pixels were written into GVRAM by a MAME Lua script calling `SP:write_u16()` — the host poking emulated memory. **No 68000 instruction was executed to draw this frame.** The equivalent is proving a framebuffer works by writing to it from a debugger. It says the display path is correct; it says *nothing* about whether the 68000 can fill that framebuffer in time. Lua writes cost zero 68000 cycles, so the 38% full-frame blit estimate that the entire CPU budget rests on remains completely unvalidated. That is next step (2), the decoder skeleton, and it is untouched. Reproduce: ``` python3 tools/bench/prep_frame.py tmp/frame.bin 0 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 -snapview native -seconds_to_run 6 ``` ### 22.1 The blocker: CRTC R20 bit 11 hides the graphics layer The IPL leaves **CRTC R20 (`$E80028`) = `0x0B16`**. Bit 11 is *"G-VRAM set to buffer"*, and MAME's `x68k_v.cpp` bails out of `draw_gfx()` on it outright: ```c if (m_crtc->gfx_layer_buffer()) // if graphic layers are set to buffer, they aren't visible return false; // x68k_crtc.h: bool gfx_layer_buffer() const { return BIT(m_reg[20], 11); } ``` While that bit is set, GVRAM writes still land and read back correctly — which is exactly what makes it so misleading. Six separate attempts at the video controller (`$E82400/$E82500/$E82600`) rendered black with every register reading back the intended value. **The video controller was never the problem.** `R20` bits 9-8 select the colour setup, and this determines how `$C00000` is decoded: `0x0300` = 65536c (16 bits/word), `0x0100` = 256c (low byte), `0x0000` = 16c (4 bits). Set `R20 = 0x0116` for our mode. ### 22.2 Monitor contrast: the IPL leaves it at 14, not 15 `$E8E001` bits 3-0 are monitor contrast; MAME does `m_screen->set_brightness(contrast * 0x11)`. The IPL leaves it at **14**, which scales all output to 14/15 = 93.3%. Every rendered colour came out ~7% dark until this was set to 15. **The player must write `$E8E001 = 15` at startup.** Contrast `0` blanks the screen entirely (`x68k_v.cpp:661`) — that is the cheap fade-to-black for scene transitions, no palette animation required. ### 22.3 Palette format CONFIRMED (was previously an assumption) `PALETTE(config, m_gfxpalette).set_format(2, &x68k_state::GGGGGRRRRRBBBBBI, 256)` ``` bit 15..11 10..6 5..1 0 GGGGG RRRRR BBBBB I <- I is a shared LSB for all three channels ``` Expansion is `pal6bit((field << 1) | I)`, i.e. `(v << 2) | (v >> 4)`. With contrast at 15, **all 256 entries render exactly as this predicts** — the frame is pixel-identical, not merely close. GVRAM line stride is confirmed as 512 words = 1024 bytes, matching `HARDWARE.md`. ### 22.4 A new quality ceiling: the 15-bit palette costs 38.88 dB > **Superseded by 23.3.** The 38.88 dB figure assumed the shared LSB `I` is > always 1. Choosing `I` per palette entry by minimum error lifts the ceiling to > **40.81 dB** on the same frame. The conclusion below ("`scsi` is close to > display-transparent") is therefore weaker than stated — there is ~2 dB more > headroom than this section claims. Section 3 called the 256-colour palettised frame "the real quality ceiling". That was measured in 24-bit RGB. The hardware palette only stores 5 bits per channel plus a shared LSB, so there is a **second** quantisation below it: | stage | PSNR | |---|---| | 24-bit palettised source -> X68000 15-bit+I display | **38.88 dB** | | `scsi` profile codec error (00020, FINDINGS 15) | 39.4 dB | The codec's error at `scsi` is **the same order as the display's own error**. On real hardware `scsi` is therefore close to display-transparent, and pushing `lam` below 10 buys quality the monitor cannot show. This bounds how much the `scsi` profile is worth raising — it does not change the profiles themselves. Caveat: measured on one frame (00020 f0001). It is a property of the palette, not the content, so it should generalise, but it has not been checked across scenes. ### 22.5 Why the first frame appears twice GVRAM is a 512-pixel-wide page while the IPL's CRTC is still in its 768-wide text timing, so the layer repeats at exactly x=512. This is correct hardware behaviour, not a bug. The player sets its own CRTC mode and the wrap disappears. No CRTC timing table has been written yet — the harness deliberately keeps the IPL's timing so that no invented CRTC values are in play. ## 23. A real CRTC mode: 256x192 inside 256x256 (session 4) Session 3's harness borrowed the IPL's 768x512 text timing and invented no CRTC values, which is why the frame repeated at x=512 (22.5). This session derived a real 256x256 mode table from MAME 0.277 source and verified it by snapshot. Table: `tools/bench/crtc_mode.lua`. Regression test: `tools/bench/verify_frame256.py`. ![256x256 mode](images/x68k_256x256_mode.png) *Left: palettised source. Right: the emulated X68000's native 256x512 raster — 256 dots wide, 512 scanlines carrying 256 double-scanned graphics rows, with the 192-row picture letterboxed in true black.* ### 23.1 The table, and why it needed no guessing `refresh_mode()` in `x68k_crtc.cpp` selects the dot clock as `(reg20 bit4 ? 69.55199MHz : 38.86363MHz) / div`, with `div` from a ladder keyed on `reg20 & 0x1f`. Three entries matter: | `reg20 & 0x1f` | div | dot clock | mode | |---|---|---|---| | `0x16` | 2 | 34.776 MHz | IPL's 768 wide, 31.5kHz | | `0x11` | 3 | 23.184 MHz | 512 wide, 31.5kHz | | `0x10` | 6 | 11.592 MHz | **256 wide, 31.5kHz, graphics double-scanned** | The IPL's `R00 = 137` gives `m_htotal = (137+1)*8 = 1104` dots, and `34.776e6 / 1104 = 31500.0 Hz` **exactly**. Holding the same line rate at div 6 needs `11.592e6 / 31500 = 368` dots `= 46` chars, so `R00 = 45`. `368 = 1104/3` exactly, so every horizontal register is the 768-mode value divided by three, and the active window divides without remainder: `(124-28)/3 = 32` chars `= 256` dots. **No horizontal value was recalled or estimated.** Only the blanking split rounds: the 768 mode is sync/back/front = 14/14/14 chars, `/3 = 4.67` each, and the closest integer triple summing to `46-32 = 14` is 5/5/4. | reg | value | meaning | |---|---|---| | R00 | 45 | H total, 46 chars = 368 dots -> 31500.0 Hz | | R01 | 5 | H sync end (3.45 us) | | R02 | 10 | H display begin -> `hbegin = 81` | | R03 | 42 | H display end -> `hend = 336`; inclusive width `336-81+1 = 256` | | R04 | 567 | V total, 568 scanlines -> 55.46 Hz | | R05 | 5 | V sync end | | R06 | 40 | V display begin -> `vbegin = 41` | | R07 | 552 | V display end -> 512 scanlines = 256 double-scanned rows | | R08 | 27 | H sync adjust (MAME stores it and never reads it) | | R20 | `0x0110` | display (not buffer), 256-colour, 31.5kHz, 256x256 | **The vertical registers are NOT halved**, which is the one thing that looks wrong and is not. The CRTC still generates a 568-line raster; "256 lines" is a graphics-layer double-scan applied in `draw_gfx()` (`x68k_v.cpp:401`), not a change to the raster. Halving R04 would ask the monitor for 110 Hz. MAME emits a `visarea larger then reg[20]` logerror for this; it is cosmetic. Total blanking time is identical to the 768 mode (112 dots at 11.592 MHz = 336 dots at 34.776 MHz = 9.66 us), which is the property a real monitor cares about — so this table should be safe on hardware, though that is untested. ### 23.2 MAME's double-scan is phase-shifted by one raster line `get_gfx_pixel()` indexes `m_gfxbitmap.pix(scanline / divisor, pixel)` using the **absolute** scanline, and `vbegin = 41` is odd. So in the native 256x512 snapshot the identical row pairs are `(1,2), (3,4), ...` and row 0 is a lone half-line. Even rows are graphics rows 0..255. This cost a false failure before it was understood; the regression test now asserts the shifted pairing explicitly so a change in MAME's behaviour is visible rather than confusing. ### 23.3 The shared LSB `I` must be chosen per palette entry — worth 1.96 dB Session 3's `pack()` hardcoded `I = 1`. That is not free: `I` is shared by all three channels and each renders as `pal6bit((field << 1) | I)`, so with `I = 1` the darkest reachable value is `pal6bit(1) = 4`, and **true black does not exist**. Choosing `I` per entry to minimise summed squared error over R,G,B: | rule | ceiling vs 24-bit palettised (00020 f0001) | entries with I=0 | |---|---|---| | `I = 1` fixed (session 3) | 38.85 dB | 0 | | `I` per entry, min squared error | **40.81 dB** | 102 / 256 | Nearly **2 dB for free**, and 102 of 256 entries want `I = 0` — this is not a corner case. It supersedes the ceiling in 22.4 and means `scsi` has about 2 dB more headroom before it hits the display than that section claimed. The encoder does not yet do this. `tools/encoder/` still emits 24-bit palettes and the packing happens Lua-side; whatever eventually writes X68000 palette words must use the per-entry rule. ### 23.4 Letterboxing requires a reserved black palette entry GVRAM cleared to zero displays **palette entry 0**, and a free mediancut palette puts a real image colour there — on 00020 f0001 it was `(206,192,176)`, used by 210 image pixels, so it cannot simply be repurposed. A 256x192 picture in a 256x256 mode has 64 blank rows, so the palette must be built with **255 colours plus a reserved black at index 0** (`prep_frame.py --reserve-black`). Combined with 23.3, entry 0 also needs `I = 0` or the bars sit at RGB (4,4,4). Cost: one of 256 entries. Measured quality effect: none visible — the ceiling figure in 23.3 is already measured on the 255-colour palette. ### 23.5 What is still not proven GVRAM was again filled from Lua. **No 68000 instruction has drawn a pixel yet**, and the 38% full-frame blit estimate underpinning the CPU budget remains unvalidated. What this section adds is that the *target mode* is now real, so 68000 code has a defined geometry to write into: 256 words per visible row, a 1024-byte line stride, and rows 32..223 of a 256-row page. --- ## 24. The blit, measured on the 68000 — the 38% estimate was wrong (session 5) **The first 68000 instructions in this project to draw a pixel.** Everything in 22 and 23 was GVRAM filled from Lua, which costs zero 68000 cycles. This section replaces the estimate that the whole CPU budget rested on with a measurement. Harness: `tools/bench/blit.s` + `tools/bench/blit.lua`. Four variants of a full-frame 256x192 paint, each looped to run ~4 emulated seconds, timed from `machine.time` between two flag writes by the 68000 itself. | variant | what it does | cycles/frame | % of a 12fps frame | |---|---|---:|---:| | **V1** | `movem.l` blit from a word-expanded RAM frame (96KB read + 96KB write) | **446,286** | **53.6%** | | V2 | naive `move.b`/`move.w` per pixel from a byte source | 1,284,174 | 154.1% | | **V3** | write-only floor — registers preloaded, no source read at all | **225,789** | **27.1%** | | **V4** | the same 96KB of writes issued in **4x4 block order** | **637,971** | **76.6%** | The 12fps budget is 833,333 cycles (10.0 MHz confirmed from `x68k.cpp:1133`, `40_MHz_XTAL / 4`). ### 24.1 The numbers are cross-checked against hand-derived cycle counts Every variant was predicted from the MC68000 timing tables *before* the run (`MOVEM.L` M->R `(An)+` = 12+8n, `(d16,An)` = 16+8n; R->M `(An)` = 8+8n, `(d16,An)` = 12+8n) and then measured: | | predicted | measured | error | |---|---:|---:|---:| | V1 | 447,744 | 446,286 | 0.33% | | V2 | 1,284,096 | 1,284,174 | 0.006% | | V3 | 225,792 | 225,789 | 0.001% | | V4 | 640,704 | 637,971 | 0.43% | This agreement is the point. A MAME timing number on its own would be worth little given how many false-good results this project has produced (FINDINGS 4); two independent derivations landing within half a percent is worth something. The residual error is the frame-granularity of the measurement — Lua gets no cycle counter (`luaengine.cpp` exposes `machine.time` and nothing from `device_execute_interface`), so timing resolution is one video frame, 18.03 ms. ### 24.2 SCOPE: these are instruction cycles, and therefore a LOWER BOUND MAME's `gvram_w`/`gvram_r` (`x68k_crtc.cpp:501,595`) contain **no timing at all** — no wait states, no `adjust_icount`. GVRAM in MAME is as fast as main RAM. Real X68000 GVRAM stalls the CPU on access, so every figure above is a floor, not a prediction. **Do not quote these as hardware numbers.** Interrupts were masked (`SR = $2700`) so the IPL's timer and VBL handlers could not steal cycles into the measurement; a real player will take interrupts on top. ### 24.3 The 38% estimate is dead — a full-frame blit is 53.6% The realistic "decode into a RAM frame, then blit it" design costs **53.6% of the frame budget before decoding a single block**, and that is the zero-wait- state floor. The estimate the CPU budget has been carrying since session 1 was 38%. It was optimistic by 41%. The cause is visible in the V1/V3 gap: **reading the source frame is exactly half the total cost** (221,952 of 446,286 cycles). The 68000 pays 8 cycles per longword read and 8 per longword written, and in 256-colour mode a pixel occupies a whole word of address space, so a frame is 96KB of traffic in each direction rather than 48KB. ### 24.4 The high byte of every GVRAM write is discarded — confirmed from source `gvram_w` case `0x0100` writes `data & 0x00ff` with `mem_mask 0x00ff`. So in 256-colour mode the CPU cannot pack two pixels into one word, and the odd bytes of a word-expanded source frame never need clearing — V1 exploits this by 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 stride destroys the `movem.l` burst, 208 cycles per block against a theoretical best of ~150. But a decoder never writes every block: SKIP blocks cost **nothing at all**, and the previous frame is already sitting in GVRAM, so **no RAM reference frame is needed for SKIP to work**. So the two designs scale differently: - **compose-in-RAM then blit** — flat 53.6%, independent of how much changed - **decode-direct-to-GVRAM** — 76.6% x (fraction of non-SKIP blocks) **They cross at 70% of blocks changed.** Below that, writing straight into GVRAM wins, and it also drops the 96KB RAM reference frame entirely. Above it, the flat blit wins. **This makes the non-SKIP block fraction the single most important unmeasured number in the project.** It is already computable from the encoder — it is a by-product of the mode decision in `vq_hybrid.py` — and it has never been reported. Measure it before writing any decoder inner loop, because it selects which inner loop to write. ### 24.6 The frame the 68000 drew is pixel-exact V1's output was snapshotted and passes `verify_frame256.py` unchanged: `256x512 native, double-scan exact, active 256x192 pixel-exact, letterbox true black`, 40.81 dB. So 68000 code drives the mode of FINDINGS 23 correctly, and 23.5 is now closed. --- ## 25. The sustained action sequence, found and measured (session 5) STATUS has carried "a *sustained* action sequence is the one thing that could still break the bitrate" as the open risk since session 2. Every clip measured before this was 1.2-1.7 s. This section closes it: **it does break the profiles, though not the bus.** ### 25.1 The two largest streams on the disc are not game footage A survey that sorts 224 streams by size and encodes the biggest would have measured **live action**: | stream | size | what it actually is | |---|---:|---| | 00216 | 3777 MB | the feature with a **burned-in picture-in-picture commentary** | | 00215 | 3475 MB | the commentary itself, full-screen live action | | **00223** | **1802 MB** | **clean animation, 9.4 min — the one to use** | The PiP in 00216 is burned into video stream 0, not a selectable secondary stream, so there is no ffmpeg flag that recovers a clean frame from it. This extends FINDINGS 13's menu-vs-content warning: the classification needed is **content / menu / bonus**, and bonus material is the one that looks most like content by every cheap metric (size, duration, bitrate). ### 25.2 Picking the worst window by measurement, not by eye `tools/analysis/07_motion_survey.py` scans a whole stream at 96x72 and reports the highest-mean sliding window of inter-frame absolute difference. On 00223: ``` 6793 frames @12fps = 566.1s motion energy mean 9.40 median 5.60 p90 21.70 max 112.39 hottest sustained 10s window: t = 539.4s (2.01x stream mean) quietest 10s window: t = 144.2s (0.19x stream mean) ``` The 10.6x spread between the quietest and hottest sustained windows is the whole argument for not sampling clips by hand. `t = 539.4s` is the Singe endgame. ### 25.3 Both profiles overshoot on that window — rate control is now required Encoding those 120 frames at the shipping profiles, with the fixed `lam` the CLI currently uses: | profile | target | measured | overshoot | PSNR | palette ceiling | |---|---:|---:|---:|---:|---:| | `sasi` | 110 KB/s | **129.6 KB/s** | **+18%** | 27.82 dB | 31.33 dB | | `scsi` | 280 KB/s | **373.8 KB/s** | **+34%** | 30.81 dB | 31.33 dB | | *(00020 baseline, `sasi`)* | 110 KB/s | 108.0 KB/s | -2% | 36.94 dB | 39.90 dB | **This reclassifies rate control from insurance to a requirement.** STATUS has had "wire rate control into `encode.py`" at priority 3-4 since session 2 with the note "no longer a blocker (FINDINGS 21)". That was true of the clips measured then. It is not true of this one. `ratectl.encode_rate_controlled()` already exists and builds a per-frame lam ladder; it has simply never been hooked up. Note what did **not** break: 373.8 + 7.8 = 381.6 KB/s is still under the 488 KB/s working figure, so FINDINGS 21's ring-buffer conclusion survives — but at 78% of the pipe sustained over ten seconds rather than the comfortable margin implied by 1.7 s clips. ### 25.4 The palette ceiling is content-dependent, and on hard content it binds The 256-colour scene palette costs **31.33 dB** on this window against **39.90 dB** on 00020 — 8.6 dB worse. Fire, lava and smoke gradients are exactly what a 256-entry mediancut palette handles worst. This inverts an assumption the project has been carrying. FINDINGS 23.3 put the X68000 display ceiling at 40.81 dB and treated it as comfortably clear of the codec's own error. On this content the **scene palette (31.33 dB), not the display hardware (40.81 dB), is the binding constraint** — and `scsi` is already within 0.51 dB of it. Spending bits to close that last half-dB is spending them against a ceiling that is not the display's. ### 25.5 `scsi` collapses to RAW under stress Mode distribution on this window is qualitatively different from anything measured before: | profile | SKIP | V1 | V4 | RAW | |---|---:|---:|---:|---:| | `sasi` (lam=60) | 45.6% | 16.3% | 24.2% | 13.9% | | `scsi` (lam=10) | 26.2% | 5.5% | 7.1% | **61.2%** | | *00020, `sasi`* | 46.9% | 24.1% | 17.8% | 11.2% | At `lam=10` the rate-distortion decision finds literal pixels cheaper than any codeword for 61% of blocks — the codebooks are simply not describing this content. That is the mechanism behind the +34% overshoot in 25.3, and it is a 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 | |---|---:|---:|---:| | `sasi`, Singe window | 48.4% | 82.8% | 36 / 120 (30%) | | `scsi`, Singe window | 70.8% | 92.4% | 64 / 120 (53%) | | `sasi`, 00020 | 54.0% | 88.8% | 3 / 14 (21%) | Neither path wins outright: **30-53% of frames want the flat blit and the rest want direct-to-GVRAM.** A player that implements both and picks per frame — the mode headers are parsed before any pixel is written, so the count is free — pays a median of **37.0%** of the frame budget and is capped at **53.6%**. A player that implements only direct-to-GVRAM pays up to 76.6% and would miss frames on the scene cuts. So the answer to 24.5 is "both", and the selection is a one-line comparison against a block count the decoder already has in hand. ### 25.7 What this does not measure One 10 s window of one stream, at fixed lam, with `_paint` still a Python loop. The full-disc survey is still not done, and the numbers above are the *worst* window rather than a distribution over content. What has changed is that the worst case is now a measurement rather than a worry. --- ## 26. Rate control is unsound as written — found before wiring it up (session 5) FINDINGS 25.3 promoted rate control from insurance to a requirement. Reading `ratectl.py` before wiring it into `encode.py` turned up a correctness bug that would have produced exactly the kind of plausible-looking wrong result this project keeps catching (FINDINGS 4, 9, 14, 18). ### 26.1 The lam ladder desynchronises the encoder from the decoder `H.encode()` is **temporally recursive**: SKIP blocks are copied from the previous *reconstruction*, and `prev = out` closes the loop (`vq_hybrid.py:84-109`). A frame's output therefore depends on every frame before it in that same run. `encode_rate_controlled()` runs `H.encode()` once per lam over the **whole sequence**, building a ladder of independent temporal chains, then picks each frame from whichever rung fits the budget. When frame *f* comes from rung *i* and frame *f-1* was emitted from rung *j != i*, the SKIP blocks in *f* reference a reconstruction **the decoder never saw**. Measured on the Singe window (`tools/analysis/09_ratectl_drift.py`, 120 frames, 5 rungs, target 110 KB/s): | | | |---|---| | rung switches | **67** over 120 frames | | frames whose emitted output differs from what the encoder recorded | **111 / 120** | | worst frame | **21,339 px = 43.4% of the frame** | | encoder-vs-decoder agreement, worst frame | 27.1 dB | | reported PSNR overstatement | **0.36 dB** | The 0.36 dB is the least interesting number here. The encoder is reporting quality for a reconstruction that will never exist, and 43% of a frame differing is a visible artefact whatever the mean says. **The fix is structural, not a tuning change:** `H.encode()` must become frame-drivable — take `prev` and one lam, return one frame — so rate control can feed back the frame it actually emitted. The current whole-sequence signature is what makes the ladder tempting in the first place. ### 26.2 The ladder spans 250x past the shippable range `lam_hi=2e5`, but FINDINGS 15 puts the quality cliff between lam=800 and lam=2000 and says do not ship past lam~800. Every rung above ~800 is unshippable, so a frame that only fits at lam=9457 has not been rate-controlled, it has been destroyed. Cap `lam_hi` at 800 and let a frame that cannot fit overrun the bucket — a visible overrun is a better failure than silent garbage. ### 26.3 The ladder is far too coarse where it matters With `steps=5` the geomspace lands on 1 / 21 / 447 / 9457 / 200000, and **only two rungs were ever chosen**. The budget is 8,721 B/frame; the two straddling rungs deliver 23,183 B (lam=21) and 3,071 B (lam=447) — a **7.5x** gap across the operating point. Rate control cannot land near a target it has to jump over. The module docstring already describes the right approach — *"per frame we binary-search lam to land inside a byte budget"* — but the implementation is a fixed precomputed ladder. Doc and code disagree; the doc is correct. ### 26.4 What does work The leaky bucket lands the mean where it should: **109.1 KB/s against a 110 target**, with 32% of frames over the per-frame budget and banked by the bucket. That mechanism is sound and worth keeping. It is the per-frame lam *selection* underneath it that needs rebuilding, not the bucket. ### 26.5 Cost note before starting Each rung is a full-sequence encode and `_paint` is still a Python per-block loop, so a 5-rung run over 120 frames takes minutes. **Vectorise `_paint` first** — it is already on the list for the full-disc survey and it makes the rate-control work practical rather than merely faster. ## 27. Rate control, rebuilt and wired in (session 6) FINDINGS 26 stopped the session-5 rate controller before it shipped: it picked frames out of independently-encoded whole-sequence runs, so 111 of 120 frames referenced reconstructions the decoder would never see. The fix was structural, as 26.1 said it had to be. It is now wired into `encode.py` and **on by default** for a profile. ### 27.1 The encoder is frame-drivable, and the drift is zero by construction `vq_hybrid` now exposes one frame at a time — `frame_ctx(m, f, prev)` / `decide(ctx, lam)` / `paint(m, ctx, mode)` — and `encode()` is a thin loop over that API. Rate control drives the same three calls and feeds back **the frame it actually emitted** as the next frame's `prev`. There is no ladder to pick from, so the desync has no way to occur. `tools/analysis/09_ratectl_drift.py`, unchanged in what it asserts: | | session 5 | session 6 | |---|---|---| | frames whose emitted output differs from what the encoder recorded | 111 / 120 | **0 / 120** | | worst frame | 21,339 px (43.4%) | **0 px** | | reported PSNR overstatement | 0.36 dB | **0.00 dB** | This is the harder case for that test on purpose: it runs with `lam_lo=1.0`, so lam moves on 117 of 119 frame boundaries. Under the old ladder, 67 rung switches were enough to corrupt 111 frames. ### 27.2 Both overshoots are closed, and they cost under 1 dB The Singe window (FINDINGS 25.3), which is the worst sustained window on the disc. Totals include the 7.8 KB/s ADPCM allowance: | profile | target | fixed lam (session 5) | rate-controlled | quality cost | |---|---|---|---|---| | `sasi` | 110 KB/s | 137.4 KB/s (**+25%**) | **109.5 KB/s** | 27.82 → 27.22 dB (−0.60) | | `scsi` | 280 KB/s | 381.6 KB/s (**+36%**) | **280.0 KB/s** | 30.81 → 29.90 dB (−0.91) | Zero frames hit the lam=800 cliff at either profile, so nothing was destroyed to get there (26.2's failure mode did not trigger). `sasi` needed lam to reach 183 at worst against a floor of 60; `scsi` reached 58.7 against 10. The controller is working an order of magnitude below the cliff, which is where the search range being capped at 800 rather than 2e5 stops mattering at all — and that is the point: a range that never needs its top is a range you can trust. `scsi` still sits **1.43 dB** from the scene palette ceiling of 31.33 dB (FINDINGS 25.4), against 0.51 dB before. The ceiling, not the codec, is still what bounds this content. The percentages differ from 25.3's +18%/+34% because those compared video payload against the total target; the table above compares like with like (total against total). The payload figures are unchanged: 129.6 and 373.8 KB/s. ### 27.3 Rate control makes the display path cheaper, not dearer The decoder-architecture numbers of FINDINGS 25.6 were measured on the fixed-lam encoder. Re-measured under rate control, on the same window, with the player picking the cheaper of compose-then-blit and direct-to-GVRAM per frame: | profile | median display cost | frames above the 70% crossover | |---|---|---| | `sasi` fixed → RC | 37.0% → **36.6%** | 30.0% → 26.7% | | `scsi` fixed → RC | 53.6% → **47.1%** | 53.3% → 35.8% | Raising lam moves blocks to SKIP and V1, which is fewer blocks to write. The "implement both paths, pick per frame" conclusion is unaffected and the cap is still 53.6%. ### 27.4 The quality floor barely matters; the prefill matters, wrongly Two knobs were measured rather than guessed. **`--rc-floor`** decides whether a quiet frame may spend more than the fixed-lam profile would. On the Singe window it is worth nothing — 109.5 vs 110.0 KB/s and **0.00 dB** — because no frame on that window is quiet enough for the bucket to saturate. The default is `profile` (never spend more than session 5 would), so rate control cannot regress content that already fits. **`--prefill`** models how full the player's buffer is at scene start. It is tempting and it is a trap, so it defaults to 0: | clip | prefill 0.0 | 0.5 | 1.0 | target | |---|---|---|---|---| | Singe, 120 fr, `sasi` | 109.5 | 112.9 | **116.3** | 110 | | Singe, 120 fr, `scsi` | 280.0 | 289.1 | **298.2** | 280 | | 00020, 14 fr, `sasi` | 92.0 | **115.8** | **115.8** | 110 | | 00020, 14 fr, `scsi` | 224.8 | **255.9** | **255.9** | 280 | (`scsi` on 00020 is the one cell where prefill looks harmless: the clip fits under 280 either way. That is the content being easy, not the knob being safe.) Prefill buys a permission to overshoot of exactly `bucket / nframes`. At 8 frames of bucket over 120 frames that is 6.2% — measured — and on a 14-frame clip the bucket is larger than the clip, so rate control switches itself off and reproduces fixed-lam exactly (lam never leaves its floor: min = median = max = 60). **A prefill that makes a target look met has disabled the controller.** ### 27.5 The 00020 undershoot is a clip-length artefact, not a bug At prefill 0 the 14-frame 00020 clip lands at 92.0 KB/s against a 110 ceiling — 0.66 dB given away for nothing. That is the leaky bucket's startup transient: the first `bucket_frames` frames cannot draw on a bank they have not accumulated. It is bounded by `bucket / nframes`, so it is 6% on a 10-second window and 20% on a 1.2-second one. The lesson is the one FINDINGS 25.3 already taught in a different costume: **a 1.2-second clip cannot be used to judge rate control.** Real scenes are tens of seconds. Do not tune the bucket against 00020. Worth recording separately: fixed-lam `sasi` on 00020 delivers 115.8 KB/s — the supposedly easy clip was **already 5% over its target**, which nothing had noticed because the profile table quotes its PSNR and not its bitrate. ### 27.6 FINDINGS 26.5's cost premise was wrong in both halves 26.5 said a rate-control experiment was minutes because `_paint` is a Python per-block loop, and told the next session to vectorise it first. Vectorising it was correct and it is **17.1x faster**, but it was never the bottleneck, and the ladder was never minutes. Measured per frame, 256x192: | | ms | |---|---| | `VQ.assign` x2 — codeword search | **22.83** | | SKIP error against `prev` | 1.40 | | `decide` — argmin at one lam | 0.06 | | `paint`, vectorised | 0.29 | | `paint`, old per-block loop | 4.93 | `_paint` was 14% of a frame. A 5-rung ladder over 120 frames was ~18 s of encoding, not minutes — the "few minutes" in the drift test's docstring was `H.build`'s k-means (51 s), which no amount of vectorising `_paint` would have touched. What actually makes per-frame rate control affordable is that `VQ.assign`'s output depends on **neither `lam` nor `prev`**, so it is computed once per frame and a lam search only re-runs the 0.06 ms argmin: | | | |---|---| | 12-step per-frame lam search, 120 frames, symbols cached | **0.31 s** | | the same search by re-running whole-sequence encodes | 49.10 s | That is a 158x difference, and it is the reason the controller can afford a real 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. ### 28.7 The profiles are an I/O axis; the CPU limit is the clock `sasi` and `scsi` are two points on one rate-distortion curve, chosen against **disk bandwidth**. They say nothing about CPU, and the locked target CPU is a stock 10MHz 68000 for both. So both have to fit the same 833,333 cycles: | | sasi | scsi | |---|---:|---:| | stock / Super, 10 MHz | median 74.4%, **31% of frames miss** | median 94.9%, **42% miss** | | XVI, 16.67 MHz | median 44.6%, 0% miss | median 56.9%, 0% miss | Clocks confirmed from MAME 0.277 `x68k.cpp:1133/1194/1200`: `x68000` and `x68ksupr` are **both** `40_MHz_XTAL/4` = 10 MHz, and only `x68kxvi` is faster at `33.33_MHz_XTAL/2`. The Super has SCSI at 10 MHz, so a faster bus does not imply a faster CPU — the XVI column above is headroom, not a target. `sasi` is the cheaper profile, but choosing it is not a fix: it still misses 31% of frames. The cycle ceiling has to be enforced in the encoder either way. **How much of the miss is the encoder's to fix.** Re-coding every non-SKIP block as V1 — the cheapest mode, quality ignored — is the floor any mode assignment can reach: | | frames that miss | recoverable by re-coding | impossible at 12fps | |---|---:|---:|---:| | `sasi` | 37/120 | 26 | **11** (from 89.8% non-SKIP up) | | `scsi` | 51/120 | 39 | **12** (from 91.9% non-SKIP up) | So a cost-aware mode decision can reach about **three quarters** of the misses. The remaining ~10% of frames are 28.5's ceiling in practice: past ~90% non-SKIP no mode assignment fits, because the blocks have to be drawn at all. Those frames need a structural answer — a late frame at a cut, a cut spread over two frame times, or a lower framerate — not a better encoder. ### 28.8 V4 costs more cycles than RAW, so it is CPU-dominated by it 448.2 against 400.4 cycles. RAW is also pixel-exact where V4 is lossy, so V4's only advantage is that it costs 4 payload bytes instead of 16. **On the CPU axis V4 is strictly dominated**, which inverts the mode preference the byte lagrangian has: an encoder short of cycles but not of bytes should buy RAW wherever it would have bought V4, and gain quality doing it. That escape is only open to the byte-rich profile. `scsi` already spends 41.3% of its blocks on RAW (FINDINGS 25.5 saw it "collapse to RAW under stress" and read that as a failure; on the CPU axis it is the cheap direction). `sasi` at 110 KB/s cannot afford it, so its only lever is V4 -> V1 -> SKIP, every step of which costs quality. **The CPU constraint therefore bites harder on `sasi` in quality terms even though it bites less in cycles.** Caveat: this ordering is a property of *this* decoder, not of the codec. V4's cost is four indexed `movem.l` lookups; pairing sub-block rows into `movem.l d0/d2,(a4)` would save ~16 of 448 cycles, which narrows the gap to RAW without closing it. --- ## 29. Trading bytes for cycles: the bus has 4x the headroom the CPU has (session 7) > **ALSO SUPERSEDED IN PART BY 38.** "The bus has 4x the headroom the CPU has" > is about the SCSI pipe. The 68000's LOCAL bus is a different resource and the > decoder occupies 86.7% of it, so trading cycles for bytes is not free in the > currency that turned out to bind. 29.6's DMAC idea is costed in 39. > > **SUPERSEDED IN PART BY 30, which measured it.** The mode survives and the > conclusion holds, but every number in this section moved: a span costs 43.7 > cycles + 9.152/pixel *only* in an encoder-assisted format (the obvious > decoder is 97.9 + 10.46), spans beat V1 from runs of 4 blocks and not 2, and > the re-priced trade-off is 52.0% median / 10 misses, not 43.0% / 8. Read 30's > tables over 29.3's. 29.5's other three items are still open, and 29.6 stands. > > **STATUS AT THE TIME: DERIVED, NOT MEASURED.** No 68000 had executed a span > decoder. The per-pixel figure it rests on *is* measured (FINDINGS 24 V1) but > at full row width; the per-span overhead was hand-derived. FINDINGS 4 is why > it was labelled and then tested rather than believed. FINDINGS 28 leaves the project CPU-bound while the **bus sits 4x idle**: `sasi` spends 110 KB/s of a 488 KB/s pipe. That asymmetry is exploitable, because the codec was designed when bytes were the scarce thing and every one of its decisions trades cycles to save them. ### 29.1 The decoder pays per changed PIXEL; the disk pays per BYTE Per-pixel costs, all measured: | what | cycles/pixel | source | |---|---:|---| | write-only floor (no source read) | 4.59 | FINDINGS 24 V3 | | **row-linear copy from word-expanded RAM** | **9.08** | FINDINGS 24 V1 | | block-order copy, same bytes | 12.98 | FINDINGS 24 V4 | | V1 codebook block | 18.74 | FINDINGS 28.2 | | RAW, byte literals unpacked to words | 25.03 | FINDINGS 28.2 | | naive per-pixel byte expansion | 26.13 | FINDINGS 24 V2 | Two structural facts fall out. **The 1024-byte stride costs 43%** — the same bytes cost 12.98 cycles/px in 4x4 block order against 9.08 row-linear, because the stride breaks the `movem.l` burst. And **unpacking bytes to words costs more than the write itself**: 25.03 against 9.08. So the two cheapest things a decoder can be handed are *word-expanded* pixels in *row-linear runs* — and both cost bytes on disc, which is what we have. ### 29.2 Codebooks are a byte optimisation that now costs cycles A word-expanded literal 4x4 block, `movem.l (a0)+,d0-d7` straight from the stream buffer into GVRAM, derives to **~240 cycles** — cheaper than V1's measured 299.9, and pixel-exact. V1 is dearer *because* it is compressed: it pays an index decode and an indexed `movem.l` that a literal does not, and then does exactly the same four writes. It buys 31 bytes and spends 60 cycles. **Every codebook mode is CPU-dominated by a literal.** V4 was already dominated by RAW (28.8); with word-expanded literals available, so is V1. The VQ codebook earns its place only while bytes are scarce. ### 29.3 Row-linear literal spans, priced against the real mode maps Replace the per-block escape with a per-row **span**: `(x, count, word-expanded pixels)`, decoded with `movem.l` bursts. A run of L horizontally adjacent dirty blocks becomes 4 spans of 4L pixels, deriving to `4 * (50 + 4L * 9.08)` cycles against `300L` for V1 — **cheaper for any run of 2 blocks or more**, at 32 bytes per block instead of 1. Applied greedily (buy the best cycles-saved-per-byte until the bus budget is gone) to the *unchanged* mode maps of the `sasi` Singe window: | | today | + literal spans | |---|---:|---:| | median frame | 74.4% | **43.0%** | | p90 frame | 115.1% | **83.6%** | | worst frame | 136.2% | **106.2%** | | frames missing the budget | **37/120** | **8/120** | | bitrate | 101.7 KB/s | 453.2 KB/s (bus 488) | And the fit is structural rather than lucky: **spans get cheaper exactly where blocks get expensive.** A span amortises its overhead over a long run, and long runs are what a high-change frame is made of. The frames that miss today are the frames spans help most. ### 29.4 This reopens 28.5, which said a scene cut cannot fit 28.5 concluded that no mode assignment fits a 100%-changed frame at 12fps, because the cheapest full redraw available — all-V1 — is 110.5%. That was true of *the mode set the codec has*. Adding a byte-expensive, cycle-cheap mode changes the arithmetic: mixing a fraction `x` of the frame as spans against V1 for the rest, - CPU needs `x >= 0.19` - the 40,977 B/frame bus budget allows `x <= 0.39` **The interval is not empty.** A scene cut fits at 12fps if roughly a quarter to a third of it arrives as word-expanded row-linear literals. 28.5's "structural ceiling" was a ceiling of the bitstream, not of the machine. ### 29.5 What has to be measured before any of this is believed 1. **Span cost on the 68000.** The 50-cycle per-span overhead is derived, and the 9.08 cycles/px is measured at *full row width* with 12-register bursts — a short or oddly-aligned span cannot burst as well, so short spans are flattered here. Extend `tools/bench/blit.s` with a span variant and measure it against run length. **This is the load-bearing number.** 2. **Re-run the ring-buffer simulation at ~450 KB/s.** FINDINGS 21's zero required prefill was established at 110 and 280 KB/s against a 488 KB/s pipe. At 453 the margin is a tenth of what it was, and 21's own caveat was that the test is cumulative — it needs redoing, not extrapolating. 3. **Confirm the 4 Mbps figure**, which is user-supplied with no recorded provenance and which this design would run at 93% of. It has been a "would be nice" since session 1; a design that leans on it makes it load-bearing. 4. **Confirm DMA, not PIO** (STATUS priority 5). At 453 KB/s a PIO fallback puts the transfer cost on the CPU we are trying to relieve. Cheapest check available and now the most consequential. ### 29.6 The other lever, not yet costed: let the DMAC do the copy The X68000 has an HD63450 DMAC (4 channels, `x68k.cpp:1046`). Channel 3 is ADPCM — confirmed, `adpcm_drq_tick` asserts `drq3_w` — but memory-to-memory transfer on a free channel would take the GVRAM copy off the CPU entirely, leaving it only the parsing. This is the one idea here that could move the budget without spending a single extra byte. It cannot be settled in MAME: like the SCSI/SASI devices (BENCHMARK.md), the HD63450 is a functional model, so a timing number out of it would measure the emulator's scheduler. It needs hand-derivation against the datasheet plus real hardware — the same three-tier approach the disk benchmark already documents. ## 30. The span, measured: the mode survives, and it is an encoder format (session 8) FINDINGS 29 priced a new decoder mode at `4 * (50 + 4L*9.08)` cycles and marked the whole section DERIVED. This is the measurement. `tools/bench/blit.s` gained two span variants, `tools/bench/prep_spans.py` generates one stream per span length, `tools/bench/span.lua` times them, and `tools/bench/span.sh` runs the lot, and the whole thing takes about 25 seconds. Same scope as every 68000 figure since FINDINGS 24: instruction cycles against MAME's zero-wait-state GVRAM, interrupts masked. A **lower bound**, not a prediction. ### 30.1 What was measured Twelve `v5` configs and eleven `v6` configs, each cutting the **same** 256x192 frame into spans of a different length, so the work differs only in how finely it is cut. Regressing `cycles = A*spans + B*pixels` over a set reads the per-span overhead and the per-pixel cost straight off. Every config draws the whole picture, the picture is cleared before each run and snapshotted after, and all 23 snapshots are checked pixel-exact by `tools/bench/verify_frame256.py`. A config cannot time fast by writing nothing. | | per span | per pixel | fit error | |---|---:|---:|---:| | **v5** — decoder handed `(x, npix)`, works the copy out | **97.9** | **10.459** | ±1.4%, and only on spans that are a whole number of bursts | | **v6** — encoder hands it an address and a jump | **43.7** | **9.152** | **±0.3% over all 11 lengths** | | *29's assumption* | *50.0* | *9.080* | — | **29's arithmetic was right about a format nobody had written yet.** v6 hits it almost exactly; v5 — the obvious decoder, and the one 29 was describing — is 2.24x dearer per span and 14% dearer per pixel. ### 30.2 Why the difference is a format difference, not an optimisation v5's record is `(x, npix)`, so the decoder computes the destination, divides `npix` into 16-pixel bursts, and handles the 0..15 remainder: about 122 cycles of arithmetic and branching per span before a single pixel moves. All of it is known at encode time. v6's record is `{u32 absolute GVRAM address, u16 jump displacement}` and nothing else. The displacement jumps into an unrolled chain of eleven 24-pixel copy units, so a span of any supported length is straight-line code with no loop, no remainder, and no address arithmetic — `move.l (a0)+,a2` / `move.w (a0)+,d0` / `jmp v6ch(pc,d0.w)`, then `movem.l` pairs. GVRAM is at $C00000 on every X68000, so absolute destinations are a legitimate thing to bake into a stream. Two consequences of that format, both cheap: - **Span lengths are multiples of 24 pixels** and a run pads up to it. The padding costs bytes and its own pixels, nothing else, and it is *correct on screen*: a literal span carries true pixels of the current frame, so painting a clean neighbour is a no-op visually. - **A span may overrun the visible 256 pixels of its row by up to 23.** Free: the line stride is 1024 bytes and only the first 512 are displayed, so the overrun lands in the invisible half of the line. ### 30.3 The remainder path is where a short span actually dies v5's cost per span, measured, against its length: | span | 4 px | 8 px | 12 px | 16 px | 20 px | 24 px | 32 px | |---|---:|---:|---:|---:|---:|---:|---:| | cycles/span | 180.3 | 240.9 | 296.3 | 261.8 | 347.7 | 401.9 | 430.7 | | cycles/pixel | 45.08 | 30.11 | 25.46 | **16.36** | 17.65 | 17.27 | **13.46** | A 12-pixel span costs *more* than a 16-pixel one. Everything below the 16-pixel burst width goes through `move.l`/`move.w` at roughly 10 cycles a pixel plus the per-span overhead, and 29's warning that "short spans are flattered" was correct — but the fix is to pad them up to a burst, not to avoid them. v6 has no remainder path at all, which is most of why its fit is linear to 0.3%. ### 30.4 Registers are the reason the per-pixel cost moved FINDINGS 24's 9.08 cycles/pixel came from a fixed blit with 12 registers free for `movem.l` and no live state. A span decoder keeps a stream pointer, a destination and counters live, so v5 can spare only 8 registers per burst — 32 bytes instead of 48 — and pays 10.46 cycles/pixel for it. v6 gets back to 12 registers precisely because the encoder holds the state instead, and lands at 9.152. **The per-pixel figure is a function of how much the decoder has to remember**, which is not something the FINDINGS 24 measurement could have shown. Two smaller results, both cheap and both worth having on the record: - **Odd-`x` alignment is free.** Spans starting at an odd pixel run their bursts at `addr mod 4 == 2` and cost 259.0 cycles/span against 261.8 aligned — inside the timing granularity. The 68000's 16-bit bus does not care, as expected; now it is measured rather than assumed. - **A full-row span is 154 cycles per 4x4 block**, the floor this mode can reach, against V1's measured 299.9. ### 30.5 Re-pricing: the trade holds, and it is smaller `tools/analysis/12_span_tradeoff.py` now runs on measured constants. Same greedy (buy the best cycles-saved-per-byte until the bus budget is gone), same unmodified mode maps, same Singe window: | | today | 29 (derived) | **30 (measured)** | |---|---:|---:|---:| | `sasi` median frame | 74.4% | 43.0% | **52.0%** | | `sasi` worst frame | 136.2% | 106.2% | **108.7%** | | `sasi` frames missing | 37/120 | 8/120 | **10/120** | | `sasi` bitrate | 101.7 KB/s | 453.2 | **448.0 KB/s** | | `scsi` median frame | 94.9% | 69.4% | **74.6%** | | `scsi` frames missing | 51/120 | 18/120 | **25/120** | And the break-even moved. Cycles per 4x4 block in a run of L blocks, v6, with each of the run's 4 spans padded to a whole 24-pixel unit: | L | 1 | 2 | 4 | 8 | 16 | 64 | |---|---:|---:|---:|---:|---:|---:| | cycles/block | 1053 | 527 | **263** | 242 | 176 | 154 | So a run beats all-V1 (299.9) **from L=4 up**, not from L=2 as 29.3 claimed, and runs of 1-3 blocks all cost the same 1053 cycles because they pad to the same single unit. A cost-aware mode decision should not offer a span below 4 blocks at all. ### 30.6 29.4 survives: a scene cut still fits at 12fps Mixing a fraction `x` of a 100%-changed frame as full-row spans against V1 for the rest, on measured costs (154 cycles and 33.4 bytes per block): - CPU needs `x >= 0.196` - the 40,977 B/frame bus budget allows `x <= 0.373` The interval is not empty — narrower than 29.4's 0.19..0.39, same conclusion. FINDINGS 28.5's "a scene cut cannot fit" was a ceiling of the bitstream, not of the machine, and that now rests on a measurement. `12_span_tradeoff.py` prints this arithmetic and will say so if it ever stops being true. ### 30.7 What this does NOT settle The three remaining items of 29.5 are unchanged and are now **more** load-bearing, because the measured design runs at 448 KB/s of a 488 KB/s pipe rather than 453: re-run the ring-buffer simulation at that rate, confirm the 4 Mbps figure's provenance, and confirm DMA rather than PIO. A PIO fallback would put a 448 KB/s transfer back on the CPU this mode exists to relieve. Also unmeasured: **the parse cost of a span-heavy stream**. Every figure here times the copy. The 68000 also has to read the mode map and dispatch: the re-priced `sasi` stream buys 8773 spans across 120 frames, a mean of 73 a frame, and each one's three-instruction dispatch is inside the fitted 43.7 — but the mode-map walk that decides a span exists is not. `decode.s` does not implement spans yet. ## 31. The mode decision can see cycles now, and it costs 0.26 dB (session 8) FINDINGS 28 left the decoder missing 31% of frames at `sasi` and 42% at `scsi` while the mode decision minimised `D + lam*R` — distortion against BYTES — on a machine whose binding budget is CYCLES. This is the second controller. `vq_hybrid.decide(ctx, lam, mu)` now minimises `D + lam*bytes + mu*cycles`, and `ratectl.encode_rate_controlled(cycle_budget=...)` bisects `mu` per frame against 833,333 cycles with the `lam` bisection nested inside it. `tools/analysis/13_cpu_ratectl.py` measures what it costs. ### 31.1 The result Worst sustained window, 120 frames, same targets, same quality floors: | | PSNR | KB/s | CPU median | CPU max | frames missing | |---|---:|---:|---:|---:|---:| | `sasi` bytes only | 27.22 dB | 109.5 | 74.4% | 136.2% | **37/120** | | `sasi` + cycle ceiling | **26.95 dB** | 109.4 | 81.5% | 110.6% | **1/120** | | `scsi` bytes only | 29.90 dB | 280.0 | 94.9% | 146.6% | **51/120** | | `scsi` + cycle ceiling | **29.27 dB** | 278.6 | 99.6% | 110.6% | **1/120** | **36 of 37 misses at `sasi` for 0.26 dB, 50 of 51 at `scsi` for 0.62 dB.** The bitrate does not move: the byte controller still binds, and mu changes *which* modes are bought rather than how many bytes. `sasi` pays less quality than `scsi` because it had less to give up: it was already short of bytes, so the cycle-cheap directions it takes (V4 -> V1, and blocks it can afford to hold) were near where the byte lagrangian already sat. 28.8 predicted the shape of this and got the sign right. Mode mix, `sasi`, bytes-only -> with the ceiling: SKIP 46.4 -> 47.1%, V1 19.8 -> 23.0%, **V4 25.2 -> 20.3%**, RAW 8.5 -> 9.6%. At `scsi` the V4 collapse is dramatic — **15.0 -> 5.3%**, with RAW taking it at 41.3 -> 43.2%, which is 28.8's inversion happening in practice: RAW is dearer in bytes and cheaper in cycles, so a byte-rich profile buys its way out of V4. Only **46 of 120 frames need any mu at all** at `sasi`; the median frame is decided at mu=0 and is unchanged from session 6. ### 31.2 The one frame that cannot fit is the intra frame, not a hard case Both profiles miss exactly one frame, both at 110.6% — the all-V1 floor of FINDINGS 28.5 — and in both it is **frame 0**. It has no previous reconstruction, so every block must be coded, which is the definition of a 100%-changed frame. A scene cut mid-stream is the same thing. That is the correct behaviour rather than a failure, and it is worth being explicit about why: at `MU_CLIFF` a block only becomes SKIP if holding the previous reconstruction costs less than ~28,665 units of distortion. A frame with nothing on screen worth holding stays fully coded and is emitted **late on purpose**, exactly as a frame that will not fit at `LAM_CLIFF` is emitted over budget. Freezing a cut to make a deadline is the worse failure. ### 31.3 28.7 was too pessimistic, and the reason is instructive 28.7 estimated that only ~three quarters of the misses were the encoder's to fix — 26 of 37 at `sasi` — because re-coding every non-SKIP block as V1 still missed 11 frames. Measured, the controller fixes **36 of 37**. The gap is that 28.7's floor held the SKIP set fixed and asked "how cheap can the blocks we already decided to draw be?". The real decision can also **move a block to SKIP**, paying distortion for it, and above ~90% non-SKIP that is the only lever left. So 28.7's floor was a floor for a fixed SKIP set, not for the mode decision. Two conclusions of 28.7 stand: the profiles are an I/O axis and both must fit the same 10 MHz budget. ### 31.4 SKIP is not a constant, and the way out is two cost functions A SKIP block costs 13.25 cycles when all four blocks sharing its header byte are SKIP (one `tst.b` clears the group) and ~45 in a mixed byte, so its price depends on its neighbours — which a per-block lagrangian cannot see. Picking one number is a real trade: 45 overcharges clustered SKIPs and pushes the encoder away from the mode that saves the most cycles, 13.25 undercharges isolated ones and lets frames overrun. The resolution is that **the budget check does not have to use the same cost function as the mode decision**. `decide()` uses 13.25 purely to *rank* modes within a block, where the choice only scales the incentive (the V1-SKIP gap moves 12% between the two candidates). The controller scores whole frames with `vq_hybrid.cycles()`, the exact clustered rule, validated to 1 point against the 68000 — so the bisection converges on what the machine will really do, whatever the ranking constant was. That function is now defined once and imported by `11_cpu_budget.py`, rather than living in two places that can drift apart. ### 31.5 Both controllers are gated against decoder drift The mu controller varies the mode map frame to frame exactly as the lam controller does, so it is exposed to the FINDINGS 26.1 failure — an encoder reporting a reconstruction the decoder will never produce. `09_ratectl_drift.py` now runs **both** configurations and both report 0/120 drifting frames, 0.00 dB overstatement. The CPU ceiling is on by default in `encode.py` (`--no-cpu-fit` restores session 7 behaviour). ### 31.6 With spans on top, the window fits completely Re-running the span pricing of FINDINGS 30 against a cost-aware container — lever B first, then lever A on what it leaves: | `sasi` | bytes only | + cycle ceiling | + ceiling + spans | |---|---:|---:|---:| | median frame | 74.4% | 81.5% | **56.8%** | | worst frame | 136.2% | 110.6% | **91.5%** | | frames missing | 37/120 | 1/120 | **0/120** | | bitrate | 101.7 KB/s | 101.6 | 449.3 KB/s | The intra frame lands at 91.5% — spans are what make a full redraw fit, which is 30.6's arithmetic arriving in a real container. That row is still a **model** of a bitstream nothing implements; the two levers have never run on the 68000 together, and the ring-buffer question of 30.7 gets sharper at 449 KB/s. ## 32. SASI is dropped, and the reason is capacity, not bandwidth (session 9) **USER DECISION**: drop the `sasi` profile. A SASI volume on this machine is limited to 40 MB, and the game does not fit in one. That ends the two-quality-mode decision of session 2. `scsi` is now the only profile, and `encode.py --profile` has one choice. The retired 110 KB/s rate point is not deleted from the record, for the reason in 32.3. ### 32.1 How much video there actually is Measured off the source Blu-ray rather than recalled: the unique scene footage is streams `00000`-`00201`, **1366.6 s = 22.8 min**. The longer streams (`00215` 1376 s, `00216` 1152 s, `00223` 566 s) are compilations of the same material and are not additional content — 00223 is the window every codec measurement in this project has been taken on. Total across all 224 streams is 88.3 min, which is the figure to *not* quote. 22.8 min agrees with the ~22 min of laserdisc footage the arcade original is usually credited with, which is the cross-check that the compilations really are duplicates. At the rates this codec has actually produced, including the 7.8 KB/s audio allowance: | stream | rate | whole game | |---|---:|---:| | retired 110 KB/s profile | 109.4 KB/s | **146.0 MiB** | | `scsi`, measured (FINDINGS 31) | 278.6 KB/s | **371.8 MiB** | | `scsi` + spans (MODEL, 31.6) | 449.3 KB/s | **599.6 MiB** | ### 32.2 Where the 40 MB actually comes from It is not a bus-addressing limit. MAME 0.277's `src/mame/sharp/x68k_hdc.cpp` builds the SASI LBA from a 6-byte Group-0 CDB as `(cmd[1] & 0x1f) << 16 | cmd[2] << 8 | cmd[3]` — **21 bits of 256-byte blocks, so 512 MiB is addressable per unit**. `call_create` makes a 20 MB image (`0x13c98` blocks) because that is what a period drive was. So the 40 MB ceiling is a **Human68k / IPL volume-format and period-drive limit**, not something the SASI command set imposes. That distinction does not rescue the profile: four units at 40 MB is 160 MiB, and 146.0 MiB of video would consume essentially the entire SASI address space of the machine at the *lowest* rate this codec has ever produced, leaving nothing for Human68k, the player, or the game's own data. *Scope: the 21-bit CDB and the 256-byte block are read out of MAME's implementation. The 40 MB volume figure is the user's, and is consistent with Human68k's SASI partitioning; it has not been measured here.* ### 32.3 The rate point may come back, under a different name Dropping SASI removes an interface, not a bitrate, and the two are on different axes — the profile axis has been I/O bandwidth only since FINDINGS 28.7. The awkward part is that **capacity and bandwidth now pull in opposite directions**: - the only period medium with room for 371.8 MiB (let alone 599.6) is **CD-ROM** at 540-650 MB, and - a **1x CD-ROM sustains ~150 KB/s**, which is *below* the surviving 280 KB/s profile and much nearer the rate that was just retired. A SCSI hard disk has the bandwidth but has to be large for the era at 372 MiB. **The user's call was to ship `scsi` as the only profile now and settle the medium when the pipe is measured** — the blocked disk benchmark (`docs/BENCHMARK.md`) and the DMA-vs-PIO check of FINDINGS 29.5. **Correction to the framing above, found after that call was made.** The medium is less open than this section first presented it. FINDINGS 21.2 already committed the deployment target to **SD-backed SCSI (BlueSCSI / SCSI2SD)**, in session 2, and that is the premise the whole 488 KB/s constant rests on. On SD there is no capacity problem at any rate this codec produces — an SD card is gigabytes — and no seek tail either. So: - **Capacity does not choose between the survivors.** It killed SASI, whose 40 MB ceiling is a Human68k volume-format limit that SD emulation does not lift, and it does not bind on SD-backed SCSI at all. - **CD-ROM is the one that capacity rules out**, not in. With spans the stream is 487.1 KB/s = **650.1 MiB** for the whole game, past a CD's ~620 MiB usable — and 487 KB/s is more than 3x a 1x CD-ROM's ~150 KB/s. A CD-ROM delivery would mean giving up the span lever *and* re-deriving a profile around 150 KB/s. So the open question is not "which medium" but the one FINDINGS 29.5/30.7 already had: **confirm the 488 KB/s figure's provenance, and confirm DMA**. `profile_gen.py` exists precisely to re-derive a profile from a measured bandwidth once there is one. ### 32.4 What MAME says about the SCSI path that survives Read out of MAME 0.277 while settling 32.2, and directly relevant because the medium decision is now the thing gating the profile: **The CZ-6BS1's DMA is real and fully modelled.** `x68k_scsiext.cpp` wires the MB89352's DREQ to the expansion slot and replaces the data register at `$EA0015` with DMA-aware glue: on a DMA cycle (`m_slot->exown()`, driven by `m_hd63450->own()`) a read goes to `spc->dma_r()` and #DTACK is negated until DRQ asserts. `x68k.cpp:1114` closes the loop the other way (`out_dtack_callback -> hd63450_device::dtack_w`). That is a genuine DMAC-driven transfer with hardware flow control, on the **stock `x68000` driver** — the one MAME marks working. This is the configuration FINDINGS 29.5 asked about, and the answer for this board is **DMA, not PIO**. **The internal SCSI of the Super/XVI/030 is NOT modelled that way**, and it is a trap. `x68k.cpp:1176` reads, verbatim, `// TODO: duplicate DMA glue from CZ-6BS1`. So MAME's internal SCSI is PIO-only. A benchmark run on `x68ksupr` would measure a PIO fallback the real machine does not have — on top of those drivers already being MACHINE_NOT_WORKING (FINDINGS 28.7). **Benchmark `x68000 -exp1 cz6bs1`, not `x68ksupr`.** **CD-ROM is a first-class SCSI device on the internal bus** — `x68k.cpp:1168` puts an `NSCSI_CDROM` at ID 6 by default — but the CZ-6BS1 card's own device list offers `harddisk` only. So the CD-ROM delivery route of 32.3 is emulatable, but not on the board whose DMA is modelled, without a source change. None of this is a transfer RATE. `docs/BENCHMARK.md`'s split still holds and is worth restating because 32.3 defers a decision to a measurement: MAME can settle whether the path works and whether it is DMA, and **cannot** settle KB/s, because its device models are functional rather than transfer-timing accurate. The rate half of the medium question needs derivation or real hardware, not a longer MAME run. ## 33. The container carries its own alignment: DLX1 -> DLX2 (session 9) The encoder gap left open since session 7 (FINDINGS 28.3, STATUS item 4) is closed. `encode.py` now emits **DLX2**, which pads every frame record up to a 4-byte boundary — the first one included, by padding the codebook tables so `off_frm` is aligned. `dlx.py` reads both versions; DLX1 containers stay readable because every measurement in FINDINGS 28-31 was taken on one. Measured on the same 120-frame window: | | record starts not 4-aligned | padding cost | |---|---:|---:| | DLX1 (through session 8) | **94/120** | 0 (the loader added 180 B) | | DLX2 (now) | **0/120** | 160 B = 1.33 B/frame = **16 B/s** | 16 B/s against 278.6 KB/s is 0.006% of the stream. The thing it buys is not speed: an odd `move.l (a0)+` on a 68000 is an **address error**, which vectors into the IPL and presents as an infinite loop, not as a slow read. That is the bug that cost session 7 an afternoon. `tools/bench/prep_dlx.py` still realigns at load time and now says whether it had to — `0/120 record starts unaligned -- the container carries its own padding` on a DLX2 input. It is kept rather than deleted because it is what makes the session 7-8 containers decodable, and those are the containers the published timings belong to. **Cross-check that this changed nothing else:** re-encoding the `scsi` window with the DLX2 writer reproduces FINDINGS 31.1 exactly — 29.27 dB, 278.6 KB/s, median 99.6% / max 110.6% of a 12fps frame, 1/120 frames missing. The padding is additive; it does not touch the mode decision. ## 34. The cost model, checked against the machine on a cost-aware container (session 9) STATUS item 1. Everything in FINDINGS 31 was the validated cost MODEL (`vq_hybrid.cycles`) applied to a container it had never been checked against — the 1-point validation of 28.2 belongs to the *session 7* stream. This is the cost-aware container timed on the emulated 68000, same harness, same scope (instruction cycles, zero-wait-state GVRAM, interrupts masked; a LOWER BOUND). | anchor | non-SKIP | model | measured | error | |---|---:|---:|---:|---:| | min non-SKIP | 15.4% | 254,683 cyc / 30.6% | 262,751 / 31.5% | **-3.07%** | | median | 53.2% | 681,199 / 81.7% | 690,251 / 82.8% | **-1.31%** | | p90 | 75.7% | 832,116 / 99.9% | 834,213 / 100.1% | **-0.25%** | | max non-SKIP | 100.0% | 921,293 / 110.6% | 921,187 / 110.5% | **+0.01%** | | whole 120-frame mean | — | 649,089 / 77.9% | 657,081 / 78.8% | **-1.22%** | The model holds, and its error is **signed**: it under-predicts by 1-3% on light frames and converges to exact on heavy ones. That is the right direction to be wrong in for a ceiling controller — the bisection is tightest where the model is most accurate — but it means the median frame is ~1 point dearer than FINDINGS 31 reports, not cheaper. The four synthetic single-mode frames reproduce session 7 exactly: all-V1 110.5%, all-V4 165.2%, all-RAW 147.6%, all-SKIP 4.9%. Those are properties of `decode.s`, not of the container, so agreeing across two different streams is the cross-check that the harness is measuring what it claims. ### 34.1 The 23-minute "hang" was the buffering trap again The session-8 note said this run "was still going at 12 minutes of CPU". It was re-run here and sat at 99.9% CPU for **23 minutes** with a 0-byte log, then was killed. Re-launched under **`stdbuf -oL`** with `-seconds_to_run 60`, the identical plan completed in about **25 seconds of wall time** and printed every line as it went — MAME reports `Average speed: 528.72% (52 seconds)`, so the whole plan needs ~52 emulated seconds and the machine runs it at 5x realtime. The lesson is the one already in STATUS, one level deeper: it is not enough to write MAME's output to a file instead of a pipe. **A file is block-buffered too**, so a long MAME run is unobservable until it exits, and an unobservable run that is merely finishing looks exactly like one that is wedged. Session 8 lost the measurement to that, and session 9 lost 23 minutes to it before spending 25 seconds getting the answer. **`stdbuf -oL` on every MAME job that prints progress.** ## 35. The CPU budget has never had the disk in it (session 9) > **TESTED BY 38 AND IT STANDS.** Session 10 first argued that the flat > subtraction here is too pessimistic -- that the disk DMA could hide in bus > cycles the CPU was not using -- and scored the same window at 53/120 instead > of 84/120. **That was wrong.** A 68000 has no cache and a two-word prefetch > queue, so it stalls as soon as another master takes the bus; DMA time is > additive, which is exactly what this section assumed. The 84/120 stands and > 38.3 now reproduces it. **Raised by the user: "PIO is such a CPU killer. DMA is not. I'm concerned about us drawing the wrong conclusions."** The concern is correct, and it is larger than the labelling question of 32.4. This is the seventh false premise this project has caught, and the most expensive one. Every CPU figure in FINDINGS 24 through 34 is measured against **833,333 cycles per frame**, the full 10 MHz clock divided by 12 fps. Nothing has ever been subtracted from it for moving the bitstream off the disk. The decoder has been scored as though the data arrives for free. ### 35.1 What the transfer actually costs `profile_gen.py` has carried `DMA_CLOCKS_PER_WORD = 8` since session 2 (FINDINGS 5, an **ESTIMATE** from HD63450 timing, never measured) and prints a "DMA steal" line — but that line was only ever compared against the 38.3% *blit* figure of FINDINGS 17, which FINDINGS 24 superseded and which was never the decoder cost. It was never debited from the decoder budget. At the rates that matter, on a 10 MHz 68000: | stream | DMA @ 8 clk/word | PIO, unrolled (~12 clk/B) | PIO, byte loop (~20 clk/B) | |---|---:|---:|---:| | `scsi`, 278.6 KB/s | **11.4%** | 34.2% | 57.1% | | `scsi` + spans, 487.1 KB/s | **20.0%** | 59.9% | **99.8%** | The PIO columns are hand-derived floors, not measurements: a byte from an I/O register plus a store is 16 cycles on a 68000 before any loop overhead. They are here to size the risk, and the size of the risk is that **PIO at the span rate consumes the entire machine**. ### 35.2 What that does to the conclusions of FINDINGS 31 Debiting the DMA steal — the *cheap* case, the one we are hoping for: | | KB/s | steal | budget left | median | p90 | worst | fits? | |---|---:|---:|---:|---:|---:|---:|:--| | `scsi` today | 278.6 | 11.4% | 738,238 | **112.4%** | 112.9% | 124.8% | **no** | | `scsi` + spans | 487.1 | 20.0% | 667,070 | **98.3%** | 102.8% | 114.3% | **no** | FINDINGS 31's headline — "1 frame of 120 misses" — is measured against a budget with no I/O in it. With DMA debited the surviving profile does not fit at all: the *median* frame is over. And 31.6's "with spans the window fits completely" becomes a worst frame of 114.3%, because **the span lever buys cycles by spending bandwidth, and the bandwidth comes back out of the CPU as steal.** Spans still help — 112.4% -> 98.3% at the median, 14 points — but they no longer close the gap on their own. ### 35.3 Why this is not settled by the DMA finding of 32.4 32.4 established that the CZ-6BS1's DMA *path* exists and is modelled. Three things it does not establish, and all three are load-bearing: 1. **DMA vs PIO is a property of OUR player, not of the board.** The hardware supports DMA; if the player reads through IOCS and IOCS does PIO, we get PIO and the table above. `docs/BENCHMARK.md` item 4 already proposed driving the MB89352 registers directly for exactly this reason — that is now not an optimisation but the difference between fitting and not. 2. **8 clocks per word has never been measured.** It is now the single most load-bearing unmeasured number in the project: at 8 the port is marginal, at 12 it is dead, at 4 it is comfortable. It comes from a datasheet reading in session 2 and nothing has checked it since. 3. **MAME cannot settle it.** Its device models are functional, not transfer-timing accurate (BENCHMARK.md), and it models no GVRAM wait states either — so a MAME run can confirm the transfer is a DMA cycle and cannot price it. This needs derivation from the HD63450 and MB89352 datasheets, or real hardware. ### 35.4 What this does and does not overturn It does **not** overturn the decoder measurements: 300/448/400 cycles per block and the model validation of FINDINGS 34 are properties of `decode.s` and stand unchanged. What it overturns is every statement of the form "N frames of 120 miss the budget", because the budget was wrong. Those all need re-running against `833,333 * (1 - steal)` once `steal` is a measurement rather than a datasheet estimate. It also sharpens the framerate question of STATUS item 5 considerably. At 10 fps the budget is 1,000,000 cycles and the same DMA steal is proportionally smaller per frame, which is now a much stronger argument for 10 fps than "one late frame per cut" ever was. ### 35.5 `11_cpu_budget.py` now debits it, and 10 fps absorbs it The tool takes `--io dma|pio|none` (default **dma**) and prints the budget it is actually scoring against. On `tmp/rc_fr_singe_scsi_cpufit.dlx`: | `--io` | budget left | median | worst | frames missing | |---|---:|---:|---:|---:| | `none` — the pre-session-9 premise | 833,333 | 99.6% | 110.6% | **1/120** | | `dma` (8 clk/word, estimated) | 738,234 | 112.4% | 124.8% | **84/120** | | `pio` (12 clk/B, floor) | 548,036 | 151.4% | 168.1% | **120/120** | `--io none` prints a warning naming FINDINGS 35, so the old number cannot be produced by accident. **At 10 fps and DMA the same container goes back to 1/120** — median 93.7%, worst 104.0%. That is conservative, because it holds the 12 fps byte rate: a real 10 fps encode carries ~17% fewer bytes per second, so the steal falls too. This changes what the framerate decision (STATUS item 5) is *for*. It was a quality question about one late frame per scene cut. It is now the lever that pays for the disk, and on current estimates it is the difference between a stream that fits and one that misses 70% of its frames. ## 36. A `scsi` window does not fit in the machine the test rig emulates (session 9) Swapping the decoder gate onto the surviving profile's container made it fail — `frame 119 not pixel-exact: 49,005 px differ`. That is not a decoder bug and not the DLX2 change. **The container does not fit in RAM.** `tools/bench/decode.lua` loads the entire stream into emulated memory at `STREAM = 0x30000`, and the locked target is a stock **2 MB** machine: | container | stream | ends at | verdict | |---|---:|---:|---| | session 7-8 `sasi` | 1,108,888 B | 0x13EB98 = 1.25 MB | fits | | `scsi` cost-aware | 2,840,860 B | 0x2E591C = 2.90 MB | **overruns 0x200000 by 940 KB** | The loader wrote 940 KB past the top of memory, the decoder then parsed whatever that reads back as, and the run neither completed its sequential pass nor drew the right picture. Every 68000 decode verification before session 9 was done on a container small enough to fit by accident — the `sasi` profile was a third the bitrate, so nobody met this. **This is a property of the test rig, not of the player.** The shipping player streams from disk into a ring buffer and holds seconds of video, not minutes. But it does bound what the rig can prove: at 278.6 KB/s, a 2 MB machine holds about **6.7 seconds** of stream, so the strongest test in the tree can only ever audit a prefix of a window. The fix keeps the test honest rather than making it pass: - `prep_dlx.py` truncates the frame list to what fits, **prints that it did**, and takes `--ram` / `--all-frames`. On the `scsi` window it keeps **80 of 120** frames. - `verify_decode.py` takes `--nframes` so the reference decoder replays exactly the prefix the 68000 decoded, instead of running 40 frames ahead of it. - `check.sh` reads the count back out of `decode_meta.lua` and passes it through, and now **fails loudly if the sequential pass did not complete** — the missing `snapshot taken` marker — instead of reporting a pixel diff against a half-drawn screen. That guard is what turned this from a mystery into a five-minute diagnosis. Verifying a prefix is still a real test: SKIP blocks make every frame a claim about the one before it, so frame 79 is only correct if all 80 were. What is lost is coverage of the last 40 frames, and the honest way to get it back is to gate on more than one window rather than to pretend one pass covers everything. **The timing confirms the diagnosis.** Truncated to 80 frames the pass completes in **8 emulated seconds** and the frame is pixel-exact; the model predicts ~6.6 s for 80 frames at this container's cost, so that is the expected number. The 120-frame run that overran RAM could not finish the same work in **44**. A decoder reading garbage does not run slowly for an interesting reason — it was parsing lengths out of unmapped memory and walking wherever they pointed. Any "the decoder is 4x slower than the model on RAW-heavy streams" conclusion drawn from that run would have been entirely false, which is the third time in this session that an unobservable run nearly produced a wrong finding. ## 37. A second emulator, and MAME is not running the core we thought (session 10) Every 68000 cycle figure in FINDINGS 24-35 came from one instrument. This is a second one, run against byte-for-byte the same `decode.bin` and the same container. `tools/bench/c68k/` links **px68k's C68K core** into a headless harness: a hand-built X68000 memory map, no SDL, no ROMs, no emulated machine. The decoder touches nothing but RAM, the control block and GVRAM, so the machine around it was never part of the measurement. ### 37.1 What the two instruments actually are **MAME 0.277's `M68000` is not Musashi.** `src/devices/cpu/m68000/m68000.lst` plus `m68000gen.py`: it is the microcode core, where timing emerges from the modelled micro-sequence and 4-clock bus cycles. C68K is a static per-instruction cycle table (`ORI_CLOCKS_*` / `EA_CLOCKS_*` in `c68kmacro.h`), hand-transcribed from the Motorola manual by a different author. Those are two different ways of arriving at a number, which is what makes the agreement worth something. It would be worth much less if both were tables. ### 37.2 The harness is self-validating It decodes all 80 frames and dumps the screen; `verify_c68k.py` checks it against `tools/encoder/dlx.py` **pixel for pixel, on palette indices**. That is the licence for the cycle numbers: the harness rebuilds px68k's memory model from scratch -- byte-swapped RAM (`mem_wrap.c:420`), GVRAM word writes that discard the high byte -- and any of it being subtly wrong would still print plausible cycles. It could not print a pixel-exact 80-frame temporal recursion. It does. **`decode.s` is now pixel-exact under two independent CPU cores.** ### 37.3 The numbers ``` anchor MAME C68K delta MAME C68K of a 12fps frame min non-SKIP 42.8% 600982 620760 +3.29% 72.1% 74.5% median 65.2% 841038 869036 +3.33% 100.9% 104.3% p90 72.9% 836124 856872 +2.48% 100.3% 102.8% max non-SKIP 100.0% 921187 923090 +0.21% 110.5% 110.8% synthetic all-SKIP 40729 40946 +0.53% 4.9% 4.9% synthetic all-V1 921187 923090 +0.21% 110.5% 110.8% synthetic all-V4 1376881 1420754 +3.19% 165.2% 170.5% synthetic all-RAW -- 1273298 -- 152.8% ``` The `all-RAW` cell is empty because MAME's timed pass did not reach it. That is an operational fact worth recording: with `-video soft -nothrottle` this box runs `x68000` at about **0.033x realtime**, so `decode.lua`'s eight anchors plus two full passes — ~48 emulated seconds — cost ~25 minutes of wall clock, and two runs were killed by their own `timeout`. The C68K harness does the same work in seconds because it emulates a CPU and not a machine. **Anchor MAME runs by wall clock, not by `-seconds_to_run`.** **Cycle-table error is bounded at 3.3%, and it runs against us** -- C68K reads high on every anchor. Nothing here rescues FINDINGS 35. The disagreement is mode-dependent (all-V1 +0.21%, all-V4 +3.19%), so it localises to the V4 path's indexed two-register `movem.l`, not to a systematic clock difference. **FINDINGS 28.8 is confirmed independently**: under C68K, V4 (170.5%) still costs more than RAW (152.8%). That conclusion inverts the encoder's mode preference, so having it from a second core matters more than most. ### 37.4 What it does not settle px68k has no bus-timing model anywhere in `x68k/*.c` -- grep it. Neither instrument charges GVRAM wait states, so this is **the same lower bound, measured twice**. It bounds cycle-table error. It says nothing about the distance to a real X68000; that is still BENCHMARK.md Tier 3. ### 37.5 One trap, recorded because it will catch the next person C68K is 64-bit-unsafe by construction: its `MOVEM` macros do `src = (UINT32)(&D0)` -- they truncate the host address of the register file and dereference it -- and `C68k_Set_Fetch` keeps the opcode-fetch base in a `UINT32`. Under the default PIE the binary loads near `0x555555550000` and the first `movem` segfaults. The Makefile builds `-no-pie` and the harness mmaps its arena `MAP_32BIT`. Both are load-bearing, not tidiness. ## 38. The bus, measured: the project is bus-bound, not CPU-bound (session 10) > **This supersedes part of 29 and part of 35.** FINDINGS 29's "the bus has 4x > the headroom the CPU has" is true of the SCSI pipe and false of the 68000's > local bus, and they are different resources. FINDINGS 35's flat CPU debit for > the disk charges the CPU for bus cycles it was not going to use. Everything since FINDINGS 24 has been costed in CPU clocks. The 68000 has another budget nobody had counted: its **memory bus**, one 4-clock cycle at a time, carrying instruction prefetch as well as data. ### 38.1 Two sources that check each other `tools/bench/c68k/c68k_bench` counts every Read/Write callback the C68K core makes -- exact, because C68K splits a long access into two word calls, which is what the 16-bit bus does. It cannot count **instruction prefetch**: C68K reads opcodes straight through a host pointer with no callback, and MAME exposes no fetch count either. So `tools/analysis/15_bus_occupancy.py` derives prefetch by walking `decode.s`'s straight-line paths in `tools/bench/decode.lst` and multiplying by each frame's mode histogram. The same walk also predicts the data half -- and that half is measurable: ``` measured mean 66,700 data bus cycles/frame derived mean 66,672 error -0.04% mean, 0.06% worst ``` The walk reproduces the measurement, so its prefetch figure stands on the same footing. `15_bus_occupancy.py` exits non-zero if that check ever stops holding. ### 38.2 The result ``` mean median worst frame bus slots in a frame 201,497 211,013 230,772 data accesses 66,672 68,044 105,216 instruction prefetch 108,002 110,982 122,910 total bus cycles 174,674 181,998 193,248 bus OCCUPANCY 86.7% 86.8% 88.3% slots left for a DMAC 26,823 26,618 21,115 ``` **The decoder occupies 86.7% of its own bus, and prefetch is 62% of that.** A data-only count understates occupancy by about 2x, which is exactly the mistake an instrumented emulator would lead you into. Per mode, bus clocks against measured clocks: V1 204/299.9 (68%), V4 308/448.2 (69%), RAW 316/400.4 (79%), and the v6 span **9.0/9.152 (98%)**. ### 38.3 What that does to the frame budget -- and one wrong turn The first thing done with 86.7% was to argue that FINDINGS 35's flat CPU debit for the disk is too pessimistic: the decoder leaves ~26,800 bus slots a frame idle against the disk's ~23,000, so score it as contention, `frame = max(CPU clocks, 4 x bus cycles)`, and the window misses 53/120 rather than 84/120. **That is wrong, and the MC68450 manual is what says so.** A 68000 relinquishes the bus on BGACK and cannot execute without it -- no cache, a two-word prefetch queue that empties immediately. Worse, the DMAC does not interleave at operand granularity by default: limited-rate auto-request hands it the bus in *bursts* of `2(BT+4)` clocks out of a sample period of `2(BT+BR+5)`, taking `2^-(BR+1)` of the bandwidth in slabs (MC68450 sect 5.2.3.2, Fig 5-2). During a slab the CPU is stopped. So **DMA time is additive to CPU time**, which is what FINDINGS 35 assumed all along. `14_dmac_chain.py` reproduces its 84/120 exactly in the `today` column. What 86.7% *does* say is worse than the thing it appeared to rescue: **there is almost no room to overlap anything.** The 13.3% of bus slots the decoder leaves idle are single gaps inside a `movem`-heavy loop, not windows a bus master can be handed. Any design whose case rests on DMA hiding under CPU work on this machine should be assumed dead until measured on hardware. The measurement still earns its place: it is what prices the span painter against a DMAC in 39, and it is the reason the answer there came out the way it did. ### 38.4 What is not counted Bus arbitration. The 68000's BR/BG/BGACK handover costs cycles a cycle-steal DMA cannot avoid, and the disk debit here embeds it only insofar as FINDINGS 5's 8 clocks/word already does. Also: no GVRAM wait states, as everywhere since 24. Both make the real occupancy **higher** than 86.7%, not lower. ## 39. The DMAC chain against the span: the datasheet says no (session 10) FINDINGS 29.6 named "let the DMAC do the copy" the one lever that could move the budget without spending a byte, and left it uncosted. This costs it, and the answer is **no** -- but only after the constants came from the MC68450 manual rather than from bus arithmetic, which is the whole lesson of the section. ### 39.1 They are the same container v6's record is `{u32 absolute GVRAM address, u16 jump displacement}` = 6 bytes. An MC68450/HD63450 **array-chaining entry** is `{u32 memory address, u16 transfer count}` = 6 bytes. Set the channel dual-address, direction device->memory, Sequence Control counting both addresses up: MAR reloads per entry (the GVRAM destination), DAR walks the stream buffer, MTC is the span's word count. **The chain array IS the span table.** Every byte figure in FINDINGS 30 carries over, and this is not a fork in the format -- the encoder emits the same thing either way, only the executor changes. That much is real and survives everything below. ### 39.2 The first answer was wrong by a clock Session 10 first derived the DMAC's cost from bus arithmetic: moving a pixel is a read cycle plus a write cycle, 2 bus cycles, 8 clocks, against v6's measured 9.152 -- a 12.6% edge. On that basis the design scored 1/120 frames over budget against v6's 10/120 and looked decisive. The datasheet does not agree. **MC68450 Fig 4-25 sheet 4**, dual address / operand size WORD / device size 16 bits, D->M or M->D: `{WORD READ, WORD WRITE}` = **9 CLOCKS**. Confirmed by the long-operand row, two of each for 18. And **Fig 4-25 note 2** says why: the DMAC's reads take four clocks and its **writes take five**. The 68000 writes in four. | per pixel | clocks | source | |---|---:|---| | DMAC, dual-address word, two 16-bit ports | **9.000** | MC68450 Fig 4-25 sheet 4 | | v6 `movem` chain | **9.152** | MEASURED, FINDINGS 30 | **1.7%.** One clock on every DMAC write is the entire difference between a 12.6% win and a rounding error. Per span, sequential array chaining costs **36 clocks** (Fig 4-25 sheet 1: three word reads for the 6-byte entry, plus reload) against v6's measured 43.7 -- the DMAC's one genuine edge, and it is 7.7 clocks. ### 39.3 Scored additively, as 38.3 requires ``` today v6 span v6 fine tail DMAC chain bitrate KB/s 270.8 479.2 479.9 479.9 frame, median 108.1% 99.3% 96.5% 95.0% frame, worst 114.7% 112.0% 111.4% 110.3% frames missing 84/120 55/120 18/120 12/120 blocks spanned/frame 0 727 839 845 ``` `today` reproduces FINDINGS 35's 84/120 exactly, which is the check that the scenario lines up. ### 39.4 What the DMAC actually buys, and who else can sell it `v6 fine tail` is the decomposition. v6 pads every span up to 24 pixels because its copy is an unrolled chain of 12-register `movem` units; adding a second, finer chain of 2-register units caps the padding at 3 pixels instead of 23, for the price of some more unrolled code and **nothing per span**. Priced conservatively (a 4-pixel unit costs 56 clocks against a full unit's 220 for 24, so it is dearer per pixel and paid at most once a span): | | frames over | |---|---:| | v6 as built | 55/120 | | **v6 with a finer chain tail -- software only** | **18/120** | | DMAC chain | 12/120 | **86% of the DMAC's advantage over v6 is the 24-pixel padding quantum**, and that is a property of v6's unrolled chain, not of the CPU. The residual is 1.7% a pixel and 7.7 clocks a span, worth 6 frames of 120. Break-even against all-V1 moves the same way: v6 as built needs a run of 4 blocks, v6 with the finer tail needs 3, the DMAC needs 1. ### 39.5 The verdict **Fix the quantum in software.** Six frames of 120 does not buy a reserved DMAC channel, a two-region container layout, and a dependency on transfer timing that cannot be verified in either emulator on this box. The `v6 fine tail` figure is itself DERIVED and should be measured with `span.sh` before it is believed -- that is a day's work in a tool that already exists, against a hardware dependency that needs an actual X68000. Keep 39.1 on the record. If a later measurement moves the DMAC's per-pixel cost below 8 clocks -- for instance if GVRAM tolerates a four-clock DMAC write in a way the datasheet's typical-system assumption does not model -- the container does not have to change to take advantage of it. ### 39.6 What else would have to be true, if it is ever revisited - **A free channel.** Four exist; channel 3 is ADPCM (`adpcm_drq_tick` asserts `drq3_w`) and the SCSI stream needs one. - **Two regions per frame.** Chaining fetches entries from an array while DAR walks the pixel data, so the span table and the literal words cannot be interleaved as v6 interleaves them. - **The mode-map walk stays on the CPU.** 39.3 charges it; FINDINGS 30.7 flagged that 12_span_tradeoff.py did not. ### 39.7 A number the datasheet settled on the way past FINDINGS 5's **8 clocks/word** for the SCSI DMA has been an unsourced estimate since session 1 and STATUS has called it the most load-bearing unmeasured number in the project. Fig 4-25 sheet 3 gives single-address `W/B READ` 4 clocks and `W/B WRITE` 5; a device->memory disk transfer is one memory write. So it is **5 clocks/word if the DMAC holds the bus** and about **12 if it arbitrates per word** (front-end 5 best case / 8 worst, sect 4.5.2.1; back-end 2, sect 4.5.2.2). The feature list's "up to 5 Megabytes per Second at 10 MHz, no wait states" is the held-bus case: 2 bytes per 4-clock cycle. **8 is the midpoint of a bracket the datasheet supports, not a guess.** Which end applies depends on how the MB89352 drives REQ and whether cycle-steal-with- hold is used, which is a design decision the player has not made yet -- and it is worth 7 clocks a word on a 480 KB/s stream, so it is worth making deliberately. --- ## 40. The finer chain tail, measured: v7 (session 11) FINDINGS 39.4 attributed **86% of the DMAC array-chain's advantage over v6** to one thing that is not a property of the DMAC at all -- v6's 24-pixel padding quantum -- and derived that fixing it in software would take the `scsi` window from 55/120 frames over budget to 18/120. It labelled that figure DERIVED and said it should not be believed until `span.sh` measured it. This measures it. ### 40.1 The result `tools/bench/blit.s` gains **v7**: v6's 24-pixel coarse chain with a second, finer chain appended. Measured over thirteen span lengths by `tools/bench/span.sh`, every one of which drew a **pixel-exact** frame: ``` cycles = 66.0 per span + 9.143 per COARSE pixel + 9.978 per FINE pixel ``` fitting all 13 lengths to within **0.2%** -- and the fit is not flattered by its own configs, because the three-term model was identified on span lengths chosen so that every fine remainder a real span can have (0, 4, 8, 12, 16, 20) appears. v5 and v6 re-measure to 97.9 + 10.459 and 43.7 + 9.152, reproducing FINDINGS 30 exactly, so the harness has not drifted underneath the new variant. | clocks per 4x4 block, run of L blocks | L=1 | L=2 | L=3 | L=4 | L=8 | L=64 | |---|---:|---:|---:|---:|---:|---:| | v6 as built | 1053 | 527 | 351 | 263 | 241 | 154 | | **v7** | **424** | **292** | **248** | **226** | **183** | **151** | | DMAC chain (datasheet) | 288 | 216 | 192 | 180 | 162 | 146 | **Break-even against all-V1 (299.9) moves from L=4 to L=2 blocks.** 39.4 predicted L=3; the measurement is better than the derivation. ### 40.2 The scoring, and a derivation that was right for the wrong reasons Rescoring the same `scsi` window with `14_dmac_chain.py`, the same additive model, the same mode maps: | | frames over budget | |---|---:| | today | 84/120 | | v6 span as built | 55/120 | | **v7, MEASURED** | **18/120** | | DMAC chain (datasheet) | 12/120 | **18/120, exactly the derived figure.** That agreement is a coincidence of two cancelling errors, and it is worth writing down because a match this clean would otherwise be read as confirmation: - 39.4 assumed a **2-register `movem` tail**, derived at 56 clocks per 4 pixels = 14.0 clocks/pixel. The real tail costs **9.978** -- 29% cheaper. - 39.4 assumed the second entry point costs **nothing per span**. It costs **22.3 clocks** (66.0 against v6's 43.7), because it is a second `move.w (a0)+,d0` and a second `jmp`. The per-pixel win and the per-span loss are within a frame of each other over this window. **The conclusion survives; the reasoning behind it did not.** ### 40.3 The instruction the derivation should have picked A 2-register `movem` is the obvious "smaller unit of the same thing", and it is the wrong instruction. Per 4 pixels: | tail unit | bus cycles | clocks | per pixel | |---|---:|---:|---:| | `movem.l (a0)+,d0-d1` + `movem.l d0-d1,(a2)` + `lea` | 14 | 56 | 14.0 | | **2 x `move.l (a0)+,(a2)+`** | **10** | **40** | **~10.0** | `movem` pays two instruction words and a `lea` to move what two of the plainest instructions on the machine move with post-increment on both sides. v7's fine unit is therefore **one `move.l (a0)+,(a2)+` = 2 pixels**, which also makes the padding quantum **2** rather than 4 -- and a span is a run of 4x4 blocks, so its length is always a multiple of 4 and **the padding is exactly zero**. 39.4's "caps the padding at 3 pixels" was pessimistic by three pixels. The derived bus model predicts the measurement well once the right instruction is in it: 5 bus cycles = 20 clocks per 2 pixels = 10.0/pixel against 9.978 measured, and 54 bus cycles = 216 clocks per 24 against 9.143*24 = 219.4. ### 40.4 Where the fine displacement lives, and why it is not in the record Two chains need two entry points, and the second one has to survive the coarse copy. Holding it in a register would cost a payload register -- v6's whole reason for a 24-pixel unit is that it has exactly 12 free (`d0-d6/a1/a3-a6`). So it is not in the span record. **It is in the stream**, after the coarse pixels and before the fine ones. The coarse chain falls out into `move.w (a0)+,d0 / jmp v7fh(pc,d0.w)`, and at that instant `d0` is dead payload and `a0` is pointing exactly at it. The decoder holds nothing extra across the copy and keeps all 12 registers. The record is still `{u32 absolute GVRAM address, u16 coarse displacement}`; the container costs **2 more bytes per span**, which `14_dmac_chain.py` charges. ### 40.5 The verdict, now measured rather than argued **FINDINGS 39.5 stands: fix the quantum in software, drop the DMAC.** v7 takes back **37 of the 43 frames** the DMAC chain would, using an instruction sequence that needs no reserved channel, no two-region container, and no transfer timing that neither emulator on this box can verify. 39.1 still holds if that ever changes: the chain array and the span table are the same six bytes. ### 40.6 The 13-minute run that measured nothing `span.sh` ran for 13 minutes producing an empty log and zero snapshots, and the same MAME command with a shorter `-seconds_to_run` completed the identical work in 30 seconds. The cause is still not identified. What matters is that **the run was unobservable in both directions**: MAME's stdout did not reach the log until exit even under `stdbuf -oL`, and the snapshots -- the one artefact that would have shown progress -- may themselves only land at exit. So the bisection that resolved it did not chase the hang. It **shrank the stimulus** instead: `tmp/spans_meta.lua` carries byte offsets into a blob that `prep_spans.py` writes once, so deleting config lines from the metadata runs any subset in seconds against the same unmodified stream file. v5 alone, v7 alone and the full set at a shorter run all completed; the wedge never reproduced. This is the fourth instance of the pattern FINDINGS 34.1 named, and it is the first where **the instrument was unobservable but the thing being measured was fine**. `span.sh` now runs at `-seconds_to_run 200`, measured at 30 s wall for all 36 configs, and asserts the snapshot count against the number of configs in the generated metadata rather than a literal 23 -- so adding a config can no longer silently weaken the pixel-exactness gate.