Files
Dragon-s-Lair-X68k/tools/bench/stream.lua
T
prosolis c419251266 Put the frame clock on the 68000, and find that the 12 fps frame does not exist
ROADMAP P3 said "needs MFP timer or VBL" and neither can do it.  The MFP's
timer clock is 16 MHz/4, its prescalers stop at 200 and its data register is 8
bits, so the slowest tick any single timer can make is 78.125 Hz -- 6.5x faster
than a frame -- and 4e6/12 is not an integer, so no setting reaches 12 Hz at
all.  The raster has no whole divide near 12 either: 4 refreshes is 13.86 fps
and 5 is 11.09.  tools/analysis/23_frame_clock.py walks all 7x256 timer settings
rather than asserting it.

src/player/clock.i takes the V-DISP falling edge on MFP GPIP4 -- the start of
vertical blanking, which is when a player would present -- and adds fps*VTOTAL
per edge to a 16-bit accumulator, emitting a tick at 31,500 and keeping the
remainder.  The long-run rate is fps*VTOTAL/VTOTAL = 12.000000 fps exactly, and
both constants are read out of the CRTC at init, so the clock is derived from
the registers that generate the raster it counts.  Measured over 3,000
refreshes: 3,000 interrupts, 649 ticks where 649.1429 were due.

It costs 181.35 clocks per V-DISP, 838 per frame, 0.1006% of the budget -- timed
by the 68000 itself, because the host's granularity is 17.64 ms and the
interrupt is microseconds.  The loop's own cost was calibrated rather than
looked up and landed on 38.000002 clocks, which both licenses the subtraction
and confirms buscost.py's model; the 181.35 then decomposes exactly, leaving
43.99 clocks for the interrupt exception -- the textbook 44, measured.

THE ONE THAT MOVES SOMETHING: 12 fps on a 55.4577 Hz raster is 4.6215 refreshes,
so a frame is shown for 4 refreshes (72.13 ms) or 5 (90.16 ms), 37.9% of them
short.  The 833,333-clock budget every figure in this project is priced against
is the MEAN slot, and the short one is 13.4% under it.  The cadence was already
in the tree unnamed: stream.lua's tick is sampled at frame boundaries, so its
gaps were always 4 or 5, and every host-paced result in FINDINGS 49/51 carried
it.  P3 moved who produces it onto the machine and made it visible.  It is not a
dropped frame -- the pace gate lets an overrun eat the next frame's idle -- and
on the gate container it costs 4 frames of 120 their idle against 1 for the
nominal model, most of that the frame-0 transient at 111% of budget.  stream.s
counts it now, and the rig matches an offline model of the divider exactly.

Also struck: MAME's raster runs 2.22% fast.  refresh_mode() builds the frame
period from scr.max_x*scr.max_y with scr.max_x = m_htotal - 8, one character
cell short and an inclusive bound used as a count, so it runs at 56.6901 Hz
where the registers say 55.4577 -- agreeing to six digits with the arithmetic.
Every "1/55.46 s granularity" note in this tree was wrong and is 1/56.69 s,
corrected in six files with the derivation put once in crtc_mode.lua.  No
conclusion changes and no 68000 cycle figure moves; the CPU clock is unrelated
to the screen.  But anything paced by the raster runs fast under MAME, so the
rig reports both rates and prices the interrupt against the hardware's.

decode.s and frame.i are unchanged; decode.bin is still 1,296 B at the same MD5.
The pace gate's wait loop is byte-for-byte the one FINDINGS 51 measured and the
free-running path executes none of the new code.  check.sh gains two stages: the
clock's own measurement, and 120 frames decoded pixel-exact with nothing outside
the machine deciding when a frame may start.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-24 20:55:34 -07:00

565 lines
30 KiB
Lua

-- Drive src/player/stream.s: decode a whole window through a BOUNDED RING,
-- with a modelled SCSI pipe as the producer. STATUS item 3, FINDINGS 49.
--
-- tools/bench/decode.lua preloads the entire container into emulated RAM and
-- lets the 68000 walk a0 through all of it. That gate is pixel-exact over 120
-- frames (FINDINGS 45) and tests nothing about delivery -- 45.4.1 says so in as
-- many words. It is also RAM-bound for a reason that has nothing to do with the
-- player: 5,261,814 B of stream needs a 6 MB machine.
--
-- Here the container lives in a HOST file (tools/bench/prep_stream.py's disk
-- image) and this script plays the part of the MB89352 plus a DMAC channel:
-- it delivers bytes at a modelled rate into a ring of DLX_RING_KB, and the
-- 68000 decodes out of that ring and nothing else. The emulated machine holds
-- ~256 KB of stream instead of 5 MB, so a STOCK 2 MB machine runs the whole
-- window -- the RAM ceiling of FINDINGS 44.6.4/45 is a property of the old rig
-- and this one does not have it.
--
-- WHAT IS BEING TESTED, precisely: that the block loop and the span chain --
-- which read with a monotonically increasing a0 and no bounds check anywhere --
-- stay pixel-exact when a0 is inside a ring one twentieth the size of the
-- stream, and when the address it is handed jumps backwards to the ring base
-- roughly every sixth frame. A green run is not "the decoder still works"; it
-- is "the wrap policy in src/player/stream.s does not corrupt a single pixel of
-- a temporally recursive 120-frame decode".
--
-- THE WRAP POLICY IS `aligned` (tools/analysis/19_ring_stream.py): never start a
-- record that will not fit before the end of the ring; leave the hole and
-- restart at the base. The alternative, letting records wrap and mirroring the
-- ring head into a shadow, costs 5.57% of the frame budget forever against this
-- one's 9.1% of a buffer, and the decoder is already at 91.1% of budget at p90.
-- (Those two are s14_d5_all1500's; the gate container this rig usually runs
-- makes it 3.64% of the budget against 5.7% of the ring. Per container.)
--
-- MEASUREMENT SCOPE, unchanged from decode.lua: MAME's gvram_w/gvram_r carry no
-- timing, so decode times here are pure 68000 instruction cycles against
-- zero-wait-state memory -- a LOWER BOUND. The pipe, likewise, is a MODEL: a
-- constant byte rate on the emulated clock, not a simulation of the MB89352.
-- What it is honest about is ARRIVAL ORDER and RESIDENCY, which is what the
-- ring exists to manage; it says nothing about the clocks the DMAC steals from
-- the 68000 while it does it. That debit is FINDINGS 43.2's and is not modelled
-- here -- so a zero-stall result from this rig means "the bytes were in time",
-- NOT "the frame fits".
--
-- Env:
-- DLX_RING_KB ring size in KB (default 256, FINDINGS 21's)
-- DLX_STREAM_KBPS modelled pipe, KB/s REQUIRED -- no default.
-- 0 = unlimited, which isolates the WRAP question from the
-- DELIVERY one and is what the green light uses.
-- There is deliberately no default rate: this project has
-- never measured the delivery pipe, and the figure that used
-- to be defaulted to here was a user-supplied "4 Mbps" with
-- no provenance that was never a bus measurement at all
-- (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, 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)
-- DLX_SLACK_CSV write the per-tick lookahead series to this path
-- DLX_SNAP_EVERY 1 = snapshot every frame tick (needs DLX_PACE). For
-- recording the player; not used by check.sh.
--
-- PACING, AND WHY THE UNPACED RIG COULD NOT ANSWER THE BUFFERING QUESTION
-- (FINDINGS 49.7.2). Free-running, src/player/stream.s asks for record i the
-- instant it finishes record i-1. It therefore outruns any finite pipe, the
-- ring never backs up, `overlaps` never refuses a placement, and every ring size
-- down to 48 KB passes while holding ONE record. That sweep tests wrap
-- correctness, which is real, and says nothing about buffering, which is what a
-- branch point needs. With DLX_PACE=1 the producer supplies a frame clock and
-- the decoder may not start frame i before tick i, so the ring fills to its own
-- capacity and FR_HEAD-FR_TAIL becomes the honest number: whole frames the
-- decoder could run on with delivery stopped dead. DLX_CUT_AT/DLX_CUT_FR then
-- stop it dead and check the answer against a real underrun.
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 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, 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
local GVRAM, GPAL = 0xC00000, 0xE82000
local CPUHZ = 10000000
local FRAME12 = CPUHZ / META.fps
local AUDIO_KBPS = 7.8 -- ratectl.AUDIO_KBPS; the pipe carries it too
local RING_KB = tonumber(os.getenv("DLX_RING_KB") or "") or 256
local KBPS = tonumber(os.getenv("DLX_STREAM_KBPS") or "")
if KBPS == nil then
print("[STR] DLX_STREAM_KBPS is not set and has no default. Set it to the "
.."delivery rate you want to model, or to 0 for an unlimited pipe "
.."(which is what tools/bench/check.sh uses -- it gates the WRAP "
.."policy, and an unlimited pipe removes delivery as a variable).")
manager.machine:exit()
return
end
local PREFILL = (tonumber(os.getenv("DLX_PREFILL_KB") or "") or 0) * 1024
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")
-- One snapshot per frame tick instead of one at the end of the run. This is a
-- DOCUMENTATION artefact -- 120 PNGs of a paced player, for a recording -- and
-- it is deliberately not on any path tools/bench/check.sh takes. Needs
-- DLX_PACE: snapshotting a free-running decoder would sample the screen at
-- whatever rate the 68000 happened to finish frames at, which is not a frame
-- rate and would misrepresent the player as faster than it is.
local SNAP_EVERY = (os.getenv("DLX_SNAP_EVERY") == "1")
local RINGSZ = RING_KB * 1024
-- Video's share of the pipe. Debiting audio is not optional bookkeeping: the
-- ADPCM stream comes off the same disk and out of the same budget (FINDINGS 33).
local BPS = (KBPS > 0) and (KBPS - AUDIO_KBPS) * 1024 or math.huge
local code do local f=io.open("stream.bin","rb"); code=f:read("a"); f:close() end
local cb do local f=io.open("stream_cb.bin","rb"); cb=f:read("a"); f:close() end
local DISK = assert(io.open("stream_disk.bin","rb"))
local YOFF = (MODE.height - META.H) // 2
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
local function P(s) print("[STR] "..s) end
local function push(addr, s, from, len)
local i, n = from, len
while n >= 4 do
SP:write_u32(addr, (string.unpack(">I4", s, i)))
addr, i, n = addr+4, i+4, n-4
end
while n > 0 do
SP:write_u8(addr, string.byte(s,i)); addr, i, n = addr+1, i+1, n-1
end
end
-- ------------------------------------------------------------ the producer
-- Ring occupancy is tracked as an explicit list of records still owned by the
-- decoder, rather than as a modular write-minus-read distance. Under `aligned`
-- the ring is not a simple modulus -- a wrap leaves a HOLE of arbitrary size --
-- so a distance would have to carry the holes as a correction term and would be
-- the easiest thing in this file to get quietly wrong. Six-ish live records is
-- a short list; an O(n) overlap test on it is exact and obviously exact.
-- arrival[i] = emulated time at which record i became RESIDENT. This, not the
-- decoder's spin counter, is what answers the delivery question. stream.s has
-- no frame clock: it asks for the next record the instant it finishes the last
-- one, so it outruns any finite pipe and "stalled" is what a decoder that is
-- merely EARLY looks like. A shipping player waits for vblank at 12 fps and
-- spends that same time idle. So the honest test is not "did the decoder ever
-- wait" but "was record i resident by its 12 fps deadline", which is a question
-- about arrival times alone and does not need the decoder paced.
local arrival = {}
local live, wcur, nsent = {}, 0, 0
local n_rate, n_ring = 0, 0
local holes, hole_bytes, credit = 0, 0, 0
local last_t, pf_t0, delivered = nil, nil, 0
local function overlaps(off, len)
for _,r in ipairs(live) do
if off < r.off + r.len and r.off < off + len then return true end
end
return false
end
local function reap()
local tail = SP:read_u32(FR_TAIL)
local i = 1
while i <= #live do
if live[i].idx < tail then
-- RD_PTR is the decoder's byte-granular release, and nothing here needs
-- it -- this producer has the index and works in whole records. Checking
-- it makes it a cross-check instead of a field nothing reads: a real
-- producer without an index (a DMAC chasing the CPU) has only this.
--
-- It holds for the LAST record retired in a pass and not for the others.
-- RD_PTR is a single released-to pointer, so it names the end of record
-- tail-1; if several records were consumed since the previous reap -- and
-- under a paced decoder with a stopped pipe, several is normal -- the
-- earlier ones are long overwritten by it. Asserting per record made the
-- check a test of how often reap happened to run.
if live[i].idx == tail - 1 then
local rp = SP:read_u32(RD_PTR)
local want = RING + live[i].off + live[i].len
if rp ~= want then
P(string.format("RD_PTR MISMATCH after frame %d: decoder released "
.."%08X, record ends %08X", live[i].idx, rp, want))
M:exit()
end
end
table.remove(live, i)
else
i = i + 1
end
end
end
-- Cut window: the pipe stops dead, as it does across a seek. Credit is FROZEN
-- rather than left to accumulate -- a drive that is repositioning is not
-- banking bytes it will burst on arrival, and letting credit build would hand
-- the ring back everything the cut took the moment it ended, which is the one
-- way to make a seek look free.
local cut_t0, cut_t1, cut_done = nil, nil, false
local function produce(now)
local dt = now - (last_t or now); last_t = now
local cut = (cut_t0 and now >= cut_t0 and now < cut_t1)
-- A seek stops DELIVERY. It does not stop the decoder, which goes on draining
-- the ring and releasing bytes behind itself, so reap() runs either way --
-- skipping it would have the ring look full for the whole cut and hide the
-- one thing the cut is for.
if not cut then credit = credit + dt * BPS end
reap()
if cut then return end
while nsent < META.nframes do
local rec = META.index[nsent + 1]
-- WHICH RESOURCE REFUSED, counted separately. A producer that stops
-- because it has no credit is RATE-bound and a bigger ring buys nothing; one
-- that stops because the decoder still owns the bytes is RING-bound and a
-- faster pipe buys nothing. The two look identical from the decoder's side
-- -- both are simply "no new record" -- and they have opposite fixes.
if credit < rec.len then n_rate = n_rate + 1; break end
-- `aligned`: refuse to start a record that will not finish inside the ring.
-- The hole is charged only once the record is actually PLACED. Charging it
-- at the point the wrap is decided counts one hole per retry while the
-- decoder still owns the ring base -- which is every tick of a fast pipe --
-- and reported 105 wraps over 120 records where there are 18.
local w, hole = wcur, 0
if w + rec.len > RINGSZ then w, hole = 0, RINGSZ - wcur end
if overlaps(w, rec.len) then n_ring = n_ring + 1; break end -- decoder owns them
if hole > 0 then holes = holes + 1; hole_bytes = hole_bytes + hole end
DISK:seek("set", rec.off)
push(RING + w, DISK:read(rec.len), 1, rec.len)
-- The descriptor MUST be visible before the count that advertises it. Here
-- the CPU is stopped while this runs so the order is academic; on hardware
-- it is not, and stream.s reads them in the opposite order for that reason.
SP:write_u32(DESC + (nsent % DESCN) * 4, RING + w)
live[#live+1] = {idx=nsent, off=w, len=rec.len}
wcur, nsent = w + rec.len, nsent + 1
credit, delivered = credit - rec.len, delivered + rec.len
SP:write_u32(FR_HEAD, nsent)
arrival[nsent] = now -- record nsent-1 is now resident
end
end
-- ------------------------------------------------------------------ setup
local function setup()
MODE.apply(SP)
push(CB1, cb, 1, META.cb1_len)
push(CB4, cb, 1 + META.cb1_len, META.cb4_len)
local palo = 1 + META.cb1_len + META.cb4_len
for c = 0, 255 do
SP:write_u16(GPAL + c*2, (string.unpack(">I2", cb, palo + c*2)))
end
for y = 0, MODE.height-1 do
local base, v = GVRAM + y*1024, 0
if y < YOFF or y >= YOFF+META.H then v = META.dark end
for x = 0, MODE.width-1 do SP:write_u16(base + x*2, v) end
end
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
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", 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 ""))
P(string.format("ring %d KB at %06X, pipe %s, prefill %d KB, maxrec %d B",
RING_KB, RING, (KBPS > 0) and (KBPS.." KB/s") or "unlimited",
PREFILL // 1024, META.maxrec))
if META.maxrec > RINGSZ then
P("RING TOO SMALL: one record does not fit. stream.s needs a whole record "
.."contiguous."); M:exit()
end
end
local function launch()
local cpu = M.devices[":maincpu"]
cpu.state["SR"].value = 0x2700 -- supervisor, ALL interrupts masked
cpu.state["SP"].value = 0x8000
cpu.state["PC"].value = 0x10000
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 = {}
SUB = emu.add_machine_frame_notifier(function()
local ok, err = pcall(function()
local t = T()
if st == "boot" then
if t < 3.0 then return end
setup(); last_t, pf_t0 = t, t; st = "prefill"; return
end
if st == "prefill" then
produce(t)
if delivered >= PREFILL then
P(string.format("prefill done: %.1f KB in %.3f s, releasing the CPU",
delivered/1024, t - pf_t0))
launch(); t_rel = t; st = "running"
end
return
end
if st == "running" then
if PACED then
-- 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
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.
if SNAP_EVERY and tick > 0 then M.video:snapshot() end
-- Sampled AT the tick, before this frame is decoded: FR_TAIL is the
-- count of frames already done, FR_HEAD the count resident, so the
-- difference is exactly how many further frames the decoder could
-- draw if the pipe went silent at this instant. Whole records, not
-- bytes -- the contiguity constraint of 49.2 means a partial record
-- buys nothing.
local ahead = SP:read_u32(FR_HEAD) - SP:read_u32(FR_TAIL)
-- Only sampled while the producer still HAS records to place. Once
-- it has sent the last one the lookahead drains to zero for reasons
-- that are about the window ending, not about the ring's capacity or
-- the pipe's rate -- and the minimum over the whole run would then
-- always be the drain tail, which is the one part of it that tells
-- you nothing.
if nsent < META.nframes then
if ahead < min_ahead then min_ahead, min_at = ahead, tick end
sum_ahead, n_ahead = sum_ahead + ahead, n_ahead + 1
slack_series[#slack_series+1] = {tick, ahead, n_ring, n_rate}
end
if CUT_AT and tick >= CUT_AT and not cut_t0 then
cut_t0, cut_t1 = t, t + CUT_FR / META.fps
P(string.format("PIPE CUT at tick %d for %.2f frame times "
.."(%.1f ms), with %d frames resident ahead",
tick, CUT_FR, 1000*CUT_FR/META.fps, ahead))
end
end
end
produce(t)
if cut_t0 and not cut_done and t >= cut_t1 then cut_done = true end
local fl = SP:read_u32(FLAG)
if fl == 1 and not t0 then t0 = t; return end
if fl == 0xEE then
P("BITSTREAM DESYNC -- the decoder consumed the wrong number of bytes.")
P(" Under a ring that is the whole point: it means a record was placed "
.."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..".")
M:exit(); return
end
if fl == 0xFF then
local dt = t - (t0 or t)
local stalls = SP:read_u32(STALLS)
local spins = SP:read_u32(SPINS)
if PACED then
-- Paced, the wall clock measures the PACE, not the decode: the loop
-- spends whatever is left of each slot spinning in `pacewait`. The
-- decode cost of this container is decode.lua's and FINDINGS 45's;
-- printing a cycles/frame here would just report 1/fps back.
P(string.format("decoded %d frames in %.4f s emulated at a %d fps "
.."pace (%.2f s nominal) -- the clock here measures "
.."the PACE, not the decode",
META.nframes, dt, META.fps, META.nframes/META.fps))
else
P(string.format("decoded %d frames in %.4f s emulated -> %.0f cycles/"
.."frame = %.1f%% of a %dfps frame",
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,
100*(holes > 0 and hole_bytes/holes or 0)/RINGSZ))
-- The decoder's own spin counter, kept for what it is: evidence that
-- stream.s outran the pipe, NOT evidence of an underrun. See the note
-- on `arrival` above.
if PACED then
-- Paced, a stall is an UNDERRUN: the frame's slot arrived and its
-- record had not. Free-running it is earliness (49.6) and means the
-- 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
-- cannot build it. A seek empties the ring the same way, so that
-- transient is the branch-point case rather than an artefact to be
-- trimmed -- but it has to be told apart from the CEILING, which is
-- what the ring is worth once it is full.
--
-- Which resource stopped the producer says which is which, and only
-- RING refusals count: a rate refusal fires on nearly every tick
-- (credit accrues continuously and records are placed whole), so it
-- marks nothing. A ring refusal means the ring actually filled.
local ceiling, ceil_at = 0, -1
for _,e in ipairs(slack_series) do
if e[2] > ceiling then ceiling, ceil_at = e[2], e[1] end
end
local ss_min, ss_at = math.huge, -1
for _,e in ipairs(slack_series) do
if e[3] > 0 and e[2] < ss_min then ss_min, ss_at = e[2], e[1] end
end
P(string.format("SEEK SLACK: ceiling %d frames (%.0f ms), first "
.."reached at tick %d; mean %.1f over the window",
ceiling, 1000*ceiling/META.fps, ceil_at,
sum_ahead/math.max(1,n_ahead)))
if n_ring > 0 then
P(string.format(" RING-BOUND: the ring filled (%d refusals). Once "
.."full it survives delivery stopped dead for %d "
.."frames = %.0f ms; min after first fill %d "
.."(tick %d)", n_ring, ceiling,
1000*ceiling/META.fps, ss_min, ss_at))
else
P(string.format(" RATE-BOUND: the ring NEVER filled in %d frames. "
.."A bigger ring buys nothing at this pipe; the "
.."buffer is still accumulating when the window "
.."ends.", META.nframes))
end
P(string.format(" BUILD TIME: %d ticks = %.2f s of play to reach the "
.."ceiling from empty -- which is what a branch point "
.."costs before the NEXT seek is affordable",
ceil_at, ceil_at/META.fps))
if SLACK_CSV then
local fh = io.open(SLACK_CSV, "w")
fh:write("tick,ahead,ring_refusals,rate_refusals\n")
for _,e in ipairs(slack_series) do
fh:write(string.format("%d,%d,%d,%d\n", e[1], e[2], e[3], e[4]))
end
fh:close()
P("slack series -> "..SLACK_CSV)
end
else
P(string.format("decoder waited on %d/%d frames (%d polls) -- it is "
.."free-running, so this is earliness, not underrun",
stalls, META.nframes, spins))
end
-- The delivery result. Deadline for record i is release + i/fps.
local misses, worst, prefill_s = 0, 0.0, 0.0
for i = 0, META.nframes-1 do
local a = arrival[i+1]
if a then
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
end
if late > prefill_s then prefill_s = late end
end
end
P(string.format("DEADLINE at %d fps: %d/%d records late, worst by "
.."%.1f ms (%.2f frame times)", META.fps, misses,
META.nframes, worst*1000, worst*META.fps))
-- The smallest start delay that makes every deadline: FINDINGS 21's
-- "required prefill", measured on the emulated clock through the real
-- ring rather than simulated from record sizes.
P(string.format("REQUIRED PREFILL: %.1f ms = %.2f frame times = %.1f KB "
.."at %s", prefill_s*1000, prefill_s*META.fps,
(KBPS > 0) and (prefill_s * BPS / 1024) or 0.0,
(KBPS > 0) and (KBPS.." KB/s") or "an unlimited pipe"))
M.video:snapshot()
P("snapshot taken after the sequential pass -- last frame, decoded "
.."entirely out of a "..RING_KB.." KB ring")
st = "snapped"; return
end
if t > 900 then P("TIMEOUT flag="..string.format("%08X",fl)); M:exit() end
return
end
if st == "snapped" then M:exit(); return end
end)
if not ok then print("[STR] LUA ERROR: "..tostring(err)); M:exit() end
end)