Put the ring on the 68000, and find the disc stops whenever the player is not asking

ROADMAP P5. The loader moved in session 21 and the frame clock in 22; the ring
producer was the last policy living outside the machine. src/player/ring.i does
`aligned` placement, the descriptor ring, a prefill, 51.2's slack rule and a
seek, and the host keeps only the transport.

It needed a container change. `aligned` asks whether the next record fits
before the end of the ring -- a length asked BEFORE the record is fetched -- and
every reader in this tree answered that by walking the frame stream, which is
exactly what a player streaming off a disc cannot do. DLX4 carries nframes u16
record lengths in the scene header. Frame payloads are byte-identical to the
DLX3 encode, so no fitted constant moves; the scene header goes 5,920 to 6,164 B.

The producer reproduces the host's tiling exactly: 18 wraps, 14.7 KB mean hole,
pixel-exact, a third independent implementation of the same policy.

What it exposed is bigger than the item. A channel only moves bytes while it has
a request and only the CPU can issue one, so the disc stands still between
records by an amount the PLAYER sets, not the medium -- and no host-filled run
could see it. At 488 KB/s in a 256 KB ring a one-deep request queue gives away
6.8% of the pipe and underruns 59 of 120 frames; two-deep gives away 3.4% and
underruns none. The container's whole surplus over the wire is 8.7%, so the
player's own loop was spending most of the slack a branch point saves up.
Prefill is the weaker lever: six records of it still leaves 24 underruns.

Three silent bugs are recorded in FINDINGS 55.7 -- all produced wrong pixels or
a desync rather than a fault -- plus a rig one: MAME renders a screen line by
line, so snapshotting the frame the decoder finished in captures a tear that
reads exactly like a decoder bug.

check.sh gains the machine-owned ring and a seek with the decode after it.
decode.bin is unchanged at 1,296 B and a host-filled run executes none of the
new code, so every FINDINGS 49/51 figure stands. ALL GREEN before and after.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-24 21:45:05 -07:00
parent c419251266
commit 2676f3b835
16 changed files with 1880 additions and 38 deletions
+421 -17
View File
@@ -52,7 +52,18 @@
-- 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_PREFILL_KB bytes to deliver before releasing the CPU (default 0).
-- Host-owned ring only: with DLX_RINGOWN the machine does
-- its own prefill and DLX_PREFILL_FR is the knob.
-- DLX_RINGOWN 1 = THE 68000 OWNS THE RING (ROADMAP P5,
-- src/player/ring.i). This script stops placing records and
-- becomes a TRANSPORT: it answers one request at a time,
-- delivering XF_LEN bytes from XF_OFF to XF_DST in
-- len/rate seconds, and the machine decides which record,
-- where in the ring, and when. It also AUDITS every
-- placement the machine makes against its own index.
-- DLX_PREFILL_FR whole records the machine's prefill waits for before it
-- releases the decoder (default 2; see ring_prefill)
-- 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
@@ -101,6 +112,26 @@ 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
-- src/player/ring.i. The transport mailbox, then the producer's state and its
-- instruments. Nothing below 0x18300 changed: the decoder's handshake with the
-- ring (FR_HEAD/FR_TAIL/DESC) is the same one FINDINGS 49 and 51 measured, which
-- is what makes a self-filled run a test of ring.i and not of a new rig.
local XF_SLOT, XF_SLSZ = 0x18300, 16
local XF_GO, XF_ACK, XF_QD = 0x18320, 0x18324, 0x18328
local A_RINGOWN, A_RNG_B, A_RNG_SZ, A_IDX_B = 0x1832C, 0x18330, 0x18334, 0x18338
local A_RQ_NEXT, A_WCUR, A_RCUR = 0x1833C, 0x18340, 0x18344
local A_NHOLE, A_NHOLEB, A_NFULL = 0x18354, 0x18358, 0x1835C
local A_NPOLL, A_NISSUE = 0x18360, 0x18364
local A_SLKMIN, A_SLKAT = 0x18368, 0x1836C
local A_PFREC, A_PFDONE = 0x18370, 0x18374
local A_NSEEK, A_SKWAIT = 0x18378, 0x1837C
-- The scene header's record index, pushed into RAM before the CPU is launched.
-- It sits ABOVE the codebooks rather than in the low RAM around ring.i's own
-- tables: setup() runs one raster frame before launch() and the IPL is still
-- executing in that gap, so anything written below ~$20000 can be overwritten
-- before the 68000 ever sees it. That cost an hour: the machine read a table of
-- zeroes and asked the transport for a 0-byte record.
local IDXRAM = 0x30000 -- the DLX4 index, in RAM
local DESCN = 64
local CB1, CB4 = 0x20000, 0x22000
local RING = 0x40000
@@ -125,6 +156,21 @@ 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")
local RINGOWN = (os.getenv("DLX_RINGOWN") == "1")
local PREFILL_FR = tonumber(os.getenv("DLX_PREFILL_FR") or "") or 2
-- Passes over the scene. With DLX_RINGOWN each pass after the first begins
-- with a REAL seek in src/player/stream.s: the channel is waited quiet, the
-- ring is declared empty and the lookahead 51.3 says takes seconds of play to
-- accumulate is thrown away and rebuilt from the prefill. The check that
-- matters afterwards is the one this rig always makes -- the last frame of the
-- last pass has to be pixel-exact, and a SKIP block is a claim about the
-- previous frame, so it is only right if every frame after the seek was.
local ITERS = tonumber(os.getenv("DLX_ITER") or "") or 1
-- How many requests the player may have outstanding at once. 1 is the obvious
-- loop and leaves the channel idle from every completion until the next poll;
-- 2 keeps the next request queued so it never stops. Both are real designs and
-- the difference between them is what this rig measures.
local QDEPTH = tonumber(os.getenv("DLX_QDEPTH") or "") or 2
-- 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
@@ -140,6 +186,16 @@ 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 idxblob do
local f = io.open("stream_idx.bin","rb")
if f then idxblob = f:read("a"); f:close() end
end
if RINGOWN and not idxblob then
print("[STR] DLX_RINGOWN needs the DLX4 record index (stream_idx.bin). "
.."Re-run tools/bench/prep_stream.py on a DLX4 container: the machine "
.."cannot derive record lengths by walking a stream it has not fetched.")
manager.machine:exit(); return
end
local YOFF = (MODE.height - META.H) // 2
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
@@ -264,6 +320,202 @@ local function produce(now)
end
end
-- -------------------------------------------------------------- transport
-- THE OTHER HALF OF ROADMAP P5. With DLX_RINGOWN the producer above is not
-- used at all: src/player/ring.i decides which record to fetch, where to put it
-- and when it is safe, and this becomes the part that is genuinely not the
-- CPU's -- an SPC and one DMAC channel moving bytes at a rate.
--
-- IT IS BUILT OUT OF MEMORY TAPS, not out of the machine-frame notifier, and
-- that is a measurement decision rather than a stylistic one. A notifier sees
-- the machine once per raster frame, 17.64 ms (54.5), and the thing being
-- measured here is the gap between a transfer COMPLETING and the CPU issuing
-- the next one -- which is a fraction of a frame slot. Sampling it at 17.64 ms
-- would have quantised the very quantity in question, and in the flattering
-- direction if the ack were early or the pessimistic one if late.
--
-- write tap on XF_GO fires inside the 68000's write, so the issue time is
-- exact. The bytes are copied in there and then: the
-- CPU cannot observe them before its ack anyway, because
-- it does not advertise the record until it retires.
-- read tap on XF_ACK synthesises the completion word from emulated time --
-- `done` iff now >= t_done -- so the CPU learns of the
-- completion on the exact cycle it happens, the way it
-- would from a status register.
-- A 68000 bus access is 16 bits, so a longword is two tap calls: the write is
-- acted on at the LOW half (written second, so the whole value is there) and
-- the read latches its answer at the HIGH half so the two halves cannot
-- straddle a completion.
local xf = {go = 0, ack = 0, busy = 0.0, gap = 0.0, gapmax = 0.0, ngap = 0,
latch = 0, tfree = 0, copylate = 0.0, ncopylate = 0}
-- q[n] = the n-th request (n counts from 1): when the CPU issued it, when the
-- transport will have finished it, and whether its bytes have been moved yet.
local q = {}
local npass = 1
local function xf_deliver(off, dst, len, now)
DISK:seek("set", off)
push(dst, DISK:read(len), 1, len)
end
-- The host as AUDITOR rather than as producer. Every placement the machine
-- makes is checked against this script's own record index and its own list of
-- records the decoder has not finished with -- the same overlap test the host
-- producer used to make its decisions with, now used only to grade them. A
-- wrong placement corrupts pixels rather than faulting (49.2), so it has to be
-- caught where it is made.
local function audit(idx, off, dst, len)
local rec = META.index[idx + 1]
if not rec then return string.format("record %d does not exist", idx) end
if off ~= rec.off then
return string.format("record %d: machine asked for disc offset %d, the "
.."index says %d", idx, off, rec.off) end
if len ~= rec.len then
return string.format("record %d: machine asked for %d B, the index says %d",
idx, len, rec.len) end
local w = dst - RING
if w < 0 or w + len > RINGSZ then
return string.format("record %d: placed at ring offset %d..%d, outside a "
.."%d B ring", idx, w, w + len, RINGSZ) end
local tail = SP:read_u32(FR_TAIL)
local i = 1
while i <= #live do
if live[i].idx < tail then table.remove(live, i) else i = i + 1 end
end
for _, r in ipairs(live) do
if w < r.off + r.len and r.off < w + len then
return string.format("record %d at %d..%d overlaps record %d at %d..%d, "
.."which the decoder has not consumed",
idx, w, w + len, r.idx, r.off, r.off + r.len) end
end
live[#live+1] = {idx = idx, off = w, len = len}
return nil
end
-- THE TAP OBJECTS ARE KEPT ALIVE HERE ON PURPOSE. install_*_tap returns a
-- handle and the tap dies with it: dropping it on the floor leaves the taps
-- working until Lua's collector next runs, and then the mailbox silently stops
-- answering. That looks exactly like a wedged producer -- the machine polled
-- 2.4 million times for an ack that had already been computed.
-- THE TAP OBJECTS ARE KEPT ALIVE HERE ON PURPOSE. install_*_tap returns a
-- handle and the tap dies with it: dropping it on the floor leaves the taps
-- working until Lua's collector next runs, and then the mailbox silently stops
-- answering. That looks exactly like a wedged producer -- the machine polled
-- 2.4 million times for an ack that had already been computed.
local TAPS = {}
local function install_transport()
-- NOTHING IN A TAP CALLBACK TOUCHES THE MEMORY SPACE. MAME 0.277 segfaults
-- if a tap reads or writes the space it was triggered from -- a nested access
-- part way through the CPU's own -- and it does it intermittently, which is
-- the worst way to find out. So the taps do pure Lua: the write tap stamps
-- the EXACT emulated time the 68000 issued a request, the read tap answers
-- the completion count from those stamps, and every actual memory access is
-- done from the machine-frame notifier.
--
-- What that buys is the thing worth having: issue and completion times are
-- both exact, so the gap between a transfer finishing and the next one
-- starting -- the disc standing still because nobody has asked it for
-- anything -- is measured at cycle resolution rather than at 17.64 ms.
TAPS[#TAPS+1] = SP:install_write_tap(XF_GO, XF_GO + 3, "dlx_xf_go",
function(offset, data, mask)
if offset ~= XF_GO + 2 then return data end -- low half, written second
-- ...and it must be the INCREMENT, not any other write to the word.
-- ring_init clears the mailbox at boot, which lands on this address with
-- the request fields still empty; without this test the transport answered
-- that clear as if it were a request and audited a 0-byte record 0.
if data ~= ((xf.go + 1) & 0xFFFF) then return data end
xf.go = xf.go + 1
q[xf.go] = {tissue = T(), served = false}
return data
end)
TAPS[#TAPS+1] = SP:install_read_tap(XF_ACK, XF_ACK + 3, "dlx_xf_ack",
function(offset, data, mask)
if offset == XF_ACK then
-- Latched on the high half so the two halves of one longword read cannot
-- straddle a completion. Requests complete IN ORDER: one channel, one
-- transfer at a time, however many are queued.
local now = T()
while true do
local r = q[xf.ack + 1]
if not (r and r.served and now >= r.tdone) then break end
xf.ack = xf.ack + 1
end
xf.latch = xf.ack
return (xf.latch >> 16) & 0xFFFF
end
return xf.latch & 0xFFFF
end)
end
-- Service every request the machine has issued but this script has not yet
-- looked at: read what was asked for, grade the placement, move the bytes, and
-- work out when the channel will have finished it. Called from the
-- machine-frame notifier, which is where this script may touch memory.
local function transport_service(t)
local n = xf.ack + 1
while q[n] do
local r = q[n]
if r.served then n = n + 1; goto continue end
local slot = XF_SLOT + ((n - 1) % 2) * XF_SLSZ
local off, dst = SP:read_u32(slot), SP:read_u32(slot + 4)
local len, idx = SP:read_u32(slot + 8), SP:read_u32(slot + 12)
if idx == 0 and n > 1 then
-- A new pass: everything the auditor knows about the ring is about the
-- scene we just left. The decoder has not consumed the new records and
-- the old ones are no longer anybody's.
live, nsent, arrival = {}, 0, {}
npass = npass + 1
P(string.format("SEEK PASS %d: the machine seeked back to record 0 at "
.."%.3f s and is refilling from empty", npass, r.tissue))
end
local err = audit(idx, off, dst, len)
if err then
P("MISPLACED: "..err)
P(" The machine owns the ring in this run, so this is a policy bug in "
.."src/player/ring.i, not a rig one -- and it would have shown up as "
.."wrong pixels, because the block loop reads without a bounds check.")
M:exit(); return
end
-- ONE CHANNEL: a transfer starts when the channel is free AND the request
-- exists, so a queued request starts the instant its predecessor lands and
-- a late one starts when it is issued. The difference is the gap, and the
-- gap is the player's, not the medium's -- no host-filled run could see it,
-- because the host producer placed records whenever it liked.
local startt = math.max(r.tissue, xf.tfree)
if xf.tfree > 0 then
local g = startt - xf.tfree
if g > 0 and nsent < META.nframes then
xf.gap, xf.ngap = xf.gap + g, xf.ngap + 1
if g > xf.gapmax then xf.gapmax = g end
end
end
local dur = (BPS == math.huge) and 0 or (len / BPS)
local done = startt + dur
-- A cut is a seek: the transfer freezes for its duration rather than
-- carrying on invisibly. Freezing is the conservative reading and the one
-- 51.6 settled on for the host producer -- a drive that is repositioning is
-- not banking bytes it will burst on arrival.
if cut_t0 and done > cut_t0 and startt < cut_t1 then
done = done + (cut_t1 - math.max(startt, cut_t0))
end
xf_deliver(off, dst, len, t)
r.tdone, r.served = done, true
xf.tfree = done
xf.busy = xf.busy + dur
nsent = math.max(nsent, idx + 1)
arrival[idx + 1] = done -- resident when the last byte lands
delivered = delivered + len
-- The one place this rig is coarser than the machine: a record whose
-- modelled transfer was shorter than the wait for this notifier was already
-- "done" by the time the bytes could be moved. Counted, not absorbed.
if t > done then
xf.copylate, xf.ncopylate = xf.copylate + (t - done), xf.ncopylate + 1
end
n = n + 1
::continue::
end
end
-- ------------------------------------------------------------------ setup
local function setup()
MODE.apply(SP)
@@ -280,10 +532,24 @@ local function setup()
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(ITER, ITERS); 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)
SP:write_u32(A_RINGOWN, RINGOWN and 1 or 0)
if RINGOWN then
-- The scene header the machine reads: the DLX4 record index, exactly the
-- bytes the container carries, pushed into RAM the way a loaded scene
-- header would be. ring_init turns it into the disc-offset table a seek
-- needs; nothing here derives a record boundary for the machine.
push(IDXRAM, idxblob, 1, #idxblob)
SP:write_u32(A_IDX_B, IDXRAM)
SP:write_u32(A_RNG_B, RING)
SP:write_u32(A_RNG_SZ, RINGSZ)
SP:write_u32(A_PFREC, PREFILL_FR)
SP:write_u32(XF_QD, QDEPTH)
install_transport()
end
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
@@ -292,9 +558,15 @@ local function setup()
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",
P(string.format("ring %d KB at %06X, pipe %s, prefill %s, maxrec %d B",
RING_KB, RING, (KBPS > 0) and (KBPS.." KB/s") or "unlimited",
PREFILL // 1024, META.maxrec))
RINGOWN and (PREFILL_FR.." records (the machine's own)")
or ((PREFILL // 1024).." KB"), META.maxrec))
if RINGOWN then
P(string.format("ring OWNED BY THE 68000 (src/player/ring.i): this script "
.."is a transport with a %d-deep request queue, %d B of "
.."DLX4 index at %06X", QDEPTH, #idxblob, IDXRAM))
end
if META.maxrec > RINGSZ then
P("RING TOO SMALL: one record does not fit. stream.s needs a whole record "
.."contiguous."); M:exit()
@@ -322,13 +594,18 @@ local slack_series = {}
SUB = emu.add_machine_frame_notifier(function()
local ok, err = pcall(function()
local t = T()
if RINGOWN then transport_service(t) end
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
if not RINGOWN then produce(t) end
-- RINGOWN: there is nothing for the host to prefill. The machine issues
-- its own requests, so it has to be running first; ring_prefill then
-- holds the decoder until PF_REC records are resident, which is the same
-- policy in the place a player would keep it.
if RINGOWN or 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"
@@ -344,6 +621,14 @@ SUB = emu.add_machine_frame_notifier(function()
-- 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
-- src/player/stream.s rebased the clock for a new pass: tick 0 is the
-- instant the decoder was released after the seek's prefill, not the
-- start of the run. Everything sampled per tick starts again with it.
pace, tick_t = -1, {}
min_ahead, min_at = math.huge, -1
sum_ahead, n_ahead, slack_series = 0, 0, {}
end
if tick > pace then
pace = tick
if not SELFCLK then SP:write_u32(PACE, pace) end
@@ -372,7 +657,12 @@ SUB = emu.add_machine_frame_notifier(function()
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}
-- Column 3 is "has the producer been refused for space yet",
-- which is what makes a later minimum meaningful: before the first
-- refusal the ring is still filling. With the ring owned by the
-- machine that counter is ring.i's, not this script's.
slack_series[#slack_series+1] =
{tick, ahead, RINGOWN and SP:read_u32(A_NFULL) or 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
@@ -382,12 +672,38 @@ SUB = emu.add_machine_frame_notifier(function()
end
end
end
produce(t)
if not RINGOWN then produce(t) end
if RINGOWN and os.getenv("DLX_RINGDBG") == "1" then
dbg_n = (dbg_n or 0) + 1
if dbg_n % 30 == 0 then
P(string.format("DBG t=%.2f flag=%X go=%d ack=%d tfree=%.3f queued=%s "
.."head=%d tail=%d rq=%d poll=%d full=%d wcur=%d rcur=%d",
t, SP:read_u32(FLAG), xf.go, xf.ack, xf.tfree,
tostring(q[xf.ack+1] ~= nil), SP:read_u32(FR_HEAD),
SP:read_u32(FR_TAIL), SP:read_u32(A_RQ_NEXT),
SP:read_u32(A_NPOLL), SP:read_u32(A_NFULL),
SP:read_u32(A_WCUR), SP:read_u32(A_RCUR)))
end
end
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.")
if RINGOWN then
local tail, head = SP:read_u32(FR_TAIL), SP:read_u32(FR_HEAD)
P(string.format(" tail=%d head=%d rq=%d wcur=%d rcur=%d go=%d ack=%d",
tail, head, SP:read_u32(A_RQ_NEXT), SP:read_u32(A_WCUR),
SP:read_u32(A_RCUR), xf.go, xf.ack))
P(string.format(" DESC[%d]=%08X, the index says record %d is at ring "
.."offset ? len %d", tail,
SP:read_u32(DESC + (tail % 64)*4), tail,
META.index[tail+1] and META.index[tail+1].len or -1))
local ls = {}
for _, r in ipairs(live) do
ls[#ls+1] = string.format("%d@%d+%d", r.idx, r.off, r.len) end
P(" host thinks live: "..table.concat(ls, " "))
end
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
@@ -400,6 +716,11 @@ SUB = emu.add_machine_frame_notifier(function()
" (fps*VTOTAL does not fit the 16-bit accumulator)" or ""))
M:exit(); return
end
if fl == 0xE3 then
P("RING INIT REFUSED: the record index has more entries than "
.."src/player/ring.i's disc-offset table (ROFFMAX) can hold.")
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..".")
@@ -443,10 +764,62 @@ SUB = emu.add_machine_frame_notifier(function()
vd, pace + 1, vd/(pace + 1), META.fps, vt,
31500/(META.fps*vt), 181.35*vd/(pace+1)))
end
local rholes, rhole_b = holes, hole_bytes
if RINGOWN then
rholes, rhole_b = SP:read_u32(A_NHOLE), SP:read_u32(A_NHOLEB)
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 ring)", rholes, rhole_b,
rholes > 0 and rhole_b/rholes/1024 or 0,
100*(rholes > 0 and rhole_b/rholes or 0)/RINGSZ))
if RINGOWN then
-- The producer's own account of itself. These are read out of the
-- machine's RAM, not kept here: the point of the run is that this
-- script did not make any of these decisions.
local pf = SP:read_u32(A_PFDONE)
local npoll, nissue = SP:read_u32(A_NPOLL), SP:read_u32(A_NISSUE)
local nfull = SP:read_u32(A_NFULL)
local slk, slkat = SP:read_u32(A_SLKMIN), SP:read_u32(A_SLKAT)
local nseek, skw = SP:read_u32(A_NSEEK), SP:read_u32(A_SKWAIT)
P(string.format("MACHINE-OWNED RING: %d records placed by the 68000, "
.."%d polls, %d refused for space (ring-bound), "
.."%d seeks", nissue, npoll, nfull, nseek))
P(string.format("PREFILL: released the decoder at %d records "
.."(policy: %d); LEAST SLACK %d records at frame %d",
pf, PREFILL_FR, slk, slkat))
-- THE NUMBER THIS RIG EXISTS TO PRODUCE. The channel only moves
-- bytes while a request is outstanding and only the CPU can issue the
-- next one, so the disc stands still between the completion of one
-- record and the issue of the next. That gap is a property of the
-- PLAYER's loop, not of the medium, and no host-filled run could see
-- it -- the host producer placed records whenever it liked.
local span = (arrival[nissue] or t) - (t0 or t)
P(string.format("CHANNEL IDLE: %.1f ms over %d gaps (worst %.1f ms) = "
.."%.1f%% of the %.2f s the transport was needed for; "
.."busy %.1f ms",
xf.gap*1000, xf.ngap, xf.gapmax*1000,
span > 0 and 100*xf.gap/span or 0, span,
xf.busy*1000))
-- THE RIG'S OWN RESOLUTION, PRINTED RATHER THAN LEFT IMPLICIT.
-- The transport's timing is exact (memory taps), but the BYTES are
-- moved from the machine-frame notifier, so a record whose modelled
-- transfer is shorter than the wait for that notifier is acked later
-- than the model says it landed. Every millisecond here is a
-- millisecond the decoder may have waited that the medium would not
-- have made it wait.
if xf.ncopylate > 0 then
P(string.format("RIG RESOLUTION: %d of %d transfers were acked late "
.."because the bytes are moved on the notifier, by "
.."%.1f ms in total (mean %.1f ms) -- an artefact of "
.."this rig, not of the player",
xf.ncopylate, nissue, xf.copylate*1000,
xf.copylate*1000/xf.ncopylate))
end
if skw > 0 then
P(string.format(" the last seek waited %d polls for the channel "
.."to go quiet before it could start", skw))
end
end
-- 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.
@@ -490,15 +863,22 @@ SUB = emu.add_machine_frame_notifier(function()
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
if ss_min == math.huge then ss_min, ss_at = -1, -1 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
-- WHOSE REFUSAL COUNT. With the ring owned by the machine the host
-- producer's counters are not merely stale, they are zero -- it never
-- placed anything -- so the classification has to come from
-- src/player/ring.i's own N_FULL. Reading n_ring here in a RINGOWN
-- run would have reported RATE-BOUND on every run by construction.
local ring_ref = RINGOWN and SP:read_u32(A_NFULL) or n_ring
if ring_ref > 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,
.."(tick %d)", ring_ref, ceiling,
1000*ceiling/META.fps, ss_min, ss_at))
else
P(string.format(" RATE-BOUND: the ring NEVER filled in %d frames. "
@@ -530,6 +910,16 @@ SUB = emu.add_machine_frame_notifier(function()
for i = 0, META.nframes-1 do
local a = arrival[i+1]
if a then
-- INSTRUMENT NOTE, and it only bites under the self-clock.
-- tick_t[] is when this script OBSERVED the machine's tick, which
-- is the next machine-frame boundary after it -- up to 17.64 ms
-- late (54.5). So these deadlines are generous by that much and
-- this count UNDERSTATES lateness. The sharp instrument for the
-- same question is the decoder's own stall counter, which is exact
-- because the machine takes it: at 488 KB/s with a one-deep request
-- queue this reported 6 records late where the 68000 counted 59
-- frames that had to wait. Host-paced (DLX_PACE=1) the deadline is
-- a host model and is exact, so FINDINGS 49.6's table is unaffected.
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
@@ -550,14 +940,28 @@ SUB = emu.add_machine_frame_notifier(function()
.."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
-- ONE FRAME OF SETTLE BEFORE THE CAPTURE, and it is not cosmetic.
-- MAME renders a screen scanline by scanline and the machine-frame
-- notifier fires at the END of that frame, so a bitmap for a frame in
-- which GVRAM changed holds some lines drawn before the change and some
-- after. Snapshotting it captures a TEAR -- and the symptom is a
-- pixel-exactness failure in the bottom blocks plus a broken double-scan
-- pairing, which reads exactly like a decoder bug and is not one. It
-- only bites when the decoder finishes its last frame LATE in a screen
-- frame, which is why it never showed up until a run with underruns in
-- it. Waiting one whole frame renders the finished picture with nothing
-- writing to GVRAM.
st = "settle"; return
end
if t > 900 then P("TIMEOUT flag="..string.format("%08X",fl)); M:exit() end
return
end
if st == "settle" then
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 st == "snapped" then M:exit(); return end
end)
if not ok then print("[STR] LUA ERROR: "..tostring(err)); M:exit() end