Three files hardcoded /media/reala-misaki/BDROM -- extract.py, 07_motion_survey.py and check.sh -- which made the pipeline unrunnable for anyone whose disc mounts anywhere else. DLX_BDROM now overrides it everywhere, defaulting to /media/$USER/BDROM, so this box is unchanged and another one works. Verified by running extract.py against a symlinked mount at a different path. check.sh's failure message names the path it looked at and the variable to set, instead of assuming udisks put it where this machine puts it. README gains a "Reproducing this" section: no media ships here and none of it is redistributable, so it says what you have to bring (the disc) and what is already packaged (vasm is vendored as a binary with its source tarball; the k-means is hand-rolled, so numpy and Pillow are the whole Python dependency). It also names the two gates that SKIP rather than fail -- the px68k second-core pass and the IPL ROM DMAC gate -- because both live outside this repo and a silent skip is worth reading as a skip. One trap called out rather than left to bite: scene selection is a hard-coded stream number, not a search. A different pressing that numbers its .m2ts files differently will extract the wrong footage and the green light will PASS on it. check.sh ALL GREEN. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
351 lines
20 KiB
Markdown
351 lines
20 KiB
Markdown
# Dragon's Lair — Sharp X68000 port
|
|
|
|
Porting Dragon's Lair to a stock X68000 (68000 @ 10MHz, 2MB, SCSI).
|
|
|
|
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
|
|
pushing ~22 minutes of Don Bluth animation through a 10MHz 68000.
|
|
|
|
---
|
|
|
|
## What it looks like
|
|
|
|

|
|
|
|
Left, the Blu-ray frame cropped to 256x192. Right, **the same frame as the
|
|
emulated 68000 actually drew it** — 256 colours out of the X68000's 65536, one
|
|
16-colour-per-4x4-block codebook, decoded by `src/player/decode.s` from the
|
|
container. Not a re-render: these are the pixels MAME had on screen, extracted
|
|
from its own snapshot. 2x nearest-neighbour, no filtering.
|
|
|
|
**The player, running.** 119 frames out of a **256 KB ring buffer on an emulated
|
|
stock 2 MB X68000**, paced to a 12 fps frame clock, streamed from a host file at
|
|
488 KB/s — `src/player/stream.s`, no Lua in the decode path. Source on the left,
|
|
the machine's screen on the right.
|
|
|
|
<video src="docs/img/player.webm" controls muted loop width="100%"></video>
|
|
|
|
[`docs/img/player.webm`](docs/img/player.webm) — 119 frames, 12 fps, VP9
|
|
|
|
116 of those 119 frames are **pixel-exact** against `tools/encoder/dlx.py`'s
|
|
reference reconstruction. The other three are **torn** — the top of the picture
|
|
is frame *n* and the bottom still holds frame *n-1*, because MAME captured the
|
|
screen while the block loop was partway down it. That is not a rig artefact:
|
|
`decode.s` writes straight to the displayed page (one display path, FINDINGS
|
|
28.1 — the dual-path plan is kept runnable as a counterexample precisely because
|
|
it corrupts frames), so a real player tears the same way.
|
|
`tools/media/make_readme_media.py` asserts the tear rather than trimming it —
|
|
every differing pixel has to come from the previous frame, or it refuses to
|
|
build.
|
|
|
|
**What the decoder is actually doing.** The same window with the block-mode map
|
|
beside it: **black = SKIP** (costs nothing, draws nothing — the previous frame
|
|
stands), **blue = V1** (one codebook index for a whole 4x4 block), **amber = V4**
|
|
(four indices), **red = RAW** (sixteen bytes verbatim). The mode mix is what
|
|
every cost table in `docs/FINDINGS.md` is really about — V4 costs 1.5x V1, and
|
|
since session 8 the mode decision is charged both bytes *and* cycles, which is
|
|
why a byte-rich profile buys its way out to RAW instead of V4.
|
|
|
|
<video src="docs/img/modes.webm" controls muted loop width="100%"></video>
|
|
|
|
[`docs/img/modes.webm`](docs/img/modes.webm) — the same 119 frames with the mode map
|
|
|
|
**Name the layer:** everything above is **emulated** (MAME 0.277 `x68000`,
|
|
`-bios ipl10`, stock 10 MHz / 2 MB), cross-checked frame-for-frame on a second
|
|
CPU core (px68k's C68K). Nothing in this project has run on real hardware yet.
|
|
|
|
---
|
|
|
|
**And the binding resource is the 68000's local BUS, not its clock.** The
|
|
decoder occupies 86.7% of it once instruction prefetch is counted, and 52 of the
|
|
53 frames that miss the 12fps budget miss it on the bus, not the CPU
|
|
(FINDINGS 38). Read that before optimising anything for cycles.
|
|
|
|
The **literal span with a fine tail** (v7) is now IN the player: `decode.s`
|
|
paints it, pixel-exact under both CPU cores, and it costs inside the decoder
|
|
what `blit.s` said it would to 0.2% (FINDINGS 41).
|
|
|
|
**The delivery path is built and tested too** (FINDINGS 49). `src/player/stream.s`
|
|
decodes the whole 120-frame window **out of a 256 KB ring on a stock 2 MB
|
|
machine**, final frame pixel-exact, with the container in a host file rather than
|
|
preloaded into RAM. The constraint turned out to be **contiguity, not byte
|
|
count** — the block loop reads with a monotonically increasing `a0` and no bounds
|
|
check, so the ring needs the whole next record resident *and contiguous*, which
|
|
is a condition no byte-counting buffer simulation can see.
|
|
|
|
**And building it caught a live defect**, then cost the project a constant.
|
|
The shipping candidate is 496.7 KB/s; the pipe figure the design had been
|
|
simulated against since session 2 was smaller, and nothing in the tree was
|
|
comparing the two — the rate controller binds on clocks and has no pipe term at
|
|
all, while the buffer sizing kept standing on a constant the design had stopped
|
|
enforcing.
|
|
|
|
**So the pipe constant is retired (session 18, USER DECISION).** It was never a
|
|
bus measurement — user-supplied, no provenance, 10% of SCSI-1's asynchronous
|
|
rating (FINDINGS 42.1). It is gone as a default from every analysis tool and
|
|
from `stream.lua`; `--bus` / `--kbps` / `DLX_STREAM_KBPS` are now **required
|
|
arguments**, so no table can be scored against a rate its own output does not
|
|
state. **There is no working delivery figure, and that is the honest state.**
|
|
|
|
What replaces it is a requirement rather than a constant:
|
|
`tools/analysis/19_ring_stream.py` reports the **zero-prefill pipe**, the rate a
|
|
medium must clear for a container to need no prefill. For the candidate that is
|
|
**513.2 KB/s** — a hardware acceptance test to measure a BlueSCSI against.
|
|
|
|
**Bytes are not free, and the number that said they were was in the wrong
|
|
unit.** Session 13 found the pipe figure the design was built against was never
|
|
a bus figure (SCSI-1 is 1.5 MB/s asynchronous) and concluded the span pass
|
|
saturates at ~837 KB/s, 0/120 frames over budget. Session 14 found the disk
|
|
debit behind that was charged **per word of stream to a byte-wide port** — the
|
|
MB89352 is an 8-bit SPC, so the DMAC pays per BYTE, and the debit is 2x every
|
|
table since FINDINGS 5. No 68000 bus cycle is shorter than four clocks, so the
|
|
old figure was below a physical floor.
|
|
|
|
**What survives, re-encoded honestly: 496.7 KB/s at 29.19 dB, 1 frame of 120
|
|
over the 12fps budget** — and that one is frame 0, the intra frame, late on
|
|
purpose. The remaining lever is not ours: **whether the CZ-6BS1 wires the SPC's
|
|
DACK to the bus's `#EXACK`**, which decides 5 clocks/byte against 9, and with
|
|
it 242 KB/s and 0.69 dB. Read FINDINGS 43 before quoting any rate figure.
|
|
|
|
**And session 20 read the answer the machine already had.** The X68000's IPL ROM
|
|
programs all four HD63450 channels itself, and MAME boots the rig with it, so
|
|
`tools/analysis/21_iplrom_dmac.py` decodes the configuration straight out of the
|
|
image and gates on the bytes still being there. The audio channel is
|
|
dual-address, 8-bit port, cycle steal **without hold**, one external request per
|
|
byte: **16..19 clocks a byte, not 5** — which prices the ADPCM stream at
|
|
1.25%..1.48% of a frame and closes ROADMAP's "do this first" item. The disk
|
|
channel is programmed **identically**. That is 16..19 clocks per delivered byte,
|
|
above the whole 5..12 bracket the project costs the transport in, and at that
|
|
price nothing fits. It is SASI and not the MB89352, so it does not settle the
|
|
question — but **a cheap configuration is now the thing that has to be shown,
|
|
not the thing assumed.** FINDINGS 52.
|
|
|
|
**Green-light check:** `./tools/bench/check.sh` (~3 min, needs the Blu-ray
|
|
mounted) re-runs both display regression tests, the rate-control drift test, the
|
|
display-path coherency counterexample and a 120-frame 68000 decode, then prints
|
|
`ALL GREEN`.
|
|
|
|
## Reproducing this
|
|
|
|
**No media ships in this repo, and none of it is redistributable.** Bring your
|
|
own Dragon's Lair Blu-ray. Everything else needed to rebuild every number and
|
|
every picture above is here or is packaged.
|
|
|
|
You need:
|
|
|
|
| | |
|
|
|---|---|
|
|
| the disc | loop-mounted read-only. `udisksctl loop-setup -r -f DRAGONS_LAIR.iso` — the tree was built against a decrypted UDF 2.x image (7-Zip cannot read UDF 2.x; use the loop mount) |
|
|
| `python3` | plus **numpy** and **Pillow**. Nothing else — the k-means is hand-rolled rather than pulling in sklearn |
|
|
| `ffmpeg` / `ffprobe` | frame extraction, and the README clips |
|
|
| **MAME** | tested on 0.277, with the `x68000` ROM set. The rigs drive it headless via `-autoboot_script` |
|
|
| vasm (m68k, Motorola syntax) | **vendored**: `tools/vasm/vasmm68k_mot` is a Linux x86-64 binary, with the source tarball beside it to rebuild elsewhere |
|
|
|
|
Then:
|
|
|
|
```sh
|
|
export DLX_BDROM=/path/to/your/mounted/bluray # if not /media/$USER/BDROM
|
|
./tools/bench/check.sh # ~3 min, prints ALL GREEN
|
|
```
|
|
|
|
`DLX_BDROM` is honoured by every tool that reads the disc. Two stages are
|
|
optional and **skip rather than fail** when their input is absent, because both
|
|
live outside this repo:
|
|
|
|
- `PX68K=/path/to/px68k` — a px68k checkout, for the second-CPU-core gate. This
|
|
is the cheapest strong test in the tree (seconds, no MAME, no ROMs), and it is
|
|
what licenses the bus and cycle figures.
|
|
- `IPLROM=/path/to/iplrom.dat` — the X68000 IPL ROM, for the DMAC-configuration
|
|
gate (FINDINGS 52). Defaults to `~/mame/roms/iplrom.dat`.
|
|
|
|
To rebuild the stills and clips in `docs/img/` you also need a paced recording
|
|
run — see the header of `tools/media/make_readme_media.py`.
|
|
|
|
**Scene selection is a hard-coded stream number**, not a search: the gates use
|
|
stream `00020` and `00223` of the disc's 224 `.m2ts` files, which are the ones
|
|
FINDINGS §1 and §25 characterise. A different pressing may number them
|
|
differently, and if so the green light will extract the wrong footage rather
|
|
than fail — check that `tmp/fr_singe/` looks like the Singe encounter (which is
|
|
what the directory is named for) before trusting any figure.
|
|
|
|
## Read first
|
|
- **`docs/FINDINGS.md`** — measured hardware facts, content statistics, codec
|
|
decision, and a section on measurement traps that produced three separate
|
|
false results. Read §4 before trusting any pipeline number.
|
|
- **`docs/STATUS.md`** — current state, working setup, blockers, next steps.
|
|
**Start here.** It also lists what has been explicitly abandoned, so old ideas
|
|
do not get re-proposed.
|
|
- **`docs/ROADMAP.md`** — the remaining work to a completion target, and which
|
|
milestone that target is. Read it with STATUS, not instead of it: STATUS holds
|
|
the measurements, ROADMAP holds the shape and goes stale first.
|
|
- **`docs/BENCHMARK.md`** — how to measure the storage subsystem, and why a
|
|
bandwidth figure out of MAME would be meaningless.
|
|
- **`docs/HARDWARE.md`** — X68000 GVRAM/CRTC reference.
|
|
|
|
## Layout
|
|
```
|
|
docs/ findings, status, hardware reference
|
|
tools/analysis/ measurement scripts, numbered in the order they were written
|
|
(01/02 marked BROKEN deliberately, kept as regression refs).
|
|
Run from the repo root — they import from tools/encoder/.
|
|
07 finds the hottest sustained window in a stream; 08 renders
|
|
source | decoded | block-mode map as .webm; 09 is the
|
|
rate-control drift gate (FINDINGS 26/27) and is part of
|
|
check.sh -- it exits non-zero if the encoder ever again
|
|
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; 12 prices the literal-span mode of
|
|
FINDINGS 30 against those same mode maps, and prints whether a
|
|
scene cut still fits at 12fps; 13 measures what fitting the
|
|
CPU budget costs in dB (FINDINGS 31) and caches H.build so the
|
|
search loop is seconds, not minutes.
|
|
14 prices the HD63450 array-chain against the v6 and v7
|
|
spans (FINDINGS 39/40) and prints the sensitivity that decides
|
|
it -- v7 is measured, and takes 37 of the 43 frames the DMAC
|
|
would, so the DMAC stays dropped;
|
|
15 measures how much of the 68000's LOCAL bus the decoder
|
|
occupies (FINDINGS 38) and exits non-zero if its derived
|
|
model stops matching the harness's measurement.
|
|
16 is the DLX3 span container ROUND-TRIP gate (part of
|
|
check.sh): it encodes, writes the container, reads it back with
|
|
the reference decoder and fails if a pixel differs -- or if it
|
|
emitted too few spans to have tested anything. 17 prices the
|
|
spans the encoder ACTUALLY emitted, with no selection model,
|
|
which is what 12 and 14 could only simulate.
|
|
18 measures what a 16-colour text-plane literal would cost in
|
|
dB, and closes that direction (FINDINGS 46.3).
|
|
19 is the RING-BUFFER simulation, and it supersedes 09_buffer_
|
|
sim.py's question rather than repeating it: it models the ring's
|
|
ADDRESSES, because src/player/ needs each record contiguous and
|
|
not merely resident. It reports the ZERO-PREFILL PIPE -- the
|
|
rate a medium must clear for a container to need no prefill --
|
|
and warns explicitly when demand exceeds supply on the MEAN,
|
|
where a "required prefill" figure would flatter a sustained
|
|
overrun. Its wrap count and hole size match tools/bench/
|
|
stream.lua's, measured on a real 68000, to the digit.
|
|
buscost.py is the shared bus-cycle table both import; the
|
|
per-BLOCK constants live in tools/encoder/vq_hybrid.py and are
|
|
imported, never copied (session 12 corrected one of them).
|
|
tools/bench/ MAME Lua injection harness + 68000 benchmark sources.
|
|
`check.sh` re-runs both display regression tests (~40 s).
|
|
`blit.s`/`blit.lua` time the full-frame GVRAM blit on the
|
|
68000 itself (FINDINGS 24) — not part of check.sh, because
|
|
wall timings would make the green-light check host-sensitive.
|
|
`span.sh` (prep_spans.py + span.lua + blit.s v5/v6/v7)
|
|
measures the literal-span mode the same way (FINDINGS 30 and
|
|
40, ~30 s); it also asserts that every one of its 36 timing
|
|
configs drew a pixel-exact frame, the count taken from the
|
|
generated metadata so a new config cannot weaken the gate.
|
|
v7 is v6 with a second, 2-pixel chain for the span tail:
|
|
66.0 cycles/span + 9.143 per coarse pixel + 9.978 per fine
|
|
pixel, MEASURED, which is the win FINDINGS 39.4 predicted.
|
|
`crtc_mode.lua` is the single source of truth for CRTC R00-R08
|
|
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.
|
|
`prep_stream.py`/`stream.lua` do the same for `stream.s`, but
|
|
lay the container out as a DISK in a host file and feed it
|
|
through a bounded ring at a modelled pipe rate -- so the rig is
|
|
no longer bounded by the emulated machine's RAM, and a stock
|
|
2 MB machine runs the whole window. `dlxload.py` holds the
|
|
codebook/palette load-time maths both preps share.
|
|
tools/bench/c68k/ headless px68k C68K harness -- a SECOND emulator for every
|
|
68000 cycle figure (FINDINGS 37). Links only px68k's CPU core:
|
|
no SDL, no ROMs, no emulated machine. `make PX68K=~/src/px68k`
|
|
then `run.sh`; `verify_c68k.py` checks the decode is
|
|
pixel-exact, which is what licenses the cycle numbers. It also
|
|
counts BUS cycles, which MAME cannot report.
|
|
The Makefile's -no-pie and the harness's MAP_32BIT arena are
|
|
load-bearing: C68K truncates host pointers to 32 bits.
|
|
tools/vasm/ vasm m68k assembler (built from source)
|
|
tools/encoder/ hybrid VQ encoder + DLX3 container writer (working).
|
|
spans.py is the v7 span geometry, selection and serialiser, and
|
|
the single place the chain layout is stated on the encoder side
|
|
-- it must match blit.s/decode.s (11 coarse units of 24 px, 11
|
|
fine of 2).
|
|
DLX2 4-byte-aligns every frame record: an odd `move.l` is an
|
|
ADDRESS ERROR on a 68000, not a slow read (FINDINGS 28.3).
|
|
dlx.py is the reference DECODER -- ground truth for the 68000.
|
|
src/player/ decode.s: the 68000 DLX3 decoder, PRELOADED-stream front-end.
|
|
stream.s: the same decoder behind a bounded RING (FINDINGS 49).
|
|
Both include frame.i (the block loop and span chain) and geom.i
|
|
(the constants) so there is exactly ONE copy of the bytes every
|
|
cycle constant in FINDINGS 24/30/40/41 is fitted to. check.sh
|
|
asserts decode.s still assembles to the same 1,296 bytes.
|
|
decode.s: the 68000 DLX3 decoder. Pixel-exact under MAME and
|
|
px68k's C68K core, blocks and v7 literal spans both. The span
|
|
pass is blit.s v7 verbatim -- the same instruction sequence the
|
|
66.0/9.143/9.978 fit was measured on, so do not tidy it.
|
|
See FINDINGS 28, 31, 40 and 41.
|
|
assets/ extracted frames/audio (gitignored)
|
|
```
|
|
|
|
## Encoder
|
|
|
|
```
|
|
python3 tools/encoder/extract.py 00020 /tmp/fr 12 crop
|
|
python3 tools/encoder/encode.py /tmp/fr out.dlx --profile scsi --preview p.png
|
|
```
|
|
|
|
**Two budgets, not one.** `--kbps` is the quality rate point and `--span-kbps`
|
|
is the ceiling the span pass may draw on. They are different things: the profile
|
|
is chosen, the pipe is hardware, and bytes between them buy a better picture if
|
|
spent on `lam`, the 68000's deadline if spent on spans, and nothing if left
|
|
unspent. Spans run before `mu` because a span pays in bytes and `mu` pays in
|
|
picture (FINDINGS 41.2).
|
|
|
|
**One profile: `scsi`, 280 KB/s.** The 110 KB/s `sasi` profile was dropped in
|
|
session 9 on capacity, not bandwidth — a SASI volume is limited to 40 MB, and
|
|
the game's 22.8 minutes of footage is 146 MiB even at that rate (FINDINGS 32).
|
|
The rate point may return under another name once the delivery medium is
|
|
settled, because a 1x CD-ROM sustains ~150 KB/s and CD-ROM is the only period
|
|
medium with the capacity.
|
|
|
|
The profile bitrate is a **ceiling**: lam is bisected per frame under a leaky
|
|
bucket, so the profile's `lam` is a quality floor rather than a setting
|
|
(`--fixed-lam` opts out).
|
|
|
|
**But at `--spans all` none of that binds.** A 32-frame bucket emits the same
|
|
container byte for byte as an 8-frame one, and `lam` never leaves its floor on
|
|
any frame of the reference window: the rate is set by the span pass and by `mu`,
|
|
not by `--kbps` or the bucket (FINDINGS 44.3). Two known unit inconsistencies on
|
|
that side are implemented and default OFF because they measure as a wash --
|
|
`--joint-decide` (the per-block lagrangian prices a byte at `lam + mu*c` rather
|
|
than `lam`) and `--joint-bucket` (the bucket may not lend clocks it cannot
|
|
repay). FINDINGS 44.
|
|
|
|
An encode is ~95% k-means; a 120-frame window is ~29 s, of which ~22 s is
|
|
training the two codebooks (FINDINGS 44.5).
|
|
|
|
There are **two** ceilings, on two different axes. The second is the 68000's
|
|
decode budget: `mu` is bisected per frame against 833,333 cycles so the frame
|
|
also *decodes* in time, which takes the worst sustained window from 37 frames
|
|
over budget to 1 for 0.62 dB at `scsi` (FINDINGS 31). It is on by default; `--no-cpu-fit`
|
|
restores session 7 behaviour. Unlike bytes, cycles have no bucket — there is no
|
|
double buffer to decode ahead into, so it is a hard per-frame ceiling. The codec is
|
|
a Cinepak-style hybrid: each 4x4 block is coded as SKIP, one 4x4 codeword, four
|
|
2x2 codewords, or RAW literal pixels, chosen per block by rate-distortion.
|
|
|
|
The RAW escape means `lam=0` is pixel-exact against the palettised frame, so the
|
|
quality knob spans lossless to heavily-compressed without changing the bitstream.
|
|
|
|
Profiles are derived from a bandwidth figure, not chosen by eye:
|
|
|
|
```
|
|
python3 tools/encoder/profile_gen.py --bw-mbps 4 --name scsi
|
|
```
|
|
|
|
> **On reading `docs/FINDINGS.md`:** it is append-only and several later sections
|
|
> overturn earlier ones. Superseded sections carry a blockquote at the top
|
|
> pointing to the correction — heed those, especially 18 (reversed by 21).
|
|
|
|
Source media (`DRAGONS_LAIR.iso`) and ROMs are gitignored — supply your own.
|
|
|
|
**Not every large stream is game footage.** `00216` is the feature with a
|
|
burned-in commentary picture-in-picture and `00215` is the commentary itself —
|
|
the two largest files on the disc. The clean 9.4-minute animation is **`00223`**.
|
|
See FINDINGS 25.1 before running any size-ranked survey.
|