Files
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

133 lines
5.9 KiB
Python

"""What the 68000's own frame clock costs, out of the two clock.lua runs.
ROADMAP P3. Usage: clock_cost.py <off-run.txt> <on-run.txt>
THE SUBTRACTION. Both runs execute the same one-instruction loop over a window
of the same number of raster frames, so the window is the same number of 68000
clocks in both. With the clock off, every clock in the window went into loop
iterations:
L = clocks / iters_off clocks per iteration
With it armed, the interrupts took some of them:
H = (clocks - iters_on * L) / ints clocks per V-DISP interrupt
L is CALIBRATED rather than looked up. That is the point: this project's cost
model (tools/analysis/buscost.py) says a 68000 bus cycle is 4 clocks and an
instruction costs 4 * (instruction words + data accesses), and the whole reason
to measure is to avoid scoring the clock against the table the table is meant to
be checked by. L falling on a whole number of clocks is therefore a RESULT, not
an assumption, and it is reported as one.
WHAT THE FIGURE IS PER FRAME. Not H -- the interrupt fires once per refresh and
a frame is several refreshes. On the hardware raster that is 31500/VTOTAL over
fps interrupts per frame, and the de-skewed rate is the one to use: MAME's
raster is fast by htotal/(htotal-8) (see tools/bench/clock.lua), and charging
the player the emulator's extra interrupts would overstate the cost by that
same 2.2%.
"""
import sys
def read(path):
d, cad = {}, {}
for line in open(path):
f = line.split()
if f[0] == "cad":
cad[int(f[1])] = int(f[2])
else:
d[f[0]] = float(f[1])
d["cad"] = cad
return d
def main(off_path, on_path):
off, on = read(off_path), read(on_path)
if off["on"] != 0 or on["on"] != 1:
sys.exit("FAIL: expected the calibration run first and the armed run "
"second; got on=%d then on=%d" % (off["on"], on["on"]))
for k in ("frames", "clocks", "fps", "vtotal"):
if off[k] != on[k]:
sys.exit("FAIL: the two runs do not share a window: %s is %g in "
"the calibration run and %g in the armed one"
% (k, off[k], on[k]))
clocks = off["clocks"]
L = clocks / off["iters"]
ints = on["ints"]
H = (clocks - on["iters"] * L) / ints
# The self-check that licenses the subtraction: the interrupt count must be
# the raster frame count. clock.lua already fails on this, restated here
# because this file is also read on its own.
if abs(ints - on["frames"]) > 1:
sys.exit("FAIL: %d interrupts over %g raster frames -- not the raster"
% (ints, on["frames"]))
fps, skew = on["fps"], on["skew"]
hw_hz = on["hw_hz"]
per_frame_ints = hw_hz / fps
per_frame = H * per_frame_ints
FRAME_CLK = 10e6 / fps
print(" calibration: %.6f clocks per loop iteration over %d iterations"
% (L, off["iters"]))
print(" (%s a whole number of clocks -- the loop is one "
"`addq.l #1,abs.l` at 7 bus cycles plus a `bra.s`)"
% ("lands on" if abs(L - round(L)) < 1e-3 else "does NOT land on"))
print(" INTERRUPT: %.2f clocks per V-DISP, measured over %d of them"
% (H, ints))
print(" PER FRAME: %.2f interrupts x %.2f = %.0f clocks = %.4f%% of a "
"%g fps frame" % (per_frame_ints, H, per_frame,
100 * per_frame / FRAME_CLK, fps))
print(" (%.4f refreshes per frame on the HARDWARE raster of "
"31500/%d = %.4f Hz, not on MAME's, which is %.4fx fast)"
% (per_frame_ints, on["vtotal"], hw_hz, skew))
# THE DRIFT GATE, and it is stated in TICKS rather than in ppm on purpose.
# A remainder-keeping divider emits floor() or ceil() of the exact tick
# count over any window and never accumulates -- so the only honest
# tolerance is one tick, and any ppm figure is that one tick divided by
# however long the window happened to be. Quoting ppm would let a longer
# window advertise a tighter clock for no reason.
want = on["frames"] * fps * on["vtotal"] / 31500.0
ticks = on["ticks"]
print(" DRIFT: %d ticks over %d refreshes; exact is %.4f, so the "
"error is %+.4f ticks" % (ticks, on["frames"], want, ticks - want))
if abs(ticks - want) > 1.0:
sys.exit("FAIL: %d ticks where %.4f were due -- off by %.2f, which is "
"more than the one tick a remainder can hold back. The "
"divider is accumulating drift." % (ticks, want, ticks - want))
cad = on["cad"]
tot = sum(cad.values())
if tot:
# Refreshes per frame is 31500 / (fps * VTOTAL) exactly -- the divider's
# own ratio, upside down. A remainder-keeping divider can only ever
# emit the two whole numbers either side of it, so anything else in the
# histogram is a bug in the divider and not a rounding taste.
rpf = 31500.0 / (fps * on["vtotal"])
lo, hi = int(rpf), int(rpf) + 1
print(" CADENCE: %s (%d intervals; %.4f refreshes per frame, so "
"only %d and %d are possible)"
% (", ".join("%dx%d (%.1f%%)" % (k, v, 100.0 * v / tot)
for k, v in sorted(cad.items())), tot, rpf, lo, hi))
for k in cad:
if k not in (lo, hi):
sys.exit("FAIL: a frame tick waited %d refreshes, which a "
"remainder-keeping divider cannot produce" % k)
# The mix is forced too: lo*a + hi*b = refreshes, a + b = ticks.
b = tot * rpf - lo * tot
print(" expected %d:%d split %.1f%% / %.1f%%, got "
"%.1f%% / %.1f%%"
% (lo, hi, 100 * (tot - b) / tot, 100 * b / tot,
100.0 * cad.get(lo, 0) / tot, 100.0 * cad.get(hi, 0) / tot))
return 0
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit(__doc__)
sys.exit(main(sys.argv[1], sys.argv[2]))