Files
Dragon-s-Lair-X68k/tools/analysis/31_display_duty.py
T
prosolis 6f698ca226 Put the player on a real volume, and find the write window is the frame
ROADMAP K3. src/player/packed.s (2,898 B) brings up its own display, builds
its own 193-entry DMA chain, keeps its own frame clock off V-DISP and fetches
every record itself with READ(10) off a CZ-6BS1. The rig writes no picture
byte, no palette entry and no CRTC register.

120 of 120 frames pixel-exact, every one compared, in both palette orders --
the gate had to grow to do it, because a packed frame is a LITERAL and the
codec's recursion was what made one comparison audit 120.

And the write window turns out to be the frame. A packed write requires R20
bit 11, buffer mode blanks the layer, and a DMAC-direct player holds the
window open for the whole data phase, so

    dark fraction of a slot = record bytes / (DATA-PHASE rate x slot)

which is 1.0 at the container's own 582.0 KB/s: every frame delivered, on
time, pixel-exact, and none of them displayed. The rate in that expression is
the BURST rate, a third hardware number B1 has no test for. It reverses 61.5's
ranking -- a packed player that DMAs to RAM and paints with the measured 27.3%
blit is on screen 72.7% of every slot at any rate, and the two are equal only
at 2,131 KB/s = 3.7x the wire.

And a held channel costs the frame clock half its ticks without the clock
being able to tell: 487 of 1,038 V-DISP edges lost, zero late frames reported,
the player believing 12 fps while the screen ran at 6.37.

FINDINGS 64. ROADMAP K4 opened and fenced behind B2.
check.sh ALL GREEN before and after.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-25 09:10:48 -07:00

216 lines
11 KiB
Python

#!/usr/bin/env python3
"""HOW LONG IS THE PICTURE ACTUALLY ON SCREEN? ROADMAP K3, FINDINGS 64.
python3 tools/analysis/31_display_duty.py [container.dlxp] [--rate KB/s ...]
THE QUESTION NOTHING IN THIS TREE HAD ASKED. Every budget in docs/FINDINGS.md
asks what a frame COSTS -- clocks, bus cycles, bytes on the wire. Session 32
built the packed player and ran it (src/player/packed.s), and the run reported a
number no budget has a column for: the write window was open on 99.5% of the
host frames, so the graphics layer was DARK for 99.5% of the scene. Every frame
was pixel-exact and almost none of them was visible.
WHY THAT IS ARITHMETIC AND NOT AN EMULATOR ARTEFACT. 256-colour GVRAM masks the
high byte of every write unless CRTC R20 bit 11 is set (46.5/47.1), and the
packed layout's whole 1.0 B/pixel claim is that one word carries two pixels --
so a packed write REQUIRES the bit. If buffer mode blanks the layer while the
bit is set (47.4/ROADMAP B2 -- MAME says it does, and 48.1's prior leans that
way), then the layer is dark for exactly as long as the window is open, and for
a DMAC-direct player the window is open for the whole transfer. There is no
second page to hide behind: the packed layout SPENDS both 256-colour pages,
which is the same fact that made a frame one channel start (FINDINGS 62).
dark fraction of a slot = record bytes / (data-phase rate x slot)
AND THE RATE IN THAT EXPRESSION IS THE BURST RATE, NOT THE SUSTAINED ONE. This
is the correction the session had to make to itself. The container's 582.0 KB/s
is a SUSTAINED requirement -- it decides whether record i arrives before slot i.
The dark fraction is set by how fast bytes move DURING THE DATA PHASE, which for
a drive with a read-ahead cache can be several times the sustained figure. The
two are independent, and a medium can pass one and fail the other:
sustained >= 582.0 KB/s or frames arrive late (B1, known)
data phase >> 582.0 KB/s or the frame is never displayed (NEW, and B1 has
no test for it)
THE OTHER PLAYER IN THE FAMILY DOES NOT HAVE THIS PROPERTY. A packed player
that DMAs the record into RAM and paints it with the CPU opens the window only
for the paint -- tools/bench/blit.s V8, MEASURED, not assumed -- which is a
fixed share of the slot no matter what the medium does. It costs more clocks
and 49 KB of RAM and it buys a picture that is on screen. FINDINGS 61.5 already
priced both in CLOCKS and ranked DMAC-direct first; this file is the column that
was missing from that table, and it reverses the ranking under B2-blanks.
"""
import argparse, os, re, sys
sys.path.insert(0, "tools/encoder")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from dlxp import DLXP
import buscost as B
CPUHZ = 10e6 # stock X68000, MAME 0.277 x68k.cpp:1133
ap = argparse.ArgumentParser()
ap.add_argument("container", nargs="?", default="tmp/packed_singe.dlxp")
ap.add_argument("--blit-log", default="tmp/blit_v8.log",
help="tools/bench/blit.lua's log -- the MEASURED packed paint. "
"Not a constant in this file: 47.6.1 filed the movem shape "
"as an assumption and session 29 measured it, and a second "
"copy of a measured number is how one of them goes stale.")
ap.add_argument("--rate", type=float, nargs="*", default=None,
help="data-phase rates to price, KB/s. REQUIRED to mean "
"anything: this project has no delivery figure and will "
"not default to one (FINDINGS 50).")
ap.add_argument("--run-log", default="tmp/packed_free_steal.log",
help="a free-running tools/bench/packed.lua log, for the "
"measured corroboration section")
a = ap.parse_args()
d = DLXP(a.container)
SLOT_S = 1.0 / d.fps
FRAME_CLK = CPUHZ * SLOT_S
wire = d.kbps() # KB/s, and fixed by geometry
blit = {}
if os.path.exists(a.blit_log):
for line in open(a.blit_log, errors="replace"):
m = re.search(r"V(\d+)\s+(\d+) cyc", line)
if m:
blit[int(m.group(1))] = int(m.group(2))
if 8 not in blit:
sys.exit(f"{a.blit_log} has no V8 result. The packed paint is a MEASUREMENT "
f"(tools/bench/blit.lua) and this tool will not substitute a "
f"constant for it -- run the blit bench, or point --blit-log at "
f"its log.")
PAINT_CLK = blit[8]
PAINT_FRAC = PAINT_CLK / FRAME_CLK
print(f"""THE CONTAINER (tools/encoder/dlxp.py)
{a.container}: {d.W}x{d.H} {d.fps} fps, {d.nframes} frames
record {d.rec_bytes:,} B = {d.rec_bytes // 512} sectors, palette \
{'LAST' if d.palette_last else 'FIRST'}
slot {SLOT_S*1000:.2f} ms = {FRAME_CLK:,.0f} clocks
wire {wire:.1f} KB/s -- FIXED by geometry. A codec's bitrate is a lever and a
literal frame's is not (61.6), so nothing an encoder does moves this.
THE TWO PACKED PLAYERS, and the difference is WHEN the write window is open
A. DMAC-DIRECT (src/player/packed.s, ROADMAP K3, and the one that is built).
One channel start, 193 destinations, the CPU halted or nearly. The window
must be open for the WHOLE data phase, because the channel writes when the
bytes arrive and the CPU cannot know when that is -- and a packed write
that lands with the bit clear is masked to its low byte and silently wrong.
B. DMA-TO-RAM + CPU PAINT. The record lands in RAM with the window shut; the
68000 then paints it with the packed movem blit. The window is open for the
PAINT and nothing else: {PAINT_CLK:,} clocks, {100*PAINT_FRAC:.1f}% of a slot,
MEASURED by tools/bench/blit.lua (V8), and INDEPENDENT of the medium.
Under 47.4-blanks the dark interval IS the open window, so B is on screen for
{100*(1-PAINT_FRAC):.1f}% of every slot at ANY rate that delivers the record at all,
and A's visibility is a function of the rate.
""")
rates = a.rate
if not rates:
print("""NO RATES GIVEN, so no table. This project retired its delivery
constant outright (FINDINGS 50, USER DECISION) and every tool requires an
explicit rate; a default here would be the same mistake in a new place. Pass
--rate with the figures you want priced. The three thresholds already derived
elsewhere, for reference and NOT as defaults:
453.6 KB/s the DLX5 codec gate container needs no prefill (49.5/60)
576.0 KB/s a packed container with no per-frame palette (61.5)
582.0 KB/s THIS container, palette included (63)
and note that all three are SUSTAINED figures. The dark fraction below is set by
the DATA-PHASE rate, which is a different measurement nothing has taken.""")
sys.exit(0)
print("A's VISIBILITY, against the DATA-PHASE rate\n")
print(f" {'data phase':>12} | {'transfer':>9} | {'window open':>11} | "
f"{'PICTURE ON SCREEN':>17} | vs B")
print(f" {'KB/s':>12} | {'ms':>9} | {'% of slot':>11} | "
f"{'% of slot':>17} |")
print(" " + "-"*12 + "-+-" + "-"*9 + "-+-" + "-"*11 + "-+-" + "-"*17 + "-+-----")
for R in sorted(rates):
t_ms = d.rec_bytes / (R * 1024) * 1000
openf = min(1.0, t_ms / (SLOT_S * 1000))
vis = 1.0 - openf
verdict = ("A wins" if vis > 1 - PAINT_FRAC else
"B wins" if vis < 1 - PAINT_FRAC else "equal")
late = " LATE" if R < wire else ""
print(f" {R:>12.1f} | {t_ms:>9.2f} | {100*openf:>11.1f} | "
f"{100*vis:>17.1f} | {verdict}{late}")
# The crossover, stated as a rate rather than left to be read off the table: it
# is the one number in here a hardware acceptance test can be written against.
cross = d.rec_bytes / (PAINT_FRAC * SLOT_S) / 1024
print(f"""
A and B show the picture for the same share of the slot at a data-phase rate
of {cross:,.0f} KB/s. Below that, THE PLAYER WITH THE CPU IN THE LOOP IS ON
SCREEN LONGER than the one without it -- which is the reverse of FINDINGS
61.5's ranking, and 61.5 is not wrong: it ranked them in CLOCKS, and this is
the column that table does not have.
{cross:,.0f} KB/s is {cross/wire:.1f}x the container's own wire. So a medium that exactly
meets the sustained requirement puts the DMAC-direct player's picture on
screen for {100*max(0.0, 1-wire/wire):.0f}% of every slot: it delivers every frame, on time,
pixel-exact, and displays none of them.""")
print(f"""
THE CPU SIDE, so the trade is priced on both axes (FINDINGS 61.5's ladder)
W is clocks stolen per delivered byte. Only the dual-address rungs have a code
path on this machine (59.2), and 9 is the floor: a 4-clock read of the device
plus a 5-clock write to memory.
""")
print(f" {'W':>3} | {'A: DMAC-direct':>15} | {'B: DMA + CPU paint':>19}")
print(" " + "-"*3 + "-+-" + "-"*15 + "-+-" + "-"*19)
AUDIO = B.ADPCM_BYTES_PER_S / d.fps * B.ADPCM_CLK_BYTE_BEST
for W in (5, 9, 12, 16, 19):
xfer = d.rec_bytes * W
ca = (xfer + AUDIO) / FRAME_CLK
cb = (xfer + AUDIO + PAINT_CLK) / FRAME_CLK
print(f" {W:>3} | {100*ca:>14.1f}% | {100*cb:>18.1f}%")
print(f"""
Both include the audio DMA at {AUDIO:,.0f} clocks a frame ({100*AUDIO/FRAME_CLK:.2f}%), charged from
the IPL ROM's own channel-3 setup (21_iplrom_dmac.py, 52.5). Neither includes
a decoder, because neither has one.
So B costs the paint -- {100*PAINT_FRAC:.1f}% of a frame -- and TWO record buffers,
{2*d.rec_bytes:,} B of RAM. Two and not one: at any rate near the wire the delivery
of record i+1 occupies most of the slot the paint of record i happens in, so
they overlap by construction. On a 2 MB machine that is {200*d.rec_bytes/(2*1024*1024):.1f}% of memory and
it is the resource this design has spare -- the ring the packed branch deleted
was 256 KB (FINDINGS 49). That is what a visible picture costs if 47.4 blanks.""")
# ---- the measured corroboration. It is a SEPARATE section and it is bounded
# on purpose: MAME's device models carry no transfer timing (docs/BENCHMARK.md,
# 42.5), so the run cannot supply a rate for the table above -- what it can do
# is show that the mechanism is real and that the arithmetic predicts it.
if os.path.exists(a.run_log):
txt = open(a.run_log, errors="replace").read()
m_rate = re.search(r"record lands in ([\d.]+) ms, i\.e\. ([\d.]+) KB/s", txt)
m_open = re.search(r"WRITE WINDOW OPEN on (\d+) of (\d+) host frames", txt)
if m_rate and m_open:
ms, kbps = float(m_rate.group(1)), float(m_rate.group(2))
op, tot = int(m_open.group(1)), int(m_open.group(2))
pred = min(1.0, d.rec_bytes / (kbps * 1024) / SLOT_S)
print(f"""
MEASURED, on the emulated machine (tools/bench/packed_run.sh, free-running)
{a.run_log}: a {d.rec_bytes:,} B record landed in {ms:.2f} ms = {kbps:.1f} KB/s, and the
write window was open on {op} of {tot} host frames = {100*op/tot:.1f}%.
The expression above predicts {100*pred:.1f}% at that rate.
THIS IS NOT A RATE MEASUREMENT AND {kbps:.0f} KB/s IS NOT A MEDIUM. MAME's
device models carry no transfer timing (42.5); the figure is a property of the
apparatus. What the run DOES establish is that the mechanism is the one the
arithmetic describes -- and one thing more that no arithmetic could have
given: the DMAC CONFIGURATION DOES NOT MOVE IT. Held and stealing delivered
the same record within 0.5% of each other, so what a channel configuration
buys is who owns the CPU, not when the picture appears.""")
else:
print(f"""
NO MEASURED SECTION: {a.run_log} is absent. Run
tools/bench/packed_run.sh to produce it. The arithmetic above stands without
it -- it is geometry -- but the run is what showed the effect was there to be
derived at all.""")