Commit Graph
8 Commits
Author SHA1 Message Date
prosolis 5921fab118 Put the transport on the 68000, and find PIO costs 87 clocks a byte
ROADMAP P4b. src/player/xfer.i answers src/player/ring.i's XF_* mailbox with a
real READ(10) to a real MB89352 in place of tools/bench/stream.lua's modelled
transport: 120 records, 4,488,588 B, pixel-exact out of a 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.

What it costs is the finding. 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 the cost is the
instruction stream and not MAME's device model, and it is the first number this
rig has produced that survives leaving the emulator. That is 391.8% of a 12 fps
frame; the machine's own V-DISP clock agrees from the other end at 2.57 fps.
Against the ladder, W=5 held is 22.4% of a frame and W=19 is 85.3%, 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 a clock.

"UNDERRUNS: 0/120" is vacuous with a synchronous transport, and stream.lua now
prints that argument next to the zero: a frame cannot start before its record
has landed because the decoder IS the transport. The counter that means
something is NO IDLE, 119/120 with a worst overrun of 441 whole ticks. Same
class of error as 49.7.2's free-running ring passing at 48 KB.

58.3: a record is not a sector -- 117 of 120 start part way into one, and
reading whole sectors into the ring corrupts the neighbours rather than wasting
bytes (49.2, no bounds check). scsi.i reads the covering sectors and stores only
the window, which is free in PIO and stops being free the moment P4a succeeds.
tools/analysis/26_sector_align.py prices the three ways out and sector-aligned
records win on both axes: +0.43% wire and zero clocks, against +1.34% and a
bounce copy at +5 clk/B. ROADMAP now carries a four-item re-encode bundle and
P4a should be attempted against a sector-aligned container.

check.sh gains two stages and was ALL GREEN before and after. decode.bin is
unchanged at 1,296 B and the same MD5.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-24 23:15:53 -07:00
prosolis e935d8661c Put the SPC on the 68000, and find P4 was blocked on a file nobody needed
ROADMAP P4, first half. Every byte the player has ever consumed was placed in
emulated RAM by a host: decode.lua preloaded a container, stream.lua answered a
mailbox at a modelled rate. src/player/scsi.i selects a SCSI target on a real
MB89352 and issues READ(10) itself -- 4,096 B from LBA 0 and 2,048 B from LBA
1000, both byte-for-byte against the host's copy of the same volume, with no
IOCS and no host in the transfer path. The non-zero LBA is the half that
matters: a driver that emits a malformed LBA field still passes block 0,
because zero is what a malformed field usually is.

P4 was recorded as blocked in this tree and was not. Session 21's handoff said
MAME's x68000 has no MB89352 path; -exp1 cz6bs1 instantiates one next to the
HD63450, and FINDINGS 32.4 had read that card's DMA glue in session 9. The
session-21 note is a regression in the record, not a discovery. What is
genuinely absent is the 8 KB scsiexrom.bin MAME requires to INSTANTIATE the
card and the player never executes -- driving the SPC registers directly has
been the plan since BENCHMARK item 4 in session 2 -- so scsi_run.sh supplies a
zero-filled placeholder on its own rompath, leaves the user's romset alone, and
lets MAME print WRONG CHECKSUMS as it should. B3 is untouched: it wants that
ROM's bytes disassembled and a blank one has none.

The register map is measured, not inferred, and it corrects MAME's own
documentation. The probe walks $EA0000..$EA003F one address at a time with a
bus-error handler that records the fault and steps the index, because a
sequential dump reports the first hole as the answer -- the earlier version
took a bus error at $EA0006 and knew nothing about the other 57. 60 of 64
answer; the two holes are exactly the TMOD and EXBF the MB89352 omits and the
MB87030 has. MAME leaves HOLES and does not shift the later indices down, which
its own device summary claims it does, and that is what keeps DREG at $EA0015.

The data register is DMA-only here and a PIO write vanishes. x68k_scsiext.cpp
glues $EA0015 and nothing else, and with exown() asserted and DRQ low the byte
is discarded: no error bit, no status change, no interrupt. Quieting all four
DMAC channels does not change it. Measured rather than reasoned about -- write
$5A, read back $00 with the FIFO still empty -- because ten command bytes
vanishing without trace looks exactly like a target refusing a command, which
is how it first presented. So every transfer runs the SPC in DMA mode and the
CPU moves the bytes through the DMAC's own door.

That costs the argument something, and it is easy to overclaim here: with exown
asserted at idle MAME cannot distinguish a CPU-driven byte at $EA0015 from a
DMAC-driven one. This shows the DATA PATH and cannot by itself show that the
HD63450 is driving it, which is precisely what ROADMAP calls P4's first job.
Whether a real CZ-6BS1 also refuses PIO there is not settled; it is a property
of MAME's model and it wants a board.

W did not move by one clock, and could not have. MAME's device models are
functional rather than transfer-timing accurate and 42.5 reads its DMAC
configured in wall-clock attotimes, so this is BENCHMARK Tier 1 -- does the
read path work -- and never Tier 2. W is still the largest open number here.

Five bugs, four of them silent, recorded in 57.5 because the pattern is the
finding: a chain of rol.l #8 that loaded a transfer counter of ZERO from a
count of 10; a byte handed to a FIFO mistaken for a byte on the bus; a fixed
phase sequence where the bus decides the order; the discarded PIO write; and an
initiator that must drop ACK and only then release the bus. The last appeared
only once there were TWO reads -- one passed byte-exact and every conclusion
from it was sound, and the second could not select. A player issues one command
per record, so that failure would have been universal in the ring and invisible
in a one-read demonstration.

No decoder code changed; decode.bin is still 1,296 B at the same MD5. check.sh
gains a SCSI stage that builds the volume out of the same stream_disk.bin the
ring rig reads, gates the register window at 60 of 64 and both reads
byte-exact, and skips when chdman is absent. ALL GREEN before and after.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-24 22:53:58 -07:00
prosolis 00232bb22b Import the scene graph, and find the worst gap between two branch points is zero
ROADMAP G1, scheduled early because it is a measurement input, and it paid for
that immediately. 51.3 established that a ring's lookahead is accumulated out of
pipe - wire and that a seek spends all of it, so what a branch point costs is
set by the time since the last one. 55.5 rehearsed a seek on the machine and
said out loud that it could not ask the question, because nothing here knew
where the branch points are.

They are everywhere, and 5.4% of them are free of charge to the player and not
to us. Over 612 distinct transitions into a seek, taking the earliest instant
each input window opens: worst 0.000 s, p10 0.950, median 3.473, best 82.497.
33 open on the first frame of a clip the disc SEEKED to -- press right as
flaming_ropes.enter_room appears and you are in fall_to_death, two seeks with no
play between them. 51.2's slack rule can therefore be answered NO by the
content rather than by the buffer, and no amount of ring is a defence.

It does not break the design. A branch on an empty ring costs the 2-record
prefill, 149.7 ms at 488 KB/s, not the climb. What it removes is margin: at that
rate 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%, because doubling the ceiling
does not touch the surplus. The ring is not the lever; the surplus is.

CORRECTION to FINDINGS 16: there is only one transcription. The SNES chapter
set says in its own README that it is derived from DirkSimple, so the planned
diff of two independent sources catches conversion errors only. Run anyway:
durations agree 388/505 within a frame, branch structure 470/505, and of the 35
differences 16 are renames and 18 of the other 19 are that port dropping the
arcade's diagonals. Zero transcription discrepancies, and none were findable.

Two constraints on the input layer come free: the arcade needs eight directions,
and the shortest input window is 98 ms against 54.4's 72.13/90.16 ms frame slot,
so input cannot be polled on the frame tick.

The coupling to outside source is contained to one file (USER DECISION).
tools/import/scenegraph.py is the only code here that knows those projects
exist -- their paths, table names, timing formulas, constants -- and it writes
DLXSCENE1, this project's own schema, into gitignored tmp/ with the sources'
licences inside it. tools/analysis/25_scene_graph.py reads only that. Nothing is
vendored and nothing outside-derived is committed. The split was made after the
measurement and the whole output was re-run byte for byte to show it moved no
number.

Both import gates are negative-tested: deleting one sequence upstream fails the
516/906 count, and closing the table early fails the constructor-end check,
which replaced one that was vacuous.

No 68000 code ran or changed; decode.bin is still 1,296 B at the same MD5.
check.sh gains an import stage that skips when there is no checkout. ALL GREEN
before and after.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-24 22:11:04 -07:00
prosolis 2676f3b835 Put the ring on the 68000, and find the disc stops whenever the player is not asking
ROADMAP P5. The loader moved in session 21 and the frame clock in 22; the ring
producer was the last policy living outside the machine. src/player/ring.i does
`aligned` placement, the descriptor ring, a prefill, 51.2's slack rule and a
seek, and the host keeps only the transport.

It needed a container change. `aligned` asks whether the next record fits
before the end of the ring -- a length asked BEFORE the record is fetched -- and
every reader in this tree answered that by walking the frame stream, which is
exactly what a player streaming off a disc cannot do. DLX4 carries nframes u16
record lengths in the scene header. Frame payloads are byte-identical to the
DLX3 encode, so no fitted constant moves; the scene header goes 5,920 to 6,164 B.

The producer reproduces the host's tiling exactly: 18 wraps, 14.7 KB mean hole,
pixel-exact, a third independent implementation of the same policy.

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 the PLAYER sets, not 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; two-deep gives away 3.4% and
underruns none. The container's whole surplus over the wire is 8.7%, so the
player's own loop was spending most of the slack a branch point saves up.
Prefill is the weaker lever: six records of it still leaves 24 underruns.

Three silent bugs are recorded in FINDINGS 55.7 -- all produced wrong pixels or
a desync rather than a fault -- plus a rig one: MAME renders a screen line by
line, so snapshotting the frame the decoder finished in captures a tear that
reads exactly like a decoder bug.

check.sh gains the machine-owned ring and a seek with the decode after it.
decode.bin is unchanged at 1,296 B and a host-filled run executes none of the
new code, so every FINDINGS 49/51 figure stands. ALL GREEN before and after.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-24 21:45:05 -07:00
prosolis c419251266 Put the frame clock on the 68000, and find that the 12 fps frame does not exist
ROADMAP P3 said "needs MFP timer or VBL" and neither can do it.  The MFP's
timer clock is 16 MHz/4, its prescalers stop at 200 and its data register is 8
bits, so the slowest tick any single timer can make is 78.125 Hz -- 6.5x faster
than a frame -- and 4e6/12 is not an integer, so no setting reaches 12 Hz at
all.  The raster has no whole divide near 12 either: 4 refreshes is 13.86 fps
and 5 is 11.09.  tools/analysis/23_frame_clock.py walks all 7x256 timer settings
rather than asserting it.

src/player/clock.i takes the V-DISP falling edge on MFP GPIP4 -- the start of
vertical blanking, which is when a player would present -- and adds fps*VTOTAL
per edge to a 16-bit accumulator, emitting a tick at 31,500 and keeping the
remainder.  The long-run rate is fps*VTOTAL/VTOTAL = 12.000000 fps exactly, and
both constants are read out of the CRTC at init, so the clock is derived from
the registers that generate the raster it counts.  Measured over 3,000
refreshes: 3,000 interrupts, 649 ticks where 649.1429 were due.

It costs 181.35 clocks per V-DISP, 838 per frame, 0.1006% of the budget -- timed
by the 68000 itself, because the host's granularity is 17.64 ms and the
interrupt is microseconds.  The loop's own cost was calibrated rather than
looked up and landed on 38.000002 clocks, which both licenses the subtraction
and confirms buscost.py's model; the 181.35 then decomposes exactly, leaving
43.99 clocks for the interrupt exception -- the textbook 44, measured.

THE ONE THAT MOVES SOMETHING: 12 fps on a 55.4577 Hz raster is 4.6215 refreshes,
so a frame is shown for 4 refreshes (72.13 ms) or 5 (90.16 ms), 37.9% of them
short.  The 833,333-clock budget every figure in this project is priced against
is the MEAN slot, and the short one is 13.4% under it.  The cadence was already
in the tree unnamed: stream.lua's tick is sampled at frame boundaries, so its
gaps were always 4 or 5, and every host-paced result in FINDINGS 49/51 carried
it.  P3 moved who produces it onto the machine and made it visible.  It is not a
dropped frame -- the pace gate lets an overrun eat the next frame's idle -- and
on the gate container it costs 4 frames of 120 their idle against 1 for the
nominal model, most of that the frame-0 transient at 111% of budget.  stream.s
counts it now, and the rig matches an offline model of the divider exactly.

Also struck: MAME's raster runs 2.22% fast.  refresh_mode() builds the frame
period from scr.max_x*scr.max_y with scr.max_x = m_htotal - 8, one character
cell short and an inclusive bound used as a count, so it runs at 56.6901 Hz
where the registers say 55.4577 -- agreeing to six digits with the arithmetic.
Every "1/55.46 s granularity" note in this tree was wrong and is 1/56.69 s,
corrected in six files with the derivation put once in crtc_mode.lua.  No
conclusion changes and no 68000 cycle figure moves; the CPU clock is unrelated
to the screen.  But anything paced by the raster runs fast under MAME, so the
rig reports both rates and prices the interrupt against the hardware's.

decode.s and frame.i are unchanged; decode.bin is still 1,296 B at the same MD5.
The pace gate's wait loop is byte-for-byte the one FINDINGS 51 measured and the
free-running path executes none of the new code.  check.sh gains two stages: the
clock's own measurement, and 120 frames decoded pixel-exact with nothing outside
the machine deciding when a frame may start.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-24 20:55:34 -07:00
prosolis 7179339bd2 Move the loader onto the 68000, and find 5,920 bytes nobody counted
src/player/load.i expands both codebooks to word-per-pixel form and packs the
palette to GGGGGRRRRRBBBBBI out of the RAW container header, byte-exact against
tools/bench/dlxload.py on both CPU cores.  The palette half is gated on words
read back out of the palette registers at $E82000, so "the words reached the
hardware" is part of what passes.  ROADMAP P1 is done; P2's encoder half (a
reserved black entry, 23.4) is not, and is a re-encode rather than an edit.

A scene change costs 18.96 ms of 68000 time, 22.8% of one 12 fps frame; boot
costs 24.70 ms.  The scratch tables describe the CRTC, not the scene, so
pal_tables is a separate entry point built once at boot -- 5.29 ms off every
scene change.

The one that moves something: the scene header is 5,920 B that no rate table in
this tree included, because it belongs to no frame record.  In FINDINGS 51.3's
currency it is divided by the surplus pipe - wire, so it is hypersensitive:
138 ms of extra refill climb at 488 KB/s and 1.099 s at 451.4 KB/s, for the
same bytes.  tools/analysis/22_scene_load.py prices it across explicit rates.

Recorded as open: the two CPU cores agree to <3% on every stage but the table
build, where they differ by 16.4%.  px68k's C68K charges a flat 50 clocks for
MULU/MULS (c68kmacro.h:1869) where the 68000 charges 38+2n, which explains
4,608 of the 8,703 clock gap.  4,095 clocks are unexplained.  Nothing else in
src/player/ multiplies, so no figure in FINDINGS 24-52 is affected.

decode.s and stream.s are untouched; decode.bin is still 1,296 B at the same
MD5.  check.sh gains a stage that gates byte-exactness on both cores and
deliberately does not gate the cycle counts -- MAME's clock is 1/55.46 s and a
wall timing would make the green light host-sensitive.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-24 20:20:40 -07:00
prosolis ed172c2da2 Rewrite the README as a description, and strike a unit error it exposed
The README had become a changelog: which session found what, which constant was
retired and by whom, which figure was overturned. That is what STATUS is for,
and it made the front page a poor read for anyone who has not been following.
Rewritten to say what the project is, what it looks like, where it stands and
how to reproduce it, present tense, no session numbers, no em dashes.

Restating the standing facts caught one of my own from this session. FINDINGS
52.5 cited 42.4's W sensitivity table -- W <= 6 fits 0/120 frames, W = 8 misses
47/120 -- as though those were clocks per BYTE. They are per WORD. FINDINGS 43
is the section that caught W being charged per word to a byte-wide port and says
in terms that 42.3's 0/120 was never physically reachable, so quoting it in byte
units re-imported the exact 2x error 43 exists to have corrected, one section
after using that same trap as a warning.

Struck in 52.5 and in ROADMAP P4, and replaced with the ladder buscost.py
already carries, which is in the right unit: 5 clk/B single address with the bus
held, 9 dual address held, 12 single address arbitrated per byte, 16..19 dual
address arbitrated per byte, the last being what the IPL ROM programs. Nothing
else in 52 depended on the struck figures -- 15_bus_occupancy.py's sweep is in
byte units and is unaffected.

The "cross-check" claim goes with it. 52.5 read the agreement between its W = 8
row and 42.4's 47/120 as two independent models landing in the same place. It is
two different units on two different containers at two different rates, and
calling it corroboration was manufacturing agreement out of the unit error.

What replaces "W <= 12 is a requirement on the player's DMAC programming" is the
sharper version: getting the DMAC to HOLD THE BUS is what separates 9 from 19,
and that is the thing P4 has to demonstrate.

Docs only; no code changed and no gate output moves.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-24 18:30:42 -07:00
prosolis 2f9f5cc995 Pace the ring, then read the DMAC config out of the IPL ROM: audio is cheap and the disk is not
Two sessions that were never separated in the working tree, so they land as one
commit. check.sh ALL GREEN before and after both.

SESSION 19 -- the ring rig gets a frame clock (FINDINGS 51).

src/player/stream.s had no frame clock: it asked for record i the instant it
finished i-1, outran any finite pipe, and never let the ring back up. The 49.1
sweep passing at 48 KB was therefore a wrap-correctness result and nothing else.
PACE/PACEON ($18034/$18038) hold the decoder to 12 fps, so FR_HEAD-FR_TAIL
finally means what it reads as: whole frames the decoder could still draw with
delivery stopped dead. PACEON=0 free-runs and is what the wrap gate still uses,
so every figure in 49 is unmoved.

Paced, on the gate container: 64 KB holds 2 frames, 256 KB holds 7-8, 512 KB
holds 14-15, all pixel-exact. Tolerance is ceiling-1, measured by cutting the
pipe: 256 KB buys 500 ms of dead pipe, not 583.

SLACK IS ACCUMULATED, NOT OWNED. It is built out of pipe-wire and a seek spends
all of it. At 488 KB/s a 256 KB ring needs 4.83 s of play to reach its ceiling
from empty; 512 KB needs 8.42 s to reach 14. A bigger ring raises the ceiling
AND lengthens the climb, so a branch point does not ask "is the buffer big
enough" but "has there been enough play since the last one" -- and Dragon's
Lair's decision points are seconds apart. The rig now also says WHICH resource
is binding: at 460 KB/s every ring from 192 KB to 512 KB is rate-bound at
ceiling 4 and never fills, so larger rings are dead RAM in that scene.
20_seek_slack.py is the same model rewritten in Python from record sizes,
sharing no code with the Lua producer: 35/35 ceilings inside its bracket.

SESSION 20 -- the DMAC configuration was in the IPL ROM the whole time
(FINDINGS 52).

ROADMAP's "do this first" was to put the ADPCM stream on the bus. That needs a
clocks-per-byte figure for the audio channel, and 11_cpu_budget.py was charging
audio the DISK's rate -- 5 clk/B, its own help text calling it "single-address,
bus held". Audio was being charged the favourable end of B3, a 242 KB/s open
question.

It never had to be a guess. The IPL ROM programs all four HD63450 channels
itself and MAME boots the rig with it, so 21_iplrom_dmac.py reads the
configuration out of the image and decodes the MC68450 fields. Eight
(address, expected bytes, meaning) sites; a mismatch or an unknown revision
exits non-zero. In check.sh, no emulator, milliseconds.

ch3 DCR=$80, OCR=$32: dual address, 8-bit port, cycle steal WITHOUT hold,
REQG=10 external request. The DMAC arbitrates once per byte with no burst to
amortise the 5..8 + 2 over, so an audio byte is 16..19 clocks, not 5 -- the old
debit was 3.2x..3.8x small. And on the bus it is still nothing: 651 B/frame is
1.25%..1.48% of a frame, about 4% of what the decoder leaves. P6's bus risk
does not materialise. The unit worry was worth checking and nearly right: 15.6
kHz is 8 MHz/512 = 15,625 samples/s, two 4-bit samples to a byte = 7,812.5 B/s
exactly, and AUDIO_KBPS=7.8 is that in decimal kB while the tool multiplied by
1024.

THE DISK CHANNEL IS PROGRAMMED IDENTICALLY. ch1 (SASI) is DCR=$80 too, and so
is ch0. That is 16..19 clocks per delivered byte, where 42.4 brackets W at 5..12
and 42.5 has W=8 already missing 47/120 frames. The only worked example of a
disk DMA configuration on this machine sits above the entire bracket, and at
that price nothing fits at any container size. It is not scsiexrom.bin so B3
stays open -- what changed is that a cheap configuration is now the thing that
has to be SHOWN. W <= 12 is a requirement on the player's DMAC programming, not
a range the hardware hands us, and it is now the largest open number in the
project, ahead of the rate.

An unforced cross-check fell out: 15_bus_occupancy.py's new W sweep puts W=8 at
105.7% of the frame, agreeing with 42.5's 47/120, from mode histograms and bus
clocks respectively, two models sharing no code.

Also: ADPCM outranks the disk at the arbiter (CPR 1 against 2), so an audio byte
never waits and a video byte does -- relevant to 51's smooth-rate delivery model.

README MEDIA.

stream.lua gains DLX_SNAP_EVERY=1 (needs DLX_PACE, off by default, on no path
check.sh takes) and tools/media/make_readme_media.py turns the PNGs into
docs/img/. The stills and both clips are MAME's own screen pixels.

Building it turned up something worth recording. 116 of 119 captured frames are
pixel-exact against dlx.py; three are TORN -- frame n on top, frame n-1 below
the tear line -- because MAME captured the screen while the block loop was
partway down it. decode.s writes straight to the displayed page (one display
path, 28.1), so a real player tears the same way, and this is the first time
that consequence has been visible rather than argued. The script ASSERTS the
tear and refuses to build otherwise, rather than trimming three frames and
reporting "every frame I kept is exact". Second correction the capture forced:
the snapshot fires before frame n is decoded, so the obvious reading is that it
holds frame n-1 -- it does not, because MAME renders the screen at the end of
the machine frame, by which time the 68000 has finished frame n.

11_cpu_budget.py's "validated to within 1 pt" line is also corrected: the model
reads 2..10 pt HIGH and by more as the frame gets harder, which was already true
before either session.

src/player/decode.s is unchanged; decode.bin is still 1,296 B at the same MD5.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-24 18:14:01 -07:00