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:
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""What a real frame clock can be built from, and what its cadence costs.
|
||||
|
||||
python3 tools/analysis/23_frame_clock.py [--fps 12] [--vtotal 568]
|
||||
[--csv tmp/c68k_frames.csv]
|
||||
|
||||
ROADMAP P3 says "needs MFP timer or VBL", which hides the fact that ONE OF
|
||||
THOSE CANNOT DO IT and the other cannot do it either without a divider. This
|
||||
file enumerates the space rather than asserting a conclusion, the way FINDINGS
|
||||
47 had to be re-done once a hardware "no" turned out to be a claim about a whole
|
||||
configuration space nobody had walked.
|
||||
|
||||
EVERY CONSTANT HERE IS SOURCED, and from a file on this machine:
|
||||
|
||||
MFP timer clock 16 MHz / 4 MAME 0.277 sharp/x68k.cpp:1027-1028
|
||||
prescaler ladder 4,10,16,50,64,100,200 machine/mc68901.cpp:173
|
||||
timer data reg 8 bits, 0 means 256 machine/mc68901.cpp (TCDR/TADR)
|
||||
V-DISP -> GPIP4 x68k.cpp:1139, and it is also Timer A's event input,
|
||||
mc68901.cpp:167 GPIO_TIMER = {GPIP_4, GPIP_3}
|
||||
line rate 31,500 Hz exactly in both 31.5 kHz modes, derived in
|
||||
tools/bench/crtc_mode.lua from the dot clocks
|
||||
interrupt cost MEASURED, not tabled: tools/bench/clock_run.sh
|
||||
|
||||
THE PART THAT IS NOT A CLOCK PROBLEM AT ALL. 12 fps on a 55.4577 Hz raster is
|
||||
4.6215 refreshes per frame, so every frame is shown for 4 refreshes or 5 --
|
||||
72.13 ms or 90.16 ms -- and 37.9% of them get the short one. That is the
|
||||
display's quantisation and no choice of clock changes it. What it changes is
|
||||
the BUDGET: 833,333 clocks is the mean slot, not the slot, and the short slot is
|
||||
721,270. With the per-frame decode costs in hand this file says exactly how
|
||||
many frames do not fit theirs, which is a thing this project has never had to
|
||||
ask because until now the tick came from a host that could not miss.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
|
||||
MFP_HZ = 16_000_000 // 4 # x68k.cpp:1027-1028
|
||||
PRESCALER = [4, 10, 16, 50, 64, 100, 200] # mc68901.cpp:173
|
||||
HFREQ = 31500 # lines/s, crtc_mode.lua
|
||||
CPUHZ = 10_000_000 # 40 MHz / 4, x68k.cpp:1133
|
||||
|
||||
|
||||
def timer_space(fps):
|
||||
"""Every (prescale, data) the MFP can be set to, against a target fps."""
|
||||
slowest = MFP_HZ / (PRESCALER[-1] * 256)
|
||||
print(f"\n=== 1. THE MFP TIMER, WHICH CANNOT DO IT ALONE")
|
||||
print(f" timer clock {MFP_HZ:,} Hz, prescalers {PRESCALER}, data 1..256")
|
||||
print(f" slowest tick any single timer can produce: "
|
||||
f"{MFP_HZ}/({PRESCALER[-1]}*256) = {slowest:.3f} Hz")
|
||||
print(f" a {fps} fps frame needs {fps} Hz, which is {slowest/fps:.1f}x "
|
||||
f"slower than that -- so a software divider is REQUIRED whatever the "
|
||||
f"source, and 'use an MFP timer' is not by itself an answer.")
|
||||
exact = [(p, d) for p in PRESCALER for d in range(1, 257)
|
||||
if (MFP_HZ * d * p) and (MFP_HZ % (p * d) == 0)
|
||||
and (MFP_HZ // (p * d)) % fps == 0]
|
||||
print(f" settings whose tick rate is a whole multiple of {fps} Hz, so that "
|
||||
f"a plain counter would be exact: {len(exact)}")
|
||||
if exact:
|
||||
best = min(exact, key=lambda pd: MFP_HZ / (pd[0] * pd[1]))
|
||||
p, d = best
|
||||
tick = MFP_HZ / (p * d)
|
||||
print(f" slowest of them: prescale /{p} data {d} = {tick:.4f} Hz, "
|
||||
f"{tick/fps:.0f} ticks per frame")
|
||||
print(f" -> {tick/fps:.0f} interrupts per frame, against 4.6215 for "
|
||||
f"the raster: {tick/fps/(HFREQ/(fps*568)):.1f}x the cost, and its "
|
||||
f"phase against the raster is arbitrary, so a frame would be "
|
||||
f"presented mid-scan.")
|
||||
else:
|
||||
print(f" NONE. {MFP_HZ}/{fps} = {MFP_HZ/fps:,.2f} is not an "
|
||||
f"integer, so no prescale/data pair divides to {fps} Hz at all.")
|
||||
|
||||
|
||||
def raster_space(fps, vtotal):
|
||||
hz = HFREQ / vtotal
|
||||
print(f"\n=== 2. THE RASTER, WHICH IS THE RIGHT SOURCE AND IS ALSO NOT "
|
||||
f"A WHOLE DIVIDE")
|
||||
print(f" V-DISP is {HFREQ}/{vtotal} = {hz:.4f} Hz, and it is BOTH the "
|
||||
f"GPIP4 interrupt and Timer A's event-count input")
|
||||
print(f" whole divides -- all the MFP can do in hardware, no software:")
|
||||
for n in (3, 4, 5, 6):
|
||||
f = hz / n
|
||||
print(f" Timer A event count = {n}: {f:7.4f} fps "
|
||||
f"({100*(f/fps-1):+6.2f}% from {fps})")
|
||||
print(f" {fps} fps needs {hz/fps:.4f} refreshes per frame, which is not a "
|
||||
f"whole number, so no event-count setting is exact either.")
|
||||
print(f"\n THE DIVIDER THAT IS EXACT: add fps*VTOTAL = {fps*vtotal} per "
|
||||
f"V-DISP, emit a tick at {HFREQ}, keep the remainder.")
|
||||
print(f" long-run rate = {fps}*{vtotal}/{vtotal} = {fps} fps EXACTLY, "
|
||||
f"with a remainder that never accumulates")
|
||||
print(f" the accumulator stays under {HFREQ + fps*vtotal:,}, so it is "
|
||||
f"16-bit arithmetic on a 68000 (the ceiling is fps < "
|
||||
f"{(65536-HFREQ)/vtotal:.1f} at this VTOTAL, and clk_init checks it)")
|
||||
|
||||
|
||||
def divider_gaps(fps, vtotal, n):
|
||||
"""The tick sequence src/player/clock.i emits, in refreshes per tick."""
|
||||
acc, gaps, since = 0, [], 0
|
||||
while len(gaps) < n:
|
||||
acc += fps * vtotal
|
||||
since += 1
|
||||
if acc >= HFREQ:
|
||||
acc -= HFREQ
|
||||
gaps.append(since)
|
||||
since = 0
|
||||
return gaps
|
||||
|
||||
|
||||
def host_gaps(fps, refresh_hz, n):
|
||||
"""The tick sequence tools/bench/stream.lua's HOST clock emits.
|
||||
|
||||
It looks uniform in the source -- `floor((t - t_rel) * fps)` -- and is not.
|
||||
Lua only sees the machine at frame boundaries, so tick k lands on the first
|
||||
refresh at or after k/fps, and the gaps between ticks come out as the same
|
||||
two whole numbers of refreshes the divider produces. The host-paced runs of
|
||||
FINDINGS 49 and 51 therefore already had this cadence in them; what ROADMAP
|
||||
P3 changes is who produces it, not whether it exists.
|
||||
"""
|
||||
at = [-(-int(k * refresh_hz * 1000000 // fps) // 1000000) for k in range(n + 1)]
|
||||
return [at[k + 1] - at[k] for k in range(n)]
|
||||
|
||||
|
||||
def cadence(fps, vtotal, costs, label, refresh_hz, gaps, uniform=False):
|
||||
"""Charge each frame the slot it really gets, and run the pace gate."""
|
||||
rpf = 1.0 if uniform else HFREQ / (fps * vtotal)
|
||||
lo, hi = (1, 1) if uniform else (int(rpf), int(rpf) + 1)
|
||||
slot_lo = lo / refresh_hz * CPUHZ
|
||||
slot_hi = hi / refresh_hz * CPUHZ
|
||||
nominal = CPUHZ / fps
|
||||
|
||||
n_lo = sum(1 for g in gaps[:len(costs)] if g == lo)
|
||||
print(f"\n --- {label}: refresh {refresh_hz:.4f} Hz")
|
||||
print(f" slots are {lo} refreshes = {slot_lo:,.0f} clk "
|
||||
f"({1000*lo/refresh_hz:.2f} ms) or {hi} = {slot_hi:,.0f} clk "
|
||||
f"({1000*hi/refresh_hz:.2f} ms)")
|
||||
print(f" the nominal {fps} fps budget every figure in this project is "
|
||||
f"priced against is {nominal:,.0f} clk; the SHORT slot is "
|
||||
f"{100*(slot_lo/nominal-1):+.1f}% of it")
|
||||
over_lo = sum(1 for c in costs if c > slot_lo)
|
||||
over_hi = sum(1 for c in costs if c > slot_hi)
|
||||
over_nom = sum(1 for c in costs if c > nominal)
|
||||
print(f" frames that do not fit: {over_lo}/{len(costs)} the short "
|
||||
f"slot, {over_hi}/{len(costs)} the long one, {over_nom}/{len(costs)} "
|
||||
f"the nominal budget")
|
||||
|
||||
# The schedule, with the catch-up the pace gate actually performs: frame i
|
||||
# starts at max(finish of i-1, tick i). A frame that overruns does not fail
|
||||
# -- it eats the next frame's idle, and the clock catches up by itself.
|
||||
t, tick, late, worst, first = 0.0, 0.0, 0, 0.0, None
|
||||
for i, c in enumerate(costs):
|
||||
if t <= tick:
|
||||
t = tick # idled: on time
|
||||
else:
|
||||
if i: # frame 0 has no predecessor
|
||||
late += 1
|
||||
if first is None:
|
||||
first = i
|
||||
worst = max(worst, t - tick)
|
||||
t += c
|
||||
tick += gaps[i] / refresh_hz * CPUHZ
|
||||
print(f" through the pace gate: {late}/{len(costs)} frames found "
|
||||
f"their slot already open (first at frame {first}), worst start "
|
||||
f"{worst:,.0f} clk = {1000*worst/CPUHZ:.1f} ms behind its tick")
|
||||
if not uniform:
|
||||
print(f" {n_lo}/{len(costs)} slots were the short one "
|
||||
f"({100*n_lo/len(costs):.1f}%; the exact share is "
|
||||
f"{100*(hi-rpf):.1f}%)")
|
||||
return late
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--fps", type=int, default=12)
|
||||
ap.add_argument("--vtotal", type=int, default=568,
|
||||
help="CRTC R04+1; 568 is the 31.5 kHz 256-line mode")
|
||||
ap.add_argument("--csv", default="tmp/c68k_frames.csv",
|
||||
help="per-frame decode cost, from tools/bench/c68k/run.sh")
|
||||
a = ap.parse_args()
|
||||
|
||||
timer_space(a.fps)
|
||||
raster_space(a.fps, a.vtotal)
|
||||
|
||||
print(f"\n=== 3. WHAT THE CADENCE COSTS")
|
||||
if not os.path.exists(a.csv):
|
||||
print(f" {a.csv} not found -- run tools/bench/c68k/run.sh first. The "
|
||||
f"cadence question CANNOT be answered from percentiles: it needs "
|
||||
f"the per-frame series, because what matters is whether an "
|
||||
f"expensive frame lands in a short slot and how long the catch-up "
|
||||
f"takes afterwards.")
|
||||
return 1
|
||||
costs = [float(r["cycles"]) for r in csv.DictReader(open(a.csv))]
|
||||
print(f" {len(costs)} frames from {a.csv}: mean {sum(costs)/len(costs):,.0f} "
|
||||
f"clk, max {max(costs):,.0f} (frame {costs.index(max(costs))})")
|
||||
|
||||
hw = HFREQ / a.vtotal
|
||||
n = len(costs)
|
||||
# MAME's screen is fast by htotal/(htotal-8): refresh_mode() builds the
|
||||
# frame period from scr.max_x*scr.max_y with scr.max_x = m_htotal - 8. Both
|
||||
# rasters are run, because the rig measures against the fast one and the
|
||||
# player will run on the other -- reporting only one would leave the rig's
|
||||
# count and this file's differing with nobody able to say which was wrong.
|
||||
htotal = 368
|
||||
mame = hw * htotal / (htotal - 8)
|
||||
|
||||
# The budget model every figure in FINDINGS assumes: a slot of exactly
|
||||
# 1/fps. No machine has this; it is the yardstick, run through the same
|
||||
# schedule so that what the cadence ADDS can be read off.
|
||||
cadence(a.fps, a.vtotal, costs, "the NOMINAL model (a slot of exactly "
|
||||
"1/fps, which no raster produces)", float(a.fps),
|
||||
[1] * (n + 1), uniform=True)
|
||||
cadence(a.fps, a.vtotal, costs, "the HOST tick, as tools/bench/stream.lua "
|
||||
"actually emits it", mame, host_gaps(a.fps, mame, n + 1))
|
||||
cadence(a.fps, a.vtotal, costs, "the 68000's own clock on the HARDWARE "
|
||||
"raster", hw, divider_gaps(a.fps, a.vtotal, n + 1))
|
||||
cadence(a.fps, a.vtotal, costs, f"the 68000's own clock on MAME's raster "
|
||||
f"(fast by {htotal}/{htotal-8})", mame,
|
||||
divider_gaps(a.fps, a.vtotal, n + 1))
|
||||
|
||||
print(f"""
|
||||
Reading it.
|
||||
|
||||
THE CADENCE WAS ALREADY THERE. The nominal row is the model every budget in
|
||||
this project is priced against -- a slot of exactly 1/fps -- and no raster
|
||||
produces it. The host row is what tools/bench/stream.lua has been emitting all
|
||||
along: `floor((t - t_rel) * fps)` looks uniform, but Lua only sees the machine at
|
||||
frame boundaries, so its ticks land on refreshes and its gaps are the same two
|
||||
whole numbers. The host-paced results of FINDINGS 49 and 51 therefore already
|
||||
carried a 4/5 cadence that nothing named. ROADMAP P3 did not introduce it; it
|
||||
moved who produces it onto the machine, where it belongs, and made it visible.
|
||||
|
||||
THE SHORT SLOT IS REAL AND IT IS NOT A FAILURE. {sum(1 for c in costs if c > 721270)}/{len(costs)} frames do not fit
|
||||
721,270 clocks. The pace gate only says "not before tick i", so a frame that
|
||||
overruns spends the next frame's idle and the clock recovers by itself; the cost
|
||||
is one frame presented a refresh late, not a dropped frame. What the counts
|
||||
above measure is frames with no idle left, and the difference between the
|
||||
nominal row and the raster rows -- 1 against 4 -- is the whole price of the
|
||||
cadence on this container.
|
||||
|
||||
THE EXPENSIVE FRAME IS FRAME 0, at {max(costs)/(CPUHZ/a.fps)*100:.0f}% of the nominal budget: the first
|
||||
frame of a scene has nothing to SKIP against, so it is the whole picture in one
|
||||
slot. Most of what follows it in these counts is that transient draining, which
|
||||
is why the first index is printed next to the total. It also means the cost is
|
||||
paid AT A SCENE CHANGE, alongside the 18.96 ms of loader (FINDINGS 53.2) and the
|
||||
seek -- not spread over the window.
|
||||
|
||||
SCOPE. Decode costs are C68K's, on zero-wait-state memory, so they are a lower
|
||||
bound; real DRAM moves every row here in the same direction. The MAME rows are
|
||||
the emulator's fast raster and exist to be compared with tools/bench/pace_run.sh,
|
||||
not to describe hardware.""")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user