Put sound in the packed container, and find the padding is a rate error
ROADMAP P6b, FINDINGS 67. DLXP2: a 64-byte header and then groups -- one audio lump of A sectors, then F records -- so record i is at off_frm + i*rec + (i//F)*A*512 and lump k at off_aud + k*(F*rec + A*512). Still no index and still none needed, which is the packed branch's whole claim surviving the one change that could have ended it. The player carries the third term in six instructions once a frame and zero parsing, and 120 of 120 records are still pixel-exact off a real MB89352 volume with the interleave in, against a silent control that says no picture byte moved. The finding is what 65.3 called padding. A lump is 7,168 B of SPACE; eleven frames of audio is 7,161.4583... B, so the payload alternates 7,161 and 7,162 and the rest is zero. A player that fed the chip the whole lump -- which is what "14 sectors every 11 frames" invites -- runs 0.09% fast, and that is not waste, it is drift: 0.84 ms a group, 1.25 s of lip-sync over the game's 22.8 minutes. What a player carries is one accumulator, acc += 11*15625; n = acc//24; acc %= 24, which is clock.i's shape for clock.i's reason and the third time this tree has met the pattern. The four ADPCM axes ride in the header as fields rather than a version number, and the gate flips each one to prove they earn it: nibble order -31.99 dB, delta formula -24.86, clamp 0.00, accumulator -0.49. Nothing parses a packed container, so the gate partitions the whole file -- 131 spans, no overlap, no gap -- and asserts what a cadence-blind player would read: exactly records 11..119 wrong, and frames 0..10 identical either way, which is how an off-by-one like that survives a rig that checks frame 0. Wire 582.0 + 7.64 = 589.6 KB/s, 65.3's prediction to the tenth. Green light ALL GREEN before (tmp/check_s35_start.log) and after (tmp/check_s35_end.log), with the new stage in it. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
@@ -395,6 +395,37 @@ the obvious one-lump-per-record cadence wastes **57.3%** of every audio sector.
|
||||
The wire goes **582.0 → 589.6 KB/s**. The codec container, which already has the
|
||||
index the packed one deleted, pays **zero**.
|
||||
|
||||
**And the packed container has sound in it now — its padding turned out to be
|
||||
drift.** DLXP2 is a 64-byte header and then *groups*: one audio lump of 14
|
||||
sectors, then 11 records, so `record i = off_frm + i*rec + (i//11)*7,168`. **Still
|
||||
no index and still none needed** — the packed branch's whole claim survives the
|
||||
one change that could have ended it — and on the 68000 the third term is **six
|
||||
instructions once a frame** and zero parsing, because the cadence is two numbers
|
||||
in the header rather than a table in the stream. `src/player/packed.s` fetches
|
||||
records out of the interleaved container off a real MB89352 volume and **120 of
|
||||
120 are still pixel-exact**, against a **silent control** built from the same
|
||||
frames that says no picture byte moved.
|
||||
|
||||
The finding is what 65.3 called padding. A lump is **7,168 bytes of space** and
|
||||
eleven frames of audio is **7,161.4583… bytes**, so the *payload* alternates
|
||||
7,161 and 7,162 and the rest is zero. A player that fed the chip the whole lump
|
||||
— which is what "14 sectors every 11 frames" invites — runs **0.09% fast**, and
|
||||
that is not waste, it is **drift: 1.25 seconds of lip-sync over the game's 22.8
|
||||
minutes**. What a player carries is one accumulator, `acc += 11*15625;
|
||||
n = acc//24; acc %= 24`, which is the frame clock's shape for the frame clock's
|
||||
reason. **Third time this tree has met a ratio with a remainder**, and the rule
|
||||
it keeps writing is that rounding one *once* is an error and rounding it *every
|
||||
period* is a rate.
|
||||
|
||||
The four ADPCM axes ride in the header as fields rather than a version number,
|
||||
and the gate flips each one to show they earn it: **nibble order −31.99 dB,
|
||||
delta formula −24.86, clamp 0.00, accumulator −0.49**. Nothing parses a packed
|
||||
container, so the gate **partitions the whole file** — 131 spans, no overlap, no
|
||||
gap — and asserts what a cadence-blind player would read: exactly records
|
||||
11..119 wrong, **and frames 0..10 identical either way**, which is how an
|
||||
off-by-one like that survives a rig that checks frame 0. The wire is **582.0 +
|
||||
7.64 = 589.6 KB/s**, which is 65.3's prediction to the tenth (FINDINGS 67).
|
||||
|
||||
**The scene graph is in, and the worst gap between two decision points is
|
||||
zero.** `tools/import/scenegraph.py` imports the arcade scene graph — 40 scenes,
|
||||
516 sequences, 906 input windows — and 5.4% of the game's 612 branch transitions
|
||||
@@ -425,11 +456,13 @@ mounted) re-runs both display regression tests, the rate-control drift gate, the
|
||||
display-path coherency counterexample, a 120-frame 68000 decode on two CPU
|
||||
cores, the ring and paced-ring passes, the DMAC configuration gate and the
|
||||
load-time transforms on both cores, then imports and gates the scene graph
|
||||
when a DirkSimple checkout is present, then builds the packed container and
|
||||
renders it through px68k's own GVRAM model, then **runs the packed player for
|
||||
120 frames off a real volume and compares every one of them**, then encodes the
|
||||
same window's audio and gates it against ffmpeg's decoder, then prints
|
||||
`ALL GREEN`.
|
||||
when a DirkSimple checkout is present, then builds the packed container --
|
||||
with audio in it, and a silent control beside it -- and renders it through
|
||||
px68k's own GVRAM model, then **runs the packed player for 120 frames off a real
|
||||
volume and compares every one of them**, then encodes the same window's audio
|
||||
and gates it against ffmpeg's decoder, then asks the emulated MSM6258 which of
|
||||
sixteen decoders it is, then gates the DLXP2 container and every one of its
|
||||
axes, then prints `ALL GREEN`.
|
||||
|
||||
## Reproducing this
|
||||
|
||||
@@ -631,6 +664,11 @@ tools/import/ the ONLY code in this tree coupled to somebody else's source.
|
||||
project's own DLXSCENE1 schema, with the sources' licences and
|
||||
attribution inside it. Nothing is vendored and the output is
|
||||
gitignored derived data.
|
||||
34 is DLXP2's gate: the file partitioned into spans, the
|
||||
records checked against a SILENT control, the lumps checked
|
||||
byte-exact against the encoder, and one negative control per
|
||||
ADPCM axis. Nothing parses a packed container, so a lump one
|
||||
sector out does not fail -- it paints.
|
||||
30 is the PACKED container's gate: the format's invariants
|
||||
(round-trip, sectors, the transparency key, the palette word),
|
||||
and the quality re-derivation 61.9 asked for, in both the RGB888
|
||||
@@ -649,12 +687,19 @@ tools/encoder/ hybrid VQ encoder and DLX3 container writer.
|
||||
record as whole sectors straight into the ring with no window
|
||||
and no bounce copy; dlx.record_lengths() is the one place that
|
||||
rule is applied.
|
||||
dlxp.py and pack.py are the OTHER container -- DLXP1, the
|
||||
decoder-free packed one (FINDINGS 63). Nothing is shared with
|
||||
the codec's writer on purpose: a packed record is a palette and
|
||||
a picture, both geometry, and dlxp.py is the one place the
|
||||
interleave and the 97-sector record are stated. There is no
|
||||
rate control in pack.py because there is no rate lever.
|
||||
dlxp.py and pack.py are the OTHER container -- DLXP2, the
|
||||
decoder-free packed one (FINDINGS 63, 67). Nothing is shared
|
||||
with the codec's writer on purpose: a packed record is a
|
||||
palette and a picture, both geometry, and dlxp.py is the one
|
||||
place the interleave, the 97-sector record, the audio cadence
|
||||
and the lump PAYLOAD are stated. There is no rate control in
|
||||
pack.py because there is no rate lever.
|
||||
adpcm.py is the MSM6258 codec and it carries TWO decoders on
|
||||
purpose: the module defaults are ffmpeg's, so that
|
||||
tools/bench/verify_adpcm.py stays a check against an
|
||||
independent implementation, and adpcm.CHIP is the set measured
|
||||
out of the machine's own chip (66). Anything that encodes FOR
|
||||
the machine passes CHIP explicitly.
|
||||
dlx.py is the reference DECODER, ground truth for the 68000.
|
||||
24 models the ring with the 68000 owning it: the request
|
||||
queue, the poll-only-when-not-decoding rule and 54.4's frame
|
||||
|
||||
@@ -7075,3 +7075,147 @@ write*.
|
||||
`adpcm.CHIP`, and 65.3's cadence arithmetic (F=11, A=14, wire 582.0 → 589.6
|
||||
KB/s) is untouched by any of this — it is a byte count, and none of the four
|
||||
axes changes how many bytes a second the format needs.
|
||||
|
||||
---
|
||||
|
||||
## 67. DLXP2 — a packed container with sound in it, and the padding turns out to be drift
|
||||
|
||||
**ROADMAP P6b. Session 35.** `tools/encoder/dlxp.py` (DLXP2),
|
||||
`tools/encoder/pack.py --audio`, `tools/analysis/34_packed_audio.py`,
|
||||
`src/player/packed.s` (the third LBA term), `tools/bench/prep_packed.py`,
|
||||
`tools/bench/packed.lua`.
|
||||
|
||||
**NAME THE LAYER.** The container is **host arithmetic**, gated against itself
|
||||
and against a silent control. The one thing that ran on the emulated 68000 is
|
||||
the *video* consequence: `packed.s` fetches records out of an interleaved
|
||||
container and all 120 are still pixel-exact off a real volume. **No audio byte
|
||||
has been fed to a chip out of this container**, on any layer — the chip gate
|
||||
(66) feeds a designed stream, not this one.
|
||||
|
||||
### 67.1 The format, and what it costs the player: one `divu` and one `mulu`
|
||||
|
||||
A DLXP2 is a DLXP1 with a 64-byte header and, from sector 1, **groups**: one
|
||||
audio lump of `A` sectors, then `F` records.
|
||||
|
||||
record i = off_frm + i*rec_bytes + (i//F)*A*512
|
||||
lump k = off_aud + k*(F*rec_bytes + A*512)
|
||||
|
||||
Neither is a lookup. **The format still has no index and still needs none** —
|
||||
which was 63/64.1's whole claim for the packed branch, and a second stream at an
|
||||
unrelated rate is exactly the thing that could have ended it. The lump goes
|
||||
**before** the group it feeds rather than after it (65.3's formula put it after):
|
||||
a stream is read forwards, so bytes that arrive after the slot they belong to
|
||||
are bytes a player had to fetch early anyway.
|
||||
|
||||
On the 68000 the third term is **six instructions in `pg_frame`** — a `divu`, an
|
||||
`andi` to drop the remainder `divu` leaves in the high half, a `mulu` and an
|
||||
`add` — and **zero instructions of parsing**, because the cadence is two numbers
|
||||
in the header rather than a table in the stream.
|
||||
|
||||
**Measured, on the emulated machine, off a real MB89352 volume: 120 of 120
|
||||
frames pixel-exact** out of the interleaved container, every one compared. The
|
||||
interleave moved no picture byte — asserted against a **silent control** built
|
||||
from the same frames with `--audio` off, all 120 records byte-identical.
|
||||
|
||||
### 67.2 The finding: the payload is not the lump
|
||||
|
||||
65.3 chose the cadence F=11, A=14 and called the 0.09% "padding". It is padding
|
||||
on the wire. **It is drift in the player**, and that is a different thing.
|
||||
|
||||
A lump is `A*512` = **7,168 B of space**. Eleven frames of audio is
|
||||
`11*15625/24` = **7,161.4583… B**. So the *payload* alternates **7,161 and
|
||||
7,162** — the same remainder FINDINGS 54's frame clock carries, one dimension
|
||||
over — and the last 6.54 B of the sector run are zero.
|
||||
|
||||
**A player that fed the chip the whole lump** — the obvious implementation, and
|
||||
the one the phrase "14 sectors of audio every 11 frames" invites — hands it
|
||||
6.54 B a group it should not have. That is **0.84 ms of extra audio every
|
||||
0.9167 s**, and it does not average out:
|
||||
|
||||
| play | lip-sync error |
|
||||
|---|---:|
|
||||
| 1 min | 0.05 s |
|
||||
| **22.8 min (the game)** | **1.25 s** |
|
||||
|
||||
A second and a quarter is a scene of dialogue arriving after the mouth that
|
||||
spoke it. So `lump_bytes(k)` is **part of the format**, not a convenience, and
|
||||
what a player carries is the same shape `clock.i` carries and for the same
|
||||
reason — a rate whose denominator is 24 cannot be a count:
|
||||
|
||||
acc += 11*15625 ; = 171,875
|
||||
n = acc // 24 ; the MTC for this lump's channel
|
||||
acc %= 24
|
||||
|
||||
**The general shape, and it is the third time this tree has hit it**: 54's frame
|
||||
clock, 65.3's `.0417` B a slot, and now this. A ratio with a remainder that is
|
||||
rounded *once* is a rounding error; rounded *every period* it is a rate error,
|
||||
and a rate error integrates.
|
||||
|
||||
### 67.3 The four ADPCM axes are in the header, and the gate proves they earn it
|
||||
|
||||
66 measured which decoder MAME's chip runs and priced getting it wrong at up to
|
||||
−25.74 dB. DLXP2 carries all four — nibble order, delta formula, clamp width,
|
||||
the accumulator at PLAY — as **three header fields rather than a version
|
||||
number**, so a mismatch is legible in a hexdump instead of inferred from a
|
||||
container's age.
|
||||
|
||||
`34_packed_audio.py` decodes the container's own lumps and **flips one axis at a
|
||||
time**, which is the negative control that makes the fields load-bearing rather
|
||||
than documentation:
|
||||
|
||||
| axis | header | flipped to | SNR | cost |
|
||||
|---|---|---|---:|---:|
|
||||
| — | | | **21.99 dB** | |
|
||||
| nibble order | low | high | −10.00 dB | **−31.99 dB** |
|
||||
| delta formula | terms | shift | −2.87 dB | **−24.86 dB** |
|
||||
| clamp | 10 | 12 | 21.99 dB | +0.00 dB |
|
||||
| accumulator at PLAY | −2 | 0 | 21.50 dB | −0.49 dB |
|
||||
|
||||
These are **decode-side** flips on bytes that were encoded correctly, where
|
||||
66.2's table encoded and decoded on the wrong model together; the two agree on
|
||||
which axes are expensive and disagree by a few dB on how expensive, which is
|
||||
what different experiments on the same fact look like. The clamp is still free
|
||||
**on this window only** and for 66.3's reason: it peaks at 435 of 511.
|
||||
|
||||
### 67.4 The failure mode this format has and the codec's does not
|
||||
|
||||
A DLX record is found through an index, so a player that reads the wrong entry
|
||||
gets a length word that does not parse. **A packed record is found by
|
||||
arithmetic and nothing parses it.** A player that drops the `(i//F)*A` term
|
||||
reads 97 sectors starting 14 sectors early and *paints them*: the tail of the
|
||||
previous record, then most of this one, shifted down the screen. It is a
|
||||
picture. Nothing errors.
|
||||
|
||||
And it is invisible where a gate usually looks: **frames 0..10 are byte-identical
|
||||
either way**. The gate asserts the whole shape — that a cadence-blind read is
|
||||
wrong for exactly records `F..n-1`, 109 of 120, first at frame 11 — rather than
|
||||
that some frame differs.
|
||||
|
||||
The same argument is why the gate **partitions the file** instead of reading
|
||||
records back one at a time: an off-by-one that shifts everything after it reads
|
||||
back fine record by record. 131 spans, no overlap, no gap, ending exactly at the
|
||||
file's last byte.
|
||||
|
||||
### 67.5 The wire, unchanged from the prediction
|
||||
|
||||
video 582.0 KB/s geometry, no lever
|
||||
audio 7.64 KB/s the cadence's, padding included
|
||||
total 589.6 KB/s +1.31%
|
||||
|
||||
**65.3 predicted 589.6 and the container is 589.6** — which is what a byte count
|
||||
should do, since none of 66's four axes changes how many bytes a second the
|
||||
format needs. The audio figure charges the padding on purpose: the disc moves
|
||||
whole sectors and the wire pays for the zero ones.
|
||||
|
||||
### 67.6 What is still open
|
||||
|
||||
* **No audio has been played out of this container**, on any layer. The chip
|
||||
gate (66) fed a designed nibble stream through the IPL ROM's channel-3
|
||||
configuration; wiring *this* stream to *that* transport, and running it beside
|
||||
the video channel, is P6's remaining quarter.
|
||||
* **The second consumer has not met a branch point.** 51.3's refill climb with
|
||||
audio on the wire is arithmetic in `32_audio_wire.py` and has not been run.
|
||||
* **The level is still open downward** (66.3), and the loudest passage on the
|
||||
disc is still unmeasured.
|
||||
* **The lump buffer is not allocated anywhere.** 65.3 charges 14,336 B for
|
||||
double-buffering the cadence and no player holds it.
|
||||
|
||||
+46
-8
@@ -115,6 +115,24 @@ loudest passage on the disc is unmeasured. **(2) The transport is P6b's, not
|
||||
scaffolding**, and it worked first time. **P6b is next and its bytes are
|
||||
decided: DLXP2 encodes with `adpcm.CHIP`.**
|
||||
|
||||
Amended end of session 35: **P6b IS DONE AND ITS PADDING WAS DRIFT (FINDINGS
|
||||
67).** DLXP2 exists: a 64-byte header, groups of one `A`-sector audio lump then
|
||||
`F` records, `record i = off_frm + i*rec + (i//F)*A*512` — **still no index and
|
||||
still none needed**, which is the packed branch's whole claim surviving the one
|
||||
change that could have ended it. The player carries it in **six instructions
|
||||
once a frame**, and 120 of 120 records are still pixel-exact off a real volume
|
||||
with the interleave in, against a **silent control** that says no picture byte
|
||||
moved. **65.3's 0.09% was not waste, it was a rate error**: a lump is 7,168 B of
|
||||
space and eleven frames of audio is 7,161.4583… B, so a player that fed the chip
|
||||
the whole lump would run 0.09% fast — **1.25 s of lip-sync over the game's 22.8
|
||||
minutes**. The payload is a remainder, `acc += 11*15625; n = acc//24; acc %= 24`,
|
||||
which is `clock.i`'s shape for `clock.i`'s reason and the **third** time this
|
||||
tree has met the pattern. The four ADPCM axes ride in the header and the gate
|
||||
flips each one to prove they earn it (**order −31.99 dB, formula −24.86**). The
|
||||
wire is **589.6 KB/s**, 65.3's figure to the tenth. **What is left of P6 is the
|
||||
last quarter: no audio has been played out of this container on any layer, and
|
||||
two DMA channels have never run at once.**
|
||||
|
||||
**THE COMPLETION TARGET IS M3, THE VERTICAL SLICE** (USER DECISION): one scene
|
||||
tree — a decision point, two outcomes, a death clip — with audio, streaming from
|
||||
a real SCSI volume on a stock 2 MB machine, playable. That is the point at which
|
||||
@@ -747,10 +765,28 @@ throughout (65.5). The gap is the register semantics, and the way to close it is
|
||||
machine that is known-correct because Sharp wrote it. That is also the real
|
||||
design, so it is not scaffolding.
|
||||
|
||||
**P6b. A CONTAINER WITH SOUND IN IT.** DLXP1 has no audio section. 65.3 is the
|
||||
arithmetic a DLXP2 is built from and no byte of one is written. It waits on P6a,
|
||||
because a container full of audio encoded against the wrong formula is 25 dB of
|
||||
work to redo.
|
||||
**P6b. A CONTAINER WITH SOUND IN IT. DONE, session 35 — FINDINGS 67.**
|
||||
~~DLXP1 has no audio section. 65.3 is the arithmetic a DLXP2 is built from and no
|
||||
byte of one is written.~~ DLXP2 is written, gated and carried by the player:
|
||||
`tools/encoder/dlxp.py`, `pack.py --audio`, `tools/analysis/34_packed_audio.py`,
|
||||
the third LBA term in `src/player/packed.s`. The cadence arithmetic came through
|
||||
unchanged (F=11, A=14, 589.6 KB/s) and the **payload** did not: a lump's audio is
|
||||
7,161 or 7,162 B of a 7,168 B sector run, and feeding the chip the whole lump
|
||||
drifts 1.25 s over the game.
|
||||
|
||||
**P6c. AUDIO OUT OF THE CONTAINER, ON THE MACHINE (new, session 35).** Every
|
||||
piece exists and none is joined up. The container carries the bytes (67); the
|
||||
transport is `src/player/adpcm.i`'s channel-3 configuration, which is the IPL
|
||||
ROM's own and worked first time (66.1). What is missing is **the lump buffer**
|
||||
— 14,336 B, double-buffered, allocated by nobody — and **the remainder
|
||||
accumulator**, which is three instructions and is not optional.
|
||||
|
||||
**And the interaction neither half has met: TWO CHANNELS AT ONCE.** The video
|
||||
channel holds the bus and halts the 68000, which already costs the frame clock
|
||||
half its ticks without the clock being able to tell (64.3). An audio channel
|
||||
that must be serviced *during* that hold has never been run. 52's 1.25%..1.48%
|
||||
of a frame is a figure measured in isolation, and `32_audio_wire.py` names what
|
||||
it turns into here: audio does not merely cost clocks, it costs **darkness**.
|
||||
|
||||
**E6. Container v2** — audio interleave, per-record index, scene table. Depends
|
||||
on P6's answer and on P5's index.
|
||||
@@ -878,10 +914,12 @@ B2 blanking ─┬─ NOT blanked ─> K3's DMAC-DIRECT player is the one: 54.9%
|
||||
picks a different player.
|
||||
B1 BURST rate (NEW, 64.2) ──> which of the two K3/K4 wins, if B2 blanks
|
||||
|
||||
P6 audio DONE bar one (65): encoder gated, cadence F=11/A=14, 589.6 KB/s
|
||||
└─> P6a WHICH DELTA FORMULA? ─┬─ needs no board: MAME has :okim6258
|
||||
worth 25 dB, so it is a │ at $E92001/$E92003 (65.5)
|
||||
PRECONDITION not a tweak └─> then P6b, a container with sound in it
|
||||
P6 audio: encoder gated (65), P6a the chip's own decoder measured (66),
|
||||
P6b DLXP2 written and gated, 589.6 KB/s on the wire (67)
|
||||
└─> P6c AUDIO OUT OF THE CONTAINER ─┬─ the bytes exist, the transport
|
||||
needs no board: the transport │ exists, the lump buffer does not
|
||||
is the IPL ROM's own (66.1) └─> TWO CHANNELS AT ONCE, which is
|
||||
the one thing nothing has run
|
||||
```
|
||||
|
||||
**Read that top-left branch as the project's live question.** Everything else
|
||||
|
||||
+112
-1
@@ -1,4 +1,115 @@
|
||||
# Status & next-session handoff — end of session 34 (2026-08-25)
|
||||
# Status & next-session handoff — end of session 35 (2026-08-25)
|
||||
|
||||
## Session 35: the packed container gets sound, and the padding is drift
|
||||
|
||||
**Green light first and last: `./tools/bench/check.sh` was ALL GREEN before any
|
||||
of this (`tmp/check_s35_start.log`) and ALL GREEN after
|
||||
(`tmp/check_s35_end.log`)** — the same stages, plus one new one.
|
||||
|
||||
**FINDINGS 67. ROADMAP P6b is DONE.** `tools/encoder/dlxp.py` (DLXP2),
|
||||
`tools/encoder/pack.py --audio`, `tools/analysis/34_packed_audio.py`,
|
||||
`src/player/packed.s`, `tools/bench/prep_packed.py`, `tools/bench/packed.lua`,
|
||||
`tools/analysis/30_packed_container.py`, `tools/bench/check.sh`.
|
||||
|
||||
**NAME THE LAYER.** The container is **host arithmetic**. What ran on the
|
||||
emulated 68000 is its *video* consequence: `packed.s` now carries the third LBA
|
||||
term and fetches 120 records out of an interleaved container off a real MB89352
|
||||
volume, all 120 pixel-exact. **No audio byte has been played out of this
|
||||
container on any layer.**
|
||||
|
||||
**1. THE FORMAT.** 64-byte header; from sector 1, groups of one `A`-sector audio
|
||||
lump then `F` records. `record i = off_frm + i*rec + (i//F)*A*512`,
|
||||
`lump k = off_aud + k*(F*rec + A*512)`. **Still no index and still none needed**,
|
||||
which was the packed branch's whole claim and is the thing a second stream could
|
||||
have ended. The lump goes **before** the group it feeds; 65.3's formula put it
|
||||
after, and a stream is read forwards. On the 68000 it costs **six instructions
|
||||
once a frame** and zero parsing. The four ADPCM axes ride in the header as
|
||||
fields, not a version number.
|
||||
|
||||
**2. THE HEADLINE, AND 65.3 CALLED IT THE WRONG THING.** A lump is 7,168 B of
|
||||
*space*; eleven frames of audio is **7,161.4583… B**, so the payload alternates
|
||||
**7,161 / 7,162** and the rest is zero. A player that fed the chip the whole
|
||||
lump — which is what "14 sectors every 11 frames" invites — runs **0.09% fast**,
|
||||
and that is not waste, it is **drift**: 0.84 ms a group, **1.25 s of lip-sync
|
||||
over the game's 22.8 minutes**. What a player carries is one accumulator,
|
||||
`acc += 11*15625; n = acc//24; acc %= 24` — the same shape as `clock.i` and for
|
||||
the same reason. **Third time this tree has met it** (54, 65.3, now).
|
||||
|
||||
**3. THE GATE, AND ITS THREE NEGATIVE CONTROLS.** Nothing parses a packed
|
||||
container, so a lump one sector out does not fail, it paints. `34_packed_audio.py`
|
||||
therefore (a) **partitions the whole file** — 131 spans, no overlap, no gap,
|
||||
ending on the last byte — because a per-record read cannot see an off-by-one
|
||||
that shifts everything after it; (b) asserts a **cadence-blind player** gets
|
||||
exactly records 11..119 wrong, **109 of 120, and frames 0..10 identical either
|
||||
way**, which is how such a bug survives a rig that checks frame 0; (c) **flips
|
||||
each of the four ADPCM axes one at a time** — nibble order **−31.99 dB**, delta
|
||||
formula **−24.86 dB**, clamp 0.00, accumulator −0.49 — so the header fields are
|
||||
load-bearing rather than documentation. And the picture is gated against a
|
||||
**silent control** built from the same frames: all 120 records byte-identical.
|
||||
|
||||
**4. THE WIRE IS THE PREDICTION.** 582.0 + 7.64 = **589.6 KB/s**, which is
|
||||
65.3's figure to the tenth, because none of 66's four axes changes a byte count.
|
||||
|
||||
**RISKS IN THIS SESSION'S RESULT:**
|
||||
|
||||
- **Nothing has been heard.** The container has audio in it; no transport has
|
||||
fed it to a chip, on any layer.
|
||||
- **The lump buffer is not allocated anywhere.** 65.3 charges 14,336 B for
|
||||
double-buffering the cadence and no player holds it.
|
||||
- **Two channels have never run at once.** The audio DMA is 1.25%..1.48% of a
|
||||
frame *in isolation* (52); the video channel holds the bus (64.2/64.3).
|
||||
- **The level is still open downward** (66.3) and the loudest passage on the
|
||||
disc is still unmeasured.
|
||||
|
||||
## HANDOFF — start here
|
||||
|
||||
**THE TREE IS ALL GREEN**, session 35's stage included.
|
||||
|
||||
### The work, in the order it should be done
|
||||
|
||||
**1. THE LAST QUARTER OF P6 — audio out of the container, on the machine.**
|
||||
Everything it needs now exists and none of it has been joined up: the container
|
||||
carries the bytes (67), the transport is `adpcm.i`'s channel-3 configuration
|
||||
which worked first time (66.1), and the missing piece is **the lump buffer and
|
||||
the remainder accumulator in a player**. Do the accumulator as 67.2 states it or
|
||||
the run drifts 1.25 s over a game.
|
||||
|
||||
**2. AND THEN THE THING NEITHER HALF HAS MET: TWO CHANNELS AT ONCE.** The video
|
||||
channel holds the bus and halts the 68000 (64.3 — it costs the frame clock half
|
||||
its ticks *without the clock being able to tell*). An audio channel that has to
|
||||
be serviced during that hold has never been run. This is the interaction 52
|
||||
could not have had, and `32_audio_wire.py` names it: audio does not merely cost
|
||||
clocks, it costs **darkness**.
|
||||
|
||||
**3. THE REFILL CLIMB WITH A SECOND CONSUMER** through a real branch point
|
||||
(51.3, 55.4). The slack table is in `32_audio_wire.py`; nothing has been run.
|
||||
|
||||
**4. THE AUDIO LEVEL, which 66.3 reopened.** Measure the loudest passage on the
|
||||
disc before choosing a level.
|
||||
|
||||
### What is still BLOCKED, so it is not picked up by mistake
|
||||
|
||||
**K4 — the packed player that is on screen — is conditional on B2**, a board
|
||||
question. **E7, E4 and C1** are parked (61.8), and **P4a's wiring** is parked
|
||||
with the ring K3 deleted.
|
||||
|
||||
**The hardware list is unchanged and is the user's**: B1 (sustained AND the
|
||||
data-phase BURST rate, 64.2 — and the acceptance figure is now **589.6 KB/s**,
|
||||
not 582.0), B2 (blanking), B3 (`#EXREQ`), B4 (a byte write to a palette
|
||||
register), and session 34's fifth: play a known nibble stream on a real
|
||||
MSM6258V and record the line out.
|
||||
|
||||
### Reproducing this session
|
||||
|
||||
./tools/bench/check.sh # ALL GREEN
|
||||
python3 tools/encoder/pack.py tmp/fr_singe tmp/packed_singe.dlxp \
|
||||
--nframes 120 --audio tmp/au_singe.raw
|
||||
python3 tools/analysis/34_packed_audio.py # the DLXP2 gate on its own
|
||||
|
||||
**WHAT IS NEXT.** P6's last quarter: the container's own audio, out of a
|
||||
channel, beside the video channel that holds the bus.
|
||||
|
||||
---
|
||||
|
||||
## Session 34: the chip is asked, and the encoder was wrong on four axes
|
||||
|
||||
|
||||
+25
-2
@@ -85,6 +85,15 @@ PG_PACEON = $1891C ; 1 = obey the frame clock. 0 free-runs, which
|
||||
; tests the CHAIN without the clock in the way.
|
||||
PG_ITER = $18920 ; passes over the scene; >1 exercises the SEEK,
|
||||
; which for this container is arithmetic
|
||||
PG_CADF = $18924 ; audio cadence: frames between one lump and the
|
||||
; next, 0 in a silent container. A DLXP2 puts
|
||||
; A sectors of ADPCM in front of every F records
|
||||
; (65.3, dlxp.py) and a player that did not know
|
||||
; would read the lump AS a record -- 7,168 B of
|
||||
; audio painted across the top 28 rows of the
|
||||
; screen, which is a picture, and a gate that
|
||||
; only looked for errors would pass it.
|
||||
PG_CADA = $18928 ; ...and the sectors in a lump
|
||||
|
||||
; ---- outputs
|
||||
PG_SHOWN = $18930 ; frames displayed. Bumped AFTER bit 11 is
|
||||
@@ -308,12 +317,26 @@ pg_frame:
|
||||
|
||||
move.w #PG_R20B,CRTC_R20.l ; the write window opens
|
||||
|
||||
; LBA = PG_LBA0 + d7 * PG_RECS. Arithmetic, not a lookup: a packed record's
|
||||
; length is geometry, so this player carries no record index at all (dlxp.py).
|
||||
; LBA = PG_LBA0 + d7*PG_RECS + (d7/PG_CADF)*PG_CADA. Arithmetic, not a lookup:
|
||||
; a packed record's length is geometry and an audio lump's is a cadence, so this
|
||||
; player carries no index of either kind (dlxp.py). The third term is the whole
|
||||
; cost of DLXP2 on the video path -- a divu and a mulu, once a frame -- and it
|
||||
; is zero instructions of parsing, because the cadence is two numbers in the
|
||||
; header and not a table in the stream.
|
||||
move.l d7,d3
|
||||
move.l PG_RECS.l,d0
|
||||
mulu d0,d3 ; frames * sectors, both small
|
||||
add.l PG_LBA0.l,d3
|
||||
move.l PG_CADF.l,d0
|
||||
beq.s .nocad ; silent container: no lumps to skip
|
||||
move.l d7,d2
|
||||
divu d0,d2 ; d2.w = frame / F, the lumps passed
|
||||
andi.l #$FFFF,d2 ; divu leaves the REMAINDER in the high
|
||||
; half, and it is not zero at F=11
|
||||
move.l PG_CADA.l,d0
|
||||
mulu d0,d2
|
||||
add.l d2,d3
|
||||
.nocad:
|
||||
move.l PG_RECS.l,d4
|
||||
lea GVRAM,a1 ; IGNORED under chaining -- the channel
|
||||
; takes MAR from the array's first entry
|
||||
|
||||
@@ -91,12 +91,20 @@ print(f" round-trip: {d.nframes} records unpack and re-pack byte-identical")
|
||||
print(f" index 0 (transparency key) used {zero} times; "
|
||||
f"index 255 (black) {black:,} times in the picture")
|
||||
|
||||
kbps = d.kbps()
|
||||
# THE PICTURE'S wire, and it is the one this file is about. DLXP2 puts audio on
|
||||
# the same wire at a cadence (65.3, 67) and `d.kbps()` is both; what is asserted
|
||||
# here is that the PICTURE's share is still exactly geometry, because that is
|
||||
# 61.6's claim and a second stream is exactly the thing that could quietly
|
||||
# dilute it.
|
||||
kbps = d.video_kbps()
|
||||
geom = d.rec_bytes * d.fps / 1024
|
||||
if abs(kbps - geom) > 1e-6:
|
||||
fail.append(f"wire {kbps} != geometry {geom}")
|
||||
print(f" wire {kbps:.1f} KB/s = {d.rec_bytes:,} B x {d.fps} fps. FIXED. A codec's "
|
||||
f"bitrate is a lever and a literal frame's is geometry (61.6)")
|
||||
f"bitrate is a lever and a literal frame's is geometry (61.6)"
|
||||
+ (f"\n ...and {d.audio_kbps():.2f} KB/s of audio rides beside it on the "
|
||||
f"F={d.cad_f}/A={d.cad_a} cadence, for {d.kbps():.1f} KB/s total "
|
||||
f"(tools/analysis/34_packed_audio.py)" if d.has_audio else ""))
|
||||
print()
|
||||
|
||||
# --- 2. the picture ----------------------------------------------------------
|
||||
|
||||
@@ -134,7 +134,7 @@ print(f""" THE FLOOR OF THAT SWEEP is F={F}, A={A}: {100*w:.3f}% padding, {add:
|
||||
|
||||
=== WHAT IT DOES TO THE WIRE ===========================================""")
|
||||
|
||||
vid_kbs = d.rec_bytes * d.fps / 1024
|
||||
vid_kbs = d.video_kbps()
|
||||
for label, cad in (("F=1 (one lump a record)", f1), (f"F={F} (the floor)", best[0])):
|
||||
tot = vid_kbs + cad[5]
|
||||
print(f" {label:26s} video {vid_kbs:7.1f} + audio {cad[5]:5.2f} = {tot:7.1f} KB/s "
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DLXP2's GATE: a packed container with sound in it. ROADMAP P6b.
|
||||
|
||||
python3 tools/analysis/34_packed_audio.py [packed.dlxp] [--audio tmp/au_singe.raw]
|
||||
|
||||
WHAT THIS IS FOR. FINDINGS 65.3 did the arithmetic of putting audio in the
|
||||
packed container and wrote no byte of one; 66 measured which of sixteen decoder
|
||||
models the machine's chip runs and priced the axes at up to 25.7 dB. This is
|
||||
the container those two produce, and the reason it needs a gate of its own is
|
||||
that NOTHING PARSES A PACKED CONTAINER. A DMA channel copies bytes and has no
|
||||
opinion about them (62), so a container whose lump is one sector out does not
|
||||
fail -- it plays 512 B of picture as audio and 512 B of audio as picture, both
|
||||
of which are things, and a gate that only checked for errors would pass it.
|
||||
|
||||
THE FOUR CLAIMS, and each is checked against something that is not the writer:
|
||||
|
||||
1. THE FILE IS ITS OWN ARITHMETIC. Every byte of the container is accounted
|
||||
for by `off_frm + i*rec + (i//F)*A*512` and `off_aud + k*(F*rec + A*512)`
|
||||
with no byte left over and no byte claimed twice. A per-record read cannot
|
||||
catch an off-by-one that shifts everything after it; a partition can.
|
||||
2. THE PICTURE DID NOT MOVE. Interleaving a second stream into a format whose
|
||||
whole claim is "record i is at LBA0 + i*97" is exactly the change that can
|
||||
break that claim, so every record is compared against a re-encode of the
|
||||
same frames with `--audio` off. The silent container is the control.
|
||||
3. THE BYTES ARE THE ENCODER'S. The lumps, concatenated, are byte-exact
|
||||
against `adpcm.encode` run again on the same PCM with the same four axes.
|
||||
4. THE HEADER'S AXES ARE LOAD-BEARING. The stream decodes to the source at
|
||||
the SNR the encoder reported, and flipping any ONE of the four axes the
|
||||
header carries collapses it. A header field nothing would notice being
|
||||
wrong is a comment.
|
||||
|
||||
AND THE FINDING IT REPORTS (FINDINGS 67). The padding is not where a reader of
|
||||
65.3 would put it. A lump is A*512 B of SPACE; F frames of audio is
|
||||
F*hz/(2*fps) B, which at F=11 is 7,161.4583..., so the PAYLOAD alternates 7,161
|
||||
and 7,162 and the sector run is 7,168 either way. A player that handed the chip
|
||||
the whole lump -- the obvious implementation, and the one the phrase "14 sectors
|
||||
of audio every 11 frames" invites -- would be feeding it 6.54 B a group too
|
||||
much. That is not waste, which is what padding usually is. It is DRIFT.
|
||||
"""
|
||||
import argparse, math, os, struct, sys
|
||||
sys.path.insert(0, "tools/encoder")
|
||||
sys.path.insert(0, "tools/analysis")
|
||||
import adpcm
|
||||
import dlxp as P
|
||||
from dlxp import DLXP, SECTOR
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("container", nargs="?", default="tmp/packed_singe.dlxp")
|
||||
ap.add_argument("--audio", default="tmp/au_singe.raw")
|
||||
ap.add_argument("--silent", default="tmp/packed_singe_silent.dlxp",
|
||||
help="the control: the same frames with --audio off. Built by "
|
||||
"check.sh; skipped rather than faked when absent")
|
||||
ap.add_argument("--game-min", type=float, default=22.8,
|
||||
help="the game's running length, for what the drift comes to")
|
||||
a = ap.parse_args()
|
||||
|
||||
fails = []
|
||||
def ck(ok, msg):
|
||||
print((" OK " if ok else " FAIL ") + msg)
|
||||
if not ok:
|
||||
fails.append(msg)
|
||||
|
||||
d = DLXP(a.container) # every format invariant is checked here
|
||||
print(f"{a.container}: DLXP{d.version} {d.W}x{d.H} {d.fps}fps {d.nframes} frames, "
|
||||
f"{'AUDIO' if d.has_audio else 'SILENT'}")
|
||||
if not d.has_audio:
|
||||
sys.exit(f"{a.container} carries no audio -- this gate has nothing to check. "
|
||||
f"Build it with tools/encoder/pack.py --audio")
|
||||
|
||||
grp = d.cad_f * d.aud_hz / (2 * d.fps)
|
||||
print(f"""
|
||||
=== THE LAYOUT =========================================================
|
||||
record {d.rec_bytes:,} B = {d.rec_bytes//SECTOR} sectors, lump {d.cad_a*SECTOR:,} B = {d.cad_a} sectors,
|
||||
cadence F={d.cad_f} A={d.cad_a}, {d.n_lumps} lumps, {d.aud_bytes:,} B of ADPCM at {d.aud_hz:,} Hz
|
||||
|
||||
record i = {d.off_frm:,} + i*{d.rec_bytes:,} + (i//{d.cad_f})*{d.cad_a*SECTOR:,}
|
||||
lump k = {d.off_aud:,} + k*{d.cad_f*d.rec_bytes + d.cad_a*SECTOR:,}
|
||||
|
||||
and NEITHER of those is a lookup. A packed record's length is geometry and a
|
||||
lump's is a cadence, so DLXP2 still has no index and still needs none.""")
|
||||
|
||||
# --- 1. the file is its own arithmetic ------------------------------------
|
||||
# Every byte, partitioned. Not "does record 7 read back" -- an off-by-one that
|
||||
# shifts the whole stream reads back fine one record at a time.
|
||||
spans = [(d.frame_off(i), d.rec_bytes, f"record {i}") for i in range(d.nframes)]
|
||||
spans += [(d.lump_off(k), d.cad_a * SECTOR, f"lump {k}") for k in range(d.n_lumps)]
|
||||
spans.sort()
|
||||
pos, overlap, gap = SECTOR, [], []
|
||||
for off, n, what in spans:
|
||||
if off < pos: overlap.append(what)
|
||||
elif off > pos: gap.append((pos, off, what))
|
||||
pos = max(pos, off + n)
|
||||
ck(not overlap, f"nothing overlaps ({len(spans)} spans: {d.nframes} records "
|
||||
f"+ {d.n_lumps} lumps)" + (f" -- {overlap[:3]}" if overlap else ""))
|
||||
ck(not gap, "no byte between the header and the end belongs to nothing"
|
||||
+ (f" -- {gap[:3]}" if gap else ""))
|
||||
ck(pos == len(d.raw), f"the arithmetic ends at {pos:,} and the file is "
|
||||
f"{len(d.raw):,} B")
|
||||
ck(all(off % SECTOR == 0 for off, _, _ in spans),
|
||||
"every record and every lump starts on a 512 B sector -- 58.3/60.1's "
|
||||
"precondition survives the interleave")
|
||||
|
||||
# --- 1b. and the cadence term is load-bearing -----------------------------
|
||||
# THE FAILURE MODE THIS FORMAT HAS AND THE CODEC'S DOES NOT. A DLX record is
|
||||
# found through an index and a player that read the wrong entry gets a length
|
||||
# word that does not parse. A packed record is found by ARITHMETIC and nothing
|
||||
# parses it, so a player that drops the `(i//F)*A` term reads 97 sectors
|
||||
# starting 14 sectors early and paints them: the last 14 sectors of the previous
|
||||
# record, then 83 of this one, shifted down the screen. It is a picture. Here
|
||||
# is what the gate would be comparing if the term were missing, and it is only
|
||||
# WRONG from frame F on -- the first group is exempt, which is how an off-by-one
|
||||
# like this survives a rig that checks frame 0.
|
||||
blind = [i for i in range(d.nframes)
|
||||
if d.raw[d.off_frm + i*d.rec_bytes:d.off_frm + (i+1)*d.rec_bytes]
|
||||
!= d.record(i)]
|
||||
ck(blind == list(range(d.cad_f, d.nframes)),
|
||||
f"a cadence-blind player reads the wrong bytes for {len(blind)} of "
|
||||
f"{d.nframes} records, first at frame {blind[0] if blind else '-'} -- and "
|
||||
f"frames 0..{d.cad_f-1} are IDENTICAL either way, so frame 0 proves nothing")
|
||||
|
||||
# --- 2. the picture did not move ------------------------------------------
|
||||
if os.path.exists(a.silent):
|
||||
q = DLXP(a.silent)
|
||||
same = (q.nframes == d.nframes
|
||||
and all(q.record(i) == d.record(i) for i in range(d.nframes)))
|
||||
ck(same, f"all {d.nframes} records byte-exact against the SILENT control "
|
||||
f"({os.path.basename(a.silent)}) -- interleaving audio moved no "
|
||||
f"picture byte")
|
||||
ck(q.has_audio is False and q.off_frm == SECTOR,
|
||||
"and the control really is silent: no audio flag, record 0 at sector 1")
|
||||
else:
|
||||
print(f" SKIPPED: no silent control at {a.silent}")
|
||||
|
||||
# --- 3. the bytes are the encoder's ---------------------------------------
|
||||
if os.path.exists(a.audio):
|
||||
raw = open(a.audio, "rb").read()
|
||||
pcm = struct.unpack("<%dh" % (len(raw) // 2), raw)
|
||||
src = [max(-2048, min(2047, x >> 4)) for x in pcm]
|
||||
axes = d.decoder()
|
||||
ck(axes == adpcm.CHIP, f"the header's four axes ARE adpcm.CHIP: {axes}")
|
||||
nib = adpcm.encode(src, variant=axes["variant"], init=axes["init"],
|
||||
bits=axes["bits"])
|
||||
want = adpcm.pack(nib, order=axes["order"])[:d.aud_bytes]
|
||||
got = d.audio()
|
||||
ck(got == want, f"the {len(got):,} B the lumps carry are byte-exact against "
|
||||
f"adpcm.encode on the same PCM")
|
||||
ck(all(d.lump(k, padding=True)[len(d.lump(k)):] == b"\0" * (
|
||||
d.cad_a * SECTOR - len(d.lump(k))) for k in range(d.n_lumps)),
|
||||
"and every lump's padding is zero, so a player that overruns the payload "
|
||||
"feeds the chip silence rather than the next lump's first sample")
|
||||
|
||||
# --- 4. the header's axes are load-bearing ----------------------------
|
||||
def snr(axes_):
|
||||
rec = adpcm.decode(adpcm.unpack(got, len(src), order=axes_["order"]),
|
||||
variant=axes_["variant"], init=axes_["init"],
|
||||
bits=axes_["bits"])
|
||||
n = min(len(rec), len(src))
|
||||
e = sum((x - y) ** 2 for x, y in zip(src[:n], rec[:n]))
|
||||
s = sum(x * x for x in src[:n])
|
||||
return 10 * math.log10(s / e) if e else float("inf")
|
||||
|
||||
right = snr(axes)
|
||||
ck(right > 20.0, f"decoded on the axes the header names: {right:.2f} dB")
|
||||
print(f"\n AND EVERY AXIS IS A NEGATIVE CONTROL -- flip ONE and this is what\n"
|
||||
f" a player that ignored the header would hear:\n")
|
||||
print(f" {'axis':<12} {'header':>8} {'flipped to':>11} {'SNR':>9} cost")
|
||||
flips = [("order", "high" if axes["order"] == "low" else "low"),
|
||||
("variant", "terms" if axes["variant"] == "shift" else "shift"),
|
||||
("bits", 12 if axes["bits"] == 10 else 10),
|
||||
("init", 0 if axes["init"] else -2)]
|
||||
for k, v in flips:
|
||||
w = dict(axes); w[k] = v
|
||||
s2 = snr(w)
|
||||
print(f" {k:<12} {str(axes[k]):>8} {str(v):>11} {s2:9.2f} dB "
|
||||
f"{s2-right:+.2f} dB")
|
||||
if k in ("order", "variant"):
|
||||
ck(s2 < right - 2.0, f"axis '{k}' is load-bearing: {s2-right:+.2f} dB")
|
||||
else:
|
||||
print(f" SKIPPED: no PCM at {a.audio} -- the bytes were not re-derived")
|
||||
|
||||
# --- the finding ----------------------------------------------------------
|
||||
per = d.cad_a * SECTOR - grp
|
||||
print(f"""
|
||||
=== THE PAYLOAD IS NOT THE LUMP (FINDINGS 67) ==========================
|
||||
A lump is {d.cad_a*SECTOR:,} B of SPACE. {d.cad_f} frames of audio is {grp:,.4f} B, so the
|
||||
PAYLOAD is {P.lump_bytes(0, d.cad_f, d.fps, d.aud_hz):,} or {P.lump_bytes(2, d.cad_f, d.fps, d.aud_hz):,} -- the same remainder FINDINGS 54's frame
|
||||
clock carries, one dimension over -- and the last {per:.4f} B are zero.
|
||||
|
||||
A PLAYER THAT FED THE CHIP THE WHOLE LUMP would hand it {per:.2f} B a group it
|
||||
should not have. At {d.aud_hz:,} Hz that is {2*per/d.aud_hz*1000:.2f} ms of audio every
|
||||
{d.cad_f/d.fps:.4f} s, which is {100*per/grp:.3f}% -- and it does not average out, it ACCUMULATES:""")
|
||||
for mins in (1.0, a.game_min):
|
||||
print(f" {mins:5.1f} min of play -> {mins*60*(per/grp):.2f} s of lip-sync error")
|
||||
print(f""" so the cadence's {100*per/grp:.3f}% is not the waste figure 65.3 called it and left
|
||||
at that. It is waste ON THE WIRE and DRIFT IN THE PLAYER, and the second is
|
||||
the expensive one: {a.game_min:.1f} minutes is {a.game_min*60*(per/grp):.2f} s, which is a scene of dialogue
|
||||
arriving after the mouth that spoke it.
|
||||
|
||||
WHAT A PLAYER CARRIES INSTEAD IS ONE ACCUMULATOR, and it is three
|
||||
instructions rather than a table:
|
||||
|
||||
acc += {d.cad_f}*{d.aud_hz:,} ; = {d.cad_f*d.aud_hz:,}
|
||||
n = acc // {2*d.fps} ; the MTC for this lump's channel
|
||||
acc %= {2*d.fps}
|
||||
|
||||
which is exactly clock.i's shape (54) and for exactly the same reason: a rate
|
||||
with a denominator of {2*d.fps} cannot be a count, so it is a remainder.
|
||||
|
||||
=== THE WIRE ===========================================================
|
||||
video {d.video_kbps():7.1f} KB/s FIXED by geometry
|
||||
audio {d.audio_kbps():7.2f} KB/s the CADENCE's, padding included -- the disc moves
|
||||
whole sectors and the wire pays for the zero ones
|
||||
total {d.kbps():7.1f} KB/s ({100*(d.kbps()/d.video_kbps()-1):+.2f}%), and 65.3 predicted {589.6:.1f}
|
||||
""")
|
||||
|
||||
print(f"{'FAIL' if fails else 'OK'} 34_packed_audio: {len(fails)} failure(s)")
|
||||
sys.exit(1 if fails else 0)
|
||||
+32
-3
@@ -667,10 +667,22 @@ echo "--- session 31: the PACKED container, and the picture re-derived (FINDINGS
|
||||
# present, the way the codec's gate container is: a packed encode is 3 seconds
|
||||
# because there is no k-means in it, so there is no reason to let a stale file
|
||||
# stand between the encoder and the gate.
|
||||
# The window's audio has to exist before the container can carry it, so the
|
||||
# extraction that used to live in session 33's stage moves up here. Same seconds
|
||||
# as the frames, and that is not a convenience: an audio stream that is not the
|
||||
# same seconds as the picture is not this project's audio.
|
||||
[ -f tmp/au_singe.raw ] || python3 tools/encoder/extract_audio.py 00223 tmp/au_singe.raw 15625 539.4 10.0
|
||||
python3 tools/encoder/pack.py tmp/fr_singe tmp/packed_singe.dlxp \
|
||||
--nframes "$NF" > tmp/pack_encode.log 2>&1 \
|
||||
--nframes "$NF" --audio tmp/au_singe.raw > tmp/pack_encode.log 2>&1 \
|
||||
|| { cat tmp/pack_encode.log; exit 1; }
|
||||
grep -aE "^ (record|wire)" tmp/pack_encode.log
|
||||
grep -aE "^ (record|wire|DLXP2|the four axes|lump payload)" tmp/pack_encode.log
|
||||
# ...and the SILENT control beside it, which is what says the interleave moved no
|
||||
# picture byte. It is the same encode with one flag off, and a packed encode is
|
||||
# four seconds because there is no k-means in it, so the control is cheap enough
|
||||
# to build every run rather than reason about.
|
||||
python3 tools/encoder/pack.py tmp/fr_singe tmp/packed_singe_silent.dlxp \
|
||||
--nframes "$NF" > tmp/pack_encode_silent.log 2>&1 \
|
||||
|| { cat tmp/pack_encode_silent.log; exit 1; }
|
||||
# WHAT IS GATED. Four format invariants that a DMA channel cannot check for
|
||||
# itself -- it copies bytes and has no opinion about them (FINDINGS 62) -- and
|
||||
# the three quality claims FINDINGS 61.9 rests the whole packed branch on:
|
||||
@@ -740,7 +752,8 @@ echo "--- session 33: AUDIO -- the encoder, and what it does to the wire (FINDIN
|
||||
# is the SAME WINDOW as the frames -- 00223 from 539.4 s for 10 s -- because an
|
||||
# audio stream that is not the same seconds as the picture is not this project's
|
||||
# audio, and a gate that lets the two drift apart would never say so.
|
||||
[ -f tmp/au_singe.raw ] || python3 tools/encoder/extract_audio.py 00223 tmp/au_singe.raw 15625 539.4 10.0
|
||||
# tmp/au_singe.raw was extracted by session 31's stage, which needs it to build
|
||||
# the container.
|
||||
# There is NO ffmpeg encoder for this format -- adpcm_ima_oki is decode-only --
|
||||
# so the encoder cannot be checked against a reference. What is checked is that
|
||||
# the decoder our encoder runs in its own loop IS ffmpeg's, sample for sample.
|
||||
@@ -770,4 +783,20 @@ python3 tools/analysis/33_adpcm_model.py tmp/au_singe.raw > tmp/adpcm_model.log
|
||||
grep -aE "wrong only here|played on the chip|decoded on the encoder|headroom left" \
|
||||
tmp/adpcm_model.log
|
||||
|
||||
echo "--- session 35: DLXP2 -- a packed container with sound in it (FINDINGS 67) ---"
|
||||
# ROADMAP P6b. 65.3 did the arithmetic and wrote no byte; 66 measured which of
|
||||
# sixteen decoder models the chip runs. This is the container both produce, and
|
||||
# it needs a gate of its own because NOTHING PARSES A PACKED CONTAINER: a lump
|
||||
# one sector out does not fail, it paints 512 B of audio and plays 512 B of
|
||||
# picture, and a gate that only looked for errors would pass it.
|
||||
#
|
||||
# The picture side of it is gated twice over: here against a SILENT control
|
||||
# built from the same frames, and above by the packed player itself, which now
|
||||
# carries the `(i//F)*A` term and still gets all 120 frames pixel-exact off a
|
||||
# real volume.
|
||||
python3 tools/analysis/34_packed_audio.py tmp/packed_singe.dlxp \
|
||||
> tmp/packed_audio.log 2>&1 || { cat tmp/packed_audio.log; exit 1; }
|
||||
grep -aE "^ (OK|FAIL) |^ (order|variant|bits|init) |min of play ->" \
|
||||
tmp/packed_audio.log
|
||||
|
||||
echo "ALL GREEN"
|
||||
|
||||
+19
-2
@@ -68,6 +68,7 @@ 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_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
|
||||
@@ -107,12 +108,28 @@ local function setup()
|
||||
SP:write_u32(PG_HELD, HELD and 1 or 0)
|
||||
SP:write_u32(PG_PACEON, PACED and 1 or 0)
|
||||
SP:write_u32(PG_ITER, ITERS)
|
||||
-- DLXP2's cadence. Zero for a silent container, and the 68000 branches on the
|
||||
-- zero: a player told the wrong cadence does not fail, it reads an audio lump
|
||||
-- as a record and paints it.
|
||||
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)
|
||||
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))
|
||||
P(string.format("record %d B = %d sectors at LBA %d + i*%d, palette %s",
|
||||
local cad = ""
|
||||
if (META.cad_f or 0) > 0 then
|
||||
cad = string.format(" + (i//%d)*%d", META.cad_f, META.cad_a)
|
||||
end
|
||||
P(string.format("record %d B = %d sectors at LBA %d + i*%d%s, palette %s",
|
||||
META.rec_bytes, META.rec_sectors, META.lba0,
|
||||
META.rec_sectors, META.palette_last == 1 and "LAST" or "FIRST"))
|
||||
META.rec_sectors, cad,
|
||||
META.palette_last == 1 and "LAST" or "FIRST"))
|
||||
if (META.cad_f or 0) > 0 then
|
||||
P(string.format("DLXP2: %d B of ADPCM at %d Hz rides in %d lumps of %d sectors, "
|
||||
.."one in front of every %d records -- the third term above is the "
|
||||
.."whole cost of it on the video path",
|
||||
META.aud_bytes, META.aud_hz, META.n_lumps, META.cad_a, META.cad_f))
|
||||
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"
|
||||
|
||||
@@ -43,15 +43,25 @@ with open(out, "w") as fh:
|
||||
("lba0", d.off_frm // SECTOR),
|
||||
("palette_last", int(d.palette_last)),
|
||||
("has_palette", int(d.has_palette)),
|
||||
# DLXP2. Zero in a silent container, and the player branches on
|
||||
# the zero rather than being built two ways.
|
||||
("cad_f", d.cad_f), ("cad_a", d.cad_a),
|
||||
("has_audio", int(d.has_audio)),
|
||||
("aud_bytes", d.aud_bytes), ("aud_hz", d.aud_hz),
|
||||
("n_lumps", d.n_lumps),
|
||||
("entries", entries)]:
|
||||
fh.write(f" {k} = {v},\n")
|
||||
fh.write("}\n")
|
||||
|
||||
print(f"{sys.argv[1]}: DLXP1 {d.W}x{d.H} {d.fps}fps {d.nframes} frames")
|
||||
print(f"{sys.argv[1]}: DLXP{d.version} {d.W}x{d.H} {d.fps}fps {d.nframes} frames"
|
||||
+ (f", AUDIO F={d.cad_f} A={d.cad_a}" if d.has_audio else ", silent"))
|
||||
print(f" record {d.rec_bytes:,} B = {recs} sectors, palette "
|
||||
f"{'LAST' if d.palette_last else 'FIRST'}, {d.pal_bytes} B")
|
||||
print(f" record i is at LBA {d.off_frm // SECTOR} + i*{recs} -- ARITHMETIC. "
|
||||
cad = (f" + (i//{d.cad_f})*{d.cad_a}" if d.has_audio else "")
|
||||
print(f" record i is at LBA {d.off_frm // SECTOR} + i*{recs}{cad} -- ARITHMETIC. "
|
||||
f"There is no index in this container and none can be needed.")
|
||||
print(f" the chain the 68000 must build: {entries} entries "
|
||||
f"({rows} rows{' + 1 palette' if d.has_palette else ''})")
|
||||
print(f" wire {d.kbps():.1f} KB/s, FIXED by geometry -> {out}")
|
||||
print(f" wire {d.video_kbps():.1f}"
|
||||
+ (f" + {d.audio_kbps():.2f} = {d.kbps():.1f}" if d.has_audio else "")
|
||||
+ f" KB/s, FIXED by geometry -> {out}")
|
||||
|
||||
+259
-14
@@ -35,19 +35,65 @@ The layout, all of it fixed, all of it verified as a picture in FINDINGS 47.2:
|
||||
chaining from one start (FINDINGS 62). A container that carried the stride
|
||||
would be 4x the size and would say nothing extra.
|
||||
|
||||
header, 32 bytes, big-endian, then zero pad to the first sector:
|
||||
header, 64 bytes, big-endian, then zero pad to the first sector:
|
||||
0 'DLXP'
|
||||
4 u16 version (1)
|
||||
4 u16 version (2)
|
||||
6 u16 flags bit 0: a per-frame palette is present
|
||||
bit 1: the palette is at the END of the record
|
||||
bit 2: an audio stream is interleaved (DLXP2)
|
||||
8 u16 width, u16 height
|
||||
12 u16 fps, u16 nframes
|
||||
16 u32 record bytes fixed, and a whole number of 512 B sectors
|
||||
20 u32 palette bytes 512 (256 GRB555+I words), or 0
|
||||
24 u32 picture bytes 49,152
|
||||
28 u32 frames offset 512 B, i.e. sector 1
|
||||
28 u32 frames offset offset of RECORD 0, lump 0 already skipped
|
||||
-- DLXP2 adds, and every field is zero in a silent container:
|
||||
32 u32 audio offset offset of LUMP 0, 512 B, i.e. sector 1
|
||||
36 u32 audio sample Hz 15,625 -- the chip's, not the disc's
|
||||
40 u32 audio bytes the ADPCM payload, padding NOT counted
|
||||
44 u16 cadence F frames between one lump and the next
|
||||
46 u16 cadence A whole sectors in a lump
|
||||
48 u16 audio format bit 0: LOW nibble first. bit 1: the OKI
|
||||
datasheet's per-term delta ('terms')
|
||||
50 u16 audio clamp bits where the accumulator saturates, 10 or 12
|
||||
52 i16 audio init the accumulator at PLAY, -2 on this chip
|
||||
54 u16 reserved (0)
|
||||
56 u32 reserved (0)
|
||||
60 u32 reserved (0)
|
||||
|
||||
then nframes FIXED-SIZE records, each 49,664 B = 97 sectors EXACTLY.
|
||||
then, from sector 1, GROUPS: one audio lump of A sectors, then F records.
|
||||
A record is 49,664 B = 97 sectors EXACTLY and a lump is A*512 B EXACTLY, so
|
||||
|
||||
record i is at off_frm + i*rec_bytes + (i//F)*A*512
|
||||
lump k is at off_aud + k*(F*rec_bytes + A*512)
|
||||
|
||||
-- still arithmetic, still no index, still nothing walked.
|
||||
|
||||
WHY AUDIO IS A CADENCE AND NOT A FIELD IN THE RECORD (FINDINGS 65.3). 15,625
|
||||
samples a second, two to a byte, is 651.0416... B per 12 fps slot, and the dots
|
||||
are the whole problem: put slot i's audio in record i and records become
|
||||
VARIABLE LENGTH, which needs an index, which ends the format. A fixed cadence
|
||||
of F frames per A sectors keeps `LBA(i)` arithmetic and pays instead in padding,
|
||||
and the padding is a rational-approximation problem whose answer is not the
|
||||
obvious cadence: F=1 wastes 57.3% of every audio sector and F=11, A=14 wastes
|
||||
0.09%.
|
||||
|
||||
AND THE PADDING IS NOT WHERE THE BYTES ARE (FINDINGS 67). A lump is A*512 B of
|
||||
SPACE and it does NOT carry A*512 B of audio: F frames need F*15625/24 B, which
|
||||
is 7,161.4583... at F=11, so a lump's PAYLOAD alternates 7,161 and 7,162 by the
|
||||
same remainder arithmetic FINDINGS 54's frame clock carries, and the rest of the
|
||||
sector is zero. A player that fed the chip the whole lump would be handing it
|
||||
6.54 B a group it should not have -- 0.09% too much audio, which is not waste,
|
||||
it is DRIFT: 0.83 ms a group, 1.2 s of lip-sync over the game's 22 minutes. So
|
||||
`lump_bytes(k)` below is the format, not a convenience, and a player computes it
|
||||
with one accumulator: `acc += F*aud_hz; n = acc // (2*fps); acc %= 2*fps`.
|
||||
|
||||
THE FOUR ADPCM AXES ARE IN THE HEADER BECAUSE GETTING ONE WRONG COSTS 25 dB
|
||||
(FINDINGS 66). Nibble order, delta formula, clamp width and the accumulator's
|
||||
value at PLAY are properties of the DECODER, and a container encoded for one
|
||||
decoder and played on another comes out with the noise louder than the signal.
|
||||
They are four fields rather than a version number so that a mismatch is legible
|
||||
in a hexdump rather than inferred from a container's age.
|
||||
|
||||
THERE IS NO RECORD INDEX AND NO LENGTH WORD, and that is the difference DLX4's
|
||||
index was invented for (49.3): a codec record's length is content-dependent, so
|
||||
@@ -68,12 +114,78 @@ import struct
|
||||
import numpy as np
|
||||
|
||||
MAGIC = b"DLXP"
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
SECTOR = 512 # same rule and the same reason as dlx.SECTOR
|
||||
PAL_BYTES = 512 # 256 entries, one GRB555+I word each
|
||||
HDR_BYTES = 32
|
||||
HDR_BYTES = 64
|
||||
FLAG_PALETTE = 1 << 0
|
||||
FLAG_PALETTE_LAST = 1 << 1
|
||||
FLAG_AUDIO = 1 << 2
|
||||
|
||||
# The two ADPCM axes that are booleans. The other two -- the clamp width and
|
||||
# the accumulator at PLAY -- are numbers and get their own fields, because
|
||||
# encoding them as flags would mean this file deciding which values are legal.
|
||||
AFMT_ORDER_LOW = 1 << 0
|
||||
AFMT_VARIANT_TERMS = 1 << 1
|
||||
|
||||
# The default cadence, and it is a MEASUREMENT rather than a taste (FINDINGS
|
||||
# 65.3): the sweep's floor is F=81 and costs 91,136 B more of player RAM for the
|
||||
# last 0.09 of a point of padding, on a machine where two record buffers already
|
||||
# want 99,328 B.
|
||||
CADENCE_F = 11
|
||||
|
||||
|
||||
def audio_format(variant, order, bits, init):
|
||||
"""adpcm.py's four axes -> the three header fields that carry them.
|
||||
|
||||
This file does not import adpcm.py and must not: a container format that
|
||||
depended on an encoder would be a format that could not be read without one.
|
||||
What it carries is the DESCRIPTION, and `tools/analysis/34_packed_audio.py`
|
||||
is what checks the description against the encoder that wrote the bytes.
|
||||
"""
|
||||
if variant not in ("shift", "terms") or order not in ("high", "low"):
|
||||
raise ValueError(f"unknown ADPCM decoder ({variant!r}, {order!r})")
|
||||
fmt = ((AFMT_ORDER_LOW if order == "low" else 0)
|
||||
| (AFMT_VARIANT_TERMS if variant == "terms" else 0))
|
||||
return fmt, int(bits), int(init)
|
||||
|
||||
|
||||
def audio_decoder(fmt, bits, init):
|
||||
"""The inverse: what a player, or a gate, has to run to hear the bytes."""
|
||||
return dict(variant="terms" if fmt & AFMT_VARIANT_TERMS else "shift",
|
||||
order="low" if fmt & AFMT_ORDER_LOW else "high",
|
||||
bits=int(bits), init=int(init))
|
||||
|
||||
|
||||
def cadence(fps, hz, F=CADENCE_F):
|
||||
"""(F, A): F frames of audio rounded UP to whole sectors.
|
||||
|
||||
A*512 must cover F frames or the chip runs dry, so A is a ceiling and the
|
||||
excess is padding the wire pays for and nothing plays. Integer arithmetic
|
||||
throughout: the whole point of 65.3 is that this ratio has a remainder, and
|
||||
a float here would hide the case where it does not.
|
||||
"""
|
||||
num, den = F * hz, 2 * fps # bytes per group = num/den
|
||||
return F, -(-num // (den * SECTOR))
|
||||
|
||||
|
||||
def lump_bytes(k, F, fps, hz, total=None):
|
||||
"""The PAYLOAD of lump k -- what is handed to the chip, padding excluded.
|
||||
|
||||
Exact, and exactness is the finding (FINDINGS 67): floor((k+1)*F*hz/(2*fps))
|
||||
- floor(k*F*hz/(2*fps)) alternates 7,161 and 7,162 at F=11, and a player
|
||||
that fed the chip the whole A*512 B lump instead would run 0.09% fast --
|
||||
1.2 s of lip-sync over 22 minutes.
|
||||
"""
|
||||
den = 2 * fps
|
||||
n = ((k + 1) * F * hz) // den - (k * F * hz) // den
|
||||
if total is not None: # the last lump is short, not padded
|
||||
n = max(0, min(n, total - (k * F * hz) // den))
|
||||
return n
|
||||
|
||||
|
||||
def n_lumps(nframes, F):
|
||||
return -(-nframes // F)
|
||||
|
||||
|
||||
def pack_picture(idx):
|
||||
@@ -111,23 +223,73 @@ def record_bytes(W, H, palette=True):
|
||||
return n
|
||||
|
||||
|
||||
def write(path, W, H, fps, frames, palette_last=False):
|
||||
"""`frames` is a sequence of (palette_words_bytes | None, picture_bytes)."""
|
||||
def write(path, W, H, fps, frames, palette_last=False, audio=None):
|
||||
"""`frames` is a sequence of (palette_words_bytes | None, picture_bytes).
|
||||
|
||||
`audio`, when given, is a dict: `data` the packed ADPCM bytes, `hz` the
|
||||
chip's sample rate, `variant`/`order`/`bits`/`init` the four axes FINDINGS
|
||||
66 measured, and optionally `F`. The stream is CUT UP HERE and nowhere
|
||||
else, by `lump_bytes`, for the same reason `pack_picture` is the one place
|
||||
the interleave lives: a second copy of a layout rule is a place the layout
|
||||
can drift.
|
||||
"""
|
||||
frames = list(frames)
|
||||
pal_b = PAL_BYTES if frames and frames[0][0] is not None else 0
|
||||
pic_b = W * H
|
||||
rec_b = record_bytes(W, H, palette=bool(pal_b))
|
||||
flags = ((FLAG_PALETTE if pal_b else 0)
|
||||
| (FLAG_PALETTE_LAST if palette_last and pal_b else 0))
|
||||
| (FLAG_PALETTE_LAST if palette_last and pal_b else 0)
|
||||
| (FLAG_AUDIO if audio else 0))
|
||||
|
||||
if audio:
|
||||
hz = int(audio["hz"])
|
||||
F, A = cadence(fps, hz, audio.get("F", CADENCE_F))
|
||||
au = audio["data"]
|
||||
fmt, bits, init = audio_format(audio["variant"], audio["order"],
|
||||
audio["bits"], audio["init"])
|
||||
lumps = []
|
||||
got = 0
|
||||
for k in range(n_lumps(len(frames), F)):
|
||||
n = lump_bytes(k, F, fps, hz, total=len(au))
|
||||
if n > A * SECTOR:
|
||||
raise ValueError(f"lump {k} needs {n} B and the cadence gives "
|
||||
f"{A*SECTOR}")
|
||||
lumps.append(au[got:got + n].ljust(A * SECTOR, b"\0"))
|
||||
got += n
|
||||
# A container whose audio runs out before its pictures do is a container
|
||||
# that goes silent part way through, which is exactly the failure a
|
||||
# writer should refuse rather than a player discover.
|
||||
if got < len(au):
|
||||
raise ValueError(f"{len(au)-got} B of audio have no lump to ride in "
|
||||
f"-- {len(frames)} frames hold {got} B")
|
||||
if got < min(len(au), (len(frames) * hz) // (2 * fps)):
|
||||
raise ValueError(f"the audio stream is short: {len(au)} B for "
|
||||
f"{len(frames)} frames at {fps} fps")
|
||||
off_aud, off_frm = SECTOR, SECTOR + A * SECTOR
|
||||
atail = struct.pack(">IIIHHHHhHII", off_aud, hz, len(au), F, A,
|
||||
fmt, bits, init, 0, 0, 0)
|
||||
else:
|
||||
F = A = 0
|
||||
lumps = []
|
||||
off_aud, off_frm = 0, SECTOR
|
||||
atail = struct.pack(">IIIHHHHhHII", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
||||
|
||||
hdr = (MAGIC + struct.pack(">HHHHHH", VERSION, flags, W, H, fps, len(frames))
|
||||
+ struct.pack(">III", rec_b, pal_b, pic_b)
|
||||
+ struct.pack(">I", SECTOR))
|
||||
+ struct.pack(">IIII", rec_b, pal_b, pic_b, off_frm) + atail)
|
||||
assert len(hdr) == HDR_BYTES, len(hdr)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(hdr + b"\0" * (SECTOR - HDR_BYTES))
|
||||
for i, (pw, pic) in enumerate(frames):
|
||||
if len(pic) != pic_b or (pal_b and len(pw) != pal_b):
|
||||
raise ValueError(f"frame {i}: record parts are the wrong size")
|
||||
# The lump goes BEFORE the group it feeds, which is the one place
|
||||
# this format makes a choice audio forced on it. 65.3's formula put
|
||||
# it after; a stream is read forwards, so bytes that arrive after
|
||||
# the slot they belong to are bytes a player has to have fetched
|
||||
# early anyway. Placing it first makes the fetch order the play
|
||||
# order and costs one lump of offset in the arithmetic.
|
||||
if audio and i % F == 0:
|
||||
fh.write(lumps[i // F])
|
||||
rec = pic if not pal_b else (pic + pw if palette_last else pw + pic)
|
||||
fh.write(rec)
|
||||
return rec_b
|
||||
@@ -154,6 +316,32 @@ class DLXP:
|
||||
raise ValueError(f"{path}: DLXP version {self.version}")
|
||||
self.has_palette = bool(self.flags & FLAG_PALETTE)
|
||||
self.palette_last = bool(self.flags & FLAG_PALETTE_LAST)
|
||||
self.has_audio = bool(self.flags & FLAG_AUDIO)
|
||||
(self.off_aud, self.aud_hz, self.aud_bytes, self.cad_f, self.cad_a,
|
||||
self.aud_fmt, self.aud_bits, self.aud_init,
|
||||
_r0, _r1, _r2) = struct.unpack(">IIIHHHHhHII", b[32:64])
|
||||
if not self.has_audio:
|
||||
if any((self.off_aud, self.aud_hz, self.aud_bytes, self.cad_f,
|
||||
self.cad_a, self.aud_fmt, self.aud_bits, self.aud_init)):
|
||||
raise ValueError(f"{path}: silent container with audio fields set")
|
||||
else:
|
||||
if (self.cad_f, self.cad_a) != cadence(self.fps, self.aud_hz,
|
||||
self.cad_f):
|
||||
raise ValueError(f"{path}: cadence F={self.cad_f} A={self.cad_a} "
|
||||
f"does not cover {self.cad_f} frames of "
|
||||
f"{self.aud_hz} Hz audio at {self.fps} fps")
|
||||
if self.off_aud != SECTOR or self.off_aud % SECTOR:
|
||||
raise ValueError(f"{path}: audio starts at {self.off_aud}")
|
||||
if self.aud_bits not in (10, 12):
|
||||
raise ValueError(f"{path}: ADPCM clamp is {self.aud_bits} bits")
|
||||
# The stream has to fill the groups it is cut into, or the last
|
||||
# frames of the scene play in silence and nothing says so.
|
||||
want = sum(lump_bytes(k, self.cad_f, self.fps, self.aud_hz)
|
||||
for k in range(n_lumps(self.nframes, self.cad_f)))
|
||||
if not (want - self.cad_f * self.aud_hz // (2 * self.fps)
|
||||
<= self.aud_bytes <= want):
|
||||
raise ValueError(f"{path}: {self.aud_bytes} B of audio for "
|
||||
f"{self.nframes} frames, geometry wants {want}")
|
||||
if self.pic_bytes != self.W * self.H:
|
||||
raise ValueError(f"{path}: picture is {self.pic_bytes} B for "
|
||||
f"{self.W}x{self.H} -- the packed layout is 1.0 B/px")
|
||||
@@ -167,12 +355,56 @@ class DLXP:
|
||||
if self.off_frm % SECTOR or self.rec_bytes % SECTOR:
|
||||
raise ValueError(f"{path}: not sector-aligned -- stream at "
|
||||
f"{self.off_frm}, record {self.rec_bytes}")
|
||||
want = self.off_frm + self.nframes * self.rec_bytes
|
||||
if self.off_frm != (SECTOR + (self.cad_a * SECTOR if self.has_audio
|
||||
else 0)):
|
||||
raise ValueError(f"{path}: record 0 is at {self.off_frm}, and a "
|
||||
f"{self.cad_a}-sector lump comes before it")
|
||||
want = (self.off_frm + self.nframes * self.rec_bytes
|
||||
+ (n_lumps(self.nframes, self.cad_f) - 1) * self.cad_a * SECTOR
|
||||
if self.has_audio else
|
||||
self.off_frm + self.nframes * self.rec_bytes)
|
||||
if len(b) != want:
|
||||
raise ValueError(f"{path}: {len(b)} bytes, geometry says {want}")
|
||||
|
||||
def record(self, i):
|
||||
def frame_off(self, i):
|
||||
"""The arithmetic, and the ONE place it is written on the host side --
|
||||
src/player/packed.s is the other, in six instructions, and
|
||||
tools/analysis/34_packed_audio.py is what makes the two agree."""
|
||||
if not (0 <= i < self.nframes):
|
||||
raise IndexError(i)
|
||||
o = self.off_frm + i * self.rec_bytes
|
||||
return o + (i // self.cad_f) * self.cad_a * SECTOR if self.has_audio else o
|
||||
|
||||
def lump_off(self, k):
|
||||
if not self.has_audio or not (0 <= k < self.n_lumps):
|
||||
raise IndexError(k)
|
||||
return self.off_aud + k * (self.cad_f * self.rec_bytes
|
||||
+ self.cad_a * SECTOR)
|
||||
|
||||
@property
|
||||
def n_lumps(self):
|
||||
return n_lumps(self.nframes, self.cad_f) if self.has_audio else 0
|
||||
|
||||
def lump(self, k, padding=False):
|
||||
"""Lump k's PAYLOAD -- what the chip is fed. `padding=True` returns the
|
||||
whole A*512 B sector run instead, which is what the disc moves and what
|
||||
a player must NOT hand to the chip (FINDINGS 67)."""
|
||||
o = self.lump_off(k)
|
||||
if padding:
|
||||
return self.raw[o:o + self.cad_a * SECTOR]
|
||||
n = lump_bytes(k, self.cad_f, self.fps, self.aud_hz, total=self.aud_bytes)
|
||||
return self.raw[o:o + n]
|
||||
|
||||
def audio(self):
|
||||
"""The whole ADPCM stream, reassembled from its lumps."""
|
||||
return b"".join(self.lump(k) for k in range(self.n_lumps))
|
||||
|
||||
def decoder(self):
|
||||
"""The four axes the bytes were encoded for, as adpcm.decode's kwargs."""
|
||||
return audio_decoder(self.aud_fmt, self.aud_bits, self.aud_init)
|
||||
|
||||
def record(self, i):
|
||||
o = self.frame_off(i)
|
||||
return self.raw[o:o + self.rec_bytes]
|
||||
|
||||
def _split(self, i):
|
||||
@@ -214,5 +446,18 @@ class DLXP:
|
||||
"""(H,W,3) uint8 -- the frame as the display produces it."""
|
||||
return self.palette_rgb(i)[self.indices(i)]
|
||||
|
||||
def video_kbps(self):
|
||||
"""The picture on the wire. FIXED by geometry -- there is no lever."""
|
||||
return self.rec_bytes * self.fps / 1024
|
||||
|
||||
def audio_kbps(self):
|
||||
"""What the CADENCE costs, padding included, which is the honest figure:
|
||||
the disc moves whole sectors and the wire pays for the ones that are
|
||||
zero as well as the ones that are audio (FINDINGS 65.3)."""
|
||||
if not self.has_audio:
|
||||
return 0.0
|
||||
return self.cad_a * SECTOR / self.cad_f * self.fps / 1024
|
||||
|
||||
def kbps(self):
|
||||
return self.nframes * self.rec_bytes / (self.nframes / self.fps) / 1024
|
||||
"""Both, over the scene's own duration."""
|
||||
return self.video_kbps() + self.audio_kbps()
|
||||
|
||||
+66
-3
@@ -3,7 +3,7 @@
|
||||
|
||||
python3 tools/encoder/pack.py <frames_dir> <out.dlxp> [--fps 12]
|
||||
[--nframes N] [--palette-last] [--no-palette]
|
||||
[--scene-palette]
|
||||
[--scene-palette] [--audio tmp/au_singe.raw]
|
||||
|
||||
WHAT IS NOT HERE IS THE POINT. No VQ, no codebooks, no mode map, no rate
|
||||
control, no `lam`, no leaky bucket, no span geometry -- `encode.py` is 452 lines
|
||||
@@ -25,6 +25,15 @@ as "a direction, not the player's number" (risk 2 in the session 30 handoff).
|
||||
`vq.frame_palette` is what actually ships it: 254 colours, index 0 held free for
|
||||
the transparency key, black at 255. `tools/analysis/30_packed_container.py`
|
||||
re-derives the figure against this and charges the GRB555 word on top.
|
||||
|
||||
AND `--audio` MAKES IT A DLXP2, WHICH IS THE ONE PLACE THE FOUR ADPCM AXES ARE
|
||||
CHOSEN. It encodes for `adpcm.CHIP` -- the datasheet's per-term delta, the LOW
|
||||
nibble of a byte first, a 10-bit clamp, the accumulator at -2 -- because that is
|
||||
the set FINDINGS 66 measured out of the machine's own chip through the machine's
|
||||
own DMA channel, and encoding for any other set costs up to 25.7 dB. It does
|
||||
NOT use adpcm.py's module defaults, which are ffmpeg's on purpose so that
|
||||
tools/bench/verify_adpcm.py stays a check against an independent implementation.
|
||||
The axes go in the header, so a player never has to be told.
|
||||
"""
|
||||
import argparse, glob, os, sys
|
||||
import numpy as np
|
||||
@@ -35,6 +44,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "bench"))
|
||||
import vq as VQ
|
||||
import dlxp as P
|
||||
import adpcm
|
||||
from dlxload import pack_palette
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
@@ -52,6 +62,14 @@ ap.add_argument("--no-palette", action="store_true",
|
||||
ap.add_argument("--scene-palette", action="store_true",
|
||||
help="one palette for the whole scene, repeated in every record "
|
||||
"-- the CONTROL for 61.9's per-frame claim")
|
||||
ap.add_argument("--audio", default=None,
|
||||
help="raw s16le mono at --audio-hz, from extract_audio.py. "
|
||||
"Makes the output a DLXP2: the stream is encoded for "
|
||||
"adpcm.CHIP and interleaved on the 65.3 cadence")
|
||||
ap.add_argument("--audio-hz", type=int, default=15625,
|
||||
help="the CHIP's rate, and the rate the .raw was resampled to")
|
||||
ap.add_argument("--cadence", type=int, default=P.CADENCE_F,
|
||||
help="frames between audio lumps (65.3's sweep picks 11)")
|
||||
a = ap.parse_args()
|
||||
|
||||
files = sorted(glob.glob(f"{a.frames_dir}/f*.png"))
|
||||
@@ -95,7 +113,35 @@ for n, src in enumerate(rgb):
|
||||
psnr_pal.append(VQ.psnr(src, pal[idx]))
|
||||
recs.append((palw, P.pack_picture(idx).tobytes()))
|
||||
|
||||
rec_b = P.write(a.out, W, H, a.fps, recs, palette_last=a.palette_last)
|
||||
# THE AUDIO, AND IT IS ENCODED FOR THE CHIP AND NOT FOR ffmpeg. The source is
|
||||
# s16le because that is what ffmpeg resamples to; the chip's word is 12 bits
|
||||
# signed, so the shift is a requantisation and not a format conversion, and it
|
||||
# is the same one tools/bench/verify_adpcm.py makes.
|
||||
audio = None
|
||||
if a.audio:
|
||||
import struct as _struct
|
||||
raw = open(a.audio, "rb").read()
|
||||
pcm = _struct.unpack("<%dh" % (len(raw) // 2), raw)
|
||||
src12 = [max(-2048, min(2047, x >> 4)) for x in pcm]
|
||||
need = len(files) * a.audio_hz // (2 * a.fps)
|
||||
nib = adpcm.encode(src12, variant=adpcm.CHIP["variant"],
|
||||
init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"])
|
||||
data = adpcm.pack(nib, order=adpcm.CHIP["order"])[:need]
|
||||
# What the encoder thinks it wrote, checked by DECODING it with the same
|
||||
# four axes -- the encoder runs its decoder inside its own loop, so this is
|
||||
# not circular in the way it looks: it is the PACKED bytes going back
|
||||
# through unpack(), which is where a nibble-order slip would land.
|
||||
back = adpcm.decode(adpcm.unpack(data, len(src12), order=adpcm.CHIP["order"]),
|
||||
variant=adpcm.CHIP["variant"], init=adpcm.CHIP["init"],
|
||||
bits=adpcm.CHIP["bits"])
|
||||
ref = src12[:len(back)]
|
||||
e = [(x - y) ** 2 for x, y in zip(ref, back)]
|
||||
sig = sum(x * x for x in ref)
|
||||
snr = 10 * np.log10(sig / sum(e)) if sum(e) else float("inf")
|
||||
audio = dict(data=data, hz=a.audio_hz, F=a.cadence, **adpcm.CHIP)
|
||||
|
||||
rec_b = P.write(a.out, W, H, a.fps, recs, palette_last=a.palette_last,
|
||||
audio=audio)
|
||||
d = P.DLXP(a.out) # re-read: every invariant is checked
|
||||
if d.nframes != len(recs):
|
||||
sys.exit("writer and reader disagree about the frame count")
|
||||
@@ -103,10 +149,27 @@ if d.nframes != len(recs):
|
||||
kind = ("scene palette" if a.scene_palette else "per-frame palette")
|
||||
if a.no_palette:
|
||||
kind += ", NONE in the record"
|
||||
print(f"{a.out}: DLXP1 {W}x{H} {a.fps}fps {d.nframes} frames, {kind}"
|
||||
print(f"{a.out}: DLXP{P.VERSION} {W}x{H} {a.fps}fps {d.nframes} frames, {kind}"
|
||||
f"{', palette LAST' if a.palette_last else ''}")
|
||||
print(f" record {rec_b:,} B = {rec_b // P.SECTOR} sectors exactly, "
|
||||
f"file {os.path.getsize(a.out):,} B")
|
||||
if d.has_audio:
|
||||
print(f" DLXP2: audio {d.aud_bytes:,} B at {d.aud_hz:,} Hz, SNR {snr:.2f} dB, "
|
||||
f"cadence F={d.cad_f} A={d.cad_a} ({d.n_lumps} lumps)")
|
||||
print(f" the four axes, in the header: "
|
||||
+ ", ".join(f"{k}={v}" for k, v in d.decoder().items()))
|
||||
# STEADY STATE, not the file: the last lump of a 120-frame window feeds 4
|
||||
# frames out of 11 and occupies 14 sectors either way, so a whole-file
|
||||
# padding figure is a boundary effect of the WINDOW and would change with
|
||||
# its length. 65.3's 0.09% is the cadence's, and the cadence is the thing.
|
||||
grp = d.cad_f * d.aud_hz / (2 * d.fps)
|
||||
print(f" lump payload {P.lump_bytes(0, d.cad_f, d.fps, d.aud_hz):,}.."
|
||||
f"{P.lump_bytes(2, d.cad_f, d.fps, d.aud_hz):,} B of {d.cad_a*P.SECTOR:,} "
|
||||
f"-- {100*(d.cad_a*P.SECTOR-grp)/grp:.3f}% padding steady state, and the "
|
||||
f"payload is NOT the lump (FINDINGS 67)")
|
||||
print(f" wire {d.video_kbps():.1f} + {d.audio_kbps():.2f} = {d.kbps():.1f} KB/s "
|
||||
f"-- FIXED by geometry, there is no lever")
|
||||
else:
|
||||
print(f" wire {d.kbps():.1f} KB/s -- FIXED by geometry, there is no lever")
|
||||
print(f" palette-domain PSNR vs the 24-bit source: "
|
||||
f"{np.mean(psnr_pal):.2f} dB (min {np.min(psnr_pal):.2f})")
|
||||
|
||||
Reference in New Issue
Block a user