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())
|
||||
@@ -11,7 +11,8 @@
|
||||
-- a LOWER BOUND, not a prediction. Interrupts are masked (SR=$2700) so the
|
||||
-- IPL's timer and VBL handlers cannot steal cycles into the measurement.
|
||||
--
|
||||
-- Timing resolution is one video frame (1/55.46 s = 18.03 ms), because Lua
|
||||
-- Timing resolution is one video frame (1/56.69 s = 17.64 ms -- MAME's, not
|
||||
-- the hardware's 55.46; see crtc_mode.lua), because Lua
|
||||
-- gets no cycle counter -- luaengine.cpp exposes machine.time and nothing
|
||||
-- from device_execute_interface. Each variant therefore loops enough times
|
||||
-- to run ~4 emulated seconds, putting the granularity error near 0.4%.
|
||||
|
||||
+45
-1
@@ -286,7 +286,7 @@ echo "--- session 21: the 68000 builds its own codebooks and palette (FINDINGS 5
|
||||
#
|
||||
# NOT gated on the cycle counts, and the reason is NOT the one blit.s has. These
|
||||
# are emulated time and reproduce exactly run to run; what they are not is
|
||||
# sharp, because MAME samples them on a 1/55.46 s clock and the job takes
|
||||
# sharp, because MAME samples them on a 1/56.69 s clock and the job takes
|
||||
# milliseconds. Nothing in the tree's cost models depends on them either. A
|
||||
# change in them is a re-derivation in FINDINGS 53, not a red light here.
|
||||
bash tools/bench/load_run.sh "$DLX" > tmp/load_gate.log 2>&1 || {
|
||||
@@ -294,4 +294,48 @@ bash tools/bench/load_run.sh "$DLX" > tmp/load_gate.log 2>&1 || {
|
||||
exit 1; }
|
||||
grep -aE "^ *OK|both CPU cores|SCENE CHANGE" tmp/load_gate.log | sed 's/^ *//;s/^/ /'
|
||||
|
||||
echo "--- session 22: the 68000 keeps its own frame clock (FINDINGS 54) ---"
|
||||
# ROADMAP P3. Until now the 12 fps tick came from tools/bench/stream.lua -- a
|
||||
# host writing a word into emulated RAM. A player has no host. src/player/
|
||||
# clock.i derives the tick from the CRTC's own V-DISP output through the MFP,
|
||||
# with a remainder-keeping divider whose two constants are READ OUT OF THE CRTC
|
||||
# at init, so the clock and the raster it counts cannot disagree.
|
||||
#
|
||||
# WHAT IS GATED, and it is deliberately structural rather than numeric:
|
||||
# * the interrupt count equals the raster frame count -- the tick IS the
|
||||
# raster, not something that merely resembles it;
|
||||
# * the divider does not accumulate drift, stated in TICKS (a remainder can
|
||||
# hold back at most one) rather than in ppm, which would let a longer
|
||||
# window advertise a tighter clock for free;
|
||||
# * every frame tick waits 4 or 5 refreshes and nothing else, which is what a
|
||||
# remainder-keeping divider can produce and a broken one cannot.
|
||||
# The interrupt COST is printed and not gated, for the same reason FINDINGS 53's
|
||||
# cycle counts are not: it is a measurement, and a change in it is a
|
||||
# re-derivation in FINDINGS 54 rather than a red light here.
|
||||
bash tools/bench/clock_run.sh 3000 12 > tmp/clock_gate.log 2>&1 || {
|
||||
echo "FAIL: the frame clock did not pass."; tail -12 tmp/clock_gate.log
|
||||
exit 1; }
|
||||
grep -aE "INTERRUPT:|PER FRAME:|DRIFT:|CADENCE:" tmp/clock_gate.log
|
||||
grep -q "V-DISP interrupts 3000" tmp/clock_gate.log || {
|
||||
echo "FAIL: the tick is not the raster -- the interrupt count and the frame"
|
||||
echo " count disagree. Everything else in FINDINGS 54 rests on that."
|
||||
exit 1; }
|
||||
|
||||
echo "--- session 22: 120 frames decoded on the machine's own clock (FINDINGS 54) ---"
|
||||
# The strongest form of the claim: the same pixel-exact 120-frame decode out of
|
||||
# the same 256 KB ring, with NOTHING outside the machine deciding when a frame
|
||||
# may start. The pace gate in src/player/stream.s is byte-for-byte the one
|
||||
# FINDINGS 51 measured -- it cannot tell a host-written tick from a machine-
|
||||
# written one, which is why this is a test of the clock and not of a new rig.
|
||||
DLX_PACE=2 bash tools/bench/pace_run.sh 256 0 > tmp/selfpace_check.log 2>&1 || {
|
||||
echo "FAIL: the self-paced pass did not complete."; tail -8 tmp/selfpace_check.log
|
||||
exit 1; }
|
||||
grep -aE "decoder SELF-PACED|FRAME CLOCK|UNDERRUNS|NO IDLE" tmp/selfpace_check.log
|
||||
grep -q "UNDERRUNS: 0/120" tmp/selfpace_check.log || {
|
||||
echo "FAIL: the self-paced decoder underran."; exit 1; }
|
||||
grep -q "^OK" tmp/selfpace_check.log || {
|
||||
echo "FAIL: the self-paced pass was not pixel-exact. The clock changed WHEN"
|
||||
echo " frames start; if it changed WHAT they draw, the interrupt is"
|
||||
echo " corrupting decoder state."; tail -4 tmp/selfpace_check.log; exit 1; }
|
||||
|
||||
echo "ALL GREEN"
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
-- Drive src/player/clockgate.s: measure the 68000's own FRAME CLOCK.
|
||||
-- ROADMAP P3.
|
||||
--
|
||||
-- Two things are being measured and they need different instruments.
|
||||
--
|
||||
-- THE RATE AND THE CADENCE are counted, not timed. The clock's tick is a
|
||||
-- V-DISP interrupt, and MAME's Lua sees the machine once per screen frame --
|
||||
-- which is once per V-DISP. So the host's sampling granularity is exactly the
|
||||
-- clock's own granularity, and the cadence comes out as integers: how many
|
||||
-- refreshes each frame tick waited. There is no timing error to argue about
|
||||
-- in a count of 4s and 5s.
|
||||
--
|
||||
-- THE COST IS TIMED BY THE 68000, because the host cannot. 1/55.46 s of host
|
||||
-- granularity is 18 ms and the interrupt costs microseconds. So the 68000 runs
|
||||
-- a one-instruction loop for a window of thousands of refreshes and the host
|
||||
-- reads the iteration count at both ends; the interrupt cost falls out of the
|
||||
-- difference between a run with the clock armed and one without. See the head
|
||||
-- of src/player/clockgate.s for the arithmetic. This script emits the raw
|
||||
-- counts; tools/bench/clock_cost.py does the subtraction, so that the two runs
|
||||
-- it needs can be separate MAME invocations.
|
||||
--
|
||||
-- MEASUREMENT SCOPE. This is MAME 0.277's emulated X68000, not real hardware.
|
||||
-- What is being priced is the interrupt sequence of MAME's cycle-accurate
|
||||
-- M68000 core (src/devices/cpu/m68000, the `M68000` device x68k.cpp:1133 asks
|
||||
-- for) against zero-wait-state RAM. Real DRAM adds wait states to the six bus
|
||||
-- cycles of the exception and the four of the handler alike, so this is a LOWER
|
||||
-- BOUND in the same way every other 68000 figure in this project is.
|
||||
--
|
||||
-- Env:
|
||||
-- DLX_CLK_ON 1 = arm the frame clock, 0 = leave it off (the calibration
|
||||
-- run). REQUIRED -- the two runs are not interchangeable and a
|
||||
-- default would let one be reported as the other.
|
||||
-- DLX_CLK_FPS frame rate to ask clk_init for (default 12)
|
||||
-- DLX_CLK_WIN measurement window, in raster frames (default 3000 = 54.1 s)
|
||||
-- DLX_CLK_OUT where to write the raw counts (default tmp/clock_run.txt)
|
||||
|
||||
M = manager.machine
|
||||
SP = M.devices[":maincpu"].spaces["program"]
|
||||
|
||||
local function findfile(n)
|
||||
for _,p in ipairs{"../tools/bench/"..n, "tools/bench/"..n, n} do
|
||||
local f = io.open(p,"rb"); if f then f:close(); return p end
|
||||
end
|
||||
error(n.." not found")
|
||||
end
|
||||
local MODE = loadfile(findfile("crtc_mode.lua"))()
|
||||
|
||||
local CGFLAG, CGON, CGCNT = 0x18070, 0x18074, 0x18078
|
||||
local CLK_PACE = 0x18034
|
||||
local CLK_ACC, CLK_INCR = 0x18060, 0x18062
|
||||
local CLK_VDISP, CLK_FPS = 0x18064, 0x18068
|
||||
local CLK_ERR = 0x1806C
|
||||
local CPUHZ = 10000000
|
||||
|
||||
local ONS = os.getenv("DLX_CLK_ON")
|
||||
local FPS = tonumber(os.getenv("DLX_CLK_FPS") or "") or 12
|
||||
local WIN = tonumber(os.getenv("DLX_CLK_WIN") or "") or 3000
|
||||
local OUT = os.getenv("DLX_CLK_OUT") or "clock_run.txt"
|
||||
|
||||
local function P(s) print("[CLK] "..s) end
|
||||
|
||||
if ONS ~= "0" and ONS ~= "1" then
|
||||
P("DLX_CLK_ON must be 0 (calibration, clock off) or 1 (clock armed). The "
|
||||
.."cost figure is the DIFFERENCE between the two runs, so neither is "
|
||||
.."meaningful alone and neither gets to be the default.")
|
||||
M:exit()
|
||||
return
|
||||
end
|
||||
local ON = (ONS == "1")
|
||||
|
||||
local code do local f=io.open("clockgate.bin","rb"); code=f:read("a"); f:close() end
|
||||
|
||||
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
|
||||
|
||||
-- Settling frames between the gate reporting `running` and the window opening.
|
||||
-- The CPU may still be inside clk_init when the host first sees CGFLAG=1, and
|
||||
-- the first V-DISP edge after arming lands wherever the raster happens to be.
|
||||
-- Two frames puts the window entirely inside the steady state.
|
||||
local SETTLE = 2
|
||||
|
||||
local st, n = "boot", 0
|
||||
local f_ready, f0, f1 = nil, nil, nil
|
||||
local c0, c1, v0, v1, p0, p1, t0, t1
|
||||
-- Cadence: refreshes between consecutive frame ticks. Recorded as a histogram
|
||||
-- and as the raw first few, because the interesting claim is not the mean (the
|
||||
-- divider makes that exact by construction) but that the SPREAD is only ever
|
||||
-- the two values either side of fps*VTOTAL/HFREQ.
|
||||
local last_pace, last_pace_f, cad, seen_tick = nil, nil, {}, false
|
||||
|
||||
SUB = emu.add_machine_frame_notifier(function()
|
||||
local ok, err = pcall(function()
|
||||
n = n + 1
|
||||
if st == "boot" then
|
||||
if T() < 3.0 then return end
|
||||
MODE.apply(SP)
|
||||
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
|
||||
SP:write_u32(CGFLAG, 0)
|
||||
SP:write_u32(CGON, ON and 1 or 0)
|
||||
SP:write_u32(CLK_FPS, FPS)
|
||||
local cpu = M.devices[":maincpu"]
|
||||
cpu.state["SR"].value = 0x2700 -- clk_init lowers it to $2500 itself
|
||||
cpu.state["SP"].value = 0x8000
|
||||
cpu.state["PC"].value = 0x10000
|
||||
P(string.format("clockgate.bin=%d B, clock %s, asking for %d fps, "
|
||||
.."window %d raster frames", #code,
|
||||
ON and "ARMED" or "OFF (calibration run)", FPS, WIN))
|
||||
st = "wait"; return
|
||||
end
|
||||
if st == "wait" then
|
||||
local fl = SP:read_u32(CGFLAG)
|
||||
if fl == 0xEE then
|
||||
local e = SP:read_u32(CLK_ERR)
|
||||
P("clk_init REFUSED: CLK_ERR="..e..(e == 1 and
|
||||
" (CRTC is not in a 31.5 kHz mode, so HFREQ=31500 would be wrong)" or
|
||||
e == 2 and " (fps*VTOTAL does not fit the 16-bit accumulator)" or ""))
|
||||
M:exit(); return
|
||||
end
|
||||
if fl ~= 1 then
|
||||
if T() > 60 then P("TIMEOUT: the gate never started"); M:exit() end
|
||||
return
|
||||
end
|
||||
f_ready = n; st = "settle"; return
|
||||
end
|
||||
if st == "settle" then
|
||||
if n < f_ready + SETTLE then return end
|
||||
f0, t0 = n, T()
|
||||
c0 = SP:read_u32(CGCNT)
|
||||
v0 = SP:read_u32(CLK_VDISP)
|
||||
p0 = SP:read_u32(CLK_PACE)
|
||||
last_pace, last_pace_f = p0, n
|
||||
if ON then
|
||||
P(string.format("armed: incr=%d (fps*VTOTAL), acc=%d, first tick "
|
||||
.."pending", SP:read_u16(CLK_INCR),
|
||||
SP:read_u16(CLK_ACC)))
|
||||
end
|
||||
st = "run"; return
|
||||
end
|
||||
if st == "run" then
|
||||
if ON then
|
||||
local pc = SP:read_u32(CLK_PACE)
|
||||
if pc ~= last_pace then
|
||||
-- The FIRST change is dropped. Its interval runs from the window
|
||||
-- opening rather than from a tick, so it measures where the window
|
||||
-- happened to start and would show up as a spurious short bucket.
|
||||
if seen_tick then
|
||||
-- More than one tick in a single refresh would mean fps above the
|
||||
-- raster rate; give it its own bucket rather than averaging it in.
|
||||
local gap = n - last_pace_f
|
||||
if pc - last_pace > 1 then gap = 0 end
|
||||
cad[gap] = (cad[gap] or 0) + 1
|
||||
end
|
||||
seen_tick = true
|
||||
last_pace, last_pace_f = pc, n
|
||||
end
|
||||
end
|
||||
if n < f0 + WIN then return end
|
||||
f1, t1 = n, T()
|
||||
c1 = SP:read_u32(CGCNT)
|
||||
v1 = SP:read_u32(CLK_VDISP)
|
||||
p1 = SP:read_u32(CLK_PACE)
|
||||
st = "done"
|
||||
|
||||
local frames = f1 - f0
|
||||
local secs = t1 - t0
|
||||
local clocks = secs * CPUHZ
|
||||
local iters = c1 - c0
|
||||
local ints = v1 - v0
|
||||
local ticks = p1 - p0
|
||||
P(string.format("window: %d raster frames, %.6f s emulated -> %.0f "
|
||||
.."68000 clocks", frames, secs, clocks))
|
||||
-- THE INSTRUMENT IS 2.22% FAST AND IT IS WORTH SAYING SO EVERY RUN.
|
||||
-- The CRTC registers describe a 31,500 lines/s raster of VTOTAL lines.
|
||||
-- MAME does not run it at that rate: x68k_crtc.cpp refresh_mode()
|
||||
-- computes the frame period as (scr.max_x * scr.max_y) dots with
|
||||
-- scr.max_x = m_htotal - 8, one character cell short and an INCLUSIVE
|
||||
-- rectangle bound used as a count. So the emulated raster is fast by
|
||||
-- htotal/(htotal-8) -- 368/360 in this mode -- and every rate derived
|
||||
-- from it here is fast by the same factor. The divider under test is
|
||||
-- built on the registers, so its HARDWARE rate is the asked-for one and
|
||||
-- what this rig can check is that it tracks whatever raster it is given.
|
||||
local vtotal = SP:read_u16(0xE80008) + 1
|
||||
local htotal = (SP:read_u16(0xE80000) + 1) * 8
|
||||
local hw_hz = 31500 / vtotal
|
||||
local skew = htotal / (htotal - 8)
|
||||
P(string.format(" raster period %.4f ms = %.4f Hz", 1000*secs/frames,
|
||||
frames/secs))
|
||||
P(string.format(" the CRTC registers describe 31500/%d = %.4f Hz; "
|
||||
.."MAME is fast by htotal/(htotal-8) = %d/%d = %.4f",
|
||||
vtotal, hw_hz, htotal, htotal-8, skew))
|
||||
P(string.format(" loop iterations %d", iters))
|
||||
if ON then
|
||||
P(string.format(" V-DISP interrupts %d, frame ticks %d", ints,
|
||||
ticks))
|
||||
-- The self-check that makes the rest of it worth reading: the interrupt
|
||||
-- count and the host's screen-frame count are supposed to be the SAME
|
||||
-- clock seen from two sides. If they disagree by more than the one
|
||||
-- edge the window boundaries can straddle, the tick is not the raster.
|
||||
if math.abs(ints - frames) > 1 then
|
||||
P(string.format("FAIL: %d V-DISP interrupts over %d raster frames. "
|
||||
.."The tick is not coming from the raster.", ints,
|
||||
frames))
|
||||
M:exit(); return
|
||||
end
|
||||
-- Two numbers, and confusing them is the whole trap. The measured rate
|
||||
-- is against MAME's fast raster; dividing the skew out gives the rate
|
||||
-- the same code produces on a machine whose raster matches its own
|
||||
-- registers, which is the number the player is judged on.
|
||||
local meas = ticks/secs
|
||||
P(string.format(" measured rate %.6f fps against MAME's raster "
|
||||
.."(%+.0f ppm vs the asked %d)", meas,
|
||||
1e6*(meas/FPS - 1), FPS))
|
||||
P(string.format(" de-skewed %.6f fps -> %+.1f ppm from %d, "
|
||||
.."which is the tick quantisation of %d ticks and not "
|
||||
.."drift", meas/skew, 1e6*(meas/skew/FPS - 1), FPS,
|
||||
ticks))
|
||||
local ks = {}
|
||||
for k in pairs(cad) do ks[#ks+1] = k end
|
||||
table.sort(ks)
|
||||
local s = ""
|
||||
for _,k in ipairs(ks) do
|
||||
s = s .. string.format("%d:%d ", k, cad[k])
|
||||
end
|
||||
P(" cadence, refreshes per frame tick: "..s)
|
||||
end
|
||||
|
||||
local fh = io.open(OUT, "w")
|
||||
fh:write(string.format("on %d\nfps %d\nframes %d\nsecs %.15g\n"
|
||||
.."clocks %.15g\niters %d\nints %d\nticks %d\n"
|
||||
.."vtotal %d\nhtotal %d\nhw_hz %.15g\nskew %.15g\n",
|
||||
ON and 1 or 0, FPS, frames, secs, clocks, iters,
|
||||
ints, ticks, vtotal, htotal, hw_hz, skew))
|
||||
for k, v in pairs(cad) do fh:write(string.format("cad %d %d\n", k, v)) end
|
||||
fh:close()
|
||||
P("counts -> "..OUT)
|
||||
P("done")
|
||||
M:exit(); return
|
||||
end
|
||||
end)
|
||||
if not ok then print("[CLK] LUA ERROR: "..tostring(err)); M:exit() end
|
||||
end)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""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]))
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# One frame-clock run: the 68000 derives its own 12 fps tick from the raster
|
||||
# (ROADMAP P3, FINDINGS 54).
|
||||
#
|
||||
# tools/bench/clock_run.sh [window-in-raster-frames] [fps]
|
||||
#
|
||||
# TWO MAME INVOCATIONS, and they are not interchangeable. The first leaves the
|
||||
# clock off and calibrates the cost of the gate's own loop; the second arms it.
|
||||
# The interrupt cost is the difference, so a run that reported only the second
|
||||
# would be reporting a number it cannot compute. See src/player/clockgate.s.
|
||||
#
|
||||
# Only MAME can run this: the frame clock is an MFP interrupt driven by the
|
||||
# CRTC's V-DISP output, and tools/bench/c68k has neither device. That is why
|
||||
# this stage has no second-core half, unlike load_run.sh.
|
||||
set -e
|
||||
cd "$(dirname "$0")/../.."
|
||||
WIN=${1:-3000}
|
||||
FPS=${2:-12}
|
||||
|
||||
tools/vasm/vasmm68k_mot -Fbin -o tmp/clockgate.bin src/player/clockgate.s > /dev/null
|
||||
|
||||
for ON in 0 1; do
|
||||
# stdbuf -oL: without it a long MAME run is unobservable until it exits, and
|
||||
# a run that is merely finishing looks exactly like one that is wedged (34.1).
|
||||
( cd tmp && DLX_CLK_ON=$ON DLX_CLK_FPS=$FPS DLX_CLK_WIN=$WIN \
|
||||
DLX_CLK_OUT=clock_$ON.txt SDL_VIDEODRIVER=dummy stdbuf -oL \
|
||||
timeout -k 5 600 mame x68000 -bios ipl10 -ramsize 2M -video soft -window \
|
||||
-sound none -nothrottle -plugins -autoboot_script ../tools/bench/clock.lua \
|
||||
-seconds_to_run 240 > clock_$ON.log 2>&1 )
|
||||
# A run that never reached the counts must fail as that, not as bad arithmetic.
|
||||
grep -q "^\[CLK\] done" tmp/clock_$ON.log || {
|
||||
echo "FAIL: the clock rig did not finish run ON=$ON -- no completion marker."
|
||||
tail -8 tmp/clock_$ON.log; exit 1; }
|
||||
done
|
||||
grep -a "^\[CLK\]" tmp/clock_1.log | sed -n '/window:/,/cadence/p' | sed 's/\[CLK\] / /'
|
||||
python3 tools/bench/clock_cost.py tmp/clock_0.txt tmp/clock_1.txt
|
||||
@@ -23,6 +23,26 @@
|
||||
-- Total blanking time is identical to the 768 mode (112 dots @ 11.592MHz =
|
||||
-- 336 dots @ 34.776MHz = 9.66us), which is what a real monitor needs.
|
||||
--
|
||||
-- THE EMULATOR DOES NOT RUN THE RASTER THESE REGISTERS DESCRIBE, and every
|
||||
-- rig in this tree samples the machine at ITS rate, not at the hardware's.
|
||||
-- x68k_crtc.cpp refresh_mode() builds the frame period as
|
||||
--
|
||||
-- (scr.max_x * scr.max_y) dots / dotclock, scr.max_x = m_htotal - 8
|
||||
--
|
||||
-- which is one character cell short AND uses an inclusive rectangle bound as a
|
||||
-- count. So MAME's refresh is fast by htotal/(htotal-8) = 368/360 = 1.02222:
|
||||
-- 56.6901 Hz where the registers say 55.4577. MEASURED, not read off the
|
||||
-- source alone -- tools/bench/clock.lua reports both every run, and they agree
|
||||
-- to six digits (FINDINGS 54.5).
|
||||
--
|
||||
-- It matters in exactly two places and is harmless in the rest. Anything timed
|
||||
-- by counting host frames has 1/56.69 s of granularity, not 1/55.46; and
|
||||
-- anything PACED by the raster runs 2.22% fast under MAME. It does NOT touch
|
||||
-- 68000 cycle figures: the CPU clock is 40 MHz/4 and has nothing to do with the
|
||||
-- screen. Do not "correct" the 55.4577 below to match a measurement -- it is
|
||||
-- the hardware's, derived from the dot clocks above, and it is what
|
||||
-- src/player/clock.i builds its divider on.
|
||||
--
|
||||
-- VERTICAL registers are NOT halved. The CRTC still generates a 568-line
|
||||
-- 31.5kHz raster (31500/568 = 55.46 Hz); "256 lines" is a graphics-layer
|
||||
-- double-scan (draw_gfx() halves gfxrect, x68k_v.cpp:401). Halving them would
|
||||
|
||||
@@ -93,7 +93,7 @@ end
|
||||
|
||||
-- The plan: one sequential correctness pass, then the cost anchors, then a
|
||||
-- full pass timed. Iteration counts target ~4 emulated seconds each so the
|
||||
-- 1/55.46 s timing granularity costs under 0.5%.
|
||||
-- 1/56.69 s timing granularity (crtc_mode.lua) costs under 0.5%.
|
||||
-- DLX_VERIFY_ONLY=1 drops the cost anchors and runs only the correctness pass,
|
||||
-- so tools/bench/check.sh can gate the decoder without paying for ~2 minutes of
|
||||
-- timing runs that would make the green light sensitive to host load anyway.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
--
|
||||
-- MEASUREMENT SCOPE, unchanged from decode.lua: MAME's memory carries no wait
|
||||
-- states, so these are pure 68000 instruction cycles -- a LOWER BOUND on real
|
||||
-- hardware. Interrupts are masked (SR=$2700). The host clock has 1/55.46 s
|
||||
-- hardware. Interrupts are masked (SR=$2700). The host clock has 1/56.69 s
|
||||
-- granularity and the job takes milliseconds, so each configuration is repeated
|
||||
-- LITER times and divided; repeating is honest because do_load is not
|
||||
-- temporally recursive -- every pass rewrites what the last one wrote, from the
|
||||
|
||||
+13
-2
@@ -15,12 +15,23 @@
|
||||
#
|
||||
# kbps 0 = unlimited pipe. There is no default rate anywhere in this tree
|
||||
# (FINDINGS 50) and there is none here either.
|
||||
#
|
||||
# DLX_PACE selects WHO KEEPS THE TIME: 1 (default) is the host writing the tick,
|
||||
# 2 is the 68000 writing it off the CRTC's V-DISP (ROADMAP P3, FINDINGS 54).
|
||||
# Everything else about the run is identical, which is the whole point -- the
|
||||
# pace gate in src/player/stream.s cannot tell them apart, so a difference in
|
||||
# the result is a difference in the CLOCK and not in the rig.
|
||||
set -e
|
||||
cd "$(dirname "$0")/../.."
|
||||
RING=${1:?ring KB}; KBPS=${2:?pipe KB/s, or 0 for unlimited}
|
||||
CUT_AT=$3; CUT_FR=${4:-1}
|
||||
DLX=${DLX:-tmp/rc_fr_singe_scsi_span.dlx}
|
||||
PACE=${DLX_PACE:-1}
|
||||
TAG="r${RING}_k${KBPS}${CUT_AT:+_cut${CUT_AT}x${CUT_FR}}"
|
||||
# The default tag is left ALONE when the host keeps the time: tools/bench/
|
||||
# pace_sweep.sh reads tmp/pace_r<ring>_k<kbps>.log by name, and renaming the
|
||||
# host-paced logs would break a sweep that has nothing to do with this option.
|
||||
if [ "$PACE" != 1 ]; then TAG="${TAG}_p$PACE"; fi
|
||||
|
||||
tools/vasm/vasmm68k_mot -Fbin -o tmp/stream.bin src/player/stream.s > /dev/null
|
||||
[ -f tmp/stream_disk.bin ] || python3 tools/bench/prep_stream.py "$DLX" > tmp/prep_stream.log
|
||||
@@ -29,7 +40,7 @@ mkdir -p "tmp/snap_pace_$TAG"; rm -f "tmp/snap_pace_$TAG/x68000"/*.png
|
||||
# of a prefix is not an assignment token, so bash takes the next word as the
|
||||
# command and the run dies with "SDL_VIDEODRIVER=dummy: command not found".
|
||||
CUTENV=(); [ -n "$CUT_AT" ] && CUTENV=(DLX_CUT_AT="$CUT_AT" DLX_CUT_FR="$CUT_FR")
|
||||
( cd tmp && env DLX_PACE=1 DLX_RING_KB=$RING DLX_STREAM_KBPS=$KBPS \
|
||||
( cd tmp && env DLX_PACE=$PACE DLX_RING_KB=$RING DLX_STREAM_KBPS=$KBPS \
|
||||
"${CUTENV[@]}" DLX_SLACK_CSV="slack_$TAG.csv" \
|
||||
SDL_VIDEODRIVER=dummy stdbuf -oL timeout -k 5 900 \
|
||||
mame x68000 -bios ipl10 -ramsize 2M -video soft -window -sound none \
|
||||
@@ -42,6 +53,6 @@ grep -q "snapshot taken" "tmp/pace_$TAG.log" || {
|
||||
echo "FAIL($TAG): no snapshot marker -- the pass did not complete."
|
||||
tail -6 "tmp/pace_$TAG.log"; exit 1; }
|
||||
echo "=== $TAG"
|
||||
grep -aE "decoder (PACED|FREE)|ring: |UNDERRUNS|SEEK SLACK|RING-BOUND|RATE-BOUND|BUILD TIME|PIPE CUT|DEADLINE|REQUIRED" \
|
||||
grep -aE "decoder (SELF-PACED|PACED|FREE)|FRAME CLOCK|ring: |UNDERRUNS|NO IDLE|SEEK SLACK|RING-BOUND|RATE-BOUND|BUILD TIME|PIPE CUT|DEADLINE|REQUIRED" \
|
||||
"tmp/pace_$TAG.log" | sed "s/\[STR\] / /"
|
||||
python3 tools/bench/verify_decode.py "$DLX" --snap "tmp/snap_pace_$TAG" | tail -2
|
||||
|
||||
@@ -107,7 +107,8 @@ end
|
||||
local function launch(cfg)
|
||||
push(STREAM, blob, cfg.off+1, cfg.len)
|
||||
clear_picture()
|
||||
-- ~4 emulated seconds per config: 1/55.46 s granularity costs under 0.5%.
|
||||
-- ~4 emulated seconds per config: 1/56.69 s granularity (crtc_mode.lua)
|
||||
-- costs under 0.5%.
|
||||
local est = cfg.nspans*(cfg.var == 5 and 60 or (cfg.var == 6 and 50 or 70))
|
||||
+ cfg.npix*10
|
||||
cfg.iter = math.max(4, math.floor(4*CPUHZ/est))
|
||||
|
||||
+84
-7
@@ -53,7 +53,17 @@
|
||||
-- (FINDINGS 42.1). A default is how a folklore number ends up
|
||||
-- silently underneath a table nobody restates it in.
|
||||
-- DLX_PREFILL_KB bytes to deliver before releasing the CPU (default 0)
|
||||
-- DLX_PACE 1 = hold the decoder to META.fps (default 0 = free-run)
|
||||
-- DLX_PACE 1 = hold the decoder to META.fps, with the HOST writing
|
||||
-- the tick (default 0 = free-run)
|
||||
-- 2 = hold it to META.fps with the 68000 writing its own
|
||||
-- tick, off the CRTC's V-DISP (src/player/clock.i, ROADMAP
|
||||
-- P3). Same gate, same ring, same deadlines; what changes
|
||||
-- is that nothing outside the machine decides when a frame
|
||||
-- may start. Deadlines are then taken from the ticks the
|
||||
-- machine actually emitted rather than from a host model of
|
||||
-- 12 fps -- which matters, because MAME's raster runs 2.22%
|
||||
-- fast (see tools/bench/clock.lua) and a host model would
|
||||
-- quietly grade every arrival against the wrong clock.
|
||||
-- DLX_CUT_AT frame tick at which the pipe stops dead (a seek). Needs
|
||||
-- DLX_PACE; unset = no cut.
|
||||
-- DLX_CUT_FR how many frame times the cut lasts (default 1)
|
||||
@@ -88,7 +98,9 @@ local META = loadfile("stream_meta.lua")()
|
||||
local FLAG, ITER, NFR = 0x18000, 0x18008, 0x1800C
|
||||
local RD_PTR, FR_HEAD, FR_TAIL = 0x18020, 0x18024, 0x18028
|
||||
local STALLS, SPINS, DESC = 0x1802C, 0x18030, 0x18100
|
||||
local PACE, PACEON = 0x18034, 0x18038
|
||||
local PACE, PACEON, CLKON = 0x18034, 0x18038, 0x1803C
|
||||
local CLK_VDISP, CLK_FPS, CLK_ERR = 0x18064, 0x18068, 0x1806C
|
||||
local LATEFR, LATEMAX, LATE1ST = 0x18080, 0x18084, 0x18088
|
||||
local DESCN = 64
|
||||
local CB1, CB4 = 0x20000, 0x22000
|
||||
local RING = 0x40000
|
||||
@@ -108,7 +120,8 @@ if KBPS == nil then
|
||||
return
|
||||
end
|
||||
local PREFILL = (tonumber(os.getenv("DLX_PREFILL_KB") or "") or 0) * 1024
|
||||
local PACED = (os.getenv("DLX_PACE") == "1")
|
||||
local SELFCLK = (os.getenv("DLX_PACE") == "2")
|
||||
local PACED = (os.getenv("DLX_PACE") == "1") or SELFCLK
|
||||
local CUT_AT = tonumber(os.getenv("DLX_CUT_AT") or "")
|
||||
local CUT_FR = tonumber(os.getenv("DLX_CUT_FR") or "") or 1
|
||||
local SLACK_CSV = os.getenv("DLX_SLACK_CSV")
|
||||
@@ -269,9 +282,13 @@ local function setup()
|
||||
SP:write_u32(FLAG, 0); SP:write_u32(FR_HEAD, 0); SP:write_u32(FR_TAIL, 0)
|
||||
SP:write_u32(ITER, 1); SP:write_u32(NFR, META.nframes)
|
||||
SP:write_u32(PACE, 0); SP:write_u32(PACEON, PACED and 1 or 0)
|
||||
SP:write_u32(CLKON, SELFCLK and 1 or 0)
|
||||
SP:write_u32(CLK_FPS, META.fps)
|
||||
P(string.format("stream.bin=%d B, codebooks %d+%d B, disk %d B, %d frames",
|
||||
#code, META.cb1_len, META.cb4_len, META.disk_len, META.nframes))
|
||||
P(string.format("decoder %s%s", PACED and ("PACED at "..META.fps.." fps")
|
||||
P(string.format("decoder %s%s", SELFCLK
|
||||
and ("SELF-PACED at "..META.fps.." fps off V-DISP")
|
||||
or PACED and ("PACED at "..META.fps.." fps by the host")
|
||||
or "FREE-RUNNING (tests wrap, not buffering -- 49.7.2)",
|
||||
CUT_AT and string.format(", pipe cut at tick %d for %.2f fr",
|
||||
CUT_AT, CUT_FR) or ""))
|
||||
@@ -292,6 +309,12 @@ local function launch()
|
||||
end
|
||||
|
||||
local st, t0, t_rel = "boot", nil, 0
|
||||
-- tick_t[i+1] = emulated time of frame tick i, filled in as they are observed.
|
||||
-- Under a self-clock this replaces the host's `t_rel + i/fps` as the deadline:
|
||||
-- the machine's clock is the one the player is actually held to, and MAME's
|
||||
-- raster is 2.22% fast, so grading arrivals against a host model of 12 fps
|
||||
-- would flatter every record by that much.
|
||||
local tick_t = {}
|
||||
local pace, min_ahead, min_at = -1, math.huge, -1
|
||||
local sum_ahead, n_ahead = 0, 0
|
||||
local slack_series = {}
|
||||
@@ -314,10 +337,21 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
end
|
||||
if st == "running" then
|
||||
if PACED then
|
||||
local tick = math.floor((t - t_rel) * META.fps)
|
||||
-- WHO WRITES THE TICK. Host-paced, the tick is a host model of
|
||||
-- META.fps and this script advances it. Self-paced, the 68000 has
|
||||
-- already advanced it off the raster and this script only READS it --
|
||||
-- the frame gate in src/player/stream.s cannot tell the two apart,
|
||||
-- which is the point: the same bytes are gated either way.
|
||||
local tick = SELFCLK and SP:read_u32(PACE)
|
||||
or math.floor((t - t_rel) * META.fps)
|
||||
if tick > pace then
|
||||
pace = tick
|
||||
SP:write_u32(PACE, pace)
|
||||
if not SELFCLK then SP:write_u32(PACE, pace) end
|
||||
-- The emulated time this tick actually happened, which is what a
|
||||
-- record's deadline is measured against under a self-clock. Ticks
|
||||
-- can arrive more than one apart if the host misses a frame, so the
|
||||
-- whole run is filled rather than just the newest.
|
||||
for k = #tick_t, tick do tick_t[k+1] = t end
|
||||
-- Taken BEFORE this tick's frame is decoded, so snapshot n is the
|
||||
-- finished picture of frame n-1. tick 0 is skipped: nothing has been
|
||||
-- drawn yet and it would record a black screen as a decoded frame.
|
||||
@@ -358,6 +392,14 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
.."or described wrongly, not that the codec changed.")
|
||||
M:exit(); return
|
||||
end
|
||||
if fl == 0xE2 then
|
||||
local e = SP:read_u32(CLK_ERR)
|
||||
P("FRAME CLOCK REFUSED: CLK_ERR="..e..(e == 1 and
|
||||
" (the CRTC is not in a 31.5 kHz mode, so the divider's 31500 "
|
||||
.."lines/s would be wrong)" or e == 2 and
|
||||
" (fps*VTOTAL does not fit the 16-bit accumulator)" or ""))
|
||||
M:exit(); return
|
||||
end
|
||||
if fl == 0xE1 then
|
||||
P("PRODUCER STALLED OUT -- stream.s spun SPINMAX times with no new "
|
||||
.."record. Delivered "..nsent.."/"..META.nframes..".")
|
||||
@@ -382,6 +424,25 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
META.nframes, dt, dt*CPUHZ/META.nframes,
|
||||
100*(dt*CPUHZ/META.nframes)/FRAME12, META.fps))
|
||||
end
|
||||
if SELFCLK then
|
||||
-- The clock's own report, in the run where it actually paced a
|
||||
-- decoder rather than a busy loop. The rate is against MAME's fast
|
||||
-- raster; tools/bench/clock_run.sh is where it gets de-skewed.
|
||||
local vd = SP:read_u32(CLK_VDISP)
|
||||
local vt = SP:read_u16(0xE80008) + 1 -- VTOTAL, as clk_init read it
|
||||
-- DELIBERATELY NOT A RATE. 120 ticks is far too short a window to
|
||||
-- quote fps from: the run's start and end each straddle a tick, so
|
||||
-- +/-1 on 119 intervals is +/-8000 ppm and would read as drift the
|
||||
-- clock does not have. What IS exact here is a count of refreshes
|
||||
-- against a count of ticks. The rate figure comes from
|
||||
-- tools/bench/clock_run.sh, over thousands of them.
|
||||
P(string.format("FRAME CLOCK: %d V-DISP interrupts drove %d ticks "
|
||||
.."(%.4f refreshes/frame; the divider's own ratio is "
|
||||
.."31500/(%d*%d) = %.4f), %.0f clocks/frame of "
|
||||
.."interrupt at FINDINGS 54's 181.35 each",
|
||||
vd, pace + 1, vd/(pace + 1), META.fps, vt,
|
||||
31500/(META.fps*vt), 181.35*vd/(pace+1)))
|
||||
end
|
||||
P(string.format("ring: %d wraps, %d B of hole (mean %.1f KB, %.1f%% of "
|
||||
.."the ring)", holes, hole_bytes,
|
||||
holes > 0 and hole_bytes/holes/1024 or 0,
|
||||
@@ -395,6 +456,20 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
-- opposite thing, so the two are never printed in the same words.
|
||||
P(string.format("UNDERRUNS: %d/%d frames waited past their %d fps slot "
|
||||
.."(%d polls)", stalls, META.nframes, META.fps, spins))
|
||||
-- The other way a paced frame can go wrong, and it is not the same
|
||||
-- failure. An UNDERRUN is the pipe: the record was not there. This
|
||||
-- is the CPU: the record was there, the slot was already open, and
|
||||
-- the decoder had no idle left in the frame before. Under a
|
||||
-- host-written tick every slot is 83.33 ms; under the machine's own
|
||||
-- clock they alternate 72.13 and 90.16 ms, 37.9% of them short, and
|
||||
-- this is what the short ones cost (FINDINGS 54).
|
||||
local late, latemax = SP:read_u32(LATEFR), SP:read_u32(LATEMAX)
|
||||
local late1 = SP:read_u32(LATE1ST)
|
||||
P(string.format("NO IDLE: %d/%d frames found their slot already open "
|
||||
.."-- the frame before used all of it; worst overrun "
|
||||
.."%d whole tick%s, first at frame %d", late,
|
||||
META.nframes, latemax, latemax == 1 and "" or "s",
|
||||
late1 == 0xFFFFFFFF and -1 or late1))
|
||||
-- WHAT THE MINIMUM OVER A RUN IS, AND IS NOT. At release the ring
|
||||
-- holds only what the prefill put there, so the early ticks report a
|
||||
-- buffer that has not been built yet, not a ring or a pipe that
|
||||
@@ -455,7 +530,9 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
for i = 0, META.nframes-1 do
|
||||
local a = arrival[i+1]
|
||||
if a then
|
||||
local late = a - (t_rel + i / META.fps)
|
||||
local due = SELFCLK and tick_t[i+1] or (t_rel + i / META.fps)
|
||||
if not due then due = t_rel + i / META.fps end
|
||||
local late = a - due
|
||||
if late > 0 then
|
||||
misses = misses + 1
|
||||
if late > worst then worst = late end
|
||||
|
||||
Reference in New Issue
Block a user