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
+2 -1
View File
@@ -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
View File
@@ -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"
+240
View File
@@ -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)
+132
View File
@@ -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]))
+36
View File
@@ -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
+20
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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