The 68000 decoder draws pixel-exact frames, and does not fit

src/player/decode.s parses DLX1 and decodes straight into GVRAM. Verified
pixel-exact over a 120-frame sequential run of the worst sustained window on
the disc -- all four block modes, full temporal recursion, so the last frame
is only right if all 120 were. In check.sh.

It costs a mean of 81.7% of a 12fps frame budget, and 31% of frames exceed
100% (42% at scsi). CPU is now the binding constraint. FINDINGS 28.

Three things that were believed and are not true:

- The dual-display-path plan of FINDINGS 24.5/25.6 is incoherent. The compose
  path needs a RAM copy of the previous reconstruction; the direct path's
  selling point is that it keeps none. Mixing them shows stale pixels on 70 of
  120 frames, worst frame 18.8% of the screen. Every coherent repair is dearer
  than not mixing, and 24.5's two figures were both copies with no decode in
  either, so there was never a crossover to find. One path ships, and the 96KB
  reference frame is gone. tools/analysis/10_pathmix_drift.py keeps the
  counterexample runnable; check.sh asserts it still reproduces.

- The four block modes do not cost the same. V1 300, V4 448, RAW 400 cycles
  against the old model's flat 207.8. V4 is 25% of blocks and 50% of the
  cycles, and the mode decision charges it bytes it does not charge cycles for.
  tools/analysis/11_cpu_budget.py reproduces all four frames timed on the
  68000 to within 1 point. Hand-derived timings agree to 0.5% on V1.

- The container is big-endian but not aligned. Variable-length records laid end
  to end put frame 1's length field at an odd address, and move.l (a0)+ there
  is an address error: frame 0 decoded perfectly and then vectored into the
  IPL for 59 emulated seconds looking like a hang. Found by dumping PC, not by
  reading the source.

Also: an all-V1 frame, the cheapest possible full redraw, is 110.5% of budget.
No mode assignment fits a scene cut at 12fps. That one needs a decision, not a
measurement.

Next: charge cycles in the mode decision and bisect against 833,333 per frame,
the way session 6 bisects lam against bytes -- but with no bucket, because a
late frame cannot be banked.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-23 15:04:38 -07:00
parent 497f88b945
commit e1aa26bb57
11 changed files with 1276 additions and 38 deletions
+155
View File
@@ -862,6 +862,12 @@ leaving them uninitialised. This is why 96KB, not 48KB, is the irreducible
write traffic.
### 24.5 The architecture question, and where it turns over
> **Superseded by FINDINGS 28.1/28.2 (session 7).** The two-path plan below is
> incoherent — the compose path needs a RAM reference the direct path never
> writes — and its two costs are both *copies*, so they were never comparable to
> a decode. The "76.6% x non-SKIP fraction" model is also 2.03x optimistic:
> the four block modes cost 300/448/400 cycles, not one figure. One path ships.
V4 prices the access pattern a decoder that writes codewords **straight into
GVRAM** actually has: 4 rows of 8 bytes at a 1024-byte stride per 4x4 block. The
same 96KB of writes costs **76.6%** in block order versus 53.6% row-linear — the
@@ -980,6 +986,11 @@ rate-control problem, not a codec-structure problem: the RD decision is behaving
correctly for the lam it was given.
### 25.6 The decoder needs BOTH display paths, chosen per frame
> **Superseded by FINDINGS 28.1 (session 7).** Mixing the paths displays stale
> pixels on 70 of these 120 frames. The "median 37.0%, capped at 53.6%" below is
> the cost of an incorrect player; every coherent version is dearer, and plain
> direct-to-GVRAM is the cheapest of them.
Applying FINDINGS 24.5's crossover to the real per-frame distribution:
| | median non-SKIP | p90 | frames over the 70% crossover |
@@ -1216,3 +1227,147 @@ bisection instead of a 5-rung ladder — which was the actual defect in 26.3.
The cache holds **one frame**. At ~133 KB of intermediates per frame, caching
the sequence would cost 900 MB on a 9.4-minute stream to save nothing: every
caller works a frame at a time.
---
## 28. The 68000 decoder exists, is pixel-exact, and does not fit (session 7)
`src/player/decode.s` parses DLX1 and draws frames on the emulated X68000. It is
**pixel-exact across a 120-frame sequential run** of the worst sustained window
on the disc (`tools/bench/verify_decode.py`), exercising all four block modes
and the full temporal recursion — the last frame is only right if every frame
before it was.
It is also **too slow**. On that window, at the shipping `sasi` profile:
| | non-SKIP blocks | measured cost |
|---|---:|---:|
| cheapest frame | 15.4% | 31.5% of a 12fps frame |
| median frame | 47.8% | 73.8% |
| p90 frame | 82.5% | **116.4%** |
| worst frame | 100.0% | **135.8%** |
| mean over the window | 47.8% | **81.7%** |
**31% of frames miss the 833,333-cycle budget**, and like every figure since
FINDINGS 24 these are instruction cycles against zero-wait-state memory, so
they are a floor. This is the first time CPU, not disk, is the binding
constraint — FINDINGS 21 retired the bandwidth worry, and this replaces it.
### 28.1 The dual-path plan of 24.5/25.6 was incoherent, and is withdrawn
FINDINGS 24.5 specified two display paths chosen per frame on the non-SKIP
count, and 25.6 costed the mix at "median 37.0%, capped at 53.6%". Two of its
premises cannot both hold:
- compose-in-RAM-then-blit exists to make the blit **row-linear**, so it must
assemble a **full** frame in RAM. The pixels it does not decode this frame —
the SKIP blocks — can only come from a RAM copy of the previous
reconstruction.
- decode-direct-to-GVRAM's stated advantage is that **"no RAM reference frame
is needed"**, because the previous frame is already in GVRAM.
So every direct frame silently invalidates the reference the next compose frame
reads. Simulated on the Singe window at the crossover the plan specifies
(`tools/analysis/10_pathmix_drift.py`): **70 of 120 frames display pixels no
correct player would display**, first at frame 2, worst frame 18.8% of the
screen. This is FINDINGS 26 in different clothing — two code paths disagreeing
about what "the previous frame" means — and it is the **sixth** false premise
this project has caught before it shipped.
Every coherent repair is worse than not mixing at all:
| strategy | median | p90 | max | correct |
|---|---:|---:|---:|---|
| mix per frame, as specified | 36.6% | 53.6% | 53.6% | **no** |
| mix, direct also writes the RAM reference | 53.6% | 68.4% | 81.4% | yes |
| mix, re-read GVRAM into RAM on each switch | 36.6% | 107.2% | 107.2% | yes, 13 frames miss |
| compose only | 53.6% | 53.6% | 53.6% | yes |
| **direct only** | **36.6%** | 62.5% | 76.6% | yes |
(Costs in that table are 24.5's own model, for like-for-like comparison; 28.2
replaces the model itself.)
**24.5 also compared the wrong two things.** Its 53.6% and 76.6% are both
*copies* measured in `blit.s` — neither includes decoding. A real compose path
costs decode-into-RAM **plus** the 53.6% blit, so it is strictly dearer than
decoding straight into GVRAM, whatever the block mix. There was never a
crossover to find.
**The decoder therefore implements one path, direct-to-GVRAM**, and drops the
96 KB RAM reference frame entirely.
### 28.2 The four block modes do not cost the same, and V4 is the expensive one
24.5's model — "76.6% of a frame x the non-SKIP fraction" — prices every
non-SKIP block as one `movem.l` burst. Measured separately, with synthetic
single-mode frames (`tools/bench/prep_dlx.py`):
| mode | cycles/block | vs the 24.5 model (207.8) |
|---|---:|---:|
| SKIP, in an all-SKIP header byte | 13.3 | model says 0 |
| SKIP, inside a mixed byte | ~45 | model says 0 |
| V1 (one 4x4 codeword) | **299.9** | 1.44x |
| V4 (four 2x2 codewords) | **448.2** | 2.16x |
| RAW (16 literal indices) | **400.4** | 1.93x |
Applied to the real per-frame histograms (`tools/analysis/11_cpu_budget.py`),
the model reproduces all four frames timed on the 68000 to within **1
percentage point**, and shows 24.5 to be **2.03x optimistic at the median**.
Where the cycles actually go over the window:
| mode | % of blocks | % of cycles |
|---|---:|---:|
| SKIP | 46.4% | 9.2% |
| V1 | 19.8% | 26.1% |
| V4 | **25.2%** | **49.7%** |
| RAW | 8.5% | 15.0% |
**V4 is a quarter of the blocks and half the cycles.** It costs 1.49x a V1 block
while the mode decision in `vq_hybrid.py` charges it only its 4x payload bytes.
The lagrangian trades distortion against *bytes*; on this machine it now has to
trade distortion against *cycles* as well.
### 28.3 The container is big-endian but not aligned, and that is an address error
The DLX1 header docstring says every multi-byte field is big-endian "so the
68000 reads them with a plain `move`". Alignment is the other half of that
sentence and the container does not have it: frame records are
`[u32 length][768-byte mode header][payload]` laid end to end with arbitrary
payload lengths, so record boundaries land on odd addresses.
`move.l (a0)+,d0` at an odd address is an **address error** on a 68000 — not a
slow read. The first run decoded frame 0 perfectly, consumed exactly its 8,715
payload bytes, then read frame 1's length at `$03220F` and vectored into the IPL
at `$FF059A`, where it sat for 59 emulated seconds looking like an infinite
loop. It was found by dumping PC and the address registers, not by reading the
source: the code was correct, the data layout was not.
The decoder now rounds each record start up to 4. **The container should carry
the padding itself** so a streaming player can DMA records into place: measured
cost on this window is **199 bytes over 120 frames — 1.66 B/frame, 20 B/s**
against a 110 KB/s budget. Until `encode.py` does it, `prep_dlx.py` realigns at
load time.
### 28.4 The measurements agree with hand-derived MC68000 timings
As in FINDINGS 24, each figure was derived from the instruction timing tables
before being believed. A V1 block, summing dispatch, index decode, the indexed
`movem.l` load and four `movem.l` stores, plus its quarter share of the header
loop: **298.5 cycles derived against 299.9 measured — 0.5%.** RAW derives to
396 against 400.4 measured (1%). V4 derives to 415 against 448 (7%, the gap
being in the indexed two-register `movem.l`, the mode this decoder uses most
heavily). So these are 68000 cycles, not a MAME artefact.
### 28.5 A full frame does not fit at 12fps in ANY mode
An all-V1 frame — the cheapest possible way to redraw all 3,072 blocks — costs
**921,187 cycles, 110.5% of the budget**. All-V4 is 165.2% and all-RAW 147.6%.
So the ceiling is structural, not a tuning problem: **at 12fps on a 10MHz 68000
no more than ~88% of the screen can change in one frame**, however cheaply it is
coded. Scene cuts change 100% of it. Either a cut gets one late frame (the
outgoing content is unrelated, so this may be free to the eye), or cuts have to
be spread across two frame times, or the framerate has to come down — at 10fps
the budget is 1,000,000 cycles and an all-V1 frame fits.
### 28.6 What this does not measure
One 10 s window of one stream at one profile, and MAME still models no GVRAM
wait states. The `scsi` profile will be worse: FINDINGS 25.5 has it collapsing
to RAW under stress, and RAW is 1.93x the old model's block. Nothing here has
been run on `00020` or on quiet content, where the median frame is far cheaper.