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:
@@ -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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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.")
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)")
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user