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