Files
Dragon-s-Lair-X68k/docs/ROADMAP.md
T
prosolis 07f36c2af9 Put the palette on the channel, and find one start paints a whole frame
ROADMAP K1, the packed player's one open structural item.  A frame is a picture
AND a palette, and no run in this tree had pointed a DMA channel at the palette
registers.  dmagate.s runs 7-9, gated by dma_run.sh and check.sh:

  7. 512 B off the disc into $E82000, bus held -- byte-exact in 256 register
     words, read back OUT OF the registers by the 68000;
  8. the SAME transfer aimed at RAM -- byte-exact at $2C000, and 256 of 256
     palette words still read the poison the CPU wrote, which is what attributes
     run 7 to the channel's MAR rather than to the readback path;
  9. ONE array-chained start across two kinds of destination -- the palette and
     six picture rows at the 1,024 B line stride, 2,048 B byte-exact.

So a packed frame is one channel start: a 193-entry array, palette first, CPU
halted from the first byte to the last.  The array is scene-constant, because
the packed layout spends both 256-colour pages and there is no page to flip.
What is left on the CPU per frame in the video path is the channel start and the
READ(10) -- no per-frame PAINT, which is not the same claim as no per-frame CPU.

The destination is POISONED first (62.1).  Runs 4-6 wrote into RAM that was zero
and GVRAM that was stale against a record that is mostly pad; "it matches the
disc" was weaker than it read as.  The host counts whether the poison actually
discriminates instead of assuming it: 511 of 512, and the gate refuses under 500.

And it opened a hardware item (62.4, ROADMAP B4).  MAME maps the palette to
palette_device over memory_array, whose write16 is a plain COMBINE_DATA -- RAM
that honours mem_mask, with no handler that could refuse a byte write.  Unlike
GVRAM's 256-colour arm there is nothing here to be wrong about, so the run
bounds the model and not the board.  What a real palette register does with a
byte write is unmeasured.  A negative costs 0.28% of a frame and nothing else.

29_packed_player.py now also prints the two rows with the per-frame palette
charged -- 55.7% of a frame on the chain, 582 KB/s -- alongside the picture-only
figures the codec comparison is quoted against.

check.sh ALL GREEN before (tmp/check_s30_start.log) and after
(tmp/check_s30_end.log).

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-25 07:06:44 -07:00

713 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Roadmap — remaining work to a completion target
Written end of session 19 (2026-08-24), against a tree that is ALL GREEN.
Amended end of session 21: P1 done, P2 half done (FINDINGS 53).
Amended end of session 22: P3 done (FINDINGS 54).
Amended end of session 23: P5 done (FINDINGS 55).
Amended end of session 24: G1 done (FINDINGS 56).
Amended end of session 25: P4 HALF done (FINDINGS 57).
Amended end of session 26: P4b done, P4a is the last open item before M2
(FINDINGS 58).
Amended end of session 27: P4a done at the transport level; THE RE-ENCODE
BUNDLE under P2 is now the only thing between this tree and M2, because 59.4
made sector-aligned records a precondition the transport enforces rather than a
preference (FINDINGS 59). **And 59.7 re-ranks what is left: the frame affords
6.74 clocks a byte, a dual-address byte costs 9, so B3 stopped being a constant
to look up and became the question of whether the design fits at all.**
Amended end of session 28: **THE RE-ENCODE BUNDLE IS DONE — all four items, one
re-measurement (FINDINGS 60).** The container is DLX5, sector-aligned, and the
disc now moves exactly the records. Two of the four closed as NEGATIVES: E2's
`--spans all` default is refused on measurement, and E3's joint span/lam
selection emits byte-identical containers because `lam` never leaves its floor.
**What is left of M2 is P4a's wiring** — the DMA channel behind `ring.i`'s
mailbox — and the budget did not move: headroom 6.74 -> 6.69 clk/B, so every
conclusion in 59.7 stands.
Amended end of session 29: **THE DECODER-FREE PACKED PLAYER IS BACK, MEASURED,
AND IT FITS THE CLOCK BUDGET THE CODEC MISSES (FINDINGS 61).** The packed
full-frame blit is **27.3%** of a 12 fps frame — measured, not assumed — a
channel fills GVRAM in buffer mode off the disc with the CPU halted, and it
walks the 1,024 B line stride itself through array chaining. At the 9 clk/B
floor the codec is 110.4% and a decoder-free packed player is **55.2%**. It asks
**576 KB/s, fixed**, against E7's 327 KB/s target. **So B1 stopped setting how
much headroom the player has and started deciding WHICH PLAYER EXISTS**, and B2
stopped being a nice-to-have. The codec cannot be packed: 47.6.4 is closed and
the answer is no, both ways (61.3).
Amended end of session 30: **K1 IS DONE AND THE ANSWER IS THE GOOD ONE
(FINDINGS 62).** A channel writes the palette registers at `$E82000` byte-exact,
and ONE array-chained start crosses from device registers into GVRAM — so a
frame is a palette entry and 192 row entries, started once, with the CPU halted
throughout, and the array is scene-constant. **It opened B4**: MAME models the
palette as plain `COMBINE_DATA` storage with no handler to be wrong about, so
the run bounds the model and not the board, and what a real palette register
does with a byte write is UNMEASURED. B4 is the cheapest hardware item in the
project and a negative costs 0.28% of a frame. **K2, the packed container, is
next.**
**THE COMPLETION TARGET IS M3, THE VERTICAL SLICE** (USER DECISION): one scene
tree — a decision point, two outcomes, a death clip — with audio, streaming from
a real SCSI volume on a stock 2 MB machine, playable. That is the point at which
every layer of this design has been shown to work at once. M4 is listed because
it is real work, but past M3 it is content grinding rather than open questions.
`docs/STATUS.md` remains the session-by-session record and the handoff. This file
is the shape of what is left; where the two disagree about what is done, STATUS
is the one with the measurements and this one is the one that goes stale. Both
were wrong about two encoder gaps until this file was written — see "What was
already done" below.
---
## Status of the four resources
The project's own framing, restated because every item below is priced in one of
these units:
| resource | state |
|---|---|
| **68000 local bus** | the binding one. Decoder occupies 86.7%; 52 of 53 missed frames miss on the bus, not the clock (FINDINGS 38). |
| **68000 clocks** | measured, and the rate controller binds on them. |
| **Delivery rate** | **no working figure, deliberately** (FINDINGS 50, USER DECISION). Every tool REQUIRES an explicit rate. |
| **Seek time** | **no figure at all, and never had one.** 51.3/51.4 made it matter. |
| **W, clocks stolen per delivered byte** | 5 single-address held, 9 dual held, 12 single arbitrated; the IPL ROM's own disk channel is **16..19** (52.5). **The largest open number in the project.** Session 27 added the row underneath it: with **no external request line** on the card (59.2) the channel is auto-requested and is charged **by time rather than by byte**, so at 460 KB/s a 50% bus share costs **10.61 clk/B** and a smaller share cannot carry the rate at all (59.3). |
| **The frame's headroom for a transport** | **6.69 clk/B** — 30.2% of a 12 fps frame, after the MEASURED decode (68.6%) and best-case audio (1.25%), at the DLX5 gate container's **37,585 B delivered record** (session 28: the sector pad is delivered, so `15_bus_occupancy.py` charges it). **It is the number every row above is read against**, and a dual-address byte's floor is 9. It was 6.74 against the DLX4 container; the bundle moved it by 0.05 and moved no conclusion. |
---
## What was already done, and was still on the list
Found while inventorying for this file. Both had been closed in code for several
sessions and were still listed as open gaps in `docs/STATUS.md`:
- **4-byte record padding.** `DLX2`, `encode.py:139-156`, inside rate-control
accounting, reported per frame and per second.
- **CPU cost in the mode decision.** `vq_hybrid.py:218`, priced against measured
per-mode cycles with the exact clustered SKIP rule.
Both entries are now struck in STATUS. **The lesson is procedural: a gap list
that is only ever appended to manufactures phantom work.** Anything crossed off
below should be crossed off in STATUS in the same sitting.
---
## Blocked on hardware this tree does not have
None of these block M2 or M3 software work, because session 18 forced every rate
to be an explicit argument. They set constants, and two of them decide how much
headroom the finished player has.
**B1. Measure the BlueSCSI — throughput AND seek time.** **Session 29 gave this
a second acceptance test that is not a codec figure at all: 576 KB/s SUSTAINED,
which is what a decoder-free packed literal frame costs and cannot be talked down
from (FINDINGS 61.5).** A codec's bitrate is a lever; a literal frame's is
geometry. So the measurement now has three thresholds to be read against —
453.6 KB/s (the gate container needs no prefill), 327 KB/s (E7's target at the
dual-address floor) and 576 KB/s (no decoder at all) — and which of them the
medium clears decides which player gets built.
Throughput has an acceptance test already derived from real record sizes:
**513.2 KB/s** for the session-14 candidate, **453.6 KB/s** for the DLX5 gate
container (`19_ring_stream.py`, FINDINGS 49.5; the figure was 451.4 before
session 28's re-encode and the sector pad raised it). Seek time has nothing.
51.3/51.4 is why the second half matters: slack is *accumulated* out of
`pipe - wire`, so what a branch point costs is set by the rate and the time since
the last branch, not by the ring size. At 460 KB/s every ring from 192 KB to
512 KB is rate-bound and never fills. **Do not substitute a guess** — run at
several explicit rates and report the sensitivity. That is exactly how the
retired pipe constant survived five sessions after 42.1 called it folklore.
**B2. Does buffer mode blank the display?** `probe_bit11_blank.lua` is written
and settles it in minutes on a real board. FINDINGS 48 shifted the prior toward
MAME and toward "unusable" — **do not pre-build on 1.0 B/pixel**. Same sitting:
the priority register `0xE82500` at `0x0000` (47.3).
> **Session 29 raised what this is worth, and gave it a number to be worth
> (FINDINGS 61).** It used to gate a derived halving. It now gates a player that
> has been measured to fit a budget the shipping design misses — 55.2% of a frame
> against 110.4% at the 9 clk/B floor. And 61.6 found an asymmetry worth carrying
> to the board: the black interval is the PAINT, not the frame, so a CPU-painted
> packed player is dark for **27.3%** of a frame while the cheaper DMAC-direct one
> is dark for **30..113%**. Under MAME's reading the cheap architecture is the
> dark one. **B2 and B1 are now the same decision from two sides, and B2 is the
> five-minute half.**
**B4. Does a real palette register take a BYTE write?** (62.4, new session 30.)
`$E82000` is 256 16-bit registers. The decoder-free packed player's per-frame
palette rides the frame's array chain as a 193rd entry, which means a
dual-address channel with an 8-bit device port writes it **one byte at a time**,
even bytes into the high half of a register and odd into the low. **MAME cannot
be asked**: it maps the palette to `palette_device::read16/write16`, which is
`memory_array`'s `COMBINE_DATA` over plain storage — RAM that honours
`mem_mask`, with no handler that could refuse. So the run in 62 is a statement
about the model and not about the board.
**It is the cheapest item on this list, cheaper than B2**: write `$A5` to
`$E82000` and `$5A` to `$E82001` from the CPU and read the word back. If it
comes back `$A55A` the palette rides the chain. **The blast radius of a negative
is small and known** — the palette leaves the chain and the CPU writes 256 words
a frame at 61.9's derived 0.28% of a frame — which is why this is B4 and not
above B2.
**B3. Single-address vs dual-address DMA — and now, first, DOES THE CARD DRIVE
`#EXREQ`?** 242 KB/s and 0.69 dB was the old framing. **Session 27 promoted this
to the item that decides whether the design fits at all (59.7).** The frame
affords **6.74 clocks a byte**; a dual-address byte is **9** — a 4-clock read of
the device plus a 5-clock write to memory — so **no dual-address configuration
fits this container at 12 fps, at any delivery rate and any GCR share.** Single
address is 5 clk/B and fits at 92.2% of the frame with 7.8% to spare, and it
needs the device to ACK the DMAC directly, which needs the request line.
**MAME cannot answer it**: `x68k_scsiext.cpp`'s `drq_w` only stores a flag and
the expansion slot has no request path to the HD63450 at all (59.2). The slot
PINOUT has `#EXREQ` at B36 and `#EXACK` at B37, so the provision exists on the
real board. **What is wanted from hardware is therefore narrower and sharper
than "disassemble the ROM": does a CZ-6BS1 assert `#EXREQ` during a data phase,
and will the HD63450 run a single-address channel against it?** A scope or a
logic analyser answers the first; the second is a program the player can run.
Sourcing `scsiexrom.bin` (8 KB, CRC `7be488de`) and disassembling its DMAC init
is still the cheapest paper route to the same answer, because whatever Sharp's
own driver programs into DCR's DTYP is a statement about what the card supports.
FINDINGS 48.4. Not on this machine (checked, session 18).
**Ranking, amended session 29.** It was: B1 sets how much headroom the player
has, B3 decides whether there is any. FINDINGS 61 adds a third reading — **B1
and B2 together decide which player exists.** If the medium clears 576 KB/s
sustained and buffer mode does not blank, the decoder-free packed literal fits at
the dual-address floor B3 cannot get under, and B3 stops mattering for video at
all. If it does not, B3 is still the question. The three hardware facts are no longer
independent, and B2 is by far the cheapest of them.
> **ENCODER WORK IS PARKED — USER DECISION, session 29.** The first draft of this
> amendment said "nothing here is a reason to stop work on the codec". That does
> not survive its own arithmetic. It rested on comparing the packed player's
> 576 KB/s against **E7's 327 KB/s target, which does not exist**: the codec that
> exists is 440 KB/s and 110.4% of a frame, so the real gap is 1.31x, not 1.76x.
> And the branches are not symmetric. **Packed needs two facts** — buffer mode
> does not blank, medium clears 576 KB/s. **The codec needs E7 to succeed** —
> unproven, and 60.4/60.5 measured two of its three levers inert — **AND** the
> medium to clear 327, **AND** it ships at ~100% of the frame with no margin,
> which is where 55.2% is now.
>
> **E7 and E4 are both parked**, E4 included: `H.build`'s k-means builds VQ
> codebooks and a literal player has no VQ. C1 is gated by E4 and follows.
>
> **The codec is KEPT AND NOT BUILT ON.** That is inventory, not work. B2 is
> unanswered and 48.1's prior leans against packing — an assertion against a
> silence — and if buffer mode blanks there is no version of the packed player
> that is merely expensive (48.3), at which point the codec is the only path
> left. Keeping a working decoder on disk costs nothing; building on it costs
> sessions.
**The older ranking, which still holds inside the codec branch:** B1 (throughput
and seek) sets how much headroom the finished player has. **B3 decides whether
there is any.** If the card drives `#EXREQ`, the ladder applies and the design fits with
room. If it does not, the fallback is limited-rate auto-request at a share the
player picks (P4c), and the container has to come down to **328 KB/s of payload**
to fit at the 9 clk/B floor — 34% below where the gate container sits (59.7).
> **Session 20 moved the prior hard, and it moved the wrong way (FINDINGS 52.5).**
> The IPL ROM *is* on this machine, and `tools/analysis/21_iplrom_dmac.py` reads
> its HD63450 setup: the on-board disk channel (ch1, SASI) is `DCR = $80` —
> **dual address, 8-bit port, cycle steal WITHOUT hold**, with `REQG = 10`
> external request, i.e. a full bus arbitration per byte. That is **16..19
> clocks per delivered byte**, above the whole 5..12 bracket 42.4 costs P4 in.
> Same vendor, same DMAC, same class of 8-bit port — but it is *not*
> `scsiexrom.bin`, so B3 stays open. What it changes is that a cheap
> configuration is now the thing that has to be **shown**, not assumed.
---
## The packed branch — what building it means (session 29, USER DECISION)
**This is where the work goes now.** FINDINGS 61: a decoder-free packed literal
player is **55.2% of a frame at the 9 clk/B dual-address floor** against the
codec's 110.4%, and **+4.89 dB** on the shipping container because a literal
frame is not tied to a scene palette the codec's codewords index into. It costs
**582 KB/s, fixed, with no lever.** Three items, in order.
~~**K1. Can a DMA channel write the palette registers at `$E82000`?**~~
**DONE, session 30 — FINDINGS 62. YES, in this model.** `dmagate.s` runs 79:
512 B off the disc into the whole graphic palette, **byte-exact in 256
register words** read back out of `$E82000` by the 68000; the same transfer
aimed at RAM leaving the palette as the CPU poisoned it, which is what
attributes the first run to the channel's `MAR`; and **ONE array-chained start
crossing from device registers into GVRAM**, which is the shape of a whole
frame. The destination is POISONED first (62.1) because "it matches" was a
weak claim against a record that is mostly pad, and the host counts the
poison's discriminating power rather than assuming it: 511 of 512.
**And the array is SCENE-constant** (62.3) — the packed layout spends both
256-colour pages, so there is no page to flip and the 193 destinations never
change; the 1,158 B array is built once at scene setup. What is left on the
CPU per frame in the video path is the channel start and the READ(10), and
neither is priced — say "no per-frame PAINT work", not "no per-frame CPU work".
**It opened B4** (62.4): MAME models the palette as a generic `palette_device`
over `memory_array`, whose `write16` is a plain `COMBINE_DATA`, so it has no
handler to be wrong about and cannot discriminate. And it filed one open
design choice, 62.5: palette FIRST or 193rd is visible on screen and is not
decided.
- **K2. A packed container.** Quantise to **254** colours (index 0 is the
transparency key, black at 255 — 47.2), interleave column *i* with *i+128*,
emit a per-frame palette, sector-align the record. Simpler than a coded one:
no VQ, no rate control, no mode map, no `lam`. `encode.py` already aligns
(DLX5) and `prep_frame.py --pack-transparent` already emits the layout.
**Re-derive 61.9's 34.08 dB against `vq.scene_palette` here** — the measured
figure is PIL's MEDIANCUT and is a direction, not the player's number.
- **K3. End to end, off the disc.** Palette, page-1 X-scroll 384, priority
`vc1 = 0x0002`, R20 bit 11; one chained DMA a frame. Gated pixel-exact over the
same 120-frame window the decoder is gated on.
**What K3 deletes, and why that is a risk and not a win to be banked:** a
DMAC-direct packed player has **no ring**`ring.i`, `xfer.i` and most of
`stream.s` leave the video path, and **P4a's wiring is parked with them.** A
simplification that large usually hides something, and 61.7.2 names the specific
untested thing: a chained transfer has never run back to back at 12 fps.
**K1 and K2 survive a bad answer to B2. K3 does not.** ~~Do K1 first.~~ **K1 is
done (session 30, FINDINGS 62); K2 is next.**
---
## M2 — a player, as opposed to a decoder
`decode.s` draws pixel-exact frames from RAM Lua pre-loaded; `stream.s` decodes
out of a bounded ring fed by a host file on a paced clock. Neither is a player.
**Exit criterion: boots from a real SCSI volume on a stock 2 MB X68000, plays
one scene at 12 fps from disc, no host-file pipe, no Lua in the loop. Silent.**
~~**P1. Codebook expansion on the 68000.**~~ **DONE, session 21 — FINDINGS 53.**
`src/player/load.i` expands both codebooks out of the raw container header,
byte-exact against `dlxload.py` on both CPU cores. **9.26 ms**, and it was
priced where it lands rather than treated as free setup: the scene header is
**5,920 B that no rate table in this tree counted**, and in the currency of
51.3 — accumulated slack — those bytes lengthen the refill climb by 138 ms at
488 KB/s and by **1.099 s at 451.4 KB/s**, because the surplus they are divided
by goes to zero. The whole fixed cost of a scene change is about a third of one
frame slot; what makes a branch point expensive is still the seek and the climb.
Shipping the codebooks pre-expanded was considered and refused: it trades
9.26 ms of CPU for 5,120 more header bytes, which is a wash in milliseconds and
not a wash in kind (53.6).
~~**P2. Palette packing on the 68000.**~~ **DONE, session 28 — FINDINGS 60.**
The half that was open was the encoder's, and it closed with the whole bundle
below. Session 21 — FINDINGS 53 — did the packing itself:
~~The encoder still emits RGB888; the X68000 word packing is Lua-side.~~ The
packing is on the 68000: `pal_pack` writes 256 words straight into `$E82000`
with `I` chosen per entry by minimum squared error (**1.96 dB**, 23.3), gated on
the words read back **out of the palette registers**. 9.70 ms per scene, plus
5.29 ms of scene-independent table build hoisted to boot (53.3).
~~**What is left is the other half of the sentence: reserve index 0 as black with
`I = 0` (23.4).**~~ **DONE, session 28 — 60.3.** `VQ.scene_palette` quantises
the picture into 255 entries and holds index 0 at (0,0,0); `pack_palette` gives
it `I = 0` by its own minimum-squared-error rule, so 23.3's "the bars sit at
RGB (4,4,4)" needed no special case. **0.04 dB** on the Singe window, palette
ceiling unmoved. Black is reserved, not withheld — the mapper may still spend
index 0 on genuinely black pixels; what it buys is that index 0 is black
whatever the scene contains.
~~**THE RE-ENCODE BUNDLE, collected here because it is now four items and they
share one re-measurement.**~~ **DONE, session 28 — FINDINGS 60. Two of the four
closed as NEGATIVES, which is the more useful half.** The four were:
1. ~~reserve palette index 0 as black, `I = 0`~~ **DONE** (23.4, 60.3);
2. ~~`--spans all` as the default~~ **MEASURED AND REFUSED** (E2, 60.4).
267.9 KB/s / 28.88 dB / 2 frames late at `need`, against 448.2 KB/s /
29.07 dB / 1 late at `all`: **+67% of the wire for +0.19 dB and one frame
of 120**, against a container the budget already says is 35% too big.
`need` stays the default. **It was called "the loaded lever on the byte
side" and it is — it is just loaded the wrong way**, and E7 is why. The
GATE container keeps `all`: it is a fixture, not a recipe;
3. ~~re-derive span selection jointly with `lam`~~ **IMPLEMENTED, MEASURED,
NO-OP** (E3, 60.5). `--joint-spans` hands the span pass's freed bytes back
to the lam search and re-spans; all four cells of `{need,all} x
{greedy,joint}` are BYTE-IDENTICAL, and so is `--rc-floor open`. **`lam`
never leaves its floor on any of 120 frames at either floor the encoder
offers** (44.3), so there is nothing to spend the freed bytes on. The code
stays, defaulted off, because a container that moved `lam` off its floor
would make the question live again;
4. ~~**sector-align every record**~~ **DONE — the container is DLX5** (58.3
option C, promoted to a precondition by 59.4; 60.1). Realised cost
**+0.48%** on the wire against the +0.43% predicted, zero clocks, and
**120/120 records start on a sector boundary** where 3/120 did. The disc
and the ring now move the SAME 4,510,208 B and check.sh gates on that
identity, both figures read out of the container instead of written into
the script — the old literals went red on the re-encode, correctly.
**The consumer had to be told too** (60.2): `stream.s` released the ring
to the last byte it READ, which strands up to 511 B of pad a record, and
the ring's own audit caught it on frame 0 while every frame still decoded
pixel-exact. The release rounds to `RECALN` now.
The letterbox no longer gets the palette's closest thing to black — item 1 put
true black at index 0, and `load.i` needed no change, as it said it would not.
~~**P3. A real frame clock.**~~ **DONE, session 22 — FINDINGS 54.**
`src/player/clock.i` derives the tick from the CRTC's own V-DISP through the
MFP, with a remainder-keeping divider whose two constants are read out of the
CRTC at init. **Exactly 12.000000 fps, by construction** — measured at 649 ticks
over 3,000 refreshes where 649.1429 were due, so the remainder still held and
nothing accumulated. **181.35 clocks per V-DISP, 838 per frame, 0.1006% of the
budget**, timed by the 68000 itself because the host's 17.64 ms granularity
cannot see it. `PACEON=0` free-run is untouched and so is the wait loop; the
free-running path executes none of the new code.
The item said "MFP timer or VBL" and **neither can do it alone**: 4e6/12 is not
an integer and no prescale/data pair reaches 12 Hz, while the slowest MFP tick
of any kind is 78.125 Hz; and the raster's 55.4577 Hz has no whole divide near
12 either (4 gives 13.86, 5 gives 11.09). `tools/analysis/23_frame_clock.py`
walks the whole space rather than asserting it.
**What it exposed is bigger than the item.** 12 fps on a 55.4577 Hz raster is
4.6215 refreshes, so a frame gets **4 refreshes (72.13 ms) or 5 (90.16 ms)** and
**there is no 83.33 ms frame** — that figure is the mean slot, and 37.9% of slots
are 13.4% under it. The cadence was ALREADY in every host-paced result in
FINDINGS 49/51, because `stream.lua`'s tick is sampled at frame boundaries and
its gaps were always 4 or 5; nothing had named it. On the gate container it
costs 4 frames of 120 their idle against 1 for the nominal model. **It is not a
dropped frame** — the pace gate lets an overrun eat the next frame's idle and
the clock recovers — but it means every budget in this project is priced against
a slot 37.9% of frames do not get. 54.4.
**Also struck: MAME's raster runs 2.22% fast** (`refresh_mode()` builds the frame
period from `htotal - 8`), so the tree's "1/55.46 s granularity" was 1/56.69 s
throughout. No 68000 cycle figure moves — the CPU clock is unrelated to the
screen — but anything paced by the raster does. 54.5.
**P4. Real transport. P4b DONE, session 26 — FINDINGS 58. P4a DONE at the
transport level, session 27 — FINDINGS 59. What is now between this tree and M2
is THE RE-ENCODE BUNDLE under P2, because the channel refuses a windowed read
(59.4) and 117 of 120 records need one.**
~~Drive the MB89352 instead of a host file.~~ `src/player/scsi.i` selects a SCSI
target and issues READ(10) on the 68000, with no IOCS and no host in the
transfer path: **4,096 B from LBA 0 and 2,048 B from LBA 1000, both byte-exact**
against the host's copy of the same volume.
**This item was listed as blocked and was not.** Session 21 recorded "MAME's
`x68000` has no MB89352 path"; `-exp1 cz6bs1` instantiates one, and FINDINGS
32.4 had read that card's DMA glue back in session 9. The real gap was the 8 KB
`scsiexrom.bin` MAME needs to instantiate the card and **the player never
executes**; a blank placeholder on a separate rompath settles it. **B3 still
wants the real ROM's bytes** and is untouched by this.
**What is left is the half that decides the project**, and it is now two pieces:
~~**P4a. A DMAC configuration that HOLDS THE BUS.**~~ **DONE at the transport
level, session 27 — FINDINGS 59.** `src/player/dma.i` programs HD63450 channel 1
and takes the DATA IN phase: **the same 2,048 B off the disc three ways — PIO,
held, stealing — all three byte-exact.** 57.3's warning was met rather than
worked around: the evidence never reads `$EA0015`. **MTC is sampled by the
instruction after the one that starts the channel, and held it reads zero of
2,048** — the whole transfer happened between two instructions, because the
68000 did not execute in between — against the full count and 426 CPU loop trips
for the stealing configuration. Put the stealing registers in the held slot and
every byte still arrives and the gate goes **red**, which is what says the
counter can come out different (58.3's vacuous-counter trap, avoided
deliberately).
**Three bounds on the apparatus, read out of MAME's source and not inferred**
(59.2): the card has **no request line to the DMAC** (its flow control is
DTACK), so external request — the mode the `W`=5 and `W`=12 rows assume —
cannot be run; **single address** cannot be run either (only channel 0 has
device callbacks); and **only burst is modelled as held**. Of the four rows of
the ladder exactly one, dual address held, has a code path here, and it is the
one demonstrated. The slot pinout has `#EXREQ` at B36, so a real card plausibly
drives it — **that is now B3's sharpest form**.
~~**What is left of P4a is downstream of the container, not of the DMAC**
(59.4)~~**and that block is GONE as of session 28.** `sc_in_data` refused a
windowed read because a channel cannot drop the 300 B in front of a record; the
container is DLX5 now and no record asks for a window. `xfer.i`'s sector
arithmetic already degenerates correctly — `SC_WSKIP` is 0 and `SC_WKEEP` is the
whole record on every one of the 120 — so **what is left of P4a is the wiring:
which loop moves the bytes.** `dma_run.sh`'s windowed-read refusal stays as a
negative control rather than as a description of the container. **This is the
next item, and it is the last one before M2** (60.9).
**P4c (new, and it is a DESIGN CHOICE the tree had not named).** Auto-request is
charged **by time, not by byte** — the channel spends its share of the bus
whether or not a byte is there, so halving the delivery rate DOUBLES the CPU
cost of the same record. The MC68450's GCR sets that share: `BT`/`BR`, four
values, 50/25/12.5/6.25%. `tools/analysis/28_autorequest_cost.py` prices it
against an explicit rate; at 460 KB/s **only the 50% share carries this
container**, at 10.61 clk/B and 47.6% of a frame per record, against 40.4% for
the `W`=9 row and 391.8% measured for PIO. **If B3 comes back saying the real
card drives `#EXREQ`, the ladder applies and this is the fallback; if it does
not, this IS the cost model** and the GCR pair is a number the player has to
choose.
~~**P4b. `scsi.i` behind `ring.i`'s `XF_*` mailbox.**~~ **DONE, session 26 —
FINDINGS 58.** `src/player/xfer.i` answers the mailbox with a real READ(10) per
record: **120 records, 4,488,588 B, pixel-exact, out of the same 256 KB ring,
with a real mid-stream seek in a second pass**. The tiling is the SAME 18 wraps
and 14.7 KB mean hole that 49.4's host producer and 55.4's modelled transport
produced — a third transport, same placement, which is the assertion that
`ring.i` could not tell which side of the seam answered it. The change above the
seam is two `bsr`s, and the one in `ring_seek`'s quiet-wait is not optional:
with the transport inside the machine, that loop is the only thing that can
retire an outstanding request.
**What it cost is the finding, and it re-prices P4a.** `tools/bench/
xfer_cost.sh` subtracts the same 120 frames run twice and gets **87.28 clocks
per delivered byte** — against the 68000's own cycle table for the loop, which
says **87.15**. **0.2% apart**, so it is the instruction stream and not MAME's
device model, and it is therefore the first number this rig has produced that
survives leaving the emulator. At this container's 37,405 B mean record that is
**391.8% of a 12 fps frame**, and the machine's own V-DISP clock agrees from the
other end: **2.57 fps**.
W = 5 single address, bus HELD ............................ 22.4%
W = 9 dual address, held .................................. 40.4%
W = 12 single address, arbitrated .......................... 53.9%
W = 19 dual address, arbitrated -- the IPL ROM's own (52.5) . 85.3%
PIO 87 MEASURED, session 26 ................................ 391.8%
**So P4a is worth 4.6x the worst DMA configuration in this tree and 17.5x the
best**, where before this session it was worth 9 against 19. `W` itself **did
not move by one clock** and is still the largest open number — but what depends
on it just got much larger.
**One more thing P4a inherits (58.3).** A record is not a sector: 117 of 120
start part way into one. PIO absorbs that for free because the CPU is already
touching every byte and simply does not store the ones outside the window — a
property that **disappears the moment the DMAC takes over**, because a channel
writes a contiguous run and cannot drop bytes. The three ways out price as
+1.34% wire and no DMA (windowed PIO), +1.34% wire and **+5 clk/B of copy**
(bounce buffer, which is exactly the cost `aligned` was chosen over `split` to
avoid), or **+0.43% wire and zero clocks** (sector-aligned records in the
container). The last one wins on both axes and is a **re-encode**; see the
bundle under P2. **P4a should be attempted against a sector-aligned container,
not against this one.** *(Session 27: it was, in the only sense that mattered —
the transport now REFUSES the windowed case rather than being trusted not to
reach it, so the bundle is a precondition rather than a plan. 59.4. Session 28:
the container IS one — the realised wire cost is +0.48% against the +0.43%
predicted here, and 120/120 records start on a sector boundary. 60.1.)*
*(original item, still the standing description of the `W` question:)*
Drive the MB89352 instead of a host file. **Session 23
added a second axis to it:** `W` is the clocks stolen per delivered byte, and
55.3 measured that the player's own request loop gives away 3-7% of the pipe
before `W` is even asked about. A transport design has to answer both. The `W`
handshake — clocks stolen per delivered byte, bracketed 5..12 by MC68450 Fig
4-25 — is listed in "Decisions locked" as UNDECIDED and as the thing that
decides the project: `W<=6` fits 0/120 frames, `W=8` misses 47/120. It is a
property of how the player drives the SPC, **so it is ours to choose, not to
receive** (FINDINGS 42.4-42.6). B3 informs it.
**Session 20 promoted this to the project's biggest open number.** FINDINGS 52.5
found the only worked example of a disk DMA configuration on this machine — the
IPL ROM's own — sitting at **16..19 clk/B**, where the whole design fails at any
container size (`15_bus_occupancy.py` sweeps it). The per-byte ladder is 5 clk/B
single-address with the bus held, 9 dual-address held, 12 single-address
arbitrated, 16..19 dual-address arbitrated. **Getting the DMAC to hold the bus
is the difference between 9 and 19**, it is a property of how the player
programs the channel, and demonstrating a configuration that does it is P4's
first job rather than its last.
**Do not quote 42.4's `W <= 6` / `W = 8` sensitivity table for this.** It is in
clocks per WORD and FINDINGS 43 voided it; 52.5 cited it in byte units when
first written and strikes it.
~~**P5. Seek and branch.**~~ **DONE, session 23 — FINDINGS 55.**
`src/player/ring.i` fills the ring on the 68000: `aligned` placement, the
descriptor ring, a prefill policy, 51.2's slack rule as arithmetic the player
can run (`ring_may_seek`), and a seek that quiets the channel and re-addresses
the stream out of the index. It reproduces the host producer's tiling exactly —
18 wraps, 14.7 KB mean hole, pixel-exact — and the host now AUDITS every
placement instead of making it.
The index is a **container change**: DLX4 carries `nframes` u16 record lengths
in the scene header, because `aligned` needs a record's length before it fetches
it and walking the stream is precisely what a player cannot do. Frame payloads
are byte-identical to the DLX3 encode; the scene header goes 5,920 to 6,164 B.
**What it exposed is bigger than the item.** A channel only moves bytes while it
has a request and only the CPU can issue one, so the disc **stands still between
records** by an amount set by the player's loop rather than by the medium — and
no host-filled run could see it. At 488 KB/s in a 256 KB ring, a one-deep
request queue gives away **6.8% of the pipe and underruns 59 of 120 frames**; a
two-deep one gives away 3.4% and underruns none. The container's whole surplus
over the wire at that rate is 8.7%, so the player's own loop was spending most
of the slack 51.3 accumulates. **Prefill is the weaker lever** — six records of
it still leaves 24 underruns at depth 1 — and the fix costs no clocks and no
bytes. 55.3, 55.4.
**P5a (open, and it belongs with P4).** The two-deep queue is modelled as two
mailbox slots. On the machine it is two DMAC channels or one channel with a
chained descriptor array, and which of those is affordable is a `W` question.
**P7. Boot.** The player as an executable loading from the SCSI volume.
Buildable, and empty until P4: there is nothing to boot from yet.
---
## M3 — the vertical slice, and the completion target
**Exit criterion: one decision point, two outcomes, a death clip, with audio,
playing from disc on stock hardware.**
**P6. Audio — and it is the largest unpriced risk left in the project.**
MSM6258 ADPCM, 15.6 kHz mono, **7.8 KB/s**. That figure is in `ratectl.py`'s
budget and nowhere else: not extracted, not encoded, not interleaved into the
container, and **never priced on the bus**. Two reasons to treat it as a risk
rather than a task:
1. A second DMA consumer attacks **the bus** — the resource this project already
established is the binding one, at 86.7% occupied. Clock headroom says
nothing about whether it fits.
2. 7.8 KB/s is a *byte* figure. The last time a byte/word unit error went
unexamined in a delivery budget it cost the project a 2x error in every table
since FINDINGS 5 (session 14, the MB89352 being an 8-bit SPC).
~~**Price it before writing it**: add the ADPCM DMA stream to `15_bus.py` and see
what it does to the 86.7%.~~ **DONE, session 20 — FINDINGS 52.** It is in
`15_bus_occupancy.py` and the answer is **1.25%..1.48% of the frame**, about 4%
of what the decoder leaves. The per-byte cost is no longer a guess borrowed from
the disk: `tools/analysis/21_iplrom_dmac.py` reads the IPL ROM's own HD63450
configuration and finds ch3 dual-address, 8-bit port, cycle steal without hold,
external request — **16..19 clocks per byte**, where `11_cpu_budget.py` had been
charging audio the disk's 5. Both worries above resolve:
1. **The bus concern does not materialise.** A second DMA consumer at 7.8 kB/s
is not what a bus at 88% occupancy is short of.
2. **The unit was checked and is nearly right.** 15.6 kHz = 8 MHz ÷ 512 =
15,625 samples/s, 4 bits each, two to a byte = **7,812.5 B/s exactly**. The
7.8 was decimal kB being multiplied by 1024; 2.4% high, now derived from the
sample rate in `buscost.ADPCM_BYTES_PER_S`.
**What is still open in P6 is everything except the bus:** extraction, encode,
container interleave, and what a second stream does to `wire` — and therefore to
`pipe - wire`, and therefore to 51.3's refill climb. That last one is the
interaction to price next, and it is E2's question with a second consumer in it.
**E6. Container v2** — audio interleave, per-record index, scene table. Depends
on P6's answer and on P5's index.
**E7. A BYTE TARGET, AND IT COMES OUT OF THE BUS BUDGET RATHER THAN OUT OF
TASTE (new, session 27, FINDINGS 59.7; re-measured session 28, 60.7).** The
frame affords **6.69 clocks a byte** after the measured decode and the audio,
and a dual-address byte costs **9**. So *if* B3 comes back saying the card
cannot drive `#EXREQ`, the container has to reach **27,924 B a frame — 327 KB/s
of payload** to fit at 12 fps, where the DLX5 gate container delivers 37,585 B
and 440 KB/s: **35% too big.** (It was 6.74 / 328 / 34% against the DLX4
container. **The bundle moved the target by one KB/s and moved no conclusion**,
which is what a precondition is supposed to do.)
Three things make this less alarming than the number looks, and one makes it
worse:
- The gate container is **deliberately the heaviest thing the encoder emits**
(span-heavy, the 488 recipe, every block mode exercised). It is a test
fixture, not a shipping target.
- A lighter container **also decodes cheaper**, so the 68.5% decode term falls
with the byte term. 328 KB/s is the pessimistic reading of the lever.
- `rc_fr_singe_scsi_cpufit.dlx` already exists — the encoder has had a
CPU-fitting mode since session 11.
- **Worse:** `15_bus_occupancy.py` REFUSES to price the cpufit container,
correctly, because the C68K measurement it cross-checks against belongs to
the gate container. **So E7 starts with a harness re-run**
(`tools/bench/c68k/run.sh`) against whichever container is to be the target,
and until that is done "34% too big" is a statement about the fixture rather
than about the project.
~~**G1. Import the scene graph — early, because it is a measurement input.**~~
**DONE, session 24 — FINDINGS 56.** It was pulled ahead for exactly the reason
given, and it paid: **the worst gap between two consecutive decision points is
zero**, and 5.4% of the game's 612 branch transitions are. Two seeks can fall
back to back with no play between them, so 51.2's slack rule can be answered NO
by the content rather than by the buffer.
It does not break the design — a branch on an empty ring costs the prefill
(149.7 ms, 1.80 frame slots at 488 KB/s), not the climb — but it removes the
margin: at 488 KB/s in a 256 KB ring, **76% of this game's branch points arrive
before the ring has refilled**, and a 512 KB ring makes that 90%. **The ring is
not the lever; the surplus is.**
Two constraints on the input layer came with it: the arcade needs **eight
directions**, and the shortest input window is **98 ms** against a 72.13/90.16 ms
frame slot, so input cannot be polled on the frame tick (56.7).
**The cross-check plan was wrong and is struck.** The SNES chapters are
*derived* from DirkSimple, by their own README, so there is one transcription and
not two; the diff catches conversion errors only (56.2). **Nothing is vendored:**
`tools/import/scenegraph.py` is the one file coupled to those projects and it
writes this project's own `DLXSCENE1` schema into gitignored `tmp/`
(USER DECISION, session 24).
---
## M4 — the whole game
Listed for completeness; past M3 these are scope, not risk.
- **C1. Full-disc survey**, 22.8 minutes. Classify **content / menu / bonus**
not menu vs content: the two largest streams are bonus material and look like
content by size, duration and bitrate alike (25.1). Run
`07_motion_survey.py` per stream first for a hot-window shortlist.
**Gated by E4, and parked with it (session 29).**
- ~~**E4. `H.build` k-means**, 51 s of a 55 s run, once per scene.~~ **PARKED,
session 29 (USER DECISION).** It was the thing to attack before C1. It builds
**VQ codebooks**, and a decoder-free packed player has no VQ — so this is
encoder work on the branch that is no longer being built on. It comes back if
and only if B2 goes MAME's way. C1 is gated by it and is parked with it.
- ~~**E2. `--spans all` as default.**~~ **MEASURED AND REFUSED, session 28 —
60.4.** It was "a recommendation, not a measurement" since 43.6.1 and it is a
measurement now: **+67% of the wire for +0.19 dB and one frame of 120.** It
IS the loaded lever on the byte side (44.3) — it is loaded the wrong way, and
E7 is why. What the entry predicted is exactly what it does: it raises `wire`,
which shrinks `pipe - wire`, which lengthens the refill climb. `need` stays
the default; the GATE container keeps `all` because it is a fixture.
- ~~**E3. Re-derive span selection jointly with `lam`** (39.3).~~
**IMPLEMENTED, MEASURED, NO-OP, session 28 — 60.5.** `--joint-spans` emits
byte-identical containers in all four `{need,all} x {greedy,joint}` cells and
at both lam floors, because **`lam` never leaves its floor on any of 120
frames**. Kept and defaulted off.
- **C2. Framing** — crop vs squash vs wide (FINDINGS 12). Needs an eyeball
against arcade reference, not a measurement. Cheap; blocks only final encodes.
- **C3. Disk image packaging**, ~1.09 GiB at the candidate rate.
- **G2/G3.** Branching, input windows, death clips, attract mode; playtest.
---
## Dependency summary
```
P4a DONE (59): the channel drives the data phase and
holds the bus -- 391.7% of a frame becomes 40..95%
B3 #EXREQ? ──┬─ YES ─> single address, 5 clk/B, 92.4% ── FITS ──┐
│ │
└─ NO ──> auto-request, 9 clk/B FLOOR, 110.4% ──> E7 byte target
(the frame affords 6.69; 59.7, 60.7) 327 KB/s
P2 re-encode bundle DONE (60): DLX5, records ARE sectors ─────────┤
E2 refused on measurement, E3 a no-op -- 60.4, 60.5 │
E7/E4/C1 PARKED session 29: encoder work waits on B2 ───────────┤
K1 palette-register DMA? ─> K2 packed container ─> K3 end to end ──┤
(61.9; K1/K2 survive a bad B2, K3 does not) │
P4a WIRING (the channel behind ring.i's mailbox) <- THE LAST ITEM ─┤
P1 P2 P3 P4b P5 P7, P6 bus cost (52), G1 scene graph (56) ────────┼─> M2 ─>
B1 seek+rate (sets HEADROOM, not fit) ─────────────────────────────┘ M3 ─> M4
B2 blanking ─┬─ NOT blanked ─> the DECODER-FREE PACKED player is live (61):
│ 55.2% of a frame at the 9 clk/B FLOOR, against
│ the codec's 110.4% -- and then B1 decides it,
│ because it wants 576 KB/s FIXED (61.4, 61.5)
└─ blanked ──────> a 27..113% duty strobe; there is no version of
it that is merely expensive (48.3, 61.6)
```
**Read that top-left branch as the project's live question.** Everything else
on the diagram is work; `#EXREQ` is a fact about a board nobody here has, and it
decides which of the two lower paths the player is on.
## Standing rules that apply to all of it
- **Green light first and last.** `./tools/bench/check.sh`, ALL GREEN, before and
after. **Never two MAME jobs at once** — session 18 did it, two `decode.lua`
runs shared a log file, and it produced a 0-byte log and 15 wasted minutes.
- **Name the layer.** Emulated, or real hardware. Every progress claim.
- **Label measured / estimated / folklore.** A rate with no provenance is
folklore even when it is plausible, and this project has already paid for that
twice.
- **No new default constants.** Rates stay explicit arguments. If a measurement
is not available, report the sensitivity across several rates rather than
picking one.