blit.s gains v7 -- v6's 24-pixel movem chain plus a second chain whose unit is
one `move.l (a0)+,(a2)+`. Measured over 13 span lengths by span.sh, every config
pixel-exact:
cycles = 66.0 per span + 9.143 per COARSE pixel + 9.978 per FINE pixel
fitting all 13 to within 0.2%. v5 and v6 re-measure to FINDINGS 30 exactly, so
the harness has not drifted underneath the new variant.
Rescored against the same scsi window and the same additive model, v7 takes
84/120 frames over budget to 18/120 -- exactly what FINDINGS 39.4 derived, and
that agreement is two cancelling errors: the derivation's 2-register movem tail
is 29% too dear per pixel, and its "nothing per span" for the second chain entry
is 22.3 clocks too cheap. The plain post-incrementing move.l is the right tail
instruction, and it makes the padding quantum 2 pixels, which a run of 4x4
blocks pads to exactly zero.
The DMAC stays dropped on a measurement now rather than an argument: v7 takes
back 37 of the 43 frames the array chain would, with no reserved channel and no
timing neither emulator here can verify. Break-even against all-V1 moves from
L=4 blocks to L=2.
The fine displacement is carried mid-stream rather than in the span record, so
the decoder holds nothing across the copy and keeps all 12 payload registers --
which is the whole reason the coarse unit is 24 pixels.
span.sh is now -seconds_to_run 200 (30 s wall, 36 configs) and takes its
expected snapshot count from the generated metadata instead of a literal 23.
Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2535 lines
129 KiB
Markdown
2535 lines
129 KiB
Markdown
# 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.
|
||
|
||

|
||
*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.
|
||
|
||

|
||
*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 <framedir> 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`.
|
||
|
||

|
||
|
||
*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.
|