# Dragon's Lair: Sharp X68000 port Porting Dragon's Lair to a stock X68000 (68000 @ 10MHz, 2MB, SCSI). 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, and it is a **delivery** problem rather than a compression one — bus cycles and bytes off a disc. **The live design has no decoder in it.** The disc holds packed 8-bit frames and a DMA channel walks them into GVRAM with the CPU halted. Measured on the machine, decoding 37,585 bytes costs more than not decoding 49,152: at the 9 clk/B dual-address floor the codec is 110.4% of a 12 fps frame and the decoder-free packed player is 54.9%. A working codec is in the tree and is **parked** (USER DECISION). Both branches are described below; the packed one is where the work goes. ## What it looks like **The packed player, running off a real volume, with sound.** `src/player/packed.s` on an emulated stock X68000: the 68000 brings up its own display, builds its own 193-entry DMA chain, keeps its own frame clock off the CRTC's V-DISP, and fetches every record itself with `READ(10)` from a real MB89352. **There is no decoder in the machine.** Source on the left; on the right, MAME's own snapshots of what the 68000 put on screen. **120 of 120 frames are pixel-exact against the container**, every one compared, and the clip refuses to build otherwise. [`docs/img/packed-player.webm`](docs/img/packed-player.webm) (120 frames, 12 fps, VP9, with the chip's own audio) **The clip is a composite of two runs and that is the point.** The picture is the gate run — paced at half rate so each snapshot lands inside the write window, cycle stealing, no sound. The sound is the audio run — the same container at 12 fps with the MSM6258 on channel 3, captured by MAME off the speaker, cut at the first sample the chip produced and gated sample-exact against lump 0 before anything is written. **What it is not is a real-time capture of the shipping configuration**, which at this container's own burst rate would show a blank layer for 99.5% of every slot (FINDINGS 64.2, below). ![Blu-ray source next to the packed player's own screen](docs/img/packed-player.png) The still is the frame whose PSNR is closest to the mean over the gated window, so it is not the flattering one: **33.13 dB against the window's mean of 33.10**, which is the packed container's predicted GRB555 figure to the digit. **Name the layer.** Everything here is **emulated**: MAME 0.277 `x68000`, `-bios ipl10`, stock 10 MHz / 2 MB, with every 68000 cycle figure cross-checked on a second CPU core (px68k's C68K) and the display path cross-checked against px68k's own `gvram.c`. **Nothing in this project has run on real hardware yet.** ## How the packed player works **256-colour GVRAM throws away the high byte of every word a CPU writes**, so a picture byte normally costs two disc bytes. **CRTC R20 bit 11 turns the masking off**, and with the two 256-colour pages scrolled apart one word carries two pixels (FINDINGS 46/47). That makes a frame 1.0 B/pixel — 49,152 bytes — and it makes the frame a *literal*: no codebook, no recursion, no decode. A record is that picture plus its own 256-entry palette: **49,664 bytes, which is 97 sectors exactly**. A packed record's length is geometry, so **the container carries no index and no length word** — record *i* is at `off + i*rec` and a seek is arithmetic. A DMA channel fills GVRAM in buffer mode straight off the disc and **walks the 1,024-byte line stride itself** through array chaining, so a frame is **one channel start and not 192** — and the chain crosses from the palette registers at `$E82000` into GVRAM in the same start (FINDINGS 62). The array is scene-constant, because the packed layout spends both 256-colour pages and there is no page to flip. **Audio rides a fixed cadence, not the record.** The MSM6258 wants 7,812.5 B/s and 12 fps wants 651.0417 B a slot, a ratio with no arithmetic relationship, so audio in the record would make records variable-length and bring the index back. DLXP2 groups instead: **one 14-sector audio lump, then 11 records**, so `record i = off_frm + i*rec + (i//11)*7168` — six instructions once a frame. The lump is 7,168 bytes of space and eleven frames of audio is 7,161.4583…, so **the payload alternates 7,161 and 7,162** and a player that fed the chip whole lumps runs 0.09% fast: **1.25 s of lip-sync drift over the game's 22.8 minutes**, predicted, and played at 1.26 (FINDINGS 67.2/68.3). The player carries one accumulator instead, which is the frame clock's shape for the frame clock's reason. **The wire is fixed and there is no lever on it: 582.0 KB/s of picture + 7.64 KB/s of audio cadence = 589.6 KB/s.** A codec's bitrate is adjustable; a literal frame's is geometry, and no scene costs less than another. | | | |---|---:| | record | 49,664 B = 97 sectors | | wire | **589.6 KB/s** | | picture, as the display renders it | **33.10 dB** mean over the gate window | | CPU per frame, video path | one channel start and one `READ(10)` — no paint | | cost at the 9 clk/B dual-address floor | **54.9% of a 12 fps frame** (FINDINGS 64.2) | | the codec, at the same floor | 110.4% (FINDINGS 61.4) | ## Where it stands **What runs, end to end, on the emulated machine off a real volume:** - **120 of 120 frames pixel-exact**, in both palette orders, every frame compared rather than the last — a packed frame is a literal, so frame 119 says nothing about frame 60 (FINDINGS 64.1). - **All 78,125 B of a container's audio, read back off the speaker**, sample-exact against the four ADPCM axes carried in the container's own header, one delivered byte at a time — because a player's counters all stay right through a bug that overwrites the buffer the channel is reading (FINDINGS 68.1). - **A mid-stream seek with sound on it**: `pg_aseek` rebuilds the lump index, the stream position, the remainder accumulator and the byte offset into the group and issues a second read — **132,162 B of spliced stream accounted for byte by byte** across a branch deliberately not on a group boundary (FINDINGS 71.1). - **The scene graph**: 40 scenes, 516 sequences, 906 input windows, imported from the arcade's own graph into this project's schema (FINDINGS 56). **The binding resource is the 68000's local BUS, not its clock**, and every item above is priced in one of four units: | resource | state | |---|---| | **68000 local bus** | the binding one. The codec's decoder occupies 86.7% of it, and 52 of the 53 frames that miss the 12 fps budget miss on the bus (FINDINGS 38). | | **68000 clocks** | measured, on two independent cores. | | **delivery rate** | **no working figure, deliberately** (FINDINGS 50, USER DECISION). `--bus`, `--kbps` and `DLX_STREAM_KBPS` are required arguments with no defaults, so no table can be scored against a rate its own output does not state. | | **seek time** | **no figure at all, and never had one.** | | **W, clocks stolen per delivered byte** | 5 single-address held, 9 dual held, 12 single arbitrated, 16..19 for the IPL ROM's own disk channel. **Still the largest open number.** The CPU doing the transfer itself is **87.28 clocks a byte, measured** — 4.6x the worst DMA configuration found here and 17.5x the best (FINDINGS 58). | ### The open question that decides which player gets built **The write window is the frame.** A packed write *requires* R20 bit 11; buffer mode blanks the layer while it is set; and a DMAC-direct player holds the window open for the whole data phase, because there is no second page to hide behind. dark fraction of a slot = record bytes / (DATA-PHASE rate x slot) **The rate in that expression is the BURST rate, not the sustained one**, and at the container's own 582.0 KB/s the dark fraction is **1.0**: every frame delivered, on time, pixel-exact, and none of them displayed. Free-running, the run reported exactly that — **the window was open on 99.5% of host frames**. It **reverses the ranking** in clocks, so there are two packed players and the difference between them is *when* the window is open: | | clocks (W=9) | on screen | RAM | |---|---:|---:|---:| | **A — DMAC-direct** (built, FINDINGS 64) | **54.9%** of a slot | 0% at the container's wire, 72.7% only at 2,131 KB/s | none | | **B — DMA to RAM + CPU paint** (K4, not built) | 82.2% | **72.7% at any rate** | 99,328 B | B's paint is **measured**: the packed `movem` blit is 227,553 clocks, 27.3% of a slot, independent of the medium. They are equally visible only at **3.7x the wire**. So B2 — *does buffer mode blank the display?* — decides which player exists rather than how much headroom one has, and **K4 is not built until it is answered** (FINDINGS 64.2, ROADMAP K4). ### What else a player has to carry **A held channel costs the frame clock half its ticks, without the clock being able to tell.** `clock.i` counts V-DISP interrupts; a held channel halts the 68000; the MFP's pending bit is one bit. Held at 12 fps, **487 of 1,038 edges are lost** and the player reports **zero late frames**, because the tick it grades itself against is advanced by the interrupt the channel stopped it from taking. It believed it was at 12 fps; the screen was at **6.37** (FINDINGS 64.3). **Held, the bus costs the audio 463 times the seam.** The MSM6258 has no starvation state — it goes on decoding nibbles out of whatever byte its data register still holds — so the interval between a channel counting out and the CPU arming the next lump is a held nibble pair driving the predictor. Stealing, that seam is **0.51 ms over ten seconds**, because the audio service runs from inside `dma.i`'s transfer wait. Held, it is **236 ms, 2.31% of the audio**, with every byte still correct (FINDINGS 68.2). **The chip is not the datasheet and not ffmpeg, and it was asked.** Sixteen candidate decoder models were fitted to what the emulated MSM6258 produced from a designed nibble stream; **exactly one reproduces it sample-exact**, with a negative control on every axis. The chip runs the datasheet's truncation, takes the **low** nibble first, clamps its accumulator at **10 bits** and starts it at **−2**. Getting the delta formula wrong costs −2.88 dB; **getting the nibble order wrong costs −25.74 dB** (FINDINGS 66). The four axes ride in the container header as fields rather than a version number. **The audio level is measured off the whole disc and does not change.** All 201 streams that have audio, 21.5 minutes: the **disc peaks at 946 of 2048, 5.35 dB over the chip's clamp**, in 402 events totalling 44.0 ms. Forty windows encoded at six gains price the choice, and the disc's own level has the **best mean SNR (22.03 dB)** — the gain that guarantees zero clamping costs 0.85 dB across the game to buy back 1.90 dB on the 2.11 seconds that clamp, because the OKI step table's floor is a constant and does not scale (FINDINGS 69). **The predictor does not seek.** The MSM6258's accumulator is a pure integrator with no leakage term, so a branch that hands the chip bytes chosen for a state it is not in produces **a DC offset that does not decay**: playing through, −355 of 511 with AC 0.00, still −108 four seconds later; stopping and re-PLAYing, a single permanent constant of −65. **A re-PLAY is 5.5x better and neither is zero.** The only fix that reaches zero is the encoder's — reset the predictor where a branch can land — and it costs **0.33 dB** (21.99 → 21.66) because the step table re-converges in a few samples (FINDINGS 71). **Branch points do not wait for the buffer.** 5.4% of the game's 612 branch transitions open an input window on the first frame of a clip the disc *seeked to*, so two seeks can fall back to back with no play between them. On the packed branch there is **no refill climb at all** — a record goes straight into GVRAM and the video lookahead is zero records — so the only consumer with any lookahead is the audio one: **1.833 s of sound held against 0.000 s of picture** (FINDINGS 56/70.2). And a DLXP2 group puts its lump *in front* of its records, so a seek lands with its audio behind it: **mean 416.5 ms of silence entering a branch** over the arcade's 409 within-container targets, which is what `pg_aseek`'s second read removes for 11.7 ms (FINDINGS 70.3). ### The parked branch: the codec The codec is a Cinepak-style hybrid — each 4x4 block coded as SKIP, one 4x4 codeword, four 2x2 codewords, or RAW literal pixels, chosen per block by rate-distortion, with a v7 literal-span mode over the top. It works, it is measured, and it is **pixel-exact on the 68000 under two independent CPU cores**; `stream.s` decodes a 120-frame window out of a 256 KB ring on a stock 2 MB machine with the container in a file rather than in RAM. [`docs/img/player.webm`](docs/img/player.webm) — the codec player, 119 frames, 12 fps. 116 are pixel-exact against the reference decoder; **three are torn**, frame *n* on top of frame *n-1*, because MAME captured the screen while the block loop was partway down it. `decode.s` writes straight to the displayed page, so a real player tears the same way, and the media builder **asserts the tear** rather than trimming it. [`docs/img/modes.webm`](docs/img/modes.webm) is the same window with the block-mode map beside it — black SKIP, blue V1, amber V4, red RAW — which is what every cost table in FINDINGS is really about. **Why it is parked.** Its remaining path is a conjunction and the packed one is not: reaching a fitting rate needs a 35% byte reduction, two of its three levers measure inert, and the reward on success is a design at ~100% of the frame against the packed player's 54.9%. **It is kept on disk and not built on** — if B2 comes back "buffer mode blanks", it is the only thing left (FINDINGS 61.8, 48.3). ## What is open **Hardware — this list is the user's, and nothing here can be settled by an emulator.** - **B1. Measure the medium.** Three thresholds, not one: **sustained ≥ 589.6 KB/s** or frames arrive late; **the data-phase BURST rate**, which decides how much of the slot the picture is on screen; and **seek time**, which has no figure at all. Plus what one extra SCSI command costs, which decides the audio cadence. The 0.7–1.7 MB/s usually quoted for BlueSCSI on an X68000 is **folklore with no published benchmark behind it**. - **B2. Does buffer mode blank the display?** `probe_bit11_blank.lua` is written and settles it in minutes on a real board. It decides A vs B above. - **B3. Does a real CZ-6BS1 drive `#EXREQ`?** MAME's card has no request line to the DMAC at all, so every configuration that can be run against it is auto-requested and **charged by time rather than by byte**. A real request line is what single-address 5 clk/B needs. - **B4. Does a real palette register take a byte write?** A negative answer costs 0.28% of a frame and nothing else. - **The MSM6258V, on silicon**: the four axes; whether it resets accumulator, step index and nibble select on PLAY only when it was not already playing; and whether it goes on asserting `#DRQ` while STOPped. **Software, in order.** 1. **The predictor-reset container (DLXP3).** The cheapest thing that takes a measured cost to zero rather than down. The player half already exists (`PG_ARST`). 2. **The audio buffering depth.** `PG_ANBUF` is 3; two slots is one constant and one run. The audio buffer is the packed branch's *only* buffer. 3. **The silent-clip and short-audio cases**: a scene whose audio is shorter than its frames, and a scene with no audio track at all. 4. **The cadence pick.** With a working seek path the silence F=11 costs is zero, so the trade is padding against RAM — plus one SCSI command per branch, which is B1's. **Parked, so it is not re-proposed:** the codec's remaining encoder work (E7, E4, C1); `ring.i`, `xfer.i` and most of `stream.s`, which a DMAC-direct player has no use for because it has no ring, and P4a's wiring with them. **K4 is blocked on B2**, not parked. ## 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 either here or is packaged. | | | |---|---| | the disc | loop-mounted read-only: `udisksctl loop-setup -r -f DRAGONS_LAIR.iso`. Built against a decrypted UDF 2.x image; 7-Zip cannot read UDF 2.x, so use the loop mount | | `python3` | plus **numpy** and **Pillow**, and nothing else. The k-means is hand-rolled rather than pulling in sklearn | | `ffmpeg` / `ffprobe` | frame extraction, and the clips above | | **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 | ```sh export DLX_BDROM=/path/to/your/mounted/bluray # if not /media/$USER/BDROM ./tools/bench/check.sh # the green light, prints ALL GREEN ``` `check.sh` re-runs everything above that a host can re-run: both display regression tests, the rate-control drift gate, the display-path coherency counterexample, a 120-frame 68000 decode on two CPU cores, the ring and paced-ring passes, the DMAC configuration gate, the load-time transforms on both cores, the scene-graph import when a DirkSimple checkout is present, the packed container (with audio, and a silent control beside it) rendered through px68k's own GVRAM model, **the packed player for 120 frames off a real volume with every frame compared**, the audio encoder against ffmpeg's decoder, the sixteen-way decoder identification against the emulated chip, the DLXP2 container and each of its four axes with a negative control on each, the audio level off every stream of the game's own footage, and the refill climb against the arcade's own 612 branch points. `DLX_BDROM` is honoured by every tool that reads the disc. Two stages **skip rather than fail** when their input is absent, because both live outside this repo: `PX68K=/path/to/px68k` for the second-CPU-core gate — the cheapest strong test in the tree, and what licenses the bus and cycle figures — and `IPLROM=/path/to/iplrom.dat` for the DMAC configuration gate. The player runs and the media are rebuilt from them: ```sh bash tools/bench/packed_run.sh # the packed player, 7 runs python3 tools/media/make_packed_media.py # docs/img/packed-player.{png,webm} python3 tools/media/make_readme_media.py # the parked codec's stills and clips ``` Both media builders **gate before they write**: a still or a clip of the player is a claim that the player drew it, so every frame is checked pixel-exact against the container first and the audio cut is checked sample-exact against lump 0. A README that illustrated a pixel-exact player with an approximate picture would be a small lie about the one property this project keeps testing. **Scene selection is a hard-coded stream number, not a search.** The gates use streams `00020` and `00223` of the disc's 224 `.m2ts` files. A different pressing may number them differently, and if so the green light will extract the wrong footage rather than fail, so check that `tmp/fr_singe/` looks like the Singe encounter before trusting any figure. **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`** (FINDINGS 25.1). **Nothing outside-derived is committed here.** The scene graph is regenerated from a reader's own clones into gitignored `tmp/`, and `tools/import/scenegraph.py` is the single file in the repo coupled to those projects — everything downstream reads `DLXSCENE1`, this project's own schema, with the sources' attribution carried in it. DirkSimple is zlib (Ryan C. Gordon); the SNES chapter set is MIT (Chad Doebelin) and, by its own README, *derived* from DirkSimple rather than an independent transcription. ## Encoders **The live one is the packed container**, and it has no rate control in it because there is no rate lever: ```sh # the gate window: stream 00223, 10.0 s from 539.4 s, the same seconds both times python3 tools/encoder/extract.py 00223 tmp/fr_singe 12 crop 539.4 10.0 python3 tools/encoder/extract_audio.py 00223 tmp/au_singe.raw 15625 539.4 10.0 python3 tools/encoder/pack.py tmp/fr_singe out.dlxp --audio tmp/au_singe.raw ``` `dlxp.py` is the format and the one place the interleave, the 97-sector record, the audio cadence and the lump payload are stated. It encodes in ~3 seconds because there is no k-means in it. `--scene-palette --no-palette` is the fallback container: **30.79 dB, zero palette churn, 576.0 KB/s**, still ahead of the codec as the display renders both, and it is what ships if B2 says the layer blanks — because a per-frame palette changes **231 of 256 entries every frame**, and a picture under the neighbouring frame's palette is **12.8 dB worse** ([`docs/img/palette-mismatch.png`](docs/img/palette-mismatch.png)). `adpcm.py` is the MSM6258 codec and it carries **two decoders on purpose**: the module defaults are ffmpeg's, so `tools/bench/verify_adpcm.py` stays a check against an independent implementation, and `adpcm.CHIP` is the set measured out of the machine's own chip. Anything that encodes *for* the machine passes `CHIP` explicitly. **The parked codec's encoder is kept and still runs:** ```sh python3 tools/encoder/encode.py tmp/fr_singe out.dlx --profile scsi --preview p.png ``` `--kbps` is the quality rate point and `--span-kbps` the ceiling the span pass may draw on; `mu` is bisected per frame against the 68000's own decode budget so a frame also *decodes* in time. One profile, `scsi`; the 110 KB/s `sasi` profile was dropped on capacity rather than bandwidth — a SASI volume is 40 MB and the game is 146 MiB even at that rate. The RAW escape means `lam=0` is pixel-exact against the palettised frame. See FINDINGS 31/41/44 before changing any of it. ## Documentation - **`docs/STATUS.md`** is the current state, working setup, blockers and next steps, newest session first. **Start here.** It also lists what has been explicitly abandoned, so old ideas do not get re-proposed. - **`docs/ROADMAP.md`** is the remaining work to a completion target. Read it with STATUS rather than instead of it: STATUS holds the measurements, ROADMAP holds the shape and goes stale first. - **`docs/FINDINGS.md`** is measured hardware facts, content statistics, the codec decision, and a section on measurement traps that produced three separate false results. Read §4 before trusting any pipeline number. It is append-only and later sections overturn earlier ones; superseded sections carry a blockquote pointing at the correction. - **`docs/BENCHMARK.md`** is how to measure the storage subsystem, and why a bandwidth figure out of MAME would be meaningless. - **`docs/HARDWARE.md`** is the X68000 GVRAM/CRTC reference. ## Layout ``` src/player/ packed.s THE LIVE PLAYER: display bring-up, the 193-entry DMA chain, its own V-DISP clock, READ(10) off a real volume, and the MSM6258 on channel 3 -- a lump ring, the remainder accumulator that stops the padding becoming drift, a service routine that runs from INSIDE dma.i's transfer wait (0.51 ms of seam against 236), and pg_aseek. dma.i the HD63450 channel: array chaining, held and stealing, and DM_HOOK. scsi.i the MB89352: selection, READ(10), the data phase. adpcm.i the audio transport, every register byte of it decoded out of the IPL ROM rather than recalled. clock.i the frame clock, off the CRTC's V-DISP. geom.i the constants, in one place. decode.s the parked codec's 68000 decoder (1,296 bytes, stream.s asserted), the same decoder behind a bounded ring, ring.i the ring producer, the transport under it, and the xfer.i load-time codebook/palette transforms. OUT OF THE load.i VIDEO PATH: a DMAC-direct player has no ring. frame.i the block loop and span chain, included by both, so there is exactly ONE copy of the bytes every cycle constant is fitted to. The span pass is blit.s v7 verbatim -- do not tidy it. tools/encoder/ dlxp.py THE LIVE CONTAINER (DLXP2) and pack.py writes it. pack.py Nothing is shared with the codec's writer on purpose. adpcm.py the MSM6258 codec, carrying ffmpeg's decoder and the chip's measured one side by side. dlx.py the codec's container and its REFERENCE DECODER, encode.py ground truth for the 68000; the encoder, rate spans.py control, and the v7 span geometry. Parked, kept. extract.py frames and audio off the disc. tools/bench/ check.sh the green light. packed_run.sh the packed player: gate, rate, audio, held, seek. verify_packed.py / verify_packed_audio.py the two gates that matter. The audio one reads the SPEAKER, one delivered byte at a time, because every counter in the player stayed right through a bug that overwrote the buffer the channel was reading. crtc_mode.lua the SINGLE SOURCE OF TRUTH for CRTC R00-R08 and R20. Do not write CRTC values anywhere else. probe_bit11_blank.lua B2, ready to run on a real board. c68k/ a SECOND emulator for every 68000 cycle figure: px68k's CPU core, no SDL, no ROMs. It also counts BUS cycles, which MAME cannot report. gvpack/ the same argument for the DISPLAY: px68k's own x68k/gvram.c, so the address decode, the R20 bit-11 write path and the scroll wrap are its code and not a model of it. blit.s the full-frame GVRAM blit, timed on the 68000 itself. V8 is the packed one: 227,553 clocks. tools/analysis/ measurement scripts, numbered in the order they were written. Run from the repo root. 01 and 02 are marked BROKEN deliberately and kept as regression references; 10 is a COUNTEREXAMPLE and exits non-zero by design, demonstrating that the two-display-path plan corrupts 70 of 120 frames. 15 is the bus occupancy model, 19 the ring's ADDRESSES (not its occupancy), 21 the IPL ROM's DMAC configuration, 25 the scene graph, 30/34 the packed containers, 31 the display duty, 35 the audio level off the whole disc, 36 the climb against real branch points, 37 what a branch costs the chip. buscost.py is the shared bus-cycle table; the per-block constants live in vq_hybrid.py and are imported, never copied. tools/import/ scenegraph.py -- the ONLY code in this tree coupled to somebody else's source. Output is gitignored derived data. tools/media/ make_packed_media.py builds the live player's still and clip; make_readme_media.py builds the parked codec's. Both gate before they write. tools/vasm/ vasm m68k assembler, binary plus source tarball. docs/img/ the stills and clips above, built from real emulated runs. assets/, tmp/ extracted frames, audio and run output (gitignored). ``` Source media (`DRAGONS_LAIR.iso`) and ROMs are gitignored. Supply your own.