Put the container on the chip, and find the held bus costs 463 times the seam
ROADMAP P6c, FINDINGS 68. 78,125 B of a DLXP2's audio out of channel 3, sample-exact, while the video channel fetches records off the same disc. The two pieces 67.6 said were missing: the lump buffer (pg_afill/pg_afetch, three slots and the minimum is unmeasured) and 67.2's remainder accumulator (pg_apay). The capture prices what the accumulator avoided at 1.26 s of lip-sync over the game, against 67.2's predicted 1.25. The finding is the third piece, which nothing had named: the MSM6258 has no starvation state, so the gap between a channel counting out and the next arm is a held nibble pair driving the predictor. Stealing, the seam is 0.51 ms over ten seconds because dma.i's new DM_HOOK services the chip from inside the transfer wait -- 250,000 of 250,240 looks. Held, the 68000 is halted and gets 369: every one of the ten lump boundaries has a seam, worst 72.8 ms, 2.31% of the audio. Identical bytes, different sound. 64.3 reaching the audio. Two bugs, and no counter in the player could see either. Clearing DM_BARV does not unchain a channel -- OCR bits 3-2 are what it obeys -- and the symptom is POLL TIMEOUT on the lump and every record after it. And the refill ran one lump ahead of its ring and overwrote the buffer the channel was reading: 11 of 11 armed, 11 fetched, no starve, and the sound wrong from 0.2 s in. Which is why the gate is a WAV: verify_packed_audio.py walks the stream one delivered byte at a time, because MAME's okim6258 resets the nibble select on every write and a byte is two nibbles only 99.994% of the time. check.sh ALL GREEN before (tmp/check_s36_start.log) and after (tmp/check_s36_end.log), with the new stage. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
+31
-2
@@ -741,12 +741,41 @@ if [ -f "$HOME/mame/roms/x68000.zip" ] || [ -d "$HOME/mame/roms/x68000" ]; then
|
||||
DLX_PK_GATE_ONLY=1 DLX_PK_NFR="$NF" bash tools/bench/packed_run.sh \
|
||||
tmp/packed_singe.dlxp > tmp/packed_gate_stage.log 2>&1 \
|
||||
|| { cat tmp/packed_gate_stage.log; exit 1; }
|
||||
grep -aE "^ (FLAG|array|chain|frame clock|late frames|WRITE WINDOW)|^OK " \
|
||||
tmp/packed_gate_stage.log
|
||||
# SPLIT AT RUN 5, because the audio run below is in the same log and its
|
||||
# verifier emits OK lines too -- one grep over the whole file would print
|
||||
# session 36's result under session 31's heading.
|
||||
sed -n '1,/--- 5. THE AUDIO/p' tmp/packed_gate_stage.log | \
|
||||
grep -aE "^ (FLAG|array|chain|frame clock|late frames|WRITE WINDOW)|^OK "
|
||||
else
|
||||
echo " SKIPPED: no x68000 romset -- the player was not run"
|
||||
fi
|
||||
|
||||
echo "--- session 36: THE CONTAINER'S OWN AUDIO, OUT OF THE CHIP (FINDINGS 68) ---"
|
||||
# ROADMAP P6c. Every piece of this existed before this stage did and none of it
|
||||
# was joined up: the container has carried the lumps since 67, the transport is
|
||||
# the IPL ROM's channel-3 configuration from 66, and what was missing was the
|
||||
# lump buffer and the remainder accumulator in a player. This is the run where
|
||||
# a byte of the container reaches the speaker.
|
||||
#
|
||||
# WHY THE GATE IS A WAV AND NOT A COUNTER. src/player/packed.s reports lumps
|
||||
# armed, lumps fetched, payload bytes and starves, and EVERY ONE OF THOSE CAN BE
|
||||
# RIGHT WHILE THE SOUND IS WRONG -- which is not hypothetical, it is the bug
|
||||
# this session shipped: the refill ran one lump too far ahead and overwrote the
|
||||
# buffer channel 3 was reading out of, and the player's account of it was
|
||||
# 11 of 11 armed, 11 fetched, 78,125 B, no starve. Nothing parses a packed
|
||||
# container (FINDINGS 67.4), so a wrong byte is not an error, it is a sound.
|
||||
# tools/bench/verify_packed_audio.py accounts for all 78,125 bytes against
|
||||
# MAME's own capture, one delivered byte at a time.
|
||||
#
|
||||
# The run is part of packed_run.sh's gate half, so DLX_PK_GATE_ONLY takes it.
|
||||
if [ -f tmp/packed_gate_stage.log ] && \
|
||||
grep -aq -- "--- 5. THE AUDIO" tmp/packed_gate_stage.log; then
|
||||
sed -n '/--- 5. THE AUDIO/,$p' tmp/packed_gate_stage.log | \
|
||||
grep -aE "^(OK|FAIL) |^ AUDIO:|^ {5}(nibbles per|worst|payload|-> |the player)"
|
||||
else
|
||||
echo " SKIPPED: no x68000 romset, or the container is silent"
|
||||
fi
|
||||
|
||||
echo "--- session 33: AUDIO -- the encoder, and what it does to the wire (FINDINGS 65) ---"
|
||||
# ROADMAP P6, everything in it except the bus half session 20 closed. The audio
|
||||
# is the SAME WINDOW as the frames -- 00223 from 539.4 s for 10 s -- because an
|
||||
|
||||
+107
-1
@@ -68,11 +68,19 @@ local META = loadfile("packed_meta.lua")()
|
||||
local PG_FLAG, PG_NFR, PG_FPS, PG_LBA0 = 0x18900, 0x18904, 0x18908, 0x1890C
|
||||
local PG_RECS, PG_PALL, PG_HELD = 0x18910, 0x18914, 0x18918
|
||||
local PG_PACEON, PG_ITER = 0x1891C, 0x18920
|
||||
local PG_CADF, PG_CADA = 0x18924, 0x18928
|
||||
local PG_CADF, PG_CADA, PG_AUDON = 0x18924, 0x18928, 0x1892C
|
||||
local PG_SHOWN, PG_ERR, PG_ERRAT = 0x18930, 0x18934, 0x18938
|
||||
local PG_LATE, PG_LATE1, PG_LATEM = 0x1893C, 0x18940, 0x18944
|
||||
local PG_VDISP, PG_VD0, PG_TSPIN = 0x18948, 0x1894C, 0x18950
|
||||
local PG_GSPIN, PG_LOSTV, PG_ARRN = 0x18954, 0x18958, 0x1895C
|
||||
-- ROADMAP P6c: the audio path's inputs and its own account of what it did.
|
||||
local PG_AFPS, PG_AHZ, PG_ALBA0 = 0x18960, 0x18964, 0x18968
|
||||
local PG_NLUMP, PG_ABYTES, PG_APRE = 0x1896C, 0x18970, 0x18974
|
||||
local PG_AARM, PG_AFET, PG_ABYT = 0x18980, 0x18984, 0x18988
|
||||
local PG_ADRY, PG_ASEAM, PG_ASRV = 0x1898C, 0x18990, 0x18994
|
||||
local PG_ACSR, PG_ACER, PG_ALATE = 0x18998, 0x1899C, 0x189A0
|
||||
local PG_AMTC0, PG_AK, PG_AKF, PG_AACC = 0x189A4, 0x189B0, 0x189B4, 0x189B8
|
||||
local PG_AFERR, PG_AFERA = 0x189A8, 0x189AC
|
||||
local PG_ARR = 0x1B000
|
||||
-- src/player/clock.i and src/player/scsi.i, read for diagnosis only.
|
||||
local CLK_PACE, CLK_VDISP, CLK_ERR = 0x18034, 0x18064, 0x1806C
|
||||
@@ -85,6 +93,13 @@ local ITERS = tonumber(os.getenv("DLX_PK_ITER") or "") or 1
|
||||
local NFR = tonumber(os.getenv("DLX_PK_NFR") or "") or META.nframes
|
||||
local FPS = tonumber(os.getenv("DLX_PK_FPS") or "") or META.fps
|
||||
local CSV = os.getenv("DLX_PK_CSV")
|
||||
-- ROADMAP P6c. OFF by default and it is not a convenience: a run with the chip
|
||||
-- silent is the CONTROL this one is read against, and every gate that existed
|
||||
-- before session 36 is that control. DLX_PK_APRE is the lumps fetched before
|
||||
-- frame 0 -- see src/player/packed.s on why the answer is not 1.
|
||||
local AUDIO = (os.getenv("DLX_PK_AUD") or "0") == "1"
|
||||
local APRE = tonumber(os.getenv("DLX_PK_APRE") or "") or 2
|
||||
local AJSON = os.getenv("DLX_PK_AJSON")
|
||||
local SNAP_DELAY = 2
|
||||
|
||||
local SCERRNAME = {[0]="OK", "SELECTION TIMEOUT -- no target answered",
|
||||
@@ -114,6 +129,16 @@ local function setup()
|
||||
SP:write_u32(PG_CADF, META.cad_f or 0)
|
||||
SP:write_u32(PG_CADA, META.cad_a or 0)
|
||||
SP:write_u32(PG_SHOWN, 0)
|
||||
-- P6c. AUDON is separate from the cadence on purpose: the LBA arithmetic has
|
||||
-- to skip the lumps whether or not a chip is being fed, and a run that skips
|
||||
-- them without playing them is the control this one is measured against.
|
||||
SP:write_u32(PG_AUDON, (AUDIO and (META.has_audio or 0) == 1) and 1 or 0)
|
||||
SP:write_u32(PG_AFPS, META.fps) -- the CONTAINER's, NOT the pace
|
||||
SP:write_u32(PG_AHZ, META.aud_hz or 0)
|
||||
SP:write_u32(PG_ALBA0, META.lba_aud or 0)
|
||||
SP:write_u32(PG_NLUMP, META.n_lumps or 0)
|
||||
SP:write_u32(PG_ABYTES, META.aud_bytes or 0)
|
||||
SP:write_u32(PG_APRE, APRE)
|
||||
P(string.format("packed.bin=%d B, %dx%d %d fps, %d of %d frames, %d passes",
|
||||
#code, META.W, META.H, META.fps, NFR, META.nframes, ITERS))
|
||||
local cad = ""
|
||||
@@ -130,6 +155,19 @@ local function setup()
|
||||
.."whole cost of it on the video path",
|
||||
META.aud_bytes, META.aud_hz, META.n_lumps, META.cad_a, META.cad_f))
|
||||
end
|
||||
if AUDIO and (META.has_audio or 0) == 1 then
|
||||
P(string.format("AUDIO ON: lump k at LBA %d + k*%d, payload 11*%d/24 B a "
|
||||
.."group -- the PAYLOAD and not the %d B lump (FINDINGS "
|
||||
.."67.2). Decoder from the header: %s/%s, %d-bit clamp, "
|
||||
.."accumulator %d at PLAY. Prefill %d lumps of %d.",
|
||||
META.lba_aud, META.cad_f*META.rec_sectors + META.cad_a,
|
||||
META.aud_hz, META.cad_a*512, META.aud_variant,
|
||||
META.aud_order, META.aud_bits, META.aud_init,
|
||||
APRE, META.n_lumps))
|
||||
elseif (META.has_audio or 0) == 1 then
|
||||
P("audio present in the container and NOT played -- this is the silent "
|
||||
.."control (DLX_PK_AUD=1 plays it)")
|
||||
end
|
||||
P(string.format("channel: %s, %s",
|
||||
HELD and "BUS HELD (burst, max rate)" or "CYCLE STEALING",
|
||||
PACED and ("SELF-PACED at "..FPS.." fps off V-DISP"
|
||||
@@ -268,6 +306,74 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
P(string.format("CPU: %d trips round the TRANSFER wait in total, %d round "
|
||||
.."the PACE gate", SP:read_u32(PG_TSPIN),
|
||||
SP:read_u32(PG_GSPIN)))
|
||||
if AUDIO and (META.has_audio or 0) == 1 then
|
||||
local armed, fet = SP:read_u32(PG_AARM), SP:read_u32(PG_AFET)
|
||||
local byt, dry = SP:read_u32(PG_ABYT), SP:read_u32(PG_ADRY)
|
||||
local seam, srv = SP:read_u32(PG_ASEAM), SP:read_u32(PG_ASRV)
|
||||
local late, acc = SP:read_u32(PG_ALATE), SP:read_u32(PG_AACC)
|
||||
P(string.format("AUDIO: %d of %d lumps armed, %d fetched, %d B of "
|
||||
.."payload handed to the chip (the stream is %d B)",
|
||||
armed, META.n_lumps, fet, byt, META.aud_bytes))
|
||||
-- THE PADDING, CHARGED. A player that fed the chip the whole lump
|
||||
-- would have handed it n_lumps*A*512 B; the difference is the drift
|
||||
-- FINDINGS 67.2 priced at 1.25 s over the game, and printing both
|
||||
-- numbers is the only way the accumulator is visible from outside.
|
||||
-- FULL groups only. The last lump is short when the scene's frame
|
||||
-- count is not a multiple of F, and averaging that in reports the
|
||||
-- scene's TAIL as though it were the cadence -- which is a different
|
||||
-- number from the drift and looks like a worse one. The percentage
|
||||
-- lives in verify_packed_audio.py, which knows each lump's payload.
|
||||
local nfull = META.n_lumps
|
||||
if NFR % META.cad_f ~= 0 then nfull = nfull - 1 end
|
||||
local lumpb = nfull * META.cad_a * 512
|
||||
P(string.format(" %d whole groups: %d B of lump space for the "
|
||||
.."payload the accumulator asked for. The whole-lump "
|
||||
.."player feeds the chip that space, and the excess is "
|
||||
.."DRIFT and not waste (67.2). Accumulator left at "
|
||||
.."%d/%d.", nfull, lumpb, acc, 2*META.fps))
|
||||
P(string.format(" service: %d calls, %d found the channel "
|
||||
.."counted out, %d of those had NO lump ready (a "
|
||||
.."STARVE -- the chip replays its last byte)",
|
||||
srv, seam, dry))
|
||||
P(string.format(" re-arms with MTC still non-zero: %d (bytes "
|
||||
.."fetched and never played; 0 is the correct value)",
|
||||
late))
|
||||
local ferr = SP:read_u32(PG_AFERR)
|
||||
if ferr ~= 0 then
|
||||
P(string.format(" A LUMP FETCH FAILED on lump %d: %s -- the "
|
||||
.."picture is unaffected and the sound is gone, which "
|
||||
.."is why this has its own error word",
|
||||
SP:read_u32(PG_AFERA), SCERRNAME[ferr] or ("code "..ferr)))
|
||||
end
|
||||
P(string.format(" channel 3 at the end: CSR=$%02X CER=$%02X, "
|
||||
.."MTC one instruction after the first START = %d",
|
||||
SP:read_u32(PG_ACSR), SP:read_u32(PG_ACER),
|
||||
SP:read_u32(PG_AMTC0)))
|
||||
-- WHAT HOLDING THE BUS COSTS A SECOND CONSUMER, and it is this line.
|
||||
-- Stealing, pg_aserv runs from inside dma.i's transfer wait as well as
|
||||
-- twice a frame; held, the 68000 is HALTED for the whole transfer and
|
||||
-- the two frame-loop calls are all it gets. The ratio is the audio's
|
||||
-- half of FINDINGS 64.3.
|
||||
P(string.format(" -> %.1f service calls per frame shown. %s",
|
||||
shown > 0 and srv/shown or 0,
|
||||
HELD and ("BUS HELD: the 68000 is halted for the whole "
|
||||
.."transfer, so DM_HOOK never runs and this is the "
|
||||
.."two frame-loop calls and nothing else.")
|
||||
or ("CYCLE STEALING: DM_HOOK ran from inside the "
|
||||
.."transfer wait, which is where a 68000 driving this "
|
||||
.."video path has any time at all.")))
|
||||
if AJSON then
|
||||
local f = io.open(AJSON, "w")
|
||||
f:write(string.format('{"armed":%d,"fetched":%d,"bytes":%d,'
|
||||
..'"starve":%d,"seam":%d,"serv":%d,"late":%d,"acc":%d,'
|
||||
..'"csr":%d,"cer":%d,"held":%s,"fps":%d,"shown":%d}\n',
|
||||
armed, fet, byt, dry, seam, srv, late, acc,
|
||||
SP:read_u32(PG_ACSR), SP:read_u32(PG_ACER),
|
||||
HELD and "true" or "false", FPS, shown))
|
||||
f:close()
|
||||
P("audio counters -> "..AJSON)
|
||||
end
|
||||
end
|
||||
local open = 0
|
||||
for _,v in ipairs(r20seen) do open = open + v end
|
||||
P(string.format("WRITE WINDOW OPEN on %d of %d host frames (%.1f%%) -- "
|
||||
|
||||
@@ -70,7 +70,7 @@ run() {
|
||||
( cd tmp && env SDL_VIDEODRIVER=dummy "$@" stdbuf -oL timeout -k 5 900 \
|
||||
mame x68000 -bios ipl10 -exp1 cz6bs1 \
|
||||
-rompath "$HOME/mame/roms;./p4roms" -hard dlxpdisk.chd \
|
||||
-ramsize 2M -video soft -window -sound none -nothrottle -plugins \
|
||||
-ramsize 2M -video soft -window $SOUNDARGS -nothrottle -plugins \
|
||||
-autoboot_script ../tools/bench/packed.lua \
|
||||
-snapshot_directory "./snap_packed_$tag" -snapview native \
|
||||
-seconds_to_run "$SECS" > "packed_$tag.log" 2>&1 )
|
||||
@@ -80,6 +80,11 @@ run() {
|
||||
grep -a "^\[PK\]" "tmp/packed_$tag.log" | sed 's/^\[PK\] / /'
|
||||
}
|
||||
fail() { echo "FAIL: $1"; exit 1; }
|
||||
# -sound none for every run that is not about sound, which is all of them until
|
||||
# run 5. 15,625 is not a preference there: it is the chip's own stream rate
|
||||
# (8 MHz / 512), and equal rates are what keep MAME's resampler from filtering
|
||||
# the thing being measured (FINDINGS 66, adpcm_run.sh).
|
||||
SOUNDARGS="-sound none"
|
||||
|
||||
echo "--- 1. THE GATE: $NFR records, paced at $GATE_FPS fps, channel stealing ---"
|
||||
SECS=$(( NFR / GATE_FPS + 25 ))
|
||||
@@ -120,6 +125,39 @@ grep -aq "0 frames NOT SAMPLED" tmp/packed_gate.log || \
|
||||
python3 tools/bench/verify_packed.py "$DLXP" --snap tmp/snap_packed_gate \
|
||||
--map tmp/packed_snaps_gate.csv --min-frames "$NFR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. THE AUDIO. ROADMAP P6c: the container's own bytes, out of channel 3,
|
||||
# beside the video channel. Paced at the CONTAINER's rate rather than the
|
||||
# gate's half rate, because the audio was cut at 12 fps and a 6 fps run would
|
||||
# starve the chip for half of every group -- the picture can be slowed down and
|
||||
# a crystal cannot. Nothing is snapshotted; the instrument is the WAV, and
|
||||
# tools/bench/verify_packed_audio.py accounts for every byte of the stream in
|
||||
# it. The tag is `aud` and not `audio` because tmp/packed_audio.log is
|
||||
# tools/analysis/34_packed_audio.py's, in check.sh.
|
||||
#
|
||||
# CYCLE STEALING, and that is a result rather than a setting -- run 6 below is
|
||||
# the same run with the bus held and it is a CONTRAST, not a gate.
|
||||
AUDIO_ON=$(sed -n 's/.*has_audio = \([01]\),.*/\1/p' tmp/packed_meta.lua)
|
||||
CFPS=$(sed -n 's/^ fps = \([0-9]*\),.*/\1/p' tmp/packed_meta.lua)
|
||||
if [ "$AUDIO_ON" = "1" ]; then
|
||||
echo
|
||||
echo "--- 5. THE AUDIO: the container's own lumps, out of channel 3, while"
|
||||
echo " the video channel is on the same bus (ROADMAP P6c) ---"
|
||||
SECS=$(( NFR / 8 + 30 ))
|
||||
SOUNDARGS="-samplerate 15625 -wavwrite packed_aud.wav"
|
||||
run aud DLX_PK_HELD=0 DLX_PK_PACE=1 DLX_PK_FPS="$CFPS" DLX_PK_NFR=$NFR \
|
||||
DLX_PK_AUD=1 DLX_PK_AJSON=packed_aud.json
|
||||
SOUNDARGS="-sound none"
|
||||
grep -aq "^\[PK\] FLAG=\$FF" tmp/packed_aud.log || \
|
||||
fail "the audio run did not reach the end of the scene."
|
||||
python3 tools/bench/verify_packed_audio.py "$DLXP" tmp/packed_aud.wav \
|
||||
tmp/packed_aud.json || \
|
||||
fail "the chip did not play the container. The counters above can all be
|
||||
right while this fails -- nothing parses a packed container, so a lump
|
||||
fetched into a buffer that is still being read is not an error, it is a
|
||||
sound (FINDINGS 67.4, and it is the bug session 36 shipped and caught)."
|
||||
fi
|
||||
|
||||
if [ "${DLX_PK_GATE_ONLY:-0}" = "1" ]; then exit 0; fi
|
||||
|
||||
echo
|
||||
@@ -150,6 +188,41 @@ LOST=$(sed -n 's/.*-> \([0-9]*\) edges LOST.*/\1/p' tmp/packed_held_paced.log |
|
||||
rasters -- and the comparison with the stealing run below is then a
|
||||
comparison of two configurations that do the same thing."
|
||||
LOSTS=$(sed -n 's/.*-> \([0-9]*\) edges LOST.*/\1/p' tmp/packed_gate.log | head -1)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. THE SAME AUDIO WITH THE BUS HELD, and this is the interaction ROADMAP P6c
|
||||
# said neither half had met. A burst channel HALTS the 68000 for the whole
|
||||
# 88 ms record, so the audio service cannot run during it -- src/player/dma.i's
|
||||
# DM_HOOK is never reached and the chip is looked at twice a frame instead of
|
||||
# two thousand times. The bytes are the same bytes either way; what changes is
|
||||
# WHEN the next lump is armed, and the chip has no starvation state: what it
|
||||
# does in between is replay the byte pair it is holding.
|
||||
#
|
||||
# NOT A GATE. Both configurations play the container byte for byte and the
|
||||
# verifier passes on both; the difference is entirely in the seams, and a seam
|
||||
# is a design cost rather than a correctness one.
|
||||
if [ "$AUDIO_ON" = "1" ]; then
|
||||
echo
|
||||
echo "--- 6. THE AUDIO AGAIN, WITH THE BUS HELD (the contrast, not a gate) ---"
|
||||
SECS=$(( NFR / 6 + 30 ))
|
||||
SOUNDARGS="-samplerate 15625 -wavwrite packed_aud_held.wav"
|
||||
run aud_held DLX_PK_HELD=1 DLX_PK_PACE=1 DLX_PK_FPS="$CFPS" DLX_PK_NFR=$NFR \
|
||||
DLX_PK_AUD=1 DLX_PK_AJSON=packed_aud_held.json
|
||||
SOUNDARGS="-sound none"
|
||||
python3 tools/bench/verify_packed_audio.py "$DLXP" tmp/packed_aud_held.wav \
|
||||
tmp/packed_aud_held.json || fail "the held run did not play the
|
||||
container. The bytes are not what holding the bus was expected to cost."
|
||||
echo
|
||||
echo " THE SEAM, STEALING AGAINST HELD -- audio does not merely cost clocks:"
|
||||
for t in aud aud_held; do
|
||||
printf ' %-9s ' "$t"
|
||||
python3 tools/bench/verify_packed_audio.py "$DLXP" "tmp/packed_$t.wav" \
|
||||
| sed -n 's/^ worst \(.*\)$/\1/p' | head -1
|
||||
done
|
||||
echo " Stealing, the 68000 sees the channel from inside dma.i's transfer"
|
||||
echo " wait. Held, it is halted for the whole record and cannot look at all."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo " V-DISP edges lost: $LOST held at 12 fps, $LOSTS stealing at $GATE_FPS fps."
|
||||
echo " A player keeps a clock, reads a stick and feeds ADPCM. Which of the two"
|
||||
|
||||
@@ -49,6 +49,18 @@ with open(out, "w") as fh:
|
||||
("has_audio", int(d.has_audio)),
|
||||
("aud_bytes", d.aud_bytes), ("aud_hz", d.aud_hz),
|
||||
("n_lumps", d.n_lumps),
|
||||
# ROADMAP P6c. lba_aud is off_aud/512 and is a HEADER field
|
||||
# rather than the constant 1 it happens to equal: a shipping
|
||||
# volume has a filesystem in front of the stream and the
|
||||
# player adds PG_LBA0's base to neither of them by accident.
|
||||
("lba_aud", d.off_aud // SECTOR if d.has_audio else 0),
|
||||
# the four axes, so the rig can print what the container says
|
||||
# it was encoded for and the verifier can decode with it
|
||||
("aud_variant", '"%s"' % d.decoder()["variant"]
|
||||
if d.has_audio else '""'),
|
||||
("aud_order", '"%s"' % d.decoder()["order"]
|
||||
if d.has_audio else '""'),
|
||||
("aud_bits", d.aud_bits), ("aud_init", d.aud_init),
|
||||
("entries", entries)]:
|
||||
fh.write(f" {k} = {v},\n")
|
||||
fh.write("}\n")
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The container's own audio, READ BACK OFF THE SPEAKER. ROADMAP P6c.
|
||||
|
||||
python3 tools/bench/verify_packed_audio.py <in.dlxp> <capture.wav> [counters.json]
|
||||
|
||||
WHY THIS READS THE CAPTURE AND NOT THE PLAYER'S COUNTERS. src/player/packed.s
|
||||
reports how many lumps it armed and how many payload bytes it handed the chip,
|
||||
and every one of those numbers can be right while the sound is wrong. Nothing
|
||||
in this format parses anything (FINDINGS 67.4): a lump fetched one sector out is
|
||||
not an error, it is 7,168 B of noise played at the right length; a payload one
|
||||
byte long is not an error either, it is a rate. The only instrument that can
|
||||
tell those apart from a correct run is the stream the chip actually produced.
|
||||
|
||||
WHAT IS CHECKED, and it is the whole scene rather than a sample of it:
|
||||
|
||||
1. every lump's payload, decoded with the FOUR AXES OUT OF THE CONTAINER'S OWN
|
||||
HEADER (FINDINGS 66/67.3), appears in the capture SAMPLE-EXACT and in
|
||||
order. Not "close": the recursion is exact arithmetic and MAME's okim6258
|
||||
puts `signal << 4` into a stream the machine routes to the speaker at gain
|
||||
0.50, so a chip sample is `signal * 8` and recovering it is a division and
|
||||
not a rounding. The residual is asserted.
|
||||
|
||||
2. the PAYLOAD lengths are the accumulator's and not the lump's. A player
|
||||
that fed the chip the whole A*512 B lump runs 0.09% fast -- 1.25 s of
|
||||
lip-sync over the game (67.2) -- and the difference between the two is 6.54
|
||||
B a group, which is 13 samples. So this is checked by LENGTH: lump k's run
|
||||
of matched samples must be exactly 2*lump_bytes(k), and that alternates
|
||||
14,322 / 14,324 rather than being 14,336 every time.
|
||||
|
||||
3. THE SEAMS, measured rather than assumed. Channel 3 counts out at the end
|
||||
of a lump and the chip has no FIFO and no starvation state -- it goes on
|
||||
decoding whatever byte its data register still holds, alternating that
|
||||
byte's low and high nibbles, until the CPU arms the next lump. Those
|
||||
samples are NOT silence, they are the recursion running on a repeated byte,
|
||||
and the state they leave behind is what lump k+1 decodes from. So the
|
||||
search below carries the state across the seam and reports its LENGTH,
|
||||
which is the audible cost of every design decision on the video path.
|
||||
|
||||
THE NEGATIVE CONTROL IS BUILT IN. A seam is found by searching for the repeat
|
||||
count that makes the next lump match; if the player had fed the wrong bytes, no
|
||||
repeat count would make it match and the run fails rather than sliding.
|
||||
"""
|
||||
import json, os, struct, sys, wave
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "encoder"))
|
||||
import adpcm
|
||||
from dlxp import DLXP, lump_bytes as _lump_bytes
|
||||
|
||||
|
||||
def dlxp_lump_bytes(d, k):
|
||||
"""What the CADENCE gives lump k, before the stream's end
|
||||
truncates it -- so a lump that is short because the scene ran
|
||||
out can be told from one that is short because the remainder
|
||||
arithmetic said so."""
|
||||
return _lump_bytes(k, d.cad_f, d.fps, d.aud_hz)
|
||||
|
||||
SCALE = 8 # okim6258's `signal << 4` into a 32768 stream, times the
|
||||
# machine's 0.50 speaker route. verify_adpcm_chip.py's.
|
||||
RESID = 2 # counts of slack on that recovery, as 66 measured it
|
||||
LOOK = 32 # samples of continuation a candidate run has to survive
|
||||
MAXRUN = 60000 # nibbles one delivered byte may be stretched over: 3.8 s
|
||||
# at 15,625 Hz, far past any seam a working player makes.
|
||||
# A bound is what makes a failure say "not found" rather
|
||||
# than run until the host is bored.
|
||||
LEAD = 400000 # samples of silence before PLAY
|
||||
|
||||
|
||||
def stepper(dec):
|
||||
"""One sample of the recursion, as a closure over the container's own four
|
||||
axes. adpcm.decode_state is the same arithmetic and is what the whole-lump
|
||||
paths use; this exists because the walk below needs it ONE NIBBLE AT A TIME
|
||||
and a function call per sample over 78,125 bytes is the difference between
|
||||
a gate that runs in seconds and one that does not."""
|
||||
lo, hi = adpcm.clamp_bounds(dec["bits"])
|
||||
variant, step, adj = dec["variant"], adpcm.STEP, adpcm.INDEX_ADJUST
|
||||
|
||||
def one(st, n):
|
||||
sig, idx = st
|
||||
sig += adpcm.delta(n, step[idx], variant)
|
||||
sig = lo if sig < lo else (hi if sig > hi else sig)
|
||||
idx += adj[n & 7]
|
||||
idx = 0 if idx < 0 else (48 if idx > 48 else idx)
|
||||
return sig, (sig, idx)
|
||||
return one
|
||||
|
||||
|
||||
def walk(rec, pos, st, data, dec):
|
||||
"""Account for EVERY byte the player handed the chip, in order.
|
||||
|
||||
WHY A WALK AND NOT A COMPARISON. A whole-stream `decode(data) == capture`
|
||||
is the check this wanted to be and it does not survive contact with the
|
||||
machine. The MSM6258 has no FIFO and no handshake at all: the DMA channel
|
||||
writes a byte into the data register whenever #DRQ3 asks, and the chip
|
||||
decodes a nibble out of whatever is in there on every sample tick. Those
|
||||
are two clocks -- 7,812.5 B/s and 15,625 Hz -- and MAME's okim6258 data_w
|
||||
RESETS the nibble select on every write. So a byte is normally played as
|
||||
two nibbles, and near a boundary it can be played as one (the high nibble
|
||||
dropped) or as three or more (the low/high pair repeated) purely from where
|
||||
the write lands inside a sound-stream slice.
|
||||
|
||||
THE MODEL IS THEREFORE ONE LINE: byte b was played as `c` nibbles taken from
|
||||
the cycle (b&15, b>>4), c >= 1. This walk finds `c` for every byte, and the
|
||||
HISTOGRAM of c is the result -- c=2 everywhere is a chip being fed exactly
|
||||
at its own rate, and every c>2 is the chip replaying a byte while the 68000
|
||||
was somewhere else, which is what a SEAM is.
|
||||
|
||||
It is not a loose check. Every one of the `c` samples has to be exactly
|
||||
right, the run has to be followed by LOOK samples that are exactly right,
|
||||
and a byte the player never sent leaves no c at all. Returns
|
||||
(fail_index, pos, runs); fail_index is None on success.
|
||||
"""
|
||||
one = stepper(dec)
|
||||
runs = []
|
||||
n = len(data)
|
||||
pos_ = pos
|
||||
for i in range(n):
|
||||
b = data[i]
|
||||
pair = (b & 15, b >> 4)
|
||||
nxt = data[i + 1] & 15 if i + 1 < n else None
|
||||
# DEEP FIRST, THEN SHALLOW. LOOK samples of continuation is what tells
|
||||
# a real seam from a coincidence -- a repeated pair can agree with the
|
||||
# next lump's first nibble for one sample and does not for thirty-two.
|
||||
# But the window is 16 bytes wide and a SECOND stretched byte inside it
|
||||
# rejects the true answer as well as the false ones, so a byte that no
|
||||
# candidate survives is retried with a shallow window rather than
|
||||
# failing -- and the retry window is TWO samples, the next byte's own
|
||||
# pair, because a four-sample one reaches into the stretched byte
|
||||
# itself. The cost of resolving it wrong is a failed walk, not a pass.
|
||||
got = _pick(rec, pos_, st, pair, nxt, data, i, one, LOOK)
|
||||
if got is None:
|
||||
got = _pick(rec, pos_, st, pair, nxt, data, i, one, 2)
|
||||
if got is None:
|
||||
return i, pos_, runs
|
||||
c, st = got
|
||||
pos_ += c
|
||||
runs.append(c)
|
||||
return None, pos_, runs
|
||||
|
||||
|
||||
def _pick(rec, pos, st, pair, nxt, data, i, one, look):
|
||||
"""The run length for one byte: every candidate `c` whose samples are exact
|
||||
and whose continuation survives `look`, resolved to c=2 where c=2 is one of
|
||||
them. Two nibbles a byte is what the two clocks agree on; anything else is
|
||||
an event and an event needs the evidence, which is what `look` is."""
|
||||
cands = []
|
||||
s2, c = st, 0
|
||||
while c < MAXRUN and pos + c < len(rec):
|
||||
smp, s3 = one(s2, pair[c & 1])
|
||||
if rec[pos + c] != smp:
|
||||
break
|
||||
c += 1
|
||||
s2 = s3
|
||||
if nxt is None:
|
||||
cands.append((c, s2))
|
||||
break
|
||||
smp2, _ = one(s2, nxt)
|
||||
if pos + c < len(rec) and rec[pos + c] == smp2 \
|
||||
and _look(rec, pos + c, s2, data, i + 1, one, look):
|
||||
cands.append((c, s2))
|
||||
if c > 2 and len(cands) >= 2:
|
||||
break
|
||||
if not cands:
|
||||
return None
|
||||
for cc in cands:
|
||||
if cc[0] == 2:
|
||||
return cc
|
||||
return cands[0]
|
||||
|
||||
|
||||
def _look(rec, pos, st, data, i, one, look=LOOK):
|
||||
"""`look` samples of continuation, assuming two nibbles a byte from here.
|
||||
|
||||
This is what tells a real seam from a coincidence. At a lump boundary the
|
||||
repeated pair can happen to agree with the next lump's first nibble for one
|
||||
sample; it does not go on agreeing for thirty-two.
|
||||
"""
|
||||
n, k = len(data), 0
|
||||
while k < look and i < n:
|
||||
b = data[i]
|
||||
for nib in (b & 15, b >> 4):
|
||||
if pos >= len(rec):
|
||||
return True
|
||||
smp, st = one(st, nib)
|
||||
if rec[pos] != smp:
|
||||
return False
|
||||
pos += 1
|
||||
k += 1
|
||||
i += 1
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(__doc__)
|
||||
d = DLXP(sys.argv[1])
|
||||
counters = json.load(open(sys.argv[3])) if len(sys.argv) > 3 else None
|
||||
if not d.has_audio:
|
||||
sys.exit("this container is silent -- there is nothing to have heard")
|
||||
|
||||
dec = d.decoder()
|
||||
fails = []
|
||||
def ck(ok, msg):
|
||||
print(("OK " if ok else "FAIL ") + msg)
|
||||
if not ok:
|
||||
fails.append(msg)
|
||||
|
||||
w = wave.open(sys.argv[2])
|
||||
n, ch, rate = w.getnframes(), w.getnchannels(), w.getframerate()
|
||||
s = struct.unpack("<%dh" % (n * ch), w.readframes(n))
|
||||
left, right = s[0::ch], s[1::ch]
|
||||
ck(rate == d.aud_hz,
|
||||
f"the capture is at {rate:,} Hz and the chip's stream is {d.aud_hz:,} -- "
|
||||
f"equal rates are what keep MAME's resampler out of the measurement")
|
||||
ck(list(left) == list(right), "both speakers carry the same samples (pan 00)")
|
||||
ck(any(left), "the capture contains a signal at all")
|
||||
if not any(left) or rate != d.aud_hz:
|
||||
return 1
|
||||
rec = [round(v / SCALE) for v in left]
|
||||
start = next(i for i, v in enumerate(rec) if v)
|
||||
ck(start < LEAD, f"the chip starts playing {start/rate:.2f} s in")
|
||||
|
||||
print(f"--- {sys.argv[1]}: {d.n_lumps} lumps, decoder {dec['variant']}/"
|
||||
f"{dec['order']}, {dec['bits']}-bit clamp, accumulator {dec['init']} "
|
||||
f"at PLAY -- ALL FOUR out of the header (67.3)")
|
||||
|
||||
# ---- THE STREAM, ACCOUNTED FOR BYTE BY BYTE. d.audio() is the container's
|
||||
# own lumps reassembled BY PAYLOAD -- 67.2's accumulator, not the padded
|
||||
# sector runs -- so a player that fed the chip whole lumps does not merely
|
||||
# score worse here, it fails to walk: the 6.54 B of zero at the end of a
|
||||
# lump are nibbles that are not in this stream.
|
||||
data = d.audio()
|
||||
bad, pos, runs = walk(rec, start, (dec["init"], 0), data, dec)
|
||||
ck(bad is None,
|
||||
f"all {len(data):,} bytes of the container's audio reached the chip, in "
|
||||
f"order, and every sample the chip produced from them is exact"
|
||||
+ ("" if bad is None else f" -- byte {bad:,} of {len(data):,} does not "
|
||||
f"account for capture sample {pos:,}"))
|
||||
if bad is not None:
|
||||
k = bad * 2 * d.fps // (d.cad_f * d.aud_hz)
|
||||
print(f" that is inside lump {k}, {bad - sum(len(d.lump(j)) for j in range(k)):,} B in")
|
||||
return 1
|
||||
|
||||
matched = pos - start
|
||||
worst = max(abs(v - SCALE * round(v / SCALE))
|
||||
for v in left[start:start + matched])
|
||||
ck(worst <= RESID,
|
||||
f"every one of {matched:,} matched samples is within {worst} of a "
|
||||
f"multiple of {SCALE} -- so `signal = sample/{SCALE}` recovers the chip's "
|
||||
f"own stream rather than rounding to the nearest story")
|
||||
|
||||
# ---- 1. THE FEED. c=2 is a chip being fed at exactly its own rate.
|
||||
hist = {}
|
||||
for c in runs:
|
||||
hist[c] = hist.get(c, 0) + 1
|
||||
two = hist.get(2, 0)
|
||||
ck(two * 1000 >= len(runs) * 999,
|
||||
f"{two:,} of {len(runs):,} bytes ({two*100/len(runs):.3f}%) were played "
|
||||
f"as exactly two nibbles -- the chip was paced by its own #DRQ3 and not "
|
||||
f"by the CPU")
|
||||
print(f" nibbles per delivered byte: "
|
||||
+ ", ".join(f"{c}x{v:,}" for c, v in sorted(hist.items())))
|
||||
|
||||
# ---- 2. THE SEAMS, which is what every c > 2 is. A lump's channel counts
|
||||
# out and the chip goes on replaying the last byte until the CPU arms the
|
||||
# next one; the excess nibbles ARE that interval, measured in the only place
|
||||
# it exists, which is the sound.
|
||||
seams = [(i, c - 2) for i, c in enumerate(runs) if c > 2]
|
||||
print(f"--- THE SEAMS: {len(seams)} byte(s) were stretched, out of "
|
||||
f"{d.n_lumps - 1} lump boundaries")
|
||||
if seams:
|
||||
ex = sum(c for _, c in seams)
|
||||
print(f" worst {max(c for _, c in seams)} samples = "
|
||||
f"{max(c for _, c in seams)/rate*1000:.2f} ms; total {ex} samples "
|
||||
f"= {ex/rate*1000:.2f} ms of replayed byte over "
|
||||
f"{matched/rate:.2f} s of audio ({ex*100/matched:.4f}%)")
|
||||
# and every stretched byte must BE a lump boundary -- a stretch anywhere
|
||||
# else is the CPU losing the chip in the middle of a buffer.
|
||||
ends = set()
|
||||
off = 0
|
||||
for k in range(d.n_lumps):
|
||||
off += len(d.lump(k))
|
||||
ends.add(off - 1)
|
||||
stray = [i for i, _ in seams if i not in ends]
|
||||
ck(not stray,
|
||||
f"every stretched byte is the LAST byte of a lump ({len(stray)} were not)"
|
||||
+ ("" if not stray else f" -- first at byte {stray[0]:,}, which is the "
|
||||
f"chip running dry in the middle of a buffer"))
|
||||
|
||||
# ---- 3. THE PAYLOAD IS THE ACCUMULATOR'S (FINDINGS 67.2). The walk
|
||||
# already proves it -- a whole-lump player's stream contains the padding and
|
||||
# would not walk -- so what is left is to price what was avoided. The LAST
|
||||
# lump is left out: 120 frames is not a multiple of F=11, so it carries ten
|
||||
# frames of audio and is short for an arithmetic reason and not a rate one.
|
||||
full = [k for k in range(d.n_lumps)
|
||||
if len(d.lump(k)) == dlxp_lump_bytes(d, k)]
|
||||
ck(len(full) >= d.n_lumps - 1,
|
||||
f"{len(full)} of {d.n_lumps} lumps carry a whole group of audio")
|
||||
if len(full) > 1:
|
||||
pad = d.cad_a * 512
|
||||
got = sum(len(d.lump(k)) for k in full)
|
||||
over = pad * len(full) - got
|
||||
secs = got * 2 / rate
|
||||
print(f"--- THE PADDING IS DRIFT (FINDINGS 67.2), over the {len(full)} "
|
||||
f"lumps that carry a whole group")
|
||||
print(f" payload {got:,} B against {pad*len(full):,} B of lump "
|
||||
f"space: {over:,} B more, {over*100/got:.3f}%, "
|
||||
f"{over*2/rate*1000:.2f} ms over {secs:.2f} s of audio")
|
||||
print(f" -> {over*2/rate/secs*22.8*60:.2f} s of lip-sync over the "
|
||||
f"game's 22.8 min, and the accumulator in pg_apay is the three "
|
||||
f"lines that do not spend it")
|
||||
|
||||
if counters:
|
||||
ck(counters["late"] == 0,
|
||||
f"the player never re-armed a channel that still had bytes to send "
|
||||
f"({counters['late']} did)")
|
||||
ck(counters["starve"] == 0,
|
||||
f"the player never found the channel counted out with no lump ready "
|
||||
f"({counters['starve']} times it did)")
|
||||
ck(counters["bytes"] == len(data),
|
||||
f"the player's own byte count ({counters['bytes']:,}) is the "
|
||||
f"container's whole payload ({len(data):,})")
|
||||
print(f" the player: {counters['armed']} lumps armed, "
|
||||
f"{counters['fetched']} fetched, {counters['serv']:,} service "
|
||||
f"calls over {counters['shown']} frames "
|
||||
f"({'BUS HELD' if counters['held'] else 'CYCLE STEALING'})")
|
||||
|
||||
print("PACKED AUDIO GATE " + ("GREEN" if not fails
|
||||
else f"RED: {len(fails)} failed"))
|
||||
return 1 if fails else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user