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
This commit is contained in:
prosolis
2026-08-24 20:55:34 -07:00
parent 7179339bd2
commit c419251266
20 changed files with 1513 additions and 23 deletions
+202
View File
@@ -0,0 +1,202 @@
; ---------------------------------------------------------------------------
; clock.i -- the FRAME CLOCK, on the 68000 itself. ROADMAP P3.
;
; src/player/stream.s has a pace gate: frame i may not START before tick i, and
; PACE is the tick counter. Until now PACE was written by tools/bench/
; stream.lua, i.e. by the host, off the host's idea of what 12 fps means. That
; was honest for what FINDINGS 49/51 were measuring -- arrival times against a
; deadline -- and it is not a player. A player has no host. These are the
; bytes that replace it.
;
; WHAT THE MACHINE ACTUALLY OFFERS, because "use the MFP timer or vblank" hides
; a real constraint. The MC68901's timer clock on this board is 16 MHz / 4 =
; 4 MHz (MAME 0.277 src/mame/sharp/x68k.cpp:1027-1028), its prescaler ladder is
; {4, 10, 16, 50, 64, 100, 200} (src/devices/machine/mc68901.cpp:173) and its
; data register is 8 bits. The SLOWEST tick a single MFP timer can produce is
; therefore 4e6 / (200*256) = 78.125 Hz, and 4e6/12 = 333,333.33 is not even an
; integer -- so no prescaler/data pair ticks at 12 Hz, and no timer at any
; setting ticks as slowly as a 12 fps frame. A frame clock needs a divider in
; software whichever source it is built on. tools/analysis/23_frame_clock.py
; enumerates the whole space rather than asserting this.
;
; So the source is the RASTER, and that is a better answer than a timer anyway.
; GPIP4 on the MFP is V-DISP (x68k.cpp:1139, `m_crtc->vdisp_cb().set(i4_w)`),
; high while the display is active; the same signal is the MFP's Timer A event
; input (mc68901.cpp:167, GPIO_TIMER = {GPIP_4, GPIP_3}). Its interrupt is
; channel 6, IR_GPIP_4 = $40 in IERB/IPRB/IMRB (mc68901.cpp:76). We take the
; FALLING edge (AER bit 4 = 0), which is the start of vertical blanking -- the
; instant a player would present a finished frame, so the clock and the flip
; are the same event rather than two events with a phase between them.
;
; THE DIVIDER IS EXACT, AND IT IS EXACT BY CONSTRUCTION. The raster is
;
; 31,500 lines/s / (R04 + 1) lines/frame
;
; and 31,500 / 568 = 55.4577 Hz is not a multiple of 12, so a whole-number
; divide cannot do it: 4 refreshes is 13.87 fps and 5 is 11.09 fps. Instead
; each V-DISP adds `fps * (R04+1)` to an accumulator and a frame tick is emitted
; whenever it reaches 31,500, keeping the remainder:
;
; acc += fps*VTOTAL ; if acc >= HFREQ: acc -= HFREQ ; PACE += 1
;
; Over VTOTAL/gcd raster frames that emits exactly fps*VTOTAL/gcd ticks, so the
; long-run rate is fps*VTOTAL/VTOTAL = fps EXACTLY, with a bounded remainder and
; ZERO accumulated drift -- not 12.0001, not 11.9998. It holds for any fps and
; any vertical geometry, which is why the two constants are READ OUT OF THE
; CRTC at init rather than assembled in: the clock is derived from the same
; registers that generate the raster it is counting, so the two cannot disagree.
;
; WHAT IT COSTS IN CADENCE, WHICH IS THE PART THAT IS NOT FREE. 12 fps on a
; 55.4577 Hz raster is 4.6215 refreshes per frame, so a frame is shown for
; either 4 or 5 refreshes -- 72.13 ms or 90.16 ms. Nothing can change that;
; it is the display's quantisation, not the clock's error, and a timer-derived
; clock would have exactly the same cadence with an arbitrary phase against the
; raster on top. It does mean the slot a frame gets is NOT always the 83.33 ms
; every budget in this project is priced against, and the short slot is 13.4%
; under it. tools/analysis/23_frame_clock.py prices that; do not read this file
; as a claim that the clock made the budget bigger.
;
; INTERRUPTS, AND WHAT HAD TO BE TURNED OFF. The rigs launch the 68000 at
; SR=$2700 with everything masked, into a machine the IPL ROM has already booted
; -- so the MFP arrives with whatever IOCS enabled on it (keyboard receive,
; Timer C, its own V-DISP handler) and vectors pointing into IOCS. Lowering the
; mask without disarming the MFP would vector into code we did not put there.
; clk_init therefore writes IERA = IERB = 0 first, which on the MC68901 also
; clears the matching pending bits (mc68901.cpp REGISTER_IERA/B: `m_ipr &=
; m_ier`), and only then arms GPIP4 alone. Levels 1-5 stay masked at SR=$2500,
; so the DMAC (IRQ3) and the SCC (IRQ5) cannot get in either; level 7 is the
; front-panel NMI and is not ours to mask.
;
; The vector is the MFP's own: VR is written with the S bit CLEAR, so the
; in-service register is not used and an acknowledge clears the pending bit by
; itself (mc68901.cpp get_vector). No end-of-interrupt write in the handler.
; ---------------------------------------------------------------------------
; --- MFP registers. The device sits on D0-D7 of a 16-bit bus (x68k.cpp:793,
; `.umask16(0x00ff)`), so register n is one BYTE at $E88001 + 2n.
MFP = $E88001
MFP_GPIP = MFP+0*2
MFP_AER = MFP+1*2
MFP_DDR = MFP+2*2
MFP_IERA = MFP+3*2
MFP_IERB = MFP+4*2
MFP_IPRA = MFP+5*2
MFP_IPRB = MFP+6*2
MFP_ISRA = MFP+7*2
MFP_ISRB = MFP+8*2
MFP_IMRA = MFP+9*2
MFP_IMRB = MFP+10*2
MFP_VR = MFP+11*2
MFP_GPIP4 = 4 ; bit number of V-DISP in GPIP/AER/DDR
MFP_IVDISP = $40 ; IR_GPIP_4, channel 6, in IERB/IPRB/IMRB
MFP_VBASE = $40 ; vector base; S clear -> no in-service register
CLK_VEC = (MFP_VBASE+6)*4 ; $118: MFP channel 6 vector, as an ADDRESS
CRTC = $E80000
CRTC_R04 = CRTC+4*2 ; V total, in scanlines, minus one
CRTC_R20 = CRTC+20*2 ; mode; bit 4 = 31.5 kHz
HFREQ = 31500 ; lines/s in the 31.5 kHz modes. Exact: the
; 768-wide IPL mode is 34.776 MHz / 1104 dots
; and the 256-wide mode 11.592 MHz / 368, both
; 31500.0 (tools/bench/crtc_mode.lua).
; --- state. PACE is stream.s's, deliberately: the whole point is that the
; 68000 now writes the word the host used to write, and the pace gate that
; reads it does not change by a single byte.
CLK_PACE = $18034 ; == stream.s PACE
CLK_ACC = $18060 ; word: Bresenham remainder, < HFREQ
CLK_INCR = $18062 ; word: fps * (R04+1), computed by clk_init
CLK_VDISP = $18064 ; long: V-DISP edges taken. An INSTRUMENT --
; it is what lets a rig check that the tick
; count and the raster count are the same clock.
CLK_FPS = $18068 ; long: requested fps, an argument to clk_init
CLK_ERR = $1806C ; long: 0 ok / 1 not a 31.5 kHz mode
; / 2 fps*VTOTAL would overflow 16 bits
; ---------------------------------------------------------------------------
; clk_init -- arm the frame clock. Reads CLK_FPS, leaves CLK_ERR.
; Clobbers d0-d2. Leaves the CPU at SR=$2500 on success.
; ---------------------------------------------------------------------------
clk_init:
clr.l CLK_ERR.l
clr.l CLK_VDISP.l
clr.w CLK_ACC.l
clr.l CLK_PACE.l
; The mode has to be the one HFREQ describes. A 15 kHz mode would halve the
; line rate and the divider would run at double speed while looking correct,
; which is the failure this test exists to prevent.
move.w CRTC_R20.l,d0
btst #4,d0
bne.s .modeok
move.l #1,CLK_ERR.l
rts
.modeok:
; VTOTAL and the increment. Both out of the CRTC, so a change of mode changes
; the clock with it. acc is 16-bit and reaches at most HFREQ-1+incr, so incr
; must leave room: 65536 - 31500 = 34036. At VTOTAL=568 that is fps < 59.9,
; which is every rate this machine can display anyway -- but it is checked
; rather than argued.
move.w CRTC_R04.l,d0
addq.w #1,d0 ; VTOTAL scanlines
move.w d0,d1
move.w CLK_FPS+2.l,d2 ; low word of the long
mulu d2,d1 ; fps * VTOTAL (see FINDINGS 53.4 on
; C68K's flat MULU charge; this is boot
; code and is not cost-measured there)
cmp.l #65536-HFREQ,d1
bcs.s .fitok
move.l #2,CLK_ERR.l
rts
.fitok:
move.w d1,CLK_INCR.l
; The vector, before the source is armed.
move.l #clk_isr,CLK_VEC.w
; Disarm everything the IPL left running, then arm GPIP4 alone. Order matters:
; IER first (which clears IPR with it), then the edge, then the mask.
move.b #0,MFP_IERA.l
move.b #0,MFP_IERB.l
move.b #0,MFP_IMRA.l
move.b #MFP_VBASE,MFP_VR.l ; S clear: acknowledge clears pending
bclr #MFP_GPIP4,MFP_DDR.l ; V-DISP is an input
bclr #MFP_GPIP4,MFP_AER.l ; interrupt on the FALLING edge, i.e.
; at the start of vertical blanking
move.b #MFP_IVDISP,MFP_IERB.l
move.b #MFP_IVDISP,MFP_IMRB.l
move.w #$2500,sr ; let level 6 in; 1-5 stay masked
rts
; ---------------------------------------------------------------------------
; clk_stop -- disarm, and put the mask back where the rigs expect it.
; ---------------------------------------------------------------------------
clk_stop:
move.w #$2700,sr
move.b #0,MFP_IERB.l
move.b #0,MFP_IMRB.l
rts
; ---------------------------------------------------------------------------
; clk_isr -- one V-DISP. Every instruction here is charged to every frame the
; decoder draws, so it is deliberately the shortest thing that is still exact:
; four word operations and one long increment.
;
; Only the LOW WORD of d0 is touched, so only the low word is saved. The
; accumulator, the increment and the threshold all fit in 16 bits by the check
; in clk_init, which is what makes that legal.
; ---------------------------------------------------------------------------
clk_isr:
move.w d0,-(sp)
addq.l #1,CLK_VDISP.l
move.w CLK_ACC.l,d0
add.w CLK_INCR.l,d0
cmp.w #HFREQ,d0
bcs.s .nf
sub.w #HFREQ,d0
addq.l #1,CLK_PACE.l
.nf:
move.w d0,CLK_ACC.l
move.w (sp)+,d0
rte
+59
View File
@@ -0,0 +1,59 @@
; Front-end for the frame clock (ROADMAP P3), for the rig.
;
; It exists to answer two questions that the streaming rig cannot answer on its
; own, because there the clock is buried under a decoder:
;
; 1. does the tick actually come from the raster, and at exactly the rate
; asked for -- measured over thousands of refreshes, not four;
; 2. WHAT IT COSTS, in clocks, per interrupt. This project's currency is
; 68000 clocks and the decoder already occupies 86.7% of the bus, so a
; frame clock is not free until someone has priced it.
;
; THE INSTRUMENT, and why it is a busy loop. MAME's Lua only sees the machine
; at frame boundaries, so it can time to 1/55.46 s and no finer -- 18 ms, where
; the whole per-frame cost of this clock is microseconds. Differencing two
; wall timings would measure nothing. So the 68000 counts instead: a loop with
; ONE instruction in its body runs for a fixed number of refreshes, and the
; iteration count is read out at both ends.
;
; clock off: iters0 * L = clocks in the window -> L
; clock on: iters1 * L + ints * H = clocks in the window -> H
;
; The window is an exact number of raster frames, so its length in clocks is
; exact and does not depend on the host at all. L is calibrated out by the
; first run rather than assumed from a cycle table, which matters: the point of
; the exercise is to price this code on the machine that will run it, and a
; table is the thing being checked. With ~2.7e7 iterations behind it, L carries
; enough digits that the interrupt cost -- 0.08% of the window -- survives the
; subtraction.
;
; The body is `addq.l #1,CGCNT.l` and nothing else: no compare, no counter in a
; register that an interrupt could be accused of disturbing, and a value that
; the host can read at any moment without stopping the CPU.
;
; The gate does NOT decode anything. What the clock does to a real frame is
; tools/bench/stream.lua's question, with DLX_PACE=2.
CGFLAG = $18070 ; 0 idle / 1 running / $EE clk_init refused
CGON = $18074 ; 1 = arm the frame clock, 0 = leave it off
CGCNT = $18078 ; <- loop iterations, read by the host at both
; ends of the window
org $10000
start:
clr.l CGCNT.l
move.l CGON.l,d0
beq.s noclk
bsr clk_init
tst.l CLK_ERR.l
bne.s bad
noclk:
move.l #1,CGFLAG.l ; the host starts its window here
loop:
addq.l #1,CGCNT.l
bra.s loop
bad:
move.l #$EE,CGFLAG.l
hold: bra.s hold
include "src/player/clock.i"
+2 -1
View File
@@ -4,7 +4,8 @@
; does nothing the shipping player would not do, so that the bytes being
; measured are the bytes that will ship. The player's own boot path will call
; do_load once with the mode bits set to 3; this repeats it LITER times so a
; host clock with 1/55.46 s granularity can time a job that takes milliseconds,
; host clock with 1/56.69 s granularity (tools/bench/crtc_mode.lua) can time a
; job that takes milliseconds,
; and splits it by LMODE so the codebook expansion and the palette pack can be
; priced apart. A player calls do_load with mode 7 once at boot -- the three
; scratch tables describe the machine, not the scene -- and with mode 3 at every
+71
View File
@@ -81,6 +81,18 @@ PACE = $18034 ; producer -> decoder: frame ticks elapsed since
; release. Frame i may not START before tick i.
PACEON = $18038 ; 1 = obey PACE. 0 leaves the loop free-running,
; byte for byte the loop FINDINGS 49 measured.
LATEFR = $18080 ; frames that reached the pace gate with their
; tick ALREADY ARRIVED, i.e. did not idle for a
; single poll -- the previous frame used its
; whole slot. See the gate below.
LATEMAX = $18084 ; the worst of those, in WHOLE ticks overrun
LATE1ST = $18088 ; index of the FIRST such frame, so that a
; count can be told apart from a start-up
; transient without re-running anything
CLKON = $1803C ; 1 = the 68000 paces ITSELF: src/player/clock.i
; drives PACE off the CRTC's V-DISP instead of
; the host writing it. Needs PACEON=1; the gate
; below cannot tell the two apart and must not.
DESC = $18100 ; DESCN x u32, record base addresses
DESCN = 64 ; power of two; the index is masked, not compared
@@ -92,12 +104,27 @@ SPINMAX = 2000000 ; polls with no progress before giving up
org $10000
start:
; ---- the frame clock, if this run is asking the 68000 to keep its own time.
; It goes here rather than inside the frame loop because clk_init CLEARS PACE:
; tick 0 has to be the instant the decoder was released, exactly as it is when
; the host writes PACE, or the first frame's deadline moves.
tst.l CLKON.l
beq.s noclk
bsr clk_init
tst.l CLK_ERR.l
beq.s noclk
move.l #$E2,FLAG.l ; the clock refused; CLK_ERR says why
bra hold
noclk:
move.l #1,FLAG.l ; timer starts here
outer:
move.l NFR.l,SCR_N.l
clr.l FR_TAIL.l
clr.l STALLS.l
clr.l SPINS.l
clr.l LATEFR.l
clr.l LATEMAX.l
move.l #-1,LATE1ST.l
frameloop:
; ---- PACE GATE (FINDINGS 49.7.2, and it is the whole point of this session).
; Free-running, this loop asks for record i the instant it finishes record i-1,
@@ -117,8 +144,44 @@ frameloop:
;
; It also makes STALLS mean something. Free-running, a stall is EARLINESS
; (49.6); paced, a frame that has to wait for its record is a real underrun.
;
; AND IT COUNTS THE FRAMES THAT WERE ALREADY LATE, which is a question only a
; REAL frame clock raises. 12 fps on a 55.4577 Hz raster is 4.6215 refreshes
; per frame, so the divider hands out slots of 4 refreshes (72.13 ms) and 5
; (90.16 ms), 37.9% of them short -- and the SHORT one is 13.4% under the
; 83.33 ms every budget in this project is priced against (FINDINGS 54). A
; frame that does not fit its slot does not fail here: PACE has already moved
; on, so the next frame starts the instant this one finishes and the clock
; catches up by itself. What it costs is one late PRESENT, and nothing in this
; tree counted those because until now the tick was a host model with no
; cadence in it at all.
;
; The test is "did this frame have to WAIT", not "is it a whole tick behind".
; Frame i waits while PACE < i; so PACE >= i on arrival means the decoder came
; to the gate with slot i already open and idled for zero polls, which is the
; same statement as "frame i-1 ran to the end of its slot". A whole tick of
; overrun -- PACE - FR_TAIL >= 1 -- is the much rarer case where it ran past
; the end of the NEXT one, and is reported separately as the worst seen.
;
; Frame 0 is excluded: it starts at tick 0 by definition and has no predecessor
; to have overrun. The wait loop below is untouched -- all of this is ahead of
; it, and the free-running path executes none of it.
tst.l PACEON.l
beq.s nopace
move.l PACE.l,d0
cmp.l FR_TAIL.l,d0 ; PACE < FR_TAIL: the slot has not come
bcs.s pacewait ; round yet, so this frame is EARLY
tst.l FR_TAIL.l
beq.s pacewait ; frame 0 starts AT tick 0 by definition
tst.l LATEFR.l
bne.s .nf1
move.l FR_TAIL.l,LATE1ST.l
.nf1:
addq.l #1,LATEFR.l
sub.l FR_TAIL.l,d0 ; whole ticks overrun; 0 = inside the
cmp.l LATEMAX.l,d0 ; slot but with nothing left of it
bls.s pacewait
move.l d0,LATEMAX.l
pacewait:
move.l PACE.l,d0
cmp.l FR_TAIL.l,d0 ; d0 - FR_TAIL; carry = tick not reached
@@ -176,9 +239,17 @@ nostall:
bne frameloop
subq.l #1,ITER.l
bne outer
; ---- leave the MFP as it was found. A rig that exits with a live interrupt
; source and a lowered mask hands the next thing that runs an interrupt it
; has no vector for, and the failure would land somewhere else entirely.
tst.l CLKON.l
beq.s noclk2
bsr clk_stop
noclk2:
move.l #$FF,FLAG.l ; timer stops here
hold: bra.s hold
desync: move.l #$EE,FLAG.l
bra.s hold
include "src/player/frame.i"
include "src/player/clock.i"