Files
Dragon-s-Lair-X68k/tools/analysis/24_ring_owner.py
T
prosolis 2676f3b835 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
2026-08-24 21:45:05 -07:00

306 lines
13 KiB
Python

"""What the player's own fill loop costs the pipe (ROADMAP P5, FINDINGS 55).
19_ring_stream.py and 20_seek_slack.py model a ring whose producer is free to
act whenever it likes: bytes arrive at a rate and the only questions are where
they go and whether the ring can hold them. That is what a HOST-filled ring is,
and it is what every delivery figure in FINDINGS 49 and 51 was measured on.
A player has no host. The 68000 owns the ring (src/player/ring.i), and it can
only act when it is not decoding -- which turns the producer into a consumer of
the same resource the decoder is short of, and puts an idle CHANNEL between
every pair of records:
a transfer ends -> the disc has nothing to do -> the CPU next polls
-> it issues -> the disc starts again
The gap in the middle is bytes the medium could have delivered and did not, and
no rate table in this tree contains it. Its size is set by the PLAYER: how many
requests it may have outstanding (a DMAC channel takes one at a time; two slots
mean the next is already queued when the current lands), and when it polls.
This is that model, written from record sizes and per-frame decode costs, and
sharing no code with the Lua rig it is compared against -- the same arrangement
as 49.4 and 51.5. The rig drives a real 68000 through a real ring and is the
measurement; this says where to point it and what to expect.
python3 tools/analysis/24_ring_owner.py <container.dlx> --kbps R [R ...]
[--ring KB] [--qdepth N [N ...]] [--prefill RECORDS]
[--cadence raster|nominal]
`--kbps` is REQUIRED and has no default, for the reason FINDINGS 50 gives.
THE INDEX IS READ FROM THE CONTAINER, not derived by walking it. A DLX4
container carries nframes u16 record lengths in its scene header precisely
because the producer needs the length of a record it has not fetched; this model
reads the same table the 68000 does, so a container whose index disagreed with
its stream would be caught here as well as in dlx.py's constructor.
"""
import sys, os, argparse
sys.path.insert(0, "tools/encoder")
import numpy as np
from dlx import DLX
import vq_hybrid as H
import spans as SP
CPUHZ = 10_000_000
HFREQ = 31500 # lines/s in the 31.5 kHz modes (src/player/clock.i)
VTOTAL = 568 # CRTC R04+1 in the 256x256 mode (tools/bench/crtc_mode.lua)
CLK_ISR = 181.35 # clocks per V-DISP, MEASURED (FINDINGS 54.3)
AUDIO_KBPS = 7.8 # ratectl.AUDIO_KBPS; the pipe carries it too
def frame_costs(d):
"""Per-frame decode cost in 68000 clocks: blocks plus v7 spans.
Both halves come from the encoder's own measured constants -- H.cycles is
the single source 11_cpu_budget.py uses, and SP.clocks is the v7 fit of
FINDINGS 40 -- so this is the same cost model the rate controller fits `mu`
against, applied to the emitted container rather than to a candidate.
"""
out = []
for f in range(d.nframes):
c = H.cycles(d.modes(f))
for _, _, px in d.spans(f)[0]:
c += SP.clocks(len(px))
out.append(c)
return np.array(out)
def ticks(n, fps):
"""Frame tick times from src/player/clock.i's divider, or a nominal clock.
The player's clock is the raster with a remainder: a frame gets 4 refreshes
(72.13 ms) or 5 (90.16 ms) and there is no 83.33 ms frame (FINDINGS 54.4).
A model that hands out uniform slots gives every frame 13.4% more time than
the short one really has, so the cadence is reproduced here rather than
averaged away.
"""
R = VTOTAL / HFREQ # one refresh, seconds
acc, out, t = 0, [0.0], 0.0
while len(out) < n:
t += R
acc += fps * VTOTAL
if acc >= HFREQ:
acc -= HFREQ
out.append(t)
return np.array(out)
def simulate(rec, dec, tick, bps, ringsz, qdepth, prefill):
"""One pass of the machine-owned ring. Returns a dict of instruments.
The rules are src/player/ring.i's, stated as events:
* the CPU polls whenever it is NOT decoding -- the pace wait and the
record wait both call ring_poll, and nothing else in the frame does;
* a request occupies a slot until it is RETIRED, which happens at a poll,
so the queue is measured against retirement and not against completion;
* placement is `aligned`: a record that will not fit before the end of the
ring restarts at the base, and only if the base is free;
* the channel serves one transfer at a time, in order.
"""
n = len(rec)
# ring state, in ring offsets
wcur = rcur = 0
rq = 0 # next record to request
retired = 0 # requests retired (== FR_HEAD)
consumed = 0 # records the decoder has finished (== FR_TAIL)
inflight = [] # [(record, done_time)] in issue order
chan_free = 0.0 # when the channel finishes what it has
gaps, gap_tot, gap_max = 0, 0.0, 0.0
busy = 0.0
full_refusals = 0
started = False # the first transfer has no gap before it
def live_empty():
return rq == consumed
def place(length):
"""Where the next record goes: (offset, hole) or None if it cannot."""
nonlocal full_refusals
if live_empty():
if wcur + length <= ringsz:
return wcur, 0
return 0, ringsz - wcur
if rcur == wcur:
return None # completely full
if rcur < wcur: # free is [wcur, SZ) then [0, rcur)
if wcur + length <= ringsz:
return wcur, 0
if length <= rcur:
return 0, ringsz - wcur
return None
if wcur + length <= rcur: # live wraps; free is [wcur, rcur)
return wcur, 0
return None
def issue(now):
"""Issue as many requests as the queue and the ring allow, at `now`."""
nonlocal wcur, rcur, rq, chan_free, gaps, gap_tot, gap_max, busy
nonlocal full_refusals, started
while rq < n and (rq - retired) < qdepth:
p = place(rec[rq])
if p is None:
full_refusals += 1
return
off, _hole = p
if live_empty():
rcur = off
start = max(now, chan_free)
if started:
g = start - chan_free
if g > 1e-12:
gaps += 1
gap_tot += g
gap_max = max(gap_max, g)
started = True
dur = rec[rq] / bps
busy += dur
chan_free = start + dur
inflight.append((rq, chan_free))
wcur = off + rec[rq]
rq += 1
def retire(now):
"""Publish every transfer that has landed by `now`. In order."""
nonlocal retired
while inflight and inflight[0][1] <= now:
inflight.pop(0)
retired += 1
def advance_reader():
"""Step rcur over the records the decoder has finished with."""
nonlocal rcur
i = consumed_seen[0]
while i < consumed:
end = rcur + rec[i]
if i + 1 < n and end + rec[i + 1] > ringsz:
end = 0
rcur = end
i += 1
consumed_seen[0] = i
consumed_seen = [0]
# ---- prefill. The decoder is not running, so the CPU polls continuously
# and the channel never waits for it: this is the one part of a scene where
# the request loop costs nothing.
now = 0.0
while retired < prefill and rq < n:
issue(now)
if not inflight:
break
now = inflight[0][1]
retire(now)
prefill_done = now
t0 = now
underruns, worst_late, noidle = 0, 0.0, 0
slack_series = []
for i in range(n):
deadline = t0 + tick[i]
# TWO WAYS A FRAME CAN START LATE, AND THEY ARE NOT THE SAME FAILURE.
# The decoder reaches the record wait at max(its own finish, the tick):
# if it got there after the tick, the PREVIOUS frame used its whole slot
# and this is the CPU (54.4's cadence). If it got there on time and the
# record was not resident, that is the PIPE. The rig counts them
# separately -- NO IDLE and UNDERRUNS -- so conflating them here would
# have made the model disagree with it for a reason that is not about
# delivery at all.
if now > deadline + 1e-9:
noidle += 1
arrive = max(now, deadline)
now = arrive
# the record wait: the CPU polls, so it retires and issues while it waits
starved = retired <= i
while retired <= i:
issue(now)
if not inflight:
break
now = max(now, inflight[0][1])
retire(now)
if starved:
underruns += 1
worst_late = max(worst_late, now - arrive)
slack_series.append(retired - consumed)
issue(now)
# ---- decode. No polls: whatever the channel finishes now waits.
now += dec[i] / CPUHZ
consumed += 1
advance_reader()
retire(now)
issue(now)
# ---- idle until the next tick. The CPU polls throughout, so every
# completion is retired and every free slot is refilled at once.
nxt = t0 + tick[i + 1] if i + 1 < n else now
while inflight and inflight[0][1] < nxt:
now = max(now, inflight[0][1])
retire(now)
issue(now)
now = max(now, min(nxt, now))
span = max(now - t0, 1e-9)
return dict(underruns=underruns, worst_late=worst_late, gaps=gaps,
noidle=noidle,
gap_tot=gap_tot, gap_max=gap_max, busy=busy, span=span,
ceiling=max(slack_series), mean_slack=float(np.mean(slack_series)),
full=full_refusals, prefill_s=prefill_done)
ap = argparse.ArgumentParser()
ap.add_argument("container")
ap.add_argument("--kbps", type=float, nargs="+", required=True,
help="delivery rates to model. REQUIRED: this tree has no "
"default rate (FINDINGS 50)")
ap.add_argument("--ring", type=int, default=256, help="ring size, KB")
ap.add_argument("--qdepth", type=int, nargs="+", default=[1, 2],
help="requests the player may have outstanding")
ap.add_argument("--prefill", type=int, default=2, help="records before release")
ap.add_argument("--cadence", choices=["raster", "nominal"], default="raster")
a = ap.parse_args()
d = DLX(a.container)
if not d.has_index:
sys.exit(f"{a.container} is DLX{d.version}: this model reads the record "
f"index the player reads, and only DLX4 carries one.")
rec = np.array([q * 4 for q in d.index], np.int64)
dec = frame_costs(d) + CLK_ISR * (HFREQ / VTOTAL) / d.fps # + the clock's own
if a.cadence == "raster":
tick = ticks(d.nframes + 1, d.fps)
else:
tick = np.arange(d.nframes + 1) / d.fps
wire = rec.mean() * d.fps / 1024
print(f"{a.container}: {d.nframes} records, {rec.mean()/1024:.1f} KB mean, "
f"{rec.max()/1024:.1f} KB max, wire {wire:.1f} KB/s")
print(f" index: {2*d.nframes:,} B of scene header -- read, not walked")
print(f" decode: mean {dec.mean():,.0f} clk/frame ({100*dec.mean()/(CPUHZ/d.fps):.1f}% "
f"of a mean slot), p90 {np.percentile(dec,90):,.0f}")
print(f" cadence: {a.cadence}"
+ (" (4 or 5 refreshes a frame, 72.13/90.16 ms -- FINDINGS 54.4)"
if a.cadence == "raster" else " (uniform 1/fps slots)"))
print(f" ring {a.ring} KB, prefill {a.prefill} records\n")
hdr = (f"{'pipe':>8} {'Q':>2} {'idle':>9} {'gaps':>5} {'worst':>8} "
f"{'under':>7} {'late by':>8} {'noidl':>5} {'ceil':>5} {'mean':>5} "
f"{'refus':>6}")
print(hdr)
print("-" * len(hdr))
for kb in a.kbps:
bps = (kb - AUDIO_KBPS) * 1024
for q in a.qdepth:
r = simulate(rec, dec, tick, bps, a.ring * 1024, q, a.prefill)
print(f"{kb:8.0f} {q:2d} {100*r['gap_tot']/r['span']:8.1f}% "
f"{r['gaps']:5d} {r['gap_max']*1000:7.1f}ms "
f"{r['underruns']:3d}/{d.nframes:<3d} {r['worst_late']*1000:7.1f}ms "
f"{r['noidle']:5d} {r['ceiling']:5d} {r['mean_slack']:5.1f} "
f"{r['full']:6d}")
print()
print("idle = the channel with no request to work on, as a fraction of the")
print(" window. Bytes the medium could have delivered and did not.")
print("under = frames whose record was not resident when the decoder asked")
print(" for it. The PIPE.")
print("noidl = frames that reached the gate after their tick, because the one")
print(" before used its whole slot. The CPU, and 54.4's cadence.")
print("ceil = most records resident and unconsumed at a frame start: what a")
print(" branch point could spend, minus one for the restart (51.2).")
print("refus = placements refused for SPACE. Nonzero means the ring filled.")