Commit Graph
4 Commits
Author SHA1 Message Date
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