Compare commits
32
Commits
31c4c1aba1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91e2e9803d | ||
|
|
3b7f2af27e | ||
|
|
ba966efe7e | ||
|
|
8b5f51704c | ||
|
|
ab15c0749a | ||
|
|
191f2b47bb | ||
|
|
e3778f62b0 | ||
|
|
6dd3fb3597 | ||
|
|
f925a1dd9a | ||
|
|
6f698ca226 | ||
|
|
f1007a0dbc | ||
|
|
07f36c2af9 | ||
|
|
1be428c270 | ||
|
|
8800d8f8c0 | ||
|
|
621a5bb457 | ||
|
|
5921fab118 | ||
|
|
e935d8661c | ||
|
|
00232bb22b | ||
|
|
2676f3b835 | ||
|
|
c419251266 | ||
|
|
7179339bd2 | ||
|
|
ed172c2da2 | ||
|
|
e565dfbbab | ||
|
|
2f9f5cc995 | ||
|
|
b49bbdc939 | ||
|
|
c520a89e14 | ||
|
|
c5ca56330e | ||
|
|
7d365b3ff5 | ||
|
|
06b98d4b47 | ||
|
|
29eb78a599 | ||
|
|
3641f37e28 | ||
|
|
cb05e77a42 |
@@ -11,5 +11,7 @@ roms/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.dlx
|
*.dlx
|
||||||
|
*.dlxp
|
||||||
a.out
|
a.out
|
||||||
tmp/
|
tmp/
|
||||||
|
tools/bench/c68k/c68k_bench
|
||||||
|
|||||||
@@ -1,90 +1,476 @@
|
|||||||
# Dragon's Lair — Sharp X68000 port
|
# Dragon's Lair: Sharp X68000 port
|
||||||
|
|
||||||
Porting Dragon's Lair to a stock X68000 (68000 @ 10MHz, 2MB, SASI/SCSI).
|
Porting Dragon's Lair to a stock X68000 (68000 @ 10MHz, 2MB, SCSI).
|
||||||
|
|
||||||
This is fundamentally a **video codec problem**, not a game-logic problem: the
|
The game logic is a scene table with branching input windows. The difficulty is
|
||||||
game logic is a scene table with branching input windows; the difficulty is
|
pushing ~22 minutes of Don Bluth animation through a 10MHz 68000, and it is a
|
||||||
pushing ~22 minutes of Don Bluth animation through a 10MHz 68000.
|
**delivery** problem rather than a compression one — bus cycles and bytes off a
|
||||||
|
disc.
|
||||||
|
|
||||||
**Green-light check:** `./tools/bench/check.sh` (~3 min, needs the Blu-ray
|
**The live design has no decoder in it.** The disc holds packed 8-bit frames and
|
||||||
mounted) re-runs both display regression tests, the rate-control drift test, the
|
a DMA channel walks them into GVRAM with the CPU halted. Measured on the machine,
|
||||||
display-path coherency counterexample and a 120-frame 68000 decode, then prints
|
decoding 37,585 bytes costs more than not decoding 49,152: at the 9 clk/B
|
||||||
`ALL GREEN`.
|
dual-address floor the codec is 110.4% of a 12 fps frame and the decoder-free
|
||||||
|
packed player is 54.9%.
|
||||||
|
|
||||||
## Read first
|
A working codec is in the tree and is **parked** (USER DECISION). Both branches
|
||||||
- **`docs/FINDINGS.md`** — measured hardware facts, content statistics, codec
|
are described below; the packed one is where the work goes.
|
||||||
decision, and a section on measurement traps that produced three separate
|
|
||||||
false results. Read §4 before trusting any pipeline number.
|
## What it looks like
|
||||||
- **`docs/STATUS.md`** — current state, working setup, blockers, next steps.
|
|
||||||
**Start here.** It also lists what has been explicitly abandoned, so old ideas
|
**The packed player, running off a real volume, with sound.** `src/player/packed.s`
|
||||||
do not get re-proposed.
|
on an emulated stock X68000: the 68000 brings up its own display, builds its own
|
||||||
- **`docs/BENCHMARK.md`** — how to measure the storage subsystem, and why a
|
193-entry DMA chain, keeps its own frame clock off the CRTC's V-DISP, and fetches
|
||||||
|
every record itself with `READ(10)` from a real MB89352. **There is no decoder in
|
||||||
|
the machine.** Source on the left; on the right, MAME's own snapshots of what the
|
||||||
|
68000 put on screen. **120 of 120 frames are pixel-exact against the container**,
|
||||||
|
every one compared, and the clip refuses to build otherwise.
|
||||||
|
|
||||||
|
<video src="docs/img/packed-player.webm" controls muted loop width="100%"></video>
|
||||||
|
|
||||||
|
[`docs/img/packed-player.webm`](docs/img/packed-player.webm) (120 frames, 12 fps,
|
||||||
|
VP9, with the chip's own audio)
|
||||||
|
|
||||||
|
**The clip is a composite of two runs and that is the point.** The picture is the
|
||||||
|
gate run — paced at half rate so each snapshot lands inside the write window,
|
||||||
|
cycle stealing, no sound. The sound is the audio run — the same container at
|
||||||
|
12 fps with the MSM6258 on channel 3, captured by MAME off the speaker, cut at
|
||||||
|
the first sample the chip produced and gated sample-exact against lump 0 before
|
||||||
|
anything is written. **What it is not is a real-time capture of the shipping
|
||||||
|
configuration**, which at this container's own burst rate would show a blank
|
||||||
|
layer for 99.5% of every slot (FINDINGS 64.2, below).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The still is the frame whose PSNR is closest to the mean over the gated window,
|
||||||
|
so it is not the flattering one: **33.13 dB against the window's mean of 33.10**,
|
||||||
|
which is the packed container's predicted GRB555 figure to the digit.
|
||||||
|
|
||||||
|
**Name the layer.** Everything here is **emulated**: MAME 0.277 `x68000`,
|
||||||
|
`-bios ipl10`, stock 10 MHz / 2 MB, with every 68000 cycle figure cross-checked
|
||||||
|
on a second CPU core (px68k's C68K) and the display path cross-checked against
|
||||||
|
px68k's own `gvram.c`. **Nothing in this project has run on real hardware yet.**
|
||||||
|
|
||||||
|
## How the packed player works
|
||||||
|
|
||||||
|
**256-colour GVRAM throws away the high byte of every word a CPU writes**, so a
|
||||||
|
picture byte normally costs two disc bytes. **CRTC R20 bit 11 turns the masking
|
||||||
|
off**, and with the two 256-colour pages scrolled apart one word carries two
|
||||||
|
pixels (FINDINGS 46/47). That makes a frame 1.0 B/pixel — 49,152 bytes — and it
|
||||||
|
makes the frame a *literal*: no codebook, no recursion, no decode.
|
||||||
|
|
||||||
|
A record is that picture plus its own 256-entry palette: **49,664 bytes, which is
|
||||||
|
97 sectors exactly**. A packed record's length is geometry, so **the container
|
||||||
|
carries no index and no length word** — record *i* is at `off + i*rec` and a seek
|
||||||
|
is arithmetic. A DMA channel fills GVRAM in buffer mode straight off the disc and
|
||||||
|
**walks the 1,024-byte line stride itself** through array chaining, so a frame is
|
||||||
|
**one channel start and not 192** — and the chain crosses from the palette
|
||||||
|
registers at `$E82000` into GVRAM in the same start (FINDINGS 62). The array is
|
||||||
|
scene-constant, because the packed layout spends both 256-colour pages and there
|
||||||
|
is no page to flip.
|
||||||
|
|
||||||
|
**Audio rides a fixed cadence, not the record.** The MSM6258 wants 7,812.5 B/s
|
||||||
|
and 12 fps wants 651.0417 B a slot, a ratio with no arithmetic relationship, so
|
||||||
|
audio in the record would make records variable-length and bring the index back.
|
||||||
|
DLXP2 groups instead: **one 14-sector audio lump, then 11 records**, so
|
||||||
|
`record i = off_frm + i*rec + (i//11)*7168` — six instructions once a frame. The
|
||||||
|
lump is 7,168 bytes of space and eleven frames of audio is 7,161.4583…, so **the
|
||||||
|
payload alternates 7,161 and 7,162** and a player that fed the chip whole lumps
|
||||||
|
runs 0.09% fast: **1.25 s of lip-sync drift over the game's 22.8 minutes**,
|
||||||
|
predicted, and played at 1.26 (FINDINGS 67.2/68.3). The player carries one
|
||||||
|
accumulator instead, which is the frame clock's shape for the frame clock's
|
||||||
|
reason.
|
||||||
|
|
||||||
|
**The wire is fixed and there is no lever on it: 582.0 KB/s of picture +
|
||||||
|
7.64 KB/s of audio cadence = 589.6 KB/s.** A codec's bitrate is adjustable; a
|
||||||
|
literal frame's is geometry, and no scene costs less than another.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---:|
|
||||||
|
| record | 49,664 B = 97 sectors |
|
||||||
|
| wire | **589.6 KB/s** |
|
||||||
|
| picture, as the display renders it | **33.10 dB** mean over the gate window |
|
||||||
|
| CPU per frame, video path | one channel start and one `READ(10)` — no paint |
|
||||||
|
| cost at the 9 clk/B dual-address floor | **54.9% of a 12 fps frame** (FINDINGS 64.2) |
|
||||||
|
| the codec, at the same floor | 110.4% (FINDINGS 61.4) |
|
||||||
|
|
||||||
|
## Where it stands
|
||||||
|
|
||||||
|
**What runs, end to end, on the emulated machine off a real volume:**
|
||||||
|
|
||||||
|
- **120 of 120 frames pixel-exact**, in both palette orders, every frame compared
|
||||||
|
rather than the last — a packed frame is a literal, so frame 119 says nothing
|
||||||
|
about frame 60 (FINDINGS 64.1).
|
||||||
|
- **All 78,125 B of a container's audio, read back off the speaker**, sample-exact
|
||||||
|
against the four ADPCM axes carried in the container's own header, one
|
||||||
|
delivered byte at a time — because a player's counters all stay right through
|
||||||
|
a bug that overwrites the buffer the channel is reading (FINDINGS 68.1).
|
||||||
|
- **A mid-stream seek with sound on it**: `pg_aseek` rebuilds the lump index, the
|
||||||
|
stream position, the remainder accumulator and the byte offset into the group
|
||||||
|
and issues a second read — **132,162 B of spliced stream accounted for byte by
|
||||||
|
byte** across a branch deliberately not on a group boundary (FINDINGS 71.1).
|
||||||
|
- **The scene graph**: 40 scenes, 516 sequences, 906 input windows, imported from
|
||||||
|
the arcade's own graph into this project's schema (FINDINGS 56).
|
||||||
|
|
||||||
|
**The binding resource is the 68000's local BUS, not its clock**, and every item
|
||||||
|
above is priced in one of four units:
|
||||||
|
|
||||||
|
| resource | state |
|
||||||
|
|---|---|
|
||||||
|
| **68000 local bus** | the binding one. The codec's decoder occupies 86.7% of it, and 52 of the 53 frames that miss the 12 fps budget miss on the bus (FINDINGS 38). |
|
||||||
|
| **68000 clocks** | measured, on two independent cores. |
|
||||||
|
| **delivery rate** | **no working figure, deliberately** (FINDINGS 50, USER DECISION). `--bus`, `--kbps` and `DLX_STREAM_KBPS` are required arguments with no defaults, so no table can be scored against a rate its own output does not state. |
|
||||||
|
| **seek time** | **no figure at all, and never had one.** |
|
||||||
|
| **W, clocks stolen per delivered byte** | 5 single-address held, 9 dual held, 12 single arbitrated, 16..19 for the IPL ROM's own disk channel. **Still the largest open number.** The CPU doing the transfer itself is **87.28 clocks a byte, measured** — 4.6x the worst DMA configuration found here and 17.5x the best (FINDINGS 58). |
|
||||||
|
|
||||||
|
### The open question that decides which player gets built
|
||||||
|
|
||||||
|
**The write window is the frame.** A packed write *requires* R20 bit 11; buffer
|
||||||
|
mode blanks the layer while it is set; and a DMAC-direct player holds the window
|
||||||
|
open for the whole data phase, because there is no second page to hide behind.
|
||||||
|
|
||||||
|
dark fraction of a slot = record bytes / (DATA-PHASE rate x slot)
|
||||||
|
|
||||||
|
**The rate in that expression is the BURST rate, not the sustained one**, and at
|
||||||
|
the container's own 582.0 KB/s the dark fraction is **1.0**: every frame
|
||||||
|
delivered, on time, pixel-exact, and none of them displayed. Free-running, the
|
||||||
|
run reported exactly that — **the window was open on 99.5% of host frames**.
|
||||||
|
|
||||||
|
It **reverses the ranking** in clocks, so there are two packed players and the
|
||||||
|
difference between them is *when* the window is open:
|
||||||
|
|
||||||
|
| | clocks (W=9) | on screen | RAM |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| **A — DMAC-direct** (built, FINDINGS 64) | **54.9%** of a slot | 0% at the container's wire, 72.7% only at 2,131 KB/s | none |
|
||||||
|
| **B — DMA to RAM + CPU paint** (K4, not built) | 82.2% | **72.7% at any rate** | 99,328 B |
|
||||||
|
|
||||||
|
B's paint is **measured**: the packed `movem` blit is 227,553 clocks, 27.3% of a
|
||||||
|
slot, independent of the medium. They are equally visible only at **3.7x the
|
||||||
|
wire**. So B2 — *does buffer mode blank the display?* — decides which player
|
||||||
|
exists rather than how much headroom one has, and **K4 is not built until it is
|
||||||
|
answered** (FINDINGS 64.2, ROADMAP K4).
|
||||||
|
|
||||||
|
### What else a player has to carry
|
||||||
|
|
||||||
|
**A held channel costs the frame clock half its ticks, without the clock being
|
||||||
|
able to tell.** `clock.i` counts V-DISP interrupts; a held channel halts the
|
||||||
|
68000; the MFP's pending bit is one bit. Held at 12 fps, **487 of 1,038 edges are
|
||||||
|
lost** and the player reports **zero late frames**, because the tick it grades
|
||||||
|
itself against is advanced by the interrupt the channel stopped it from taking.
|
||||||
|
It believed it was at 12 fps; the screen was at **6.37** (FINDINGS 64.3).
|
||||||
|
|
||||||
|
**Held, the bus costs the audio 463 times the seam.** The MSM6258 has no
|
||||||
|
starvation state — it goes on decoding nibbles out of whatever byte its data
|
||||||
|
register still holds — so the interval between a channel counting out and the CPU
|
||||||
|
arming the next lump is a held nibble pair driving the predictor. Stealing, that
|
||||||
|
seam is **0.51 ms over ten seconds**, because the audio service runs from inside
|
||||||
|
`dma.i`'s transfer wait. Held, it is **236 ms, 2.31% of the audio**, with every
|
||||||
|
byte still correct (FINDINGS 68.2).
|
||||||
|
|
||||||
|
**The chip is not the datasheet and not ffmpeg, and it was asked.** Sixteen
|
||||||
|
candidate decoder models were fitted to what the emulated MSM6258 produced from a
|
||||||
|
designed nibble stream; **exactly one reproduces it sample-exact**, with a
|
||||||
|
negative control on every axis. The chip runs the datasheet's truncation, takes
|
||||||
|
the **low** nibble first, clamps its accumulator at **10 bits** and starts it at
|
||||||
|
**−2**. Getting the delta formula wrong costs −2.88 dB; **getting the nibble order
|
||||||
|
wrong costs −25.74 dB** (FINDINGS 66). The four axes ride in the container header
|
||||||
|
as fields rather than a version number.
|
||||||
|
|
||||||
|
**The audio level is measured off the whole disc and does not change.** All 201
|
||||||
|
streams that have audio, 21.5 minutes: the **disc peaks at 946 of 2048, 5.35 dB
|
||||||
|
over the chip's clamp**, in 402 events totalling 44.0 ms. Forty windows encoded at
|
||||||
|
six gains price the choice, and the disc's own level has the **best mean SNR
|
||||||
|
(22.03 dB)** — the gain that guarantees zero clamping costs 0.85 dB across the
|
||||||
|
game to buy back 1.90 dB on the 2.11 seconds that clamp, because the OKI step
|
||||||
|
table's floor is a constant and does not scale (FINDINGS 69).
|
||||||
|
|
||||||
|
**The predictor does not seek.** The MSM6258's accumulator is a pure integrator
|
||||||
|
with no leakage term, so a branch that hands the chip bytes chosen for a state it
|
||||||
|
is not in produces **a DC offset that does not decay**: playing through, −355 of
|
||||||
|
511 with AC 0.00, still −108 four seconds later; stopping and re-PLAYing, a single
|
||||||
|
permanent constant of −65. **A re-PLAY is 5.5x better and neither is zero.** The
|
||||||
|
only fix that reaches zero is the encoder's — reset the predictor where a branch
|
||||||
|
can land — and it costs **0.33 dB** (21.99 → 21.66) because the step table
|
||||||
|
re-converges in a few samples (FINDINGS 71).
|
||||||
|
|
||||||
|
**Branch points do not wait for the buffer.** 5.4% of the game's 612 branch
|
||||||
|
transitions open an input window on the first frame of a clip the disc *seeked
|
||||||
|
to*, so two seeks can fall back to back with no play between them. On the packed
|
||||||
|
branch there is **no refill climb at all** — a record goes straight into GVRAM and
|
||||||
|
the video lookahead is zero records — so the only consumer with any lookahead is
|
||||||
|
the audio one: **1.833 s of sound held against 0.000 s of picture** (FINDINGS
|
||||||
|
56/70.2). And a DLXP2 group puts its lump *in front* of its records, so a seek
|
||||||
|
lands with its audio behind it: **mean 416.5 ms of silence entering a branch**
|
||||||
|
over the arcade's 409 within-container targets, which is what `pg_aseek`'s second
|
||||||
|
read removes for 11.7 ms (FINDINGS 70.3).
|
||||||
|
|
||||||
|
### The parked branch: the codec
|
||||||
|
|
||||||
|
The codec is a Cinepak-style hybrid — each 4x4 block coded as SKIP, one 4x4
|
||||||
|
codeword, four 2x2 codewords, or RAW literal pixels, chosen per block by
|
||||||
|
rate-distortion, with a v7 literal-span mode over the top. It works, it is
|
||||||
|
measured, and it is **pixel-exact on the 68000 under two independent CPU cores**;
|
||||||
|
`stream.s` decodes a 120-frame window out of a 256 KB ring on a stock 2 MB
|
||||||
|
machine with the container in a file rather than in RAM.
|
||||||
|
|
||||||
|
<video src="docs/img/player.webm" controls muted loop width="100%"></video>
|
||||||
|
|
||||||
|
[`docs/img/player.webm`](docs/img/player.webm) — the codec player, 119 frames,
|
||||||
|
12 fps. 116 are pixel-exact against the reference decoder; **three are torn**,
|
||||||
|
frame *n* on top of frame *n-1*, because MAME captured the screen while the block
|
||||||
|
loop was partway down it. `decode.s` writes straight to the displayed page, so a
|
||||||
|
real player tears the same way, and the media builder **asserts the tear** rather
|
||||||
|
than trimming it. [`docs/img/modes.webm`](docs/img/modes.webm) is the same window
|
||||||
|
with the block-mode map beside it — black SKIP, blue V1, amber V4, red RAW —
|
||||||
|
which is what every cost table in FINDINGS is really about.
|
||||||
|
|
||||||
|
**Why it is parked.** Its remaining path is a conjunction and the packed one is
|
||||||
|
not: reaching a fitting rate needs a 35% byte reduction, two of its three levers
|
||||||
|
measure inert, and the reward on success is a design at ~100% of the frame
|
||||||
|
against the packed player's 54.9%. **It is kept on disk and not built on** — if
|
||||||
|
B2 comes back "buffer mode blanks", it is the only thing left (FINDINGS 61.8,
|
||||||
|
48.3).
|
||||||
|
|
||||||
|
## What is open
|
||||||
|
|
||||||
|
**Hardware — this list is the user's, and nothing here can be settled by an
|
||||||
|
emulator.**
|
||||||
|
|
||||||
|
- **B1. Measure the medium.** Three thresholds, not one: **sustained ≥ 589.6 KB/s**
|
||||||
|
or frames arrive late; **the data-phase BURST rate**, which decides how much of
|
||||||
|
the slot the picture is on screen; and **seek time**, which has no figure at
|
||||||
|
all. Plus what one extra SCSI command costs, which decides the audio cadence.
|
||||||
|
The 0.7–1.7 MB/s usually quoted for BlueSCSI on an X68000 is **folklore with no
|
||||||
|
published benchmark behind it**.
|
||||||
|
- **B2. Does buffer mode blank the display?** `probe_bit11_blank.lua` is written
|
||||||
|
and settles it in minutes on a real board. It decides A vs B above.
|
||||||
|
- **B3. Does a real CZ-6BS1 drive `#EXREQ`?** MAME's card has no request line to
|
||||||
|
the DMAC at all, so every configuration that can be run against it is
|
||||||
|
auto-requested and **charged by time rather than by byte**. A real request line
|
||||||
|
is what single-address 5 clk/B needs.
|
||||||
|
- **B4. Does a real palette register take a byte write?** A negative answer costs
|
||||||
|
0.28% of a frame and nothing else.
|
||||||
|
- **The MSM6258V, on silicon**: the four axes; whether it resets accumulator, step
|
||||||
|
index and nibble select on PLAY only when it was not already playing; and
|
||||||
|
whether it goes on asserting `#DRQ` while STOPped.
|
||||||
|
|
||||||
|
**Software, in order.**
|
||||||
|
|
||||||
|
1. **The predictor-reset container (DLXP3).** The cheapest thing that takes a
|
||||||
|
measured cost to zero rather than down. The player half already exists
|
||||||
|
(`PG_ARST`).
|
||||||
|
2. **The audio buffering depth.** `PG_ANBUF` is 3; two slots is one constant and
|
||||||
|
one run. The audio buffer is the packed branch's *only* buffer.
|
||||||
|
3. **The silent-clip and short-audio cases**: a scene whose audio is shorter than
|
||||||
|
its frames, and a scene with no audio track at all.
|
||||||
|
4. **The cadence pick.** With a working seek path the silence F=11 costs is zero,
|
||||||
|
so the trade is padding against RAM — plus one SCSI command per branch, which
|
||||||
|
is B1's.
|
||||||
|
|
||||||
|
**Parked, so it is not re-proposed:** the codec's remaining encoder work (E7, E4,
|
||||||
|
C1); `ring.i`, `xfer.i` and most of `stream.s`, which a DMAC-direct player has no
|
||||||
|
use for because it has no ring, and P4a's wiring with them. **K4 is blocked
|
||||||
|
on B2**, not parked.
|
||||||
|
|
||||||
|
## Reproducing this
|
||||||
|
|
||||||
|
**No media ships in this repo and none of it is redistributable.** Bring your own
|
||||||
|
Dragon's Lair Blu-ray. Everything else needed to rebuild every number and every
|
||||||
|
picture above is either here or is packaged.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| the disc | loop-mounted read-only: `udisksctl loop-setup -r -f DRAGONS_LAIR.iso`. Built against a decrypted UDF 2.x image; 7-Zip cannot read UDF 2.x, so use the loop mount |
|
||||||
|
| `python3` | plus **numpy** and **Pillow**, and nothing else. The k-means is hand-rolled rather than pulling in sklearn |
|
||||||
|
| `ffmpeg` / `ffprobe` | frame extraction, and the clips above |
|
||||||
|
| **MAME** | tested on 0.277, with the `x68000` ROM set. The rigs drive it headless via `-autoboot_script` |
|
||||||
|
| vasm (m68k, Motorola syntax) | **vendored**: `tools/vasm/vasmm68k_mot` is a Linux x86-64 binary, with the source tarball beside it to rebuild elsewhere |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export DLX_BDROM=/path/to/your/mounted/bluray # if not /media/$USER/BDROM
|
||||||
|
./tools/bench/check.sh # the green light, prints ALL GREEN
|
||||||
|
```
|
||||||
|
|
||||||
|
`check.sh` re-runs everything above that a host can re-run: 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, the load-time transforms on both cores, the
|
||||||
|
scene-graph import when a DirkSimple checkout is present, the packed container
|
||||||
|
(with audio, and a silent control beside it) rendered through px68k's own GVRAM
|
||||||
|
model, **the packed player for 120 frames off a real volume with every frame
|
||||||
|
compared**, the audio encoder against ffmpeg's decoder, the sixteen-way decoder
|
||||||
|
identification against the emulated chip, the DLXP2 container and each of its four
|
||||||
|
axes with a negative control on each, the audio level off every stream of the
|
||||||
|
game's own footage, and the refill climb against the arcade's own 612 branch
|
||||||
|
points. `DLX_BDROM` is honoured by every tool that reads the disc.
|
||||||
|
|
||||||
|
Two stages **skip rather than fail** when their input is absent, because both live
|
||||||
|
outside this repo: `PX68K=/path/to/px68k` for the second-CPU-core gate — the
|
||||||
|
cheapest strong test in the tree, and what licenses the bus and cycle figures —
|
||||||
|
and `IPLROM=/path/to/iplrom.dat` for the DMAC configuration gate.
|
||||||
|
|
||||||
|
The player runs and the media are rebuilt from them:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bash tools/bench/packed_run.sh # the packed player, 7 runs
|
||||||
|
python3 tools/media/make_packed_media.py # docs/img/packed-player.{png,webm}
|
||||||
|
python3 tools/media/make_readme_media.py <c.dlx> # the parked codec's stills and clips
|
||||||
|
```
|
||||||
|
|
||||||
|
Both media builders **gate before they write**: a still or a clip of the player is
|
||||||
|
a claim that the player drew it, so every frame is checked pixel-exact against the
|
||||||
|
container first and the audio cut is checked sample-exact against lump 0. A README
|
||||||
|
that illustrated a pixel-exact player with an approximate picture would be a small
|
||||||
|
lie about the one property this project keeps testing.
|
||||||
|
|
||||||
|
**Scene selection is a hard-coded stream number, not a search.** The gates use
|
||||||
|
streams `00020` and `00223` of the disc's 224 `.m2ts` files. A different pressing
|
||||||
|
may number them differently, and if so the green light will extract the wrong
|
||||||
|
footage rather than fail, so check that `tmp/fr_singe/` looks like the Singe
|
||||||
|
encounter before trusting any figure. **Not every large stream is game footage**:
|
||||||
|
`00216` is the feature with a burned-in commentary picture-in-picture and `00215`
|
||||||
|
is the commentary itself, the two largest files on the disc. The clean
|
||||||
|
9.4-minute animation is **`00223`** (FINDINGS 25.1).
|
||||||
|
|
||||||
|
**Nothing outside-derived is committed here.** The scene graph is regenerated from
|
||||||
|
a reader's own clones into gitignored `tmp/`, and `tools/import/scenegraph.py` is
|
||||||
|
the single file in the repo coupled to those projects — everything downstream reads
|
||||||
|
`DLXSCENE1`, this project's own schema, with the sources' attribution carried in
|
||||||
|
it. DirkSimple is zlib (Ryan C. Gordon); the SNES chapter set is MIT (Chad
|
||||||
|
Doebelin) and, by its own README, *derived* from DirkSimple rather than an
|
||||||
|
independent transcription.
|
||||||
|
|
||||||
|
## Encoders
|
||||||
|
|
||||||
|
**The live one is the packed container**, and it has no rate control in it because
|
||||||
|
there is no rate lever:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# the gate window: stream 00223, 10.0 s from 539.4 s, the same seconds both times
|
||||||
|
python3 tools/encoder/extract.py 00223 tmp/fr_singe 12 crop 539.4 10.0
|
||||||
|
python3 tools/encoder/extract_audio.py 00223 tmp/au_singe.raw 15625 539.4 10.0
|
||||||
|
python3 tools/encoder/pack.py tmp/fr_singe out.dlxp --audio tmp/au_singe.raw
|
||||||
|
```
|
||||||
|
|
||||||
|
`dlxp.py` is the format and the one place the interleave, the 97-sector record,
|
||||||
|
the audio cadence and the lump payload are stated. It encodes in ~3 seconds
|
||||||
|
because there is no k-means in it. `--scene-palette --no-palette` is the fallback
|
||||||
|
container: **30.79 dB, zero palette churn, 576.0 KB/s**, still ahead of the codec
|
||||||
|
as the display renders both, and it is what ships if B2 says the layer blanks —
|
||||||
|
because a per-frame palette changes **231 of 256 entries every frame**, and a
|
||||||
|
picture under the neighbouring frame's palette is **12.8 dB worse**
|
||||||
|
([`docs/img/palette-mismatch.png`](docs/img/palette-mismatch.png)).
|
||||||
|
|
||||||
|
`adpcm.py` is the MSM6258 codec and it carries **two decoders on purpose**: the
|
||||||
|
module defaults are ffmpeg's, so `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. Anything that encodes *for* the machine passes `CHIP`
|
||||||
|
explicitly.
|
||||||
|
|
||||||
|
**The parked codec's encoder is kept and still runs:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/encoder/encode.py tmp/fr_singe out.dlx --profile scsi --preview p.png
|
||||||
|
```
|
||||||
|
|
||||||
|
`--kbps` is the quality rate point and `--span-kbps` the ceiling the span pass may
|
||||||
|
draw on; `mu` is bisected per frame against the 68000's own decode budget so a
|
||||||
|
frame also *decodes* in time. One profile, `scsi`; the 110 KB/s `sasi` profile was
|
||||||
|
dropped on capacity rather than bandwidth — a SASI volume is 40 MB and the
|
||||||
|
game is 146 MiB even at that rate. The RAW escape means `lam=0` is
|
||||||
|
pixel-exact against the palettised frame. See FINDINGS 31/41/44 before changing
|
||||||
|
any of it.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **`docs/STATUS.md`** is the current state, working setup, blockers and next
|
||||||
|
steps, newest session first. **Start here.** It also lists what has been
|
||||||
|
explicitly abandoned, so old ideas do not get re-proposed.
|
||||||
|
- **`docs/ROADMAP.md`** is the remaining work to a completion target. Read it with
|
||||||
|
STATUS rather than instead of it: STATUS holds the measurements, ROADMAP holds
|
||||||
|
the shape and goes stale first.
|
||||||
|
- **`docs/FINDINGS.md`** is measured hardware facts, content statistics, the codec
|
||||||
|
decision, and a section on measurement traps that produced three separate false
|
||||||
|
results. Read §4 before trusting any pipeline number. It is append-only and
|
||||||
|
later sections overturn earlier ones; superseded sections carry a blockquote
|
||||||
|
pointing at the correction.
|
||||||
|
- **`docs/BENCHMARK.md`** is how to measure the storage subsystem, and why a
|
||||||
bandwidth figure out of MAME would be meaningless.
|
bandwidth figure out of MAME would be meaningless.
|
||||||
- **`docs/HARDWARE.md`** — X68000 GVRAM/CRTC reference.
|
- **`docs/HARDWARE.md`** is the X68000 GVRAM/CRTC reference.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
```
|
|
||||||
docs/ findings, status, hardware reference
|
|
||||||
tools/analysis/ measurement scripts, numbered in the order they were written
|
|
||||||
(01/02 marked BROKEN deliberately, kept as regression refs).
|
|
||||||
Run from the repo root — they import from tools/encoder/.
|
|
||||||
07 finds the hottest sustained window in a stream; 08 renders
|
|
||||||
source | decoded | block-mode map as .webm; 09 is the
|
|
||||||
rate-control drift gate (FINDINGS 26/27) and is part of
|
|
||||||
check.sh -- it exits non-zero if the encoder ever again
|
|
||||||
reports a reconstruction no decoder would produce.
|
|
||||||
10 is a COUNTEREXAMPLE, and exits non-zero by design: it
|
|
||||||
demonstrates that the two-display-path plan of FINDINGS
|
|
||||||
24.5/25.6 corrupts 70 of 120 frames (FINDINGS 28.1).
|
|
||||||
11 scores a container against the MEASURED per-mode block
|
|
||||||
costs without needing MAME.
|
|
||||||
tools/bench/ MAME Lua injection harness + 68000 benchmark sources.
|
|
||||||
`check.sh` re-runs both display regression tests (~40 s).
|
|
||||||
`blit.s`/`blit.lua` time the full-frame GVRAM blit on the
|
|
||||||
68000 itself (FINDINGS 24) — not part of check.sh, because
|
|
||||||
wall timings would make the green-light check host-sensitive.
|
|
||||||
`crtc_mode.lua` is the single source of truth for CRTC R00-R08
|
|
||||||
and R20 — do not write CRTC values anywhere else.
|
|
||||||
`prep_dlx.py`/`decode.lua`/`verify_decode.py` load, time and
|
|
||||||
verify `src/player/decode.s`; the verify pass is in check.sh.
|
|
||||||
tools/vasm/ vasm m68k assembler (built from source)
|
|
||||||
tools/encoder/ hybrid VQ encoder + DLX1 container writer (working).
|
|
||||||
dlx.py is the reference DECODER -- ground truth for the 68000.
|
|
||||||
src/player/ decode.s: the 68000 DLX1 decoder. Pixel-exact, and 31% of
|
|
||||||
frames over the 12fps CPU budget. See FINDINGS 28.
|
|
||||||
assets/ extracted frames/audio (gitignored)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Encoder
|
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 tools/encoder/extract.py 00020 /tmp/fr 12 crop
|
src/player/ packed.s THE LIVE PLAYER: display bring-up, the 193-entry DMA
|
||||||
python3 tools/encoder/encode.py /tmp/fr out.dlx --profile sasi --preview p.png
|
chain, its own V-DISP clock, READ(10) off a real
|
||||||
|
volume, and the MSM6258 on channel 3 -- a lump ring,
|
||||||
|
the remainder accumulator that stops the padding
|
||||||
|
becoming drift, a service routine that runs from
|
||||||
|
INSIDE dma.i's transfer wait (0.51 ms of seam
|
||||||
|
against 236), and pg_aseek.
|
||||||
|
dma.i the HD63450 channel: array chaining, held and
|
||||||
|
stealing, and DM_HOOK.
|
||||||
|
scsi.i the MB89352: selection, READ(10), the data phase.
|
||||||
|
adpcm.i the audio transport, every register byte of it
|
||||||
|
decoded out of the IPL ROM rather than recalled.
|
||||||
|
clock.i the frame clock, off the CRTC's V-DISP.
|
||||||
|
geom.i the constants, in one place.
|
||||||
|
decode.s the parked codec's 68000 decoder (1,296 bytes,
|
||||||
|
stream.s asserted), the same decoder behind a bounded ring,
|
||||||
|
ring.i the ring producer, the transport under it, and the
|
||||||
|
xfer.i load-time codebook/palette transforms. OUT OF THE
|
||||||
|
load.i VIDEO PATH: a DMAC-direct player has no ring.
|
||||||
|
frame.i the block loop and span chain, included by both, so
|
||||||
|
there is exactly ONE copy of the bytes every cycle
|
||||||
|
constant is fitted to. The span pass is blit.s v7
|
||||||
|
verbatim -- do not tidy it.
|
||||||
|
tools/encoder/ dlxp.py THE LIVE CONTAINER (DLXP2) and pack.py writes it.
|
||||||
|
pack.py Nothing is shared with the codec's writer on purpose.
|
||||||
|
adpcm.py the MSM6258 codec, carrying ffmpeg's decoder and the
|
||||||
|
chip's measured one side by side.
|
||||||
|
dlx.py the codec's container and its REFERENCE DECODER,
|
||||||
|
encode.py ground truth for the 68000; the encoder, rate
|
||||||
|
spans.py control, and the v7 span geometry. Parked, kept.
|
||||||
|
extract.py frames and audio off the disc.
|
||||||
|
tools/bench/ check.sh the green light.
|
||||||
|
packed_run.sh the packed player: gate, rate, audio, held, seek.
|
||||||
|
verify_packed.py / verify_packed_audio.py
|
||||||
|
the two gates that matter. The audio one reads the
|
||||||
|
SPEAKER, one delivered byte at a time, because every
|
||||||
|
counter in the player stayed right through a bug
|
||||||
|
that overwrote the buffer the channel was reading.
|
||||||
|
crtc_mode.lua the SINGLE SOURCE OF TRUTH for CRTC R00-R08 and
|
||||||
|
R20. Do not write CRTC values anywhere else.
|
||||||
|
probe_bit11_blank.lua B2, ready to run on a real board.
|
||||||
|
c68k/ a SECOND emulator for every 68000 cycle figure:
|
||||||
|
px68k's CPU core, no SDL, no ROMs. It also counts
|
||||||
|
BUS cycles, which MAME cannot report.
|
||||||
|
gvpack/ the same argument for the DISPLAY: px68k's own
|
||||||
|
x68k/gvram.c, so the address decode, the R20 bit-11
|
||||||
|
write path and the scroll wrap are its code and not
|
||||||
|
a model of it.
|
||||||
|
blit.s the full-frame GVRAM blit, timed on the 68000
|
||||||
|
itself. V8 is the packed one: 227,553 clocks.
|
||||||
|
tools/analysis/ measurement scripts, numbered in the order they were written.
|
||||||
|
Run from the repo root. 01 and 02 are marked BROKEN
|
||||||
|
deliberately and kept as regression references; 10 is a
|
||||||
|
COUNTEREXAMPLE and exits non-zero by design, demonstrating that
|
||||||
|
the two-display-path plan corrupts 70 of 120 frames. 15 is the
|
||||||
|
bus occupancy model, 19 the ring's ADDRESSES (not its
|
||||||
|
occupancy), 21 the IPL ROM's DMAC configuration, 25 the scene
|
||||||
|
graph, 30/34 the packed containers, 31 the display duty, 35 the
|
||||||
|
audio level off the whole disc, 36 the climb against real
|
||||||
|
branch points, 37 what a branch costs the chip. buscost.py is
|
||||||
|
the shared bus-cycle table; the per-block constants live in
|
||||||
|
vq_hybrid.py and are imported, never copied.
|
||||||
|
tools/import/ scenegraph.py -- the ONLY code in this tree coupled to somebody
|
||||||
|
else's source. Output is gitignored derived data.
|
||||||
|
tools/media/ make_packed_media.py builds the live player's still and clip;
|
||||||
|
make_readme_media.py builds the parked codec's. Both gate
|
||||||
|
before they write.
|
||||||
|
tools/vasm/ vasm m68k assembler, binary plus source tarball.
|
||||||
|
docs/img/ the stills and clips above, built from real emulated runs.
|
||||||
|
assets/, tmp/ extracted frames, audio and run output (gitignored).
|
||||||
```
|
```
|
||||||
|
|
||||||
Two quality profiles ship from one codec and one decoder — `sasi` (110 KB/s) and
|
Source media (`DRAGONS_LAIR.iso`) and ROMs are gitignored. Supply your own.
|
||||||
`scsi` (280 KB/s) are two points on the same rate-distortion curve. Both are
|
|
||||||
**ceilings**: lam is bisected per frame under a leaky bucket, so the profile's
|
|
||||||
`lam` is a quality floor rather than a setting (`--fixed-lam` opts out). The codec is
|
|
||||||
a Cinepak-style hybrid: each 4x4 block is coded as SKIP, one 4x4 codeword, four
|
|
||||||
2x2 codewords, or RAW literal pixels, chosen per block by rate-distortion.
|
|
||||||
|
|
||||||
The RAW escape means `lam=0` is pixel-exact against the palettised frame, so the
|
|
||||||
quality knob spans lossless to heavily-compressed without changing the bitstream.
|
|
||||||
|
|
||||||
Profiles are derived from a bandwidth figure, not chosen by eye:
|
|
||||||
|
|
||||||
```
|
|
||||||
python3 tools/encoder/profile_gen.py --bw-mbps 4 --name scsi
|
|
||||||
```
|
|
||||||
|
|
||||||
> **On reading `docs/FINDINGS.md`:** it is append-only and several later sections
|
|
||||||
> overturn earlier ones. Superseded sections carry a blockquote at the top
|
|
||||||
> pointing to the correction — heed those, especially 18 (reversed by 21).
|
|
||||||
|
|
||||||
Source media (`DRAGONS_LAIR.iso`) and ROMs are gitignored — supply your own.
|
|
||||||
|
|
||||||
**Not every large stream is game footage.** `00216` is the feature with a
|
|
||||||
burned-in commentary picture-in-picture and `00215` is the commentary itself —
|
|
||||||
the two largest files on the disc. The clean 9.4-minute animation is **`00223`**.
|
|
||||||
See FINDINGS 25.1 before running any size-ranked survey.
|
|
||||||
|
|||||||
+23
-5
@@ -53,13 +53,31 @@ was used. **Do not record KB/s and treat it as a hardware figure.**
|
|||||||
Already partly in FINDINGS 5. Bounds worth tightening from datasheets:
|
Already partly in FINDINGS 5. Bounds worth tightening from datasheets:
|
||||||
|
|
||||||
- 68000 bus cycle: 4 clocks @ 10MHz, 16-bit => **5 MB/s** absolute ceiling
|
- 68000 bus cycle: 4 clocks @ 10MHz, 16-bit => **5 MB/s** absolute ceiling
|
||||||
- HD63450 single-address DMA, ~8 clocks/word => **~2.5 MB/s** practical ceiling
|
- HD63450 single-address DMA, **5 clocks/BYTE** => **2.0 MB/s** practical ceiling
|
||||||
|
(dual-address is 9 clocks/byte => 1.11 MB/s). CORRECTED session 14: this line
|
||||||
|
read "~8 clocks/word => ~2.5 MB/s", which charged a byte-wide SPC per word.
|
||||||
|
FINDINGS 43.
|
||||||
- SCSI-1 asynchronous REQ/ACK handshake per byte, plus MB89352 FIFO depth
|
- SCSI-1 asynchronous REQ/ACK handshake per byte, plus MB89352 FIFO depth
|
||||||
=> the real limiter, and the number we do not have from a primary source
|
=> the real limiter, and the number we do not have from a primary source
|
||||||
|
|
||||||
The user's working figure is **4 Mbps = 488 KB/s**, which sits sensibly between
|
**RETIRED, session 18 (USER DECISION).** This document used to name a working
|
||||||
the derived DMA ceiling and observed period-drive rates. **Provenance not yet
|
figure of "4 Mbps" here and note that every profile hung off it. It was never a
|
||||||
recorded — worth pinning down, because every profile now hangs off it.**
|
bus measurement — user-supplied, no provenance, and 10% of SCSI-1's asynchronous
|
||||||
|
rating (FINDINGS 42.1). It has been removed as a default from every analysis
|
||||||
|
tool and from `tools/bench/stream.lua`; the tools now REQUIRE an explicit rate,
|
||||||
|
so nothing can be scored against a figure the scorer never restates.
|
||||||
|
|
||||||
|
**There is no working delivery figure. That is the honest state, and it is the
|
||||||
|
point:** the rate is a property of the medium, the medium is a BlueSCSI, and it
|
||||||
|
has not been measured. `tools/analysis/19_ring_stream.py` reports the
|
||||||
|
**zero-prefill pipe** — the rate a medium must clear for a given container to
|
||||||
|
need no prefill at all — which is the threshold a measurement should be taken
|
||||||
|
against. For the session-14 candidate that is **513.2 KB/s** (FINDINGS 49.5).
|
||||||
|
|
||||||
|
One place still carries the old number: `GATE_SPAN_KBPS` in
|
||||||
|
`tools/bench/check.sh`, because the gate container was *encoded* with it and
|
||||||
|
every per-block and span constant in FINDINGS 41/43/45/49 is fitted to that
|
||||||
|
container. It is a container recipe, not a claim about any medium.
|
||||||
|
|
||||||
### The coupling nobody had counted
|
### The coupling nobody had counted
|
||||||
Cycle-stealing DMA is not free DMA. At ~8 clocks per 16-bit word:
|
Cycle-stealing DMA is not free DMA. At ~8 clocks per 16-bit word:
|
||||||
@@ -69,7 +87,7 @@ Cycle-stealing DMA is not free DMA. At ~8 clocks per 16-bit word:
|
|||||||
| 110 KB/s | 4.5% | 42.8% |
|
| 110 KB/s | 4.5% | 42.8% |
|
||||||
| 250 KB/s | 10.2% | 48.5% |
|
| 250 KB/s | 10.2% | 48.5% |
|
||||||
| 450 KB/s | 18.4% | 56.7% |
|
| 450 KB/s | 18.4% | 56.7% |
|
||||||
| 488 KB/s | 20.0% | 58.3% |
|
| ~490 KB/s | 20.0% | 58.3% |
|
||||||
|
|
||||||
FINDINGS 5 concluded that because transfers are DMA, "streaming costs
|
FINDINGS 5 concluded that because transfers are DMA, "streaming costs
|
||||||
essentially no CPU". **That is wrong.** It costs up to a fifth of the machine at
|
essentially no CPU". **That is wrong.** It costs up to a fifth of the machine at
|
||||||
|
|||||||
+6493
-4
File diff suppressed because it is too large
Load Diff
+1035
File diff suppressed because it is too large
Load Diff
+3512
-91
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
@@ -0,0 +1,131 @@
|
|||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; adpcm.i -- the MSM6258V, driven the way the machine's own ROM drives it.
|
||||||
|
; ROADMAP P6a, and it is P6b's transport arriving early rather than scaffolding.
|
||||||
|
;
|
||||||
|
; NOTHING HERE IS INVENTED. Every register value below is one that
|
||||||
|
; tools/analysis/21_iplrom_dmac.py decodes OUT OF THE IPL ROM's own bytes, at
|
||||||
|
; the addresses it prints: channel 3's DCR/SCR/MFC/CPR/DFC/DAR at $FF0C2E and
|
||||||
|
; the per-transfer OCR = $32 plus command $02 at $FF9A82. That is the one
|
||||||
|
; ADPCM path on this board that is known-correct because Sharp wrote it.
|
||||||
|
;
|
||||||
|
; WHY THIS FILE EXISTS AT ALL. Session 33 fed the chip from Lua and got
|
||||||
|
; silence, swept control 0..3 against port C 0..15, and stopped rather than
|
||||||
|
; guess (65.5). Two of the reasons are visible from here and neither is a
|
||||||
|
; register semantic anybody had to guess:
|
||||||
|
;
|
||||||
|
; * THE PPI'S PORT C IS NOT AN OUTPUT UNTIL IT IS TOLD TO BE. The ADPCM pan
|
||||||
|
; and the sample-rate divider are port C bits, and an i8255 in its reset
|
||||||
|
; state has every port an INPUT -- so a write to $E9A005 changes a latch
|
||||||
|
; nobody is reading and the pan never leaves wherever it was. Control word
|
||||||
|
; $92 (mode 0, A and B input, both halves of C OUTPUT) is what makes the
|
||||||
|
; other write mean anything.
|
||||||
|
; * AND FEEDING IT SLOWLY IS NOT FEEDING IT. The chip has no FIFO and no
|
||||||
|
; starvation state: it consumes a nibble every sample period out of whatever
|
||||||
|
; its data register last held, forever. A byte per host frame is not a
|
||||||
|
; quiet chip, it is the same two nibbles 130 times, which saturates in six
|
||||||
|
; samples. The feed has to be paced by the chip, which is what channel 3
|
||||||
|
; and its request line are FOR.
|
||||||
|
;
|
||||||
|
; THE CLOCK IS TWO WRITES AND THEY ARE IN DIFFERENT DEVICES. 15,625 Hz is
|
||||||
|
; 8 MHz / 512: the 8 MHz comes from CT1 in the YM2151's port register $1B, and
|
||||||
|
; the /512 from port C bits 3,2 = 10. Neither is readable, so the rate is
|
||||||
|
; verified from the OTHER end -- the capture's own sample count.
|
||||||
|
|
||||||
|
AD_CTRLR = $E92001 ; W: command R: status (bit7 = NOT playing)
|
||||||
|
AD_DATAR = $E92003 ; W: the byte the chip takes two nibbles from
|
||||||
|
AD_PLAY = $02 ; COMMAND_PLAY -- session 33's probes wrote $01,
|
||||||
|
AD_STOP = $01 ; which is COMMAND_STOP
|
||||||
|
PPI_PC = $E9A005
|
||||||
|
PPI_CTL = $E9A007
|
||||||
|
PPI_COUT = $92 ; mode 0, A/B input, BOTH halves of C output
|
||||||
|
PPI_RATE = $08 ; pan 00 = both, rate 10 = /512 = 15,625 Hz
|
||||||
|
YM_ADDR = $E90001
|
||||||
|
YM_DATA = $E90003
|
||||||
|
YM_CT = $1B ; CT1 in bit 1: 0 = ADPCM master clock 8 MHz
|
||||||
|
|
||||||
|
AD_DMAC = $E840C0 ; HD63450 channel 3 -- the ADPCM channel, and
|
||||||
|
A3_CSR = AD_DMAC+$00 ; the one the ROM points at $E92003
|
||||||
|
A3_CER = AD_DMAC+$01
|
||||||
|
A3_DCR = AD_DMAC+$04
|
||||||
|
A3_OCR = AD_DMAC+$05
|
||||||
|
A3_SCR = AD_DMAC+$06
|
||||||
|
A3_CCR = AD_DMAC+$07
|
||||||
|
A3_MTC = AD_DMAC+$0A
|
||||||
|
A3_MAR = AD_DMAC+$0C
|
||||||
|
A3_DAR = AD_DMAC+$14
|
||||||
|
A3_MFC = AD_DMAC+$29
|
||||||
|
A3_CPR = AD_DMAC+$2D
|
||||||
|
A3_DFC = AD_DMAC+$31
|
||||||
|
|
||||||
|
A3_DCRV = $80 ; XRM 10 cycle steal w/o hold, DTYP 00 dual
|
||||||
|
; address, DPS 0 8-bit port (IPL $FF0C2E)
|
||||||
|
A3_OCRV = $32 ; DIR memory->device, SIZE 11 byte unpacked,
|
||||||
|
; CHAIN 00, REQG 10 EXTERNAL REQUEST (IPL
|
||||||
|
; $FF9A82). External request is what makes the
|
||||||
|
; chip the pacemaker: one byte per #DRQ3, and
|
||||||
|
; #DRQ3 ticks at half the sample rate.
|
||||||
|
A3_SCRV = $04 ; MAC 01 memory increment, DAC 00 -- the device
|
||||||
|
; address is a REGISTER and must not walk
|
||||||
|
A3_CCRST = $80
|
||||||
|
|
||||||
|
; --------------------------------------------------------------- ad_setup
|
||||||
|
; The clock and the pan. No arguments, no result; trashes d0.
|
||||||
|
ad_setup:
|
||||||
|
move.b #YM_CT,YM_ADDR
|
||||||
|
moveq #60,d0 ; the YM2151 wants settling between the
|
||||||
|
.ymw: subq.l #1,d0 ; address write and the data write
|
||||||
|
bne.s .ymw
|
||||||
|
move.b #$00,YM_DATA ; CT1 = 0 -> ADPCM master clock 8 MHz
|
||||||
|
move.b #PPI_COUT,PPI_CTL ; ...and NOW port C drives something
|
||||||
|
move.b #PPI_RATE,PPI_PC ; pan both, /512
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ----------------------------------------------------------------- ad_arm
|
||||||
|
; Arm channel 3 to feed (a1) for d1 bytes and start it. Trashes d0.
|
||||||
|
; The channel is started BEFORE the chip is told to play (see ad_play), so that
|
||||||
|
; byte 0 is already in the data register when the accumulator is reset.
|
||||||
|
ad_arm:
|
||||||
|
move.b #$FF,A3_CSR ; CSR is write-one-to-clear: a stale COC
|
||||||
|
; would pass the wait loop instantly
|
||||||
|
move.b #A3_DCRV,A3_DCR
|
||||||
|
move.b #A3_SCRV,A3_SCR
|
||||||
|
move.b #$05,A3_MFC
|
||||||
|
move.b #$05,A3_DFC
|
||||||
|
move.b #$01,A3_CPR ; the ROM's own priority: ADPCM outranks
|
||||||
|
; the disk at the arbiter (52.5 item 5)
|
||||||
|
move.l #AD_DATAR,A3_DAR
|
||||||
|
move.b #A3_OCRV,A3_OCR
|
||||||
|
move.l a1,A3_MAR
|
||||||
|
move.w d1,A3_MTC
|
||||||
|
move.b #A3_CCRST,A3_CCR
|
||||||
|
rts
|
||||||
|
|
||||||
|
ad_play:
|
||||||
|
move.b #AD_PLAY,AD_CTRLR
|
||||||
|
rts
|
||||||
|
ad_halt:
|
||||||
|
move.b #AD_STOP,AD_CTRLR
|
||||||
|
rts
|
||||||
|
|
||||||
|
; --------------------------------------------------------------- ad_abort
|
||||||
|
; STOP channel 3 where it stands, and clear what stopping it posts.
|
||||||
|
;
|
||||||
|
; A SEEK IS THE ONLY THING IN THIS PLAYER THAT NEEDS THIS. Every other re-arm
|
||||||
|
; happens at COC, where the channel has already counted itself out and there is
|
||||||
|
; nothing to stop; a seek arrives MID-LUMP, because a branch is a frame index
|
||||||
|
; and a frame does not know about the cadence (FINDINGS 70.3).
|
||||||
|
;
|
||||||
|
; SAB is CCR bit 4. CSR is then written $FF -- write-one-to-clear -- to take
|
||||||
|
; down COC and ERR together, because the abort posts a channel error (CER $11)
|
||||||
|
; and pg_aserv's whole test is "did the channel count out": a stale COC would
|
||||||
|
; make the very next service call arm a lump that is already playing.
|
||||||
|
;
|
||||||
|
; NAME THE LAYER. MAME's hd63450 does not NEED the abort -- its
|
||||||
|
; dma_transfer_start reloads MAR/MTC and restarts the timer whatever the channel
|
||||||
|
; was doing. The MC68450 does: STR written to an active channel is an operation
|
||||||
|
; timing error, and the transfer that is running is not the one that was asked
|
||||||
|
; for. The abort is here for the silicon, and the run below cannot tell.
|
||||||
|
ad_abort:
|
||||||
|
move.b #$10,A3_CCR ; SAB -- software abort
|
||||||
|
move.b #$FF,A3_CSR ; ...and the COC/ERR it posts
|
||||||
|
rts
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
; Front-end for the ADPCM transport (ROADMAP P6a), for the rig.
|
||||||
|
;
|
||||||
|
; It plays ONE buffer of nibbles the host pushed into RAM and reports what the
|
||||||
|
; channel did. What is being measured is not this code -- it is the CHIP: which
|
||||||
|
; delta formula, which nibble of a byte first, where the accumulator clamps, and
|
||||||
|
; what it starts at. tools/bench/verify_adpcm_chip.py reads all four out of
|
||||||
|
; MAME's own -wavwrite capture.
|
||||||
|
;
|
||||||
|
; THE ONE THING THIS FILE HAS TO GET RIGHT is the order of the two starts. The
|
||||||
|
; chip resets its accumulator, its step index AND its nibble select when it is
|
||||||
|
; told to PLAY, and it begins consuming immediately out of whatever its data
|
||||||
|
; register holds. So the channel goes first and the CPU waits for MTC to move
|
||||||
|
; -- proof that a byte has actually been taken -- before the PLAY. The stream
|
||||||
|
; still has a prologue of unknown length, because the gap between PLAY and the
|
||||||
|
; NEXT #DRQ3 is not ours to set; the prologue is 16 zero nibbles for exactly
|
||||||
|
; that reason and the verifier reads its length off the capture.
|
||||||
|
|
||||||
|
include "src/player/geom.i"
|
||||||
|
|
||||||
|
AD_FLAG = $18600 ; u32 0 idle, 1 armed, 2 playing, $FF done, $EE error
|
||||||
|
AD_BUF = $18604 ; u32 where the nibble bytes are
|
||||||
|
AD_LEN = $18608 ; u32 how many bytes
|
||||||
|
AD_MTC0 = $1860C ; u32 MTC at the instant PLAY was written
|
||||||
|
AD_CSRF = $18610 ; u32 CSR at completion
|
||||||
|
AD_CERF = $18614 ; u32 CER with it
|
||||||
|
AD_MTCF = $18618 ; u32 MTC with it
|
||||||
|
AD_MARF = $1861C ; u32 MAR with it -- where the channel stopped
|
||||||
|
AD_SPIN = $18620 ; u32 trips round the wait loop
|
||||||
|
AD_STAT = $18624 ; u32 the chip's own status byte while playing
|
||||||
|
AD_PATIENCE = 60000000
|
||||||
|
AD_SETTLE = 60000 ; ~100 ms at 10 MHz, 18 clocks a trip ; the wait is bounded like every other
|
||||||
|
|
||||||
|
org $10000
|
||||||
|
start:
|
||||||
|
bsr ad_setup
|
||||||
|
|
||||||
|
; ---- SETTLE, and it is not superstition. The 8 MHz ADPCM clock is
|
||||||
|
; CT1 in the YM2151's port register, and this machine delivers that
|
||||||
|
; write to the ADPCM chip on the SOUND system's own schedule rather
|
||||||
|
; than at the instant of the store -- so a transfer started in the same
|
||||||
|
; breath as ad_setup plays its first ~17 ms at the PREVIOUS clock. The
|
||||||
|
; symptom is exact: the capture's first 130-odd samples come out in
|
||||||
|
; identical PAIRS, because the chip is clocking half as fast as the
|
||||||
|
; capture, and every model then fails to fit a stream that changed rate
|
||||||
|
; part way through. ~100 ms of nothing costs the gate nothing and a
|
||||||
|
; player sets its clock once at boot.
|
||||||
|
move.l #AD_SETTLE,d0
|
||||||
|
.settle:subq.l #1,d0
|
||||||
|
bne.s .settle
|
||||||
|
|
||||||
|
movea.l AD_BUF.l,a1
|
||||||
|
move.l AD_LEN.l,d1
|
||||||
|
bsr ad_arm
|
||||||
|
move.l #1,AD_FLAG.l
|
||||||
|
|
||||||
|
; ---- wait for the channel to actually take byte 0. Not a delay loop:
|
||||||
|
; the condition is MTC having moved, which is the channel's own account.
|
||||||
|
move.l #AD_PATIENCE,d3
|
||||||
|
.first: move.w A3_MTC,d0
|
||||||
|
andi.l #$FFFF,d0
|
||||||
|
cmp.l AD_LEN.l,d0
|
||||||
|
bne.s .go
|
||||||
|
subq.l #1,d3
|
||||||
|
bne.s .first
|
||||||
|
bra bad
|
||||||
|
|
||||||
|
.go: move.l d0,AD_MTC0.l
|
||||||
|
bsr ad_play
|
||||||
|
move.l #2,AD_FLAG.l
|
||||||
|
moveq #0,d0
|
||||||
|
move.b AD_CTRLR,d0 ; bit 7 clear = the chip says it is playing
|
||||||
|
move.l d0,AD_STAT.l
|
||||||
|
|
||||||
|
clr.l AD_SPIN.l
|
||||||
|
move.l #AD_PATIENCE,d3
|
||||||
|
.wait: addq.l #1,AD_SPIN.l
|
||||||
|
move.b A3_CSR,d4
|
||||||
|
btst #4,d4 ; ERR -- CER says which
|
||||||
|
bne.s bad
|
||||||
|
btst #7,d4 ; COC
|
||||||
|
bne.s .fin
|
||||||
|
subq.l #1,d3
|
||||||
|
bne.s .wait
|
||||||
|
bra.s bad
|
||||||
|
|
||||||
|
.fin: bsr report
|
||||||
|
; The chip is left PLAYING deliberately: it goes on replaying the last
|
||||||
|
; byte it was given, which the verifier ignores. Stopping here would
|
||||||
|
; put a silence in the capture at a point the host would then have to
|
||||||
|
; find, and the capture already has a length it knows.
|
||||||
|
move.l #$FF,AD_FLAG.l
|
||||||
|
hold: bra.s hold
|
||||||
|
|
||||||
|
bad: bsr report
|
||||||
|
move.l #$EE,AD_FLAG.l
|
||||||
|
bra.s hold
|
||||||
|
|
||||||
|
report:
|
||||||
|
moveq #0,d0
|
||||||
|
move.b A3_CSR,d0
|
||||||
|
move.l d0,AD_CSRF.l
|
||||||
|
moveq #0,d0
|
||||||
|
move.b A3_CER,d0
|
||||||
|
move.l d0,AD_CERF.l
|
||||||
|
move.w A3_MTC,d0
|
||||||
|
andi.l #$FFFF,d0
|
||||||
|
move.l d0,AD_MTCF.l
|
||||||
|
move.l A3_MAR,d0
|
||||||
|
move.l d0,AD_MARF.l
|
||||||
|
rts
|
||||||
|
|
||||||
|
include "src/player/adpcm.i"
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; clock.i -- the FRAME CLOCK, on the 68000 itself. ROADMAP P3.
|
||||||
|
;
|
||||||
|
; src/player/stream.s has a pace gate: frame i may not START before tick i, and
|
||||||
|
; PACE is the tick counter. Until now PACE was written by tools/bench/
|
||||||
|
; stream.lua, i.e. by the host, off the host's idea of what 12 fps means. That
|
||||||
|
; was honest for what FINDINGS 49/51 were measuring -- arrival times against a
|
||||||
|
; deadline -- and it is not a player. A player has no host. These are the
|
||||||
|
; bytes that replace it.
|
||||||
|
;
|
||||||
|
; WHAT THE MACHINE ACTUALLY OFFERS, because "use the MFP timer or vblank" hides
|
||||||
|
; a real constraint. The MC68901's timer clock on this board is 16 MHz / 4 =
|
||||||
|
; 4 MHz (MAME 0.277 src/mame/sharp/x68k.cpp:1027-1028), its prescaler ladder is
|
||||||
|
; {4, 10, 16, 50, 64, 100, 200} (src/devices/machine/mc68901.cpp:173) and its
|
||||||
|
; data register is 8 bits. The SLOWEST tick a single MFP timer can produce is
|
||||||
|
; therefore 4e6 / (200*256) = 78.125 Hz, and 4e6/12 = 333,333.33 is not even an
|
||||||
|
; integer -- so no prescaler/data pair ticks at 12 Hz, and no timer at any
|
||||||
|
; setting ticks as slowly as a 12 fps frame. A frame clock needs a divider in
|
||||||
|
; software whichever source it is built on. tools/analysis/23_frame_clock.py
|
||||||
|
; enumerates the whole space rather than asserting this.
|
||||||
|
;
|
||||||
|
; So the source is the RASTER, and that is a better answer than a timer anyway.
|
||||||
|
; GPIP4 on the MFP is V-DISP (x68k.cpp:1139, `m_crtc->vdisp_cb().set(i4_w)`),
|
||||||
|
; high while the display is active; the same signal is the MFP's Timer A event
|
||||||
|
; input (mc68901.cpp:167, GPIO_TIMER = {GPIP_4, GPIP_3}). Its interrupt is
|
||||||
|
; channel 6, IR_GPIP_4 = $40 in IERB/IPRB/IMRB (mc68901.cpp:76). We take the
|
||||||
|
; FALLING edge (AER bit 4 = 0), which is the start of vertical blanking -- the
|
||||||
|
; instant a player would present a finished frame, so the clock and the flip
|
||||||
|
; are the same event rather than two events with a phase between them.
|
||||||
|
;
|
||||||
|
; THE DIVIDER IS EXACT, AND IT IS EXACT BY CONSTRUCTION. The raster is
|
||||||
|
;
|
||||||
|
; 31,500 lines/s / (R04 + 1) lines/frame
|
||||||
|
;
|
||||||
|
; and 31,500 / 568 = 55.4577 Hz is not a multiple of 12, so a whole-number
|
||||||
|
; divide cannot do it: 4 refreshes is 13.87 fps and 5 is 11.09 fps. Instead
|
||||||
|
; each V-DISP adds `fps * (R04+1)` to an accumulator and a frame tick is emitted
|
||||||
|
; whenever it reaches 31,500, keeping the remainder:
|
||||||
|
;
|
||||||
|
; acc += fps*VTOTAL ; if acc >= HFREQ: acc -= HFREQ ; PACE += 1
|
||||||
|
;
|
||||||
|
; Over VTOTAL/gcd raster frames that emits exactly fps*VTOTAL/gcd ticks, so the
|
||||||
|
; long-run rate is fps*VTOTAL/VTOTAL = fps EXACTLY, with a bounded remainder and
|
||||||
|
; ZERO accumulated drift -- not 12.0001, not 11.9998. It holds for any fps and
|
||||||
|
; any vertical geometry, which is why the two constants are READ OUT OF THE
|
||||||
|
; CRTC at init rather than assembled in: the clock is derived from the same
|
||||||
|
; registers that generate the raster it is counting, so the two cannot disagree.
|
||||||
|
;
|
||||||
|
; WHAT IT COSTS IN CADENCE, WHICH IS THE PART THAT IS NOT FREE. 12 fps on a
|
||||||
|
; 55.4577 Hz raster is 4.6215 refreshes per frame, so a frame is shown for
|
||||||
|
; either 4 or 5 refreshes -- 72.13 ms or 90.16 ms. Nothing can change that;
|
||||||
|
; it is the display's quantisation, not the clock's error, and a timer-derived
|
||||||
|
; clock would have exactly the same cadence with an arbitrary phase against the
|
||||||
|
; raster on top. It does mean the slot a frame gets is NOT always the 83.33 ms
|
||||||
|
; every budget in this project is priced against, and the short slot is 13.4%
|
||||||
|
; under it. tools/analysis/23_frame_clock.py prices that; do not read this file
|
||||||
|
; as a claim that the clock made the budget bigger.
|
||||||
|
;
|
||||||
|
; INTERRUPTS, AND WHAT HAD TO BE TURNED OFF. The rigs launch the 68000 at
|
||||||
|
; SR=$2700 with everything masked, into a machine the IPL ROM has already booted
|
||||||
|
; -- so the MFP arrives with whatever IOCS enabled on it (keyboard receive,
|
||||||
|
; Timer C, its own V-DISP handler) and vectors pointing into IOCS. Lowering the
|
||||||
|
; mask without disarming the MFP would vector into code we did not put there.
|
||||||
|
; clk_init therefore writes IERA = IERB = 0 first, which on the MC68901 also
|
||||||
|
; clears the matching pending bits (mc68901.cpp REGISTER_IERA/B: `m_ipr &=
|
||||||
|
; m_ier`), and only then arms GPIP4 alone. Levels 1-5 stay masked at SR=$2500,
|
||||||
|
; so the DMAC (IRQ3) and the SCC (IRQ5) cannot get in either; level 7 is the
|
||||||
|
; front-panel NMI and is not ours to mask.
|
||||||
|
;
|
||||||
|
; The vector is the MFP's own: VR is written with the S bit CLEAR, so the
|
||||||
|
; in-service register is not used and an acknowledge clears the pending bit by
|
||||||
|
; itself (mc68901.cpp get_vector). No end-of-interrupt write in the handler.
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
; --- MFP registers. The device sits on D0-D7 of a 16-bit bus (x68k.cpp:793,
|
||||||
|
; `.umask16(0x00ff)`), so register n is one BYTE at $E88001 + 2n.
|
||||||
|
MFP = $E88001
|
||||||
|
MFP_GPIP = MFP+0*2
|
||||||
|
MFP_AER = MFP+1*2
|
||||||
|
MFP_DDR = MFP+2*2
|
||||||
|
MFP_IERA = MFP+3*2
|
||||||
|
MFP_IERB = MFP+4*2
|
||||||
|
MFP_IPRA = MFP+5*2
|
||||||
|
MFP_IPRB = MFP+6*2
|
||||||
|
MFP_ISRA = MFP+7*2
|
||||||
|
MFP_ISRB = MFP+8*2
|
||||||
|
MFP_IMRA = MFP+9*2
|
||||||
|
MFP_IMRB = MFP+10*2
|
||||||
|
MFP_VR = MFP+11*2
|
||||||
|
|
||||||
|
MFP_GPIP4 = 4 ; bit number of V-DISP in GPIP/AER/DDR
|
||||||
|
MFP_IVDISP = $40 ; IR_GPIP_4, channel 6, in IERB/IPRB/IMRB
|
||||||
|
MFP_VBASE = $40 ; vector base; S clear -> no in-service register
|
||||||
|
CLK_VEC = (MFP_VBASE+6)*4 ; $118: MFP channel 6 vector, as an ADDRESS
|
||||||
|
|
||||||
|
CRTC = $E80000
|
||||||
|
CRTC_R04 = CRTC+4*2 ; V total, in scanlines, minus one
|
||||||
|
CRTC_R20 = CRTC+20*2 ; mode; bit 4 = 31.5 kHz
|
||||||
|
HFREQ = 31500 ; lines/s in the 31.5 kHz modes. Exact: the
|
||||||
|
; 768-wide IPL mode is 34.776 MHz / 1104 dots
|
||||||
|
; and the 256-wide mode 11.592 MHz / 368, both
|
||||||
|
; 31500.0 (tools/bench/crtc_mode.lua).
|
||||||
|
|
||||||
|
; --- state. PACE is stream.s's, deliberately: the whole point is that the
|
||||||
|
; 68000 now writes the word the host used to write, and the pace gate that
|
||||||
|
; reads it does not change by a single byte.
|
||||||
|
CLK_PACE = $18034 ; == stream.s PACE
|
||||||
|
CLK_ACC = $18060 ; word: Bresenham remainder, < HFREQ
|
||||||
|
CLK_INCR = $18062 ; word: fps * (R04+1), computed by clk_init
|
||||||
|
CLK_VDISP = $18064 ; long: V-DISP edges taken. An INSTRUMENT --
|
||||||
|
; it is what lets a rig check that the tick
|
||||||
|
; count and the raster count are the same clock.
|
||||||
|
CLK_FPS = $18068 ; long: requested fps, an argument to clk_init
|
||||||
|
CLK_ERR = $1806C ; long: 0 ok / 1 not a 31.5 kHz mode
|
||||||
|
; / 2 fps*VTOTAL would overflow 16 bits
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; clk_init -- arm the frame clock. Reads CLK_FPS, leaves CLK_ERR.
|
||||||
|
; Clobbers d0-d2. Leaves the CPU at SR=$2500 on success.
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
clk_init:
|
||||||
|
clr.l CLK_ERR.l
|
||||||
|
clr.l CLK_VDISP.l
|
||||||
|
clr.w CLK_ACC.l
|
||||||
|
clr.l CLK_PACE.l
|
||||||
|
|
||||||
|
; The mode has to be the one HFREQ describes. A 15 kHz mode would halve the
|
||||||
|
; line rate and the divider would run at double speed while looking correct,
|
||||||
|
; which is the failure this test exists to prevent.
|
||||||
|
move.w CRTC_R20.l,d0
|
||||||
|
btst #4,d0
|
||||||
|
bne.s .modeok
|
||||||
|
move.l #1,CLK_ERR.l
|
||||||
|
rts
|
||||||
|
.modeok:
|
||||||
|
; VTOTAL and the increment. Both out of the CRTC, so a change of mode changes
|
||||||
|
; the clock with it. acc is 16-bit and reaches at most HFREQ-1+incr, so incr
|
||||||
|
; must leave room: 65536 - 31500 = 34036. At VTOTAL=568 that is fps < 59.9,
|
||||||
|
; which is every rate this machine can display anyway -- but it is checked
|
||||||
|
; rather than argued.
|
||||||
|
move.w CRTC_R04.l,d0
|
||||||
|
addq.w #1,d0 ; VTOTAL scanlines
|
||||||
|
move.w d0,d1
|
||||||
|
move.w CLK_FPS+2.l,d2 ; low word of the long
|
||||||
|
mulu d2,d1 ; fps * VTOTAL (see FINDINGS 53.4 on
|
||||||
|
; C68K's flat MULU charge; this is boot
|
||||||
|
; code and is not cost-measured there)
|
||||||
|
cmp.l #65536-HFREQ,d1
|
||||||
|
bcs.s .fitok
|
||||||
|
move.l #2,CLK_ERR.l
|
||||||
|
rts
|
||||||
|
.fitok:
|
||||||
|
move.w d1,CLK_INCR.l
|
||||||
|
|
||||||
|
; The vector, before the source is armed.
|
||||||
|
move.l #clk_isr,CLK_VEC.w
|
||||||
|
|
||||||
|
; Disarm everything the IPL left running, then arm GPIP4 alone. Order matters:
|
||||||
|
; IER first (which clears IPR with it), then the edge, then the mask.
|
||||||
|
move.b #0,MFP_IERA.l
|
||||||
|
move.b #0,MFP_IERB.l
|
||||||
|
move.b #0,MFP_IMRA.l
|
||||||
|
move.b #MFP_VBASE,MFP_VR.l ; S clear: acknowledge clears pending
|
||||||
|
bclr #MFP_GPIP4,MFP_DDR.l ; V-DISP is an input
|
||||||
|
bclr #MFP_GPIP4,MFP_AER.l ; interrupt on the FALLING edge, i.e.
|
||||||
|
; at the start of vertical blanking
|
||||||
|
move.b #MFP_IVDISP,MFP_IERB.l
|
||||||
|
move.b #MFP_IVDISP,MFP_IMRB.l
|
||||||
|
move.w #$2500,sr ; let level 6 in; 1-5 stay masked
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; clk_stop -- disarm, and put the mask back where the rigs expect it.
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
clk_stop:
|
||||||
|
move.w #$2700,sr
|
||||||
|
move.b #0,MFP_IERB.l
|
||||||
|
move.b #0,MFP_IMRB.l
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; clk_isr -- one V-DISP. Every instruction here is charged to every frame the
|
||||||
|
; decoder draws, so it is deliberately the shortest thing that is still exact:
|
||||||
|
; four word operations and one long increment.
|
||||||
|
;
|
||||||
|
; Only the LOW WORD of d0 is touched, so only the low word is saved. The
|
||||||
|
; accumulator, the increment and the threshold all fit in 16 bits by the check
|
||||||
|
; in clk_init, which is what makes that legal.
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
clk_isr:
|
||||||
|
move.w d0,-(sp)
|
||||||
|
addq.l #1,CLK_VDISP.l
|
||||||
|
move.w CLK_ACC.l,d0
|
||||||
|
add.w CLK_INCR.l,d0
|
||||||
|
cmp.w #HFREQ,d0
|
||||||
|
bcs.s .nf
|
||||||
|
sub.w #HFREQ,d0
|
||||||
|
addq.l #1,CLK_PACE.l
|
||||||
|
.nf:
|
||||||
|
move.w d0,CLK_ACC.l
|
||||||
|
move.w (sp)+,d0
|
||||||
|
rte
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
; Front-end for the frame clock (ROADMAP P3), for the rig.
|
||||||
|
;
|
||||||
|
; It exists to answer two questions that the streaming rig cannot answer on its
|
||||||
|
; own, because there the clock is buried under a decoder:
|
||||||
|
;
|
||||||
|
; 1. does the tick actually come from the raster, and at exactly the rate
|
||||||
|
; asked for -- measured over thousands of refreshes, not four;
|
||||||
|
; 2. WHAT IT COSTS, in clocks, per interrupt. This project's currency is
|
||||||
|
; 68000 clocks and the decoder already occupies 86.7% of the bus, so a
|
||||||
|
; frame clock is not free until someone has priced it.
|
||||||
|
;
|
||||||
|
; THE INSTRUMENT, and why it is a busy loop. MAME's Lua only sees the machine
|
||||||
|
; at frame boundaries, so it can time to 1/55.46 s and no finer -- 18 ms, where
|
||||||
|
; the whole per-frame cost of this clock is microseconds. Differencing two
|
||||||
|
; wall timings would measure nothing. So the 68000 counts instead: a loop with
|
||||||
|
; ONE instruction in its body runs for a fixed number of refreshes, and the
|
||||||
|
; iteration count is read out at both ends.
|
||||||
|
;
|
||||||
|
; clock off: iters0 * L = clocks in the window -> L
|
||||||
|
; clock on: iters1 * L + ints * H = clocks in the window -> H
|
||||||
|
;
|
||||||
|
; The window is an exact number of raster frames, so its length in clocks is
|
||||||
|
; exact and does not depend on the host at all. L is calibrated out by the
|
||||||
|
; first run rather than assumed from a cycle table, which matters: the point of
|
||||||
|
; the exercise is to price this code on the machine that will run it, and a
|
||||||
|
; table is the thing being checked. With ~2.7e7 iterations behind it, L carries
|
||||||
|
; enough digits that the interrupt cost -- 0.08% of the window -- survives the
|
||||||
|
; subtraction.
|
||||||
|
;
|
||||||
|
; The body is `addq.l #1,CGCNT.l` and nothing else: no compare, no counter in a
|
||||||
|
; register that an interrupt could be accused of disturbing, and a value that
|
||||||
|
; the host can read at any moment without stopping the CPU.
|
||||||
|
;
|
||||||
|
; The gate does NOT decode anything. What the clock does to a real frame is
|
||||||
|
; tools/bench/stream.lua's question, with DLX_PACE=2.
|
||||||
|
|
||||||
|
CGFLAG = $18070 ; 0 idle / 1 running / $EE clk_init refused
|
||||||
|
CGON = $18074 ; 1 = arm the frame clock, 0 = leave it off
|
||||||
|
CGCNT = $18078 ; <- loop iterations, read by the host at both
|
||||||
|
; ends of the window
|
||||||
|
|
||||||
|
org $10000
|
||||||
|
start:
|
||||||
|
clr.l CGCNT.l
|
||||||
|
move.l CGON.l,d0
|
||||||
|
beq.s noclk
|
||||||
|
bsr clk_init
|
||||||
|
tst.l CLK_ERR.l
|
||||||
|
bne.s bad
|
||||||
|
noclk:
|
||||||
|
move.l #1,CGFLAG.l ; the host starts its window here
|
||||||
|
loop:
|
||||||
|
addq.l #1,CGCNT.l
|
||||||
|
bra.s loop
|
||||||
|
bad:
|
||||||
|
move.l #$EE,CGFLAG.l
|
||||||
|
hold: bra.s hold
|
||||||
|
|
||||||
|
include "src/player/clock.i"
|
||||||
+37
-116
@@ -38,6 +38,39 @@
|
|||||||
; paid only by blocks that are NOT all-SKIP: a header byte of zero clears four
|
; paid only by blocks that are NOT all-SKIP: a header byte of zero clears four
|
||||||
; blocks with one tst.b, and SKIP is the median block.
|
; blocks with one tst.b, and SKIP is the median block.
|
||||||
;
|
;
|
||||||
|
; LITERAL SPANS (v7, FINDINGS 40). A run of horizontally adjacent dirty blocks
|
||||||
|
; is cheaper to paint as four ROW-LINEAR runs of word-expanded literal pixels
|
||||||
|
; than as blocks: 226 clocks per 4x4 block at a run of 4, against V1's 299.9,
|
||||||
|
; and the break-even is a run of 2. The run's blocks read SKIP in the mode
|
||||||
|
; header and the span section paints them instead, so the block loop below is
|
||||||
|
; unchanged -- it sees a SKIP and advances, exactly as it does for a genuinely
|
||||||
|
; held block.
|
||||||
|
;
|
||||||
|
; The section sits BETWEEN the mode header and the block payload because that is
|
||||||
|
; the only place the 68000 can reach without first parsing something of variable
|
||||||
|
; length: the header is a fixed 768 bytes. Per span the record is {u32 absolute
|
||||||
|
; GVRAM address, u16 coarse displacement}, then the coarse pixels, then a u16
|
||||||
|
; FINE displacement, then the fine pixels.
|
||||||
|
;
|
||||||
|
; The two displacements are jumps into two unrolled copy chains -- 24 pixels per
|
||||||
|
; coarse unit (a 12-register movem pair) and 2 per fine unit (one
|
||||||
|
; `move.l (a0)+,(a2)+`) -- so a span of any length is straight-line code with no
|
||||||
|
; loop, no remainder and no address arithmetic. A run of 4x4 blocks is always a
|
||||||
|
; multiple of 4 pixels long, and 4 is a multiple of the 2-pixel fine quantum, so
|
||||||
|
; NOTHING is padded (FINDINGS 40.3).
|
||||||
|
;
|
||||||
|
; The fine displacement is in the STREAM rather than in the span record because
|
||||||
|
; that is what pays for the second dispatch: when the coarse chain falls out
|
||||||
|
; into `move.w (a0)+,d0 / jmp`, d0 is dead payload and a0 is already pointing at
|
||||||
|
; it, so the decoder holds nothing extra across the copy and keeps all twelve
|
||||||
|
; payload registers (FINDINGS 40.4). Twelve is why the coarse unit is 24 pixels
|
||||||
|
; and not V5's 16, and it is the whole reason the per-pixel cost is 9.143 rather
|
||||||
|
; than 10.459 (FINDINGS 30.4).
|
||||||
|
;
|
||||||
|
; a1 (the mode header cursor) is one of those twelve, so it goes on the stack
|
||||||
|
; across the span pass. Two long accesses per frame, against the 24 pixels a
|
||||||
|
; register buys per chain unit.
|
||||||
|
;
|
||||||
; ALIGNMENT. Frame records are [u32 length][768-byte mode header][payload] laid
|
; ALIGNMENT. Frame records are [u32 length][768-byte mode header][payload] laid
|
||||||
; end to end, and payload lengths are arbitrary -- so record boundaries land on
|
; end to end, and payload lengths are arbitrary -- so record boundaries land on
|
||||||
; odd addresses, and `move.l (a0)+,d0` on an odd address is an ADDRESS ERROR on
|
; odd addresses, and `move.l (a0)+,d0` on an odd address is an ADDRESS ERROR on
|
||||||
@@ -55,14 +88,8 @@ FPTR = $18010 ; -> first frame record
|
|||||||
SCR_N = $18014 ; frames remaining this pass
|
SCR_N = $18014 ; frames remaining this pass
|
||||||
SCR_END = $18018 ; expected end of the current payload
|
SCR_END = $18018 ; expected end of the current payload
|
||||||
|
|
||||||
CB1 = $20000 ; expanded 4x4 codebook
|
include "src/player/geom.i"
|
||||||
CB4 = $22000 ; expanded 2x2 codebook
|
|
||||||
|
|
||||||
DST0 = $C08000 ; GVRAM + 32*1024 (first picture row)
|
|
||||||
DSTE = $C38000 ; GVRAM + 224*1024 (one past last)
|
|
||||||
BROW = 4096 ; bytes per block row (4 picture rows)
|
|
||||||
ROWLEN = 512 ; bytes per block row of blocks (64 * 8)
|
|
||||||
MODEB = 768 ; packed mode header, 3072 blocks * 2 bits
|
|
||||||
|
|
||||||
org $10000
|
org $10000
|
||||||
start:
|
start:
|
||||||
@@ -75,7 +102,8 @@ frameloop:
|
|||||||
lea 0(a0,d0.l),a1
|
lea 0(a0,d0.l),a1
|
||||||
move.l a1,SCR_END.l ; where the payload must end
|
move.l a1,SCR_END.l ; where the payload must end
|
||||||
move.l a0,a1 ; a1 = packed mode header
|
move.l a0,a1 ; a1 = packed mode header
|
||||||
lea MODEB(a0),a0 ; a0 = payload
|
lea MODEB(a0),a0 ; a0 = span section
|
||||||
|
bsr paint_spans ; -> a0 = block payload, a1 preserved
|
||||||
bsr decode_frame
|
bsr decode_frame
|
||||||
cmpa.l SCR_END.l,a0 ; bitstream desync is silent otherwise
|
cmpa.l SCR_END.l,a0 ; bitstream desync is silent otherwise
|
||||||
bne desync
|
bne desync
|
||||||
@@ -92,111 +120,4 @@ hold: bra.s hold
|
|||||||
desync: move.l #$EE,FLAG.l
|
desync: move.l #$EE,FLAG.l
|
||||||
bra.s hold
|
bra.s hold
|
||||||
|
|
||||||
; ---------------------------------------------------------------- one block
|
include "src/player/frame.i"
|
||||||
; \1 = right-shift needed to bring this block's 2 mode bits to bits 1-0.
|
|
||||||
BLOCK macro
|
|
||||||
move.b (a1),d0
|
|
||||||
ifne \1
|
|
||||||
lsr.b #\1,d0
|
|
||||||
endc
|
|
||||||
and.w #3,d0
|
|
||||||
beq .sk\@ ; 00 SKIP -- the median block
|
|
||||||
subq.w #1,d0
|
|
||||||
beq .v1\@ ; 01 V1
|
|
||||||
subq.w #1,d0
|
|
||||||
bne .rw\@ ; 11 RAW, else 10 V4
|
|
||||||
|
|
||||||
; -- V4: four 2x2 codewords, sub-block order TL TR BL BR (vq_hybrid.paint)
|
|
||||||
moveq #0,d0
|
|
||||||
move.b (a0)+,d0
|
|
||||||
lsl.w #3,d0
|
|
||||||
movem.l (a3,d0.w),d0-d1
|
|
||||||
move.l d0,(a4)
|
|
||||||
move.l d1,1024(a4)
|
|
||||||
moveq #0,d0
|
|
||||||
move.b (a0)+,d0
|
|
||||||
lsl.w #3,d0
|
|
||||||
movem.l (a3,d0.w),d0-d1
|
|
||||||
move.l d0,4(a4)
|
|
||||||
move.l d1,1028(a4)
|
|
||||||
moveq #0,d0
|
|
||||||
move.b (a0)+,d0
|
|
||||||
lsl.w #3,d0
|
|
||||||
movem.l (a3,d0.w),d0-d1
|
|
||||||
move.l d0,2048(a4)
|
|
||||||
move.l d1,3072(a4)
|
|
||||||
moveq #0,d0
|
|
||||||
move.b (a0)+,d0
|
|
||||||
lsl.w #3,d0
|
|
||||||
movem.l (a3,d0.w),d0-d1
|
|
||||||
move.l d0,2052(a4)
|
|
||||||
move.l d1,3076(a4)
|
|
||||||
bra .sk\@
|
|
||||||
|
|
||||||
; -- V1: one 4x4 codeword, 32 bytes, straight out of the expanded codebook
|
|
||||||
.v1\@:
|
|
||||||
moveq #0,d0
|
|
||||||
move.b (a0)+,d0
|
|
||||||
lsl.w #5,d0
|
|
||||||
movem.l (a2,d0.w),d0-d7 ; EA is resolved before the load
|
|
||||||
movem.l d0-d1,(a4)
|
|
||||||
movem.l d2-d3,1024(a4)
|
|
||||||
movem.l d4-d5,2048(a4)
|
|
||||||
movem.l d6-d7,3072(a4)
|
|
||||||
bra .sk\@
|
|
||||||
|
|
||||||
; -- RAW: 16 literal palette indices. Two indices are assembled into one long
|
|
||||||
; via swap, so each pair of pixels costs one write instead of two; the high
|
|
||||||
; byte of each word is left as zero because the hardware discards it anyway.
|
|
||||||
.rw\@:
|
|
||||||
RAWPAIR 0
|
|
||||||
RAWPAIR 4
|
|
||||||
RAWPAIR 1024
|
|
||||||
RAWPAIR 1028
|
|
||||||
RAWPAIR 2048
|
|
||||||
RAWPAIR 2052
|
|
||||||
RAWPAIR 3072
|
|
||||||
RAWPAIR 3076
|
|
||||||
.sk\@:
|
|
||||||
addq.l #8,a4
|
|
||||||
endm
|
|
||||||
|
|
||||||
RAWPAIR macro
|
|
||||||
moveq #0,d0
|
|
||||||
move.b (a0)+,d0
|
|
||||||
swap d0
|
|
||||||
move.b (a0)+,d0
|
|
||||||
move.l d0,\1(a4)
|
|
||||||
endm
|
|
||||||
|
|
||||||
; ------------------------------------------------------------- one frame
|
|
||||||
; in: a0 = payload, a1 = packed mode header
|
|
||||||
; out: a0 = one past the last payload byte consumed
|
|
||||||
decode_frame:
|
|
||||||
lea CB1,a2
|
|
||||||
lea CB4,a3
|
|
||||||
lea DST0,a6
|
|
||||||
rowloop:
|
|
||||||
move.l a6,a4
|
|
||||||
lea ROWLEN(a6),a5
|
|
||||||
byteloop:
|
|
||||||
tst.b (a1) ; four SKIPs in one test
|
|
||||||
beq allskip
|
|
||||||
BLOCK 6
|
|
||||||
BLOCK 4
|
|
||||||
BLOCK 2
|
|
||||||
BLOCK 0
|
|
||||||
addq.l #1,a1
|
|
||||||
cmpa.l a5,a4
|
|
||||||
bne byteloop
|
|
||||||
bra rowdone
|
|
||||||
allskip:
|
|
||||||
addq.l #1,a1
|
|
||||||
lea 32(a4),a4
|
|
||||||
cmpa.l a5,a4
|
|
||||||
bne byteloop
|
|
||||||
rowdone:
|
|
||||||
lea BROW(a6),a6
|
|
||||||
cmpa.l #DSTE,a6
|
|
||||||
bne rowloop
|
|
||||||
rts
|
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
; The HD63450 driving the SCSI data phase. ROADMAP P4a.
|
||||||
|
;
|
||||||
|
; WHAT P4a HAS TO SHOW, and why it needed a new kind of evidence. FINDINGS 58
|
||||||
|
; measured the CPU moving every byte itself at 87.28 clocks per delivered byte
|
||||||
|
; -- 391.8% of a 12 fps frame, against 22.4% for the cheapest DMA row of the
|
||||||
|
; ladder and 85.3% for the dearest. So the whole of what is left before M2 is a
|
||||||
|
; DMAC configuration that HOLDS THE BUS. 57.3 is why it could not simply be
|
||||||
|
; watched into existence: x68k_scsiext.cpp glues $EA0015 so that with the DMAC's
|
||||||
|
; OWN asserted -- which it is at idle on this machine -- MAME CANNOT DISTINGUISH
|
||||||
|
; a CPU-driven byte at that address from a DMAC-driven one. Watching the data
|
||||||
|
; register cannot answer the question it looks like it answers.
|
||||||
|
;
|
||||||
|
; THE DISCRIMINATOR USED HERE IS THE CPU'S OWN PROGRESS, and it never reads
|
||||||
|
; $EA0015 at all. A DMAC that holds the bus is one the CPU is not running
|
||||||
|
; against; so the witness is a single instruction:
|
||||||
|
;
|
||||||
|
; move.b #CCR_START,DM_CCR ; the channel is told to go
|
||||||
|
; move.w DM_MTC,d0 ; <- sampled by the VERY NEXT instruction
|
||||||
|
;
|
||||||
|
; If the bus was held for the transfer, the whole transfer happened between
|
||||||
|
; those two instructions and d0 reads ZERO. If it was not, d0 reads very nearly
|
||||||
|
; the full count and the CPU goes on to spin thousands of times while the
|
||||||
|
; channel trickles. Both configurations deliver the same bytes; what separates
|
||||||
|
; them is whether the 68000 got to execute anything meanwhile, which is exactly
|
||||||
|
; what "holds the bus" means and is not a fact about $EA0015.
|
||||||
|
;
|
||||||
|
; WHAT MAME CAN AND CANNOT BE ASKED, stated here because it bounds the claim and
|
||||||
|
; it is not obvious from the outside:
|
||||||
|
;
|
||||||
|
; * THE CARD HAS NO EXREQ PATH. x68k_scsiext.cpp's drq_w only stores a flag;
|
||||||
|
; the expansion slot has no request line to the DMAC at all (x68k.cpp wires
|
||||||
|
; drq0 from the FDC and drq3 from ADPCM, and nothing else). The card's flow
|
||||||
|
; control is DTACK: on a DMAC cycle with DRQ low the card NEGATES DTACK and
|
||||||
|
; the HD63450 discards that operand and retries. So every configuration
|
||||||
|
; below is AUTO-REQUEST; REQG=10, external request -- the mode the ladder's
|
||||||
|
; W=5 and W=12 rows assume -- has no wiring in this model and cannot be run.
|
||||||
|
; * SINGLE ADDRESS CANNOT BE RUN EITHER. hd63450.cpp only takes the implicit
|
||||||
|
; path when a channel has a dma_read/dma_write callback, and on this machine
|
||||||
|
; only channel 0 (the FDC) has one. DTYP=10/11 on channels 1..3 falls
|
||||||
|
; through to the dual-address code.
|
||||||
|
; * ONLY BURST IS MODELLED AS HELD. The device tests `(dcr & 0xc0) == 0`, so
|
||||||
|
; XRM=10 (cycle steal without hold) and XRM=11 (cycle steal WITH hold) are
|
||||||
|
; one code path. The bus is held, and the CPU halted, only for XRM=00 burst
|
||||||
|
; with REQG=01 max rate.
|
||||||
|
;
|
||||||
|
; So of the four rows of the per-byte ladder, exactly ONE -- dual address, bus
|
||||||
|
; held, 9 clk/B -- has a code path in this model, and it is the one demonstrated
|
||||||
|
; below. That is a bound on the apparatus and not a result about the board.
|
||||||
|
;
|
||||||
|
; AND IT IS STILL NOT A RATE. MAME's DMAC is configured in wall-clock attotimes
|
||||||
|
; (42.5), not per-operand cycles: set_burst_clocks gives channel 1 450 ns an
|
||||||
|
; operand no matter what the 68000 is doing. `W` is untouched by every line in
|
||||||
|
; this file and still wants a board (ROADMAP B1/B3).
|
||||||
|
|
||||||
|
; ---- the channel. 1, not 0: channel 0 is the FDC's and is the one channel
|
||||||
|
; with device callbacks, which would silently take the implicit-address path.
|
||||||
|
; Channel 1 is also the channel the IPL ROM points at the SASI data register
|
||||||
|
; (52.5), so this is the machine's own disk channel programmed differently.
|
||||||
|
DMA_CH = 1
|
||||||
|
DMACB = DMAC+DMA_CH*DMAC_CH ; $E84040
|
||||||
|
DM_CSR = DMACB+$00 ; channel status (write 1s to clear)
|
||||||
|
DM_CER = DMACB+$01 ; channel error (read only)
|
||||||
|
DM_DCR = DMACB+$04 ; device control
|
||||||
|
DM_OCR = DMACB+$05 ; operation control
|
||||||
|
DM_SCR = DMACB+$06 ; sequence control
|
||||||
|
DM_CCR = DMACB+$07 ; channel control
|
||||||
|
DM_MTC = DMACB+$0A ; memory transfer count, WORD
|
||||||
|
DM_MAR = DMACB+$0C ; memory address, LONG
|
||||||
|
DM_DAR = DMACB+$14 ; device address, LONG
|
||||||
|
DM_BTC = DMACB+$1A ; base transfer count, WORD (array chain)
|
||||||
|
DM_BAR = DMACB+$1C ; base address, LONG (array chain)
|
||||||
|
DM_MFC = DMACB+$29
|
||||||
|
DM_CPR = DMACB+$2D
|
||||||
|
DM_DFC = DMACB+$31
|
||||||
|
|
||||||
|
; CSR bits
|
||||||
|
CSR_COC = $80 ; channel operation complete
|
||||||
|
CSR_BTC = $40
|
||||||
|
CSR_NDT = $20 ; normal device termination
|
||||||
|
CSR_ERR = $10 ; channel error -- CER says which
|
||||||
|
CSR_ACT = $08 ; channel active
|
||||||
|
CCR_START = $80
|
||||||
|
|
||||||
|
; ---- the two configurations, as (DCR, OCR) pairs. Both are decoded by
|
||||||
|
; tools/analysis/27_dmac_config.py out of THESE bytes, using the same MC68450
|
||||||
|
; field tables 21_iplrom_dmac.py reads the IPL ROM's channels with -- so what
|
||||||
|
; the run claims it programmed and what it programmed cannot drift apart.
|
||||||
|
;
|
||||||
|
; HELD : DCR $00 = XRM 00 burst, DTYP 00 dual address, DPS 0 8-bit port
|
||||||
|
; OCR $81 = DIR device->memory, SIZE byte, no chain, REQG 01 max rate
|
||||||
|
; STEAL: DCR $80 = XRM 10 cycle steal WITHOUT hold, otherwise identical
|
||||||
|
; OCR $80 = REQG 00 auto-request at limited rate
|
||||||
|
DM_HELD_DCR = $00
|
||||||
|
DM_HELD_OCR = $81
|
||||||
|
DM_STEAL_DCR = $80
|
||||||
|
DM_STEAL_OCR = $80
|
||||||
|
|
||||||
|
; ---- what the run reports. Every one of these is a DMAC register or a count
|
||||||
|
; of the CPU's own instructions; none of them is a read of $EA0015.
|
||||||
|
; $18500 AND NOT $18300, WHICH IS WHERE THIS FIRST WENT. scsi.i's trace ends at
|
||||||
|
; $182FF and the next 160 bytes are the RING's: $18300 is ring.i's XF_SLOT
|
||||||
|
; mailbox, and tools/bench/stream.lua reads the same addresses from outside.
|
||||||
|
; dma.i is included by stream.s as well as by the gate, so DM_USE landed on the
|
||||||
|
; transfer request slot and the ring rig's first record request read as "use the
|
||||||
|
; DMAC" -- P4b's stage went red on a run that never reached its snapshot. The
|
||||||
|
; symptom was in a stage this session did not touch, which is the whole argument
|
||||||
|
; for check.sh being run before and after rather than only after.
|
||||||
|
DM_USE = $18500 ; u32 0 = PIO data phase, 1 = this file
|
||||||
|
DM_DCRV = $18504 ; u32 the DCR byte to program
|
||||||
|
DM_OCRV = $18508 ; u32 the OCR byte to program
|
||||||
|
DM_MTC0 = $1850C ; u32 MTC one instruction after START
|
||||||
|
DM_SPIN = $18510 ; u32 times the CPU went round the wait
|
||||||
|
DM_CSRF = $18514 ; u32 CSR when the channel finished
|
||||||
|
DM_CERF = $18518 ; u32 CER with it
|
||||||
|
DM_MTCF = $1851C ; u32 MTC with it
|
||||||
|
DM_MARF = $18520 ; u32 MAR with it -- where it stopped
|
||||||
|
DM_LEN = $18524 ; u32 bytes the channel was asked for
|
||||||
|
; ---- SEQUENTIAL ARRAY CHAINING, and it is OFF unless a caller asks for it.
|
||||||
|
; A device->GVRAM transfer cannot be one contiguous run: a picture row is 256 B
|
||||||
|
; of a 1024 B line stride, so 192 rows want 192 destinations. The MC68450 walks
|
||||||
|
; an array of 6-byte {u32 MAR, u16 MTC} entries for exactly this, and MAME's
|
||||||
|
; hd63450 implements it (`(ocr & 0x0c) == 0x08`, dma_transfer_start and
|
||||||
|
; dma_transfer_continue). DM_BARV = 0 means no chaining and NOTHING below
|
||||||
|
; changes, which is what stream.s gets: this file is included by the player as
|
||||||
|
; well as by the gate, and a mailbox that defaults to a new behaviour is how
|
||||||
|
; DM_USE landed on ring.i's slot and turned a stage red (above).
|
||||||
|
DM_BARV = $18528 ; u32 array base address, 0 = no chain
|
||||||
|
DM_BTCV = $1852C ; u32 array entry count (BTC)
|
||||||
|
; ---- THE SERVICE HOOK, and it is the whole of what a SECOND consumer needs
|
||||||
|
; from this file. ROADMAP P6c. A player that feeds ADPCM has to look at the
|
||||||
|
; audio channel more often than once a frame: the MSM6258 has no FIFO, so the
|
||||||
|
; instant its channel counts out the chip goes on replaying whatever byte its
|
||||||
|
; data register still holds -- and at 12 fps a once-a-frame re-arm makes that
|
||||||
|
; replay 90 ms long, which is not a gap, it is a buzz.
|
||||||
|
;
|
||||||
|
; THE ONLY PLACE A 68000 HAS TO SPARE IS INSIDE THIS WAIT. In the STEALING
|
||||||
|
; configuration the CPU goes round the loop below thousands of times per record
|
||||||
|
; (measured: 1,100,520 trips over 120 frames) and every one of them is time the
|
||||||
|
; disc is delivering and the CPU is not. So the hook is called from there, and
|
||||||
|
; the second consumer costs the video path nothing it was using.
|
||||||
|
;
|
||||||
|
; AND IN THE HELD CONFIGURATION THE HOOK CANNOT RUN AT ALL, which is not a bug
|
||||||
|
; in it: a burst channel HALTS the 68000 (dma_transfer_start asserts
|
||||||
|
; INPUT_LINE_HALT) and the CPU does not execute the loop, or anything else,
|
||||||
|
; until the record has landed. That asymmetry is the measurement -- FINDINGS
|
||||||
|
; 64.3 showed a held channel costs the frame CLOCK half its ticks, and this is
|
||||||
|
; the same fact reaching the audio.
|
||||||
|
;
|
||||||
|
; ZERO BY DEFAULT and every other front-end in this tree leaves it zero, so the
|
||||||
|
; cost to them is a `move.l` and a `beq` per trip. A mailbox that defaulted to
|
||||||
|
; a new behaviour is how DM_USE landed on ring.i's slot (above).
|
||||||
|
DM_HOOK = $18530 ; u32 0 = none, else a routine to call
|
||||||
|
; on every trip round the transfer wait.
|
||||||
|
; d0 is dead here and a0 is saved round
|
||||||
|
; the call, so the hook may trash both;
|
||||||
|
; it must preserve EVERYTHING else,
|
||||||
|
; because sc_in_dma's own d3/d4/d5 and
|
||||||
|
; scsi_read's a1 are live across it.
|
||||||
|
DM_PATIENCE = 4000000 ; the wait is bounded like every other
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- sc_in_dma
|
||||||
|
; Receive d1 bytes into (a1) in phase d2, WITHOUT the CPU touching one of them.
|
||||||
|
; Entered from sc_in_data when DM_USE is set; same registers, same contract.
|
||||||
|
;
|
||||||
|
; ORDER MATTERS AND IT IS NOT THE OBVIOUS ONE. The SPC is put into DMA transfer
|
||||||
|
; BEFORE the channel is started, because in the held configuration the 68000
|
||||||
|
; stops executing at the CCR write and does not run again until the transfer is
|
||||||
|
; over -- so anything the SPC needs to be told has to have been told already.
|
||||||
|
sc_in_dma:
|
||||||
|
movem.l d3-d5,-(sp)
|
||||||
|
move.l d1,d5 ; keep the length for the report
|
||||||
|
move.l d5,DM_LEN.l
|
||||||
|
move.b d2,SC_PCTL
|
||||||
|
move.l d1,d0
|
||||||
|
bsr sc_settc ; the SPC counts the same bytes down
|
||||||
|
|
||||||
|
; ---- the channel, quiet first: CSR is write-one-to-clear and a stale
|
||||||
|
; COC from a previous record would pass the wait loop instantly.
|
||||||
|
move.b #$FF,DM_CSR
|
||||||
|
move.l DM_DCRV.l,d0
|
||||||
|
move.b d0,DM_DCR
|
||||||
|
move.l DM_OCRV.l,d0
|
||||||
|
move.b d0,DM_OCR
|
||||||
|
move.b #$04,DM_SCR ; MAC 01 memory increment, DAC 00 none:
|
||||||
|
; the device address is a REGISTER and
|
||||||
|
; must not walk off it.
|
||||||
|
move.b #$05,DM_MFC ; the function codes the IPL ROM uses
|
||||||
|
move.b #$05,DM_DFC
|
||||||
|
move.b #$01,DM_CPR
|
||||||
|
move.l DM_BARV.l,d0
|
||||||
|
bne.s .chain
|
||||||
|
move.w d5,DM_MTC
|
||||||
|
move.l a1,DM_MAR
|
||||||
|
bra.s .darset
|
||||||
|
.chain:
|
||||||
|
; MAR and MTC are NOT written: the channel loads both from the array's
|
||||||
|
; first entry when it starts, and reloads them from the next entry at
|
||||||
|
; every count-out. Writing them here would be writing registers the
|
||||||
|
; hardware is about to overwrite, which reads like a contract and is not.
|
||||||
|
move.l d0,DM_BAR
|
||||||
|
move.l DM_BTCV.l,d0
|
||||||
|
move.w d0,DM_BTC
|
||||||
|
.darset:
|
||||||
|
move.l #SC_DREG,DM_DAR ; $EA0015 -- the DMAC's door, and now
|
||||||
|
; the DMAC is the one going through it
|
||||||
|
move.b #SCMD_XFER,SC_SCMD ; no PROGRAM bit: the SPC raises DRQ
|
||||||
|
move.l #11,SC_TAG.l ; 11 = channel armed, SPC in DMA mode
|
||||||
|
bsr sc_snap
|
||||||
|
|
||||||
|
; ---- START, and the witness immediately after it
|
||||||
|
move.b #CCR_START,DM_CCR
|
||||||
|
move.w DM_MTC,d0 ; THE DISCRIMINATOR. Held: zero.
|
||||||
|
andi.l #$FFFF,d0
|
||||||
|
move.l d0,DM_MTC0.l
|
||||||
|
|
||||||
|
; ---- wait for the channel, counting the CPU's own trips round the loop.
|
||||||
|
; In the held configuration this is one trip, because the CPU did not
|
||||||
|
; get to run until the transfer was over. In the stealing one it is
|
||||||
|
; thousands, and every one of them is a 68000 instruction that executed
|
||||||
|
; while the disc was delivering -- which is the whole point of P4a.
|
||||||
|
clr.l DM_SPIN.l
|
||||||
|
move.l #DM_PATIENCE,d3
|
||||||
|
.wait: addq.l #1,DM_SPIN.l
|
||||||
|
move.l DM_HOOK.l,d0 ; the second consumer's slot -- see above
|
||||||
|
beq.s .nohook
|
||||||
|
move.l a0,-(sp)
|
||||||
|
movea.l d0,a0
|
||||||
|
jsr (a0)
|
||||||
|
movea.l (sp)+,a0
|
||||||
|
.nohook:
|
||||||
|
move.b DM_CSR,d4
|
||||||
|
btst #4,d4 ; ERR
|
||||||
|
bne.s .err
|
||||||
|
btst #7,d4 ; COC
|
||||||
|
bne.s .fin
|
||||||
|
subq.l #1,d3
|
||||||
|
bne.s .wait
|
||||||
|
bsr .report
|
||||||
|
movem.l (sp)+,d3-d5
|
||||||
|
move.l #SCE_TIMEOUT,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
.err: bsr .report
|
||||||
|
movem.l (sp)+,d3-d5
|
||||||
|
move.l #SCE_TIMEOUT,SC_ERR.l ; a channel error is a dead transport
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
.fin: bsr .report
|
||||||
|
move.b #$FF,DM_CSR ; leave the channel as we found it
|
||||||
|
move.l #12,SC_TAG.l ; 12 = channel reported COC
|
||||||
|
bsr sc_snap
|
||||||
|
movem.l (sp)+,d3-d5
|
||||||
|
bsr sc_xferend ; the SPC's own transfer, not the DMAC's
|
||||||
|
move.l d0,-(sp)
|
||||||
|
move.l #9,SC_TAG.l
|
||||||
|
bsr sc_snap
|
||||||
|
move.l (sp)+,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---- the channel's own account of what it did, read out of its registers
|
||||||
|
.report:
|
||||||
|
moveq #0,d0
|
||||||
|
move.b DM_CSR,d0
|
||||||
|
move.l d0,DM_CSRF.l
|
||||||
|
moveq #0,d0
|
||||||
|
move.b DM_CER,d0
|
||||||
|
move.l d0,DM_CERF.l
|
||||||
|
move.w DM_MTC,d0
|
||||||
|
andi.l #$FFFF,d0
|
||||||
|
move.l d0,DM_MTCF.l
|
||||||
|
move.l DM_MAR,d0
|
||||||
|
move.l d0,DM_MARF.l
|
||||||
|
rts
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
; Front-end for the HD63450 DATA PHASE (ROADMAP P4a), for the rig.
|
||||||
|
;
|
||||||
|
; THE QUESTION. FINDINGS 58 put the transport on the 68000 and priced it: the
|
||||||
|
; CPU moving every byte itself costs 87.28 clocks per delivered byte, 391.8% of
|
||||||
|
; a 12 fps frame. Against that, the cheapest DMA row of the ladder is 22.4% and
|
||||||
|
; the dearest is 85.3%, so everything left before M2 turns on getting the DMAC
|
||||||
|
; to drive the data phase with the bus HELD. 57.3 is why it cannot be shown by
|
||||||
|
; watching the data register: with the DMAC's OWN asserted, which it is at idle
|
||||||
|
; here, MAME cannot tell a CPU-driven byte at $EA0015 from a DMAC-driven one.
|
||||||
|
;
|
||||||
|
; THE EVIDENCE THIS GATE PRODUCES, and none of it is a read of $EA0015:
|
||||||
|
;
|
||||||
|
; 1. THE SAME BYTES. The same sectors are read three times -- once by the PIO
|
||||||
|
; path FINDINGS 58 measured, once by the channel with the bus held, once by
|
||||||
|
; the channel stealing cycles -- and the HOST compares all three against its
|
||||||
|
; own copy of the image. A transport that returns the wrong bytes without
|
||||||
|
; saying so is the failure a checksum-free ring cannot survive (49.2).
|
||||||
|
; 2. THE CPU'S OWN PROGRESS. MTC is sampled by the INSTRUCTION AFTER the one
|
||||||
|
; that starts the channel. Held, it reads zero: the entire transfer
|
||||||
|
; happened between two instructions, because the 68000 did not execute in
|
||||||
|
; between. Stealing, it reads nearly the full count and the CPU then goes
|
||||||
|
; round its wait loop thousands of times while the bytes arrive. That
|
||||||
|
; difference IS "the DMAC held the bus", and it is a fact about the CPU.
|
||||||
|
; 3. THE CHANNEL'S OWN ACCOUNT. CSR, CER, the final MTC and the final MAR:
|
||||||
|
; the channel says it completed without error, moved every byte, and left
|
||||||
|
; its memory pointer exactly one transfer-length past where it started.
|
||||||
|
; 4. THE WINDOW IS REFUSED. A windowed read (58.3: 117 of 120 records start
|
||||||
|
; part way into a sector) is rejected by the transport rather than silently
|
||||||
|
; delivering the neighbouring records' bytes into the ring. P4a's
|
||||||
|
; precondition is stated by the code that has it, not by a comment.
|
||||||
|
;
|
||||||
|
; WHAT IT DOES NOT SHOW. Not `W`. Not one clock of it. MAME's DMAC runs on
|
||||||
|
; wall-clock attotimes (42.5) and its burst mode halts the CPU outright rather
|
||||||
|
; than costing it cycles per operand, so this gate settles WHICH CONFIGURATION
|
||||||
|
; WORKS and not what one costs. See src/player/dma.i for the three ways this
|
||||||
|
; model bounds the question -- no EXREQ wiring, no single-address path, and only
|
||||||
|
; burst modelled as held.
|
||||||
|
|
||||||
|
DGFLAG = $18600 ; 0 idle / 1 done
|
||||||
|
DGREC = $18800 ; 9 x 32 B: rc, err, mtc0, spin, csr, cer, mtc, mar
|
||||||
|
; $18800 AND NOT $18610, WHERE THIS LIVED: nine
|
||||||
|
; records of 32 B run to $188FF, and from $18610
|
||||||
|
; they would have run over DGWIN at $18700 --
|
||||||
|
; the window run's own result, which run 10 then
|
||||||
|
; writes back. A silent overlap between two
|
||||||
|
; runs' evidence is the kind of thing that makes
|
||||||
|
; a gate report the wrong run's numbers.
|
||||||
|
DGREC_SZ = 32
|
||||||
|
DGWIN = $18700 ; u32 return of the WINDOWED dma read (want -1)
|
||||||
|
DGWERR = $18704 ; u32 SC_ERR after it (want SCE_WINDOW)
|
||||||
|
DGR20 = $18708 ; u32 R20 as it stood during run 4
|
||||||
|
DGR20N = $1870C ; u32 R20 as it stood during run 5 (the control)
|
||||||
|
DGR20C = $18710 ; u32 R20 as it stood during run 6 (chained)
|
||||||
|
DGR20P = $18714 ; u32 R20 as it stood during run 9 (palette+rows)
|
||||||
|
R20_BUF = $0916 ; 256-colour, 31.5 kHz, G-VRAM SET TO BUFFER
|
||||||
|
R20_MSK = $0116 ; the SAME, bit 11 CLEAR -- the negative control
|
||||||
|
DGDST4 = $C0C000 ; where the control writes
|
||||||
|
DGCHA = $19000 ; the array-chain table: 8 x {u32 MAR, u16 MTC}
|
||||||
|
DGCHN = 8 ; entries
|
||||||
|
DGCROW = 256 ; bytes an entry carries -- one packed picture row
|
||||||
|
DGDST5 = $C10000 ; and the 8 row bases, at the 1024 B line stride
|
||||||
|
CRTC20 = $E80028 ; CRTC R20
|
||||||
|
DGDST3 = $C08000 ; DMA straight into GVRAM, BUFFER MODE (47.6.2)
|
||||||
|
DGLBA = 1000 ; a NON-ZERO LBA throughout: a driver that emits
|
||||||
|
; a malformed LBA field still passes LBA 0
|
||||||
|
DGBLK = 4 ; 4 x 512 = 2,048 B
|
||||||
|
DGDST0 = $20000 ; PIO
|
||||||
|
DGDST1 = $24000 ; DMA, bus held
|
||||||
|
DGDST2 = $28000 ; DMA, cycle stealing
|
||||||
|
|
||||||
|
; ---- runs 7-9: THE PALETTE (ROADMAP K1, FINDINGS 61.9's first open item).
|
||||||
|
DGPAL = $E82000 ; the GRAPHIC palette: 256 words, GGGGGRRRRRBBBBBI
|
||||||
|
DGPALN = 256 ; words in it -- and 512 B is exactly ONE sector
|
||||||
|
DGPBLK = 1 ; so the whole palette is one block off the disc
|
||||||
|
DGPOIS = $A500 ; the poison: word i = DGPOIS|i. A palette that
|
||||||
|
; still reads this was not written by anything.
|
||||||
|
DGDST6 = $2C000 ; run 8's destination: RAM, so the palette is
|
||||||
|
; left alone and must still read poison
|
||||||
|
DGDST7 = $C14000 ; run 9's six GVRAM rows, at the 1024 B stride
|
||||||
|
DGCHA2 = $19100 ; run 9's array: 7 x {u32 MAR, u16 MTC}
|
||||||
|
DGCHN2 = 7 ; palette 512 B + 6 rows x 256 B = 2,048 B
|
||||||
|
DGCROW2 = 256
|
||||||
|
DGPS7 = $1A000 ; the palette as it stood after run 7 ...
|
||||||
|
DGPS8 = $1A200 ; ... after run 8 (the control: poison) ...
|
||||||
|
DGPS9 = $1A400 ; ... and after run 9 (chained). SNAPSHOTS, not
|
||||||
|
; a late read: each run overwrites the previous
|
||||||
|
; run's palette, so a host that looks once at
|
||||||
|
; the end sees only the last of the three.
|
||||||
|
|
||||||
|
org $10000
|
||||||
|
start:
|
||||||
|
clr.l DGFLAG.l
|
||||||
|
|
||||||
|
; ---- 1. the PIO path, unchanged, as the reference the other two are measured
|
||||||
|
; against. It is here so that a DMA failure cannot be confused with a SCSI
|
||||||
|
; protocol failure: if this one is wrong, nothing below is about the DMAC.
|
||||||
|
bsr scsi_init
|
||||||
|
move.l #DGLBA,d3
|
||||||
|
moveq #DGBLK,d4
|
||||||
|
lea DGDST0,a1
|
||||||
|
bsr scsi_read
|
||||||
|
lea DGREC,a0
|
||||||
|
bsr dg_save
|
||||||
|
|
||||||
|
; ---- 2. the channel, WITH THE BUS HELD
|
||||||
|
bsr scsi_init
|
||||||
|
move.l #DM_HELD_DCR,DM_DCRV.l
|
||||||
|
move.l #DM_HELD_OCR,DM_OCRV.l
|
||||||
|
move.l #1,DM_USE.l ; after scsi_init, which clears it
|
||||||
|
move.l #DGLBA,d3
|
||||||
|
moveq #DGBLK,d4
|
||||||
|
lea DGDST1,a1
|
||||||
|
bsr scsi_read
|
||||||
|
lea DGREC+DGREC_SZ,a0
|
||||||
|
bsr dg_save
|
||||||
|
|
||||||
|
; ---- 3. the channel, STEALING CYCLES. Same bytes, same code, two register
|
||||||
|
; values different -- which is what makes the comparison a comparison.
|
||||||
|
bsr scsi_init
|
||||||
|
move.l #DM_STEAL_DCR,DM_DCRV.l
|
||||||
|
move.l #DM_STEAL_OCR,DM_OCRV.l
|
||||||
|
move.l #1,DM_USE.l
|
||||||
|
move.l #DGLBA,d3
|
||||||
|
moveq #DGBLK,d4
|
||||||
|
lea DGDST2,a1
|
||||||
|
bsr scsi_read
|
||||||
|
lea DGREC+2*DGREC_SZ,a0
|
||||||
|
bsr dg_save
|
||||||
|
|
||||||
|
; ---- 4. THE CHANNEL WRITING GVRAM, IN BUFFER MODE. 47.6.2: "the DMAC has not
|
||||||
|
; been near this" -- 44.7 costed a device->GVRAM transfer and 47 built the packed
|
||||||
|
; layout, and no run in this tree has ever pointed a channel at $C00000. Two
|
||||||
|
; separate things are being asked at once and both are write-path questions:
|
||||||
|
;
|
||||||
|
; a. can a channel write GVRAM AT ALL? Nothing says it cannot -- the DMAC
|
||||||
|
; writes through the same program address space the CPU does -- but a
|
||||||
|
; transport that silently drops its writes into a device handler is exactly
|
||||||
|
; the failure this gate exists to catch, and the SPC's own bytes make a
|
||||||
|
; better witness than a fill pattern.
|
||||||
|
; b. does a BYTE-wide channel fill the PACKED layout? 47.1 measured the write
|
||||||
|
; path with word writes from the CPU. A dual-address channel with an 8-bit
|
||||||
|
; device port writes BYTES, and MAME's gvram_w in buffer mode passes
|
||||||
|
; mem_mask straight through -- so an even byte should land in the HIGH half
|
||||||
|
; of its word (page 1) and an odd byte in the LOW half (page 0). If it
|
||||||
|
; does, a linear DMA of a stream interleaved (right<<8)|left -- which is
|
||||||
|
; exactly show_frame256_packed.lua's layout -- fills the screen with no CPU
|
||||||
|
; in the loop at all.
|
||||||
|
;
|
||||||
|
; R20 bit 11 is left SET across the readback on purpose: gvram_r returns the raw
|
||||||
|
; word in buffer mode and the masked byte outside it, so clearing it first would
|
||||||
|
; hide half of what is being measured.
|
||||||
|
;
|
||||||
|
; R20 IS WRITTEN OUTRIGHT AND NOT OR-ED INTO. The first cut of this run set bit
|
||||||
|
; 11 on top of whatever the IPL left, and the IPL leaves $0B16 (22.1) -- bit 11
|
||||||
|
; ALREADY SET, and COL = %11, the 65,536-colour setup, which writes whole words
|
||||||
|
; with or without the bit. The run passed and proved nothing: it was a test that
|
||||||
|
; could not fail. Run 5 below is the control that makes this one mean something,
|
||||||
|
; and the two differ in EXACTLY BIT 11.
|
||||||
|
bsr scsi_init
|
||||||
|
move.w #R20_BUF,CRTC20.l
|
||||||
|
move.l #R20_BUF,DGR20.l
|
||||||
|
move.l #DM_HELD_DCR,DM_DCRV.l
|
||||||
|
move.l #DM_HELD_OCR,DM_OCRV.l
|
||||||
|
move.l #1,DM_USE.l
|
||||||
|
move.l #DGLBA,d3
|
||||||
|
moveq #DGBLK,d4
|
||||||
|
lea DGDST3,a1
|
||||||
|
bsr scsi_read
|
||||||
|
lea DGREC+3*DGREC_SZ,a0
|
||||||
|
bsr dg_save
|
||||||
|
|
||||||
|
; ---- 5. THE NEGATIVE CONTROL: the same transfer with bit 11 CLEAR. In masked
|
||||||
|
; 256-colour mode gvram_w takes `data & 0x00ff` and IGNORES mem_mask, so a byte
|
||||||
|
; written to an EVEN address -- where the 68000 puts the MS byte, and where every
|
||||||
|
; other disc byte lands -- contributes nothing and cannot be read back. Half the
|
||||||
|
; transfer must be lost, and if it is not, run 4 was not measuring the bit.
|
||||||
|
bsr scsi_init
|
||||||
|
move.w #R20_MSK,CRTC20.l
|
||||||
|
move.l #R20_MSK,DGR20N.l
|
||||||
|
move.l #DM_HELD_DCR,DM_DCRV.l
|
||||||
|
move.l #DM_HELD_OCR,DM_OCRV.l
|
||||||
|
move.l #1,DM_USE.l
|
||||||
|
move.l #DGLBA,d3
|
||||||
|
moveq #DGBLK,d4
|
||||||
|
lea DGDST4,a1
|
||||||
|
bsr scsi_read
|
||||||
|
lea DGREC+4*DGREC_SZ,a0
|
||||||
|
bsr dg_save
|
||||||
|
move.w #R20_BUF,CRTC20.l ; back to buffer mode, so the host reads
|
||||||
|
; RAW WORDS out of both destinations
|
||||||
|
|
||||||
|
; ---- 6. THE ROW STRIDE, WHICH IS THE REAL SHAPE OF THE TRANSFER. Runs 4 and 5
|
||||||
|
; wrote 2,048 contiguous bytes, and no picture is contiguous: a packed row is
|
||||||
|
; 256 B of a 1024 B line stride, so a frame is 192 destinations and not one.
|
||||||
|
; 46.6 said "no stride for a DMAC to skip" about the bytes WITHIN a row and left
|
||||||
|
; the rows themselves unexamined; a channel cannot skip 768 B any more than it
|
||||||
|
; could skip the 300 B in front of a record (run 10 below).
|
||||||
|
;
|
||||||
|
; The MC68450 answers this with SEQUENTIAL ARRAY CHAINING -- an array of 6-byte
|
||||||
|
; {u32 MAR, u16 MTC} entries it walks by itself -- and MAME implements it. So
|
||||||
|
; the question is not whether the CPU can restart the channel 192 times a frame;
|
||||||
|
; it is whether it has to at all. Eight rows here, out of the same LBA and the
|
||||||
|
; same 2,048 B, so the ONLY difference from run 4 is where the bytes land.
|
||||||
|
bsr scsi_init
|
||||||
|
move.w #R20_BUF,CRTC20.l
|
||||||
|
move.l #R20_BUF,DGR20C.l
|
||||||
|
lea DGCHA,a0
|
||||||
|
lea DGDST5,a1
|
||||||
|
moveq #DGCHN-1,d5
|
||||||
|
dg_mkch:
|
||||||
|
move.l a1,(a0)+ ; MAR: this row's base
|
||||||
|
move.w #DGCROW,(a0)+ ; MTC: 256 bytes of it
|
||||||
|
lea 1024(a1),a1 ; the next row is a line stride away
|
||||||
|
dbra d5,dg_mkch
|
||||||
|
move.l #DM_HELD_DCR,DM_DCRV.l
|
||||||
|
move.l #DM_HELD_OCR|$08,DM_OCRV.l ; OCR CHAIN = %10, array chain
|
||||||
|
move.l #DGCHA,DM_BARV.l
|
||||||
|
move.l #DGCHN,DM_BTCV.l
|
||||||
|
move.l #1,DM_USE.l
|
||||||
|
move.l #DGLBA,d3
|
||||||
|
moveq #DGBLK,d4
|
||||||
|
lea DGDST5,a1 ; ignored under chaining; passed so the
|
||||||
|
; call site reads the same as the others
|
||||||
|
bsr scsi_read
|
||||||
|
lea DGREC+5*DGREC_SZ,a0
|
||||||
|
bsr dg_save
|
||||||
|
clr.l DM_BARV.l ; and OFF again, so run 7 is unchained
|
||||||
|
|
||||||
|
; ---- 7. THE PALETTE. Can a channel write $E82000? (FINDINGS 61.9, ROADMAP
|
||||||
|
; K1.) Runs 4-6 put the PICTURE on the channel; a packed frame is a picture AND
|
||||||
|
; a palette, and if the palette registers take a byte-wide DMA the way GVRAM
|
||||||
|
; does in buffer mode then the palette is a 193rd array-chain entry and ONE
|
||||||
|
; channel start paints a whole frame -- no per-frame CPU work in the video path
|
||||||
|
; at all. If they do not, the CPU writes 256 words a frame (61.9 derives that
|
||||||
|
; at ~2,370 clocks, 0.28% of a frame) and the architecture still stands; this is
|
||||||
|
; the difference between cheap and free, and it is worth one run to know which.
|
||||||
|
;
|
||||||
|
; 512 B IS THE WHOLE PALETTE AND EXACTLY ONE SECTOR, which is why this run reads
|
||||||
|
; one block where the others read four: a transfer that ran long would write
|
||||||
|
; $E82200 (the TEXT palette) and then $E82400 (the video controller's own
|
||||||
|
; registers, priority included), and a probe that reconfigures the video
|
||||||
|
; controller as a side effect is not a probe.
|
||||||
|
;
|
||||||
|
; THE PALETTE IS POISONED FIRST, and that is what stops this being run 4's trap
|
||||||
|
; a second time. A destination that already holds the right bytes cannot tell a
|
||||||
|
; channel that wrote them from a channel that did nothing; RAM at $20000 was
|
||||||
|
; zero and the record is mostly pad, so "it matches" has been a weak claim all
|
||||||
|
; session. Word i is set to $A500|i, which no 512 B of container matches by
|
||||||
|
; accident, and the host reports how many of the 512 positions the poison and
|
||||||
|
; the disc actually differ in rather than assuming all of them.
|
||||||
|
bsr scsi_init
|
||||||
|
bsr dg_poison
|
||||||
|
move.l #DM_HELD_DCR,DM_DCRV.l
|
||||||
|
move.l #DM_HELD_OCR,DM_OCRV.l
|
||||||
|
move.l #1,DM_USE.l
|
||||||
|
move.l #DGLBA,d3
|
||||||
|
moveq #DGPBLK,d4
|
||||||
|
lea DGPAL,a1
|
||||||
|
bsr scsi_read
|
||||||
|
lea DGPS7,a1
|
||||||
|
bsr dg_palsnap ; before run 8 overwrites it
|
||||||
|
lea DGREC+6*DGREC_SZ,a0
|
||||||
|
bsr dg_save
|
||||||
|
|
||||||
|
; ---- 8. THE NEGATIVE CONTROL, and it is an ATTRIBUTION control rather than a
|
||||||
|
; mechanism one. Run 5's control could point at a mode bit; there is no mode
|
||||||
|
; bit here, so what has to be excluded is that run 7's palette held the disc's
|
||||||
|
; bytes for some reason OTHER than the channel having written them there --
|
||||||
|
; a readback that aliases somewhere else, the SPC's own path touching the
|
||||||
|
; registers, the poison never having landed.
|
||||||
|
;
|
||||||
|
; Same transfer, same channel, same bytes; ONE thing different, the destination
|
||||||
|
; address. The disc's bytes must appear at $2C000, and the palette must still
|
||||||
|
; read poison in all 256 words. Two claims from one run, and the second is the
|
||||||
|
; one that makes run 7 mean anything: it also proves the CPU's poison writes
|
||||||
|
; reach the registers the host reads back, which is the positive half.
|
||||||
|
bsr scsi_init
|
||||||
|
bsr dg_poison
|
||||||
|
move.l #DM_HELD_DCR,DM_DCRV.l
|
||||||
|
move.l #DM_HELD_OCR,DM_OCRV.l
|
||||||
|
move.l #1,DM_USE.l
|
||||||
|
move.l #DGLBA,d3
|
||||||
|
moveq #DGPBLK,d4
|
||||||
|
lea DGDST6,a1
|
||||||
|
bsr scsi_read
|
||||||
|
lea DGPS8,a1
|
||||||
|
bsr dg_palsnap ; must be poison, word for word
|
||||||
|
lea DGREC+7*DGREC_SZ,a0
|
||||||
|
bsr dg_save
|
||||||
|
|
||||||
|
; ---- 9. THE 193rd ENTRY: one start, the palette AND the picture rows. This is
|
||||||
|
; the run K1 exists for. Runs 7 and 8 only show that a channel can write the
|
||||||
|
; palette registers; what the architecture needs is that ONE array chain can
|
||||||
|
; cross from a hardware register area into GVRAM without the CPU between them,
|
||||||
|
; because a frame is a palette entry followed by 192 row entries and the whole
|
||||||
|
; claim is that the CPU starts the channel once.
|
||||||
|
;
|
||||||
|
; Seven entries, 2,048 B, out of the same LBA as everything else: 512 B into the
|
||||||
|
; palette and then six rows of 256 B at the 1024 B line stride. The destination
|
||||||
|
; regions are of two different KINDS -- device registers and video RAM in buffer
|
||||||
|
; mode -- which is exactly the crossing that has never been run.
|
||||||
|
bsr scsi_init
|
||||||
|
bsr dg_poison
|
||||||
|
move.w #R20_BUF,CRTC20.l
|
||||||
|
move.l #R20_BUF,DGR20P.l
|
||||||
|
lea DGCHA2,a0
|
||||||
|
move.l #DGPAL,(a0)+ ; entry 0: the palette, a whole sector
|
||||||
|
move.w #DGPALN*2,(a0)+
|
||||||
|
lea DGDST7,a1
|
||||||
|
moveq #DGCHN2-2,d5 ; the remaining six are picture rows
|
||||||
|
dg_mkch2:
|
||||||
|
move.l a1,(a0)+
|
||||||
|
move.w #DGCROW2,(a0)+
|
||||||
|
lea 1024(a1),a1
|
||||||
|
dbra d5,dg_mkch2
|
||||||
|
move.l #DM_HELD_DCR,DM_DCRV.l
|
||||||
|
move.l #DM_HELD_OCR|$08,DM_OCRV.l ; OCR CHAIN = %10, array chain
|
||||||
|
move.l #DGCHA2,DM_BARV.l
|
||||||
|
move.l #DGCHN2,DM_BTCV.l
|
||||||
|
move.l #1,DM_USE.l
|
||||||
|
move.l #DGLBA,d3
|
||||||
|
moveq #DGBLK,d4
|
||||||
|
lea DGDST7,a1 ; ignored under chaining, as in run 6
|
||||||
|
bsr scsi_read
|
||||||
|
lea DGPS9,a1
|
||||||
|
bsr dg_palsnap
|
||||||
|
lea DGREC+8*DGREC_SZ,a0
|
||||||
|
bsr dg_save
|
||||||
|
clr.l DM_BARV.l ; and OFF again, so run 10 is unchained
|
||||||
|
|
||||||
|
; ---- 10. and a WINDOWED read through the channel, which must be REFUSED. This
|
||||||
|
; is the one test here that is expected to fail, and it has to fail LOUDLY: the
|
||||||
|
; alternative is a channel writing a whole sector into a ring that has room for
|
||||||
|
; a record, over the top of records the decoder has not finished with.
|
||||||
|
bsr scsi_init
|
||||||
|
move.l #DM_HELD_DCR,DM_DCRV.l
|
||||||
|
move.l #DM_HELD_OCR,DM_OCRV.l
|
||||||
|
move.l #1,DM_USE.l
|
||||||
|
move.l #300,SC_WSKIP.l ; a record that starts 300 B into a sector
|
||||||
|
move.l #1024,SC_WKEEP.l
|
||||||
|
move.l #DGLBA,d3
|
||||||
|
moveq #DGBLK,d4
|
||||||
|
lea DGDST2,a1
|
||||||
|
bsr scsi_read_win
|
||||||
|
move.l d0,DGWIN.l
|
||||||
|
move.l SC_ERR.l,DGWERR.l
|
||||||
|
|
||||||
|
move.l #1,DGFLAG.l
|
||||||
|
hold: bra.s hold
|
||||||
|
|
||||||
|
; ---- one config's result, copied out of the shared reporting words before the
|
||||||
|
; next run overwrites them. d0 = scsi_read's return; a0 = where it goes.
|
||||||
|
dg_save:
|
||||||
|
move.l d0,(a0)+
|
||||||
|
move.l SC_ERR.l,(a0)+
|
||||||
|
move.l DM_MTC0.l,(a0)+
|
||||||
|
move.l DM_SPIN.l,(a0)+
|
||||||
|
move.l DM_CSRF.l,(a0)+
|
||||||
|
move.l DM_CERF.l,(a0)+
|
||||||
|
move.l DM_MTCF.l,(a0)+
|
||||||
|
move.l DM_MARF.l,(a0)+
|
||||||
|
; and clear them, so a config that never reached the channel reports
|
||||||
|
; zeros of its own rather than the previous config's numbers.
|
||||||
|
clr.l DM_MTC0.l
|
||||||
|
clr.l DM_SPIN.l
|
||||||
|
clr.l DM_CSRF.l
|
||||||
|
clr.l DM_CERF.l
|
||||||
|
clr.l DM_MTCF.l
|
||||||
|
clr.l DM_MARF.l
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---- the poison. Word i of the graphic palette <- $A500|i, written by the
|
||||||
|
; 68000 itself. Two jobs: a destination that cannot hold the right answer by
|
||||||
|
; accident, and a known pattern the control run reads back out of the registers
|
||||||
|
; to show the readback path shows what is actually in them.
|
||||||
|
dg_poison:
|
||||||
|
movem.l d0-d2/a0,-(sp)
|
||||||
|
lea DGPAL,a0
|
||||||
|
moveq #0,d1 ; i
|
||||||
|
move.w #DGPALN-1,d0
|
||||||
|
.p: move.w d1,d2
|
||||||
|
ori.w #DGPOIS,d2 ; $A500|i, and i never exceeds 255
|
||||||
|
move.w d2,(a0)+
|
||||||
|
addq.w #1,d1
|
||||||
|
dbra d0,.p
|
||||||
|
movem.l (sp)+,d0-d2/a0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---- the palette as it stands, copied to (a1) by the 68000 READING THE
|
||||||
|
; REGISTERS. A snapshot and not a late host read: each palette run overwrites
|
||||||
|
; the last one's result, so all three have to be kept while they are true. It
|
||||||
|
; is also the same shape of evidence 53.3 used for pal_pack -- the words come
|
||||||
|
; back out of $E82000 rather than out of the buffer they were built in.
|
||||||
|
dg_palsnap:
|
||||||
|
movem.l d0/a0-a1,-(sp)
|
||||||
|
lea DGPAL,a0
|
||||||
|
move.w #DGPALN-1,d0
|
||||||
|
.s: move.w (a0)+,(a1)+
|
||||||
|
dbra d0,.s
|
||||||
|
movem.l (sp)+,d0/a0-a1
|
||||||
|
rts
|
||||||
|
|
||||||
|
include "src/player/scsi.i"
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
; ---------------------------------------------------------------- one block
|
||||||
|
; \1 = right-shift needed to bring this block's 2 mode bits to bits 1-0.
|
||||||
|
BLOCK macro
|
||||||
|
move.b (a1),d0
|
||||||
|
ifne \1
|
||||||
|
lsr.b #\1,d0
|
||||||
|
endc
|
||||||
|
and.w #3,d0
|
||||||
|
beq .sk\@ ; 00 SKIP -- the median block
|
||||||
|
subq.w #1,d0
|
||||||
|
beq .v1\@ ; 01 V1
|
||||||
|
subq.w #1,d0
|
||||||
|
bne .rw\@ ; 11 RAW, else 10 V4
|
||||||
|
|
||||||
|
; -- V4: four 2x2 codewords, sub-block order TL TR BL BR (vq_hybrid.paint)
|
||||||
|
moveq #0,d0
|
||||||
|
move.b (a0)+,d0
|
||||||
|
lsl.w #3,d0
|
||||||
|
movem.l (a3,d0.w),d0-d1
|
||||||
|
move.l d0,(a4)
|
||||||
|
move.l d1,1024(a4)
|
||||||
|
moveq #0,d0
|
||||||
|
move.b (a0)+,d0
|
||||||
|
lsl.w #3,d0
|
||||||
|
movem.l (a3,d0.w),d0-d1
|
||||||
|
move.l d0,4(a4)
|
||||||
|
move.l d1,1028(a4)
|
||||||
|
moveq #0,d0
|
||||||
|
move.b (a0)+,d0
|
||||||
|
lsl.w #3,d0
|
||||||
|
movem.l (a3,d0.w),d0-d1
|
||||||
|
move.l d0,2048(a4)
|
||||||
|
move.l d1,3072(a4)
|
||||||
|
moveq #0,d0
|
||||||
|
move.b (a0)+,d0
|
||||||
|
lsl.w #3,d0
|
||||||
|
movem.l (a3,d0.w),d0-d1
|
||||||
|
move.l d0,2052(a4)
|
||||||
|
move.l d1,3076(a4)
|
||||||
|
bra .sk\@
|
||||||
|
|
||||||
|
; -- V1: one 4x4 codeword, 32 bytes, straight out of the expanded codebook
|
||||||
|
.v1\@:
|
||||||
|
moveq #0,d0
|
||||||
|
move.b (a0)+,d0
|
||||||
|
lsl.w #5,d0
|
||||||
|
movem.l (a2,d0.w),d0-d7 ; EA is resolved before the load
|
||||||
|
movem.l d0-d1,(a4)
|
||||||
|
movem.l d2-d3,1024(a4)
|
||||||
|
movem.l d4-d5,2048(a4)
|
||||||
|
movem.l d6-d7,3072(a4)
|
||||||
|
bra .sk\@
|
||||||
|
|
||||||
|
; -- RAW: 16 literal palette indices. Two indices are assembled into one long
|
||||||
|
; via swap, so each pair of pixels costs one write instead of two; the high
|
||||||
|
; byte of each word is left as zero because the hardware discards it anyway.
|
||||||
|
.rw\@:
|
||||||
|
RAWPAIR 0
|
||||||
|
RAWPAIR 4
|
||||||
|
RAWPAIR 1024
|
||||||
|
RAWPAIR 1028
|
||||||
|
RAWPAIR 2048
|
||||||
|
RAWPAIR 2052
|
||||||
|
RAWPAIR 3072
|
||||||
|
RAWPAIR 3076
|
||||||
|
.sk\@:
|
||||||
|
addq.l #8,a4
|
||||||
|
endm
|
||||||
|
|
||||||
|
RAWPAIR macro
|
||||||
|
moveq #0,d0
|
||||||
|
move.b (a0)+,d0
|
||||||
|
swap d0
|
||||||
|
move.b (a0)+,d0
|
||||||
|
move.l d0,\1(a4)
|
||||||
|
endm
|
||||||
|
|
||||||
|
; ------------------------------------------------------- the span section
|
||||||
|
; in: a0 = span section, a1 = mode header (preserved across the call)
|
||||||
|
; out: a0 = one past the section, i.e. the block payload
|
||||||
|
;
|
||||||
|
; This is tools/bench/blit.s v7 verbatim, and deliberately so: the 66.0 clocks
|
||||||
|
; per span + 9.143 per coarse pixel + 9.978 per fine pixel of FINDINGS 40 were
|
||||||
|
; measured on exactly this instruction sequence, over thirteen span lengths, and
|
||||||
|
; a "tidier" rewrite here would silently invalidate every span figure in
|
||||||
|
; FINDINGS 39/40 and in tools/analysis/14_dmac_chain.py.
|
||||||
|
;
|
||||||
|
; The fine chain is entered by FALLING OUT of the coarse one, so a span with no
|
||||||
|
; coarse units enters at v7cx with d0 already reloaded -- which is why the
|
||||||
|
; coarse displacement for c=0 is SPCN*SPCU, one past the last coarse unit,
|
||||||
|
; rather than a special case.
|
||||||
|
paint_spans:
|
||||||
|
move.w (a0)+,d7 ; spans in this frame
|
||||||
|
subq.w #1,d7
|
||||||
|
bmi spnone ; a frame may legitimately have none (the
|
||||||
|
; chain is far past a short branch)
|
||||||
|
move.l a1,-(sp) ; a1 is a payload register below
|
||||||
|
spspan: move.l (a0)+,a2 ; absolute GVRAM destination
|
||||||
|
move.w (a0)+,d0 ; (SPCN - coarse) * SPCU
|
||||||
|
jmp spch(pc,d0.w)
|
||||||
|
spch:
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
spcx: move.w (a0)+,d0 ; (SPFN - fine) * SPFU, from mid-stream
|
||||||
|
jmp spfh(pc,d0.w)
|
||||||
|
spfh:
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
dbra d7,spspan
|
||||||
|
move.l (sp)+,a1
|
||||||
|
spnone: rts
|
||||||
|
|
||||||
|
; ------------------------------------------------------------- one frame
|
||||||
|
; in: a0 = payload, a1 = packed mode header
|
||||||
|
; out: a0 = one past the last payload byte consumed
|
||||||
|
decode_frame:
|
||||||
|
lea CB1,a2
|
||||||
|
lea CB4,a3
|
||||||
|
lea DST0,a6
|
||||||
|
rowloop:
|
||||||
|
move.l a6,a4
|
||||||
|
lea ROWLEN(a6),a5
|
||||||
|
byteloop:
|
||||||
|
tst.b (a1) ; four SKIPs in one test
|
||||||
|
beq allskip
|
||||||
|
BLOCK 6
|
||||||
|
BLOCK 4
|
||||||
|
BLOCK 2
|
||||||
|
BLOCK 0
|
||||||
|
addq.l #1,a1
|
||||||
|
cmpa.l a5,a4
|
||||||
|
bne byteloop
|
||||||
|
bra rowdone
|
||||||
|
allskip:
|
||||||
|
addq.l #1,a1
|
||||||
|
lea 32(a4),a4
|
||||||
|
cmpa.l a5,a4
|
||||||
|
bne byteloop
|
||||||
|
rowdone:
|
||||||
|
lea BROW(a6),a6
|
||||||
|
cmpa.l #DSTE,a6
|
||||||
|
bne rowloop
|
||||||
|
rts
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
; Geometry and codebook constants shared by every front-end in src/player/.
|
||||||
|
;
|
||||||
|
; Split out of decode.s in session 18 so that decode.s (the preloaded-stream
|
||||||
|
; rig, gated by tools/bench/check.sh) and stream.s (the ring-buffer streaming
|
||||||
|
; rig, FINDINGS 49) assemble from LITERALLY THE SAME BYTES for the block loop
|
||||||
|
; and the span chain. Those bytes are not incidental: the 66.0 clocks/span,
|
||||||
|
; 9.143 clocks/coarse pixel and 9.978 clocks/fine pixel of FINDINGS 40, and
|
||||||
|
; every per-block constant in FINDINGS 24/30/41, are fitted to this exact
|
||||||
|
; instruction sequence. Two hand-maintained copies of it would drift, and the
|
||||||
|
; drift would be invisible -- both would still decode correctly, and only the
|
||||||
|
; cost model would be wrong.
|
||||||
|
;
|
||||||
|
; The split is a no-op by construction: tools/bench/check.sh asserts that
|
||||||
|
; decode.s still assembles to the same 1,296 bytes it did before it.
|
||||||
|
|
||||||
|
CB1 = $20000 ; expanded 4x4 codebook
|
||||||
|
CB4 = $22000 ; expanded 2x2 codebook
|
||||||
|
|
||||||
|
DST0 = $C08000 ; GVRAM + 32*1024 (first picture row)
|
||||||
|
DSTE = $C38000 ; GVRAM + 224*1024 (one past last)
|
||||||
|
BROW = 4096 ; bytes per block row (4 picture rows)
|
||||||
|
ROWLEN = 512 ; bytes per block row of blocks (64 * 8)
|
||||||
|
MODEB = 768 ; packed mode header, 3072 blocks * 2 bits
|
||||||
|
SPCU = 12 ; bytes of code per COARSE span unit (24 px)
|
||||||
|
SPCN = 11 ; coarse units: 11*24 = 264 px >= one row
|
||||||
|
SPFU = 2 ; bytes of code per FINE span unit (2 px)
|
||||||
|
SPFN = 11 ; fine units: 11*2 = 22 px > one coarse unit
|
||||||
|
|
||||||
|
; RECORD ALIGNMENT, and it is a property of the CONTAINER rather than of the
|
||||||
|
; 68000. DLX2 padded each record up to 4, which is all `move.l (a0)+` needs
|
||||||
|
; (FINDINGS 28.3). DLX5 pads up to a 512 B SECTOR, so that a DMA channel can
|
||||||
|
; read a record as whole sectors straight into the ring with no window and no
|
||||||
|
; bounce copy -- `sc_in_data` REFUSES a windowed read when the data phase is
|
||||||
|
; the channel's (59.4), and 117 of 120 records needed one under DLX4.
|
||||||
|
;
|
||||||
|
; The consumer has to know it too: the decoder releases the ring up to the end
|
||||||
|
; of the record it was handed, and a decoder that released only the bytes it
|
||||||
|
; READ would leave the pad unreclaimed and drift the producer's free-space
|
||||||
|
; arithmetic by up to RECALN-1 per record. The ring base must therefore be
|
||||||
|
; RECALN-aligned, which is asserted where the ring is placed.
|
||||||
|
RECALN = 512
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; load.i -- the two LOAD-TIME transforms, on the 68000 itself. ROADMAP P1+P2.
|
||||||
|
;
|
||||||
|
; Until now both of these were done host-side, in tools/bench/dlxload.py, and
|
||||||
|
; the rigs pushed the RESULT into emulated RAM. That was the right call while
|
||||||
|
; the inner loop was the thing being measured -- charging a once-per-scene cost
|
||||||
|
; to the per-frame path would have flattered or damned it for no reason -- but
|
||||||
|
; a player has no host. These are the bytes that replace it.
|
||||||
|
;
|
||||||
|
; The reference is tools/bench/dlxload.py and it stays the reference: this code
|
||||||
|
; is gated BYTE-FOR-BYTE against it (tools/bench/verify_load.py), palette words
|
||||||
|
; and darkest-entry index included. If the two ever disagree, the symptom in a
|
||||||
|
; rig would be wrong colours rather than a crash, which is exactly the class of
|
||||||
|
; bug the split was made to prevent.
|
||||||
|
;
|
||||||
|
; WHAT IT READS. The RAW container as it comes off the disc. The DLX header is
|
||||||
|
; fixed-layout and big-endian (tools/encoder/dlx.py):
|
||||||
|
; +0 magic 'DLX3' +12 k1 u16 +16 off_pal u32
|
||||||
|
; +4 W u16 +14 k4 u16 +20 off_cb1 u32
|
||||||
|
; +6 H u16 +24 off_cb4 u32
|
||||||
|
; +8 fps u16 +28 off_frm u32
|
||||||
|
; +10 nframes u16
|
||||||
|
; The three offsets are container-relative, so every one of them is an add of
|
||||||
|
; the base the loader was handed. Nothing here parses a frame record.
|
||||||
|
;
|
||||||
|
; WHAT IT WRITES. CB1 (8 KB) and CB4 (2 KB) expanded to one WORD per pixel at
|
||||||
|
; the addresses geom.i names, and 256 packed palette words straight into the
|
||||||
|
; graphics palette at $E82000. It also reports the darkest entry, which is what
|
||||||
|
; the letterbox is filled with until the encoder reserves a black one (23.4,
|
||||||
|
; still open).
|
||||||
|
;
|
||||||
|
; WHY WORD-PER-PIXEL. The block loop movems codebook entries straight into
|
||||||
|
; GVRAM with no unpacking, and the high byte of a GVRAM word write is discarded
|
||||||
|
; by the hardware, so the high byte is left zero and never has to be cleared.
|
||||||
|
; It also makes index scaling a shift rather than a multiply (lsl.w #5 / #3).
|
||||||
|
;
|
||||||
|
; SCRATCH. Three tables, built here and dead the moment the palette is packed:
|
||||||
|
; P6TAB 64 B 6-bit level -> the 8-bit value the hardware renders it as
|
||||||
|
; SQTAB 256 B the square of that, so the darkest-entry search has no muls
|
||||||
|
; DTAB 512 B err(v, I=0) - err(v, I=1) per 8-bit channel value, signed
|
||||||
|
; DTAB is what turns P2's per-entry minimum-squared-error choice of the shared
|
||||||
|
; LSB into three table reads and a sign test. Choosing I per entry rather than
|
||||||
|
; fixing it is worth 1.96 dB (FINDINGS 23.3), and it is a per-ENTRY decision
|
||||||
|
; across three channels, so it cannot be folded into a per-channel table alone.
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LFLAG = $18040 ; 0 idle / 1 running / $FF done / $EE bad header
|
||||||
|
LHDR = $18044 ; -> raw container base
|
||||||
|
LDARK = $18048 ; <- index of the darkest palette entry
|
||||||
|
LK1 = $1804C ; <- k1, as the 68000 read it out of the header
|
||||||
|
LK4 = $18050 ; <- k4
|
||||||
|
LMODE = $18054 ; bit0 codebooks, bit1 palette entries,
|
||||||
|
; bit2 the three scratch tables
|
||||||
|
LITER = $18058 ; repeat count, so a 55 Hz host clock can time it
|
||||||
|
|
||||||
|
P6TAB = $19000 ; 64 bytes
|
||||||
|
SQTAB = $19040 ; 64 longs
|
||||||
|
DTAB = $19140 ; 256 words
|
||||||
|
GPAL = $E82000 ; graphics palette, 256 words
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- do_load
|
||||||
|
; in: a0 = container base, d1 = mode bits: 1 codebooks, 2 palette entries,
|
||||||
|
; 4 the scratch tables. A player builds the tables ONCE at boot (they
|
||||||
|
; describe the hardware's colour rendering and nothing about the scene) and
|
||||||
|
; then loads each scene with 3.
|
||||||
|
; out: d0 = 0 ok, -1 not a DLX3/DLX4 container. a0-a4 clobbered, a5 = base.
|
||||||
|
;
|
||||||
|
; The magic is accepted as 'DLX' plus a version byte of '3' OR ABOVE rather than
|
||||||
|
; as one constant. DLX4 (ROADMAP P5) adds the per-record index and a fifth
|
||||||
|
; header offset at +32; every field this routine reads is at its DLX3 place, so
|
||||||
|
; the transforms are version-independent and the check should be too. A version
|
||||||
|
; this loader has never seen is still refused -- '3' or above, not "anything
|
||||||
|
; that begins DLX".
|
||||||
|
do_load:
|
||||||
|
movea.l a0,a5
|
||||||
|
move.l (a5),d0
|
||||||
|
andi.l #$FFFFFF00,d0
|
||||||
|
cmpi.l #$444C5800,d0 ; 'DLX'
|
||||||
|
bne .bad
|
||||||
|
cmpi.b #'3',3(a5) ; ... version 3 or above
|
||||||
|
bcs .bad
|
||||||
|
move.w 12(a5),d0
|
||||||
|
ext.l d0
|
||||||
|
move.l d0,LK1.l
|
||||||
|
move.w 14(a5),d0
|
||||||
|
ext.l d0
|
||||||
|
move.l d0,LK4.l
|
||||||
|
|
||||||
|
btst #2,d1
|
||||||
|
beq.s .notab
|
||||||
|
move.l d1,-(sp)
|
||||||
|
bsr pal_tables
|
||||||
|
move.l (sp)+,d1
|
||||||
|
.notab:
|
||||||
|
btst #0,d1
|
||||||
|
beq.s .nocb
|
||||||
|
moveq #0,d2 ; the count is built as a LONG and the
|
||||||
|
move.w 12(a5),d2 ; high word must not carry junk into it
|
||||||
|
lsl.l #4,d2 ; k1 entries x 16 source bytes
|
||||||
|
movea.l 20(a5),a0
|
||||||
|
adda.l a5,a0
|
||||||
|
lea CB1,a1
|
||||||
|
bsr expand
|
||||||
|
moveq #0,d2
|
||||||
|
move.w 14(a5),d2
|
||||||
|
lsl.l #2,d2 ; k4 entries x 4 source bytes
|
||||||
|
movea.l 24(a5),a0
|
||||||
|
adda.l a5,a0
|
||||||
|
lea CB4,a1
|
||||||
|
bsr expand
|
||||||
|
.nocb:
|
||||||
|
btst #1,d1
|
||||||
|
beq.s .nopal
|
||||||
|
bsr pal_pack
|
||||||
|
.nopal:
|
||||||
|
moveq #0,d0
|
||||||
|
rts
|
||||||
|
.bad: moveq #-1,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- expand
|
||||||
|
; One source byte -> one destination word, high byte zero.
|
||||||
|
; in: a0 src, a1 dst, d2 = source byte count. Always a multiple of 4: CB1 is
|
||||||
|
; k1*16 and CB4 is k4*4, so no remainder case can exist and none is written.
|
||||||
|
; A junk high word here is not a slow path, it is a WRONG one: `lsr.l #2` walks
|
||||||
|
; two of its bits down into the low word and the dbra count comes out long.
|
||||||
|
expand:
|
||||||
|
lsr.l #2,d2
|
||||||
|
subq.l #1,d2 ; k<=256, so the count fits a dbra
|
||||||
|
moveq #0,d0
|
||||||
|
.e1: move.b (a0)+,d0
|
||||||
|
move.w d0,(a1)+
|
||||||
|
move.b (a0)+,d0
|
||||||
|
move.w d0,(a1)+
|
||||||
|
move.b (a0)+,d0
|
||||||
|
move.w d0,(a1)+
|
||||||
|
move.b (a0)+,d0
|
||||||
|
move.w d0,(a1)+
|
||||||
|
dbra d2,.e1
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- pal_tables
|
||||||
|
; The three scratch tables. SCENE-INDEPENDENT, every one of them: they describe
|
||||||
|
; how the CRTC renders a 5-bit channel plus the shared LSB, which is a property
|
||||||
|
; of the machine. A player builds them once at boot and never again, which is
|
||||||
|
; why they are a separate entry point rather than the head of pal_pack -- see
|
||||||
|
; FINDINGS 53.3 for what that is worth.
|
||||||
|
pal_tables:
|
||||||
|
; -- P6TAB[x] = ((x<<2)|(x>>4)) & $FF, and SQTAB[x] = P6TAB[x]^2
|
||||||
|
lea P6TAB,a0
|
||||||
|
lea SQTAB,a1
|
||||||
|
moveq #0,d1
|
||||||
|
.p1: move.w d1,d0
|
||||||
|
lsl.w #2,d0
|
||||||
|
move.w d1,d2
|
||||||
|
lsr.w #4,d2
|
||||||
|
or.w d2,d0
|
||||||
|
andi.w #$FF,d0
|
||||||
|
move.b d0,(a0)+
|
||||||
|
move.w d0,d2
|
||||||
|
mulu d2,d2
|
||||||
|
move.l d2,(a1)+
|
||||||
|
addq.w #1,d1
|
||||||
|
cmpi.w #64,d1
|
||||||
|
bne.s .p1
|
||||||
|
|
||||||
|
; -- DTAB[v] = (render(v,0)-v)^2 - (render(v,1)-v)^2, signed
|
||||||
|
lea P6TAB,a0
|
||||||
|
lea DTAB,a1
|
||||||
|
moveq #0,d1
|
||||||
|
.p2: move.w d1,d2
|
||||||
|
lsr.w #2,d2
|
||||||
|
andi.w #$3E,d2 ; x0 = (v>>3)<<1
|
||||||
|
moveq #0,d3
|
||||||
|
move.b 0(a0,d2.w),d3
|
||||||
|
sub.w d1,d3
|
||||||
|
muls d3,d3
|
||||||
|
moveq #0,d4
|
||||||
|
move.b 1(a0,d2.w),d4
|
||||||
|
sub.w d1,d4
|
||||||
|
muls d4,d4
|
||||||
|
sub.l d4,d3
|
||||||
|
move.w d3,(a1)+
|
||||||
|
addq.w #1,d1
|
||||||
|
cmpi.w #256,d1
|
||||||
|
bne.s .p2
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- pal_pack
|
||||||
|
; 24-bit RGB -> GGGGGRRRRRBBBBBI, the shared LSB chosen per entry by minimum
|
||||||
|
; squared error, written to the palette registers. Identical arithmetic to
|
||||||
|
; dlxload.pack_palette, including its tie-breaks: I stays 0 when the two errors
|
||||||
|
; are equal, and the darkest entry is the FIRST index at the minimum.
|
||||||
|
; in: a5 = container base, and pal_tables already run.
|
||||||
|
pal_pack:
|
||||||
|
movea.l 16(a5),a0
|
||||||
|
adda.l a5,a0 ; -> 256 x RGB888
|
||||||
|
lea GPAL,a1
|
||||||
|
lea DTAB,a2
|
||||||
|
lea SQTAB,a4 ; P6TAB is not needed here: the rendered
|
||||||
|
; value is only ever wanted SQUARED
|
||||||
|
move.l #$7FFFFFFF,d6
|
||||||
|
clr.l LDARK.l
|
||||||
|
moveq #0,d7
|
||||||
|
.p3: moveq #0,d1
|
||||||
|
move.b (a0)+,d1 ; R
|
||||||
|
moveq #0,d2
|
||||||
|
move.b (a0)+,d2 ; G
|
||||||
|
moveq #0,d3
|
||||||
|
move.b (a0)+,d3 ; B
|
||||||
|
move.w d1,d0
|
||||||
|
add.w d0,d0
|
||||||
|
move.w 0(a2,d0.w),d4
|
||||||
|
move.w d2,d0
|
||||||
|
add.w d0,d0
|
||||||
|
add.w 0(a2,d0.w),d4
|
||||||
|
move.w d3,d0
|
||||||
|
add.w d0,d0
|
||||||
|
add.w 0(a2,d0.w),d4 ; sum of err0-err1 over the three
|
||||||
|
moveq #0,d5
|
||||||
|
tst.w d4
|
||||||
|
ble.s .p4
|
||||||
|
moveq #1,d5 ; I=1 only when it is STRICTLY better
|
||||||
|
.p4: lsr.w #3,d1 ; fR
|
||||||
|
lsr.w #3,d2 ; fG
|
||||||
|
lsr.w #3,d3 ; fB
|
||||||
|
move.w d2,d4
|
||||||
|
lsl.w #5,d4
|
||||||
|
or.w d1,d4
|
||||||
|
lsl.w #6,d4 ; (fG<<11)|(fR<<6)
|
||||||
|
move.w d3,d0
|
||||||
|
add.w d0,d0
|
||||||
|
or.w d0,d4
|
||||||
|
or.w d5,d4
|
||||||
|
move.w d4,(a1)+ ; -> the palette register
|
||||||
|
|
||||||
|
add.w d1,d1 ; x = (f<<1)|I, per channel
|
||||||
|
or.w d5,d1
|
||||||
|
add.w d2,d2
|
||||||
|
or.w d5,d2
|
||||||
|
add.w d3,d3
|
||||||
|
or.w d5,d3
|
||||||
|
lsl.w #2,d1 ; SQTAB holds longs
|
||||||
|
move.l 0(a4,d1.w),d0
|
||||||
|
lsl.w #2,d2
|
||||||
|
add.l 0(a4,d2.w),d0
|
||||||
|
lsl.w #2,d3
|
||||||
|
add.l 0(a4,d3.w),d0 ; squared distance from black
|
||||||
|
cmp.l d6,d0
|
||||||
|
bge.s .p5
|
||||||
|
move.l d0,d6
|
||||||
|
move.l d7,LDARK.l ; first index at the minimum wins
|
||||||
|
.p5: addq.w #1,d7
|
||||||
|
cmpi.w #256,d7
|
||||||
|
bne .p3
|
||||||
|
rts
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
; Front-end for the load-time transforms (ROADMAP P1+P2), for the rig.
|
||||||
|
;
|
||||||
|
; It is to load.i what decode.s is to frame.i: a timing and control wrapper that
|
||||||
|
; does nothing the shipping player would not do, so that the bytes being
|
||||||
|
; measured are the bytes that will ship. The player's own boot path will call
|
||||||
|
; do_load once with the mode bits set to 3; this repeats it LITER times so a
|
||||||
|
; host clock with 1/56.69 s granularity (tools/bench/crtc_mode.lua) can time a
|
||||||
|
; job that takes milliseconds,
|
||||||
|
; and splits it by LMODE so the codebook expansion and the palette pack can be
|
||||||
|
; priced apart. A player calls do_load with mode 7 once at boot -- the three
|
||||||
|
; scratch tables describe the machine, not the scene -- and with mode 3 at every
|
||||||
|
; scene change after that.
|
||||||
|
;
|
||||||
|
; Repeating is honest here in a way it would not be for a frame: nothing in
|
||||||
|
; do_load is temporally recursive. Pass n writes exactly what pass n-1 wrote,
|
||||||
|
; over the top of it, out of the same source bytes.
|
||||||
|
|
||||||
|
include "src/player/geom.i"
|
||||||
|
|
||||||
|
org $10000
|
||||||
|
start:
|
||||||
|
move.l LMODE.l,d1
|
||||||
|
move.l LITER.l,d3
|
||||||
|
move.l #1,LFLAG.l ; timer starts here
|
||||||
|
loop:
|
||||||
|
movem.l d1/d3,-(sp)
|
||||||
|
movea.l LHDR.l,a0
|
||||||
|
bsr do_load
|
||||||
|
movem.l (sp)+,d1/d3
|
||||||
|
tst.l d0
|
||||||
|
bne.s bad
|
||||||
|
subq.l #1,d3
|
||||||
|
bne.s loop
|
||||||
|
move.l #$FF,LFLAG.l ; timer stops here
|
||||||
|
hold: bra.s hold
|
||||||
|
bad: move.l #$EE,LFLAG.l
|
||||||
|
bra.s hold
|
||||||
|
|
||||||
|
include "src/player/load.i"
|
||||||
+1308
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,506 @@
|
|||||||
|
; ---------------------------------------------------------------- ring.i
|
||||||
|
; The RING PRODUCER, on the 68000. ROADMAP P5.
|
||||||
|
;
|
||||||
|
; WHAT MOVED. FINDINGS 49 and 51 measured a ring that a HOST filled:
|
||||||
|
; tools/bench/stream.lua held the record index, decided where each record went,
|
||||||
|
; wrote the descriptor and advertised it. The 68000 only ever consumed. That
|
||||||
|
; is the same shape session 21 found in the loader and session 22 in the frame
|
||||||
|
; clock -- a policy living outside the machine that has to run inside it -- and
|
||||||
|
; it is the last one in the delivery path. A player has no host to place its
|
||||||
|
; records.
|
||||||
|
;
|
||||||
|
; So the placement policy is here now, and the host keeps only the part that is
|
||||||
|
; genuinely not the CPU's: moving bytes off a disc at a rate. What the rig
|
||||||
|
; supplies is a TRANSPORT, one request at a time, which is what a single SPC and
|
||||||
|
; one DMAC channel are (FINDINGS 52.5); what this file supplies is every
|
||||||
|
; decision about WHICH record, WHERE in the ring, and WHEN it is safe to start.
|
||||||
|
;
|
||||||
|
; THE POLICY IS `aligned`, and it is the same one 19_ring_stream.py scored and
|
||||||
|
; 49.3 chose: never start a record that will not finish before the end of the
|
||||||
|
; ring; leave the hole, restart at the base. It costs a mean hole of 5.7% of
|
||||||
|
; the ring on the gate container and ZERO clocks in the block loop, against
|
||||||
|
; `split`'s 3.64% of every frame budget forever.
|
||||||
|
;
|
||||||
|
; WHY IT NEEDS AN INDEX, and why that is a container change (DLX4). `aligned`
|
||||||
|
; asks "does the NEXT record fit before the end of the ring", which is a
|
||||||
|
; question about a record's length asked BEFORE it is fetched. Every reader in
|
||||||
|
; this tree learned record lengths by walking the frame stream -- reading each
|
||||||
|
; record's length word to find the next -- and that is exactly what a player
|
||||||
|
; streaming off a disc cannot do: the length word of record i+1 is one of the
|
||||||
|
; bytes it has not fetched yet. DLX4 puts nframes u16 longword-counts in the
|
||||||
|
; scene header for this, and the same table gives a branch point the disc
|
||||||
|
; address of an arbitrary record without reading what lies between (`ring_seek`).
|
||||||
|
;
|
||||||
|
; THE HANDSHAKE WITH THE DECODER IS UNCHANGED, deliberately. FR_HEAD/FR_TAIL/
|
||||||
|
; DESC[] are the same words src/player/stream.s already reads, written in the
|
||||||
|
; same order, so the decoder cannot tell a host-filled ring from a self-filled
|
||||||
|
; one -- which is what makes the self-filled run a test of THIS file and not of
|
||||||
|
; a new rig. Two monotonic counters, single reader, single writer, no atomics.
|
||||||
|
;
|
||||||
|
; THE TRANSPORT MAILBOX, XF_QD outstanding requests:
|
||||||
|
; XF_SLOT[seq & 1] what to fetch, where to put it, how much, and which
|
||||||
|
; record it is
|
||||||
|
; XF_GO requests issued -- bumped LAST, after the slot
|
||||||
|
; XF_ACK requests completed, in order, by the transport
|
||||||
|
; In the player XF_* is an MB89352 command and a DMAC channel; here it is
|
||||||
|
; tools/bench/stream.lua delivering at a modelled rate. Either way the CPU
|
||||||
|
; issues and polls, and the bytes arrive on somebody else's time.
|
||||||
|
;
|
||||||
|
; AND THAT IS WHERE THE COST IS. The channel only moves bytes while a request
|
||||||
|
; is outstanding, and only the CPU can issue the next one. Between the
|
||||||
|
; completion of record i and the issue of record i+1 the disc is IDLE, and the
|
||||||
|
; length of that gap is a property of the PLAYER's loop, not of the medium.
|
||||||
|
; `ring_poll` is therefore called from the pace wait -- the idle the frame clock
|
||||||
|
; already creates -- rather than once a frame: once a frame would cap the fill
|
||||||
|
; at one record per slot, which is the wire rate exactly, and a ring that can
|
||||||
|
; only just keep up can never accumulate the slack a branch point spends
|
||||||
|
; (51.3). A frame that uses its whole slot does not merely present late
|
||||||
|
; (54.4); it stops the disc for a frame time. The rig counts that gap.
|
||||||
|
|
||||||
|
; ---- transport mailbox. TWO REQUEST SLOTS, and the depth is a knob.
|
||||||
|
; A channel only moves bytes while it has a request, and only the CPU can give
|
||||||
|
; it one. With ONE slot the disc stands still from the moment a transfer
|
||||||
|
; completes until the player next polls -- and a player polls in its idle, which
|
||||||
|
; is the end of a frame slot, so the gap is up to a whole frame's decode. With
|
||||||
|
; TWO the next request is already queued when the current one lands and the
|
||||||
|
; channel need never stop. XF_QD selects which, so the cost of the first is
|
||||||
|
; measurable against the second in one rig rather than argued about.
|
||||||
|
XF_SLOT = $18300 ; 2 x 16 B: u32 disc offset, u32 destination,
|
||||||
|
; u32 length, u32 record index
|
||||||
|
XF_SLSZ = 16
|
||||||
|
XF_SLM = 1 ; slot = sequence & XF_SLM
|
||||||
|
XF_GO = $18320 ; u32 requests ISSUED, written by the 68000
|
||||||
|
XF_ACK = $18324 ; u32 requests COMPLETED, written by transport
|
||||||
|
XF_QD = $18328 ; u32 queue depth, 1 or 2 (input)
|
||||||
|
|
||||||
|
; ---- producer state
|
||||||
|
RINGOWN = $1832C ; 1 = the 68000 owns placement (this file)
|
||||||
|
RNG_B = $18330 ; ring base address
|
||||||
|
RNG_SZ = $18334 ; ring size in bytes
|
||||||
|
IDX_B = $18338 ; base of the DLX4 record index, nframes u16
|
||||||
|
RQ_NEXT = $1833C ; next record to REQUEST
|
||||||
|
WCUR = $18340 ; write cursor, a ring OFFSET
|
||||||
|
RCUR = $18344 ; read cursor: ring offset of the oldest record
|
||||||
|
; the decoder has not finished with
|
||||||
|
RTAILN = $18348 ; records RCUR has stepped over; chases FR_TAIL
|
||||||
|
NRETIRE = $1834C ; requests this file has published; chases XF_ACK
|
||||||
|
DOFF = $18350 ; running disc offset of record RQ_NEXT
|
||||||
|
; ---- instruments. None of these is read by the policy.
|
||||||
|
N_HOLE = $18354 ; wraps that left a hole
|
||||||
|
N_HOLEB = $18358 ; total bytes in those holes
|
||||||
|
N_FULL = $1835C ; polls that refused for SPACE (ring-bound)
|
||||||
|
N_POLL = $18360 ; ring_poll calls
|
||||||
|
N_ISSUE = $18364 ; requests issued
|
||||||
|
SLK_MIN = $18368 ; least slack seen at a frame start, in records
|
||||||
|
SLK_AT = $1836C ; and the frame it was seen at
|
||||||
|
PF_REC = $18370 ; prefill target, in whole records (input)
|
||||||
|
PF_DONE = $18374 ; records resident when the prefill released
|
||||||
|
N_SEEK = $18378 ; ring_seek calls
|
||||||
|
SK_WAIT = $1837C ; polls spent waiting for the channel to go
|
||||||
|
; quiet before the last seek could start
|
||||||
|
ROFF = $19400 ; u32 per record: disc offset, built at load.
|
||||||
|
; The seek half of the index -- a running sum
|
||||||
|
; is enough to PLAY, but a branch point needs
|
||||||
|
; record j's address without summing to it.
|
||||||
|
ROFFMAX = 1024 ; entries; $19400..$1A400, below CB1 at $20000
|
||||||
|
|
||||||
|
RPOLLMAX = 4000000 ; ring_poll calls with no progress before the
|
||||||
|
; producer is declared wedged
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- ring_init
|
||||||
|
; in: IDX_B, RNG_B, RNG_SZ, NFR set by the caller.
|
||||||
|
; out: d0 = 0 ok, -1 the index is longer than ROFF can hold. Builds the disc
|
||||||
|
; offset table and leaves the ring empty at record 0.
|
||||||
|
; Clobbers d0-d2/a0-a1.
|
||||||
|
ring_init:
|
||||||
|
move.l NFR.l,d0
|
||||||
|
cmpi.l #ROFFMAX,d0
|
||||||
|
bhi .toobig
|
||||||
|
movea.l IDX_B.l,a0
|
||||||
|
lea ROFF.l,a1
|
||||||
|
moveq #0,d1 ; running disc offset
|
||||||
|
move.l d0,d2
|
||||||
|
beq.s .noidx
|
||||||
|
.sum: move.l d1,(a1)+
|
||||||
|
moveq #0,d0
|
||||||
|
move.w (a0)+,d0 ; longwords in this padded record
|
||||||
|
lsl.l #2,d0
|
||||||
|
add.l d0,d1
|
||||||
|
subq.l #1,d2
|
||||||
|
bne.s .sum
|
||||||
|
.noidx:
|
||||||
|
clr.l NRETIRE.l
|
||||||
|
clr.l N_HOLE.l
|
||||||
|
clr.l N_HOLEB.l
|
||||||
|
clr.l N_FULL.l
|
||||||
|
clr.l N_POLL.l
|
||||||
|
clr.l N_ISSUE.l
|
||||||
|
clr.l N_SEEK.l
|
||||||
|
clr.l SK_WAIT.l
|
||||||
|
clr.l XF_GO.l
|
||||||
|
clr.l XF_ACK.l
|
||||||
|
move.l #$7FFFFFFF,SLK_MIN.l
|
||||||
|
move.l #-1,SLK_AT.l
|
||||||
|
moveq #0,d0
|
||||||
|
bsr ring_seek ; a scene starts with a seek to record 0
|
||||||
|
moveq #0,d0
|
||||||
|
rts
|
||||||
|
.toobig:
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- ring_seek
|
||||||
|
; in: d0 = record index to play from.
|
||||||
|
; out: the ring is empty, the cursors are at its base, and the next request
|
||||||
|
; will be for record d0. FR_HEAD is reset; the CALLER must reset FR_TAIL
|
||||||
|
; (it is the decoder's word, and this file never writes the decoder's).
|
||||||
|
;
|
||||||
|
; A SEEK CANNOT START WHILE THE CHANNEL IS BUSY. An outstanding request is
|
||||||
|
; bytes already on their way to an address this routine is about to declare
|
||||||
|
; free, so it is waited out and thrown away rather than cancelled -- a real
|
||||||
|
; SPC would need the transfer aborted and the bus handed back before a new
|
||||||
|
; command, and waiting is the version of that a rig can be honest about. What
|
||||||
|
; it costs is up to one record's delivery time, charged to the seek, and
|
||||||
|
; SK_WAIT counts the polls it took.
|
||||||
|
; Clobbers d0-d2/a0.
|
||||||
|
ring_seek:
|
||||||
|
movem.l d0-d2/a0,-(sp)
|
||||||
|
addq.l #1,N_SEEK.l
|
||||||
|
clr.l SK_WAIT.l
|
||||||
|
.wait: move.l XF_ACK.l,d1
|
||||||
|
cmp.l XF_GO.l,d1
|
||||||
|
beq.s .quiet
|
||||||
|
addq.l #1,SK_WAIT.l
|
||||||
|
bsr xf_service ; the transport hook, and here it is not
|
||||||
|
; optional: with the transport INSIDE
|
||||||
|
; the machine (src/player/xfer.i) the
|
||||||
|
; only thing that can retire the
|
||||||
|
; outstanding request is this loop, so
|
||||||
|
; without it a seek issued with a
|
||||||
|
; request in flight spins forever. A
|
||||||
|
; host transport retired it on its own
|
||||||
|
; time, which is exactly the kind of
|
||||||
|
; difference the seam exists to hide and
|
||||||
|
; this one it could not.
|
||||||
|
bra.s .wait
|
||||||
|
.quiet:
|
||||||
|
move.l XF_GO.l,NRETIRE.l ; whatever landed belongs to the scene
|
||||||
|
; we came FROM, and is discarded
|
||||||
|
move.l d0,RQ_NEXT.l
|
||||||
|
lsl.l #2,d0
|
||||||
|
lea ROFF.l,a0
|
||||||
|
move.l (a0,d0.l),DOFF.l ; the index's second job: record j's
|
||||||
|
; disc address without reading to it
|
||||||
|
clr.l WCUR.l
|
||||||
|
clr.l RCUR.l
|
||||||
|
clr.l RTAILN.l
|
||||||
|
clr.l FR_HEAD.l
|
||||||
|
movem.l (sp)+,d0-d2/a0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- ring_poll
|
||||||
|
; Advance the producer by at most one step: retire a completed request, catch
|
||||||
|
; the read cursor up with the decoder, and issue the next request if one fits.
|
||||||
|
; Preserves every register -- it is called from inside the decoder's wait loops
|
||||||
|
; and must be invisible to them.
|
||||||
|
ring_poll:
|
||||||
|
movem.l d0-d3/a0-a1,-(sp)
|
||||||
|
addq.l #1,N_POLL.l
|
||||||
|
; ---- 0. the TRANSPORT, if it lives in this machine. src/player/xfer.i answers
|
||||||
|
; at most one outstanding request per call and preserves every register;
|
||||||
|
; with XF_SCSI = 0 it is a tst and a branch, and the host is the transport
|
||||||
|
; exactly as it was in FINDINGS 55. It goes BEFORE the retire step so that
|
||||||
|
; a transfer completed here is published in the same poll.
|
||||||
|
bsr xf_service
|
||||||
|
|
||||||
|
; ---- 1. retire. The descriptor is written BEFORE the count that advertises
|
||||||
|
; it, which is the same order tools/bench/stream.lua used and the reason
|
||||||
|
; src/player/stream.s reads them the other way round.
|
||||||
|
move.l NRETIRE.l,d3
|
||||||
|
.retire:
|
||||||
|
cmp.l XF_ACK.l,d3
|
||||||
|
bcc.s .retired ; d3 >= XF_ACK: nothing new has landed
|
||||||
|
move.l d3,d0
|
||||||
|
and.l #XF_SLM,d0
|
||||||
|
lsl.l #4,d0 ; * XF_SLSZ
|
||||||
|
lea XF_SLOT.l,a1
|
||||||
|
adda.l d0,a1 ; a1 = the completed request's slot
|
||||||
|
move.l 12(a1),d0 ; its record index
|
||||||
|
lsl.l #2,d0
|
||||||
|
and.w #DESCM,d0
|
||||||
|
lea DESC.l,a0
|
||||||
|
move.l 4(a1),(a0,d0.w) ; its destination -> the descriptor
|
||||||
|
addq.l #1,FR_HEAD.l ; ...advertised only after the address
|
||||||
|
addq.l #1,d3
|
||||||
|
bra.s .retire
|
||||||
|
.retired:
|
||||||
|
move.l d3,NRETIRE.l
|
||||||
|
|
||||||
|
; ---- 2. catch the read cursor up. The decoder publishes FR_TAIL and nothing
|
||||||
|
; else the producer needs: RCUR walks the SAME placement rule the writer
|
||||||
|
; used, so it steps over the holes in exactly the places they were left.
|
||||||
|
; That is what makes the free space a single circular gap rather than a
|
||||||
|
; list of live records -- the host producer kept a list because it could
|
||||||
|
; afford to.
|
||||||
|
; THE RULE MUST BE THE WRITER'S, APPLIED TO THE SAME RECORD. Stepping
|
||||||
|
; the reader past record i lands on the END of record i, which is where
|
||||||
|
; record i+1 went only if i+1 FITTED there -- and if it did not, the writer
|
||||||
|
; put it at the ring base and left a hole. So the wrap is decided by the
|
||||||
|
; length of the record being stepped ONTO, exactly as the placement was.
|
||||||
|
;
|
||||||
|
; Deciding it with the wrong record's length was a real bug and not a
|
||||||
|
; conservative one: it left RCUR pointing into the hole, and one more
|
||||||
|
; retirement then pushed it past the end of the ring and wrapped it to a
|
||||||
|
; low address unrelated to any record. The live span computed from that is
|
||||||
|
; SHORTER than the truth, so the producer places on top of a record the
|
||||||
|
; decoder has not finished, and the symptom is a bitstream desync -- the
|
||||||
|
; decoder's a0 walking off the end of a record that changed underneath it.
|
||||||
|
move.l RTAILN.l,d1
|
||||||
|
.catch: cmp.l FR_TAIL.l,d1
|
||||||
|
bcc.s .caught
|
||||||
|
movea.l IDX_B.l,a0
|
||||||
|
move.l d1,d0
|
||||||
|
add.l d0,d0
|
||||||
|
moveq #0,d2
|
||||||
|
move.w (a0,d0.l),d2
|
||||||
|
lsl.l #2,d2 ; length of the record being retired
|
||||||
|
move.l RCUR.l,d0
|
||||||
|
add.l d2,d0 ; d0 = one past its end
|
||||||
|
addq.l #1,d1
|
||||||
|
cmp.l NFR.l,d1
|
||||||
|
bcc.s .last ; nothing follows it in this scene
|
||||||
|
move.l d1,d2
|
||||||
|
add.l d2,d2
|
||||||
|
moveq #0,d3
|
||||||
|
move.w (a0,d2.l),d3
|
||||||
|
lsl.l #2,d3 ; length of the record after it
|
||||||
|
add.l d0,d3
|
||||||
|
cmp.l RNG_SZ.l,d3
|
||||||
|
bls.s .last
|
||||||
|
moveq #0,d0 ; it did not fit: the writer restarted
|
||||||
|
; at the base, so the reader does too
|
||||||
|
.last:
|
||||||
|
move.l d0,RCUR.l
|
||||||
|
bra.s .catch
|
||||||
|
.caught:
|
||||||
|
move.l d1,RTAILN.l
|
||||||
|
|
||||||
|
; ---- 3. issue, if there is anything left and it fits.
|
||||||
|
move.l RQ_NEXT.l,d1
|
||||||
|
cmp.l NFR.l,d1
|
||||||
|
bcc .out ; whole scene requested
|
||||||
|
; ---- MEASURED AGAINST WHAT HAS BEEN RETIRED, NOT WHAT HAS BEEN ACKED, and
|
||||||
|
; the difference is a slot. A request's slot stays in use until this file
|
||||||
|
; has read the record index and destination out of it -- which happens in
|
||||||
|
; step 1 above, one poll later than the ack at the earliest. Gating on
|
||||||
|
; XF_ACK let the CPU write a slot whose descriptor had not been published
|
||||||
|
; yet: the transport had finished the transfer, the retire loop then read
|
||||||
|
; the OVERWRITTEN slot, and DESC for that frame stayed zero. The decoder
|
||||||
|
; duly decoded address zero and reported a bitstream desync.
|
||||||
|
move.l XF_GO.l,d0
|
||||||
|
sub.l NRETIRE.l,d0 ; slots still spoken for
|
||||||
|
cmp.l XF_QD.l,d0
|
||||||
|
bcc .out ; the queue is as deep as it may go
|
||||||
|
move.l d1,d0
|
||||||
|
add.l d0,d0
|
||||||
|
movea.l IDX_B.l,a0
|
||||||
|
moveq #0,d2
|
||||||
|
move.w (a0,d0.l),d2
|
||||||
|
lsl.l #2,d2 ; d2 = length to place
|
||||||
|
; ---- WHERE IT GOES, AND WHETHER IT MAY. The live bytes are the circular
|
||||||
|
; interval [RCUR, WCUR) -- oldest record the decoder has not finished with,
|
||||||
|
; up to the write cursor -- so the FREE bytes are its complement, and a
|
||||||
|
; record has to fit in ONE piece of it because the block loop reads with a
|
||||||
|
; monotonically increasing a0 (49.2).
|
||||||
|
;
|
||||||
|
; There are three shapes and they are not symmetric, which is the trap:
|
||||||
|
; empty the whole ring is free
|
||||||
|
; RCUR <= WCUR live is one run; free is [WCUR, SZ) THEN [0, RCUR),
|
||||||
|
; so a record that will not fit before the end may
|
||||||
|
; restart at the base -- this is `aligned`, and the
|
||||||
|
; skipped bytes are the hole
|
||||||
|
; RCUR > WCUR LIVE is the one that wraps; free is only [WCUR, RCUR)
|
||||||
|
; and the ring base is NOT ours -- a record that will
|
||||||
|
; not fit must simply wait
|
||||||
|
; Deciding the wrap from `WCUR + len > SZ` alone, before knowing which
|
||||||
|
; shape it is, was the second bug in this file: in the third shape it
|
||||||
|
; restarted at a base that was live and overwrote records the decoder had
|
||||||
|
; not read, and the symptom was a bitstream desync rather than a fault.
|
||||||
|
move.l WCUR.l,d3 ; d3 = candidate offset
|
||||||
|
moveq #0,d0 ; d0 = hole bytes, if any
|
||||||
|
move.l RQ_NEXT.l,d1
|
||||||
|
cmp.l RTAILN.l,d1
|
||||||
|
beq.s .isempty
|
||||||
|
move.l RCUR.l,d1
|
||||||
|
cmp.l d3,d1
|
||||||
|
beq .full ; RCUR == WCUR and not empty: the ring
|
||||||
|
; is completely full
|
||||||
|
bhi.s .freehi
|
||||||
|
; RCUR < WCUR: free is [WCUR, SZ) then [0, RCUR).
|
||||||
|
move.l d3,d0
|
||||||
|
add.l d2,d0
|
||||||
|
cmp.l RNG_SZ.l,d0
|
||||||
|
bls.s .nohole2 ; fits before the end of the ring
|
||||||
|
move.l RCUR.l,d0
|
||||||
|
cmp.l d2,d0
|
||||||
|
bcs .full ; it will not fit at the base either
|
||||||
|
move.l RNG_SZ.l,d0
|
||||||
|
sub.l d3,d0 ; the hole `aligned` is about to leave.
|
||||||
|
; Charged here, where the record is
|
||||||
|
; actually PLACED, and not where the
|
||||||
|
; wrap is decided: charging it at the
|
||||||
|
; decision counts one hole per retry
|
||||||
|
; while the decoder still owns the base,
|
||||||
|
; which is every poll of a fast pipe,
|
||||||
|
; and reported 105 wraps where there
|
||||||
|
; are 18.
|
||||||
|
moveq #0,d3
|
||||||
|
bra.s .place
|
||||||
|
.nohole2:
|
||||||
|
moveq #0,d0
|
||||||
|
bra.s .place
|
||||||
|
.freehi:
|
||||||
|
; RCUR > WCUR: the LIVE span wraps, so the only free run is [WCUR, RCUR).
|
||||||
|
move.l d3,d0
|
||||||
|
add.l d2,d0
|
||||||
|
cmp.l RCUR.l,d0
|
||||||
|
bhi .full
|
||||||
|
moveq #0,d0
|
||||||
|
bra.s .place
|
||||||
|
.isempty:
|
||||||
|
; Nothing live, so the whole ring is free and the live span is about to start
|
||||||
|
; here. Moving RCUR is what keeps the invariant true across a drained ring;
|
||||||
|
; without it the reader's cursor would still point at the last consumed record.
|
||||||
|
move.l d3,d0
|
||||||
|
add.l d2,d0
|
||||||
|
cmp.l RNG_SZ.l,d0
|
||||||
|
bls.s .enohole
|
||||||
|
move.l RNG_SZ.l,d0
|
||||||
|
sub.l d3,d0
|
||||||
|
moveq #0,d3
|
||||||
|
bra.s .esetr
|
||||||
|
.enohole:
|
||||||
|
moveq #0,d0
|
||||||
|
.esetr:
|
||||||
|
move.l d3,RCUR.l
|
||||||
|
.place:
|
||||||
|
tst.l d0
|
||||||
|
beq.s .nohole
|
||||||
|
addq.l #1,N_HOLE.l
|
||||||
|
add.l d0,N_HOLEB.l
|
||||||
|
.nohole:
|
||||||
|
move.l XF_GO.l,d1
|
||||||
|
and.l #XF_SLM,d1
|
||||||
|
lsl.l #4,d1 ; * XF_SLSZ
|
||||||
|
lea XF_SLOT.l,a1
|
||||||
|
adda.l d1,a1
|
||||||
|
move.l DOFF.l,(a1)
|
||||||
|
move.l RNG_B.l,d0
|
||||||
|
add.l d3,d0
|
||||||
|
move.l d0,4(a1)
|
||||||
|
move.l d2,8(a1)
|
||||||
|
move.l RQ_NEXT.l,12(a1)
|
||||||
|
add.l d2,d3
|
||||||
|
move.l d3,WCUR.l
|
||||||
|
add.l d2,DOFF.l
|
||||||
|
addq.l #1,RQ_NEXT.l
|
||||||
|
addq.l #1,N_ISSUE.l
|
||||||
|
addq.l #1,XF_GO.l ; LAST: the three words above must be
|
||||||
|
; visible before the request is
|
||||||
|
bra.s .out
|
||||||
|
.full:
|
||||||
|
addq.l #1,N_FULL.l
|
||||||
|
.out:
|
||||||
|
movem.l (sp)+,d0-d3/a0-a1
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- ring_prefill
|
||||||
|
; Fill until PF_REC whole records are resident, then return. THE POLICY, not a
|
||||||
|
; convenience: the decoder must not be released at slack 1, because 51.2
|
||||||
|
; measured that n resident records buy n-1 frame times of stall -- the last one
|
||||||
|
; is spent covering the pipe's restart. Releasing at 1 therefore starts a scene
|
||||||
|
; with a stall budget of zero, and the first hiccup is an underrun.
|
||||||
|
;
|
||||||
|
; It is also the ONLY place a player can buy lookahead cheaply. 51.3: slack is
|
||||||
|
; accumulated out of `pipe - wire` over seconds of play, so a scene that starts
|
||||||
|
; empty climbs for 4.83 s at 488 KB/s before it can afford a branch. Bytes
|
||||||
|
; bought here are bought before the frame clock starts and cost nothing but the
|
||||||
|
; wait -- which is the one moment in a scene when the decoder has nothing else
|
||||||
|
; to do anyway.
|
||||||
|
;
|
||||||
|
; out: d0 = 0 ok, -1 the transport never delivered. PF_DONE = records resident.
|
||||||
|
ring_prefill:
|
||||||
|
movem.l d1-d2,-(sp)
|
||||||
|
moveq #0,d1
|
||||||
|
.loop: bsr ring_poll
|
||||||
|
move.l FR_HEAD.l,d0
|
||||||
|
sub.l FR_TAIL.l,d0
|
||||||
|
cmp.l PF_REC.l,d0
|
||||||
|
bcc.s .done
|
||||||
|
addq.l #1,d1
|
||||||
|
cmp.l #RPOLLMAX,d1
|
||||||
|
bcs.s .loop
|
||||||
|
movem.l (sp)+,d1-d2
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
.done: move.l d0,PF_DONE.l
|
||||||
|
movem.l (sp)+,d1-d2
|
||||||
|
moveq #0,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- ring_slack
|
||||||
|
; out: d0 = whole records resident and unconsumed.
|
||||||
|
;
|
||||||
|
; This is the number FINDINGS 51 spent a session establishing the meaning of,
|
||||||
|
; and it is only worth what it is worth when the decoder is PACED: free-running,
|
||||||
|
; the decoder outruns any pipe and the ring never backs up, so the difference is
|
||||||
|
; a statement about earliness (49.7.2).
|
||||||
|
ring_slack:
|
||||||
|
move.l FR_HEAD.l,d0
|
||||||
|
sub.l FR_TAIL.l,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- ring_may_seek
|
||||||
|
; in: d0 = the stall a branch would cost, in whole frame times.
|
||||||
|
; out: d0 = 0 the ring can cover it, -1 it cannot. Z set on ok.
|
||||||
|
;
|
||||||
|
; THE RULE, from 51.2, measured and not assumed: n resident records buy n-1
|
||||||
|
; frame times, because the record due immediately after the pipe restarts is
|
||||||
|
; still arriving when its slot opens. A design that reads the resident count
|
||||||
|
; as its stall budget is over by one record every time.
|
||||||
|
;
|
||||||
|
; What a player does with a `no` is not this file's business -- delay the
|
||||||
|
; branch, take the outcome that needs no seek, or accept a late present -- but
|
||||||
|
; it must be able to ASK, and until now the answer only existed in the rig's
|
||||||
|
; log.
|
||||||
|
ring_may_seek:
|
||||||
|
move.l d1,-(sp)
|
||||||
|
move.l FR_HEAD.l,d1
|
||||||
|
sub.l FR_TAIL.l,d1
|
||||||
|
beq.s .no ; nothing resident: the subtraction
|
||||||
|
; below would wrap to $FFFFFFFF and an
|
||||||
|
; unsigned compare would then answer YES
|
||||||
|
; to any request, from an empty ring
|
||||||
|
subq.l #1,d1 ; the restart record is not spendable
|
||||||
|
cmp.l d0,d1
|
||||||
|
bcs.s .no
|
||||||
|
move.l (sp)+,d1
|
||||||
|
moveq #0,d0
|
||||||
|
rts
|
||||||
|
.no: move.l (sp)+,d1
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- ring_mark
|
||||||
|
; Sample the slack at a frame boundary, for the instruments only.
|
||||||
|
; in: d0 = frame number. Clobbers nothing.
|
||||||
|
ring_mark:
|
||||||
|
movem.l d0-d1,-(sp)
|
||||||
|
move.l FR_HEAD.l,d1
|
||||||
|
sub.l FR_TAIL.l,d1
|
||||||
|
cmp.l SLK_MIN.l,d1
|
||||||
|
bcc.s .out
|
||||||
|
move.l d1,SLK_MIN.l
|
||||||
|
move.l d0,SLK_AT.l
|
||||||
|
.out: movem.l (sp)+,d0-d1
|
||||||
|
rts
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
; ---------------------------------------------------------------- scsi.i
|
||||||
|
; The MB89352 TRANSPORT, on the 68000. ROADMAP P4.
|
||||||
|
;
|
||||||
|
; WHAT THIS REPLACES. src/player/ring.i decides which record to fetch, where in
|
||||||
|
; the ring to put it and when it is safe; it hands that to a TRANSPORT through
|
||||||
|
; the XF_* mailbox and polls for completion. Until now the transport was
|
||||||
|
; tools/bench/stream.lua -- a host moving bytes at a modelled rate. A player has
|
||||||
|
; no host. This file is the transport: a Fujitsu MB89352 SPC and, later, one
|
||||||
|
; HD63450 channel.
|
||||||
|
;
|
||||||
|
; THE REGISTER MAP IS MEASURED, NOT ASSUMED. src/player/scsigate.s probes
|
||||||
|
; $EA0000..$EA003F one address at a time and survives the bus errors, and the
|
||||||
|
; map below is what answered:
|
||||||
|
;
|
||||||
|
; registers sit on the ODD bytes, $EA0001 + 2n, n = 0..14
|
||||||
|
; n=3 (TMOD) and n=15 (EXBF) BUS ERROR -- the MB89352 omits both, where the
|
||||||
|
; MB87030 has them, and MAME leaves HOLES rather than shifting the
|
||||||
|
; later indices down. DREG is index 10 at $EA0015 either way, which is the
|
||||||
|
; one address FINDINGS 32.4 had quoted.
|
||||||
|
; TEMP ($EA0017) took $A5 and gave it back, so these are registers and not a
|
||||||
|
; mirror of something.
|
||||||
|
;
|
||||||
|
; That last point is why the probe was worth a run: MAME's own device summary
|
||||||
|
; says the MB89352 "shifts subsequent indices", and the machine says it does
|
||||||
|
; not. The bytes win.
|
||||||
|
;
|
||||||
|
; THE DATA REGISTER IS DMA-ONLY, AND THAT IS NOT A CHOICE THIS CODE MADE.
|
||||||
|
; x68k_scsiext.cpp puts its own glue on $EA0015 and on no other address:
|
||||||
|
;
|
||||||
|
; write: if (exown()) { if (!drq) dtack_w(1); else dma_w(data); }
|
||||||
|
; else dreg_w(data);
|
||||||
|
;
|
||||||
|
; On this machine `exown()` -- the HD63450's OWN, fed back to the slot by
|
||||||
|
; x68k.cpp -- is asserted where a PIO write needs it not to be, so the `else`
|
||||||
|
; arm is unreachable and a byte written to $EA0015 with the SPC in PROGRAM
|
||||||
|
; transfer mode is DISCARDED. Silently: no error bit, no status change, no
|
||||||
|
; interrupt. It was measured rather than reasoned about -- scsigate.s writes
|
||||||
|
; $5A to $EA0015 and reads it straight back, and gets $00 with the FIFO still
|
||||||
|
; empty -- because ten command bytes vanishing without trace looks exactly like
|
||||||
|
; a target refusing a command.
|
||||||
|
;
|
||||||
|
; So every transfer here issues SCMD WITHOUT the PROGRAM bit, which puts the SPC
|
||||||
|
; in DMA mode and makes it raise DRQ; the CPU then moves the bytes through
|
||||||
|
; $EA0015 itself and they go in via `dma_w`/`dma_r`. The CPU is standing in for
|
||||||
|
; the DMAC, through the DMAC's own door.
|
||||||
|
;
|
||||||
|
; WHAT THAT COSTS THE ARGUMENT, stated because it is easy to overclaim here:
|
||||||
|
; with `exown` asserted at idle, MAME cannot distinguish a CPU-driven byte at
|
||||||
|
; $EA0015 from a DMAC-driven one. So this rig demonstrates THE DATA PATH and
|
||||||
|
; cannot, on its own, demonstrate that the HD63450 is the thing driving it.
|
||||||
|
; Whether a real CZ-6BS1 also refuses PIO here is NOT settled by this -- it is a
|
||||||
|
; property of MAME's model, and it wants a board (ROADMAP B1/B3).
|
||||||
|
;
|
||||||
|
; PIO FIRST, DMA SECOND, DELIBERATELY. The thing P4 has to demonstrate is a
|
||||||
|
; DMAC configuration that HOLDS THE BUS (ROADMAP: "getting the DMAC to hold the
|
||||||
|
; bus is the difference between 9 and 19 clocks per byte, and demonstrating a
|
||||||
|
; configuration that does it is P4's first job"). But a DMA bring-up that fails
|
||||||
|
; cannot tell "the SCSI protocol is wrong" from "the DMAC is misprogrammed". So
|
||||||
|
; the protocol is settled in PIO, where every byte is the CPU's and nothing else
|
||||||
|
; can be blamed, and only then does the data phase move to the channel.
|
||||||
|
;
|
||||||
|
; NOTHING HERE IS A RATE MEASUREMENT, and it cannot become one. MAME's device
|
||||||
|
; models are functional, not transfer-timing accurate (docs/BENCHMARK.md), and
|
||||||
|
; 42.5 reads its DMAC configured in wall-clock attotimes rather than per-operand
|
||||||
|
; cycles. `W` -- clocks stolen per delivered byte -- is untouched by every line
|
||||||
|
; below. What this settles is which handshake the player's own code provokes.
|
||||||
|
|
||||||
|
; ---- the SPC, at the CZ-6BS1's decode
|
||||||
|
SPCB = $EA0001 ; register 0; stride 2, odd lane
|
||||||
|
SC_BDID = SPCB+0 ; own ID (write the NUMBER; reads a MASK)
|
||||||
|
SC_SCTL = SPCB+2
|
||||||
|
SC_SCMD = SPCB+4
|
||||||
|
; SPCB+6 = TMOD, ABSENT on the MB89352 -- reading it BUS ERRORS
|
||||||
|
SC_INTS = SPCB+8
|
||||||
|
SC_PSNS = SPCB+10
|
||||||
|
SC_SSTS = SPCB+12
|
||||||
|
SC_SERR = SPCB+14
|
||||||
|
SC_PCTL = SPCB+16
|
||||||
|
SC_MBC = SPCB+18
|
||||||
|
SC_DREG = SPCB+20 ; $EA0015, and the DMAC's single address
|
||||||
|
SC_TEMP = SPCB+22
|
||||||
|
SC_TCH = SPCB+24
|
||||||
|
SC_TCM = SPCB+26
|
||||||
|
SC_TCL = SPCB+28
|
||||||
|
; SPCB+30 = EXBF, ABSENT -- reading it BUS ERRORS
|
||||||
|
|
||||||
|
; SCTL
|
||||||
|
SCTL_RESET = $80 ; reset & disable
|
||||||
|
; SCMD, command in bits 7-5
|
||||||
|
SCMD_RELEASE = $00 ; command 000, let go of the bus
|
||||||
|
SCMD_RSTACK = $C0 ; command 110, drop ACK/REQ
|
||||||
|
SCMD_SELECT = $20
|
||||||
|
SCMD_RSTATN = $40 ; command 010, drop ATN
|
||||||
|
SCMD_XFER = $80
|
||||||
|
SCMD_PROGRAM = $04 ; set = PIO, clear = DMA. NOT USED, and
|
||||||
|
; the reason is the whole of 57.x -- see
|
||||||
|
; "THE DATA REGISTER IS DMA-ONLY" above.
|
||||||
|
; INTS
|
||||||
|
INTS_RESET = $01
|
||||||
|
INTS_HARDERR = $02
|
||||||
|
INTS_TIMEOUT = $04
|
||||||
|
INTS_SERVICE = $08
|
||||||
|
INTS_CMDCOMP = $10
|
||||||
|
INTS_DISCON = $20
|
||||||
|
; SSTS
|
||||||
|
SSTS_DREG_E = $01 ; DREG empty
|
||||||
|
SSTS_DREG_F = $02 ; DREG full
|
||||||
|
SSTS_TC0 = $04
|
||||||
|
SSTS_BUSY = $20
|
||||||
|
SSTS_INITCON = $80
|
||||||
|
; SCSI bus phases, as PSNS bits 2..0 and as PCTL's low three
|
||||||
|
PH_DATAOUT = 0
|
||||||
|
PH_DATAIN = 1
|
||||||
|
PH_CMD = 2
|
||||||
|
PH_STATUS = 3
|
||||||
|
PH_MSGOUT = 6
|
||||||
|
PH_MSGIN = 7
|
||||||
|
|
||||||
|
SCSI_ID = 7 ; the player is the initiator
|
||||||
|
SCSI_TGT = 0 ; the disc
|
||||||
|
|
||||||
|
; ---- error codes, reported through SC_ERR
|
||||||
|
SCE_OK = 0
|
||||||
|
SCE_SELTMO = 1 ; the target never answered selection
|
||||||
|
SCE_PHASE = 2 ; the bus went somewhere unexpected
|
||||||
|
SCE_TIMEOUT = 3 ; a poll loop ran out of patience
|
||||||
|
SCE_STATUS = 4 ; the target returned non-zero status
|
||||||
|
SCE_WINDOW = 5 ; a WINDOWED read was asked of the DMAC,
|
||||||
|
; which cannot drop bytes (58.3/P4a)
|
||||||
|
|
||||||
|
SC_ERR = $18200 ; u32 last error
|
||||||
|
SC_STAT = $18204 ; u32 SCSI status byte from the last cmd
|
||||||
|
SC_PH = $18208 ; u32 phase we were in when it went wrong
|
||||||
|
SC_CDB = $18210 ; 12 B command block, built here
|
||||||
|
SC_MSG = $1821C ; 4 B message byte, either direction
|
||||||
|
|
||||||
|
; ---- THE RECORD WINDOW, and why a transport needs one. ROADMAP P4b.
|
||||||
|
; src/player/ring.i asks for a RECORD: a byte offset into the scene's frame
|
||||||
|
; stream and a length, both of them 4-byte aligned and neither of them a
|
||||||
|
; multiple of 512. A SCSI target deals in BLOCKS. On the gate container 117
|
||||||
|
; of 120 records start part way into a sector, so a transport that reads only
|
||||||
|
; whole sectors delivers the record plus up to 511 bytes in front of it and up
|
||||||
|
; to 511 behind, and those neighbouring bytes belong to records the decoder may
|
||||||
|
; still be reading -- the block loop walks a0 with no bounds check (49.2), so
|
||||||
|
; landing them in the ring is a corruption, not a waste.
|
||||||
|
;
|
||||||
|
; IN PIO THE FIX IS FREE, and that is the only reason this is affordable here:
|
||||||
|
; the CPU is already touching every byte, so it simply does not STORE the ones
|
||||||
|
; outside the window. SC_WSKIP bytes are pulled from the FIFO and dropped, the
|
||||||
|
; next SC_WKEEP are stored, the rest are pulled and dropped. Three loops rather
|
||||||
|
; than one steered loop, deliberately: the middle one is then byte-for-byte as
|
||||||
|
; tight as the un-windowed sc_in_pio, so the per-byte cost this rig reports is
|
||||||
|
; the transport's and not the window's.
|
||||||
|
;
|
||||||
|
; UNDER A DMAC IT IS NOT FREE, and that is P4a's problem arriving early. A
|
||||||
|
; channel writes a contiguous run to a contiguous address; it cannot be told to
|
||||||
|
; drop the first 300 bytes. So when the data phase moves to the HD63450 the
|
||||||
|
; choice is a bounce buffer plus a copy of every byte (the cost `aligned` was
|
||||||
|
; chosen to avoid, 49.3) or sector-aligned records in the container -- which is
|
||||||
|
; a re-encode. 57 measured which is cheaper; see FINDINGS 58.3.
|
||||||
|
SC_WSKIP = $18220 ; u32 bytes to drop before the window
|
||||||
|
SC_WKEEP = $18224 ; u32 bytes of window to store
|
||||||
|
|
||||||
|
; ---- a TRACE, because a SCSI bring-up cannot be debugged from one error code.
|
||||||
|
; Four registers at each interesting instant: SSTS, PSNS, INTS, SERR. MAME's
|
||||||
|
; SCMD_CMD_TRANSFER is a NO-OP unless SSTS_INIT_CONNECTED is set -- it `break`s
|
||||||
|
; out of the switch without complaint -- so "the transfer did nothing" and "the
|
||||||
|
; transfer went wrong" look identical from the outside. The trace separates
|
||||||
|
; them.
|
||||||
|
SC_TAG = $1822C ; u32 where the next snapshot came from
|
||||||
|
SC_TRN = $18230 ; u32 trace entries used
|
||||||
|
SC_TR = $18240 ; 24 x 8 B: SSTS PSNS INTS SERR TCH TCM TCL TAG
|
||||||
|
|
||||||
|
; A poll bound. Every wait in this file is bounded, because a SCSI phase that
|
||||||
|
; never arrives must be REPORTED -- an unbounded spin is indistinguishable from
|
||||||
|
; a wedged emulator, and 34.1 already cost this project fifteen minutes to that
|
||||||
|
; exact confusion.
|
||||||
|
SC_PATIENCE = 200000
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- sc_snap
|
||||||
|
; Append SSTS/PSNS/INTS/SERR to the trace. Clobbers nothing the callers use.
|
||||||
|
sc_snap:
|
||||||
|
movem.l d0/a0,-(sp)
|
||||||
|
move.l SC_TRN.l,d0
|
||||||
|
cmp.l #24,d0
|
||||||
|
bge.s sn_out
|
||||||
|
lea SC_TR.l,a0
|
||||||
|
lsl.l #3,d0
|
||||||
|
adda.l d0,a0
|
||||||
|
move.b SC_SSTS,(a0)+
|
||||||
|
move.b SC_PSNS,(a0)+
|
||||||
|
move.b SC_INTS,(a0)+
|
||||||
|
move.b SC_SERR,(a0)+
|
||||||
|
move.b SC_TCH,(a0)+
|
||||||
|
move.b SC_TCM,(a0)+
|
||||||
|
move.b SC_TCL,(a0)+
|
||||||
|
move.b SC_TAG+3,(a0)+ ; WHERE this snapshot was taken
|
||||||
|
addq.l #1,SC_TRN.l
|
||||||
|
sn_out: movem.l (sp)+,d0/a0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---- the HD63450, so that PIO through the card's data register works at all.
|
||||||
|
; x68k_scsiext.cpp puts DMA-AWARE GLUE on $EA0015 and nowhere else:
|
||||||
|
;
|
||||||
|
; write: if (exown()) { if (!drq) dtack_w(1); else dma_w(data); }
|
||||||
|
; else dreg_w(data);
|
||||||
|
;
|
||||||
|
; With OWN asserted and DRQ low the byte is DROPPED, silently. That is the
|
||||||
|
; bring-up's fourth bug and the least guessable: ten command bytes went into
|
||||||
|
; $EA0015, the FIFO stayed empty, the transfer counter stayed at 10, and every
|
||||||
|
; register the SPC has said "waiting". Nothing reports a discarded write.
|
||||||
|
; OWN is the DMAC's, and the IPL ROM has been running for three seconds before
|
||||||
|
; the player's first instruction (52.5 reads its channel setup out of the ROM),
|
||||||
|
; so the player does not inherit a quiet DMAC -- it has to make one.
|
||||||
|
DMAC = $E84000
|
||||||
|
DMAC_CH = $40 ; channels are 64 B apart
|
||||||
|
dmac_quiet:
|
||||||
|
lea DMAC,a0
|
||||||
|
moveq #3,d1
|
||||||
|
dq1: move.b #0,7(a0) ; CCR: no operation
|
||||||
|
move.b #$FF,0(a0) ; CSR: write-one-to-clear
|
||||||
|
adda.w #DMAC_CH,a0
|
||||||
|
dbra d1,dq1
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- scsi_init
|
||||||
|
; Reset the SPC and claim an initiator ID. Leaves interrupts DISABLED: the
|
||||||
|
; player polls, because the ring producer is already a polling loop living in
|
||||||
|
; the pace wait (ring.i) and an interrupt would buy it nothing it does not
|
||||||
|
; already have.
|
||||||
|
scsi_init:
|
||||||
|
bsr dmac_quiet
|
||||||
|
move.b #SCTL_RESET,SC_SCTL ; reset & disable
|
||||||
|
moveq #40,d0
|
||||||
|
sci1: nop
|
||||||
|
dbra d0,sci1
|
||||||
|
move.b #SCSI_ID,SC_BDID
|
||||||
|
move.b #0,SC_SCTL ; out of reset; no arbitration, no ints
|
||||||
|
move.b #$FF,SC_INTS ; INTS is cleared by writing its bits
|
||||||
|
move.b #0,SC_PCTL
|
||||||
|
clr.l SC_ERR.l
|
||||||
|
clr.l DM_USE.l ; PIO unless a caller asks otherwise,
|
||||||
|
; AFTER this call (src/player/dma.i)
|
||||||
|
clr.l DM_HOOK.l ; ...and no second consumer is being
|
||||||
|
; serviced until one installs itself,
|
||||||
|
; for the same reason and in the same
|
||||||
|
; direction: a mailbox that defaults to
|
||||||
|
; a behaviour is how DM_USE landed on
|
||||||
|
; ring.i's slot.
|
||||||
|
clr.l SC_TRN.l
|
||||||
|
move.l #0,SC_TAG.l
|
||||||
|
bsr sc_snap
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- sc_settc
|
||||||
|
; d0 = 24-bit transfer count -> TCH/TCM/TCL
|
||||||
|
; Written LOW BYTE FIRST with lsr, not as a chain of rol.l #8. The rol version
|
||||||
|
; was the bring-up's second bug: three rotations put the ORIGINAL bits 31..24,
|
||||||
|
; 23..16 and 15..8 into TCH/TCM/TCL, so a count of 10 loaded a transfer counter
|
||||||
|
; of ZERO. MAME then completed the TRANSFER instantly and silently -- SSTS came
|
||||||
|
; back $85, TC0 set and XFER_IN_PROGRESS clear -- and the bus sat in command
|
||||||
|
; phase, which surfaced as the same `UNEXPECTED PHASE` as a protocol error.
|
||||||
|
sc_settc:
|
||||||
|
move.l d0,-(sp)
|
||||||
|
move.b d0,SC_TCL ; bits 7..0
|
||||||
|
lsr.l #8,d0
|
||||||
|
move.b d0,SC_TCM ; bits 15..8
|
||||||
|
lsr.l #8,d0
|
||||||
|
move.b d0,SC_TCH ; bits 23..16
|
||||||
|
move.l (sp)+,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- sc_waitreq
|
||||||
|
; Wait until the SPC reports a REQ with a phase, or patience runs out.
|
||||||
|
; Returns the phase in d0; sets SC_ERR and returns -1 on timeout.
|
||||||
|
sc_waitreq:
|
||||||
|
move.l #SC_PATIENCE,d1
|
||||||
|
swr1: move.b SC_PSNS,d0
|
||||||
|
btst #7,d0 ; REQ
|
||||||
|
bne.s swr2
|
||||||
|
subq.l #1,d1
|
||||||
|
bne.s swr1
|
||||||
|
move.l #SCE_TIMEOUT,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
swr2: and.l #7,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- sc_waitfree
|
||||||
|
; Wait for BUS FREE. A command is not over when its last message byte has been
|
||||||
|
; read: the target still has BSY asserted, and an initiator that starts
|
||||||
|
; arbitrating into that gets a selection timeout.
|
||||||
|
;
|
||||||
|
; This is the bring-up's fifth bug, and it only appeared once there were TWO
|
||||||
|
; reads. One read passed, byte-exact, and every conclusion drawn from it was
|
||||||
|
; sound; the SECOND could not select, because nothing had waited for the first
|
||||||
|
; to let go of the bus. A player issues one of these per record, so the failure
|
||||||
|
; would have been universal in the ring and invisible in the demonstration.
|
||||||
|
sc_waitfree:
|
||||||
|
move.l #SC_PATIENCE,d1
|
||||||
|
swf1: move.b SC_PSNS,d0
|
||||||
|
btst #3,d0 ; BSY
|
||||||
|
beq.s swf2
|
||||||
|
subq.l #1,d1
|
||||||
|
bne.s swf1
|
||||||
|
move.l #SCE_TIMEOUT,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
swf2: moveq #0,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- sc_select
|
||||||
|
; Select SCSI_TGT. The selection bitmask goes in TEMP -- both IDs, ours and
|
||||||
|
; theirs -- and the transfer counter doubles as the selection timeout (MAME:
|
||||||
|
; SelectionWaitBSY is derived from TC's upper bits, which is the datasheet's
|
||||||
|
; behaviour too).
|
||||||
|
sc_select:
|
||||||
|
move.b #$FF,SC_INTS
|
||||||
|
move.b #(1<<SCSI_ID)|(1<<SCSI_TGT),SC_TEMP
|
||||||
|
move.l #$002000,d0
|
||||||
|
bsr sc_settc
|
||||||
|
move.b #0,SC_PCTL
|
||||||
|
move.b #SCMD_SELECT,SC_SCMD
|
||||||
|
move.l #SC_PATIENCE,d1
|
||||||
|
ssel1: move.b SC_INTS,d0
|
||||||
|
btst #4,d0 ; COMMAND COMPLETE = selection won
|
||||||
|
bne.s ssel_ok
|
||||||
|
btst #2,d0 ; TIMEOUT = nobody there
|
||||||
|
bne.s ssel_tmo
|
||||||
|
subq.l #1,d1
|
||||||
|
bne.s ssel1
|
||||||
|
move.l #SCE_TIMEOUT,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
ssel_tmo:
|
||||||
|
move.b #$FF,SC_INTS
|
||||||
|
move.l #SCE_SELTMO,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
ssel_ok:
|
||||||
|
move.l #1,SC_TAG.l
|
||||||
|
bsr sc_snap
|
||||||
|
move.b #$FF,SC_INTS
|
||||||
|
move.l #2,SC_TAG.l
|
||||||
|
bsr sc_snap
|
||||||
|
moveq #0,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- sc_xferend
|
||||||
|
; Wait for the SPC to finish the TRANSFER it was given, rather than for the last
|
||||||
|
; byte to have been HANDED to it.
|
||||||
|
;
|
||||||
|
; This is the bring-up's one real bug and it is worth recording. Without it,
|
||||||
|
; sc_out_pio wrote all ten command bytes and returned, the caller immediately
|
||||||
|
; asked what phase the bus was in, and the answer was STILL COMMAND -- because
|
||||||
|
; the SPC had the last byte in its FIFO and had not yet run the REQ/ACK for it.
|
||||||
|
; The symptom was `UNEXPECTED PHASE, phase=2` at the DATA-IN check, which reads
|
||||||
|
; like a target refusing the command and is nothing of the kind. A byte handed
|
||||||
|
; to a FIFO is not a byte on the bus.
|
||||||
|
sc_xferend:
|
||||||
|
move.l #SC_PATIENCE,d3
|
||||||
|
sxe1: move.b SC_SSTS,d0
|
||||||
|
btst #4,d0 ; XFER IN PROGRESS
|
||||||
|
beq.s sxe2
|
||||||
|
subq.l #1,d3
|
||||||
|
bne.s sxe1
|
||||||
|
move.l #SCE_TIMEOUT,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
sxe2: moveq #0,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- sc_out_pio
|
||||||
|
; Send d1 bytes from (a0) in phase d2. Command blocks and nothing else, so it
|
||||||
|
; is the small, simple one.
|
||||||
|
sc_out_pio:
|
||||||
|
move.b d2,SC_PCTL
|
||||||
|
move.l d1,d0
|
||||||
|
bsr sc_settc
|
||||||
|
move.b #SCMD_XFER,SC_SCMD
|
||||||
|
move.l #4,SC_TAG.l ; 4 = TRANSFER issued for an OUT phase
|
||||||
|
bsr sc_snap
|
||||||
|
sop1: move.l #SC_PATIENCE,d3
|
||||||
|
sop2: move.b SC_SSTS,d0
|
||||||
|
btst #1,d0 ; DREG FULL -- wait for room
|
||||||
|
beq.s sop3
|
||||||
|
subq.l #1,d3
|
||||||
|
bne.s sop2
|
||||||
|
move.l #SCE_TIMEOUT,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
sop3: move.b (a0)+,SC_DREG
|
||||||
|
subq.l #1,d1
|
||||||
|
bne.s sop1
|
||||||
|
move.l #5,SC_TAG.l ; 5 = every byte handed to the FIFO
|
||||||
|
bsr sc_snap
|
||||||
|
bsr sc_xferend
|
||||||
|
move.l d0,-(sp)
|
||||||
|
move.l #6,SC_TAG.l ; 6 = after waiting for the transfer
|
||||||
|
bsr sc_snap
|
||||||
|
move.l (sp)+,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- sc_in_pio
|
||||||
|
; Receive d1 bytes into (a1) in phase d2. This is the path the DMA version
|
||||||
|
; replaces; it stays because it is what makes a DMA failure diagnosable.
|
||||||
|
sc_in_pio:
|
||||||
|
move.b d2,SC_PCTL
|
||||||
|
move.l d1,d0
|
||||||
|
bsr sc_settc
|
||||||
|
move.b #SCMD_XFER,SC_SCMD
|
||||||
|
move.l #7,SC_TAG.l ; 7 = TRANSFER issued for an IN phase
|
||||||
|
bsr sc_snap
|
||||||
|
sip1: move.l #SC_PATIENCE,d3
|
||||||
|
sip2: move.b SC_SSTS,d0
|
||||||
|
btst #0,d0 ; DREG EMPTY -- wait for a byte
|
||||||
|
beq.s sip3
|
||||||
|
subq.l #1,d3
|
||||||
|
bne.s sip2
|
||||||
|
move.l #SCE_TIMEOUT,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
sip3: move.b SC_DREG,(a1)+
|
||||||
|
subq.l #1,d1
|
||||||
|
bne.s sip1
|
||||||
|
move.l #8,SC_TAG.l ; 8 = every byte taken from the FIFO
|
||||||
|
bsr sc_snap
|
||||||
|
bsr sc_xferend
|
||||||
|
move.l d0,-(sp)
|
||||||
|
move.l #9,SC_TAG.l ; 9 = after waiting for the IN transfer
|
||||||
|
bsr sc_snap
|
||||||
|
move.l (sp)+,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- sc_in_data
|
||||||
|
; Receive d1 bytes in phase d2, storing only the WINDOW: drop SC_WSKIP, store
|
||||||
|
; SC_WKEEP at (a1), drop whatever is left. This is the DATA IN path; STATUS and
|
||||||
|
; MESSAGE IN keep sc_in_pio, which is one byte and has no window.
|
||||||
|
;
|
||||||
|
; THREE LOOPS, NOT ONE STEERED LOOP. A single loop with a `which third am I in`
|
||||||
|
; test per byte would cost ~20 clocks on every byte of every record, and the
|
||||||
|
; number this rig exists to produce is the transport's per-byte cost -- so the
|
||||||
|
; middle loop is byte-for-byte sc_in_pio's and the window is paid for once at
|
||||||
|
; each boundary instead of once per byte.
|
||||||
|
;
|
||||||
|
; A SPLIT DATA PHASE WOULD RE-SKIP. The counters are re-read from memory on
|
||||||
|
; every entry, so a target that broke one READ(10) across two DATA IN phases
|
||||||
|
; would drop the head of the second phase as well. This one does not split --
|
||||||
|
; the same limitation sc_in_pio's caller already carries -- and the fix is the
|
||||||
|
; same one: d5 has to become what each phase actually delivered.
|
||||||
|
sc_in_data:
|
||||||
|
; ---- P4a: the DATA IN phase can be handed to the HD63450 instead, and
|
||||||
|
; when it is, the CPU touches none of these bytes. src/player/dma.i.
|
||||||
|
; The window is REFUSED rather than ignored: a channel writes a
|
||||||
|
; contiguous run and cannot be told to drop the first 300 bytes, so a
|
||||||
|
; windowed DMA read would deliver the neighbours' bytes into the ring
|
||||||
|
; and the block loop has no bounds check to catch it (49.2, 58.3).
|
||||||
|
; Refusing it here is what makes "sector-aligned records" a PRECONDITION
|
||||||
|
; the transport states rather than an assumption it carries.
|
||||||
|
tst.l DM_USE.l
|
||||||
|
beq.s .pio
|
||||||
|
tst.l SC_WSKIP.l
|
||||||
|
bne.s .nowin
|
||||||
|
move.l SC_WKEEP.l,d0
|
||||||
|
cmp.l d1,d0
|
||||||
|
bne.s .nowin
|
||||||
|
bra sc_in_dma
|
||||||
|
.nowin: move.l #SCE_WINDOW,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
.pio:
|
||||||
|
movem.l d6-d7,-(sp)
|
||||||
|
move.b d2,SC_PCTL
|
||||||
|
move.l d1,d0
|
||||||
|
bsr sc_settc
|
||||||
|
move.b #SCMD_XFER,SC_SCMD
|
||||||
|
move.l #7,SC_TAG.l ; 7 = TRANSFER issued for an IN phase
|
||||||
|
bsr sc_snap
|
||||||
|
move.l SC_WSKIP.l,d6
|
||||||
|
move.l SC_WKEEP.l,d7
|
||||||
|
sub.l d6,d1
|
||||||
|
sub.l d7,d1 ; d1 = trailing bytes to drop
|
||||||
|
tst.l d6
|
||||||
|
beq.s .keep
|
||||||
|
.drop1: move.l #SC_PATIENCE,d3
|
||||||
|
.dw1: move.b SC_SSTS,d0
|
||||||
|
btst #0,d0 ; DREG EMPTY -- wait for a byte
|
||||||
|
beq.s .dg1
|
||||||
|
subq.l #1,d3
|
||||||
|
bne.s .dw1
|
||||||
|
bra .tmo
|
||||||
|
.dg1: tst.b SC_DREG ; popped and thrown away
|
||||||
|
subq.l #1,d6
|
||||||
|
bne.s .drop1
|
||||||
|
.keep: tst.l d7
|
||||||
|
beq.s .tail
|
||||||
|
.keep1: move.l #SC_PATIENCE,d3
|
||||||
|
.kw1: move.b SC_SSTS,d0
|
||||||
|
btst #0,d0
|
||||||
|
beq.s .kg1
|
||||||
|
subq.l #1,d3
|
||||||
|
bne.s .kw1
|
||||||
|
bra .tmo
|
||||||
|
.kg1: move.b SC_DREG,(a1)+
|
||||||
|
subq.l #1,d7
|
||||||
|
bne.s .keep1
|
||||||
|
.tail: tst.l d1
|
||||||
|
beq.s .fin
|
||||||
|
.tail1: move.l #SC_PATIENCE,d3
|
||||||
|
.tw1: move.b SC_SSTS,d0
|
||||||
|
btst #0,d0
|
||||||
|
beq.s .tg1
|
||||||
|
subq.l #1,d3
|
||||||
|
bne.s .tw1
|
||||||
|
bra.s .tmo
|
||||||
|
.tg1: tst.b SC_DREG
|
||||||
|
subq.l #1,d1
|
||||||
|
bne.s .tail1
|
||||||
|
.fin: movem.l (sp)+,d6-d7
|
||||||
|
move.l #8,SC_TAG.l ; 8 = every byte taken from the FIFO
|
||||||
|
bsr sc_snap
|
||||||
|
bsr sc_xferend
|
||||||
|
move.l d0,-(sp)
|
||||||
|
move.l #9,SC_TAG.l
|
||||||
|
bsr sc_snap
|
||||||
|
move.l (sp)+,d0
|
||||||
|
rts
|
||||||
|
.tmo: movem.l (sp)+,d6-d7
|
||||||
|
move.l #SCE_TIMEOUT,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- scsi_read
|
||||||
|
; READ(10) of d4 blocks from LBA d3 into (a1). READ(10) rather than READ(6)
|
||||||
|
; because a 21-bit LBA and a 256-block ceiling are limits this container will
|
||||||
|
; reach -- 4,488,588 B of frame records is already 8,767 sectors, and a full
|
||||||
|
; disc is 1.09 GiB (ROADMAP C3).
|
||||||
|
;
|
||||||
|
; DRIVEN BY THE PHASE, NOT BY A SCRIPT, and that is the third thing the bring-up
|
||||||
|
; taught. The first version ran a fixed sequence -- select, command, data,
|
||||||
|
; status, message -- and broke the moment the target asked for something else:
|
||||||
|
; it came up in MESSAGE OUT with ATN asserted and the driver, which "knew" the
|
||||||
|
; next phase was COMMAND, called it an unexpected phase and gave up. The bus
|
||||||
|
; decides the order. A driver that reads the phase and services whatever it
|
||||||
|
; finds is both shorter and correct, and it is what the target is entitled to.
|
||||||
|
scsi_read:
|
||||||
|
movem.l d3-d5/a1,-(sp)
|
||||||
|
move.l d4,d5
|
||||||
|
lsl.l #8,d5
|
||||||
|
lsl.l #1,d5 ; blocks * 512
|
||||||
|
clr.l SC_WSKIP.l ; no window: keep the whole transfer
|
||||||
|
move.l d5,SC_WKEEP.l
|
||||||
|
bra.s scr_body
|
||||||
|
; ---- the same read, delivering only SC_WSKIP..SC_WSKIP+SC_WKEEP of it. The
|
||||||
|
; caller sets the two words; everything else is identical, which is the point --
|
||||||
|
; a windowed read and a whole one must not be able to differ in the protocol.
|
||||||
|
scsi_read_win:
|
||||||
|
movem.l d3-d5/a1,-(sp)
|
||||||
|
move.l d4,d5
|
||||||
|
lsl.l #8,d5
|
||||||
|
lsl.l #1,d5
|
||||||
|
scr_body:
|
||||||
|
; ---- the command block, built before anything is on the bus
|
||||||
|
lea SC_CDB.l,a0
|
||||||
|
move.b #$28,(a0)+ ; READ(10)
|
||||||
|
clr.b (a0)+
|
||||||
|
move.l d3,d0 ; LBA, big-endian u32
|
||||||
|
rol.l #8,d0
|
||||||
|
move.b d0,(a0)+ ; 31..24
|
||||||
|
rol.l #8,d0
|
||||||
|
move.b d0,(a0)+ ; 23..16
|
||||||
|
rol.l #8,d0
|
||||||
|
move.b d0,(a0)+ ; 15..8
|
||||||
|
rol.l #8,d0
|
||||||
|
move.b d0,(a0)+ ; 7..0
|
||||||
|
clr.b (a0)+
|
||||||
|
move.l d4,d0 ; block count, big-endian u16. Same
|
||||||
|
lsr.l #8,d0 ; trap as sc_settc had: a rol chain here
|
||||||
|
move.b d0,(a0)+ ; would have emitted bits 31..24/23..16
|
||||||
|
move.b d4,(a0)+ ; of a count that lives in 15..0.
|
||||||
|
clr.b (a0)+
|
||||||
|
clr.l SC_STAT.l
|
||||||
|
bsr sc_select
|
||||||
|
tst.l d0
|
||||||
|
bmi scr_out
|
||||||
|
; Drop ATN. We have no message to send, so asking the target not to ask
|
||||||
|
; for one is cheaper than answering. The MSGOUT arm below still exists,
|
||||||
|
; because "cheaper" is not "guaranteed".
|
||||||
|
move.b #SCMD_RSTATN,SC_SCMD
|
||||||
|
; ---- service whatever the bus asks for, until the target ends the command
|
||||||
|
scr_ph:
|
||||||
|
bsr sc_waitreq
|
||||||
|
tst.l d0
|
||||||
|
bmi scr_out
|
||||||
|
move.l d0,-(sp)
|
||||||
|
move.l #3,SC_TAG.l ; 3 = the phase loop saw a REQ
|
||||||
|
bsr sc_snap
|
||||||
|
move.l (sp)+,d0
|
||||||
|
cmp.l #PH_CMD,d0
|
||||||
|
beq.s scr_cmd
|
||||||
|
cmp.l #PH_DATAIN,d0
|
||||||
|
beq.s scr_din
|
||||||
|
cmp.l #PH_STATUS,d0
|
||||||
|
beq.s scr_st
|
||||||
|
cmp.l #PH_MSGIN,d0
|
||||||
|
beq.s scr_min
|
||||||
|
cmp.l #PH_MSGOUT,d0
|
||||||
|
beq.s scr_mout
|
||||||
|
bra scr_phase
|
||||||
|
scr_cmd:
|
||||||
|
lea SC_CDB.l,a0
|
||||||
|
moveq #10,d1
|
||||||
|
moveq #PH_CMD,d2
|
||||||
|
bsr sc_out_pio
|
||||||
|
tst.l d0
|
||||||
|
bmi scr_out
|
||||||
|
bra scr_ph
|
||||||
|
scr_din:
|
||||||
|
; KNOWN LIMITATION, harmless here and not harmless forever: this asks
|
||||||
|
; for the WHOLE remaining count every time the bus enters DATA IN. A
|
||||||
|
; target that split one READ(10) across two data phases would be served
|
||||||
|
; the full length twice and overrun the caller's buffer. This one does
|
||||||
|
; not split -- 4,096 B and 2,048 B both arrive in a single phase -- but
|
||||||
|
; a real drive may, and P4b's mailbox integration is where d5 has to
|
||||||
|
; start being decremented by what each phase actually delivered.
|
||||||
|
move.l 12(sp),a1 ; the caller's destination. movem.l
|
||||||
|
; d3-d5/a1,-(sp) lays them out ASCENDING
|
||||||
|
; from sp as d3,d4,d5,a1 -- a1 is at 12.
|
||||||
|
move.l d5,d1
|
||||||
|
moveq #PH_DATAIN,d2
|
||||||
|
bsr sc_in_data
|
||||||
|
tst.l d0
|
||||||
|
bmi scr_out
|
||||||
|
bra scr_ph
|
||||||
|
scr_st:
|
||||||
|
lea SC_STAT.l,a1
|
||||||
|
addq.l #3,a1 ; the byte lands in the u32's low end
|
||||||
|
moveq #1,d1
|
||||||
|
moveq #PH_STATUS,d2
|
||||||
|
bsr sc_in_pio
|
||||||
|
tst.l d0
|
||||||
|
bmi scr_out
|
||||||
|
bra scr_ph
|
||||||
|
scr_min:
|
||||||
|
lea SC_MSG.l,a1
|
||||||
|
moveq #1,d1
|
||||||
|
moveq #PH_MSGIN,d2
|
||||||
|
bsr sc_in_pio
|
||||||
|
tst.l d0
|
||||||
|
bmi scr_out
|
||||||
|
; A message in ends the command. Anything non-zero in the status byte
|
||||||
|
; is the target refusing, and a transport that ignored it would hand the
|
||||||
|
; ring a buffer of stale bytes and call it a record.
|
||||||
|
move.l SC_STAT.l,d0
|
||||||
|
beq.s scr_ok
|
||||||
|
move.l #SCE_STATUS,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
bra.s scr_out
|
||||||
|
scr_mout:
|
||||||
|
; Nothing to say: IDENTIFY, no disconnect, LUN 0.
|
||||||
|
lea SC_MSG.l,a1
|
||||||
|
move.b #$80,(a1)
|
||||||
|
move.l a1,a0
|
||||||
|
moveq #1,d1
|
||||||
|
moveq #PH_MSGOUT,d2
|
||||||
|
bsr sc_out_pio
|
||||||
|
tst.l d0
|
||||||
|
bmi scr_out
|
||||||
|
bra scr_ph
|
||||||
|
scr_ok: ; END OF COMMAND, and it takes two steps rather than one. After the
|
||||||
|
; final message byte the SPC is still holding ACK -- PSNS reads $4F,
|
||||||
|
; REQ low and ACK high -- and a target cannot drop BSY into that. So
|
||||||
|
; ACK is dropped explicitly, and only then is the bus released.
|
||||||
|
move.b #SCMD_RSTACK,SC_SCMD
|
||||||
|
move.b #SCMD_RELEASE,SC_SCMD
|
||||||
|
move.l #10,SC_TAG.l ; 10 = after the bus release command
|
||||||
|
bsr sc_snap
|
||||||
|
bsr sc_waitfree ; leave the bus as we found it
|
||||||
|
tst.l d0
|
||||||
|
bmi scr_out
|
||||||
|
moveq #0,d0
|
||||||
|
scr_out:
|
||||||
|
movem.l (sp)+,d3-d5/a1
|
||||||
|
rts
|
||||||
|
scr_phase:
|
||||||
|
move.l d0,SC_PH.l
|
||||||
|
move.l #SCE_PHASE,SC_ERR.l
|
||||||
|
moveq #-1,d0
|
||||||
|
bra.s scr_out
|
||||||
|
|
||||||
|
; ---- the DMAC side of the data phase, ROADMAP P4a. Included unconditionally
|
||||||
|
; so that there is ONE transport file: sc_in_data dispatches on DM_USE, which
|
||||||
|
; scsi_init clears, so a front-end that never sets it assembles the same PIO
|
||||||
|
; path FINDINGS 58 measured and executes not one instruction of the following.
|
||||||
|
include "src/player/dma.i"
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
; Front-end for the MB89352 PROBE (ROADMAP P4, first step), for the rig.
|
||||||
|
;
|
||||||
|
; WHY A PROBE AND NOT A DRIVER. P4 replaces tools/bench/stream.lua's modelled
|
||||||
|
; transport with a real SPC behind the XF_* mailbox src/player/ring.i already
|
||||||
|
; talks to. Before any of that can be written, the register map has to be a
|
||||||
|
; FACT on the emulated machine rather than a reading of somebody's datasheet.
|
||||||
|
; FINDINGS 32.4 quotes MAME mapping the data register at $EA0015 -- register
|
||||||
|
; index 10 at a stride of 2 from $EA0001, i.e. registers on the ODD bytes. That
|
||||||
|
; is an inference from ONE address, and every access the driver makes rests on
|
||||||
|
; it.
|
||||||
|
;
|
||||||
|
; WHY IT ENUMERATES INSTEAD OF DUMPING. The first version of this walked
|
||||||
|
; $EA0000 upwards with a plain `move.b (a0)+`, and took a bus error at $EA0006 --
|
||||||
|
; at which point it knew one address was dead and nothing about the other 57.
|
||||||
|
; A sequential dump stops at the first hole and reports the hole as the answer.
|
||||||
|
; So this probes ONE ADDRESS AT A TIME with the index in MEMORY, and a bus error
|
||||||
|
; handler that records the fault, steps the index and re-enters the loop. A
|
||||||
|
; dead address costs an entry in the map rather than the rest of the run.
|
||||||
|
;
|
||||||
|
; The 68000 cannot resume a faulted instruction -- RTE re-runs it and faults
|
||||||
|
; again -- so the handler does not try. It restores a stack pointer saved
|
||||||
|
; before the loop and jumps back to the loop head, which reloads everything it
|
||||||
|
; needs from memory. Nothing lives in a register across a fault.
|
||||||
|
;
|
||||||
|
; This gate drives no SCSI bus and moves no data. It is the smallest thing that
|
||||||
|
; can turn "MAME instantiates an MB89352" into "the 68000 can reach it, HERE".
|
||||||
|
|
||||||
|
SCFLAG = $18080 ; 0 idle / 1 done
|
||||||
|
SCN = 64 ; addresses probed, from SPCBASE up
|
||||||
|
SCIDX = $18084 ; u32 probe index, lives in memory across faults
|
||||||
|
SCSAVSP = $18088 ; u32 stack pointer saved before the loop
|
||||||
|
SCVAL = $18100 ; SCN bytes: what each address read
|
||||||
|
SCOK = $18140 ; SCN bytes: 1 = answered, 0 = bus error
|
||||||
|
SCTMP = $180D0 ; u32: TEMP writeback -- $A5 written, read back
|
||||||
|
SCTMPOK = $180D4 ; u32: 1 = the writeback completed without fault
|
||||||
|
SCRD = $180D8 ; u32: scsi_read's return, 0 = the read worked
|
||||||
|
SCDRG = $180DC ; u32: $5A written to DREG then read straight back
|
||||||
|
SCDRS = $180E0 ; u32: SSTS between that write and that read
|
||||||
|
SCDST = $20000 ; where the read lands
|
||||||
|
SCBLKS = 8 ; 8 x 512 B, enough to cross a sector boundary
|
||||||
|
SCRD2 = $180E4 ; u32: the second read's return
|
||||||
|
SCER1 = $180E8 ; u32: SC_ERR as it stood after the first read
|
||||||
|
SCER2 = $180EC ; u32: ...and after the second
|
||||||
|
SCDST2 = $28000 ; where the second read lands
|
||||||
|
SCLBA2 = 1000 ; a NON-ZERO LBA: block 0 would pass even if the
|
||||||
|
; LBA bytes of the command block were ignored
|
||||||
|
SCBLK2 = 4
|
||||||
|
|
||||||
|
SPCBASE = $EA0000
|
||||||
|
|
||||||
|
org $10000
|
||||||
|
start:
|
||||||
|
move.l #buserr,$8.w ; vector 2
|
||||||
|
clr.l SCFLAG.l
|
||||||
|
clr.l SCTMP.l
|
||||||
|
clr.l SCTMPOK.l
|
||||||
|
clr.l SCIDX.l
|
||||||
|
move.l sp,SCSAVSP.l
|
||||||
|
|
||||||
|
; ---- probe SCN addresses, one at a time, surviving each fault
|
||||||
|
ploop:
|
||||||
|
move.l SCIDX.l,d0
|
||||||
|
cmp.l #SCN,d0
|
||||||
|
bge.s pdone
|
||||||
|
lea SPCBASE,a0
|
||||||
|
adda.l d0,a0
|
||||||
|
lea SCVAL.l,a1
|
||||||
|
lea SCOK.l,a2
|
||||||
|
move.b #1,0(a2,d0.l) ; assume it answers; the handler undoes
|
||||||
|
move.b (a0),d1 ; <- the access under test
|
||||||
|
move.b d1,0(a1,d0.l)
|
||||||
|
addq.l #1,SCIDX.l
|
||||||
|
bra.s ploop
|
||||||
|
pdone:
|
||||||
|
|
||||||
|
; ---- TEMP (register 11 on the believed map, $EA0017) is a scratch latch on a
|
||||||
|
; real MB89352. Writing a pattern and reading it back separates "these odd
|
||||||
|
; bytes are registers" from "these odd bytes are a mirror of something".
|
||||||
|
; Guarded the same way: if it faults, the handler lands in ploop with SCIDX
|
||||||
|
; already past the end, falls through here again, and SCTMPOK stays 0.
|
||||||
|
move.b #$A5,SPCBASE+23
|
||||||
|
moveq #0,d0
|
||||||
|
move.b SPCBASE+23,d0
|
||||||
|
move.l d0,SCTMP.l
|
||||||
|
move.l #1,SCTMPOK.l
|
||||||
|
|
||||||
|
; ---- DOES A WRITE TO THE DATA REGISTER REACH THE CHIP AT ALL?
|
||||||
|
; $EA0015 is the one address x68k_scsiext.cpp puts its own glue on, and that
|
||||||
|
; glue DROPS a write when the DMAC's OWN is asserted and DRQ is low. A dropped
|
||||||
|
; write is invisible: no error, no status bit, nothing. So it is tested
|
||||||
|
; directly, before any SCSI protocol can be blamed for it. dreg_w enqueues into
|
||||||
|
; the FIFO, so DREG_EMPTY must fall between the write and the read, and the read
|
||||||
|
; must give the byte back.
|
||||||
|
bsr scsi_init
|
||||||
|
move.b #$5A,SPCBASE+21
|
||||||
|
moveq #0,d0
|
||||||
|
move.b SPCBASE+13,d0 ; SSTS: is the FIFO still empty?
|
||||||
|
move.l d0,SCDRS.l
|
||||||
|
moveq #0,d0
|
||||||
|
move.b SPCBASE+21,d0
|
||||||
|
move.l d0,SCDRG.l
|
||||||
|
|
||||||
|
; ---- the SPC is reachable; now make it fetch something. A read of the first
|
||||||
|
; SCBLKS sectors, in PIO, verified BY THE HOST against the same bytes in
|
||||||
|
; tmp/dlxdisk.img. That is the whole of P4's correctness half in one line: the
|
||||||
|
; player's own code selected a target, issued a READ(10) and got the disc's
|
||||||
|
; bytes back, with no IOCS and no host in the path.
|
||||||
|
bsr scsi_init
|
||||||
|
moveq #0,d3 ; LBA 0
|
||||||
|
moveq #SCBLKS,d4
|
||||||
|
lea SCDST,a1
|
||||||
|
bsr scsi_read
|
||||||
|
move.l d0,SCRD.l
|
||||||
|
move.l SC_ERR.l,SCER1.l ; SC_ERR is the LAST error, so it is
|
||||||
|
; captured per read: reading it once at
|
||||||
|
; the end reported the second read's
|
||||||
|
; failure against the first read's name.
|
||||||
|
|
||||||
|
; ---- and again, somewhere else on the disc. A read of LBA 0 is passed by a
|
||||||
|
; driver that emits a malformed LBA field, because zero is what a malformed
|
||||||
|
; field usually is. This one is not.
|
||||||
|
move.l #SCLBA2,d3
|
||||||
|
moveq #SCBLK2,d4
|
||||||
|
lea SCDST2,a1
|
||||||
|
bsr scsi_read
|
||||||
|
move.l d0,SCRD2.l
|
||||||
|
move.l SC_ERR.l,SCER2.l
|
||||||
|
|
||||||
|
move.l #1,SCFLAG.l
|
||||||
|
hold: bra.s hold
|
||||||
|
|
||||||
|
; ---- bus error. Mark the address dead, step past it, re-enter the loop with a
|
||||||
|
; stack pointer that is known good. The stacked frame is abandoned deliberately:
|
||||||
|
; there is nothing in it worth more than the next 57 addresses.
|
||||||
|
buserr:
|
||||||
|
move.l SCSAVSP.l,sp
|
||||||
|
move.l SCIDX.l,d0
|
||||||
|
cmp.l #SCN,d0
|
||||||
|
bge.s btmp
|
||||||
|
lea SCOK.l,a2
|
||||||
|
clr.b 0(a2,d0.l)
|
||||||
|
lea SCVAL.l,a1
|
||||||
|
move.b #$FF,0(a1,d0.l)
|
||||||
|
addq.l #1,SCIDX.l
|
||||||
|
jmp ploop
|
||||||
|
btmp: ; the fault was the TEMP writeback
|
||||||
|
move.l #1,SCFLAG.l
|
||||||
|
be: bra.s be
|
||||||
|
|
||||||
|
include "src/player/scsi.i"
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
; DLX3 frame decoder, RING-BUFFER front-end -- STATUS item 3, FINDINGS 49.
|
||||||
|
;
|
||||||
|
; src/player/decode.s decodes a stream that is ALREADY WHOLLY IN RAM: the rig
|
||||||
|
; preloads 5,261,814 bytes at $30000 and walks a0 forward through all of it.
|
||||||
|
; That gate proves the decoder is pixel-exact over a 120-frame window
|
||||||
|
; (FINDINGS 45) and says NOTHING about how the bytes got there. The shipping
|
||||||
|
; player never holds a window at once; it streams from a SCSI disk into a ring
|
||||||
|
; a fraction of the size. Nothing in this tree has ever tested that path.
|
||||||
|
;
|
||||||
|
; WHAT IS ACTUALLY HARD ABOUT IT. The block loop and the span chain read the
|
||||||
|
; stream with a monotonically increasing a0 and no bounds check anywhere --
|
||||||
|
; `move.l (a0)+,d0`, `lea MODEB(a0),a0`, eleven unrolled `movem.l (a0)+`, a
|
||||||
|
; `move.b (a0)+` per block index. None of it can survive an address that wraps
|
||||||
|
; mid-record. So the ring does not merely need ENOUGH BYTES resident by the
|
||||||
|
; deadline -- 09_buffer_sim.py's question, and FINDINGS 21's answer -- it needs
|
||||||
|
; the WHOLE NEXT RECORD resident and CONTIGUOUS.
|
||||||
|
;
|
||||||
|
; THE WRAP POLICY IS `aligned`, and it was chosen on measurement, not taste
|
||||||
|
; (tools/analysis/19_ring_stream.py). The producer refuses to start a record it
|
||||||
|
; cannot finish before the end of the ring: it leaves a hole and restarts at 0.
|
||||||
|
;
|
||||||
|
; aligned costs RAM -- a mean hole of 23.4 KB in a 256 KB ring, 9.1% of it --
|
||||||
|
; and ZERO CPU.
|
||||||
|
; split lets records wrap and mirrors the ring's first MAXREC bytes into a
|
||||||
|
; shadow past its end, so any record start reads linearly. Costs
|
||||||
|
; zero RAM and 46,394 clocks/frame of memcpy -- 5.57% of the frame
|
||||||
|
; budget, forever.
|
||||||
|
;
|
||||||
|
; Both figures are for s14_d5_all1500, the shipping candidate, in a 256 KB ring;
|
||||||
|
; they scale with record size, so they are per container, not universal. The
|
||||||
|
; lighter gate container makes it 5.7% of the ring against 3.64% of the budget --
|
||||||
|
; same direction, same verdict.
|
||||||
|
;
|
||||||
|
; The decoder already spends 77.0% of the budget on the mean frame and 91.1% at
|
||||||
|
; p90 (FINDINGS 45). 5.57% more puts p90 at 96.7%. RAM is the resource this
|
||||||
|
; machine has 2 MB of and clocks are the one it has none of, so the trade is not
|
||||||
|
; close. `aligned` also needs a per-record INDEX on the fill side, which a
|
||||||
|
; BRANCHING laserdisc game needs anyway to seek to a branch point -- so the
|
||||||
|
; policy that costs no clocks also reuses a structure the player cannot avoid.
|
||||||
|
;
|
||||||
|
; The third option -- teach the block loop to wrap its own reads -- is the
|
||||||
|
; expensive one and not because of the branch. A bounds test lands INSIDE the
|
||||||
|
; instruction sequences FINDINGS 30.4 and 40 fitted their constants to, so it
|
||||||
|
; does not cost a compare, it costs every span and per-block figure in the tree
|
||||||
|
; being re-measured.
|
||||||
|
;
|
||||||
|
; THE PRODUCER IS OUTSIDE THIS FILE. Here it is tools/bench/stream.lua playing
|
||||||
|
; a SCSI disk at a modelled byte rate; in the player it is the MB89352 and a
|
||||||
|
; DMAC channel. The handshake is deliberately the same either way:
|
||||||
|
;
|
||||||
|
; producer -> FR_HEAD count of records made wholly resident (monotonic)
|
||||||
|
; DESC[] ring of record base addresses, DESCN entries
|
||||||
|
; decoder -> FR_TAIL count of records consumed (monotonic)
|
||||||
|
; RD_PTR one past the last byte read; everything below is free
|
||||||
|
;
|
||||||
|
; Two monotonic counters and a released-to pointer -- no lock, no shared cursor,
|
||||||
|
; single reader and single writer, so it is correct on a 68000 with no atomics
|
||||||
|
; provided each side only ever writes its own words. That is why FR_TAIL is
|
||||||
|
; the decoder's and FR_HEAD is the producer's rather than one shared index.
|
||||||
|
;
|
||||||
|
; STALLS ARE COUNTED, NOT HIDDEN. A frame whose record is not resident when the
|
||||||
|
; decoder wants it spins in `waitrec`, and STALLS counts the FRAMES that had to
|
||||||
|
; wait at all (not the polls). A rig that silently absorbed an underrun would
|
||||||
|
; report a pixel-exact decode of a stream that arrived late, which is precisely
|
||||||
|
; the failure this front-end exists to make visible. The spin is bounded:
|
||||||
|
; SPINMAX polls without progress sets FLAG=$E1, so a wedged producer fails as a
|
||||||
|
; wedged producer instead of as a MAME timeout with no diagnosis (FINDINGS 34.1).
|
||||||
|
|
||||||
|
FLAG = $18000 ; 0 idle / 1 running / $FF done / $EE desync
|
||||||
|
; / $E1 producer stalled out
|
||||||
|
ITER = $18008 ; outer repeat count, written by Lua
|
||||||
|
NFR = $1800C ; frames per pass
|
||||||
|
SCR_N = $18014 ; frames remaining this pass
|
||||||
|
SCR_END = $18018 ; expected end of the current payload
|
||||||
|
RD_PTR = $18020 ; decoder -> producer: released up to here
|
||||||
|
FR_HEAD = $18024 ; producer -> decoder: records resident
|
||||||
|
FR_TAIL = $18028 ; decoder -> producer: records consumed
|
||||||
|
STALLS = $1802C ; frames that had to wait for their record
|
||||||
|
SPINS = $18030 ; total poll iterations spent waiting
|
||||||
|
PACE = $18034 ; producer -> decoder: frame ticks elapsed since
|
||||||
|
; release. Frame i may not START before tick i.
|
||||||
|
PACEON = $18038 ; 1 = obey PACE. 0 leaves the loop free-running,
|
||||||
|
; byte for byte the loop FINDINGS 49 measured.
|
||||||
|
LATEFR = $18080 ; frames that reached the pace gate with their
|
||||||
|
; tick ALREADY ARRIVED, i.e. did not idle for a
|
||||||
|
; single poll -- the previous frame used its
|
||||||
|
; whole slot. See the gate below.
|
||||||
|
LATEMAX = $18084 ; the worst of those, in WHOLE ticks overrun
|
||||||
|
LATE1ST = $18088 ; index of the FIRST such frame, so that a
|
||||||
|
; count can be told apart from a start-up
|
||||||
|
; transient without re-running anything
|
||||||
|
CLKON = $1803C ; 1 = the 68000 paces ITSELF: src/player/clock.i
|
||||||
|
; drives PACE off the CRTC's V-DISP instead of
|
||||||
|
; the host writing it. Needs PACEON=1; the gate
|
||||||
|
; below cannot tell the two apart and must not.
|
||||||
|
DESC = $18100 ; DESCN x u32, record base addresses
|
||||||
|
; The producer's own words -- RINGOWN, the transport mailbox and its
|
||||||
|
; instruments -- are in src/player/ring.i, at $18300 and up, clear of DESC's
|
||||||
|
; 256 bytes.
|
||||||
|
|
||||||
|
DESCN = 64 ; power of two; the index is masked, not compared
|
||||||
|
DESCM = (DESCN-1)*4 ; mask for a BYTE offset into DESC
|
||||||
|
|
||||||
|
SPINMAX = 2000000 ; polls with no progress before giving up
|
||||||
|
|
||||||
|
include "src/player/geom.i"
|
||||||
|
|
||||||
|
org $10000
|
||||||
|
start:
|
||||||
|
; ---- the ring producer, if this run is asking the 68000 to fill its own ring
|
||||||
|
; (ROADMAP P5, src/player/ring.i). It goes FIRST because it only builds tables
|
||||||
|
; and touches no hardware: a run that cannot build them should not have armed an
|
||||||
|
; interrupt source first.
|
||||||
|
tst.l RINGOWN.l
|
||||||
|
beq.s noring
|
||||||
|
; ---- the transport, before the producer that will ask it for something.
|
||||||
|
; ring_init ends in a ring_seek and a seek WAITS for the channel to go quiet, so
|
||||||
|
; the thing that makes the channel quiet has to exist first. With XF_SCSI = 0
|
||||||
|
; this brings up nothing and the host is the transport (FINDINGS 55).
|
||||||
|
bsr xf_init
|
||||||
|
bsr ring_init
|
||||||
|
tst.l d0
|
||||||
|
bpl.s noring
|
||||||
|
move.l #$E3,FLAG.l ; the record index is longer than ROFF
|
||||||
|
bra hold
|
||||||
|
noring:
|
||||||
|
; ---- the frame clock, if this run is asking the 68000 to keep its own time.
|
||||||
|
; It goes here rather than inside the frame loop because clk_init CLEARS PACE:
|
||||||
|
; tick 0 has to be the instant the decoder was released, exactly as it is when
|
||||||
|
; the host writes PACE, or the first frame's deadline moves.
|
||||||
|
tst.l CLKON.l
|
||||||
|
beq.s noclk
|
||||||
|
bsr clk_init
|
||||||
|
tst.l CLK_ERR.l
|
||||||
|
beq.s noclk
|
||||||
|
move.l #$E2,FLAG.l ; the clock refused; CLK_ERR says why
|
||||||
|
bra hold
|
||||||
|
noclk:
|
||||||
|
move.l #1,FLAG.l ; timer starts here
|
||||||
|
outer:
|
||||||
|
move.l NFR.l,SCR_N.l
|
||||||
|
clr.l FR_TAIL.l
|
||||||
|
clr.l STALLS.l
|
||||||
|
clr.l SPINS.l
|
||||||
|
clr.l LATEFR.l
|
||||||
|
clr.l LATEMAX.l
|
||||||
|
move.l #-1,LATE1ST.l
|
||||||
|
; ---- SEEK AND PREFILL. Every pass starts with a seek to record 0 -- which on
|
||||||
|
; the first pass is just "start of scene" and on any later one is a REAL seek:
|
||||||
|
; the channel has to go quiet, the ring is declared empty, and the whole
|
||||||
|
; lookahead 51.3 says takes seconds of play to accumulate is thrown away and
|
||||||
|
; rebuilt from the prefill up. That is the branch point rehearsed with the one
|
||||||
|
; thing a rig can check afterwards -- the decode has to still be pixel-exact.
|
||||||
|
;
|
||||||
|
; The clock is REBASED here rather than at clk_init, because tick 0 must be the
|
||||||
|
; instant the decoder is released and the prefill happens before that. Under a
|
||||||
|
; host-written PACE this word belongs to the host, so it is only touched when
|
||||||
|
; the 68000 is keeping its own time; ITER>1 therefore needs CLKON.
|
||||||
|
tst.l RINGOWN.l
|
||||||
|
beq.s noseek
|
||||||
|
moveq #0,d0
|
||||||
|
bsr ring_seek
|
||||||
|
bsr ring_prefill
|
||||||
|
tst.l d0
|
||||||
|
bpl.s .pfok
|
||||||
|
move.l #$E1,FLAG.l ; the transport never delivered
|
||||||
|
bra hold
|
||||||
|
.pfok:
|
||||||
|
tst.l CLKON.l
|
||||||
|
beq.s noseek
|
||||||
|
clr.l PACE.l ; at most one tick is lost to a V-DISP
|
||||||
|
; landing between the ISR and here
|
||||||
|
noseek:
|
||||||
|
frameloop:
|
||||||
|
tst.l RINGOWN.l
|
||||||
|
beq.s nomark
|
||||||
|
move.l FR_TAIL.l,d0
|
||||||
|
bsr ring_mark
|
||||||
|
nomark:
|
||||||
|
; ---- PACE GATE (FINDINGS 49.7.2, and it is the whole point of this session).
|
||||||
|
; Free-running, this loop asks for record i the instant it finishes record i-1,
|
||||||
|
; so it outruns any finite pipe, the ring NEVER backs up, and the producer's
|
||||||
|
; overlap test never refuses a placement. A ring-size sweep under those
|
||||||
|
; conditions tests WRAP CORRECTNESS at each size and nothing about BUFFERING:
|
||||||
|
; 48 KB passes while holding one record. A shipping player does not do this --
|
||||||
|
; it draws frame i, waits for its slot, and spends the rest of the frame time
|
||||||
|
; idle while the disk fills the ring behind it.
|
||||||
|
;
|
||||||
|
; So the rig gets a frame clock. PACE is bumped by the producer (in the player,
|
||||||
|
; vblank or an MFP timer) and frame i is forbidden to start before tick i. With
|
||||||
|
; the decoder held to 12 fps the ring fills, the producer starts hitting its own
|
||||||
|
; overlap test, and FR_HEAD-FR_TAIL becomes what it claims to be: the number of
|
||||||
|
; whole frames the decoder could run on if delivery stopped dead -- which is the
|
||||||
|
; branch-point seek question stated in frames.
|
||||||
|
;
|
||||||
|
; It also makes STALLS mean something. Free-running, a stall is EARLINESS
|
||||||
|
; (49.6); paced, a frame that has to wait for its record is a real underrun.
|
||||||
|
;
|
||||||
|
; AND IT COUNTS THE FRAMES THAT WERE ALREADY LATE, which is a question only a
|
||||||
|
; REAL frame clock raises. 12 fps on a 55.4577 Hz raster is 4.6215 refreshes
|
||||||
|
; per frame, so the divider hands out slots of 4 refreshes (72.13 ms) and 5
|
||||||
|
; (90.16 ms), 37.9% of them short -- and the SHORT one is 13.4% under the
|
||||||
|
; 83.33 ms every budget in this project is priced against (FINDINGS 54). A
|
||||||
|
; frame that does not fit its slot does not fail here: PACE has already moved
|
||||||
|
; on, so the next frame starts the instant this one finishes and the clock
|
||||||
|
; catches up by itself. What it costs is one late PRESENT, and nothing in this
|
||||||
|
; tree counted those because until now the tick was a host model with no
|
||||||
|
; cadence in it at all.
|
||||||
|
;
|
||||||
|
; The test is "did this frame have to WAIT", not "is it a whole tick behind".
|
||||||
|
; Frame i waits while PACE < i; so PACE >= i on arrival means the decoder came
|
||||||
|
; to the gate with slot i already open and idled for zero polls, which is the
|
||||||
|
; same statement as "frame i-1 ran to the end of its slot". A whole tick of
|
||||||
|
; overrun -- PACE - FR_TAIL >= 1 -- is the much rarer case where it ran past
|
||||||
|
; the end of the NEXT one, and is reported separately as the worst seen.
|
||||||
|
;
|
||||||
|
; Frame 0 is excluded: it starts at tick 0 by definition and has no predecessor
|
||||||
|
; to have overrun. The wait loop below is untouched -- all of this is ahead of
|
||||||
|
; it, and the free-running path executes none of it.
|
||||||
|
tst.l PACEON.l
|
||||||
|
beq.s nopace
|
||||||
|
move.l PACE.l,d0
|
||||||
|
cmp.l FR_TAIL.l,d0 ; PACE < FR_TAIL: the slot has not come
|
||||||
|
bcs.s pacesel ; round yet, so this frame is EARLY
|
||||||
|
tst.l FR_TAIL.l
|
||||||
|
beq.s pacesel ; frame 0 starts AT tick 0 by definition
|
||||||
|
tst.l LATEFR.l
|
||||||
|
bne.s .nf1
|
||||||
|
move.l FR_TAIL.l,LATE1ST.l
|
||||||
|
.nf1:
|
||||||
|
addq.l #1,LATEFR.l
|
||||||
|
sub.l FR_TAIL.l,d0 ; whole ticks overrun; 0 = inside the
|
||||||
|
cmp.l LATEMAX.l,d0 ; slot but with nothing left of it
|
||||||
|
bls.s pacewait
|
||||||
|
move.l d0,LATEMAX.l
|
||||||
|
pacesel:
|
||||||
|
; Both branches above -- the early frame and frame 0 -- come here rather than
|
||||||
|
; jumping straight into the legacy wait, because the early frame is the COMMON
|
||||||
|
; case and it is the one with idle in it. Routing it past this test was a real
|
||||||
|
; bug and not a tidy-up: the producer then only ever ran from the record wait,
|
||||||
|
; about once a frame, and the disc spent most of the scene stopped.
|
||||||
|
tst.l RINGOWN.l
|
||||||
|
beq.s pacewait
|
||||||
|
; ---- THE IDLE IS WHERE THE DISC RUNS. ring_poll retires the completed
|
||||||
|
; request and issues the next one, and this loop is the only place in a paced
|
||||||
|
; player with time to spare. Polling once a FRAME instead would cap the fill at
|
||||||
|
; one record per slot -- the wire rate exactly -- and a ring that can only keep
|
||||||
|
; up never accumulates the slack a branch point spends (51.3).
|
||||||
|
pacewaitR:
|
||||||
|
bsr ring_poll
|
||||||
|
move.l PACE.l,d0
|
||||||
|
cmp.l FR_TAIL.l,d0
|
||||||
|
bcs.s pacewaitR
|
||||||
|
bra.s nopace
|
||||||
|
pacewait:
|
||||||
|
move.l PACE.l,d0
|
||||||
|
cmp.l FR_TAIL.l,d0 ; d0 - FR_TAIL; carry = tick not reached
|
||||||
|
bcs.s pacewait
|
||||||
|
nopace:
|
||||||
|
; ---- wait until the producer has made this record wholly resident.
|
||||||
|
; d1 counts polls for this frame; a nonzero d1 on exit means the frame stalled.
|
||||||
|
moveq #0,d1
|
||||||
|
move.l FR_TAIL.l,d2
|
||||||
|
tst.l RINGOWN.l
|
||||||
|
beq.s waitrec
|
||||||
|
; ---- the same wait, with the producer inside it. Here d1 counts POLLS rather
|
||||||
|
; than spins, so STALLS still means "frames that had to wait" and SPINS is not
|
||||||
|
; comparable with a host-filled run's. A frame that waits here is a real
|
||||||
|
; underrun either way: paced, its slot has already opened.
|
||||||
|
waitrecR:
|
||||||
|
bsr ring_poll
|
||||||
|
move.l FR_HEAD.l,d0
|
||||||
|
cmp.l d2,d0
|
||||||
|
bhi gotrec
|
||||||
|
addq.l #1,d1
|
||||||
|
cmp.l #SPINMAX,d1
|
||||||
|
bcs.s waitrecR
|
||||||
|
move.l #$E1,FLAG.l
|
||||||
|
bra hold
|
||||||
|
waitrec:
|
||||||
|
move.l FR_HEAD.l,d0
|
||||||
|
cmp.l d2,d0
|
||||||
|
bhi.s gotrec ; HEAD > TAIL: at least one record ready
|
||||||
|
addq.l #1,d1
|
||||||
|
cmp.l #SPINMAX,d1
|
||||||
|
bcs.s waitrec
|
||||||
|
move.l #$E1,FLAG.l ; producer never delivered
|
||||||
|
bra hold
|
||||||
|
gotrec:
|
||||||
|
tst.l d1
|
||||||
|
beq.s nostall
|
||||||
|
addq.l #1,STALLS.l
|
||||||
|
add.l d1,SPINS.l
|
||||||
|
nostall:
|
||||||
|
; ---- pop the descriptor. DESCN is a power of two, so the wrap is an and.
|
||||||
|
move.l d2,d0
|
||||||
|
lsl.l #2,d0
|
||||||
|
and.w #DESCM,d0
|
||||||
|
lea DESC,a1 ; DESC is absolute; (d0.w) needs a base
|
||||||
|
move.l (a1,d0.w),a0 ; a1 is reloaded from a0 two lines below
|
||||||
|
|
||||||
|
; ---- from here to the release, byte for byte what decode.s does. a0 is
|
||||||
|
; inside the ring rather than inside a preloaded blob, and the block loop
|
||||||
|
; cannot tell the difference -- which is the whole claim being tested.
|
||||||
|
move.l (a0)+,d0 ; u32 payload length, big-endian
|
||||||
|
lea 0(a0,d0.l),a1
|
||||||
|
move.l a1,SCR_END.l ; where the payload must end
|
||||||
|
move.l a0,a1 ; a1 = packed mode header
|
||||||
|
lea MODEB(a0),a0 ; a0 = span section
|
||||||
|
bsr paint_spans ; -> a0 = block payload, a1 preserved
|
||||||
|
bsr decode_frame
|
||||||
|
cmpa.l SCR_END.l,a0 ; bitstream desync is silent otherwise
|
||||||
|
bne desync
|
||||||
|
|
||||||
|
; ---- release. Round up to RECALN: the producer lays records on the
|
||||||
|
; container's own record boundaries (geom.i), so the byte one past this
|
||||||
|
; record's PADDED end is the first byte it may reuse. Releasing only the
|
||||||
|
; bytes actually read would strand up to RECALN-1 of pad per record and
|
||||||
|
; the producer's free-space arithmetic would drift by that much a frame.
|
||||||
|
; This is an absolute address, so it is only the record's padded end
|
||||||
|
; because the ring base is RECALN-aligned as well.
|
||||||
|
move.l a0,d0
|
||||||
|
addi.l #RECALN-1,d0
|
||||||
|
andi.l #~(RECALN-1),d0
|
||||||
|
move.l d0,RD_PTR.l
|
||||||
|
addq.l #1,FR_TAIL.l
|
||||||
|
|
||||||
|
subq.l #1,SCR_N.l
|
||||||
|
bne frameloop
|
||||||
|
subq.l #1,ITER.l
|
||||||
|
bne outer
|
||||||
|
; ---- leave the MFP as it was found. A rig that exits with a live interrupt
|
||||||
|
; source and a lowered mask hands the next thing that runs an interrupt it
|
||||||
|
; has no vector for, and the failure would land somewhere else entirely.
|
||||||
|
tst.l CLKON.l
|
||||||
|
beq.s noclk2
|
||||||
|
bsr clk_stop
|
||||||
|
noclk2:
|
||||||
|
move.l #$FF,FLAG.l ; timer stops here
|
||||||
|
hold: bra.s hold
|
||||||
|
desync: move.l #$EE,FLAG.l
|
||||||
|
bra.s hold
|
||||||
|
|
||||||
|
include "src/player/frame.i"
|
||||||
|
include "src/player/clock.i"
|
||||||
|
include "src/player/ring.i"
|
||||||
|
include "src/player/scsi.i"
|
||||||
|
include "src/player/xfer.i"
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
; ---------------------------------------------------------------- xfer.i
|
||||||
|
; THE TRANSPORT BEHIND THE MAILBOX. ROADMAP P4b.
|
||||||
|
;
|
||||||
|
; src/player/ring.i has always ended at a seam: it decides which record to
|
||||||
|
; fetch, where in the ring to put it and when that is safe, writes those four
|
||||||
|
; words into XF_SLOT and bumps XF_GO, and then polls XF_ACK. On the other side
|
||||||
|
; of that seam, until now, was tools/bench/stream.lua -- a host moving bytes at
|
||||||
|
; a MODELLED rate, with XF_ACK synthesised out of emulated time. A player has
|
||||||
|
; no host. This file is the other side: XF_GO is answered by src/player/scsi.i
|
||||||
|
; issuing a real READ(10) to a real MB89352, and XF_ACK is a word the 68000
|
||||||
|
; bumps when the bytes have landed.
|
||||||
|
;
|
||||||
|
; NOTHING ABOVE THE SEAM CHANGED, and that is deliberate for the same reason it
|
||||||
|
; was in sessions 22 and 23: ring.i cannot tell which transport answered it, so
|
||||||
|
; a green run here is a test of THIS file rather than of a new producer. The
|
||||||
|
; two hooks in ring.i (one in ring_poll, one in ring_seek's quiet-wait) are the
|
||||||
|
; whole of the change on that side, and with XF_SCSI = 0 they are a tst and a
|
||||||
|
; branch.
|
||||||
|
;
|
||||||
|
; IT IS SYNCHRONOUS, AND THAT IS NOT A SHORTCUT -- IT IS THE FINDING. The
|
||||||
|
; modelled transport overlapped: a request issued at time t landed at t + len/
|
||||||
|
; rate while the 68000 got on with decoding, which is what a DMAC channel does.
|
||||||
|
; Here the CPU moves every byte itself through $EA0015 (57.3: a PIO write to
|
||||||
|
; that address is discarded, so even "PIO" runs the SPC in DMA mode with the CPU
|
||||||
|
; standing in for the channel), so `bsr xf_service` does not start a transfer,
|
||||||
|
; it PERFORMS one. A two-deep request queue therefore buys nothing at all: the
|
||||||
|
; second slot is filled and drained by the same instruction stream that would
|
||||||
|
; have been decoding. FINDINGS 55.3's whole result -- that a one-deep queue
|
||||||
|
; gives away 6.8% of the pipe -- is about a transport that runs in parallel with
|
||||||
|
; the CPU, and this one does not.
|
||||||
|
;
|
||||||
|
; So what this file is FOR is not to be the shipping transport. It is to make
|
||||||
|
; the shipping transport's cost measurable: the same 120 pixel-exact frames,
|
||||||
|
; delivered by the machine off a real volume, with the CPU cost of doing it
|
||||||
|
; charged where a rate model cannot hide it. P4a -- the HD63450 holding the bus
|
||||||
|
; -- is what makes the transfer overlap again, and until it exists this is the
|
||||||
|
; honest floor.
|
||||||
|
|
||||||
|
; ---- state. Above src/player/ring.i's instruments (last: SK_WAIT at $1837C)
|
||||||
|
; and below the disc-offset table at $19400.
|
||||||
|
XF_SCSI = $18380 ; 1 = the 68000 is the transport (input)
|
||||||
|
XS_LBA0 = $18384 ; LBA of byte 0 of the scene's frame stream
|
||||||
|
XS_NXFER = $18388 ; transfers completed
|
||||||
|
XS_NBYTE = $1838C ; record bytes delivered into the ring
|
||||||
|
XS_NWIRE = $18390 ; bytes actually read off the disc, sectors and
|
||||||
|
; all -- the two differ and 58.3 is why
|
||||||
|
XS_ERR = $18394 ; SC_ERR of the FIRST failure, 0 = none
|
||||||
|
XS_ERRAT = $18398 ; ...and the request index it failed on
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- xf_init
|
||||||
|
; Clears the instruments and brings the SPC up, if this run has one. Called
|
||||||
|
; before ring_init, because ring_init ends in a ring_seek and a seek waits on
|
||||||
|
; the transport.
|
||||||
|
xf_init:
|
||||||
|
clr.l XS_NXFER.l
|
||||||
|
clr.l XS_NBYTE.l
|
||||||
|
clr.l XS_NWIRE.l
|
||||||
|
clr.l XS_ERR.l
|
||||||
|
move.l #-1,XS_ERRAT.l
|
||||||
|
tst.l XF_SCSI.l
|
||||||
|
beq.s .out
|
||||||
|
bsr scsi_init
|
||||||
|
.out: rts
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- xf_service
|
||||||
|
; Answer at most ONE outstanding request, then return. Preserves every
|
||||||
|
; register: it is called from inside ring_poll, which is itself called from
|
||||||
|
; inside the decoder's wait loops and must be invisible to them.
|
||||||
|
;
|
||||||
|
; ONE PER CALL, not "drain the queue". ring_poll retires exactly one completed
|
||||||
|
; request per call as well, and a transport that answered both queued requests
|
||||||
|
; in one visit would hand the retire loop two acks it can only take one poll at
|
||||||
|
; a time -- which is legal, but it also means the decoder's wait loop would
|
||||||
|
; disappear for two record times instead of one. One per call keeps the two
|
||||||
|
; sides stepping at the same rate.
|
||||||
|
;
|
||||||
|
; A RECORD IS NOT A SECTOR, and this is where that is dealt with. ring.i asks
|
||||||
|
; for a byte offset and a length; the target answers in 512 B blocks. So the
|
||||||
|
; command covers the sectors the record lies in, and SC_WSKIP/SC_WKEEP tell
|
||||||
|
; src/player/scsi.i's DATA IN loop which of those bytes to store. The ones
|
||||||
|
; outside the window are pulled from the FIFO and dropped -- they are NOT
|
||||||
|
; written past the ends of the destination, because the bytes on either side of
|
||||||
|
; a record in the stream belong to records the decoder may still be reading and
|
||||||
|
; the block loop has no bounds check (49.2).
|
||||||
|
;
|
||||||
|
; XS_NWIRE counts what the disc actually moved and XS_NBYTE what the ring got.
|
||||||
|
; They are not the same number and the gap is a delivery cost, not an accounting
|
||||||
|
; detail: it is bytes on the wire that no frame contains.
|
||||||
|
xf_service:
|
||||||
|
tst.l XF_SCSI.l
|
||||||
|
beq.s .idle
|
||||||
|
move.l XF_ACK.l,d0
|
||||||
|
cmp.l XF_GO.l,d0
|
||||||
|
bcs.s .work ; XF_ACK < XF_GO: something outstanding
|
||||||
|
.idle: rts
|
||||||
|
.work:
|
||||||
|
movem.l d0-d7/a0-a2,-(sp)
|
||||||
|
move.l XF_ACK.l,d0
|
||||||
|
move.l d0,d1
|
||||||
|
and.l #XF_SLM,d1
|
||||||
|
lsl.l #4,d1 ; * XF_SLSZ
|
||||||
|
lea XF_SLOT.l,a0
|
||||||
|
adda.l d1,a0
|
||||||
|
move.l (a0),d1 ; disc byte offset within the stream
|
||||||
|
movea.l 4(a0),a1 ; destination in the ring
|
||||||
|
move.l 8(a0),d2 ; length
|
||||||
|
; ---- sector arithmetic
|
||||||
|
move.l d1,d3
|
||||||
|
and.l #511,d3 ; bytes of the first sector to drop
|
||||||
|
move.l d1,d4
|
||||||
|
lsr.l #8,d4
|
||||||
|
lsr.l #1,d4 ; sector index within the stream
|
||||||
|
add.l XS_LBA0.l,d4 ; ...and where the stream begins
|
||||||
|
move.l d3,d5
|
||||||
|
add.l d2,d5
|
||||||
|
addi.l #511,d5
|
||||||
|
lsr.l #8,d5
|
||||||
|
lsr.l #1,d5 ; sectors the record lies in
|
||||||
|
move.l d3,SC_WSKIP.l
|
||||||
|
move.l d2,SC_WKEEP.l
|
||||||
|
add.l d2,XS_NBYTE.l
|
||||||
|
move.l d5,d0
|
||||||
|
lsl.l #8,d0
|
||||||
|
lsl.l #1,d0
|
||||||
|
add.l d0,XS_NWIRE.l
|
||||||
|
move.l d4,d3 ; d3 = LBA
|
||||||
|
move.l d5,d4 ; d4 = blocks
|
||||||
|
bsr scsi_read_win
|
||||||
|
tst.l d0
|
||||||
|
bmi.s .err
|
||||||
|
addq.l #1,XS_NXFER.l
|
||||||
|
addq.l #1,XF_ACK.l ; LAST: the bytes are all in the ring
|
||||||
|
; before the request is called done
|
||||||
|
movem.l (sp)+,d0-d7/a0-a2
|
||||||
|
rts
|
||||||
|
; ---- a failed read is NOT acked. The record never becomes resident, the
|
||||||
|
; decoder spins out in waitrec and reports a stalled producer, and XS_ERR says
|
||||||
|
; which request failed and why. Acking a failed transfer would publish a
|
||||||
|
; descriptor for a buffer full of whatever was there before -- and the decoder
|
||||||
|
; would find a plausible-looking length word in it and desync somewhere else
|
||||||
|
; entirely.
|
||||||
|
.err:
|
||||||
|
tst.l XS_ERR.l
|
||||||
|
bne.s .err2
|
||||||
|
move.l SC_ERR.l,XS_ERR.l
|
||||||
|
move.l XF_ACK.l,XS_ERRAT.l
|
||||||
|
.err2: movem.l (sp)+,d0-d7/a0-a2
|
||||||
|
rts
|
||||||
@@ -13,10 +13,12 @@ window it picks then gets encoded for real.
|
|||||||
|
|
||||||
Usage: python3 tools/analysis/07_motion_survey.py 00223 [window_seconds]
|
Usage: python3 tools/analysis/07_motion_survey.py 00223 [window_seconds]
|
||||||
"""
|
"""
|
||||||
import subprocess, sys
|
import subprocess, sys, os, getpass
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
STREAM_DIR = "/media/reala-misaki/BDROM/BDMV/STREAM"
|
# See tools/encoder/extract.py: DLX_BDROM overrides where the disc is mounted.
|
||||||
|
BDROM = os.environ.get("DLX_BDROM") or f"/media/{getpass.getuser()}/BDROM"
|
||||||
|
STREAM_DIR = f"{BDROM}/BDMV/STREAM"
|
||||||
W, H, FPS = 96, 72, 12
|
W, H, FPS = 96, 72, 12
|
||||||
|
|
||||||
def frames(stream):
|
def frames(stream):
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ the mode headers would exploit.
|
|||||||
RAW 16 literal palette indices -- the escape that makes lam=0 pixel-exact
|
RAW 16 literal palette indices -- the escape that makes lam=0 pixel-exact
|
||||||
|
|
||||||
Usage: python3 tools/analysis/08_mode_map.py <frames_dir> <out.webm>
|
Usage: python3 tools/analysis/08_mode_map.py <frames_dir> <out.webm>
|
||||||
[--profile sasi|scsi] [--scale N] [--lossless] [--fixed-lam]
|
[--profile scsi] [--scale N] [--lossless] [--fixed-lam]
|
||||||
|
|
||||||
--fixed-lam renders the pre-session-6 encoder (no rate control) instead.
|
--fixed-lam renders the pre-session-6 encoder (no rate control) instead.
|
||||||
Output format follows the extension. Prefer .webm: GIF re-quantises to 256
|
Output format follows the extension. Prefer .webm: GIF re-quantises to 256
|
||||||
@@ -45,7 +45,7 @@ def main():
|
|||||||
if "--scale" in sys.argv:
|
if "--scale" in sys.argv:
|
||||||
SCALE = int(sys.argv[sys.argv.index("--scale")+1])
|
SCALE = int(sys.argv[sys.argv.index("--scale")+1])
|
||||||
prof = RC.PROFILES[sys.argv[sys.argv.index("--profile")+1]
|
prof = RC.PROFILES[sys.argv[sys.argv.index("--profile")+1]
|
||||||
if "--profile" in sys.argv else "sasi"]
|
if "--profile" in sys.argv else "scsi"]
|
||||||
m = H.build(src, k1=prof["k1"], k4=prof["k4"])
|
m = H.build(src, k1=prof["k1"], k4=prof["k4"])
|
||||||
# Rate-controlled by default, so the map shows the mode decisions that
|
# Rate-controlled by default, so the map shows the mode decisions that
|
||||||
# actually ship. --fixed-lam renders the pre-session-6 encoder instead;
|
# actually ship. --fixed-lam renders the pre-session-6 encoder instead;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""At a 488 KB/s (4 Mbps) ceiling and ~52% mean utilisation, the mean is not the
|
"""At any fixed delivery ceiling and ~52% mean utilisation, the mean is not the
|
||||||
risk -- the peaks are. Measure per-frame peak-to-mean, then check whether the
|
risk -- the peaks are. Measure per-frame peak-to-mean, then check whether the
|
||||||
leaky-bucket rate controller actually holds the ceiling."""
|
leaky-bucket rate controller actually holds the ceiling."""
|
||||||
import sys; sys.path.insert(0,'tools/encoder')
|
import sys; sys.path.insert(0,'tools/encoder')
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ paint), so drift is zero by construction rather than by tuning.
|
|||||||
This replays what a real decoder does -- SKIP copies the ACTUALLY EMITTED
|
This replays what a real decoder does -- SKIP copies the ACTUALLY EMITTED
|
||||||
previous frame -- and compares it to the reconstruction ratectl recorded.
|
previous frame -- and compares it to the reconstruction ratectl recorded.
|
||||||
|
|
||||||
|
Session 8 added a SECOND controller (mu, the per-frame 68000 decode ceiling)
|
||||||
|
that also varies the mode map frame to frame, so it is exposed to exactly the
|
||||||
|
same failure and is tested here too. Both configurations must show zero drift.
|
||||||
|
|
||||||
Needs tmp/fr_singe (see docs/STATUS.md, reproducing the sustained-action
|
Needs tmp/fr_singe (see docs/STATUS.md, reproducing the sustained-action
|
||||||
result). ~55 s, nearly all of it the k-means in H.build; the rate-controlled
|
result). ~55 s, nearly all of it the k-means in H.build; the rate-controlled
|
||||||
encode of 120 frames is ~2 s.
|
encode of 120 frames is ~2 s.
|
||||||
@@ -29,10 +33,17 @@ import numpy as np
|
|||||||
import vq as VQ, vq_hybrid as H, ratectl as RC
|
import vq as VQ, vq_hybrid as H, ratectl as RC
|
||||||
|
|
||||||
m = H.build("tmp/fr_singe", k1=256, k4=256, iters=16)
|
m = H.build("tmp/fr_singe", k1=256, k4=256, iters=16)
|
||||||
# lam_lo=1.0: let quiet frames spend the whole allowance, which is the
|
|
||||||
# harder case for this test -- it maximises how often lam moves frame to frame.
|
|
||||||
enc = RC.encode_rate_controlled(m, target_kbps=110, lam_lo=1.0)
|
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, cycle_budget):
|
||||||
|
"""Encode, replay as a decoder would, and return the drift in pixels."""
|
||||||
|
print(f"\n=== {label} ===")
|
||||||
|
m.pop("_sym", None)
|
||||||
|
# lam_lo=1.0: let quiet frames spend the whole allowance, which is the
|
||||||
|
# harder case for this test -- it maximises how often lam moves frame to
|
||||||
|
# frame.
|
||||||
|
enc = RC.encode_rate_controlled(m, target_kbps=110, lam_lo=1.0,
|
||||||
|
cycle_budget=cycle_budget)
|
||||||
lam = enc["lam"]
|
lam = enc["lam"]
|
||||||
sw = int((np.diff(lam) != 0).sum())
|
sw = int((np.diff(lam) != 0).sum())
|
||||||
print(f"frames={len(lam)} distinct lam used={len(set(lam.tolist()))} "
|
print(f"frames={len(lam)} distinct lam used={len(set(lam.tolist()))} "
|
||||||
@@ -71,7 +82,14 @@ print(f"\nratectl reports PSNR {r['psnr']:.2f} dB, {r['kbps']:.1f} KB/s "
|
|||||||
tp = np.mean([VQ.psnr(o, pal[e]) for o, e in zip(m["rgb"], emitted)])
|
tp = np.mean([VQ.psnr(o, pal[e]) for o, e in zip(m["rgb"], emitted)])
|
||||||
print(f"what a decoder actually reconstructs: {tp:.2f} dB "
|
print(f"what a decoder actually reconstructs: {tp:.2f} dB "
|
||||||
f"-> overstated by {r['psnr']-tp:.2f} dB")
|
f"-> overstated by {r['psnr']-tp:.2f} dB")
|
||||||
|
return drift_px
|
||||||
|
|
||||||
|
|
||||||
# Acceptance criterion for the fix: a decoder replaying the emitted stream must
|
# Acceptance criterion for the fix: a decoder replaying the emitted stream must
|
||||||
# reconstruct exactly what the encoder recorded.
|
# reconstruct exactly what the encoder recorded -- under either controller.
|
||||||
sys.exit(1 if (drift_px > 0).any() else 0)
|
bad = 0
|
||||||
|
for label, cb in (("bytes only (session 6)", None),
|
||||||
|
("bytes + CPU ceiling (session 8)", RC.FRAME_CYCLES)):
|
||||||
|
d = check(label, cb)
|
||||||
|
bad += int((d > 0).any())
|
||||||
|
sys.exit(1 if bad else 0)
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ import sys, os, argparse
|
|||||||
sys.path.insert(0, "tools/encoder")
|
sys.path.insert(0, "tools/encoder")
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from dlx import DLX
|
from dlx import DLX
|
||||||
|
import vq_hybrid as H
|
||||||
|
import ratectl as RC
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import buscost as B
|
||||||
|
# The audio byte rate is now DERIVED, not restated: 15.6 kHz mono MSM6258V is
|
||||||
|
# 15,625 4-bit samples/s, two to a byte. RC.AUDIO_KBPS's 7.8 is that figure in
|
||||||
|
# DECIMAL kB, and was being multiplied by 1024 here -- a 2.4% overstatement,
|
||||||
|
# harmless, but it hid which unit the constant was in.
|
||||||
|
RC_AUDIO_BPS = B.ADPCM_BYTES_PER_S
|
||||||
|
|
||||||
# Machine clocks, confirmed from MAME 0.277 src/mame/sharp/x68k.cpp:1133/1194/
|
# Machine clocks, confirmed from MAME 0.277 src/mame/sharp/x68k.cpp:1133/1194/
|
||||||
# 1200 -- not recalled. x68000 and x68ksupr are BOTH 40_MHz_XTAL/4 = 10 MHz;
|
# 1200 -- not recalled. x68000 and x68ksupr are BOTH 40_MHz_XTAL/4 = 10 MHz;
|
||||||
@@ -30,10 +39,13 @@ from dlx import DLX
|
|||||||
CLOCKS = {"stock": 10.0, "super": 10.0, "xvi": 33.33 / 2, "x68030": 25.0}
|
CLOCKS = {"stock": 10.0, "super": 10.0, "xvi": 33.33 / 2, "x68030": 25.0}
|
||||||
FPS = 12
|
FPS = 12
|
||||||
|
|
||||||
# cycles per block, measured on the emulated 68000 (synthetic single-mode frames)
|
# Cycles per block, measured on the emulated 68000 (synthetic single-mode
|
||||||
C_V1, C_V4, C_RAW = 299.9, 448.2, 400.4
|
# frames). Defined in tools/encoder/vq_hybrid.py, which is where the mode
|
||||||
C_SKIP_FAST = 53.0 / 4 # all-SKIP header byte: one tst.b for 4
|
# decision needs them too -- one copy, not two, so a re-measurement cannot
|
||||||
C_SKIP_MIXED = 45.0 # a SKIP block inside a mixed byte
|
# leave the encoder and the scorer disagreeing.
|
||||||
|
C_V1, C_V4, C_RAW = H.C_V1, H.C_V4, H.C_RAW
|
||||||
|
C_SKIP_FAST, C_SKIP_MIXED = H.C_SKIP_CLUSTERED, H.C_SKIP_MIXED
|
||||||
|
cycles = H.cycles
|
||||||
|
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("container", nargs="?",
|
ap.add_argument("container", nargs="?",
|
||||||
@@ -41,6 +53,37 @@ ap.add_argument("container", nargs="?",
|
|||||||
ap.add_argument("--machine", default="stock", choices=list(CLOCKS),
|
ap.add_argument("--machine", default="stock", choices=list(CLOCKS),
|
||||||
help="which X68000's clock to budget against (default stock)")
|
help="which X68000's clock to budget against (default stock)")
|
||||||
ap.add_argument("--fps", type=float, default=FPS)
|
ap.add_argument("--fps", type=float, default=FPS)
|
||||||
|
# FINDINGS 35: the frame budget has never had the disk in it. The bitstream has
|
||||||
|
# to be moved off SCSI into the ring buffer, and on this machine that costs CPU
|
||||||
|
# whether it is DMA (the HD63450 cycle-steals) or PIO (the 68000 moves every
|
||||||
|
# byte). Default ON, because scoring a decoder against a budget that assumes the
|
||||||
|
# data arrives for free is exactly the mistake 35 was raised to stop.
|
||||||
|
ap.add_argument("--io", default="dma", choices=["dma", "pio", "none"],
|
||||||
|
help="how the bitstream reaches RAM (default dma)")
|
||||||
|
ap.add_argument("--dma-clocks-per-word", type=float, default=8.0,
|
||||||
|
help="HD63450 cycle-steal. ESTIMATE from FINDINGS 5, NEVER "
|
||||||
|
"MEASURED, and the most load-bearing unmeasured number "
|
||||||
|
"in the project (FINDINGS 35.3)")
|
||||||
|
ap.add_argument("--dma-clocks-per-byte", type=float, default=5.0,
|
||||||
|
help="what the SCSI DMA costs per DELIVERED BYTE. The MB89352 "
|
||||||
|
"is an 8-bit port, so the DMAC pays per byte and the "
|
||||||
|
"per-word denominator of FINDINGS 5/39.7 was half the "
|
||||||
|
"real debit (FINDINGS 43). 5 = single-address, bus held, "
|
||||||
|
"no drive wait; 9 = dual-address")
|
||||||
|
ap.add_argument("--pio-clocks-per-byte", type=float, default=12.0,
|
||||||
|
help="hand-derived floor for a 68000 register-to-RAM copy")
|
||||||
|
# Audio is NOT the disk, and charging it the disk's rate was charging it the
|
||||||
|
# favourable side of an open question. tools/analysis/21_iplrom_dmac.py reads
|
||||||
|
# the IPL ROM's own HD63450 setup: channel 3 is dual address, 8-bit port, cycle
|
||||||
|
# steal WITHOUT hold, external request -- one full arbitration per byte, no
|
||||||
|
# burst to amortise it over. 16 is the datasheet best case, 19 the worst.
|
||||||
|
ap.add_argument("--adpcm-clocks-per-byte", type=float,
|
||||||
|
default=B.ADPCM_CLK_BYTE_BEST,
|
||||||
|
help="what an ADPCM byte costs. READ OUT OF THE IPL ROM's DMAC "
|
||||||
|
"configuration (21_iplrom_dmac.py), not assumed: dual "
|
||||||
|
"address + per-byte arbitration = 16 best, 19 worst. The "
|
||||||
|
"audio stream always DMAs, whatever --io says about the "
|
||||||
|
"disk")
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
CPUHZ = CLOCKS[a.machine] * 1e6
|
CPUHZ = CLOCKS[a.machine] * 1e6
|
||||||
FPS = a.fps
|
FPS = a.fps
|
||||||
@@ -50,25 +93,48 @@ if not os.path.exists(a.container):
|
|||||||
|
|
||||||
d = DLX(a.container)
|
d = DLX(a.container)
|
||||||
|
|
||||||
def cycles(mode):
|
# --- what the transfer costs, from the container's own byte rate
|
||||||
g = mode.reshape(-1, 4) # one header byte = four blocks
|
vid_bps = sum(n + 4 for (_, n) in d.frames) / d.nframes * d.fps
|
||||||
allskip = (g == 0).all(1)
|
io_bps = vid_bps + RC_AUDIO_BPS
|
||||||
c = allskip.sum() * 4 * C_SKIP_FAST
|
aud_cycles_per_s = RC_AUDIO_BPS * a.adpcm_clocks_per_byte
|
||||||
m = g[~allskip]
|
if a.io == "dma":
|
||||||
c += (m == 0).sum() * C_SKIP_MIXED
|
io_cycles_per_s = vid_bps * a.dma_clocks_per_byte + aud_cycles_per_s
|
||||||
c += (m == 1).sum() * C_V1
|
elif a.io == "pio":
|
||||||
c += (m == 2).sum() * C_V4
|
io_cycles_per_s = vid_bps * a.pio_clocks_per_byte + aud_cycles_per_s
|
||||||
c += (m == 3).sum() * C_RAW
|
else:
|
||||||
return c
|
io_cycles_per_s = 0.0
|
||||||
|
io_pct = 100 * io_cycles_per_s / CPUHZ
|
||||||
|
aud_pct = 100 * aud_cycles_per_s / CPUHZ
|
||||||
|
FRAME_NET = FRAME * (1 - io_pct / 100)
|
||||||
|
|
||||||
modes = [d.modes(f) for f in range(d.nframes)]
|
modes = [d.modes(f) for f in range(d.nframes)]
|
||||||
cyc = np.array([cycles(m) for m in modes])
|
cyc = np.array([cycles(m) for m in modes])
|
||||||
pct = 100 * cyc / FRAME
|
pct = 100 * cyc / FRAME_NET
|
||||||
ns = np.array([100 * (m != 0).mean() for m in modes])
|
ns = np.array([100 * (m != 0).mean() for m in modes])
|
||||||
|
|
||||||
print(f"{a.container}: {d.nframes} frames, {d.nb} blocks/frame")
|
print(f"{a.container}: {d.nframes} frames, {d.nb} blocks/frame")
|
||||||
print(f"budget: {a.machine} @ {CLOCKS[a.machine]:.2f} MHz, {FPS:g} fps "
|
print(f"budget: {a.machine} @ {CLOCKS[a.machine]:.2f} MHz, {FPS:g} fps "
|
||||||
f"-> {FRAME:,.0f} cycles/frame")
|
f"-> {FRAME:,.0f} cycles/frame")
|
||||||
|
print(f" I/O ({a.io}): {io_bps/1024:.1f} KB/s costs {io_pct:.1f}% of the CPU "
|
||||||
|
f"-> {FRAME_NET:,.0f} cycles/frame left for decoding")
|
||||||
|
if a.io != "none":
|
||||||
|
print(f" video {vid_bps/1024:6.1f} KB/s x "
|
||||||
|
f"{(a.dma_clocks_per_byte if a.io=='dma' else a.pio_clocks_per_byte):g}"
|
||||||
|
f" clk/B = {io_pct-aud_pct:5.2f}% "
|
||||||
|
f"(W: still open, ROADMAP B3 / FINDINGS 42.4)\n"
|
||||||
|
f" audio {RC_AUDIO_BPS/1024:6.2f} KB/s x {a.adpcm_clocks_per_byte:g}"
|
||||||
|
f" clk/B = {aud_pct:5.2f}% "
|
||||||
|
f"(SETTLED: read out of the IPL ROM, FINDINGS 52)")
|
||||||
|
if a.io == "dma":
|
||||||
|
print(f" {a.dma_clocks_per_byte:g} clocks/BYTE, the MC68450 datasheet "
|
||||||
|
f"floor for an 8-bit port (FINDINGS 43).\n It is not measured on "
|
||||||
|
f"hardware; what IS settled is that the per-word denominator this\n"
|
||||||
|
f" used before session 14 was physically impossible -- 2.5 "
|
||||||
|
f"clocks/byte is below\n the 68000's 4-clock minimum bus cycle.")
|
||||||
|
elif a.io == "none":
|
||||||
|
print(" WARNING: --io none scores the decoder as if the disk were free. "
|
||||||
|
"That is the\n premise FINDINGS 35 overturned; every 'N frames miss' "
|
||||||
|
"figure before session 9\n was computed this way.")
|
||||||
if a.machine != "stock":
|
if a.machine != "stock":
|
||||||
print(" (derived: scaled by clock from cycles measured on the 10 MHz core.\n"
|
print(" (derived: scaled by clock from cycles measured on the 10 MHz core.\n"
|
||||||
" MAME 0.277 marks x68ksupr/x68kxvi/x68030 MACHINE_NOT_WORKING, so\n"
|
" MAME 0.277 marks x68ksupr/x68kxvi/x68030 MACHINE_NOT_WORKING, so\n"
|
||||||
@@ -85,15 +151,20 @@ TIMED_FRAMES = (("min non-SKIP", 15.4, 31.5), ("median", 48.1, 73.8),
|
|||||||
("p90", 82.5, 116.4), ("max non-SKIP", 100.0, 135.8))
|
("p90", 82.5, 116.4), ("max non-SKIP", 100.0, 135.8))
|
||||||
if (os.path.abspath(a.container) == os.path.abspath(TIMED)
|
if (os.path.abspath(a.container) == os.path.abspath(TIMED)
|
||||||
and a.machine == "stock" and a.fps == 12):
|
and a.machine == "stock" and a.fps == 12):
|
||||||
print("model vs the frames actually timed on the 68000:")
|
print("model vs the frames actually timed on the 68000 "
|
||||||
|
"(the model reads HIGH, and by more\n as the frame gets harder -- "
|
||||||
|
"so a 'does not fit' from it is the safe direction):")
|
||||||
for label, frac, meas in TIMED_FRAMES:
|
for label, frac, meas in TIMED_FRAMES:
|
||||||
i = int(np.argmin(abs(ns - frac)))
|
i = int(np.argmin(abs(ns - frac)))
|
||||||
print(f" {label:<14} non-SKIP {ns[i]:5.1f}% model {pct[i]:6.1f}% "
|
print(f" {label:<14} non-SKIP {ns[i]:5.1f}% model {pct[i]:6.1f}% "
|
||||||
f"measured {meas:5.1f}% error {pct[i]-meas:+.1f} pt")
|
f"measured {meas:5.1f}% error {pct[i]-meas:+.1f} pt")
|
||||||
else:
|
else:
|
||||||
print(f"(no 68000 timings for this container/machine -- the model was "
|
print(f"(no 68000 timings for this container/machine. The model is "
|
||||||
f"validated to\n within 1 pt on {TIMED} at stock/12fps;\n"
|
f"validated against four\n frames timed on the 68000, and only on "
|
||||||
f" run tools/bench/decode.lua to time another container)")
|
f"{TIMED}\n at stock/12fps -- run it on that container to see the "
|
||||||
|
f"errors, which are a few points\n CONSERVATIVE and grow with the "
|
||||||
|
f"non-SKIP fraction. Run tools/bench/decode.lua to\n time another "
|
||||||
|
f"container.)")
|
||||||
|
|
||||||
print(f"\nper-frame cost, % of a {FPS:g}fps frame budget:")
|
print(f"\nper-frame cost, % of a {FPS:g}fps frame budget:")
|
||||||
print(f" measured-cost model: median {np.median(pct):5.1f} "
|
print(f" measured-cost model: median {np.median(pct):5.1f} "
|
||||||
@@ -107,10 +178,11 @@ if a.machine == "stock" and a.fps == 12:
|
|||||||
f"(optimistic by {np.median(pct)/np.median(old):.2f}x at the median)")
|
f"(optimistic by {np.median(pct)/np.median(old):.2f}x at the median)")
|
||||||
|
|
||||||
miss = pct > 100
|
miss = pct > 100
|
||||||
print(f"\nframes that do NOT fit {FRAME:,.0f} cycles: {miss.sum()}/{d.nframes} "
|
print(f"\nframes that do NOT fit {FRAME_NET:,.0f} cycles: {miss.sum()}/{d.nframes} "
|
||||||
f"({100*miss.mean():.0f}%)")
|
f"({100*miss.mean():.0f}%)")
|
||||||
print(f" sustainable framerate if EVERY frame must fit: "
|
print(f" sustainable framerate if EVERY frame must fit: "
|
||||||
f"{CPUHZ/cyc.max():.1f} fps; at the mean frame {CPUHZ/cyc.mean():.1f} fps")
|
f"{CPUHZ*(1-io_pct/100)/cyc.max():.1f} fps; at the mean frame "
|
||||||
|
f"{CPUHZ*(1-io_pct/100)/cyc.mean():.1f} fps")
|
||||||
if miss.any():
|
if miss.any():
|
||||||
print(f" worst {pct.max():.1f}% -- {(pct.max()-100)/100*1000/FPS:.0f} ms late "
|
print(f" worst {pct.max():.1f}% -- {(pct.max()-100)/100*1000/FPS:.0f} ms late "
|
||||||
f"on an {1000/FPS:.0f} ms frame")
|
f"on an {1000/FPS:.0f} ms frame")
|
||||||
@@ -123,5 +195,7 @@ print(f"\nwhere the cycles go, over the whole window:")
|
|||||||
for k, n in enumerate(("SKIP", "V1", "V4", "RAW")):
|
for k, n in enumerate(("SKIP", "V1", "V4", "RAW")):
|
||||||
print(f" {n:<5} {100*tot[k]/tot.sum():5.1f}% of blocks "
|
print(f" {n:<5} {100*tot[k]/tot.sum():5.1f}% of blocks "
|
||||||
f"{100*spend[k]/spend.sum():5.1f}% of the cycles")
|
f"{100*spend[k]/spend.sum():5.1f}% of the cycles")
|
||||||
print(f"\nV4 is {C_V4/C_V1:.2f}x a V1 block for {4}x the payload bytes -- the mode "
|
print(f"\nV4 is {C_V4/C_V1:.2f}x a V1 block for {4}x the payload bytes. Since "
|
||||||
f"decision\nin vq_hybrid.py charges it the bytes but not the cycles.")
|
f"session 8 the mode\ndecision charges it BOTH (decide(ctx, lam, mu), "
|
||||||
|
f"FINDINGS 31), which is why V4 is now\nthe rarest non-SKIP mode here -- "
|
||||||
|
f"a byte-rich profile buys its way out to RAW instead.")
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""What does spending the idle bus bandwidth buy back in CPU cycles?
|
||||||
|
|
||||||
|
python3 tools/analysis/12_span_tradeoff.py [container.dlx] --bus <KB/s>
|
||||||
|
|
||||||
|
FINDINGS 28 leaves the decoder CPU-bound at 110 KB/s on a much wider pipe. Every
|
||||||
|
codec decision was made when bytes were scarce, so each one trades cycles to
|
||||||
|
save them -- and the cheapest thing a 68000 can be handed is the most expensive
|
||||||
|
thing to store: word-expanded pixels in row-linear runs.
|
||||||
|
|
||||||
|
This prices ONE new mode against the real mode maps: a per-row SPAN of
|
||||||
|
word-expanded literals, `movem.l`-ed straight from the stream buffer into GVRAM.
|
||||||
|
A run of L horizontally adjacent dirty blocks becomes 4 spans of 4L pixels.
|
||||||
|
|
||||||
|
MEASURED as of session 8 (FINDINGS 30), on the 68000, with the span decoder in
|
||||||
|
tools/bench/blit.s v6 and the streams in tools/bench/prep_spans.py:
|
||||||
|
43.7 cycles per span + 9.152 per pixel, fitting eleven span lengths to within
|
||||||
|
0.3%. That is the ENCODER-ASSISTED format: the record is an absolute GVRAM
|
||||||
|
address and a jump displacement into an unrolled copy chain, so the decoder does
|
||||||
|
no arithmetic per span. The obvious decoder -- handed (x, npix) and left to work
|
||||||
|
the copy out -- measures 97.9 + 10.46 and is 2.2x dearer on a 24-pixel span (v5).
|
||||||
|
Span length is therefore a multiple of 24 pixels, and a run pads up to it; the
|
||||||
|
padding is free of cycles beyond its pixels and correct on screen, because a
|
||||||
|
literal span carries true pixels of the current frame.
|
||||||
|
|
||||||
|
The mode maps are NOT re-optimised: this only re-codes regions the encoder
|
||||||
|
already chose to redraw, so it is a lower bound on what a cost-aware encoder
|
||||||
|
would find.
|
||||||
|
"""
|
||||||
|
import sys, os, argparse
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
import numpy as np
|
||||||
|
from dlx import DLX
|
||||||
|
|
||||||
|
FRAME_CYC = 833333.0 # 12fps at 10 MHz
|
||||||
|
AUDIO_KBPS = 7.8
|
||||||
|
|
||||||
|
C_V1, C_V4, C_RAW = 299.9, 448.2, 400.4 # FINDINGS 28.2 (measured)
|
||||||
|
C_SKIP_CLUSTERED, C_SKIP_MIXED = 13.25, 45.0
|
||||||
|
SPAN_OVERHEAD = 43.7 # per span, MEASURED, FINDINGS 30
|
||||||
|
CYC_PX_ROWLIN = 9.152 # per pixel, MEASURED, FINDINGS 30
|
||||||
|
SPAN_UNIT_PX = 24 # 12 registers of movem.l, one chain unit
|
||||||
|
SPAN_BYTES_PX = 2 # word-expanded: 1 pixel = 1 word
|
||||||
|
SPAN_HDR = 6 # u32 GVRAM address + u16 jump displacement
|
||||||
|
|
||||||
|
|
||||||
|
def span_px(npix): # a span is a whole number of units
|
||||||
|
return -(-npix // SPAN_UNIT_PX) * SPAN_UNIT_PX
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("container", nargs="?",
|
||||||
|
default="tmp/rc_fr_singe_sasi_rcprofile.dlx")
|
||||||
|
ap.add_argument("--bus", type=float, required=True,
|
||||||
|
help="REQUIRED. There is no default: the delivery rate is a property of the medium and this project has never measured it. FINDINGS 42.1 -- the figure this tool used to default to was a user-supplied '4 Mbps' with no provenance, was a tenth of SCSI-1's asynchronous rating, and was never a bus measurement at all. A default let every table in FINDINGS 30-49 be scored against it without anyone restating it. Pass one explicitly.")
|
||||||
|
ap.add_argument("--fps", type=float, default=12.0)
|
||||||
|
a = ap.parse_args()
|
||||||
|
if not os.path.exists(a.container):
|
||||||
|
sys.exit(f"missing {a.container}")
|
||||||
|
|
||||||
|
BYTE_BUD = (a.bus - AUDIO_KBPS) * 1024 / a.fps
|
||||||
|
d = DLX(a.container)
|
||||||
|
BLK_C = {1: C_V1, 2: C_V4, 3: C_RAW}
|
||||||
|
BLK_B = {1: 1, 2: 4, 3: 16}
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for f in range(d.nframes):
|
||||||
|
mode = d.modes(f)
|
||||||
|
g = mode.reshape(-1, 4)
|
||||||
|
allskip = (g == 0).all(1)
|
||||||
|
base = allskip.sum() * 4 * C_SKIP_CLUSTERED
|
||||||
|
mm = g[~allskip]
|
||||||
|
base += (mm == 0).sum() * C_SKIP_MIXED
|
||||||
|
for k, c in BLK_C.items():
|
||||||
|
base += (mm == k).sum() * c
|
||||||
|
base_b = d.mode_bytes + sum(BLK_B.get(int(x), 0) for x in mode)
|
||||||
|
|
||||||
|
m = mode.reshape(d.nby, d.nbx)
|
||||||
|
cand = []
|
||||||
|
for by in range(d.nby):
|
||||||
|
dirty = m[by] != 0
|
||||||
|
i = 0
|
||||||
|
while i < d.nbx:
|
||||||
|
if not dirty[i]:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
j = i
|
||||||
|
while j < d.nbx and dirty[j]:
|
||||||
|
j += 1
|
||||||
|
L = j - i
|
||||||
|
cur_c = sum(BLK_C[int(b)] for b in m[by][i:j])
|
||||||
|
cur_b = sum(BLK_B[int(b)] for b in m[by][i:j])
|
||||||
|
sp = span_px(4 * L) # padded to the chain's 24-pixel unit
|
||||||
|
span_c = 4 * (SPAN_OVERHEAD + sp * CYC_PX_ROWLIN)
|
||||||
|
span_b = 4 * (SPAN_HDR + sp * SPAN_BYTES_PX)
|
||||||
|
if span_c < cur_c:
|
||||||
|
cand.append((cur_c - span_c, span_b - cur_b, L))
|
||||||
|
i = j
|
||||||
|
|
||||||
|
cand.sort(key=lambda s: -(s[0] / max(s[1], 1))) # best cycles per byte
|
||||||
|
cyc, byt, taken = base, base_b, 0
|
||||||
|
for dc, db, L in cand:
|
||||||
|
if byt + db <= BYTE_BUD:
|
||||||
|
cyc -= dc; byt += db; taken += 1
|
||||||
|
rows.append((base, cyc, base_b, byt, len(cand), taken))
|
||||||
|
|
||||||
|
base, new, bb, nb, ncand, ntaken = map(np.array, list(zip(*rows)))
|
||||||
|
pc = lambda v: 100 * v / FRAME_CYC
|
||||||
|
|
||||||
|
print(f"{a.container}: {d.nframes} frames")
|
||||||
|
print(f"bus {a.bus:.0f} KB/s - {AUDIO_KBPS} audio -> {BYTE_BUD:,.0f} B/frame "
|
||||||
|
f"at {a.fps:g}fps\n")
|
||||||
|
print(f"{'':<26}{'today':>12}{'+ literal spans':>18}")
|
||||||
|
for label, fn in (("median frame", np.median),
|
||||||
|
("p90 frame", lambda v: np.percentile(v, 90)),
|
||||||
|
("worst frame", np.max)):
|
||||||
|
print(f" {label:<24}{pc(fn(base)):>11.1f}%{pc(fn(new)):>17.1f}%")
|
||||||
|
print(f" {'frames missing budget':<24}{int((base>FRAME_CYC).sum()):>8}/{d.nframes}"
|
||||||
|
f"{int((new>FRAME_CYC).sum()):>14}/{d.nframes}")
|
||||||
|
print(f" {'bitrate':<24}{bb.mean()*a.fps/1024:>10.1f} KB/s"
|
||||||
|
f"{nb.mean()*a.fps/1024:>13.1f} KB/s")
|
||||||
|
print(f"\nspans taken: {ntaken.sum()} of {ncand.sum()} candidate runs "
|
||||||
|
f"({100*ntaken.sum()/max(ncand.sum(),1):.0f}%) -- the rest priced out by the bus")
|
||||||
|
brk = next(L for L in range(1, 65)
|
||||||
|
if 4*(SPAN_OVERHEAD + span_px(4*L)*CYC_PX_ROWLIN) < L*C_V1)
|
||||||
|
print(f"\nspan cost MEASURED (FINDINGS 30): {SPAN_OVERHEAD:.1f}/span + "
|
||||||
|
f"{CYC_PX_ROWLIN:.3f}/pixel, {SPAN_UNIT_PX}-pixel units.")
|
||||||
|
print(f"a run of L blocks beats all-V1 from L={brk} blocks up "
|
||||||
|
f"({4*(SPAN_OVERHEAD + span_px(4*brk)*CYC_PX_ROWLIN)/brk:.0f} vs {C_V1:.0f} "
|
||||||
|
f"cycles/block); the floor at a full row is "
|
||||||
|
f"{4*(SPAN_OVERHEAD + span_px(256)*CYC_PX_ROWLIN)/64:.0f}.")
|
||||||
|
print("The mode maps are NOT re-optimised, so this is a lower bound on a "
|
||||||
|
"cost-aware encoder.")
|
||||||
|
|
||||||
|
# FINDINGS 28.5 said a scene cut cannot fit at 12fps: the cheapest full redraw
|
||||||
|
# the codec's mode set allows is all-V1 at 110.5% of budget. 29.4 reopened that
|
||||||
|
# on derived span costs; this is the same arithmetic on measured ones. Mix a
|
||||||
|
# fraction x of a 100%-changed frame as full-row spans, V1 for the rest.
|
||||||
|
NB = d.nb
|
||||||
|
row_c = 4 * (SPAN_OVERHEAD + span_px(4 * d.nbx) * CYC_PX_ROWLIN) / d.nbx
|
||||||
|
row_b = 4 * (SPAN_HDR + span_px(4 * d.nbx) * SPAN_BYTES_PX) / d.nbx
|
||||||
|
x_cpu = (NB * C_V1 - FRAME_CYC) / (NB * (C_V1 - row_c))
|
||||||
|
x_bus = (BYTE_BUD - d.mode_bytes - NB * BLK_B[1]) / (NB * (row_b - BLK_B[1]))
|
||||||
|
print(f"\nscene cut (100% of blocks change), spans at full row width "
|
||||||
|
f"({row_c:.0f} cyc, {row_b:.1f} B per block):")
|
||||||
|
print(f" all-V1 costs {100*NB*C_V1/FRAME_CYC:.1f}% of the frame -- FINDINGS 28.5")
|
||||||
|
print(f" CPU needs x >= {x_cpu:.3f} of the frame as spans; "
|
||||||
|
f"the bus allows x <= {x_bus:.3f}")
|
||||||
|
print(" " + ("the interval is NOT empty: a cut fits at 12fps (FINDINGS 29.4 holds)"
|
||||||
|
if x_cpu <= x_bus else
|
||||||
|
"the interval IS empty: a cut does not fit (FINDINGS 28.5 stands)"))
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""What does fitting the CPU budget cost in quality? (session 8, lever B)
|
||||||
|
|
||||||
|
python3 tools/analysis/13_cpu_ratectl.py [frames_dir] [--profiles scsi]
|
||||||
|
|
||||||
|
Session 6 made the BYTE budget a ceiling by bisecting `lam` per frame. FINDINGS
|
||||||
|
28 then showed the binding budget is CYCLES, not bytes, and that the mode
|
||||||
|
decision cannot see them: it minimises `D + lam*R` on a machine that charges V4
|
||||||
|
1.49x a V1 block while the lagrangian charges it 4x.
|
||||||
|
|
||||||
|
`ratectl.encode_rate_controlled(cycle_budget=...)` adds the second controller --
|
||||||
|
`mu` bisected per frame against 833,333 cycles, with the lam bisection nested
|
||||||
|
inside it. This measures what that costs: PSNR, bitrate, and how many frames
|
||||||
|
still miss, against the same encode with the ceiling off.
|
||||||
|
|
||||||
|
The cycle budget is HARD, not a bucket. Bytes bank in the player's ring buffer;
|
||||||
|
there is no double buffer to decode ahead into, so a frame that misses its
|
||||||
|
decode deadline is simply late (FINDINGS 28).
|
||||||
|
|
||||||
|
Both controllers score frames with the exact clustered cost `vq_hybrid.cycles`,
|
||||||
|
validated to 1 point against the 68000 (FINDINGS 28.2) -- not with the per-block
|
||||||
|
ranking constant the mode decision uses. See vq_hybrid's note on SKIP.
|
||||||
|
"""
|
||||||
|
import argparse, os, pickle, sys, time
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
import numpy as np
|
||||||
|
import vq as VQ, vq_hybrid as H, ratectl as RC
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("frames_dir", nargs="?", default="tmp/fr_singe")
|
||||||
|
ap.add_argument("--profiles", default="scsi")
|
||||||
|
ap.add_argument("--fps", type=int, default=12)
|
||||||
|
ap.add_argument("--cache", default=None, help="pickle of H.build (auto by dir)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
if not os.path.isdir(a.frames_dir):
|
||||||
|
sys.exit(f"missing {a.frames_dir} -- see tools/bench/check.sh for extraction")
|
||||||
|
|
||||||
|
BUDGET = RC.FRAME_CYCLES
|
||||||
|
|
||||||
|
# H.build is ~55 s, nearly all k-means, and it does not depend on the profile:
|
||||||
|
# both ship k1=k4=256. One build, cached, serves every row of the table.
|
||||||
|
cache = a.cache or f"tmp/model_{os.path.basename(a.frames_dir.rstrip('/'))}.pkl"
|
||||||
|
# The build parameters are stored with the model and a mismatch rebuilds: the
|
||||||
|
# cache is keyed on the frames directory alone, and once H.build acquired an
|
||||||
|
# option (session 28's reserved black entry, 23.4) a stale pickle would quietly
|
||||||
|
# serve a model the shipping encoder no longer builds. Same guard as
|
||||||
|
# tools/analysis/16_span_roundtrip.py.
|
||||||
|
SIG = dict(k1=256, k4=256, iters=16, reserve_black=True)
|
||||||
|
m = None
|
||||||
|
if os.path.exists(cache):
|
||||||
|
m = pickle.load(open(cache, "rb"))
|
||||||
|
if m.get("sig") != SIG:
|
||||||
|
print(f"{cache}: built with {m.get('sig')}, wanted {SIG} -- rebuilding")
|
||||||
|
m = None
|
||||||
|
else:
|
||||||
|
print(f"model from {cache}")
|
||||||
|
if m is None:
|
||||||
|
t = time.time()
|
||||||
|
m = H.build(a.frames_dir, **SIG)
|
||||||
|
m["sig"] = SIG
|
||||||
|
pickle.dump(m, open(cache, "wb"))
|
||||||
|
print(f"built model in {time.time()-t:.0f} s -> {cache}")
|
||||||
|
print(f"{a.frames_dir}: {len(m['idx'])} frames, {m['nb']} blocks, "
|
||||||
|
f"budget {BUDGET:,.0f} cycles/frame at {a.fps}fps\n")
|
||||||
|
|
||||||
|
|
||||||
|
def run(prof_name, cycle_budget):
|
||||||
|
p = RC.PROFILES[prof_name]
|
||||||
|
m.pop("_sym", None) # the frame-symbol cache holds one frame
|
||||||
|
t = time.time()
|
||||||
|
enc = RC.encode_rate_controlled(m, p["kbps"], fps=a.fps, lam_lo=p["lam"],
|
||||||
|
cycle_budget=cycle_budget)
|
||||||
|
s = RC.summarise(m, enc, p["kbps"], fps=a.fps)
|
||||||
|
s["secs"] = time.time() - t
|
||||||
|
s["ns"] = float(np.mean([100*(mm != 0).mean() for mm in enc["modes"]]))
|
||||||
|
return s, enc
|
||||||
|
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for name in a.profiles.split(","):
|
||||||
|
for label, cb in (("bytes only", None), ("bytes + cycles", BUDGET)):
|
||||||
|
s, enc = run(name, cb)
|
||||||
|
rows.append((name, label, s))
|
||||||
|
print(f"{name:5s} {label:<15s} {s['secs']:5.1f} s "
|
||||||
|
f"PSNR {s['psnr']:.2f} dB {s['kbps']:6.1f} KB/s "
|
||||||
|
f"CPU med {100*s['cyc_med']/BUDGET:5.1f}% p90 "
|
||||||
|
f"{100*s['cyc_p90']/BUDGET:5.1f}% max {100*s['cyc_max']/BUDGET:5.1f}% "
|
||||||
|
f"miss {s['cpu_miss']:3d} late {s['late']:2d} "
|
||||||
|
f"mu med {s['mu_med']:.4f} max {s['mu_max']:.3f}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
hdr = f"{'':<22}{'PSNR':>8}{'KB/s':>9}{'CPU med':>10}{'CPU max':>10}{'miss':>7}"
|
||||||
|
for name in a.profiles.split(","):
|
||||||
|
r = {lab: s for n, lab, s in rows if n == name}
|
||||||
|
b, c = r["bytes only"], r["bytes + cycles"]
|
||||||
|
print(f"--- {name} (target {RC.PROFILES[name]['kbps']} KB/s) ---")
|
||||||
|
print(hdr)
|
||||||
|
for lab, s in (("bytes only", b), ("bytes + cycles", c)):
|
||||||
|
print(f" {lab:<20}{s['psnr']:>7.2f} {s['kbps']:>8.1f} "
|
||||||
|
f"{100*s['cyc_med']/BUDGET:>9.1f}%{100*s['cyc_max']/BUDGET:>9.1f}%"
|
||||||
|
f"{s['cpu_miss']:>6d}")
|
||||||
|
print(f" {'cost of fitting':<20}{c['psnr']-b['psnr']:>+7.2f} dB, "
|
||||||
|
f"{c['kbps']-b['kbps']:+.1f} KB/s, "
|
||||||
|
f"{b['cpu_miss']-c['cpu_miss']} fewer misses, "
|
||||||
|
f"{c['late']} frames unfixable at mu={RC.MU_CLIFF:g}")
|
||||||
|
print(f" {'modes % (b/c)':<20}SKIP {b['skip']:.1f}/{c['skip']:.1f} "
|
||||||
|
f"V1 {b['v1']:.1f}/{c['v1']:.1f} V4 {b['v4']:.1f}/{c['v4']:.1f} "
|
||||||
|
f"RAW {b['raw']:.1f}/{c['raw']:.1f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("FINDINGS 28.7: re-coding every non-SKIP block as V1 is the floor the "
|
||||||
|
"CURRENT mode set\nallows, and it still missed 11 frames at the retired "
|
||||||
|
"110 KB/s profile / 12 at scsi.\nMisses above that floor are spans, not "
|
||||||
|
"the mode decision -- and 31.3 showed the\nfloor itself was too "
|
||||||
|
"pessimistic, because the real decision can move a block to SKIP.")
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Would letting the HD63450 paint the spans beat letting the 68000 do it?
|
||||||
|
|
||||||
|
python3 tools/analysis/14_dmac_chain.py [container.dlx] --bus <KB/s>
|
||||||
|
[--dma-px-bus 2] [--disk-bus-byte 1]
|
||||||
|
|
||||||
|
FINDINGS 29.6 called this the one lever that could move the CPU budget without
|
||||||
|
spending a byte, and left it uncosted. FINDINGS 30 measured the alternative --
|
||||||
|
the 68000 painting spans itself, 43.7 cycles per span + 9.152 per pixel. This
|
||||||
|
prices the two against each other, and the answer turns on a resource neither
|
||||||
|
section costed: the 68000's own LOCAL BUS.
|
||||||
|
|
||||||
|
FINDINGS 29's "the bus has 4x the headroom the CPU has" is about the SCSI pipe,
|
||||||
|
110 KB/s of the delivery pipe. That is a different bus. The 68000's memory bus runs one 4-clock
|
||||||
|
cycle at a time and carries instruction prefetch as well as data, and
|
||||||
|
tools/analysis/15_bus_occupancy.py measures the decoder using 86.7% of it.
|
||||||
|
|
||||||
|
THE TWO DESIGNS ARE THE SAME CONTAINER. v6's record is {u32 absolute GVRAM
|
||||||
|
address, u16 jump displacement} = 6 bytes; an MC68450/HD63450 array-chaining
|
||||||
|
entry is {u32 memory address, u16 transfer count} = 6 bytes. Set the channel to
|
||||||
|
dual-address, direction device->memory, Sequence Control counting both addresses
|
||||||
|
up: MAR reloads per entry (the GVRAM destination), DAR walks the stream buffer,
|
||||||
|
MTC is the span's word count. The chain array IS the span table.
|
||||||
|
|
||||||
|
THE DMAC CONSTANTS ARE NOW SOURCED, and they killed the first answer. From the
|
||||||
|
MC68450 manual (Motorola, Jul 1989, bitsavers), Fig 4-25 sheet 4: a dual-address
|
||||||
|
WORD operand between two 16-bit ports is **9 clocks**, because note 2 gives the
|
||||||
|
DMAC 4-clock reads and **5-clock writes**. The 68000 writes in 4. So:
|
||||||
|
|
||||||
|
DMAC 9.000 clocks/pixel (datasheet)
|
||||||
|
v6 9.152 clocks/pixel (measured, FINDINGS 30)
|
||||||
|
|
||||||
|
A 1.7% difference. Session 10's first pass guessed 2 bus cycles = 8 clocks from
|
||||||
|
bus arithmetic and was 12% optimistic; the extra clock on every DMAC write is
|
||||||
|
the whole story. Per span, sequential array chaining costs 36 clocks (Fig 4-25
|
||||||
|
sheet 1) against v6's measured 43.7 -- the DMAC's one real edge, and it is small.
|
||||||
|
|
||||||
|
AND DMA DOES NOT OVERLAP. The 68000 has no cache and a two-word prefetch queue,
|
||||||
|
so it stalls as soon as another master takes the bus. Frame time is therefore
|
||||||
|
CPU + DMA, additive. Session 10's first pass used max(CPU, bus) and got 53/120
|
||||||
|
where the additive model gives 84/120; FINDINGS 35's flat debit was right.
|
||||||
|
|
||||||
|
So the only material difference left is v6's 24-pixel padding quantum -- and
|
||||||
|
that is a property of v6's unrolled chain, not of the CPU. The `v7 fine tail`
|
||||||
|
column prices fixing it in software instead, and as of session 11 that column
|
||||||
|
is MEASURED on the 68000 (blit.s v7, tools/bench/span.sh, FINDINGS 40) rather
|
||||||
|
than derived: 66.0 clocks per span + 9.143 per coarse pixel + 9.978 per fine
|
||||||
|
pixel, with a 2-pixel quantum that a run of 4x4 blocks pads to exactly.
|
||||||
|
"""
|
||||||
|
import sys, os, argparse
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
sys.path.insert(0, "tools/analysis")
|
||||||
|
import numpy as np
|
||||||
|
from dlx import DLX
|
||||||
|
import buscost as B
|
||||||
|
|
||||||
|
FRAME_CYC = 833333.0
|
||||||
|
AUDIO_KBPS = 7.8
|
||||||
|
import vq_hybrid as _H
|
||||||
|
C_V1, C_V4, C_RAW = _H.C_V1, _H.C_V4, _H.C_RAW # FINDINGS 28.2 (MEASURED)
|
||||||
|
# 45.0 until session 12 measured it at 55.0 (FINDINGS 41.5) -- imported now, so
|
||||||
|
# the correction cannot be undone by a stale copy.
|
||||||
|
C_SKIP_CLUSTERED, C_SKIP_MIXED = _H.C_SKIP_CLUSTERED, _H.C_SKIP_MIXED
|
||||||
|
SPAN_BYTES_PX, SPAN_HDR = 2, 6
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("container", nargs="?", default="tmp/rc_fr_singe_scsi_cpufit.dlx")
|
||||||
|
ap.add_argument("--bus", type=float, required=True,
|
||||||
|
help="REQUIRED. There is no default: the delivery rate is a property of the medium and this project has never measured it. FINDINGS 42.1 -- the figure this tool used to default to was a user-supplied '4 Mbps' with no provenance, was a tenth of SCSI-1's asynchronous rating, and was never a bus measurement at all. A default let every table in FINDINGS 30-49 be scored against it without anyone restating it. Pass one explicitly.")
|
||||||
|
ap.add_argument("--fps", type=float, default=12.0)
|
||||||
|
ap.add_argument("--dma-px-clk", type=float, default=B.DMA_PX_CLK,
|
||||||
|
help="clocks the DMAC spends per pixel, dual-address word "
|
||||||
|
"between two 16-bit ports. 9 is the DATASHEET figure "
|
||||||
|
"(MC68450 Fig 4-25 sheet 4).")
|
||||||
|
ap.add_argument("--disk-clk-byte", type=float, default=5.0,
|
||||||
|
help="clocks the SCSI DMA steals per BYTE delivered. The SPC is "
|
||||||
|
"an 8-bit port, so the DMAC pays per byte, not per word "
|
||||||
|
"(FINDINGS 43). 5, the default, is the OPTIMISTIC end and "
|
||||||
|
"what ratectl encodes against: single-address, bus held, no "
|
||||||
|
"drive wait (Fig 4-25 sheet 2). 9 is dual-address, which is "
|
||||||
|
"what MAME models and what applies if the board does not "
|
||||||
|
"drive DACK. Score both.")
|
||||||
|
ap.add_argument("--disk-clk-word", type=float, default=None,
|
||||||
|
help="DEPRECATED denominator of FINDINGS 39.7/42, kept so the "
|
||||||
|
"old tables reproduce: sets --disk-clk-byte to half this")
|
||||||
|
a = ap.parse_args()
|
||||||
|
if a.disk_clk_word is not None:
|
||||||
|
a.disk_clk_byte = a.disk_clk_word / 2.0
|
||||||
|
|
||||||
|
if not os.path.exists(a.container):
|
||||||
|
sys.exit(f"missing {a.container}")
|
||||||
|
|
||||||
|
BYTE_BUD = (a.bus - AUDIO_KBPS) * 1024 / a.fps
|
||||||
|
BUS_SLOTS = FRAME_CYC / B.BUS_CLK
|
||||||
|
d = DLX(a.container)
|
||||||
|
BLK_C = {1: C_V1, 2: C_V4, 3: C_RAW}
|
||||||
|
BLK_B = {1: 1, 2: 4, 3: 16}
|
||||||
|
|
||||||
|
|
||||||
|
def runs(m, by):
|
||||||
|
dirty = m[by] != 0
|
||||||
|
i = 0
|
||||||
|
while i < d.nbx:
|
||||||
|
if not dirty[i]:
|
||||||
|
i += 1; continue
|
||||||
|
j = i
|
||||||
|
while j < d.nbx and dirty[j]:
|
||||||
|
j += 1
|
||||||
|
yield i, j
|
||||||
|
i = j
|
||||||
|
|
||||||
|
|
||||||
|
def span_cost(design, L):
|
||||||
|
"""(pixels carried, clocks charged to the frame) for a run of L blocks,
|
||||||
|
as 4 rows of 4L pixels. Every design is charged additively: the 68000
|
||||||
|
cannot execute while the DMAC owns the bus."""
|
||||||
|
if design == "v6":
|
||||||
|
px = B.pad24(4 * L)
|
||||||
|
return 4 * px, 4 * (B.V6_SPAN_CYC + px * B.V6_PX_CYC)
|
||||||
|
if design == "v7":
|
||||||
|
px, c = B.v7_span(4 * L)
|
||||||
|
return 4 * px, 4 * c
|
||||||
|
px = 4 * L
|
||||||
|
return 4 * px, 4 * (B.DMA_CHAIN_CLK + px * a.dma_px_clk)
|
||||||
|
|
||||||
|
|
||||||
|
def score(design):
|
||||||
|
"""Greedy, as 12_span_tradeoff.py: buy the best clocks-saved per byte spent
|
||||||
|
until the frame's byte budget is gone. Unlike 12, a spanned block still pays
|
||||||
|
its mode-map dispatch, which FINDINGS 30.7 flagged as uncounted."""
|
||||||
|
out = []
|
||||||
|
for f in range(d.nframes):
|
||||||
|
m = d.modes(f).reshape(d.nby, d.nbx)
|
||||||
|
byt = d.mode_bytes + sum(BLK_B.get(int(x), 0) for x in m.ravel())
|
||||||
|
spanned = np.zeros_like(m, bool)
|
||||||
|
span_clk = 0.0
|
||||||
|
|
||||||
|
cand = []
|
||||||
|
if design != "none":
|
||||||
|
for by in range(d.nby):
|
||||||
|
for i, j in runs(m, by):
|
||||||
|
L = j - i
|
||||||
|
cur_c = sum(BLK_C[int(b)] for b in m[by][i:j])
|
||||||
|
cur_b = sum(BLK_B[int(b)] for b in m[by][i:j])
|
||||||
|
px, sc = span_cost(design, L)
|
||||||
|
sc += L * C_SKIP_MIXED # the dispatch still happens
|
||||||
|
# v7 carries a second u16 (the fine displacement) per span.
|
||||||
|
hdr = B.V7_SPAN_HDR if design == "v7" else SPAN_HDR
|
||||||
|
span_b = 4 * hdr + px * SPAN_BYTES_PX
|
||||||
|
if sc < cur_c:
|
||||||
|
cand.append((cur_c - sc, span_b - cur_b, by, i, j, sc, L))
|
||||||
|
cand.sort(key=lambda s: -(s[0] / max(s[1], 1)))
|
||||||
|
for dc, db, by, i, j, sc, L in cand:
|
||||||
|
if byt + db <= BYTE_BUD:
|
||||||
|
byt += db
|
||||||
|
spanned[by][i:j] = True
|
||||||
|
span_clk += sc - L * C_SKIP_MIXED
|
||||||
|
g = m.copy()
|
||||||
|
g[spanned] = 0
|
||||||
|
gg = g.reshape(-1, 4)
|
||||||
|
allskip = (gg == 0).all(1)
|
||||||
|
cpu = allskip.sum() * 4 * C_SKIP_CLUSTERED
|
||||||
|
mm = gg[~allskip]
|
||||||
|
cpu += (mm == 0).sum() * C_SKIP_MIXED
|
||||||
|
for k, c in BLK_C.items():
|
||||||
|
cpu += (mm == k).sum() * c
|
||||||
|
pref, data = B.block_bus(m, spanned)
|
||||||
|
disk = byt * a.disk_clk_byte
|
||||||
|
# additive: CPU work, then span painting, then the disk stealing the bus
|
||||||
|
out.append((cpu + span_clk + disk, (pref + data) * B.BUS_CLK, byt,
|
||||||
|
spanned.sum()))
|
||||||
|
return np.array(out).T
|
||||||
|
|
||||||
|
|
||||||
|
DESIGNS = [("today", "none"), ("v6 span", "v6"),
|
||||||
|
("v7 fine tail", "v7"), ("DMAC chain", "dmac")]
|
||||||
|
res = {n: score(k) for n, k in DESIGNS}
|
||||||
|
|
||||||
|
print(f"{a.container}: {d.nframes} frames, {d.nb} blocks, {a.fps:g} fps")
|
||||||
|
print(f"SCSI pipe {a.bus:.0f} KB/s -> {BYTE_BUD:,.0f} B/frame; "
|
||||||
|
f"68000 bus {BUS_SLOTS:,.0f} cycles/frame; CPU {FRAME_CYC:,.0f} clocks\n")
|
||||||
|
|
||||||
|
print("PER PIXEL AND PER SPAN -- datasheet against measurement")
|
||||||
|
print(f" DMAC dual-address word, two 16-bit ports {B.DMA_PX_CLK:.3f} clocks "
|
||||||
|
f"MC68450 Fig 4-25 sheet 4")
|
||||||
|
print(f" v6 movem chain {B.V6_PX_CYC:.3f} clocks "
|
||||||
|
f"MEASURED, FINDINGS 30")
|
||||||
|
print(f" -> the DMAC is {100*(B.V6_PX_CYC-B.DMA_PX_CLK)/B.V6_PX_CYC:+.1f}% per pixel. "
|
||||||
|
f"The 68000 writes in 4 clocks; the DMAC takes 5.")
|
||||||
|
print(f" per span: DMAC array chaining {B.DMA_CHAIN_CLK} clocks against v6's "
|
||||||
|
f"{B.V6_SPAN_CYC:.1f}\n")
|
||||||
|
|
||||||
|
w = 15
|
||||||
|
print(f"{'':<26}" + "".join(f"{n:>{w}}" for n, _ in DESIGNS))
|
||||||
|
def row(label, fmt, get):
|
||||||
|
print(f" {label:<24}" + "".join(f"{fmt(get(res[n])):>{w}}" for n, _ in DESIGNS))
|
||||||
|
|
||||||
|
row("bitrate KB/s", lambda v: f"{v:.1f}", lambda r: r[2].mean() * a.fps / 1024)
|
||||||
|
row("frame, median", lambda v: f"{v:.1f}%", lambda r: 100*np.median(r[0])/FRAME_CYC)
|
||||||
|
row("frame, worst", lambda v: f"{v:.1f}%", lambda r: 100*r[0].max()/FRAME_CYC)
|
||||||
|
row("frames missing", lambda v: f"{v}/{d.nframes}",
|
||||||
|
lambda r: int((r[0] > FRAME_CYC).sum()))
|
||||||
|
row("blocks spanned/frame", lambda v: f"{v:,.0f}", lambda r: r[3].mean())
|
||||||
|
print(f"\n ADDITIVE: frame = CPU + span painting + disk DMA. The 68000 has no"
|
||||||
|
f"\n cache and a two-word prefetch queue, so it stalls the moment another"
|
||||||
|
f"\n master takes the bus. Disk debited at {a.disk_clk_byte:g} clocks/byte.")
|
||||||
|
|
||||||
|
# What is left of the case, isolated.
|
||||||
|
v6m = int((res["v6 span"][0] > FRAME_CYC).sum())
|
||||||
|
finem = int((res["v7 fine tail"][0] > FRAME_CYC).sum())
|
||||||
|
dmam = int((res["DMAC chain"][0] > FRAME_CYC).sum())
|
||||||
|
print(f"\nWHAT THE DMAC ACTUALLY BUYS, decomposed")
|
||||||
|
print(f" v6 as built {v6m}/{d.nframes} frames over")
|
||||||
|
print(f" v7, a finer chain tail (MEASURED) {finem}/{d.nframes}")
|
||||||
|
print(f" DMAC chain {dmam}/{d.nframes}")
|
||||||
|
print(f" -> of the gap between v6 and the DMAC, "
|
||||||
|
f"{100*(v6m-finem)/max(v6m-dmam,1):.0f}% is the 24-pixel padding")
|
||||||
|
print(f" quantum, which is a property of v6's unrolled chain and fixable")
|
||||||
|
print(f" in software. The rest is 1.7% a pixel and 7.7 clocks a span.")
|
||||||
|
|
||||||
|
# The additive model here IS FINDINGS 35's flat debit, and reproduces its
|
||||||
|
# 84/120 exactly in the "today" column. Session 10's first pass replaced it with
|
||||||
|
# max(CPU, bus) and got 53/120; that was wrong, because a 68000 cannot execute
|
||||||
|
# while the DMAC holds the bus.
|
||||||
|
|
||||||
|
print(f"\nbreak-even against all-V1 ({C_V1:.1f} cycles/block), clocks per block")
|
||||||
|
print(f" {'L':<16}" + "".join(f"{L:>8}" for L in (1, 2, 3, 4, 8, 16, 64)))
|
||||||
|
for nm, dz in (("v6 as built", "v6"), ("v7 fine tail", "v7"), ("DMAC chain", "dmac")):
|
||||||
|
print(f" {nm:<16}" + "".join(f"{span_cost(dz, L)[1]/L:>8.0f}"
|
||||||
|
for L in (1, 2, 3, 4, 8, 16, 64)))
|
||||||
|
for nm, dz in (("v6 as built", "v6"), ("v7 fine tail", "v7"), ("DMAC chain", "dmac")):
|
||||||
|
brk = next((L for L in range(1, 65) if span_cost(dz, L)[1] < L * C_V1), None)
|
||||||
|
print(f" {nm:<16} beats all-V1 from L={brk} blocks up")
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""How much of the bus does the 68000 decoder actually leave for a DMAC?
|
||||||
|
|
||||||
|
python3 tools/analysis/15_bus_occupancy.py [container.dlx] [--nframes N]
|
||||||
|
|
||||||
|
FINDINGS 29.6's DMAC idea only pays if the DMAC can find bus slots the CPU is
|
||||||
|
not using. That is not a cycle count, it is a BUS count, and nothing in the tree
|
||||||
|
had one.
|
||||||
|
|
||||||
|
Two sources, and the point is that they check each other:
|
||||||
|
|
||||||
|
DATA accesses MEASURED by tools/bench/c68k/c68k_bench, which counts every
|
||||||
|
Read/Write callback the C68K core makes. Exact.
|
||||||
|
INSTRUCTION DERIVED here by walking src/player/decode.s's straight-line
|
||||||
|
prefetch paths in tools/bench/decode.lst and multiplying by the mode
|
||||||
|
histogram. Not measurable from either emulator: MAME's core
|
||||||
|
does not expose a fetch count and C68K reads opcodes straight
|
||||||
|
through a host pointer with no callback.
|
||||||
|
|
||||||
|
If the derived DATA figure matches the measured one, the derived PREFETCH figure
|
||||||
|
from the same walk is trustworthy too. That check is the first thing printed,
|
||||||
|
and this script exits non-zero if it fails.
|
||||||
|
|
||||||
|
A 68000 bus cycle is 4 clocks, so a frame of C clocks holds C/4 bus slots.
|
||||||
|
"""
|
||||||
|
import sys, os, argparse, csv
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import numpy as np
|
||||||
|
from dlx import DLX
|
||||||
|
import buscost as B
|
||||||
|
from buscost import V7_FRAME_PREF, V7_FRAME_DATA
|
||||||
|
|
||||||
|
BUS_CLK = 4
|
||||||
|
|
||||||
|
# --- straight-line path costs, read off tools/bench/decode.lst -------------
|
||||||
|
# (instruction words, data bus cycles). A long access is two bus cycles on the
|
||||||
|
# 68000's 16-bit bus; movem.l of N registers is 2N.
|
||||||
|
#
|
||||||
|
# dispatch move.b (a1),d0 / lsr.b / and.w #3 / beq .sk 6w, 1 read
|
||||||
|
# + subq / beq .v1 -> 8w
|
||||||
|
# + subq / bne .rw -> 10w
|
||||||
|
# V4 body $10090..$100E2 = 82 B = 41w; 4 x (1 byte read
|
||||||
|
# + movem.l 2 regs = 4 reads + 2 move.l = 4 writes) = 36
|
||||||
|
# V1 body $100E2..$10106 = 36 B = 18w; 1 byte read
|
||||||
|
# + movem.l 8 regs = 16 reads + 4 x movem.l 2 = 16 w = 33
|
||||||
|
# RAW body $10106..$10164 = 94 B = 47w; 8 x (2 byte reads
|
||||||
|
# + 1 move.l = 2 writes) = 32
|
||||||
|
# .sk tail addq.l #8,a4 1w
|
||||||
|
# BLOCK 0 has no lsr.b, so one of the four dispatches in a group is 1w cheaper.
|
||||||
|
DISPATCH_SK, DISPATCH_V1, DISPATCH_V4 = 6, 8, 10
|
||||||
|
BODY = {0: (0, 0), 1: (18, 33), 2: (41, 36), 3: (47, 32)}
|
||||||
|
DISPATCH = {0: DISPATCH_SK, 1: DISPATCH_V1, 2: DISPATCH_V4, 3: DISPATCH_V4}
|
||||||
|
SK_TAIL = 1
|
||||||
|
GROUP_HEAD = 3 # tst.b (a1) 1w + beq allskip 2w
|
||||||
|
GROUP_TAIL = 4 # addq.l #1,a1 / cmpa.l a5,a4 / bne byteloop
|
||||||
|
ALLSKIP = 9 # the whole four-block fast path, tst.b included
|
||||||
|
ROW_HEAD, ROW_TAIL = 3, 7
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("container", nargs="?", default="tmp/rc_fr_singe_scsi_cpufit.dlx")
|
||||||
|
ap.add_argument("--csv", default="tmp/c68k_frames.csv",
|
||||||
|
help="per-frame output of tools/bench/c68k/run.sh")
|
||||||
|
ap.add_argument("--nframes", type=int, default=None)
|
||||||
|
ap.add_argument("--kbps", type=float, default=None,
|
||||||
|
help="delivery rate in KB/s. OPTIONAL and there is no default "
|
||||||
|
"(FINDINGS 50): supply it and the AUTO-REQUEST rows are "
|
||||||
|
"added, which are the only rows whose cost depends on how "
|
||||||
|
"long the record takes to arrive (59.3).")
|
||||||
|
a = ap.parse_args()
|
||||||
|
if not os.path.exists(a.container):
|
||||||
|
sys.exit(f"missing {a.container}")
|
||||||
|
|
||||||
|
d = DLX(a.container)
|
||||||
|
meas = {}
|
||||||
|
if os.path.exists(a.csv):
|
||||||
|
for r in csv.DictReader(open(a.csv)):
|
||||||
|
meas[int(r["frame"])] = (int(r["cycles"]),
|
||||||
|
int(r["bus_reads"]) + int(r["bus_writes"]))
|
||||||
|
NF = a.nframes or (max(meas) + 1 if meas else d.nframes)
|
||||||
|
|
||||||
|
pref_t, data_t, cyc_t = [], [], []
|
||||||
|
for f in range(NF):
|
||||||
|
m = d.modes(f).reshape(d.nby, d.nbx)
|
||||||
|
pref = d.nby * (ROW_HEAD + ROW_TAIL)
|
||||||
|
data = 0
|
||||||
|
for by in range(d.nby):
|
||||||
|
row = m[by]
|
||||||
|
for gi in range(0, d.nbx, 4):
|
||||||
|
g = row[gi:gi+4]
|
||||||
|
if (g == 0).all():
|
||||||
|
pref += ALLSKIP; data += 1
|
||||||
|
continue
|
||||||
|
pref += GROUP_HEAD + GROUP_TAIL - 1 # BLOCK 0 has no lsr.b
|
||||||
|
data += 1
|
||||||
|
for b in g:
|
||||||
|
b = int(b)
|
||||||
|
pw, pd = BODY[b]
|
||||||
|
pref += DISPATCH[b] + pw + SK_TAIL
|
||||||
|
data += 1 + pd
|
||||||
|
# The span section is bus traffic too, and it is most of the frame's data
|
||||||
|
# accesses in a span-heavy container: 48 per 24-pixel chain unit. Leaving it
|
||||||
|
# out would not merely understate the total -- it would break the CHECK
|
||||||
|
# below, which is the whole licence for the prefetch figure.
|
||||||
|
sp, _ = d.spans(f)
|
||||||
|
if sp:
|
||||||
|
pref += V7_FRAME_PREF; data += V7_FRAME_DATA
|
||||||
|
for _, _, px in sp:
|
||||||
|
sp_p, sp_d = B.v7_span_split(len(px))
|
||||||
|
pref += sp_p; data += sp_d
|
||||||
|
pref_t.append(pref); data_t.append(data)
|
||||||
|
cyc_t.append(meas.get(f, (0, 0))[0])
|
||||||
|
|
||||||
|
pref_t, data_t, cyc_t = map(np.array, (pref_t, data_t, cyc_t))
|
||||||
|
|
||||||
|
print(f"{a.container}: {NF} frames, {d.nb} blocks/frame\n")
|
||||||
|
if meas:
|
||||||
|
md = np.array([meas[f][1] for f in range(NF)])
|
||||||
|
err = 100 * (data_t - md) / md
|
||||||
|
print("CHECK -- derived DATA bus cycles against the C68K harness's measurement")
|
||||||
|
print(f" measured mean {md.mean():>10,.0f} /frame")
|
||||||
|
print(f" derived mean {data_t.mean():>10,.0f} /frame "
|
||||||
|
f"error {err.mean():+.2f}% mean, {np.abs(err).max():.2f}% worst")
|
||||||
|
if np.abs(err).max() > 2.0:
|
||||||
|
sys.exit("\nFAIL: the path walk does not reproduce the measured data "
|
||||||
|
"accesses, so its prefetch figure cannot be trusted either.")
|
||||||
|
print(" the walk reproduces the measurement, so its prefetch count stands\n")
|
||||||
|
|
||||||
|
slots = cyc_t / BUS_CLK
|
||||||
|
tot = pref_t + data_t
|
||||||
|
print(f"{'':<22}{'mean':>12}{'median':>12}{'worst frame':>14}")
|
||||||
|
for label, v in (("bus slots in a frame", slots),
|
||||||
|
(" data accesses", data_t),
|
||||||
|
(" instruction prefetch", pref_t),
|
||||||
|
(" total bus cycles", tot)):
|
||||||
|
print(f"{label:<22}{v.mean():>12,.0f}{np.median(v):>12,.0f}{v.max():>14,.0f}")
|
||||||
|
occ = 100 * tot / slots
|
||||||
|
print(f"{'bus OCCUPANCY':<22}{occ.mean():>11.1f}%{np.median(occ):>11.1f}%"
|
||||||
|
f"{occ.max():>13.1f}%")
|
||||||
|
free = slots - tot
|
||||||
|
print(f"{'slots left for a DMAC':<22}{free.mean():>12,.0f}{np.median(free):>12,.0f}"
|
||||||
|
f"{free.min():>14,.0f} (worst = fewest)")
|
||||||
|
print(f"\nprefetch is {100*pref_t.sum()/tot.sum():.0f}% of the decoder's bus traffic: "
|
||||||
|
f"the data-only\nfigure the harness prints understates occupancy by about 2x.")
|
||||||
|
print(f"A DMAC painting spans at 8 clocks (2 bus cycles) per pixel could use at\n"
|
||||||
|
f"most {free.mean()/2:,.0f} pixels' worth of the mean frame's spare slots "
|
||||||
|
f"-- against {d.nb*16:,} pixels\nin a whole screen.")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# THE OTHER TWO MASTERS. Everything above is the 68000's own traffic, and it
|
||||||
|
# was the whole of this tool until session 20. The frame also has to carry the
|
||||||
|
# bitstream in off the disk and a byte of ADPCM out to $E92003 every 128 us,
|
||||||
|
# and neither has ever appeared in a bus figure -- FINDINGS 35's lesson, which
|
||||||
|
# was about the CLOCK budget, had never been applied to the BUS one.
|
||||||
|
#
|
||||||
|
# The DMAC does not overlap with the CPU (buscost.DMA_OVERLAPS = False): the
|
||||||
|
# 68000 has no cache and a two-word prefetch queue that empties at once, so a
|
||||||
|
# stolen bus cycle is a stopped CPU. The three demands therefore ADD.
|
||||||
|
#
|
||||||
|
# Audio's per-byte figure is SETTLED, not bracketed by taste:
|
||||||
|
# tools/analysis/21_iplrom_dmac.py reads the IPL ROM's own HD63450 setup and
|
||||||
|
# finds channel 3 dual-address, 8-bit port, cycle steal without hold, external
|
||||||
|
# request -- one arbitration per byte, no burst. Video's is NOT settled: it is
|
||||||
|
# ROADMAP B3 / FINDINGS 42.4-42.6's W, so it is swept rather than picked.
|
||||||
|
print("\n" + "=" * 72)
|
||||||
|
print("THE OTHER TWO MASTERS -- what the DMAC takes out of the same frame\n")
|
||||||
|
FPS = d.fps
|
||||||
|
CPUHZ = 10e6 # stock X68000, MAME 0.277 x68k.cpp:1133
|
||||||
|
FRAME_CLK = CPUHZ / FPS
|
||||||
|
# WHAT THE TRANSPORT MOVES, which is the PADDED record and not the payload.
|
||||||
|
# Under DLX2/3/4 the pad was 0..3 B and the distinction was noise; under DLX5 it
|
||||||
|
# is 0..511 B, and charging the payload would price the channel for bytes it
|
||||||
|
# does not carry while the disc carries them anyway. A budget that debits only
|
||||||
|
# the bytes a frame CONTAINS is the same incomplete accounting this project has
|
||||||
|
# been caught by before -- the pad is delivered, so the pad is charged.
|
||||||
|
vid_bpf = sum(d.record_lengths()[:NF]) / NF
|
||||||
|
aud_bpf = B.ADPCM_BYTES_PER_S / FPS
|
||||||
|
a_lo = aud_bpf * B.ADPCM_CLK_BYTE_BEST
|
||||||
|
a_hi = aud_bpf * B.ADPCM_CLK_BYTE_WORST
|
||||||
|
cpu_clk = cyc_t.mean() if cyc_t.any() else float("nan")
|
||||||
|
|
||||||
|
print(f"frame period at {FPS:g} fps on a 10 MHz 68000: {FRAME_CLK:,.0f} clocks")
|
||||||
|
if cyc_t.any():
|
||||||
|
print(f" decoder, MEASURED (C68K) {cpu_clk:>10,.0f} clk "
|
||||||
|
f"{100*cpu_clk/FRAME_CLK:5.1f}% worst frame "
|
||||||
|
f"{100*cyc_t.max()/FRAME_CLK:.1f}%")
|
||||||
|
print(f" audio DMA, {aud_bpf:,.1f} B/frame {a_lo:>10,.0f} clk "
|
||||||
|
f"{100*a_lo/FRAME_CLK:5.2f}% .. {a_hi:,.0f} clk "
|
||||||
|
f"({100*a_hi/FRAME_CLK:.2f}%)")
|
||||||
|
print(f" {B.ADPCM_CLK_BYTE_BEST}..{B.ADPCM_CLK_BYTE_WORST} clk/byte, "
|
||||||
|
f"from the ROM's own DCR/OCR (21_iplrom_dmac.py). NOT a guess, and\n"
|
||||||
|
f" not the disk's rate: audio arbitrates for the bus once per byte "
|
||||||
|
f"and cannot burst.")
|
||||||
|
print(f"\n video DMA, {vid_bpf:,.0f} B/frame, swept over W -- ROADMAP B3 is "
|
||||||
|
f"still open:")
|
||||||
|
print(f" {'W (clk/byte)':<16}{'clk/frame':>12}{'% of frame':>12} "
|
||||||
|
f"{'CPU+audio+video':>18}")
|
||||||
|
for W, note in ((5.0, "single address, bus held (11_cpu_budget.py default)"),
|
||||||
|
(8.0, "FINDINGS 5's long-standing per-word ESTIMATE"),
|
||||||
|
(9.0, "DUAL address, bus held -- and the FLOOR of every "
|
||||||
|
"dual-address\n "
|
||||||
|
" configuration, auto-request included (59.3)"),
|
||||||
|
(12.0, "single address, arbitrated per byte"),
|
||||||
|
(16.0, "what the ROM programs for SASI (best case)"),
|
||||||
|
(19.0, "what the ROM programs for SASI (worst case)"),
|
||||||
|
(87.28, "PIO -- MEASURED, FINDINGS 58.2, the CPU doing it itself")):
|
||||||
|
v = vid_bpf * W
|
||||||
|
tot_clk = (cpu_clk if cyc_t.any() else 0) + a_lo + v
|
||||||
|
print(f" {W:<16.6g}{v:>12,.0f}{100*v/FRAME_CLK:>11.1f}% "
|
||||||
|
f"{100*tot_clk/FRAME_CLK:>17.1f}% {note}")
|
||||||
|
print(f"\n (the last column adds the MEASURED mean decode and the BEST-CASE "
|
||||||
|
f"audio, so it is\n the optimistic end of every row. 100% is the frame "
|
||||||
|
f"deadline at {FPS:g} fps.)")
|
||||||
|
print(f"""
|
||||||
|
Audio is {100*a_lo/FRAME_CLK:.2f}%..{100*a_hi/FRAME_CLK:.2f}% of the frame and video is {vid_bpf*5/FRAME_CLK*100:.0f}%..{vid_bpf*19/FRAME_CLK*100:.0f}% over the ladder, against
|
||||||
|
{vid_bpf*87.28/FRAME_CLK*100:.0f}% for the PIO transport FINDINGS 58.2 measured. The unpriced audio stream
|
||||||
|
was never the risk P6 called it -- ON THE BUS.""")
|
||||||
|
|
||||||
|
# --- HEADROOM, AND THE FLOOR UNDER THE LADDER -----------------------------
|
||||||
|
# Added session 27. The sweep above answers "what does each W cost"; it never
|
||||||
|
# answered "what can this frame afford", and the two are not the same question.
|
||||||
|
# FINDINGS 59.2 is why it matters now: with no external request line the only
|
||||||
|
# configurations that can be run are dual-address, and a dual-address byte has
|
||||||
|
# a FLOOR -- one 4-clock read of the device plus one 5-clock write to memory,
|
||||||
|
# buscost.DMA_DUAL_BYTE_CLK. No GCR share and no delivery rate goes under it.
|
||||||
|
print("\n" + "=" * 72)
|
||||||
|
print("WHAT THE FRAME CAN AFFORD, AND THE FLOOR UNDER THE LADDER\n")
|
||||||
|
head_clk = FRAME_CLK - (cpu_clk if cyc_t.any() else 0) - a_lo
|
||||||
|
head_wb = head_clk / vid_bpf
|
||||||
|
print(f" headroom after the MEASURED decode and best-case audio: "
|
||||||
|
f"{head_clk:,.0f} clk = {100*head_clk/FRAME_CLK:.1f}%")
|
||||||
|
print(f" at {vid_bpf:,.0f} B a frame that is {head_wb:.2f} CLOCKS PER BYTE, and "
|
||||||
|
f"that is the number\n a transport has to come in under.\n")
|
||||||
|
floor = B.DMA_DUAL_BYTE_CLK
|
||||||
|
print(f" dual-address floor {floor} clk/B ({B.DMA_READ_CLK} read of the "
|
||||||
|
f"device + {B.DMA_WRITE_CLK} write to memory, Fig 4-25)")
|
||||||
|
print(f" single-address held {B.DMA_DISK_CLK_WORD_HELD} clk/B (one memory "
|
||||||
|
f"write; needs the device to ACK, i.e. a REQUEST LINE)")
|
||||||
|
if head_wb < floor:
|
||||||
|
print(f"""
|
||||||
|
SO DUAL ADDRESS DOES NOT FIT THIS CONTAINER AT {FPS:g} fps -- not at any
|
||||||
|
delivery rate and not at any GCR share, because {head_wb:.2f} < {floor}. A share
|
||||||
|
decides whether the channel sits AT the floor or above it; it cannot
|
||||||
|
go under it. That is FINDINGS 59.2's three bounds arriving in the
|
||||||
|
budget: the configurations this machine can run are exactly the ones
|
||||||
|
the frame cannot afford, and the one it can afford -- single address,
|
||||||
|
{B.DMA_DISK_CLK_WORD_HELD} clk/B, {100*vid_bpf*B.DMA_DISK_CLK_WORD_HELD/FRAME_CLK:.1f}% -- needs the request line ROADMAP B3 asks about.""")
|
||||||
|
for w, what in ((floor, "dual address"), (B.DMA_DISK_CLK_WORD_HELD, "single address")):
|
||||||
|
tgt = head_clk / w
|
||||||
|
print(f"\n TO FIT AT {w} clk/B ({what}) THIS CONTAINER MUST COME DOWN TO")
|
||||||
|
print(f" {tgt:,.0f} B a frame = {tgt*FPS/1024:,.0f} KB/s of payload "
|
||||||
|
f"(it is {vid_bpf:,.0f} B, {vid_bpf*FPS/1024:,.0f} KB/s)"
|
||||||
|
+ (" -- already met" if vid_bpf <= tgt else
|
||||||
|
f" -- {100*(vid_bpf/tgt-1):.0f}% too big"))
|
||||||
|
print(f"""
|
||||||
|
AND THAT IS THE PESSIMISTIC READING OF THE ENCODER LEVER: a lighter
|
||||||
|
container also DECODES cheaper, so the decode term above falls with
|
||||||
|
the byte term. The figure to re-derive it against is this tool run on
|
||||||
|
the lighter container -- with its OWN C68K measurement, because the
|
||||||
|
cross-check at the top is what licenses every number below it.""")
|
||||||
|
else:
|
||||||
|
print(f"\n The frame affords {head_wb:.2f} clk/B, which is at or above the "
|
||||||
|
f"{floor} clk/B dual-address floor.")
|
||||||
|
|
||||||
|
# --- AUTO-REQUEST, and only when a rate is supplied ------------------------
|
||||||
|
# These are the rows 59.3 added and they are the only ones here whose cost is
|
||||||
|
# not a property of the transfer: an auto-requested channel spends its share of
|
||||||
|
# the bus whether or not a byte is there, so what a record costs depends on how
|
||||||
|
# long it takes to ARRIVE. No default rate, deliberately (FINDINGS 50).
|
||||||
|
if a.kbps:
|
||||||
|
RATE = a.kbps * 1024.0
|
||||||
|
wire_clk = vid_bpf / RATE * CPUHZ
|
||||||
|
cap = 0.5 * CPUHZ / B.DMA_DUAL_BYTE_CLK # the 50% share's ceiling
|
||||||
|
print("\n" + "=" * 72)
|
||||||
|
print(f"AUTO-REQUEST AT {a.kbps:g} KB/s -- charged by TIME, not by byte "
|
||||||
|
f"(59.3)\n")
|
||||||
|
print(f" the record takes {wire_clk:,.0f} clk to arrive = "
|
||||||
|
f"{100*wire_clk/FRAME_CLK:.1f}% of a frame\n")
|
||||||
|
print(f" {'configuration':<34}{'clk/B':>8}{'% of frame':>12}"
|
||||||
|
f"{'CPU+audio+video':>18}")
|
||||||
|
rows = [("REQG 01, max rate (100% of the bus)", 1.0, None)]
|
||||||
|
for br, share in ((0, .5), (1, .25), (2, .125), (3, .0625)):
|
||||||
|
rows.append((f"REQG 00, LRAR BR={br:02b}, {share*100:g}% share", share,
|
||||||
|
share * CPUHZ / B.DMA_DUAL_BYTE_CLK))
|
||||||
|
for name, share, sustains in rows:
|
||||||
|
v = share * wire_clk
|
||||||
|
tot = (cpu_clk if cyc_t.any() else 0) + a_lo + v
|
||||||
|
flag = ""
|
||||||
|
if sustains is not None and sustains < RATE:
|
||||||
|
flag = f" cannot carry the rate ({sustains/1024:.0f} KB/s max)"
|
||||||
|
print(f" {name:<34}{v/vid_bpf:>8.2f}{100*v/FRAME_CLK:>11.1f}%"
|
||||||
|
f"{100*tot/FRAME_CLK:>17.1f}%{flag}")
|
||||||
|
print(f"""
|
||||||
|
A FASTER DISC MAKES AUTO-REQUEST CHEAPER, which no W does -- the share is
|
||||||
|
spent over a shorter wire time. But it cannot reach the floor: a 50% share
|
||||||
|
tops out at {cap/1024:,.0f} KB/s, above which the CHANNEL is the bottleneck and the
|
||||||
|
delivered rate falls back to it. At that ceiling the cost is exactly the
|
||||||
|
{B.DMA_DUAL_BYTE_CLK} clk/B floor, which is where the section above already put it.""")
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GATE for the DLX3 span container: does the reference decoder reproduce the
|
||||||
|
encoder's own reconstruction, from the emitted bytes?
|
||||||
|
|
||||||
|
python3 tools/analysis/16_span_roundtrip.py [frames_dir] --kbps <KB/s>
|
||||||
|
|
||||||
|
Exits non-zero if any frame differs by a single pixel.
|
||||||
|
|
||||||
|
WHY THIS EXISTS SEPARATELY FROM 09. `09_ratectl_drift.py` replays SKIP
|
||||||
|
semantics in Python against the mode maps the encoder returned; it never reads
|
||||||
|
a container. A span breaks exactly that shortcut: a spanned block reads SKIP
|
||||||
|
in the mode header and is painted by the span section instead, so a replay that
|
||||||
|
knows only about mode maps reports drift where there is none, and -- far worse
|
||||||
|
-- a container whose span section is malformed would still pass, because 09
|
||||||
|
never parses one. This gate closes that: encode, WRITE THE CONTAINER, read it
|
||||||
|
back with tools/encoder/dlx.py (the byte-for-byte reference decoder the 68000
|
||||||
|
is checked against), and compare to what ratectl recorded.
|
||||||
|
|
||||||
|
It also has to prove it tested something. A round-trip over a container with
|
||||||
|
no spans in it is green by vacuity, which is the failure mode FINDINGS 40.6
|
||||||
|
named for the snapshot count: a gate must take its expected work from the
|
||||||
|
generated artefact, not from an assumption. So the thresholds below are
|
||||||
|
asserted, not printed.
|
||||||
|
|
||||||
|
The `--kbps` default is the BUS rate, not the `scsi` profile's 280: spans are
|
||||||
|
bought with bytes, and 14_dmac_chain.py scores them against the delivery pipe.
|
||||||
|
At the profile rate the lam search has already spent the allowance and there is
|
||||||
|
nothing left to buy a span with -- which is a real finding about the encoder
|
||||||
|
(FINDINGS 41.2), not a reason for the gate to test nothing.
|
||||||
|
"""
|
||||||
|
import argparse, os, pickle, sys, time
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
import numpy as np
|
||||||
|
import vq_hybrid as H, ratectl as RC, encode as E
|
||||||
|
from dlx import DLX
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("frames_dir", nargs="?", default="tmp/fr_singe")
|
||||||
|
ap.add_argument("--kbps", type=float, required=True,
|
||||||
|
help="REQUIRED. There is no default: the delivery rate is a property of the medium and this project has never measured it. FINDINGS 42.1 -- the figure this tool used to default to was a user-supplied '4 Mbps' with no provenance, was a tenth of SCSI-1's asynchronous rating, and was never a bus measurement at all. A default let every table in FINDINGS 30-49 be scored against it without anyone restating it. Pass one explicitly.")
|
||||||
|
ap.add_argument("--out", default="tmp/s12_roundtrip")
|
||||||
|
ap.add_argument("--cache", default=None)
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
cache = a.cache or f"tmp/model_{os.path.basename(a.frames_dir.rstrip('/'))}.pkl"
|
||||||
|
# The cache is keyed on the frames directory ALONE, which was fine while
|
||||||
|
# H.build had no options and became a trap the moment it did: session 28's
|
||||||
|
# reserved black entry (23.4) changes the palette, the codebooks and every
|
||||||
|
# index in the model, and a pickle from before it would have let this gate
|
||||||
|
# round-trip a container the shipping encoder no longer emits -- green, and
|
||||||
|
# testing the wrong artefact. So the build parameters are stored WITH the
|
||||||
|
# model and a mismatch rebuilds.
|
||||||
|
SIG = dict(k1=256, k4=256, iters=16, reserve_black=True)
|
||||||
|
m = None
|
||||||
|
if os.path.exists(cache):
|
||||||
|
m = pickle.load(open(cache, "rb"))
|
||||||
|
if m.get("sig") != SIG:
|
||||||
|
print(f"{cache}: built with {m.get('sig')}, wanted {SIG} -- rebuilding")
|
||||||
|
m = None
|
||||||
|
else:
|
||||||
|
print(f"model from {cache}")
|
||||||
|
if m is None:
|
||||||
|
t = time.time()
|
||||||
|
m = H.build(a.frames_dir, **SIG)
|
||||||
|
m["sig"] = SIG
|
||||||
|
pickle.dump(m, open(cache, "wb"))
|
||||||
|
print(f"built model in {time.time()-t:.0f} s -> {cache}")
|
||||||
|
|
||||||
|
bad = 0
|
||||||
|
for span_mode in ("need", "all"):
|
||||||
|
print(f"\n=== spans={span_mode}, {a.kbps:g} KB/s ===")
|
||||||
|
m.pop("_sym", None)
|
||||||
|
enc = RC.encode_rate_controlled(m, target_kbps=a.kbps, lam_lo=1.0,
|
||||||
|
cycle_budget=RC.FRAME_CYCLES,
|
||||||
|
span_mode=span_mode)
|
||||||
|
recs = E.build_records(m, enc, span_mode)
|
||||||
|
path = f"{a.out}_{span_mode}.dlx"
|
||||||
|
total, vid, _ = E.write_container(path, m, recs, 12, m["k1"], m["k4"],
|
||||||
|
span_mode)
|
||||||
|
|
||||||
|
nsp = sum(len(x) for x in enc["spans"])
|
||||||
|
nfr = sum(1 for x in enc["spans"] if x)
|
||||||
|
px = sum(len(p) for x in enc["spans"] for _, _, p in x)
|
||||||
|
print(f"{path}: {total:,} B, {len(recs)} frames, "
|
||||||
|
f"{nsp:,} spans on {nfr} frames, {px:,} pixels painted by one "
|
||||||
|
f"({100*px/(len(recs)*m['H']*m['W']):.1f}% of all pixels)")
|
||||||
|
|
||||||
|
d = DLX(path)
|
||||||
|
if not d.has_spans:
|
||||||
|
print(f"FAIL: container is DLX{d.version}, which has no span section")
|
||||||
|
bad += 1; continue
|
||||||
|
# DLX4 adds the record index and DLX() cross-checks it against its own walk
|
||||||
|
# of the frame stream, so simply constructing it above has already gated
|
||||||
|
# that. Said out loud here because it is easy to read this as version drift.
|
||||||
|
if d.has_index:
|
||||||
|
print(f" DLX{d.version}: record index agrees with the frame stream on all "
|
||||||
|
f"{d.nframes} records ({2*d.nframes:,} B of scene header)")
|
||||||
|
|
||||||
|
# The decoder's own walk of the span section must land exactly where the
|
||||||
|
# block payload starts, and blocks() already raises if the payload does not
|
||||||
|
# consume the record -- so this reads the spans back through the same code
|
||||||
|
# path the 68000 is modelled on rather than trusting the writer.
|
||||||
|
got = d.decode_all()
|
||||||
|
diff = np.array([(g != r).sum() for g, r in zip(got, enc["recon"])])
|
||||||
|
print(f"pixels differing from the encoder's reconstruction: "
|
||||||
|
f"{diff.sum()} total, worst frame {diff.max()}, "
|
||||||
|
f"frames with any: {int((diff>0).sum())}/{len(diff)}")
|
||||||
|
if diff.sum():
|
||||||
|
f = int(np.argmax(diff))
|
||||||
|
ys, xs = np.where(got[f] != enc["recon"][f])
|
||||||
|
print(f"FAIL: frame {f} differs at {diff[f]} px, first (x={xs[0]}, "
|
||||||
|
f"y={ys[0]}), block (bx={xs[0]//4}, by={ys[0]//4}), "
|
||||||
|
f"mode there = {d.modes(f)[(ys[0]//4)*d.nbx + xs[0]//4]}")
|
||||||
|
bad += 1
|
||||||
|
|
||||||
|
# A green round-trip over a container with no spans in it proves nothing.
|
||||||
|
if span_mode == "all":
|
||||||
|
if nsp < 1000:
|
||||||
|
print(f"FAIL: only {nsp} spans emitted -- this gate did not "
|
||||||
|
f"exercise the span path"); bad += 1
|
||||||
|
if not (px and max(len(x) for x in enc["spans"]) > 50):
|
||||||
|
print(f"FAIL: no frame carries a substantial span table"); bad += 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
if bad:
|
||||||
|
print(f"FAILED: {bad} check(s)")
|
||||||
|
sys.exit(1)
|
||||||
|
print("OK the span container round-trips: the reference decoder rebuilds "
|
||||||
|
"the\n encoder's reconstruction exactly, from the emitted bytes.")
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""What do the spans the ENCODER actually emitted cost, and what do they buy?
|
||||||
|
|
||||||
|
python3 tools/analysis/17_span_delivered.py a.dlx [b.dlx ...] --bus <KB/s>
|
||||||
|
|
||||||
|
Every span figure before this one -- FINDINGS 29 through 40, and
|
||||||
|
tools/analysis/12 and 14 -- was scored by SIMULATING span selection over mode
|
||||||
|
maps that were chosen without spans available. FINDINGS 39.3 flagged that as a
|
||||||
|
lower bound on what a span-aware encoder would find, and docs/STATUS.md's item 2
|
||||||
|
asks for the figures to be re-run "against a container the encoder actually
|
||||||
|
emits with spans in it". This is that script: it reads the span section out of
|
||||||
|
a DLX3 container and prices exactly those spans, with no selection model at all.
|
||||||
|
|
||||||
|
THE MODEL IS 14_dmac_chain.py's, deliberately unchanged, so the columns are
|
||||||
|
comparable:
|
||||||
|
|
||||||
|
frame clocks = block decode + span painting + disk DMA
|
||||||
|
|
||||||
|
additive, because a 68000 has no cache and a two-word prefetch queue and stalls
|
||||||
|
the moment another master takes the bus (FINDINGS 38.3). Block cost is
|
||||||
|
vq_hybrid.cycles(), which reads a spanned block as SKIP -- correct, because the
|
||||||
|
span section is what paints it, and its cost is the second term.
|
||||||
|
|
||||||
|
The span term is the MEASURED v7 fit (FINDINGS 40), and as of session 12 that
|
||||||
|
fit is confirmed inside src/player/decode.s itself rather than only in
|
||||||
|
tools/bench/blit.s: the synthetic all-SPAN anchors of tools/bench/prep_dlx.py
|
||||||
|
reproduce it to 0.23% on both emulators (FINDINGS 41.3).
|
||||||
|
"""
|
||||||
|
import argparse, os, sys
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
sys.path.insert(0, "tools/analysis")
|
||||||
|
import numpy as np
|
||||||
|
import vq_hybrid as H
|
||||||
|
import spans as SP
|
||||||
|
import buscost as B
|
||||||
|
from dlx import DLX
|
||||||
|
|
||||||
|
FRAME_CYC = 833333.0
|
||||||
|
AUDIO_KBPS = 7.8
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("containers", nargs="+")
|
||||||
|
ap.add_argument("--bus", type=float, required=True,
|
||||||
|
help="REQUIRED. There is no default: the delivery rate is a property of the medium and this project has never measured it. FINDINGS 42.1 -- the figure this tool used to default to was a user-supplied '4 Mbps' with no provenance, was a tenth of SCSI-1's asynchronous rating, and was never a bus measurement at all. A default let every table in FINDINGS 30-49 be scored against it without anyone restating it. Pass one explicitly.")
|
||||||
|
ap.add_argument("--fps", type=float, default=12.0)
|
||||||
|
ap.add_argument("--disk-clk-byte", type=float, default=5.0,
|
||||||
|
help="clocks the SCSI DMA steals per BYTE delivered. The SPC is "
|
||||||
|
"an 8-bit port, so the DMAC pays per byte, not per word "
|
||||||
|
"(FINDINGS 43). 5, the default, is the OPTIMISTIC end and "
|
||||||
|
"what ratectl encodes against: single-address, bus held, no "
|
||||||
|
"drive wait (Fig 4-25 sheet 2). 9 is dual-address, which is "
|
||||||
|
"what MAME models and what applies if the board does not "
|
||||||
|
"drive DACK. Score both.")
|
||||||
|
ap.add_argument("--disk-clk-word", type=float, default=None,
|
||||||
|
help="DEPRECATED denominator of FINDINGS 39.7/42, kept so the "
|
||||||
|
"old tables reproduce: sets --disk-clk-byte to half this")
|
||||||
|
a = ap.parse_args()
|
||||||
|
if a.disk_clk_word is not None:
|
||||||
|
a.disk_clk_byte = a.disk_clk_word / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def score(path):
|
||||||
|
d = DLX(path)
|
||||||
|
rows = []
|
||||||
|
for f in range(d.nframes):
|
||||||
|
mode = d.modes(f)
|
||||||
|
sp, _ = d.spans(f)
|
||||||
|
_, n = d.frames[f]
|
||||||
|
blk = H.cycles(mode)
|
||||||
|
spc = sum(SP.clocks(len(p)) for _, _, p in sp)
|
||||||
|
disk = n * a.disk_clk_byte
|
||||||
|
rows.append((blk, spc, disk, n, len(sp),
|
||||||
|
sum(len(p) for _, _, p in sp)))
|
||||||
|
return d, np.array(rows).T
|
||||||
|
|
||||||
|
|
||||||
|
print(f"{'container':<34}{'KB/s':>8}{'spans':>9}{'span px':>9}"
|
||||||
|
f"{'median':>9}{'worst':>9}{'over':>9}")
|
||||||
|
print(f"{'':<34}{'':>8}{'/frame':>9}{'%':>9}"
|
||||||
|
f"{'% frame':>9}{'% frame':>9}{'budget':>9}")
|
||||||
|
for path in a.containers:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"{path:<34} missing"); continue
|
||||||
|
d, r = score(path)
|
||||||
|
blk, spc, disk, byt, nsp, spx = r
|
||||||
|
tot = blk + spc + disk
|
||||||
|
kbps = byt.mean() * a.fps / 1024 + AUDIO_KBPS
|
||||||
|
print(f"{os.path.basename(path):<34}{kbps:>8.1f}{nsp.mean():>9.0f}"
|
||||||
|
f"{100*spx.mean()/(d.W*d.H):>9.1f}"
|
||||||
|
f"{100*np.median(tot)/FRAME_CYC:>9.1f}"
|
||||||
|
f"{100*tot.max()/FRAME_CYC:>9.1f}"
|
||||||
|
f"{int((tot > FRAME_CYC).sum()):>6}/{d.nframes:<3}")
|
||||||
|
|
||||||
|
print(f"\n ADDITIVE: frame = block decode + span painting + disk DMA, the model"
|
||||||
|
f"\n of 14_dmac_chain.py. Disk debited at {a.disk_clk_byte:g} clocks/byte "
|
||||||
|
f"over the\n container's own byte count; CPU budget {FRAME_CYC:,.0f} "
|
||||||
|
f"clocks at {a.fps:g} fps.")
|
||||||
|
|
||||||
|
# The decomposition is the point: a span moves work out of the block loop and
|
||||||
|
# into the span section, and it pays for it in bytes -- which the disk term
|
||||||
|
# then charges back. A design that only counted the CPU would show a win that
|
||||||
|
# the I/O it created takes away again (docs/FINDINGS.md 33).
|
||||||
|
print(f"\nWHERE EACH FRAME'S CLOCKS GO, mean over the container")
|
||||||
|
print(f" {'container':<34}{'blocks':>12}{'spans':>12}{'disk':>12}{'total':>12}")
|
||||||
|
for path in a.containers:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
continue
|
||||||
|
d, r = score(path)
|
||||||
|
blk, spc, disk = r[0], r[1], r[2]
|
||||||
|
print(f" {os.path.basename(path):<34}{blk.mean():>12,.0f}{spc.mean():>12,.0f}"
|
||||||
|
f"{disk.mean():>12,.0f}{(blk+spc+disk).mean():>12,.0f}")
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""What 256 -> 16 colours actually costs, on real frames.
|
||||||
|
|
||||||
|
python3 tools/analysis/18_text_plane_16col.py [frames_dir]
|
||||||
|
|
||||||
|
FINDINGS 46.3 opened a lead and could not price it: the X68000 text plane is
|
||||||
|
4bpp planar -- 0.5 bytes/pixel against the graphics planes' 2.0 -- so a LITERAL
|
||||||
|
uncompressed 16-colour frame is 288.0 KB/s against the shipping compressed
|
||||||
|
256-colour container's 496.7 KB/s. 42% cheaper on the wire, with no decoder.
|
||||||
|
|
||||||
|
The whole lead turns on one number nobody had computed: the quality cost of 16
|
||||||
|
colours. This computes it, and it is deliberately generous to the 16-colour
|
||||||
|
side on every axis where the hardware allows it:
|
||||||
|
|
||||||
|
* PER-FRAME palettes are legitimate here. The text palette is 16 entries and
|
||||||
|
reloading it is 16 words a frame -- nothing, against a 833,333-clock budget.
|
||||||
|
The 256-colour path cannot do this: its palette is shared scene-wide
|
||||||
|
(vq.scene_palette) because the codec's codebooks are indices INTO it.
|
||||||
|
* DITHERING is free here, and only here. The tree does not dither (vq.py:32,
|
||||||
|
"cel art is flat") because dither destroys the inter-frame coherence SKIP
|
||||||
|
blocks and v7 spans are built on. A literal frame has no codec to wreck, so
|
||||||
|
Floyd-Steinberg is available to this path at zero runtime cost.
|
||||||
|
|
||||||
|
Both are measured, so the comparison cannot be accused of hobbling the option it
|
||||||
|
is testing. Reported against the 256-colour scene-palette ceiling (the tree's
|
||||||
|
existing "palette ceiling" figure) and against the shipping container's PSNR.
|
||||||
|
"""
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
import vq as VQ
|
||||||
|
|
||||||
|
FRAMES = sys.argv[1] if len(sys.argv) > 1 else "tmp/fr_singe"
|
||||||
|
SHIPPED_PSNR = 29.19 # docs/STATUS.md, --spans all, c=5, 496.7 KB/s
|
||||||
|
|
||||||
|
rgb = VQ.load_frames(FRAMES)
|
||||||
|
H, W = rgb[0].shape[:2]
|
||||||
|
n = len(rgb)
|
||||||
|
print(f"{FRAMES}: {n} frames, {W}x{H}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def recon_scene(colors, dither):
|
||||||
|
"""One palette for the whole scene -- what the 256 path is forced to do."""
|
||||||
|
d = Image.FLOYDSTEINBERG if dither else Image.NONE
|
||||||
|
samp = np.concatenate([r.reshape(-1, 3) for r in rgb[::3]])
|
||||||
|
ref = Image.fromarray(samp.reshape(-1, 1, 3)).quantize(
|
||||||
|
colors=colors, method=Image.MEDIANCUT, dither=Image.NONE)
|
||||||
|
pal = np.array(ref.getpalette()[:colors * 3], np.uint8).reshape(-1, 3)
|
||||||
|
return [pal[np.asarray(Image.fromarray(r).quantize(palette=ref, dither=d),
|
||||||
|
np.uint8)] for r in rgb]
|
||||||
|
|
||||||
|
def recon_perframe(colors, dither):
|
||||||
|
"""A fresh palette every frame -- what the text plane can afford."""
|
||||||
|
d = Image.FLOYDSTEINBERG if dither else Image.NONE
|
||||||
|
out = []
|
||||||
|
for r in rgb:
|
||||||
|
q = Image.fromarray(r).quantize(colors=colors, method=Image.MEDIANCUT,
|
||||||
|
dither=d)
|
||||||
|
pal = np.array(q.getpalette()[:colors * 3], np.uint8).reshape(-1, 3)
|
||||||
|
out.append(pal[np.asarray(q, np.uint8)])
|
||||||
|
return out
|
||||||
|
|
||||||
|
def report(name, recon):
|
||||||
|
per = np.array([VQ.psnr(a, b) for a, b in zip(rgb, recon)])
|
||||||
|
print(f" {name:<42s} {per.mean():6.2f} dB "
|
||||||
|
f"(min {per.min():5.2f} max {per.max():5.2f})")
|
||||||
|
return per.mean()
|
||||||
|
|
||||||
|
print("PSNR vs the 24-bit source, mean over frames:")
|
||||||
|
c256 = report("256 colours, scene palette [the tree's]", recon_scene(256, False))
|
||||||
|
report("256 colours, per-frame palette", recon_perframe(256, False))
|
||||||
|
print()
|
||||||
|
s16 = report("16 colours, scene palette", recon_scene(16, False))
|
||||||
|
p16 = report("16 colours, per-frame palette", recon_perframe(16, False))
|
||||||
|
p16d = report("16 colours, per-frame + FS dither", recon_perframe(16, True))
|
||||||
|
print()
|
||||||
|
print(f" the 16-colour ceiling is the best of those: {max(s16, p16, p16d):.2f} dB")
|
||||||
|
print(f" cost of 256 -> 16, at each side's best: "
|
||||||
|
f"{c256 - max(s16, p16, p16d):.2f} dB")
|
||||||
|
print()
|
||||||
|
print(f" for scale, the shipping container delivers {SHIPPED_PSNR:.2f} dB "
|
||||||
|
f"at 496.7 KB/s")
|
||||||
|
print(f" a 16-colour literal would deliver "
|
||||||
|
f"{max(s16, p16, p16d):.2f} dB at 288.0 KB/s")
|
||||||
|
delta = max(s16, p16, p16d) - SHIPPED_PSNR
|
||||||
|
print(f" so the text-plane path is {abs(delta):.2f} dB "
|
||||||
|
f"{'BETTER' if delta > 0 else 'WORSE'} at 58% of the bitrate")
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""Ring-buffer streaming simulation, against the CONTIGUITY constraint (STATUS 3/4).
|
||||||
|
|
||||||
|
09_buffer_sim.py asked one question -- does cumulative supply ever fall behind
|
||||||
|
cumulative demand -- and answered it in BYTES. FINDINGS 21 got "zero required
|
||||||
|
prefill" out of it at 110 and 280 KB/s. That test is necessary and not
|
||||||
|
sufficient, and the missing half is the whole of STATUS item 3:
|
||||||
|
|
||||||
|
src/player/decode.s reads a frame record with a MONOTONICALLY INCREASING a0
|
||||||
|
and no bounds check anywhere. `move.l (a0)+,d0` for the length, `lea
|
||||||
|
MODEB(a0),a0` for the span section, eleven unrolled `movem.l (a0)+` chains,
|
||||||
|
`move.b (a0)+` per block index. Nothing in it can survive an address that
|
||||||
|
wraps mid-record. So the buffer does not merely need ENOUGH BYTES resident
|
||||||
|
by the deadline -- it needs the WHOLE NEXT RECORD resident and CONTIGUOUS.
|
||||||
|
|
||||||
|
Having enough bytes and having them contiguous are different conditions, and a
|
||||||
|
byte-counting simulation cannot tell them apart. This one models the ring's
|
||||||
|
addresses, not just its occupancy.
|
||||||
|
|
||||||
|
THREE WRAP POLICIES, and the point of the tool is that they are not equivalent:
|
||||||
|
|
||||||
|
split the writer wraps mid-record; the reader cannot. Requires a SHADOW of
|
||||||
|
the ring's first MAXREC bytes mirrored past its end, so any record
|
||||||
|
start can be read linearly for MAXREC bytes. Every byte landing in
|
||||||
|
that first MAXREC is written twice. Costs 68000 CLOCKS, forever, at a
|
||||||
|
rate set by MAXREC/ring -- and those clocks come out of the same
|
||||||
|
budget the decoder is already spending 77.0% of (FINDINGS 45).
|
||||||
|
|
||||||
|
aligned the writer refuses to start a record it cannot finish before the end
|
||||||
|
of the ring; it leaves a hole and restarts at 0. Costs RAM (the mean
|
||||||
|
hole) and nothing else -- no copy, no per-byte work. Needs a frame
|
||||||
|
INDEX so the fill side knows record boundaries, which a branching
|
||||||
|
laserdisc game needs anyway to seek to a branch point.
|
||||||
|
|
||||||
|
none the decoder handles the wrap itself. Priced here only to show what it
|
||||||
|
would cost: a bounds test in the block loop is inside the sequence
|
||||||
|
FINDINGS 30.4/40 fitted, so it does not cost a branch -- it costs
|
||||||
|
every span and per-block constant in the tree being re-measured.
|
||||||
|
Not simulated; see the note printed at the end.
|
||||||
|
|
||||||
|
DEADLINE MODEL, and it is the conservative one: record i must be wholly
|
||||||
|
resident when frame i's decode BEGINS. The decoder in fact reads a record
|
||||||
|
progressively over ~77% of a frame time, so a byte arriving mid-frame would in
|
||||||
|
practice be in time -- but that is a race between the DMAC's fill address and
|
||||||
|
a0, and this tool refuses to certify a design on a race it cannot see.
|
||||||
|
|
||||||
|
Fill is quantised to 512-byte SCSI blocks: a partial sector is not resident.
|
||||||
|
|
||||||
|
python3 tools/analysis/19_ring_stream.py [container ...] --kbps R [--ring KB]
|
||||||
|
|
||||||
|
`--kbps` is REQUIRED and has no default -- see the argument's help text.
|
||||||
|
"""
|
||||||
|
import sys, os, argparse
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
import numpy as np
|
||||||
|
from dlx import DLX
|
||||||
|
import ratectl as RC
|
||||||
|
|
||||||
|
SECTOR = 512
|
||||||
|
# 5 clocks/byte for a 68000 `move.l (a0)+,(a1)+` copy: 20 clocks moves 4 bytes
|
||||||
|
# on a 16-bit bus (2 read + 2 write bus cycles at 4 clocks, plus the fetch it
|
||||||
|
# shares with the loop). Deliberately the OPTIMISTIC figure -- a movem-shaped
|
||||||
|
# copy is what the shadow would really use, and it is the same 5.0.
|
||||||
|
COPY_CLK_PER_BYTE = 5.0
|
||||||
|
CPUHZ = 10_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def records(path):
|
||||||
|
"""Padded record sizes, exactly as the 68000 walks them.
|
||||||
|
|
||||||
|
prep_dlx.py rounds each record START up to 4 (FINDINGS 28.3), so the bytes
|
||||||
|
the ring must hold per frame are the padded ones, not the payload.
|
||||||
|
"""
|
||||||
|
d = DLX(path)
|
||||||
|
rec = np.array(d.record_lengths(), np.int64)
|
||||||
|
return d, rec
|
||||||
|
|
||||||
|
|
||||||
|
def simulate(rec, fill_per_frame, ring, policy, maxrec):
|
||||||
|
"""Address-level ring simulation. Returns a dict of results.
|
||||||
|
|
||||||
|
The ring is modelled as a write cursor and a read cursor over `ring` bytes.
|
||||||
|
Supply arrives at `fill_per_frame` bytes per frame time, sector-quantised.
|
||||||
|
Record i is due at the start of frame i.
|
||||||
|
"""
|
||||||
|
n = len(rec)
|
||||||
|
resident = 0.0 # bytes fully arrived and not yet consumed
|
||||||
|
carry = 0.0 # sub-sector remainder of the fill
|
||||||
|
wcur = 0 # write cursor within the ring
|
||||||
|
holes = [] # bytes wasted per wrap, `aligned` policy
|
||||||
|
shadow_bytes = 0 # bytes double-written, `split` policy
|
||||||
|
occ = []
|
||||||
|
prefill = 0.0
|
||||||
|
late = []
|
||||||
|
free = ring
|
||||||
|
|
||||||
|
# Required prefill is solved rather than searched: run once with an infinite
|
||||||
|
# head start to find the worst deficit, exactly as 09_buffer_sim does, then
|
||||||
|
# assert the ring can hold it.
|
||||||
|
deficit = np.maximum.accumulate(np.cumsum(rec - fill_per_frame))
|
||||||
|
prefill = float(max(0.0, deficit.max()))
|
||||||
|
|
||||||
|
for i, r in enumerate(rec):
|
||||||
|
# --- supply for this frame time, sector-quantised
|
||||||
|
avail = carry + fill_per_frame
|
||||||
|
sectors = int(avail // SECTOR)
|
||||||
|
got = sectors * SECTOR
|
||||||
|
carry = avail - got
|
||||||
|
|
||||||
|
# --- placement: does this frame's arriving data cross the ring end?
|
||||||
|
if policy == "aligned":
|
||||||
|
# The writer will not start a record it cannot finish. Charge the
|
||||||
|
# hole when the NEXT record would not fit in the tail.
|
||||||
|
if wcur + r > ring:
|
||||||
|
holes.append(ring - wcur)
|
||||||
|
wcur = 0
|
||||||
|
wcur += r
|
||||||
|
else: # split
|
||||||
|
end = wcur + r
|
||||||
|
if end > ring:
|
||||||
|
wcur = end - ring
|
||||||
|
# every byte that landed in the first MAXREC of the ring is
|
||||||
|
# mirrored into the shadow
|
||||||
|
shadow_bytes += min(wcur, maxrec)
|
||||||
|
else:
|
||||||
|
wcur = end
|
||||||
|
if wcur <= maxrec:
|
||||||
|
shadow_bytes += r
|
||||||
|
elif wcur - r < maxrec:
|
||||||
|
shadow_bytes += maxrec - (wcur - r)
|
||||||
|
|
||||||
|
resident += got
|
||||||
|
if resident + 1e-9 < r:
|
||||||
|
late.append((i, float(r - resident)))
|
||||||
|
resident -= r
|
||||||
|
occ.append(resident)
|
||||||
|
|
||||||
|
hole_mean = float(np.mean(holes)) if holes else 0.0
|
||||||
|
usable = ring - hole_mean if policy == "aligned" else ring
|
||||||
|
copy_clk = shadow_bytes * COPY_CLK_PER_BYTE / max(1, n)
|
||||||
|
return dict(prefill=prefill, late=late, occ=np.array(occ),
|
||||||
|
holes=holes, hole_mean=hole_mean, usable=usable,
|
||||||
|
shadow_bytes=shadow_bytes, copy_clk_per_frame=copy_clk,
|
||||||
|
wraps=len(holes) if policy == "aligned" else None)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("containers", nargs="*",
|
||||||
|
default=["tmp/s14_d5_all1500.dlx",
|
||||||
|
"tmp/rc_fr_singe_scsi_span.dlx"])
|
||||||
|
ap.add_argument("--kbps", type=float, required=True,
|
||||||
|
help="delivered pipe, KB/s. REQUIRED, and deliberately has "
|
||||||
|
"no default: the delivery rate is a property of the "
|
||||||
|
"medium and this project has never measured it. The "
|
||||||
|
"figure that used to sit here was a user-supplied "
|
||||||
|
"'4 Mbps' with no provenance and was never a bus "
|
||||||
|
"measurement (FINDINGS 42.1); leaving it as a default "
|
||||||
|
"let table after table be scored against it without "
|
||||||
|
"anyone restating what it was.")
|
||||||
|
ap.add_argument("--ring", type=float, default=256.0,
|
||||||
|
help="ring size in KB (default 256, FINDINGS 21's sizing)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
FPS = 12
|
||||||
|
print(f"ring {a.ring:.0f} KB sector {SECTOR} B "
|
||||||
|
f"audio {RC.AUDIO_KBPS} KB/s debited from the pipe\n")
|
||||||
|
|
||||||
|
for path in a.containers:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"{path}: MISSING -- skipped\n"); continue
|
||||||
|
d, rec = records(path)
|
||||||
|
maxrec = int(rec.max())
|
||||||
|
ring = int(a.ring * 1024)
|
||||||
|
wire = rec.mean() * FPS / 1024 + RC.AUDIO_KBPS
|
||||||
|
|
||||||
|
print(f"=== {path}")
|
||||||
|
print(f" {d.nframes} frames @ {d.fps}fps, record bytes "
|
||||||
|
f"min {rec.min():,} median {int(np.median(rec)):,} max {maxrec:,}")
|
||||||
|
print(f" wire demand {wire:.1f} KB/s "
|
||||||
|
f"(video {rec.mean()*FPS/1024:.1f} + audio {RC.AUDIO_KBPS}), "
|
||||||
|
f"including the u32 length and the 4-byte record pad")
|
||||||
|
|
||||||
|
# A required prefill is only a startup cost if the window's MEAN demand
|
||||||
|
# is under the pipe. If the mean is over, the deficit grows for as long
|
||||||
|
# as the scene runs and the prefill this window reports is just how far
|
||||||
|
# it got in 120 frames -- no ring size fixes that, and quoting a KB
|
||||||
|
# figure for it would be the most flattering possible way to state a
|
||||||
|
# sustained overrun. FINDINGS 21's "zero prefill" never had to make
|
||||||
|
# this distinction because it ran far under the pipe it assumed.
|
||||||
|
if wire > a.kbps:
|
||||||
|
over = wire - a.kbps
|
||||||
|
print(f" !! SUSTAINED OVERRUN at the {a.kbps:.0f} KB/s pipe: "
|
||||||
|
f"demand exceeds supply by {over:.1f} KB/s on the MEAN, not "
|
||||||
|
f"on a burst.")
|
||||||
|
print(f" The deficit grows {over*1024/FPS:,.0f} B per frame "
|
||||||
|
f"for as long as the scene runs -- {over*1024*120/FPS/1024:.0f} "
|
||||||
|
f"KB over this 120-frame window, {over*60:.0f} KB per minute "
|
||||||
|
f"of play. Prefill below is where it got in 120 frames, NOT a "
|
||||||
|
f"startup cost that fixes it.")
|
||||||
|
|
||||||
|
if maxrec > ring:
|
||||||
|
print(f" !! MAXREC {maxrec:,} > ring {ring:,}: no policy works. "
|
||||||
|
f"decode.s needs one whole record contiguous.\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- the requirement on the medium, which is the useful output, and
|
||||||
|
# the reason this tool takes no default rate. There is no measured
|
||||||
|
# pipe figure to score against (42.1), and the intent is to measure
|
||||||
|
# a BlueSCSI directly -- so the tool reports the THRESHOLD to
|
||||||
|
# measure against. The sweep is anchored to the container's own
|
||||||
|
# wire demand rather than to a list of fixed rates, so it stays
|
||||||
|
# meaningful for any container and privileges no constant.
|
||||||
|
print(f" {'pipe KB/s':>10} {'vs wire':>8} {'prefill KB':>11} "
|
||||||
|
f"{'records':>8} {'seek slack':>11}")
|
||||||
|
for mult in (0.90, 0.95, 1.00, 1.02, 1.05, 1.10, 1.25, 1.50, 2.00):
|
||||||
|
kbps = wire * mult
|
||||||
|
fill = (kbps - RC.AUDIO_KBPS) * 1024 / FPS
|
||||||
|
r = simulate(rec, fill, ring, "aligned", maxrec)
|
||||||
|
pf = r["prefill"]
|
||||||
|
# Branch-point seek slack, STATICALLY: with the ring FULL, how many
|
||||||
|
# frame times can the fill be zero before the next record is not
|
||||||
|
# resident? It is an upper bound and it assumes the premise that
|
||||||
|
# FINDINGS 51.3 took apart -- the ring is NOT full at a branch
|
||||||
|
# point, it is empty, and refilling it takes seconds of play. For
|
||||||
|
# the measured figure use tools/analysis/20_seek_slack.py, or the
|
||||||
|
# rig itself (tools/bench/pace_run.sh). Kept here as the ceiling
|
||||||
|
# this container's record sizes allow, which is what the rest of
|
||||||
|
# this row is about.
|
||||||
|
slack = (r["usable"] - maxrec) / rec.mean()
|
||||||
|
flag = ""
|
||||||
|
if pf + maxrec > r["usable"]:
|
||||||
|
flag = " <- does not fit the ring"
|
||||||
|
print(f" {kbps:>10.1f} {mult:>7.2f}x {pf/1024:>11.1f} "
|
||||||
|
f"{pf/rec.mean():>8.2f} {slack:>8.1f} fr{flag}")
|
||||||
|
# smallest pipe needing zero prefill, to 0.1 KB/s
|
||||||
|
lo, hi = wire, wire + 400
|
||||||
|
for _ in range(40):
|
||||||
|
mid = (lo + hi) / 2
|
||||||
|
f = (mid - RC.AUDIO_KBPS) * 1024 / FPS
|
||||||
|
if simulate(rec, f, ring, "aligned", maxrec)["prefill"] > 0:
|
||||||
|
lo = mid
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
print(f" ZERO-PREFILL PIPE: {hi:.1f} KB/s "
|
||||||
|
f"({hi - wire:+.1f} KB/s over the wire demand, "
|
||||||
|
f"{100*hi/wire - 100:+.1f}%)")
|
||||||
|
print(f" ^ this is the number to measure a medium against. It is a "
|
||||||
|
f"REQUIREMENT, not a verdict.")
|
||||||
|
|
||||||
|
# --- the policy trade, at the default pipe
|
||||||
|
fill = (a.kbps - RC.AUDIO_KBPS) * 1024 / FPS
|
||||||
|
print(f" wrap policy, at pipe {a.kbps:.0f} KB/s:")
|
||||||
|
for policy in ("aligned", "split"):
|
||||||
|
r = simulate(rec, fill, ring, policy, maxrec)
|
||||||
|
if policy == "aligned":
|
||||||
|
print(f" aligned wraps {r['wraps']:3} mean hole "
|
||||||
|
f"{r['hole_mean']/1024:6.1f} KB usable ring "
|
||||||
|
f"{r['usable']/1024:6.1f} KB "
|
||||||
|
f"({100*r['usable']/ring:.1f}%) CPU cost 0")
|
||||||
|
else:
|
||||||
|
pct = 100 * r["copy_clk_per_frame"] / (CPUHZ / FPS)
|
||||||
|
print(f" split shadow {r['shadow_bytes']/1024:8.1f} KB "
|
||||||
|
f"= {r['copy_clk_per_frame']:8.0f} clk/frame = "
|
||||||
|
f"{pct:.2f}% of the frame budget, forever RAM cost 0")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("The `none` policy -- decoder wraps its own reads -- is not simulated.")
|
||||||
|
print("It has no RAM or copy cost and it is still the expensive one: the")
|
||||||
|
print("bounds test lands inside the exact instruction sequences FINDINGS")
|
||||||
|
print("30.4 and 40 fitted, so it does not cost a branch, it costs every span")
|
||||||
|
print("and per-block constant in the tree being re-measured. FINDINGS 28.3.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Seek slack: how long a branch point can stop delivery (STATUS 4, FINDINGS 51).
|
||||||
|
|
||||||
|
19_ring_stream.py asks whether a container ARRIVES in time, and prints one
|
||||||
|
"seek slack" column derived statically as (usable ring - maxrec)/mean record.
|
||||||
|
That is a capacity estimate and it quietly assumes the ring is full when the
|
||||||
|
seek happens. It is not, and the difference is the whole finding:
|
||||||
|
|
||||||
|
A ring's slack is ACCUMULATED, not owned. It is built out of the surplus
|
||||||
|
between the pipe and the wire demand, at (pipe - wire) bytes per second, and
|
||||||
|
a seek spends all of it. How long a branch point can stall is a property of
|
||||||
|
the ring; how soon the NEXT branch point can be afforded is a property of the
|
||||||
|
surplus, and a bigger ring makes that one WORSE.
|
||||||
|
|
||||||
|
This is the paced-rig model (tools/bench/stream.lua with DLX_PACE=1) written
|
||||||
|
independently, and it exists to be compared against it, not to replace it. The
|
||||||
|
rig drives a real 68000 through a real ring and is the measurement; this is the
|
||||||
|
cheap sweep that says where to point it. Where they disagree, the rig wins.
|
||||||
|
|
||||||
|
python3 tools/analysis/20_seek_slack.py [container ...] --kbps R [R ...]
|
||||||
|
[--ring KB [KB ...]]
|
||||||
|
|
||||||
|
`--kbps` is REQUIRED and takes no default, for the reason FINDINGS 50 gives.
|
||||||
|
"""
|
||||||
|
import sys, os, argparse
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
import numpy as np
|
||||||
|
from dlx import DLX
|
||||||
|
import ratectl as RC
|
||||||
|
|
||||||
|
SECTOR = 512
|
||||||
|
|
||||||
|
|
||||||
|
def records(path):
|
||||||
|
d = DLX(path)
|
||||||
|
rec = np.array(d.record_lengths(), np.int64)
|
||||||
|
return d, rec
|
||||||
|
|
||||||
|
|
||||||
|
def paced_sim(rec, ring, fill_per_frame, ticks_per_frame=8):
|
||||||
|
"""Paced-decoder ring sim. Returns TWO per-tick lookahead series.
|
||||||
|
|
||||||
|
THE ANSWER IS BRACKETED TO ONE RECORD AND IS NOT SHARPER THAN THAT. At
|
||||||
|
these rates the pipe delivers almost exactly one record per frame slot, so
|
||||||
|
"how many records are resident at slot i" depends on whether you look before
|
||||||
|
or after that slot's delivery -- and the two answers differ by one, every
|
||||||
|
time. Sampled after, this agreed with the rig's ceiling in 33 of 35 cells;
|
||||||
|
sampled before, it was exactly one record lower in 33 of 35. Neither is
|
||||||
|
wrong. Picking the one that matched would have been fitting the model to
|
||||||
|
the measurement and then reporting the agreement as a cross-check, so both
|
||||||
|
are returned and the caller prints the range. The rig sits at the top of it.
|
||||||
|
|
||||||
|
The producer is `aligned` (19_ring_stream.py): it will not start a record it
|
||||||
|
cannot finish before the end of the ring, and it will not place one over
|
||||||
|
bytes the decoder still owns. The decoder consumes exactly one record per
|
||||||
|
frame time and releases it whole.
|
||||||
|
|
||||||
|
Sub-stepping matters. Delivery and consumption interleave inside a frame
|
||||||
|
time on the rig -- the producer runs on MAME's machine-frame notifier, ~5x
|
||||||
|
per 12fps slot -- and a model that delivers a whole frame's bytes at once
|
||||||
|
can place a record into space the decoder has not released yet, or refuse
|
||||||
|
one it has. Eight sub-steps is well past the point the answer stops moving.
|
||||||
|
"""
|
||||||
|
n = len(rec)
|
||||||
|
live = [] # [idx, off, len] still owned by the decoder
|
||||||
|
wcur, nsent, credit = 0, 0, 0.0
|
||||||
|
lo, hi, ring_ref, rate_ref = [], [], 0, 0
|
||||||
|
|
||||||
|
def overlaps(off, ln):
|
||||||
|
return any(off < r[1] + r[2] and r[1] < off + ln for r in live)
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
if nsent < n:
|
||||||
|
lo.append(sum(1 for r in live if r[0] >= i))
|
||||||
|
for _ in range(ticks_per_frame):
|
||||||
|
credit += fill_per_frame / ticks_per_frame
|
||||||
|
while nsent < n:
|
||||||
|
r = int(rec[nsent])
|
||||||
|
if credit < r:
|
||||||
|
rate_ref += 1
|
||||||
|
break
|
||||||
|
w, hole = wcur, 0
|
||||||
|
if w + r > ring:
|
||||||
|
w, hole = 0, ring - wcur
|
||||||
|
if overlaps(w, r):
|
||||||
|
ring_ref += 1
|
||||||
|
break
|
||||||
|
# sector quantisation: a partial sector is not resident
|
||||||
|
credit -= r
|
||||||
|
live.append([nsent, w, r])
|
||||||
|
wcur, nsent = w + r, nsent + 1
|
||||||
|
if nsent < n:
|
||||||
|
hi.append(sum(1 for r in live if r[0] >= i))
|
||||||
|
# the decoder consumed record i during the slot and releases it whole
|
||||||
|
live = [r for r in live if r[0] > i]
|
||||||
|
return np.array(lo), np.array(hi), ring_ref, rate_ref
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("containers", nargs="*",
|
||||||
|
default=["tmp/rc_fr_singe_scsi_span.dlx"])
|
||||||
|
ap.add_argument("--kbps", type=float, nargs="+", required=True,
|
||||||
|
help="delivered pipe rates, KB/s. REQUIRED, no default "
|
||||||
|
"(FINDINGS 50): this project has never measured the "
|
||||||
|
"delivery pipe and a default is how the last unmeasured "
|
||||||
|
"one stayed load-bearing for five sessions.")
|
||||||
|
ap.add_argument("--ring", type=float, nargs="+",
|
||||||
|
default=[64, 96, 128, 192, 256, 384, 512])
|
||||||
|
a = ap.parse_args()
|
||||||
|
FPS = 12
|
||||||
|
|
||||||
|
for path in a.containers:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"{path}: MISSING -- skipped\n"); continue
|
||||||
|
d, rec = records(path)
|
||||||
|
wire = rec.mean() * FPS / 1024 + RC.AUDIO_KBPS
|
||||||
|
print(f"=== {path}: {d.nframes} frames @ {d.fps}fps, mean record "
|
||||||
|
f"{rec.mean()/1024:.1f} KB, wire {wire:.1f} KB/s")
|
||||||
|
print(f"{'ring KB':>8} {'pipe':>8} {'ceiling':>9} {'build s':>8} "
|
||||||
|
f"{'mean':>11} bound")
|
||||||
|
for ring_kb in a.ring:
|
||||||
|
ring = int(ring_kb * 1024)
|
||||||
|
if rec.max() > ring:
|
||||||
|
print(f"{ring_kb:>8.0f} maxrec {rec.max():,} does not fit")
|
||||||
|
continue
|
||||||
|
for kbps in a.kbps:
|
||||||
|
fill = ((kbps - RC.AUDIO_KBPS) * 1024 / FPS) if kbps > 0 else 1e12
|
||||||
|
lo, hi, ring_ref, rate_ref = paced_sim(rec, ring, fill)
|
||||||
|
c_lo, c_hi = int(lo.max()), int(hi.max())
|
||||||
|
build = int(np.argmax(hi >= c_hi)) if len(hi) else -1
|
||||||
|
print(f"{ring_kb:>8.0f} {kbps:>8.0f} "
|
||||||
|
f"{f'{c_lo}-{c_hi}':>9} {build/FPS:>8.2f} "
|
||||||
|
f"{f'{lo.mean():.1f}-{hi.mean():.1f}':>11} "
|
||||||
|
f"{'ring' if ring_ref else 'rate'}")
|
||||||
|
# The surplus model, stated so it can be checked against the sweep
|
||||||
|
# above rather than believed: slack accrues at (pipe - wire) and a
|
||||||
|
# full ring holds `ceiling` records, so a branch point costs about
|
||||||
|
# ceiling*mean_record/(pipe - wire) seconds of play to earn back.
|
||||||
|
print()
|
||||||
|
print("Slack is accumulated, not owned. A bigger ring raises the ceiling AND")
|
||||||
|
print("lengthens the climb to it: the surplus (pipe - wire) is what fills it,")
|
||||||
|
print("and that is set by the encoder and the medium, not by the buffer.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""What the X68000's own ROM programs into the DMAC -- read out of the bytes.
|
||||||
|
|
||||||
|
python3 tools/analysis/21_iplrom_dmac.py [iplrom.dat]
|
||||||
|
|
||||||
|
FINDINGS 48.4 / ROADMAP B3 left the single-address vs dual-address question
|
||||||
|
open for the disk, priced it at 242 KB/s and 0.69 dB, and blocked it on
|
||||||
|
sourcing `scsiexrom.bin` so its DMAC init could be disassembled. The same
|
||||||
|
question was open for AUDIO and nobody had asked it: ROADMAP P6 budgets ADPCM
|
||||||
|
at 7.8 KB/s and `11_cpu_budget.py` charges those bytes the DISK's per-byte
|
||||||
|
rate, which is a guess about a channel whose configuration was never read.
|
||||||
|
|
||||||
|
It does not have to be a guess. **The IPL ROM is on this machine** -- MAME runs
|
||||||
|
the player rig with `-bios ipl10` -- and it programs all four HD63450 channels
|
||||||
|
itself. This script reads the configuration straight out of the ROM image and
|
||||||
|
decodes the MC68450 register fields, so every claim below is a byte at a named
|
||||||
|
address rather than a recollection about a chip.
|
||||||
|
|
||||||
|
It is a GATE, not a report: each piece of evidence is (address, expected bytes,
|
||||||
|
what it means), and a mismatch exits non-zero. If a different ROM revision is
|
||||||
|
pointed at it, it says so instead of quietly decoding something else.
|
||||||
|
|
||||||
|
SOURCED for the field layouts: MC68450 Direct Memory Access Controller,
|
||||||
|
Motorola, Jul 1989 (bitsavers) -- the same document FINDINGS 39 already cites
|
||||||
|
for the transfer timings in tools/analysis/buscost.py.
|
||||||
|
|
||||||
|
NOTE THE LAYER: this is the ROM's own choice of configuration, read from the
|
||||||
|
shipping image. It is not a measurement of a running machine, and it is not
|
||||||
|
proof that a different configuration is impossible -- our player programs these
|
||||||
|
registers itself. It is evidence about what Sharp's engineers could get the
|
||||||
|
board to do, from the vendor, for these exact devices.
|
||||||
|
"""
|
||||||
|
import sys, os, argparse, hashlib
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
BASE = 0xFE0000 # where the IPL ROM is mapped (and its 0xFF0000 alias)
|
||||||
|
|
||||||
|
# The image this was decoded against. A different revision is a different
|
||||||
|
# machine's answer, so it is named rather than assumed.
|
||||||
|
KNOWN = {
|
||||||
|
"7fd4caabac1d9169e289f0f7bbf71d8e":
|
||||||
|
"IPL 1.0 (MAME x68000 -bios ipl10), 131,072 B",
|
||||||
|
}
|
||||||
|
|
||||||
|
from mc68450 import REG, XRM, DTYP, DPS, PCL, SIZE, CHAIN, REQG, dcr, ocr, scr
|
||||||
|
|
||||||
|
# --- the evidence ----------------------------------------------------------
|
||||||
|
# (address, expected bytes, one-line description). Every register value quoted
|
||||||
|
# anywhere below comes out of one of these; nothing is typed in twice.
|
||||||
|
EV = [
|
||||||
|
(0xFF0BEA, "49f900e84080197c00080004197c0005",
|
||||||
|
"boot: lea $E84080,a4 (ch2) ; DCR=$08 ; SCR=$05..."),
|
||||||
|
(0xFF0C2E, "49f900e840c0197c00800004197c00040006197c00050029197c0001002d"
|
||||||
|
"197c00050031197c00050039297c00e92003",
|
||||||
|
"boot: lea $E840C0,a4 (ch3, ADPCM) ; DCR=$80 SCR=$04 MFC=$05 CPR=$01 "
|
||||||
|
"DFC=$05 BFC=$05 DAR=$E92003"),
|
||||||
|
(0xFF0D8E, "0480060429052d0031054480460469056d027105",
|
||||||
|
"boot: the ch0/ch1 init TABLE, ten (offset,value) pairs, written by the "
|
||||||
|
"loop at $FF0CD8"),
|
||||||
|
(0xFF0CE4, "217c00e940030014217c00e960010054",
|
||||||
|
"boot: DAR ch0 = $E94003 (FDC data) ; DAR ch1 = $E96001 (SASI data)"),
|
||||||
|
(0xFF9A82, "13fc003200e840c5610a13fc000200e920014e75",
|
||||||
|
"IOCS ADPCM PLAY: OCR(ch3) = $32 ; then command $02 to $E92001"),
|
||||||
|
(0xFF9A5E, "13fc00b200e840c5612e13fc000400e920014e75",
|
||||||
|
"IOCS ADPCM RECORD: OCR(ch3) = $B2 ; then command $04 to $E92001"),
|
||||||
|
(0xFF9A96, "13fc00ff00e840c023c900e840cc33c200e840ca",
|
||||||
|
"IOCS ADPCM arm: CSR=$FF ; MAR = a1 ; MTC = d2 (DCR/SCR untouched)"),
|
||||||
|
(0xFF9944, "13fc00ff00e8404013fc00b200e84045601013fc00ff00e8404013fc003200"
|
||||||
|
"e8404523c900e8404c33c300e8404a13fc008000e840474e75",
|
||||||
|
"IOCS SASI: OCR(ch1) = $B2 read / $32 write ; MAR ; MTC ; CCR = $80"),
|
||||||
|
]
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("rom", nargs="?",
|
||||||
|
default=os.path.expanduser("~/mame/roms/iplrom.dat"))
|
||||||
|
a = ap.parse_args()
|
||||||
|
if not os.path.exists(a.rom):
|
||||||
|
sys.exit(f"missing {a.rom} -- point this at the IPL ROM MAME boots the rig "
|
||||||
|
f"with (-bios ipl10).")
|
||||||
|
d = open(a.rom, "rb").read()
|
||||||
|
md5 = hashlib.md5(d).hexdigest()
|
||||||
|
print(f"{a.rom}: {len(d):,} B, md5 {md5}")
|
||||||
|
if md5 in KNOWN:
|
||||||
|
print(f" {KNOWN[md5]}\n")
|
||||||
|
else:
|
||||||
|
sys.exit(f"\nUNKNOWN ROM. Every field decoded below was read out of\n"
|
||||||
|
f" {list(KNOWN.values())[0]}\n"
|
||||||
|
f"and a different revision is a different machine's answer, not a "
|
||||||
|
f"detail. Add its\nmd5 to KNOWN only after re-reading the sites -- "
|
||||||
|
f"the addresses are revision-specific.")
|
||||||
|
|
||||||
|
print("EVIDENCE -- each line is bytes at an address, not a recollection")
|
||||||
|
bad = 0
|
||||||
|
for addr, hx, what in EV:
|
||||||
|
want = bytes.fromhex(hx)
|
||||||
|
got = d[addr - BASE: addr - BASE + len(want)]
|
||||||
|
ok = got == want
|
||||||
|
bad += not ok
|
||||||
|
print(f" {'OK ' if ok else 'FAIL'} ${addr:06X} {what}")
|
||||||
|
if not ok:
|
||||||
|
print(f" expected {want.hex()}\n got {got.hex()}")
|
||||||
|
if bad:
|
||||||
|
sys.exit(f"\nFAIL: {bad} evidence site(s) do not hold. The decode below "
|
||||||
|
"would be about\nsome other code, so it is not printed.")
|
||||||
|
|
||||||
|
# The ch0/ch1 table, decoded from the bytes rather than restated.
|
||||||
|
tbl = d[0xFF0D8E - BASE: 0xFF0D8E - BASE + 20]
|
||||||
|
init = {}
|
||||||
|
for i in range(0, len(tbl), 2):
|
||||||
|
off, val = tbl[i], tbl[i + 1]
|
||||||
|
init[(off >> 6, off & 0x3F)] = val
|
||||||
|
init[(2, 0x04)] = 0x08 # from the inline moves at $FF0BEA
|
||||||
|
init[(2, 0x06)] = 0x05
|
||||||
|
init[(2, 0x2D)] = 0x03
|
||||||
|
init[(3, 0x04)] = 0x80 # ...and at $FF0C2E
|
||||||
|
init[(3, 0x06)] = 0x04
|
||||||
|
init[(3, 0x2D)] = 0x01
|
||||||
|
|
||||||
|
DEV = {0: ("FDC", "$E94003"), 1: ("SASI", "$E96001"),
|
||||||
|
2: ("IOCS _DMAMOVE (general purpose)", "set per call"),
|
||||||
|
3: ("ADPCM MSM6258V", "$E92003")}
|
||||||
|
print("\nWHAT THE ROM PROGRAMS, per channel")
|
||||||
|
for ch in range(4):
|
||||||
|
name, dar = DEV[ch]
|
||||||
|
print(f"\n ch{ch} base $E840{ch*0x40:02X} {name} DAR = {dar}")
|
||||||
|
v = init[(ch, 0x04)]
|
||||||
|
print(f" DCR = ${v:02X}")
|
||||||
|
for line in dcr(v):
|
||||||
|
print(f" {line}")
|
||||||
|
v = init[(ch, 0x06)]
|
||||||
|
print(f" SCR = ${v:02X} " + " ; ".join(scr(v)))
|
||||||
|
print(f" CPR = ${init[(ch,0x2D)]:02X} channel priority "
|
||||||
|
f"({init[(ch,0x2D)]}, 0 = highest)")
|
||||||
|
|
||||||
|
print("\nAND THE PER-TRANSFER OCR, written every time a transfer is armed")
|
||||||
|
for label, ch, v in (("ADPCM playback", 3, 0x32), ("ADPCM record", 3, 0xB2),
|
||||||
|
("SASI write", 1, 0x32), ("SASI read", 1, 0xB2)):
|
||||||
|
print(f"\n {label:<15} ch{ch} OCR = ${v:02X}")
|
||||||
|
for line in ocr(v):
|
||||||
|
print(f" {line}")
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
WHAT THIS SETTLES
|
||||||
|
|
||||||
|
1. AUDIO IS DUAL ADDRESS, AND IT CANNOT HOLD THE BUS. ch3 DCR = $80: DTYP =
|
||||||
|
00, explicitly addressed, so every ADPCM byte is a MEMORY READ FOLLOWED BY A
|
||||||
|
DEVICE WRITE -- not the single-address 5 clocks the disk debit is written in.
|
||||||
|
XRM = 10 is cycle steal WITHOUT hold and OCR REQG = 10 is external request,
|
||||||
|
so the DMAC arbitrates for the bus ONCE PER BYTE and gives it straight back.
|
||||||
|
There is no burst to amortise the arbitration over.
|
||||||
|
|
||||||
|
2. THE PORT IS 8 BITS AND THE OPERAND IS A BYTE. DCR DPS = 0, OCR SIZE = 11.
|
||||||
|
One MSM6258V byte is two 4-bit samples, so 15.6 kHz is 7,812.5 BYTES/s and
|
||||||
|
7,812.5 DMA REQUESTS/s -- the request count does not halve the way a 16-bit
|
||||||
|
port's would. That is the FINDINGS 43 unit trap, in the other stream.
|
||||||
|
|
||||||
|
3. THE DISK CHANNEL IS PROGRAMMED IDENTICALLY, AND THAT IS THE BIGGER NEWS.
|
||||||
|
ch1 (SASI, DAR = $E96001) gets DCR = $80 and OCR = $B2 -- dual address,
|
||||||
|
8-bit port, cycle steal WITHOUT hold, external request. Byte by byte, with a
|
||||||
|
full arbitration each time, exactly like the audio. ch0 (FDC) too. Sharp
|
||||||
|
programs every explicitly-addressed 8-bit device on this board the same way.
|
||||||
|
|
||||||
|
This is not scsiexrom.bin and it does not close ROADMAP B3 -- a different
|
||||||
|
ROM drives a different SPC. But it is the same vendor, the same DMAC and the
|
||||||
|
same class of device, and it lands on the EXPENSIVE side of B3's 242 KB/s.
|
||||||
|
|
||||||
|
4. AND IT IS OUTSIDE THE BRACKET THE PROJECT HAS BEEN COSTING P4 IN.
|
||||||
|
FINDINGS 42.4-42.6 brackets W, the clocks stolen per delivered byte, at
|
||||||
|
5..12, and reports that W <= 6 fits 0/120 frames while W = 8 misses 47/120.
|
||||||
|
The ROM's own disk configuration costs 16..19. It is still true that the
|
||||||
|
player programs these registers itself and the choice is ours (42.6) -- but
|
||||||
|
the only worked example on the machine sits ABOVE the whole bracket, and
|
||||||
|
nothing in this tree has yet shown that a cheaper configuration is reachable
|
||||||
|
for an explicitly-addressed port. Treat W <= 12 as a REQUIREMENT ON THE
|
||||||
|
PLAYER'S DMAC PROGRAMMING, not as a range the hardware hands us.
|
||||||
|
|
||||||
|
5. AUDIO OUTRANKS THE DISK AT THE ARBITER. CPR: FDC 0, ADPCM 1, SASI 2,
|
||||||
|
_DMAMOVE 3, lower being higher priority. When both channels want the bus in
|
||||||
|
the same slot, the ROM's arrangement serves ADPCM first. An audio byte is
|
||||||
|
never the thing that waits; a video byte is.
|
||||||
|
""")
|
||||||
|
|
||||||
|
# --- what it costs ---------------------------------------------------------
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import buscost as B
|
||||||
|
|
||||||
|
ADPCM_HZ = 15625.0 # 8 MHz MSM6258V clock / 512
|
||||||
|
ADPCM_BPS = ADPCM_HZ / 2 # 4-bit samples, two to a byte
|
||||||
|
FPS, CPUHZ = 12.0, 10e6
|
||||||
|
lo = B.DMA_DUAL_BYTE_CLK + B.DMA_FRONT_CLK + B.DMA_BACK_CLK
|
||||||
|
hi = B.DMA_DUAL_BYTE_CLK + B.DMA_FRONT_CLK_WORST + B.DMA_BACK_CLK
|
||||||
|
bpf = ADPCM_BPS / FPS
|
||||||
|
print(f"WHAT IT COSTS, at the configuration above\n"
|
||||||
|
f" 15.6 kHz mono = {ADPCM_HZ:,.0f} samples/s = {ADPCM_BPS:,.1f} B/s "
|
||||||
|
f"= {ADPCM_BPS/1024:.2f} KiB/s\n"
|
||||||
|
f" (ratectl.AUDIO_KBPS is 7.8, which is this figure in DECIMAL kB; "
|
||||||
|
f"as KiB it is {ADPCM_BPS/1024:.2f})\n"
|
||||||
|
f" dual-address byte transfer {B.DMA_DUAL_BYTE_CLK} clk "
|
||||||
|
f"(read {B.DMA_READ_CLK} + write {B.DMA_WRITE_CLK}, Fig 4-25 sheet 4 note 2)\n"
|
||||||
|
f" + arbitration, EVERY byte {B.DMA_FRONT_CLK}..{B.DMA_FRONT_CLK_WORST}"
|
||||||
|
f" front + {B.DMA_BACK_CLK} back (sect 4.5.2.1/4.5.2.2)\n"
|
||||||
|
f" = {lo}..{hi} clocks per audio byte\n\n"
|
||||||
|
f" per frame at {FPS:g} fps: {bpf:,.1f} B costs {bpf*lo:,.0f}..{bpf*hi:,.0f} "
|
||||||
|
f"clocks of {CPUHZ/FPS:,.0f}\n"
|
||||||
|
f" = {100*bpf*lo/(CPUHZ/FPS):.2f}%..{100*bpf*hi/(CPUHZ/FPS):.2f}% of the "
|
||||||
|
f"frame, stolen from the 68000\n\n"
|
||||||
|
f" 11_cpu_budget.py charges audio --dma-clocks-per-byte, default 5, "
|
||||||
|
f"described\n as 'single-address, bus held, no drive wait'. The ROM says "
|
||||||
|
f"audio is neither\n single-address nor able to hold the bus, so that "
|
||||||
|
f"debit is {lo/5:.1f}x..{hi/5:.1f}x too small.\n"
|
||||||
|
f" In absolute terms it is small -- but it is small IN THE RESOURCE THE "
|
||||||
|
f"PROJECT IS\n SHORT OF, and it was being taken from the wrong side of "
|
||||||
|
f"an open question.")
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""What a scene change costs, now that the loader runs on the 68000 (FINDINGS 53).
|
||||||
|
|
||||||
|
python3 tools/analysis/22_scene_load.py [container ...] --kbps R [R ...]
|
||||||
|
|
||||||
|
ROADMAP P1 asked for the codebook expansion to be priced "against the refill
|
||||||
|
climb, not treated as free setup", and that is the whole job of this file. A
|
||||||
|
scene change is the one moment where every cost in this project lands at once:
|
||||||
|
the ring is empty because of the seek, the header has to arrive before a single
|
||||||
|
frame can be drawn, and the 68000 cannot decode anything until it has expanded
|
||||||
|
the codebooks out of that header.
|
||||||
|
|
||||||
|
THREE COSTS, IN THREE DIFFERENT UNITS, and they are not interchangeable:
|
||||||
|
|
||||||
|
* BYTES. The container's header region -- palette, CB1, CB4 -- must be
|
||||||
|
delivered before frame 0 can be decoded. It is not part of any frame
|
||||||
|
record, so no rate table in this tree has ever counted it.
|
||||||
|
* CLOCKS. What src/player/load.i costs to turn that header into what the
|
||||||
|
block loop reads, MEASURED on the emulated 68000 by tools/bench/load.lua
|
||||||
|
and parsed out of its log rather than copied in here as a constant.
|
||||||
|
* ACCUMULATED SLACK. The bytes above are bytes the pipe did not spend
|
||||||
|
filling the ring, so they cost play-time at the surplus rate (pipe - wire),
|
||||||
|
which is the currency FINDINGS 51.3 established a branch point spends.
|
||||||
|
This is the one that compounds: it is charged on top of the seek itself.
|
||||||
|
|
||||||
|
`--kbps` is REQUIRED and takes no default, for the reason FINDINGS 50 gives.
|
||||||
|
Rates are decimal-KB per the rest of the tree's tooling; sizes are KiB.
|
||||||
|
"""
|
||||||
|
import sys, os, re, argparse
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
import numpy as np
|
||||||
|
from dlx import DLX
|
||||||
|
import ratectl as RC
|
||||||
|
|
||||||
|
FPS = 12
|
||||||
|
CPUHZ = 10_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def rig_cycles(path):
|
||||||
|
"""The measured per-stage cost, out of tools/bench/load.lua's own log.
|
||||||
|
|
||||||
|
Parsed rather than pasted: a constant copied in here would go stale the
|
||||||
|
first time load.i changed, and it would go stale SILENTLY -- the arithmetic
|
||||||
|
below would keep working and keep being wrong.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
sys.exit(f"no rig log at {path} -- run tools/bench/load_run.sh first")
|
||||||
|
out = {}
|
||||||
|
for line in open(path, "rb").read().decode("utf-8", "replace").splitlines():
|
||||||
|
m = re.search(r"^\[LOD\]\s+(\S.*?)\s{2,}(\d+) cyc", line)
|
||||||
|
if m:
|
||||||
|
out[m.group(1).strip()] = int(m.group(2))
|
||||||
|
need = ("SCENE CHANGE: codebooks + palette", "scratch tables only (boot, once)")
|
||||||
|
for k in need:
|
||||||
|
if k not in out:
|
||||||
|
sys.exit(f"{path} has no '{k}' line -- is it a load.lua summary?")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("containers", nargs="*", default=["tmp/rc_fr_singe_scsi_span.dlx"])
|
||||||
|
ap.add_argument("--kbps", type=float, nargs="+", required=True,
|
||||||
|
help="delivered pipe rates, KB/s. REQUIRED, no default (FINDINGS 50)")
|
||||||
|
ap.add_argument("--log", default="tmp/load_check.log",
|
||||||
|
help="tools/bench/load.lua's log, for the measured cycle counts")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
cyc = rig_cycles(a.log)
|
||||||
|
scene_cyc = cyc["SCENE CHANGE: codebooks + palette"]
|
||||||
|
boot_cyc = cyc["scratch tables only (boot, once)"]
|
||||||
|
frame_cyc = CPUHZ / FPS
|
||||||
|
|
||||||
|
print(f"measured on the emulated 68000 ({a.log}):")
|
||||||
|
print(f" per scene change {scene_cyc:>8,} clocks = {1000*scene_cyc/CPUHZ:6.2f} ms "
|
||||||
|
f"= {100*scene_cyc/frame_cyc:.1f}% of one {FPS}fps frame")
|
||||||
|
print(f" once at boot {boot_cyc:>8,} clocks = {1000*boot_cyc/CPUHZ:6.2f} ms "
|
||||||
|
f" (the three scratch tables: scene-independent)")
|
||||||
|
|
||||||
|
for path in a.containers:
|
||||||
|
d = DLX(path)
|
||||||
|
hdr = int.from_bytes(d.raw[28:32], "big")
|
||||||
|
rec = np.array(d.record_lengths(), np.int64)
|
||||||
|
wire = rec.mean() * FPS / 1024 + RC.AUDIO_KBPS
|
||||||
|
print(f"\n=== {path}: header region {hdr:,} B "
|
||||||
|
f"(pal 768 + cb1 {d.k1*16:,} + cb4 {d.k4*4:,} + 32), wire {wire:.1f} KB/s")
|
||||||
|
print(f"{'pipe':>6} {'header ms':>10} {'+load ms':>9} {'total':>7} "
|
||||||
|
f"{'frames':>7} {'surplus':>9} {'slack s':>9}")
|
||||||
|
for kbps in a.kbps:
|
||||||
|
hdr_ms = 1000 * hdr / (kbps * 1024)
|
||||||
|
load_ms = 1000 * scene_cyc / CPUHZ
|
||||||
|
total = hdr_ms + load_ms
|
||||||
|
surplus = kbps - wire
|
||||||
|
# What the header costs in the currency of 51.3: play-time at the
|
||||||
|
# surplus rate. A negative surplus means the container does not fit the
|
||||||
|
# pipe at all and no amount of play buys the bytes back.
|
||||||
|
slack = f"{hdr/(surplus*1024):8.3f}" if surplus > 0 else " NEVER"
|
||||||
|
print(f"{kbps:>6.0f} {hdr_ms:>10.2f} {load_ms:>9.2f} {total:>7.2f} "
|
||||||
|
f"{total/(1000/FPS):>7.2f} {surplus:>9.1f} {slack:>9}")
|
||||||
|
|
||||||
|
print("""
|
||||||
|
Reading it. The 'frames' column is the scene change's FIXED cost in 12fps
|
||||||
|
frame slots, before the ring has been given a single frame of lookahead -- so it
|
||||||
|
is a floor under the black gap at a branch point, not the gap itself. The
|
||||||
|
'slack s' column is the one that compounds with FINDINGS 51.3: the header's
|
||||||
|
bytes are bytes that did not go into the ring, so they lengthen the climb back
|
||||||
|
to the seek-slack ceiling by that much play-time, every time.""")
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""What a real frame clock can be built from, and what its cadence costs.
|
||||||
|
|
||||||
|
python3 tools/analysis/23_frame_clock.py [--fps 12] [--vtotal 568]
|
||||||
|
[--csv tmp/c68k_frames.csv]
|
||||||
|
|
||||||
|
ROADMAP P3 says "needs MFP timer or VBL", which hides the fact that ONE OF
|
||||||
|
THOSE CANNOT DO IT and the other cannot do it either without a divider. This
|
||||||
|
file enumerates the space rather than asserting a conclusion, the way FINDINGS
|
||||||
|
47 had to be re-done once a hardware "no" turned out to be a claim about a whole
|
||||||
|
configuration space nobody had walked.
|
||||||
|
|
||||||
|
EVERY CONSTANT HERE IS SOURCED, and from a file on this machine:
|
||||||
|
|
||||||
|
MFP timer clock 16 MHz / 4 MAME 0.277 sharp/x68k.cpp:1027-1028
|
||||||
|
prescaler ladder 4,10,16,50,64,100,200 machine/mc68901.cpp:173
|
||||||
|
timer data reg 8 bits, 0 means 256 machine/mc68901.cpp (TCDR/TADR)
|
||||||
|
V-DISP -> GPIP4 x68k.cpp:1139, and it is also Timer A's event input,
|
||||||
|
mc68901.cpp:167 GPIO_TIMER = {GPIP_4, GPIP_3}
|
||||||
|
line rate 31,500 Hz exactly in both 31.5 kHz modes, derived in
|
||||||
|
tools/bench/crtc_mode.lua from the dot clocks
|
||||||
|
interrupt cost MEASURED, not tabled: tools/bench/clock_run.sh
|
||||||
|
|
||||||
|
THE PART THAT IS NOT A CLOCK PROBLEM AT ALL. 12 fps on a 55.4577 Hz raster is
|
||||||
|
4.6215 refreshes per frame, so every frame is shown for 4 refreshes or 5 --
|
||||||
|
72.13 ms or 90.16 ms -- and 37.9% of them get the short one. That is the
|
||||||
|
display's quantisation and no choice of clock changes it. What it changes is
|
||||||
|
the BUDGET: 833,333 clocks is the mean slot, not the slot, and the short slot is
|
||||||
|
721,270. With the per-frame decode costs in hand this file says exactly how
|
||||||
|
many frames do not fit theirs, which is a thing this project has never had to
|
||||||
|
ask because until now the tick came from a host that could not miss.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
MFP_HZ = 16_000_000 // 4 # x68k.cpp:1027-1028
|
||||||
|
PRESCALER = [4, 10, 16, 50, 64, 100, 200] # mc68901.cpp:173
|
||||||
|
HFREQ = 31500 # lines/s, crtc_mode.lua
|
||||||
|
CPUHZ = 10_000_000 # 40 MHz / 4, x68k.cpp:1133
|
||||||
|
|
||||||
|
|
||||||
|
def timer_space(fps):
|
||||||
|
"""Every (prescale, data) the MFP can be set to, against a target fps."""
|
||||||
|
slowest = MFP_HZ / (PRESCALER[-1] * 256)
|
||||||
|
print(f"\n=== 1. THE MFP TIMER, WHICH CANNOT DO IT ALONE")
|
||||||
|
print(f" timer clock {MFP_HZ:,} Hz, prescalers {PRESCALER}, data 1..256")
|
||||||
|
print(f" slowest tick any single timer can produce: "
|
||||||
|
f"{MFP_HZ}/({PRESCALER[-1]}*256) = {slowest:.3f} Hz")
|
||||||
|
print(f" a {fps} fps frame needs {fps} Hz, which is {slowest/fps:.1f}x "
|
||||||
|
f"slower than that -- so a software divider is REQUIRED whatever the "
|
||||||
|
f"source, and 'use an MFP timer' is not by itself an answer.")
|
||||||
|
exact = [(p, d) for p in PRESCALER for d in range(1, 257)
|
||||||
|
if (MFP_HZ * d * p) and (MFP_HZ % (p * d) == 0)
|
||||||
|
and (MFP_HZ // (p * d)) % fps == 0]
|
||||||
|
print(f" settings whose tick rate is a whole multiple of {fps} Hz, so that "
|
||||||
|
f"a plain counter would be exact: {len(exact)}")
|
||||||
|
if exact:
|
||||||
|
best = min(exact, key=lambda pd: MFP_HZ / (pd[0] * pd[1]))
|
||||||
|
p, d = best
|
||||||
|
tick = MFP_HZ / (p * d)
|
||||||
|
print(f" slowest of them: prescale /{p} data {d} = {tick:.4f} Hz, "
|
||||||
|
f"{tick/fps:.0f} ticks per frame")
|
||||||
|
print(f" -> {tick/fps:.0f} interrupts per frame, against 4.6215 for "
|
||||||
|
f"the raster: {tick/fps/(HFREQ/(fps*568)):.1f}x the cost, and its "
|
||||||
|
f"phase against the raster is arbitrary, so a frame would be "
|
||||||
|
f"presented mid-scan.")
|
||||||
|
else:
|
||||||
|
print(f" NONE. {MFP_HZ}/{fps} = {MFP_HZ/fps:,.2f} is not an "
|
||||||
|
f"integer, so no prescale/data pair divides to {fps} Hz at all.")
|
||||||
|
|
||||||
|
|
||||||
|
def raster_space(fps, vtotal):
|
||||||
|
hz = HFREQ / vtotal
|
||||||
|
print(f"\n=== 2. THE RASTER, WHICH IS THE RIGHT SOURCE AND IS ALSO NOT "
|
||||||
|
f"A WHOLE DIVIDE")
|
||||||
|
print(f" V-DISP is {HFREQ}/{vtotal} = {hz:.4f} Hz, and it is BOTH the "
|
||||||
|
f"GPIP4 interrupt and Timer A's event-count input")
|
||||||
|
print(f" whole divides -- all the MFP can do in hardware, no software:")
|
||||||
|
for n in (3, 4, 5, 6):
|
||||||
|
f = hz / n
|
||||||
|
print(f" Timer A event count = {n}: {f:7.4f} fps "
|
||||||
|
f"({100*(f/fps-1):+6.2f}% from {fps})")
|
||||||
|
print(f" {fps} fps needs {hz/fps:.4f} refreshes per frame, which is not a "
|
||||||
|
f"whole number, so no event-count setting is exact either.")
|
||||||
|
print(f"\n THE DIVIDER THAT IS EXACT: add fps*VTOTAL = {fps*vtotal} per "
|
||||||
|
f"V-DISP, emit a tick at {HFREQ}, keep the remainder.")
|
||||||
|
print(f" long-run rate = {fps}*{vtotal}/{vtotal} = {fps} fps EXACTLY, "
|
||||||
|
f"with a remainder that never accumulates")
|
||||||
|
print(f" the accumulator stays under {HFREQ + fps*vtotal:,}, so it is "
|
||||||
|
f"16-bit arithmetic on a 68000 (the ceiling is fps < "
|
||||||
|
f"{(65536-HFREQ)/vtotal:.1f} at this VTOTAL, and clk_init checks it)")
|
||||||
|
|
||||||
|
|
||||||
|
def divider_gaps(fps, vtotal, n):
|
||||||
|
"""The tick sequence src/player/clock.i emits, in refreshes per tick."""
|
||||||
|
acc, gaps, since = 0, [], 0
|
||||||
|
while len(gaps) < n:
|
||||||
|
acc += fps * vtotal
|
||||||
|
since += 1
|
||||||
|
if acc >= HFREQ:
|
||||||
|
acc -= HFREQ
|
||||||
|
gaps.append(since)
|
||||||
|
since = 0
|
||||||
|
return gaps
|
||||||
|
|
||||||
|
|
||||||
|
def host_gaps(fps, refresh_hz, n):
|
||||||
|
"""The tick sequence tools/bench/stream.lua's HOST clock emits.
|
||||||
|
|
||||||
|
It looks uniform in the source -- `floor((t - t_rel) * fps)` -- and is not.
|
||||||
|
Lua only sees the machine at frame boundaries, so tick k lands on the first
|
||||||
|
refresh at or after k/fps, and the gaps between ticks come out as the same
|
||||||
|
two whole numbers of refreshes the divider produces. The host-paced runs of
|
||||||
|
FINDINGS 49 and 51 therefore already had this cadence in them; what ROADMAP
|
||||||
|
P3 changes is who produces it, not whether it exists.
|
||||||
|
"""
|
||||||
|
at = [-(-int(k * refresh_hz * 1000000 // fps) // 1000000) for k in range(n + 1)]
|
||||||
|
return [at[k + 1] - at[k] for k in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def cadence(fps, vtotal, costs, label, refresh_hz, gaps, uniform=False):
|
||||||
|
"""Charge each frame the slot it really gets, and run the pace gate."""
|
||||||
|
rpf = 1.0 if uniform else HFREQ / (fps * vtotal)
|
||||||
|
lo, hi = (1, 1) if uniform else (int(rpf), int(rpf) + 1)
|
||||||
|
slot_lo = lo / refresh_hz * CPUHZ
|
||||||
|
slot_hi = hi / refresh_hz * CPUHZ
|
||||||
|
nominal = CPUHZ / fps
|
||||||
|
|
||||||
|
n_lo = sum(1 for g in gaps[:len(costs)] if g == lo)
|
||||||
|
print(f"\n --- {label}: refresh {refresh_hz:.4f} Hz")
|
||||||
|
print(f" slots are {lo} refreshes = {slot_lo:,.0f} clk "
|
||||||
|
f"({1000*lo/refresh_hz:.2f} ms) or {hi} = {slot_hi:,.0f} clk "
|
||||||
|
f"({1000*hi/refresh_hz:.2f} ms)")
|
||||||
|
print(f" the nominal {fps} fps budget every figure in this project is "
|
||||||
|
f"priced against is {nominal:,.0f} clk; the SHORT slot is "
|
||||||
|
f"{100*(slot_lo/nominal-1):+.1f}% of it")
|
||||||
|
over_lo = sum(1 for c in costs if c > slot_lo)
|
||||||
|
over_hi = sum(1 for c in costs if c > slot_hi)
|
||||||
|
over_nom = sum(1 for c in costs if c > nominal)
|
||||||
|
print(f" frames that do not fit: {over_lo}/{len(costs)} the short "
|
||||||
|
f"slot, {over_hi}/{len(costs)} the long one, {over_nom}/{len(costs)} "
|
||||||
|
f"the nominal budget")
|
||||||
|
|
||||||
|
# The schedule, with the catch-up the pace gate actually performs: frame i
|
||||||
|
# starts at max(finish of i-1, tick i). A frame that overruns does not fail
|
||||||
|
# -- it eats the next frame's idle, and the clock catches up by itself.
|
||||||
|
t, tick, late, worst, first = 0.0, 0.0, 0, 0.0, None
|
||||||
|
for i, c in enumerate(costs):
|
||||||
|
if t <= tick:
|
||||||
|
t = tick # idled: on time
|
||||||
|
else:
|
||||||
|
if i: # frame 0 has no predecessor
|
||||||
|
late += 1
|
||||||
|
if first is None:
|
||||||
|
first = i
|
||||||
|
worst = max(worst, t - tick)
|
||||||
|
t += c
|
||||||
|
tick += gaps[i] / refresh_hz * CPUHZ
|
||||||
|
print(f" through the pace gate: {late}/{len(costs)} frames found "
|
||||||
|
f"their slot already open (first at frame {first}), worst start "
|
||||||
|
f"{worst:,.0f} clk = {1000*worst/CPUHZ:.1f} ms behind its tick")
|
||||||
|
if not uniform:
|
||||||
|
print(f" {n_lo}/{len(costs)} slots were the short one "
|
||||||
|
f"({100*n_lo/len(costs):.1f}%; the exact share is "
|
||||||
|
f"{100*(hi-rpf):.1f}%)")
|
||||||
|
return late
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--fps", type=int, default=12)
|
||||||
|
ap.add_argument("--vtotal", type=int, default=568,
|
||||||
|
help="CRTC R04+1; 568 is the 31.5 kHz 256-line mode")
|
||||||
|
ap.add_argument("--csv", default="tmp/c68k_frames.csv",
|
||||||
|
help="per-frame decode cost, from tools/bench/c68k/run.sh")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
timer_space(a.fps)
|
||||||
|
raster_space(a.fps, a.vtotal)
|
||||||
|
|
||||||
|
print(f"\n=== 3. WHAT THE CADENCE COSTS")
|
||||||
|
if not os.path.exists(a.csv):
|
||||||
|
print(f" {a.csv} not found -- run tools/bench/c68k/run.sh first. The "
|
||||||
|
f"cadence question CANNOT be answered from percentiles: it needs "
|
||||||
|
f"the per-frame series, because what matters is whether an "
|
||||||
|
f"expensive frame lands in a short slot and how long the catch-up "
|
||||||
|
f"takes afterwards.")
|
||||||
|
return 1
|
||||||
|
costs = [float(r["cycles"]) for r in csv.DictReader(open(a.csv))]
|
||||||
|
print(f" {len(costs)} frames from {a.csv}: mean {sum(costs)/len(costs):,.0f} "
|
||||||
|
f"clk, max {max(costs):,.0f} (frame {costs.index(max(costs))})")
|
||||||
|
|
||||||
|
hw = HFREQ / a.vtotal
|
||||||
|
n = len(costs)
|
||||||
|
# MAME's screen is fast by htotal/(htotal-8): refresh_mode() builds the
|
||||||
|
# frame period from scr.max_x*scr.max_y with scr.max_x = m_htotal - 8. Both
|
||||||
|
# rasters are run, because the rig measures against the fast one and the
|
||||||
|
# player will run on the other -- reporting only one would leave the rig's
|
||||||
|
# count and this file's differing with nobody able to say which was wrong.
|
||||||
|
htotal = 368
|
||||||
|
mame = hw * htotal / (htotal - 8)
|
||||||
|
|
||||||
|
# The budget model every figure in FINDINGS assumes: a slot of exactly
|
||||||
|
# 1/fps. No machine has this; it is the yardstick, run through the same
|
||||||
|
# schedule so that what the cadence ADDS can be read off.
|
||||||
|
cadence(a.fps, a.vtotal, costs, "the NOMINAL model (a slot of exactly "
|
||||||
|
"1/fps, which no raster produces)", float(a.fps),
|
||||||
|
[1] * (n + 1), uniform=True)
|
||||||
|
cadence(a.fps, a.vtotal, costs, "the HOST tick, as tools/bench/stream.lua "
|
||||||
|
"actually emits it", mame, host_gaps(a.fps, mame, n + 1))
|
||||||
|
cadence(a.fps, a.vtotal, costs, "the 68000's own clock on the HARDWARE "
|
||||||
|
"raster", hw, divider_gaps(a.fps, a.vtotal, n + 1))
|
||||||
|
cadence(a.fps, a.vtotal, costs, f"the 68000's own clock on MAME's raster "
|
||||||
|
f"(fast by {htotal}/{htotal-8})", mame,
|
||||||
|
divider_gaps(a.fps, a.vtotal, n + 1))
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
Reading it.
|
||||||
|
|
||||||
|
THE CADENCE WAS ALREADY THERE. The nominal row is the model every budget in
|
||||||
|
this project is priced against -- a slot of exactly 1/fps -- and no raster
|
||||||
|
produces it. The host row is what tools/bench/stream.lua has been emitting all
|
||||||
|
along: `floor((t - t_rel) * fps)` looks uniform, but Lua only sees the machine at
|
||||||
|
frame boundaries, so its ticks land on refreshes and its gaps are the same two
|
||||||
|
whole numbers. The host-paced results of FINDINGS 49 and 51 therefore already
|
||||||
|
carried a 4/5 cadence that nothing named. ROADMAP P3 did not introduce it; it
|
||||||
|
moved who produces it onto the machine, where it belongs, and made it visible.
|
||||||
|
|
||||||
|
THE SHORT SLOT IS REAL AND IT IS NOT A FAILURE. {sum(1 for c in costs if c > 721270)}/{len(costs)} frames do not fit
|
||||||
|
721,270 clocks. The pace gate only says "not before tick i", so a frame that
|
||||||
|
overruns spends the next frame's idle and the clock recovers by itself; the cost
|
||||||
|
is one frame presented a refresh late, not a dropped frame. What the counts
|
||||||
|
above measure is frames with no idle left, and the difference between the
|
||||||
|
nominal row and the raster rows -- 1 against 4 -- is the whole price of the
|
||||||
|
cadence on this container.
|
||||||
|
|
||||||
|
THE EXPENSIVE FRAME IS FRAME 0, at {max(costs)/(CPUHZ/a.fps)*100:.0f}% of the nominal budget: the first
|
||||||
|
frame of a scene has nothing to SKIP against, so it is the whole picture in one
|
||||||
|
slot. Most of what follows it in these counts is that transient draining, which
|
||||||
|
is why the first index is printed next to the total. It also means the cost is
|
||||||
|
paid AT A SCENE CHANGE, alongside the 18.96 ms of loader (FINDINGS 53.2) and the
|
||||||
|
seek -- not spread over the window.
|
||||||
|
|
||||||
|
SCOPE. Decode costs are C68K's, on zero-wait-state memory, so they are a lower
|
||||||
|
bound; real DRAM moves every row here in the same direction. The MAME rows are
|
||||||
|
the emulator's fast raster and exist to be compared with tools/bench/pace_run.sh,
|
||||||
|
not to describe hardware.""")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""What the player's own fill loop costs the pipe (ROADMAP P5, FINDINGS 55).
|
||||||
|
|
||||||
|
19_ring_stream.py and 20_seek_slack.py model a ring whose producer is free to
|
||||||
|
act whenever it likes: bytes arrive at a rate and the only questions are where
|
||||||
|
they go and whether the ring can hold them. That is what a HOST-filled ring is,
|
||||||
|
and it is what every delivery figure in FINDINGS 49 and 51 was measured on.
|
||||||
|
|
||||||
|
A player has no host. The 68000 owns the ring (src/player/ring.i), and it can
|
||||||
|
only act when it is not decoding -- which turns the producer into a consumer of
|
||||||
|
the same resource the decoder is short of, and puts an idle CHANNEL between
|
||||||
|
every pair of records:
|
||||||
|
|
||||||
|
a transfer ends -> the disc has nothing to do -> the CPU next polls
|
||||||
|
-> it issues -> the disc starts again
|
||||||
|
|
||||||
|
The gap in the middle is bytes the medium could have delivered and did not, and
|
||||||
|
no rate table in this tree contains it. Its size is set by the PLAYER: how many
|
||||||
|
requests it may have outstanding (a DMAC channel takes one at a time; two slots
|
||||||
|
mean the next is already queued when the current lands), and when it polls.
|
||||||
|
|
||||||
|
This is that model, written from record sizes and per-frame decode costs, and
|
||||||
|
sharing no code with the Lua rig it is compared against -- the same arrangement
|
||||||
|
as 49.4 and 51.5. The rig drives a real 68000 through a real ring and is the
|
||||||
|
measurement; this says where to point it and what to expect.
|
||||||
|
|
||||||
|
python3 tools/analysis/24_ring_owner.py <container.dlx> --kbps R [R ...]
|
||||||
|
[--ring KB] [--qdepth N [N ...]] [--prefill RECORDS]
|
||||||
|
[--cadence raster|nominal]
|
||||||
|
|
||||||
|
`--kbps` is REQUIRED and has no default, for the reason FINDINGS 50 gives.
|
||||||
|
|
||||||
|
THE INDEX IS READ FROM THE CONTAINER, not derived by walking it. A DLX4
|
||||||
|
container carries nframes u16 record lengths in its scene header precisely
|
||||||
|
because the producer needs the length of a record it has not fetched; this model
|
||||||
|
reads the same table the 68000 does, so a container whose index disagreed with
|
||||||
|
its stream would be caught here as well as in dlx.py's constructor.
|
||||||
|
"""
|
||||||
|
import sys, os, argparse
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
import numpy as np
|
||||||
|
from dlx import DLX
|
||||||
|
import vq_hybrid as H
|
||||||
|
import spans as SP
|
||||||
|
|
||||||
|
CPUHZ = 10_000_000
|
||||||
|
HFREQ = 31500 # lines/s in the 31.5 kHz modes (src/player/clock.i)
|
||||||
|
VTOTAL = 568 # CRTC R04+1 in the 256x256 mode (tools/bench/crtc_mode.lua)
|
||||||
|
CLK_ISR = 181.35 # clocks per V-DISP, MEASURED (FINDINGS 54.3)
|
||||||
|
AUDIO_KBPS = 7.8 # ratectl.AUDIO_KBPS; the pipe carries it too
|
||||||
|
|
||||||
|
|
||||||
|
def frame_costs(d):
|
||||||
|
"""Per-frame decode cost in 68000 clocks: blocks plus v7 spans.
|
||||||
|
|
||||||
|
Both halves come from the encoder's own measured constants -- H.cycles is
|
||||||
|
the single source 11_cpu_budget.py uses, and SP.clocks is the v7 fit of
|
||||||
|
FINDINGS 40 -- so this is the same cost model the rate controller fits `mu`
|
||||||
|
against, applied to the emitted container rather than to a candidate.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for f in range(d.nframes):
|
||||||
|
c = H.cycles(d.modes(f))
|
||||||
|
for _, _, px in d.spans(f)[0]:
|
||||||
|
c += SP.clocks(len(px))
|
||||||
|
out.append(c)
|
||||||
|
return np.array(out)
|
||||||
|
|
||||||
|
|
||||||
|
def ticks(n, fps):
|
||||||
|
"""Frame tick times from src/player/clock.i's divider, or a nominal clock.
|
||||||
|
|
||||||
|
The player's clock is the raster with a remainder: a frame gets 4 refreshes
|
||||||
|
(72.13 ms) or 5 (90.16 ms) and there is no 83.33 ms frame (FINDINGS 54.4).
|
||||||
|
A model that hands out uniform slots gives every frame 13.4% more time than
|
||||||
|
the short one really has, so the cadence is reproduced here rather than
|
||||||
|
averaged away.
|
||||||
|
"""
|
||||||
|
R = VTOTAL / HFREQ # one refresh, seconds
|
||||||
|
acc, out, t = 0, [0.0], 0.0
|
||||||
|
while len(out) < n:
|
||||||
|
t += R
|
||||||
|
acc += fps * VTOTAL
|
||||||
|
if acc >= HFREQ:
|
||||||
|
acc -= HFREQ
|
||||||
|
out.append(t)
|
||||||
|
return np.array(out)
|
||||||
|
|
||||||
|
|
||||||
|
def simulate(rec, dec, tick, bps, ringsz, qdepth, prefill):
|
||||||
|
"""One pass of the machine-owned ring. Returns a dict of instruments.
|
||||||
|
|
||||||
|
The rules are src/player/ring.i's, stated as events:
|
||||||
|
* the CPU polls whenever it is NOT decoding -- the pace wait and the
|
||||||
|
record wait both call ring_poll, and nothing else in the frame does;
|
||||||
|
* a request occupies a slot until it is RETIRED, which happens at a poll,
|
||||||
|
so the queue is measured against retirement and not against completion;
|
||||||
|
* placement is `aligned`: a record that will not fit before the end of the
|
||||||
|
ring restarts at the base, and only if the base is free;
|
||||||
|
* the channel serves one transfer at a time, in order.
|
||||||
|
"""
|
||||||
|
n = len(rec)
|
||||||
|
# ring state, in ring offsets
|
||||||
|
wcur = rcur = 0
|
||||||
|
rq = 0 # next record to request
|
||||||
|
retired = 0 # requests retired (== FR_HEAD)
|
||||||
|
consumed = 0 # records the decoder has finished (== FR_TAIL)
|
||||||
|
inflight = [] # [(record, done_time)] in issue order
|
||||||
|
chan_free = 0.0 # when the channel finishes what it has
|
||||||
|
gaps, gap_tot, gap_max = 0, 0.0, 0.0
|
||||||
|
busy = 0.0
|
||||||
|
full_refusals = 0
|
||||||
|
started = False # the first transfer has no gap before it
|
||||||
|
|
||||||
|
def live_empty():
|
||||||
|
return rq == consumed
|
||||||
|
|
||||||
|
def place(length):
|
||||||
|
"""Where the next record goes: (offset, hole) or None if it cannot."""
|
||||||
|
nonlocal full_refusals
|
||||||
|
if live_empty():
|
||||||
|
if wcur + length <= ringsz:
|
||||||
|
return wcur, 0
|
||||||
|
return 0, ringsz - wcur
|
||||||
|
if rcur == wcur:
|
||||||
|
return None # completely full
|
||||||
|
if rcur < wcur: # free is [wcur, SZ) then [0, rcur)
|
||||||
|
if wcur + length <= ringsz:
|
||||||
|
return wcur, 0
|
||||||
|
if length <= rcur:
|
||||||
|
return 0, ringsz - wcur
|
||||||
|
return None
|
||||||
|
if wcur + length <= rcur: # live wraps; free is [wcur, rcur)
|
||||||
|
return wcur, 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
def issue(now):
|
||||||
|
"""Issue as many requests as the queue and the ring allow, at `now`."""
|
||||||
|
nonlocal wcur, rcur, rq, chan_free, gaps, gap_tot, gap_max, busy
|
||||||
|
nonlocal full_refusals, started
|
||||||
|
while rq < n and (rq - retired) < qdepth:
|
||||||
|
p = place(rec[rq])
|
||||||
|
if p is None:
|
||||||
|
full_refusals += 1
|
||||||
|
return
|
||||||
|
off, _hole = p
|
||||||
|
if live_empty():
|
||||||
|
rcur = off
|
||||||
|
start = max(now, chan_free)
|
||||||
|
if started:
|
||||||
|
g = start - chan_free
|
||||||
|
if g > 1e-12:
|
||||||
|
gaps += 1
|
||||||
|
gap_tot += g
|
||||||
|
gap_max = max(gap_max, g)
|
||||||
|
started = True
|
||||||
|
dur = rec[rq] / bps
|
||||||
|
busy += dur
|
||||||
|
chan_free = start + dur
|
||||||
|
inflight.append((rq, chan_free))
|
||||||
|
wcur = off + rec[rq]
|
||||||
|
rq += 1
|
||||||
|
|
||||||
|
def retire(now):
|
||||||
|
"""Publish every transfer that has landed by `now`. In order."""
|
||||||
|
nonlocal retired
|
||||||
|
while inflight and inflight[0][1] <= now:
|
||||||
|
inflight.pop(0)
|
||||||
|
retired += 1
|
||||||
|
|
||||||
|
def advance_reader():
|
||||||
|
"""Step rcur over the records the decoder has finished with."""
|
||||||
|
nonlocal rcur
|
||||||
|
i = consumed_seen[0]
|
||||||
|
while i < consumed:
|
||||||
|
end = rcur + rec[i]
|
||||||
|
if i + 1 < n and end + rec[i + 1] > ringsz:
|
||||||
|
end = 0
|
||||||
|
rcur = end
|
||||||
|
i += 1
|
||||||
|
consumed_seen[0] = i
|
||||||
|
|
||||||
|
consumed_seen = [0]
|
||||||
|
|
||||||
|
# ---- prefill. The decoder is not running, so the CPU polls continuously
|
||||||
|
# and the channel never waits for it: this is the one part of a scene where
|
||||||
|
# the request loop costs nothing.
|
||||||
|
now = 0.0
|
||||||
|
while retired < prefill and rq < n:
|
||||||
|
issue(now)
|
||||||
|
if not inflight:
|
||||||
|
break
|
||||||
|
now = inflight[0][1]
|
||||||
|
retire(now)
|
||||||
|
prefill_done = now
|
||||||
|
t0 = now
|
||||||
|
|
||||||
|
underruns, worst_late, noidle = 0, 0.0, 0
|
||||||
|
slack_series = []
|
||||||
|
for i in range(n):
|
||||||
|
deadline = t0 + tick[i]
|
||||||
|
# TWO WAYS A FRAME CAN START LATE, AND THEY ARE NOT THE SAME FAILURE.
|
||||||
|
# The decoder reaches the record wait at max(its own finish, the tick):
|
||||||
|
# if it got there after the tick, the PREVIOUS frame used its whole slot
|
||||||
|
# and this is the CPU (54.4's cadence). If it got there on time and the
|
||||||
|
# record was not resident, that is the PIPE. The rig counts them
|
||||||
|
# separately -- NO IDLE and UNDERRUNS -- so conflating them here would
|
||||||
|
# have made the model disagree with it for a reason that is not about
|
||||||
|
# delivery at all.
|
||||||
|
if now > deadline + 1e-9:
|
||||||
|
noidle += 1
|
||||||
|
arrive = max(now, deadline)
|
||||||
|
now = arrive
|
||||||
|
# the record wait: the CPU polls, so it retires and issues while it waits
|
||||||
|
starved = retired <= i
|
||||||
|
while retired <= i:
|
||||||
|
issue(now)
|
||||||
|
if not inflight:
|
||||||
|
break
|
||||||
|
now = max(now, inflight[0][1])
|
||||||
|
retire(now)
|
||||||
|
if starved:
|
||||||
|
underruns += 1
|
||||||
|
worst_late = max(worst_late, now - arrive)
|
||||||
|
slack_series.append(retired - consumed)
|
||||||
|
issue(now)
|
||||||
|
# ---- decode. No polls: whatever the channel finishes now waits.
|
||||||
|
now += dec[i] / CPUHZ
|
||||||
|
consumed += 1
|
||||||
|
advance_reader()
|
||||||
|
retire(now)
|
||||||
|
issue(now)
|
||||||
|
# ---- idle until the next tick. The CPU polls throughout, so every
|
||||||
|
# completion is retired and every free slot is refilled at once.
|
||||||
|
nxt = t0 + tick[i + 1] if i + 1 < n else now
|
||||||
|
while inflight and inflight[0][1] < nxt:
|
||||||
|
now = max(now, inflight[0][1])
|
||||||
|
retire(now)
|
||||||
|
issue(now)
|
||||||
|
now = max(now, min(nxt, now))
|
||||||
|
span = max(now - t0, 1e-9)
|
||||||
|
return dict(underruns=underruns, worst_late=worst_late, gaps=gaps,
|
||||||
|
noidle=noidle,
|
||||||
|
gap_tot=gap_tot, gap_max=gap_max, busy=busy, span=span,
|
||||||
|
ceiling=max(slack_series), mean_slack=float(np.mean(slack_series)),
|
||||||
|
full=full_refusals, prefill_s=prefill_done)
|
||||||
|
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("container")
|
||||||
|
ap.add_argument("--kbps", type=float, nargs="+", required=True,
|
||||||
|
help="delivery rates to model. REQUIRED: this tree has no "
|
||||||
|
"default rate (FINDINGS 50)")
|
||||||
|
ap.add_argument("--ring", type=int, default=256, help="ring size, KB")
|
||||||
|
ap.add_argument("--qdepth", type=int, nargs="+", default=[1, 2],
|
||||||
|
help="requests the player may have outstanding")
|
||||||
|
ap.add_argument("--prefill", type=int, default=2, help="records before release")
|
||||||
|
ap.add_argument("--cadence", choices=["raster", "nominal"], default="raster")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
d = DLX(a.container)
|
||||||
|
if not d.has_index:
|
||||||
|
sys.exit(f"{a.container} is DLX{d.version}: this model reads the record "
|
||||||
|
f"index the player reads, and only DLX4 carries one.")
|
||||||
|
rec = np.array([q * 4 for q in d.index], np.int64)
|
||||||
|
dec = frame_costs(d) + CLK_ISR * (HFREQ / VTOTAL) / d.fps # + the clock's own
|
||||||
|
if a.cadence == "raster":
|
||||||
|
tick = ticks(d.nframes + 1, d.fps)
|
||||||
|
else:
|
||||||
|
tick = np.arange(d.nframes + 1) / d.fps
|
||||||
|
|
||||||
|
wire = rec.mean() * d.fps / 1024
|
||||||
|
print(f"{a.container}: {d.nframes} records, {rec.mean()/1024:.1f} KB mean, "
|
||||||
|
f"{rec.max()/1024:.1f} KB max, wire {wire:.1f} KB/s")
|
||||||
|
print(f" index: {2*d.nframes:,} B of scene header -- read, not walked")
|
||||||
|
print(f" decode: mean {dec.mean():,.0f} clk/frame ({100*dec.mean()/(CPUHZ/d.fps):.1f}% "
|
||||||
|
f"of a mean slot), p90 {np.percentile(dec,90):,.0f}")
|
||||||
|
print(f" cadence: {a.cadence}"
|
||||||
|
+ (" (4 or 5 refreshes a frame, 72.13/90.16 ms -- FINDINGS 54.4)"
|
||||||
|
if a.cadence == "raster" else " (uniform 1/fps slots)"))
|
||||||
|
print(f" ring {a.ring} KB, prefill {a.prefill} records\n")
|
||||||
|
|
||||||
|
hdr = (f"{'pipe':>8} {'Q':>2} {'idle':>9} {'gaps':>5} {'worst':>8} "
|
||||||
|
f"{'under':>7} {'late by':>8} {'noidl':>5} {'ceil':>5} {'mean':>5} "
|
||||||
|
f"{'refus':>6}")
|
||||||
|
print(hdr)
|
||||||
|
print("-" * len(hdr))
|
||||||
|
for kb in a.kbps:
|
||||||
|
bps = (kb - AUDIO_KBPS) * 1024
|
||||||
|
for q in a.qdepth:
|
||||||
|
r = simulate(rec, dec, tick, bps, a.ring * 1024, q, a.prefill)
|
||||||
|
print(f"{kb:8.0f} {q:2d} {100*r['gap_tot']/r['span']:8.1f}% "
|
||||||
|
f"{r['gaps']:5d} {r['gap_max']*1000:7.1f}ms "
|
||||||
|
f"{r['underruns']:3d}/{d.nframes:<3d} {r['worst_late']*1000:7.1f}ms "
|
||||||
|
f"{r['noidle']:5d} {r['ceiling']:5d} {r['mean_slack']:5.1f} "
|
||||||
|
f"{r['full']:6d}")
|
||||||
|
print()
|
||||||
|
print("idle = the channel with no request to work on, as a fraction of the")
|
||||||
|
print(" window. Bytes the medium could have delivered and did not.")
|
||||||
|
print("under = frames whose record was not resident when the decoder asked")
|
||||||
|
print(" for it. The PIPE.")
|
||||||
|
print("noidl = frames that reached the gate after their tick, because the one")
|
||||||
|
print(" before used its whole slot. The CPU, and 54.4's cadence.")
|
||||||
|
print("ceil = most records resident and unconsumed at a frame start: what a")
|
||||||
|
print(" branch point could spend, minus one for the restart (51.2).")
|
||||||
|
print("refus = placements refused for SPACE. Nonzero means the ring filled.")
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""The worst gap between two decision points, out of the scene graph (G1).
|
||||||
|
|
||||||
|
FINDINGS 51.3 measured that a ring's lookahead is ACCUMULATED out of
|
||||||
|
`pipe - wire` and that a seek spends all of it, so what a branch point costs is
|
||||||
|
set by the rate and by the time since the last branch. 55.5 rehearsed a seek on
|
||||||
|
the machine and could not ask the question that matters, because nothing in this
|
||||||
|
tree knew where the branch points ARE:
|
||||||
|
|
||||||
|
what is the WORST gap, in seconds of play, between two consecutive
|
||||||
|
decision points, and does the refill climb survive it?
|
||||||
|
|
||||||
|
Only the arcade scene graph knows. This answers it in the currency 51.3
|
||||||
|
established.
|
||||||
|
|
||||||
|
python3 tools/import/scenegraph.py # writes tmp/scenegraph.json
|
||||||
|
python3 tools/analysis/25_scene_graph.py --kbps R [R ...] [--ring KB [KB ...]]
|
||||||
|
|
||||||
|
`--kbps` is REQUIRED and takes no default, for the reason FINDINGS 50 gives.
|
||||||
|
|
||||||
|
THIS FILE KNOWS NOTHING ABOUT WHERE THE TABLE CAME FROM, deliberately. It reads
|
||||||
|
`DLXSCENE1`, which is this project's own schema; `tools/import/scenegraph.py` is
|
||||||
|
the single file in the tree that knows anything about the outside projects the
|
||||||
|
table is built from, and it carries their attribution. Nothing is vendored.
|
||||||
|
"""
|
||||||
|
import sys, os, json, argparse, importlib.util
|
||||||
|
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
|
||||||
|
TABLE = os.environ.get("DLX_SCENEGRAPH", "tmp/scenegraph.json")
|
||||||
|
LD_FPS = 23.976 # the medium's frame rate; one frame is the comparison floor
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- graph
|
||||||
|
|
||||||
|
class Node:
|
||||||
|
"""One (scene, sequence): a clip, its exits, and whether entering it seeks."""
|
||||||
|
|
||||||
|
def __init__(self, scene, name, seq):
|
||||||
|
self.scene, self.name, self.seq = scene, name, seq
|
||||||
|
self.start = seq["start_ms"] # ms, or -1 for no seek
|
||||||
|
self.seeks = self.start >= 0
|
||||||
|
self.timeout_ms = seq["timeout_ms"]
|
||||||
|
self.exits = [(seq["timeout_ms"], "timeout", seq["timeout_next"])]
|
||||||
|
for a in seq["actions"]:
|
||||||
|
# The player may press as early as `from_ms`, so that is the least
|
||||||
|
# play this clip can deliver before the branch it leads to.
|
||||||
|
self.exits.append((a["from_ms"], "action", a["next"]))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self):
|
||||||
|
return f"{self.scene}.{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_graph(scenes):
|
||||||
|
nodes = {}
|
||||||
|
for scene, seqs in scenes.items():
|
||||||
|
for name, seq in seqs.items():
|
||||||
|
n = Node(scene, name, seq)
|
||||||
|
nodes[n.key] = n
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
def worst_gap(nodes):
|
||||||
|
"""Least play time, in ms, between one seek and the next.
|
||||||
|
|
||||||
|
A seek is entering a sequence whose start is >= 0; a negative start means
|
||||||
|
the disc keeps playing, so play ACCUMULATES across such sequences and the
|
||||||
|
gap is a shortest path over them. Bellman-Ford rather than Dijkstra
|
||||||
|
because a zero-length timeout is common (`start_alive` chains) and the
|
||||||
|
graph has cycles; all weights are non-negative so it terminates.
|
||||||
|
|
||||||
|
A null exit ends the scene: the player moves to another scene entirely,
|
||||||
|
which is a seek AND a container change (FINDINGS 53), so it counts as a
|
||||||
|
seek and is flagged.
|
||||||
|
"""
|
||||||
|
INF = float("inf")
|
||||||
|
dist = {k: (0.0 if n.seeks else INF) for k, n in nodes.items()}
|
||||||
|
for _ in range(len(nodes) + 1):
|
||||||
|
changed = False
|
||||||
|
for n in nodes.values():
|
||||||
|
if dist[n.key] == INF:
|
||||||
|
continue
|
||||||
|
for elapsed, kind, tgt in n.exits:
|
||||||
|
if tgt is None:
|
||||||
|
continue
|
||||||
|
tk = f"{n.scene}.{tgt}"
|
||||||
|
if tk not in nodes:
|
||||||
|
continue
|
||||||
|
d = dist[n.key] + max(0.0, float(elapsed))
|
||||||
|
if not nodes[tk].seeks and d < dist[tk] - 1e-9:
|
||||||
|
dist[tk], changed = d, True
|
||||||
|
if not changed:
|
||||||
|
break
|
||||||
|
|
||||||
|
best = {}
|
||||||
|
for n in nodes.values():
|
||||||
|
if dist[n.key] == INF:
|
||||||
|
continue
|
||||||
|
for elapsed, kind, tgt in n.exits:
|
||||||
|
tk = f"{n.scene}.{tgt}" if tgt is not None else None
|
||||||
|
ends_scene = tgt is None
|
||||||
|
if ends_scene or (tk in nodes and nodes[tk].seeks):
|
||||||
|
g = dist[n.key] + max(0.0, float(elapsed))
|
||||||
|
# One entry per (source, destination): several input windows can
|
||||||
|
# lead to the same clip and only the earliest of them binds.
|
||||||
|
k = (n.key, tgt)
|
||||||
|
if k not in best or g < best[k][0]:
|
||||||
|
best[k] = (g, n.key, tgt if tgt else "<end of scene>",
|
||||||
|
kind, ends_scene, n.scene)
|
||||||
|
return sorted(best.values()), dist
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ 51.3's currency
|
||||||
|
|
||||||
|
def slack_model(gap_s, kbps, wire_kbps, mean_rec_b):
|
||||||
|
"""Records of lookahead accrued in `gap_s` seconds of play at `kbps`, and
|
||||||
|
the seconds one record of lookahead costs.
|
||||||
|
|
||||||
|
This is 51.3's surplus model and nothing more: slack accrues at
|
||||||
|
(pipe - wire) bytes per second. The paced rig is the measurement; this
|
||||||
|
says whether the gap is even in the right order of magnitude.
|
||||||
|
"""
|
||||||
|
surplus = (kbps - wire_kbps) * 1024.0
|
||||||
|
if surplus <= 0:
|
||||||
|
return 0.0, float("inf")
|
||||||
|
return surplus * gap_s / mean_rec_b, mean_rec_b / surplus
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--kbps", type=float, nargs="+", required=True,
|
||||||
|
help="delivered pipe rates, KB/s. REQUIRED, no default "
|
||||||
|
"(FINDINGS 50).")
|
||||||
|
ap.add_argument("--table", default=TABLE, help="DLXSCENE1 scene table")
|
||||||
|
ap.add_argument("--container", default="tmp/rc_fr_singe_scsi_span.dlx",
|
||||||
|
help="container the wire demand and mean record come from")
|
||||||
|
ap.add_argument("--ring", type=float, nargs="+", default=[256, 512])
|
||||||
|
ap.add_argument("--top", type=int, default=12)
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
if not os.path.exists(a.table):
|
||||||
|
print(f"no scene table at {a.table} -- run:\n"
|
||||||
|
f" python3 tools/import/scenegraph.py")
|
||||||
|
return 2
|
||||||
|
doc = json.load(open(a.table))
|
||||||
|
if doc.get("format") != "DLXSCENE1":
|
||||||
|
print(f"{a.table}: not a DLXSCENE1 table")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
nodes = build_graph(doc["scenes"])
|
||||||
|
c = doc["counts"]
|
||||||
|
nseek = sum(1 for n in nodes.values() if n.seeks)
|
||||||
|
|
||||||
|
print(f"=== the scene graph ({a.table})")
|
||||||
|
for s in doc["sources"]:
|
||||||
|
print(f" from {s['name']} ({s['licence']}, {s['holder']}): {s['role']}")
|
||||||
|
print(f" scenes {c['scenes']}, sequences {c['sequences']}, "
|
||||||
|
f"input windows {c['windows']}")
|
||||||
|
print(f" sequences entered by a SEEK: {nseek}/{c['sequences']} "
|
||||||
|
f"({100*nseek/c['sequences']:.1f}%); the rest play on from where the "
|
||||||
|
f"disc is")
|
||||||
|
print(f" scene order: {len(doc['rows'])} rows x {len(doc['rows'][0])}")
|
||||||
|
# Gates. A parser that quietly dropped a branch would produce a SMALLER
|
||||||
|
# graph and a LONGER worst gap -- it would fail in the flattering direction.
|
||||||
|
assert c["sequences"] == 516, f"expected 516 sequences, got {c['sequences']}"
|
||||||
|
assert c["windows"] == 906, f"expected 906 input windows, got {c['windows']}"
|
||||||
|
|
||||||
|
# ---- the measurement
|
||||||
|
gaps, dist = worst_gap(nodes)
|
||||||
|
play = [g for g in gaps if g[5] != "attract_mode"]
|
||||||
|
zero = [g for g in play if g[0] <= 1e-9]
|
||||||
|
print()
|
||||||
|
print("=== the worst gap between two consecutive decision points")
|
||||||
|
print(" A 'gap' is the LEAST play time the disc delivers between one seek")
|
||||||
|
print(" and the next: the earliest an input window opens, chained across")
|
||||||
|
print(" sequences the disc plays through without seeking. One entry per")
|
||||||
|
print(" (source, destination); attract mode is excluded and reported")
|
||||||
|
print(" separately, because nothing branches there under a 12 fps budget.")
|
||||||
|
print(f" {'gap s':>7} from -> to")
|
||||||
|
for g, src, tgt, kind, ends, scene in play[:a.top]:
|
||||||
|
print(f" {g/1000:>7.3f} {src} -{kind}-> {tgt}"
|
||||||
|
+ (" [SCENE CHANGE]" if ends else ""))
|
||||||
|
sc_gaps = [g for g in play if g[4]]
|
||||||
|
print(f" ... {len(play)} distinct transitions into a seek "
|
||||||
|
f"({len(gaps)-len(play)} more in attract mode)")
|
||||||
|
worst = play[0][0] / 1000.0
|
||||||
|
med = play[len(play) // 2][0] / 1000.0
|
||||||
|
print(f" WORST {worst:.3f} s, median {med:.3f} s, "
|
||||||
|
f"best {play[-1][0]/1000:.3f} s")
|
||||||
|
print(f" SCENE CHANGES specifically ({len(sc_gaps)} of them, and each also")
|
||||||
|
print(f" needs a header before its frame 0, FINDINGS 53/55.1): worst "
|
||||||
|
f"{sc_gaps[0][0]/1000:.3f} s, median "
|
||||||
|
f"{sc_gaps[len(sc_gaps)//2][0]/1000:.3f} s")
|
||||||
|
print(f" ZERO-PLAY BRANCHES: {len(zero)} of {len(play)} "
|
||||||
|
f"({100*len(zero)/len(play):.1f}%) open an input window at t=0 of a")
|
||||||
|
print(" clip the disc SEEKED to, so two seeks can fall back to back with no")
|
||||||
|
print(" play between them at all. A rule of the form 'has there been")
|
||||||
|
print(" enough play since the last branch' (51.2's ring_may_seek) can be")
|
||||||
|
print(" answered NO by the content, not by the buffer.")
|
||||||
|
|
||||||
|
# ---- what the input layer has to survive, from the same table
|
||||||
|
inputs, windows = {}, []
|
||||||
|
for n in nodes.values():
|
||||||
|
for x in n.seq["actions"]:
|
||||||
|
inputs[x["input"]] = inputs.get(x["input"], 0) + 1
|
||||||
|
windows.append(x["to_ms"] - x["from_ms"])
|
||||||
|
windows.sort()
|
||||||
|
print()
|
||||||
|
print("=== what the input layer has to survive")
|
||||||
|
print(" " + ", ".join(f"{k} {v}" for k, v in
|
||||||
|
sorted(inputs.items(), key=lambda x: -x[1])))
|
||||||
|
diag = sum(v for k, v in inputs.items()
|
||||||
|
if k in ("upleft", "upright", "downleft", "downright"))
|
||||||
|
print(f" diagonals are {diag} windows of {len(windows)}: rare enough for a "
|
||||||
|
f"port to drop\n and not droppable by one aiming at the arcade")
|
||||||
|
print(f" window length: shortest {windows[0]:.0f} ms "
|
||||||
|
f"({windows[0]/(1000/12):.2f} frame slots at 12 fps), p10 "
|
||||||
|
f"{windows[len(windows)//10]:.0f} ms, median "
|
||||||
|
f"{windows[len(windows)//2]:.0f} ms")
|
||||||
|
print(" the floor is one to two frames wide (54.4: a slot is 72.13 or")
|
||||||
|
print(" 90.16 ms, never 83.33), so input cannot be polled on the frame tick")
|
||||||
|
|
||||||
|
# ---- 51.3's currency
|
||||||
|
if os.path.exists(a.container):
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"seek_slack", "tools/analysis/20_seek_slack.py")
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
import ratectl as RC
|
||||||
|
d, rec = m.records(a.container)
|
||||||
|
mean_rec = float(rec.mean())
|
||||||
|
wire = mean_rec * 12 / 1024 + RC.AUDIO_KBPS
|
||||||
|
gs = [g[0] / 1000.0 for g in play]
|
||||||
|
print()
|
||||||
|
print(f"=== what that gap buys, at explicit rates "
|
||||||
|
f"({os.path.basename(a.container)}: mean record "
|
||||||
|
f"{mean_rec/1024:.1f} KB, wire {wire:.1f} KB/s)")
|
||||||
|
print(f" {'ring KB':>8} {'pipe':>7} {'ceiling':>8} {'climb s':>8} "
|
||||||
|
f"{'median gap':>11} {'accrued':>8} {'under climb':>12}")
|
||||||
|
for ring_kb in a.ring:
|
||||||
|
ring = int(ring_kb * 1024)
|
||||||
|
for kbps in a.kbps:
|
||||||
|
fill = (kbps - RC.AUDIO_KBPS) * 1024 / 12
|
||||||
|
lo, hi, ring_ref, rate_ref = m.paced_sim(rec, ring, fill)
|
||||||
|
ceiling = int(hi.max())
|
||||||
|
accrued, per_rec = slack_model(med, kbps, wire, mean_rec)
|
||||||
|
climb = ceiling * per_rec
|
||||||
|
under = sum(1 for g in gs if g < climb)
|
||||||
|
print(f" {ring_kb:>8.0f} {kbps:>7.1f} {ceiling:>8} "
|
||||||
|
f"{climb:>8.2f} {med:>11.3f} {accrued:>8.2f} "
|
||||||
|
f"{f'{under}/{len(gs)}':>12} {100*under/len(gs):.0f}%")
|
||||||
|
|
||||||
|
# What a branch costs when the gap before it bought nothing. The ring
|
||||||
|
# is empty after a seek and the decoder is released at the prefill depth
|
||||||
|
# (55.4's shipped policy is 2 records), so this is the stall the player
|
||||||
|
# eats every time -- not the climb to the ceiling, which is what it
|
||||||
|
# needs in order to TOLERATE the next one. A scene change additionally
|
||||||
|
# needs its header before frame 0; 22_scene_load.py prices that case
|
||||||
|
# properly, clocks included.
|
||||||
|
PREFILL_REC, HDR_B = 2, 6164
|
||||||
|
print()
|
||||||
|
print(f" A branch taken on an empty ring, at the shipped prefill of "
|
||||||
|
f"{PREFILL_REC} records:")
|
||||||
|
for kbps in a.kbps:
|
||||||
|
b = PREFILL_REC * mean_rec
|
||||||
|
ms = b / (kbps * 1024) * 1000
|
||||||
|
hms = (b + HDR_B) / (kbps * 1024) * 1000
|
||||||
|
print(f" {kbps:>7.1f} KB/s: {ms:>7.1f} ms "
|
||||||
|
f"({ms/(1000/12):.2f} frame slots), and {hms:>7.1f} ms "
|
||||||
|
f"({hms/(1000/12):.2f}) if it is a scene change carrying "
|
||||||
|
f"{HDR_B:,} header bytes")
|
||||||
|
print()
|
||||||
|
print(" 'climb s' is 51.3's: seconds of play to refill from empty to")
|
||||||
|
print(" the ceiling. 'under climb' is how many of this game's own")
|
||||||
|
print(" branch points arrive sooner than that, i.e. are reached with")
|
||||||
|
print(" LESS lookahead than the one before them. The worst gap is")
|
||||||
|
print(f" {worst:.3f} s and buys nothing at any rate in this table.")
|
||||||
|
else:
|
||||||
|
print(f"\n{a.container}: MISSING -- rate half skipped")
|
||||||
|
|
||||||
|
# ---- the cross-check, and what it is worth
|
||||||
|
print()
|
||||||
|
print("=== the second table, and why it is not a second transcription")
|
||||||
|
cross, meta = doc.get("crosscheck"), doc.get("crosscheck_meta")
|
||||||
|
if not cross:
|
||||||
|
print(" none in this table -- the import ran without it")
|
||||||
|
return 0
|
||||||
|
print(f" {meta['chapters']} chapters, {meta['scenes_mapped']} scenes")
|
||||||
|
print(f" PROVENANCE: {meta['provenance']}")
|
||||||
|
print(" FINDINGS 16 planned to diff two INDEPENDENT transcriptions to")
|
||||||
|
print(" catch transcription errors. There is only one transcription.")
|
||||||
|
print(" This diff catches CONVERSION errors and nothing more.")
|
||||||
|
|
||||||
|
FRAME_MS = 1000.0 / LD_FPS
|
||||||
|
offs, durs, same, diff, missing = [], [], 0, 0, 0
|
||||||
|
renames, inputs_differ, examples = 0, [], []
|
||||||
|
for scene, seqs in sorted(cross.items()):
|
||||||
|
for seq, ch in sorted(seqs.items()):
|
||||||
|
n = nodes.get(f"{scene}.{seq}")
|
||||||
|
if n is None:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
if n.seeks:
|
||||||
|
offs.append(ch["start_ms"] - n.start)
|
||||||
|
durs.append(((ch["end_ms"] - ch["start_ms"]) - n.timeout_ms,
|
||||||
|
ch["chapter"]))
|
||||||
|
ce = sorted((x["input"], x["next"]) for x in ch["actions"])
|
||||||
|
de = sorted((x["input"], x["next"]) for x in n.seq["actions"])
|
||||||
|
if ce == de:
|
||||||
|
same += 1
|
||||||
|
continue
|
||||||
|
diff += 1
|
||||||
|
ci = sorted(i for i, _ in ce)
|
||||||
|
di = sorted(i for i, _ in de)
|
||||||
|
if ci != di:
|
||||||
|
inputs_differ.append((ch["chapter"], ci, di))
|
||||||
|
else:
|
||||||
|
renames += 1
|
||||||
|
if len(examples) < 3:
|
||||||
|
examples.append((ch["chapter"], ce, de))
|
||||||
|
|
||||||
|
if offs:
|
||||||
|
offs.sort()
|
||||||
|
print(f" START TIMES are on different timelines and do not compare: "
|
||||||
|
f"{len(offs)} seeking chapters,")
|
||||||
|
print(f" offset spread {min(offs)/1000:,.1f} s .. "
|
||||||
|
f"{max(offs)/1000:,.1f} s, median {offs[len(offs)//2]/1000:,.1f} s"
|
||||||
|
f" -- not a constant, and not even one sign.")
|
||||||
|
if durs:
|
||||||
|
dd = sorted(x for x, _ in durs)
|
||||||
|
agree = sum(1 for x in dd if abs(x) <= FRAME_MS)
|
||||||
|
print(f" DURATIONS compare (offset-invariant): {agree}/{len(dd)} "
|
||||||
|
f"within one frame of the medium ({100*agree/len(dd):.1f}%), "
|
||||||
|
f"median {dd[len(dd)//2]:,.0f} ms")
|
||||||
|
for x, ch in sorted(durs, key=lambda x: -abs(x[0]))[:3]:
|
||||||
|
print(f" widest {ch}: {x/1000:+.3f} s")
|
||||||
|
if same + diff:
|
||||||
|
print(f" BRANCH STRUCTURE compares: {same}/{same+diff} chapters have "
|
||||||
|
f"the identical set of (input -> target) edges "
|
||||||
|
f"({100*same/(same+diff):.1f}%)")
|
||||||
|
if diff:
|
||||||
|
print(f" {renames} of the {diff} differ only in what a target "
|
||||||
|
f"sequence is NAMED")
|
||||||
|
DIAG = ("upleft", "upright", "downleft", "downright")
|
||||||
|
lost = [ch for ch, ci, di in inputs_differ
|
||||||
|
if set(di) - set(ci) and all(x in DIAG for x in set(di) - set(ci))]
|
||||||
|
print(f" {len(inputs_differ)} differ in the INPUT SET, and "
|
||||||
|
f"{len(lost)} of those are the other table dropping the arcade's")
|
||||||
|
print(" DIAGONALS: a controller decision, not a transcription "
|
||||||
|
"difference.")
|
||||||
|
for ch, ci, di in inputs_differ:
|
||||||
|
if ch not in lost:
|
||||||
|
print(f" the remaining one: {ch}, {ci} vs {di}")
|
||||||
|
for ch, ce, de in examples:
|
||||||
|
print(f" e.g. {ch}\n cross {ce}\n graph {de}")
|
||||||
|
if missing:
|
||||||
|
print(f" {missing} chapters have no sequence in the graph "
|
||||||
|
f"(the other project added them)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""A record is not a sector: what the mismatch costs, three ways (P4b, 58.3).
|
||||||
|
|
||||||
|
src/player/ring.i asks the transport for a RECORD -- a byte offset into the
|
||||||
|
scene's frame stream and a length, both 4-byte aligned because that is what
|
||||||
|
`move.l (a0)+` needs (28.3) and neither of them a multiple of 512. A SCSI
|
||||||
|
target answers in 512 B BLOCKS. On the gate container 117 of 120 records start
|
||||||
|
part way into a sector, so something has to reconcile the two, and the three
|
||||||
|
ways of doing it are not close.
|
||||||
|
|
||||||
|
WHY IT IS NOT AN IMPLEMENTATION DETAIL. The bytes on either side of a record in
|
||||||
|
the stream belong to OTHER records -- ones the decoder may still be reading --
|
||||||
|
and the block loop walks a0 with no bounds check at all (49.2). So a transport
|
||||||
|
that reads whole sectors straight into the ring does not waste 500 bytes, it
|
||||||
|
CORRUPTS the neighbours, and the symptom is wrong pixels rather than a fault.
|
||||||
|
|
||||||
|
A. WINDOWED PIO. Read the sectors the record lies in, store only the record.
|
||||||
|
src/player/scsi.i does this and it is what FINDINGS 58 measured. It costs
|
||||||
|
nothing in clocks -- the CPU is touching every byte anyway -- and it costs
|
||||||
|
the extra sectors on the wire. It CANNOT be done by a DMAC: a channel
|
||||||
|
writes a contiguous run to a contiguous address and cannot be told to drop
|
||||||
|
the first 300 bytes.
|
||||||
|
B. BOUNCE BUFFER. Let the DMAC write whole sectors somewhere else, then copy
|
||||||
|
the record into the ring. Works under DMA, and costs a copy of every
|
||||||
|
delivered byte -- which is precisely the cost `aligned` was chosen over
|
||||||
|
`split` to avoid (49.3, 19_ring_stream.py).
|
||||||
|
C. SECTOR-ALIGNED RECORDS. Pad each record up to 512 in the container
|
||||||
|
instead of up to 4. Costs bytes on the disc and in every delivery, and
|
||||||
|
nothing else at all; the transport becomes a whole-sector read into the
|
||||||
|
ring with no window and no copy. It is a CONTAINER change -- a re-encode
|
||||||
|
and a re-measurement of every constant fitted to the gate container, which
|
||||||
|
is the class of change ROADMAP already has bundled with P2's other half.
|
||||||
|
|
||||||
|
python3 tools/analysis/26_sector_align.py <in.dlx> [--ring KB]
|
||||||
|
|
||||||
|
No rate is taken and none is needed: every figure here is a fraction of the
|
||||||
|
delivered bytes or a count of clocks, and both are rate-free. What a given
|
||||||
|
delivery rate does with them is 15_bus_occupancy.py's question.
|
||||||
|
"""
|
||||||
|
import sys, os, argparse
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
from dlx import DLX
|
||||||
|
|
||||||
|
SECTOR = 512
|
||||||
|
CPUHZ = 10_000_000
|
||||||
|
# 5.0 clocks/byte, and it is 19_ring_stream.py's constant rather than a new one:
|
||||||
|
# a 68000 `move.l (a0)+,(a1)+` moves 4 bytes in 20 clocks on a 16-bit bus. It
|
||||||
|
# is the OPTIMISTIC figure there and it is the optimistic figure here.
|
||||||
|
COPY_CLK_PER_BYTE = 5.0
|
||||||
|
# The windowed PIO loop in src/player/scsi.i, from the 68000's cycle table:
|
||||||
|
# 12 move.l #SC_PATIENCE,d3 patience reload
|
||||||
|
# 16 move.b SC_SSTS,d0 (xxx).L -> Dn
|
||||||
|
# 10 btst #0,d0
|
||||||
|
# 10 beq.s taken
|
||||||
|
# 20 move.b SC_DREG,(a1)+ (xxx).L -> (An)+
|
||||||
|
# 8 subq.l #1,d7
|
||||||
|
# 10 bne.s taken
|
||||||
|
# FINDINGS 58.2 measured 87.28 clocks per delivered byte against this loop's 86
|
||||||
|
# plus 1.15 for the dropped window bytes -- 0.2% apart, which is what says the
|
||||||
|
# cost is the instruction stream and not MAME's device model.
|
||||||
|
PIO_CLK_PER_BYTE = 86.0
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("container")
|
||||||
|
ap.add_argument("--ring", type=int, default=256, help="ring size in KB")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
d = DLX(a.container)
|
||||||
|
|
||||||
|
# The disc layout the 68000 walks: [u32 len][body], each record padded up to the
|
||||||
|
# container's own alignment -- 4 on DLX2/3/4, 512 on DLX5. Exactly
|
||||||
|
# tools/bench/prep_stream.py's, and it comes from the reader rather than from a
|
||||||
|
# second copy of the rule here, so pointing this tool at a DLX5 container asks
|
||||||
|
# it the RIGHT question: what does the mismatch still cost once the container
|
||||||
|
# has been changed to remove it? (The answer had better be nothing.)
|
||||||
|
off, recs = 0, []
|
||||||
|
for ln in d.record_lengths():
|
||||||
|
recs.append((off, ln))
|
||||||
|
off += ln
|
||||||
|
# The DENOMINATOR is the record bytes the decoder actually reads -- [u32 len]
|
||||||
|
# plus payload -- and NOT the padded length, because on a DLX5 container the
|
||||||
|
# padding IS the cost being measured. Scoring against the padded length would
|
||||||
|
# make an already-aligned container report +0.00% and look free.
|
||||||
|
payload = sum(4 + n for _, n in d.frames)
|
||||||
|
nfr = len(recs)
|
||||||
|
budget = CPUHZ / d.fps
|
||||||
|
|
||||||
|
print(f"{a.container}: {nfr} records, {payload:,} B, {d.fps} fps")
|
||||||
|
print(f" mean record {payload/nfr:,.0f} B; a {d.fps} fps frame is "
|
||||||
|
f"{budget:,.0f} clocks")
|
||||||
|
aligned0 = sum(1 for o, _ in recs if o % SECTOR == 0)
|
||||||
|
print(f" records that already start on a sector boundary: {aligned0}/{nfr}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ---- A. windowed PIO: the sectors the record lies in, and only the record kept
|
||||||
|
wire_a = sum(((o % SECTOR) + ln + SECTOR - 1) // SECTOR for o, ln in recs) * SECTOR
|
||||||
|
drop_a = wire_a - payload
|
||||||
|
print("A. WINDOWED PIO (src/player/scsi.i, what FINDINGS 58 ran)")
|
||||||
|
print(f" wire {wire_a:,} B for {payload:,} B of record "
|
||||||
|
f"= +{100*drop_a/payload:.2f}%")
|
||||||
|
print(f" clocks {PIO_CLK_PER_BYTE:.0f}/B on EVERY byte off the FIFO, "
|
||||||
|
f"dropped ones included:")
|
||||||
|
print(f" {PIO_CLK_PER_BYTE*wire_a/nfr:,.0f} clk/frame "
|
||||||
|
f"= {100*PIO_CLK_PER_BYTE*wire_a/nfr/budget:.0f}% of the frame")
|
||||||
|
print( " and it does not survive the move to the DMAC at all: a channel "
|
||||||
|
"cannot drop bytes.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ---- B. bounce buffer: DMA whole sectors elsewhere, copy the record in
|
||||||
|
print("B. BOUNCE BUFFER (whole sectors by DMA, then a copy)")
|
||||||
|
print(f" wire {wire_a:,} B, the same +{100*drop_a/payload:.2f}% -- the "
|
||||||
|
f"command is identical")
|
||||||
|
print(f" clocks {COPY_CLK_PER_BYTE:g}/B of copy on every DELIVERED byte, "
|
||||||
|
f"on top of whatever W the")
|
||||||
|
print(f" channel steals: {COPY_CLK_PER_BYTE*payload/nfr:,.0f} clk/frame "
|
||||||
|
f"= {100*COPY_CLK_PER_BYTE*payload/nfr/budget:.1f}% of the frame")
|
||||||
|
print( " which is the cost `aligned` was chosen over `split` to avoid "
|
||||||
|
"(49.3), arriving")
|
||||||
|
print( " by a different door and on every byte instead of on a wrap.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ---- C. sector-aligned records in the container
|
||||||
|
cur, pad = 0, 0
|
||||||
|
for _, ln in recs:
|
||||||
|
if cur % SECTOR:
|
||||||
|
pad += SECTOR - (cur % SECTOR)
|
||||||
|
cur += SECTOR - (cur % SECTOR)
|
||||||
|
cur += ln
|
||||||
|
print("C. SECTOR-ALIGNED RECORDS (a container change; a re-encode)"
|
||||||
|
+ (" -- THIS CONTAINER ALREADY IS ONE" if d.sector_aligned else ""))
|
||||||
|
print(f" wire {cur:,} B for {payload:,} B of record = "
|
||||||
|
f"+{100*(cur-payload)/payload:.2f}%")
|
||||||
|
print( " clocks ZERO: the read is a whole-sector read straight into the "
|
||||||
|
"ring, no window,")
|
||||||
|
print( " no copy, and the DMAC can do it.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
ringsz = a.ring * 1024
|
||||||
|
print(f" VERDICT, in the currency this project prices delivery in. C is "
|
||||||
|
f"cheaper on the wire")
|
||||||
|
print(f" than A and B by {100*(wire_a-cur)/payload:.2f} points of the payload "
|
||||||
|
f"({wire_a-cur:,} B on this scene),")
|
||||||
|
print(f" and it is the only one of the three a DMA channel can run without a "
|
||||||
|
f"copy. What it")
|
||||||
|
print(f" costs is a container revision and the re-measurement that comes with "
|
||||||
|
f"one.")
|
||||||
|
maxrec = max(ln for _, ln in recs)
|
||||||
|
maxpad = maxrec + (-maxrec) % SECTOR
|
||||||
|
print(f" It also grows the largest record from {maxrec:,} to {maxpad:,} B, "
|
||||||
|
f"which a {a.ring} KB")
|
||||||
|
print(f" ring still holds {ringsz//maxpad} times over.")
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""What the PLAYER programs into the DMAC -- read out of the assembler source.
|
||||||
|
|
||||||
|
python3 tools/analysis/27_dmac_config.py [src/player/dma.i]
|
||||||
|
|
||||||
|
ROADMAP P4a asks for a DMAC configuration that HOLDS THE BUS, and the whole
|
||||||
|
weight of the claim is in four register bytes. tools/analysis/21_iplrom_dmac.py
|
||||||
|
already reads the IPL ROM's four channels the same way, out of the shipping
|
||||||
|
image, and found Sharp's own disk channel at 16..19 clocks a byte (FINDINGS
|
||||||
|
52.5) -- above the entire bracket this project costs P4 in. This is the other
|
||||||
|
half of that comparison: the same MC68450 field tables (tools/analysis/
|
||||||
|
mc68450.py, one copy) applied to the bytes src/player/dma.i actually programs.
|
||||||
|
|
||||||
|
IT PARSES THE SOURCE RATHER THAN RESTATING IT. A constant typed into this file
|
||||||
|
would be a claim about the player that the player could quietly stop honouring;
|
||||||
|
the equates are read out of src/player/dma.i, so a change there changes what is
|
||||||
|
printed here and a mismatch between the two is not expressible.
|
||||||
|
|
||||||
|
IT IS A GATE. Each configuration is checked against what it is FOR -- held
|
||||||
|
must decode as a mode that keeps the bus, stealing must decode as one that does
|
||||||
|
not -- and a disagreement exits non-zero rather than printing a paragraph.
|
||||||
|
|
||||||
|
WHAT IT IS NOT: a rate. Nothing here is a measurement of anything. It says
|
||||||
|
which mode the player asks the chip for; tools/bench/dma_run.sh shows the
|
||||||
|
machine doing it, and `W` -- the clocks it costs on real silicon -- remains the
|
||||||
|
project's largest open number (ROADMAP B1/B3).
|
||||||
|
"""
|
||||||
|
import sys, os, re
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from mc68450 import dcr, ocr, scr, XRM, DTYP, REQG
|
||||||
|
|
||||||
|
src = sys.argv[1] if len(sys.argv) > 1 else "src/player/dma.i"
|
||||||
|
if not os.path.exists(src):
|
||||||
|
sys.exit(f"missing {src} -- run from the repo root.")
|
||||||
|
text = open(src).read()
|
||||||
|
|
||||||
|
def equ(name):
|
||||||
|
m = re.search(rf"^{name}\s*=\s*\$([0-9A-Fa-f]+)", text, re.M)
|
||||||
|
if not m:
|
||||||
|
sys.exit(f"{src} no longer defines {name}. This script reads the "
|
||||||
|
f"player's own equates; it does not keep a copy of them.")
|
||||||
|
return int(m.group(1), 16)
|
||||||
|
|
||||||
|
# The SCR the channel is given is written inline rather than equated, because it
|
||||||
|
# is the same for both configurations and there is nothing to choose about it.
|
||||||
|
m = re.search(r"move\.b\s+#\$([0-9A-Fa-f]+),DM_SCR", text)
|
||||||
|
if not m:
|
||||||
|
sys.exit(f"{src} no longer writes DM_SCR with a literal.")
|
||||||
|
SCR = int(m.group(1), 16)
|
||||||
|
|
||||||
|
CFG = [("BUS HELD", "DM_HELD_DCR", "DM_HELD_OCR"),
|
||||||
|
("CYCLE STEALING", "DM_STEAL_DCR", "DM_STEAL_OCR")]
|
||||||
|
|
||||||
|
print(f"WHAT src/player/dma.i PROGRAMS -- decoded from {src}\n")
|
||||||
|
bad = 0
|
||||||
|
for label, dn, on in CFG:
|
||||||
|
D, O = equ(dn), equ(on)
|
||||||
|
print(f" {label} DCR = ${D:02X} OCR = ${O:02X} SCR = ${SCR:02X}")
|
||||||
|
for line in dcr(D):
|
||||||
|
print(f" {line}")
|
||||||
|
for line in ocr(O):
|
||||||
|
print(f" {line}")
|
||||||
|
for line in scr(SCR):
|
||||||
|
print(f" {line}")
|
||||||
|
holds = (D >> 6 & 3) in (0, 3) # burst, or cycle steal WITH hold
|
||||||
|
dual = (D >> 4 & 3) in (0, 1)
|
||||||
|
tomem = bool(O & 0x80)
|
||||||
|
checks = [
|
||||||
|
(dual, "DTYP must be explicitly addressed: only channel 0 has device "
|
||||||
|
"callbacks in this machine, so an implicit-address DTYP on "
|
||||||
|
"channel 1 falls through to the dual-address path anyway"),
|
||||||
|
(tomem, "OCR DIR must be device -> memory; this is a READ"),
|
||||||
|
((O >> 4 & 3) == 0, "OCR SIZE must be byte: the SPC's port is 8 bits"),
|
||||||
|
((O >> 2 & 3) == 0, "OCR CHAIN must be none until P5a picks a chaining "
|
||||||
|
"scheme for the two-deep request queue (FINDINGS 55.3)"),
|
||||||
|
((SCR & 3) == 0, "SCR DAC must not count: the device address is a "
|
||||||
|
"REGISTER at $EA0015 and must not walk off it"),
|
||||||
|
((SCR >> 2 & 3) == 1, "SCR MAC must increment: the record is contiguous"),
|
||||||
|
((O & 3) in (0, 1), "OCR REQG must be an AUTO-request mode: the "
|
||||||
|
"expansion slot has no request line to the DMAC in "
|
||||||
|
"this machine, so external request cannot be run"),
|
||||||
|
]
|
||||||
|
if label == "BUS HELD":
|
||||||
|
checks.append((holds, "the held configuration must decode as a mode "
|
||||||
|
"that KEEPS the bus between operands"))
|
||||||
|
checks.append(((O & 3) == 1, "and as max-rate auto-request: MAME models "
|
||||||
|
"a held bus only for burst + REQG 01"))
|
||||||
|
else:
|
||||||
|
checks.append((not holds, "the stealing configuration must decode as a "
|
||||||
|
"mode that RELEASES the bus between operands "
|
||||||
|
"-- otherwise the two have no contrast"))
|
||||||
|
for ok, why in checks:
|
||||||
|
if not ok:
|
||||||
|
print(f" FAIL: {why}")
|
||||||
|
bad += 1
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("""AGAINST THE MACHINE'S OWN DISK CHANNEL (21_iplrom_dmac.py, FINDINGS 52.5)
|
||||||
|
|
||||||
|
IPL ROM ch1, SASI DCR $80 OCR $B2 dual address, 8-bit port, cycle steal
|
||||||
|
WITHOUT hold, EXTERNAL request
|
||||||
|
-> a full arbitration per byte, 16..19
|
||||||
|
player, held DCR $00 OCR $81 dual address, 8-bit port, BURST,
|
||||||
|
auto-request at max rate
|
||||||
|
-> the ladder's dual-address held row, 9
|
||||||
|
|
||||||
|
Sharp's own configuration and the player's differ in exactly the field that
|
||||||
|
decides the project. That is 52.5's finding read the other way round: a cheaper
|
||||||
|
configuration IS reachable for an explicitly-addressed 8-bit port, and what it
|
||||||
|
costs on real silicon is still ROADMAP B3's question and not this file's.""")
|
||||||
|
sys.exit(1 if bad else 0)
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""What AUTO-REQUEST DMA costs the 68000, when there is no request line.
|
||||||
|
|
||||||
|
python3 tools/analysis/28_autorequest_cost.py --kbps 460 [--record 37405]
|
||||||
|
|
||||||
|
WHY THIS EXISTS. The project's per-byte ladder -- W = 5 single-address held, 9
|
||||||
|
dual held, 12 single arbitrated, 16..19 dual arbitrated (FINDINGS 42.4, 52.5) --
|
||||||
|
prices a transfer that the DEVICE asks for: one external request, one operand,
|
||||||
|
a known number of stolen clocks per delivered byte. Session 27 found that the
|
||||||
|
CZ-6BS1 as MAME models it has NO REQUEST LINE to the DMAC at all (FINDINGS
|
||||||
|
59.2): the card's flow control is DTACK, and every configuration that can be run
|
||||||
|
against it is AUTO-REQUEST, where the channel transfers because its own counter
|
||||||
|
says so and not because a byte has arrived.
|
||||||
|
|
||||||
|
THAT CHANGES THE CURRENCY, and it is the reason this file is not a line in
|
||||||
|
another one. An externally requested transfer is charged PER DELIVERED BYTE.
|
||||||
|
An auto-requested one is charged PER UNIT OF TIME THE CHANNEL IS ACTIVE, because
|
||||||
|
the channel has no way to know the device is not ready: it takes its allotted
|
||||||
|
share of the bus and spends it whether or not a byte comes back. So the cost of
|
||||||
|
delivering a record depends on HOW LONG THE RECORD TAKES TO ARRIVE -- i.e. on
|
||||||
|
the delivery rate, the figure this tree deliberately has no default for (FINDINGS
|
||||||
|
50) -- and the tool REQUIRES one rather than assuming it.
|
||||||
|
|
||||||
|
SOURCED: MC68450 Direct Memory Access Controller, Motorola, Jul 1989
|
||||||
|
(bitsavers), sections 3.8 and 5.2.3.3, the same document buscost.py's transfer
|
||||||
|
timings come from. Section 5.2.3.3.1: under maximum-rate auto-request "all
|
||||||
|
operands in the data block will be transferred in one burst, so that the DMAC
|
||||||
|
will use 100% of the available bus bandwidth" -- which is the datasheet saying,
|
||||||
|
in its own words, what session 27 measured MAME's model doing when it HALTED the
|
||||||
|
68000 for the whole data phase (FINDINGS 59.1).
|
||||||
|
|
||||||
|
THE ONE LOAD-BEARING ASSUMPTION, stated because the whole table rests on it:
|
||||||
|
that the channel SPENDS its allotted share whether or not the device has a byte.
|
||||||
|
Under auto-request a request is pending until MTC is exhausted, so the DMAC
|
||||||
|
takes the bus during every burst window it is entitled to; when the device is
|
||||||
|
not ready the cycle is stretched by wait states (a real CZ-6BS1 negating DTACK)
|
||||||
|
or retried later (MAME's model discards the operand), and either way the window
|
||||||
|
is gone from the CPU's point of view. If a real card instead lets the DMAC off
|
||||||
|
the bus early when no byte is there, these figures are UPPER BOUNDS. That is a
|
||||||
|
board question and it is ROADMAP B3's.
|
||||||
|
|
||||||
|
NOT A MEASUREMENT. Every figure below is arithmetic over datasheet constants
|
||||||
|
and an explicit rate. `W` is still unmeasured and still wants a board.
|
||||||
|
"""
|
||||||
|
import sys, os, argparse
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from buscost import DMA_DUAL_BYTE_CLK, DMA_READ_CLK, DMA_WRITE_CLK
|
||||||
|
|
||||||
|
CPU_HZ = 10_000_000.0 # the X68000 the whole tree is costed against
|
||||||
|
FPS = 12.0
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--kbps", type=float, required=True,
|
||||||
|
help="delivery rate in KB/s. REQUIRED: this tree has no default "
|
||||||
|
"rate and the whole answer scales with it (FINDINGS 50).")
|
||||||
|
ap.add_argument("--record", type=int, default=37405,
|
||||||
|
help="mean record size in bytes (default: the gate container's)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
RATE = a.kbps * 1024.0
|
||||||
|
|
||||||
|
# --- 3.8 GENERAL CONTROL REGISTER, decoded from the formulas in 5.2.3.3.2 ---
|
||||||
|
# burst time = 2^(BT + 4) clocks
|
||||||
|
# sample period = 2^(BT + BR + 5) clocks
|
||||||
|
# DMAC's share = 2^-(BR + 1)
|
||||||
|
# and Table 5-3 prints all sixteen combinations, so the formulas are GATED
|
||||||
|
# against the table rather than trusted.
|
||||||
|
TABLE = { # (BR, BT): (burst, MPU period, share, sample period)
|
||||||
|
(0,0):(16,16,.5,32), (0,1):(32,32,.5,64), (0,2):(64,64,.5,128), (0,3):(128,128,.5,256),
|
||||||
|
(1,0):(16,48,.25,64), (1,1):(32,96,.25,128), (1,2):(64,192,.25,256),(1,3):(128,384,.25,512),
|
||||||
|
(2,0):(16,112,.125,128),(2,1):(32,224,.125,256),(2,2):(64,448,.125,512),(2,3):(128,896,.125,1024),
|
||||||
|
(3,0):(16,240,.0625,256),(3,1):(32,480,.0625,512),(3,2):(64,960,.0625,1024),
|
||||||
|
(3,3):(128,1920,.0625,2048),
|
||||||
|
}
|
||||||
|
bad = 0
|
||||||
|
for (br, bt), (burst, mpu, share, sample) in sorted(TABLE.items()):
|
||||||
|
f_burst, f_sample, f_share = 2**(bt+4), 2**(bt+br+5), 2.0**-(br+1)
|
||||||
|
for got, want, what in ((f_burst, burst, "burst time"),
|
||||||
|
(f_sample, sample, "sample period"),
|
||||||
|
(f_share, share, "bandwidth share"),
|
||||||
|
(f_sample - f_burst, mpu, "MPU period")):
|
||||||
|
if got != want:
|
||||||
|
print(f" FAIL BR={br:02b} BT={bt:02b} {what}: formula {got}, "
|
||||||
|
f"Table 5-3 {want}")
|
||||||
|
bad += 1
|
||||||
|
if bad:
|
||||||
|
sys.exit(f"\n{bad} disagreements between 5.2.3.3.2's formulas and Table 5-3. "
|
||||||
|
"Everything below\nis those formulas, so it is not printed.")
|
||||||
|
print(f"MC68450 5.2.3.3.2's formulas reproduce all 16 rows of Table 5-3.\n")
|
||||||
|
|
||||||
|
BYTE_CLK = DMA_DUAL_BYTE_CLK # dual address, 8-bit port: a 4-clock read of
|
||||||
|
# $EA0015 and a 5-clock write to the ring
|
||||||
|
frame_clk = CPU_HZ / FPS
|
||||||
|
wire_s = a.record / RATE # how long the record takes to land
|
||||||
|
wire_clk = wire_s * CPU_HZ # ...in 68000 clocks
|
||||||
|
per_byte_wire = wire_clk / a.record # clocks of wall time per byte
|
||||||
|
|
||||||
|
print(f"THE RECORD: {a.record:,} B at {a.kbps:g} KB/s = {wire_s*1000:.2f} ms "
|
||||||
|
f"= {wire_clk:,.0f} clocks = {100*wire_clk/frame_clk:.1f}% of a "
|
||||||
|
f"{FPS:g} fps frame")
|
||||||
|
print(f" one byte of WIRE TIME is {per_byte_wire:.2f} clocks; one byte of DMAC "
|
||||||
|
f"WORK is {BYTE_CLK} ({DMA_READ_CLK} read + {DMA_WRITE_CLK} write, "
|
||||||
|
f"buscost.py)\n")
|
||||||
|
|
||||||
|
print("REQG 01, AUTO-REQUEST AT MAXIMUM RATE -- what session 27 demonstrated")
|
||||||
|
print(f" The channel holds the bus until MTC is exhausted (5.2.3.3.1: 100% of "
|
||||||
|
f"the\n bandwidth), so the CPU gets NOTHING for the whole delivery:")
|
||||||
|
print(f" cost to the 68000 = the whole {100*wire_clk/frame_clk:.1f}% of a "
|
||||||
|
f"frame, or {per_byte_wire:.2f} clk/B")
|
||||||
|
print(f" It is the cheapest configuration per BYTE MOVED and the dearest per "
|
||||||
|
f"byte\n DELIVERED, and the gap between those is the device's own "
|
||||||
|
f"slowness:\n {BYTE_CLK} clocks of work in {per_byte_wire:.1f} clocks "
|
||||||
|
f"of waiting = {100*BYTE_CLK/per_byte_wire:.1f}% of the held bus does "
|
||||||
|
f"anything.\n")
|
||||||
|
|
||||||
|
print("REQG 00, LIMITED-RATE AUTO-REQUEST -- the lever the GCR actually gives")
|
||||||
|
print(" The DMAC takes its programmed share of the bus and spends it whether "
|
||||||
|
"or not\n a byte is there, so the CPU pays the SHARE for the WHOLE "
|
||||||
|
"delivery -- and the\n share must also be big enough to carry the rate. "
|
||||||
|
"Both, or it does not fit.\n")
|
||||||
|
print(" BR share sustains clk/B charged % of a frame fits "
|
||||||
|
f"{a.kbps:g} KB/s?")
|
||||||
|
fits_any = []
|
||||||
|
for br in range(4):
|
||||||
|
burst, mpu, share, sample = TABLE[(br, 3)] # BT=11, the longest burst
|
||||||
|
# bytes the channel can move inside one burst window, and how often that
|
||||||
|
# window comes round
|
||||||
|
bytes_per_burst = burst // BYTE_CLK
|
||||||
|
sustains = bytes_per_burst * CPU_HZ / sample
|
||||||
|
charged = share * per_byte_wire # clocks the CPU loses per
|
||||||
|
# DELIVERED byte
|
||||||
|
pct = 100 * share * wire_clk / frame_clk
|
||||||
|
ok = sustains >= RATE
|
||||||
|
if ok:
|
||||||
|
fits_any.append((br, share, charged, pct))
|
||||||
|
print(f" {br:02b} {share*100:5.2f}% {sustains/1024:7.1f} KB/s "
|
||||||
|
f"{charged:9.2f} {pct:8.1f}% {'yes' if ok else 'NO'}")
|
||||||
|
print(f"\n (BT = 11 throughout: the longest burst, 128 clocks, which is the "
|
||||||
|
f"most\n favourable row -- a shorter burst moves fewer bytes per window "
|
||||||
|
f"at the same\n share and sustains proportionally less.)")
|
||||||
|
|
||||||
|
if not fits_any:
|
||||||
|
print(f"\n NOTHING FITS. At {a.kbps:g} KB/s no limited-rate share can "
|
||||||
|
f"carry the record,\n so the only auto-request configuration that "
|
||||||
|
f"delivers is maximum rate --\n and that one stops the CPU for the "
|
||||||
|
f"whole {100*wire_clk/frame_clk:.1f}% of a frame the record takes.")
|
||||||
|
else:
|
||||||
|
br, share, charged, pct = fits_any[0]
|
||||||
|
print(f"\n CHEAPEST THAT FITS: BR = {br:02b}, {share*100:g}% of the bus, "
|
||||||
|
f"{charged:.2f} clk/B charged to the\n 68000 -- {pct:.1f}% of a frame "
|
||||||
|
f"per record.")
|
||||||
|
print(f" Against the ladder: W=5 held costs {5*a.record/frame_clk*100:.1f}%, "
|
||||||
|
f"W=9 dual held {9*a.record/frame_clk*100:.1f}%,\n W=19 the IPL ROM's "
|
||||||
|
f"own {19*a.record/frame_clk*100:.1f}%, and PIO measured "
|
||||||
|
f"{87.28*a.record/frame_clk*100:.1f}% (FINDINGS 58.2).")
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
WHAT THIS SETTLES, AND WHAT IT DOES NOT
|
||||||
|
|
||||||
|
1. AUTO-REQUEST IS CHARGED BY TIME, NOT BY BYTE. Every W in this project is
|
||||||
|
clocks per DELIVERED byte, which presumes the device asks. With no request
|
||||||
|
line the channel spends its share of the bus at a rate it was told, so the
|
||||||
|
record's cost scales with how long the disc takes -- halve the delivery rate
|
||||||
|
and the CPU cost of the same record DOUBLES. No W does that.
|
||||||
|
|
||||||
|
2. THE GCR IS A DESIGN LEVER NOBODY HAD NAMED. BT and BR are two bits each and
|
||||||
|
they set what fraction of the bus the player gives away. That is the same
|
||||||
|
kind of choice as `aligned` vs `split` and it belongs in the same list.
|
||||||
|
|
||||||
|
3. IT IS STILL NOT A MEASUREMENT. These are datasheet constants and an explicit
|
||||||
|
rate. Whether the real CZ-6BS1 drives #EXREQ (pin B36 exists on the slot, and
|
||||||
|
MAME's model simply does not connect it) is ROADMAP B3's question, and if it
|
||||||
|
does, the ladder applies and this file is the fallback rather than the plan.""")
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""The DECODER-FREE PACKED player, priced against the measured cost model.
|
||||||
|
|
||||||
|
python3 tools/analysis/29_packed_player.py [container.dlx] [--kbps R]
|
||||||
|
|
||||||
|
THE QUESTION, and why it is being asked again. FINDINGS 44.7 removed the codec
|
||||||
|
and asked what a player that just puts literal frames on screen would cost. It
|
||||||
|
answered "it fits the clocks and dies on the medium": 1,152 KB/s and 1.61 GB,
|
||||||
|
because 256-colour GVRAM's default write path throws away the high byte of every
|
||||||
|
word and a picture byte therefore costs two disc bytes. 46.5/47.1 then found
|
||||||
|
the off switch -- CRTC R20 bit 11 -- and 47.2 built the layout and rendered it
|
||||||
|
pixel-exactly on both emulators at 1.0 B/pixel. 47.5 re-derived the budget on
|
||||||
|
that and withdrew 44.7's conclusion CONDITIONALLY.
|
||||||
|
|
||||||
|
Everything in 47.5 is arithmetic over a cost model that has since been REPLACED.
|
||||||
|
When it was written the transport was an unmeasured `c`; sessions 25b-28 put the
|
||||||
|
transport on the 68000 and measured it (58.2: PIO is 87.28 clk/B), put it on the
|
||||||
|
DMAC and bounded it (59.2: this machine can run dual-address only, and a
|
||||||
|
dual-address byte has a 9 clk/B FLOOR), and re-derived what a frame can afford
|
||||||
|
(59.7/60.7: 6.69 clk/B on the gate container). 47.6.1 also filed the CPU paint
|
||||||
|
cost as an ASSUMPTION -- "the `movem` shape of the packed writes is an
|
||||||
|
assumption", no clock in 47.5 measured.
|
||||||
|
|
||||||
|
So this tool re-asks 44.7's question with:
|
||||||
|
|
||||||
|
* the paint MEASURED, not assumed -- tools/bench/blit.s V8 is V1 with 128
|
||||||
|
words a row instead of 256, and tools/bench/blit.lua times it next to V1,
|
||||||
|
V2 and V3 in the same run, so the packed number is quoted against a
|
||||||
|
variant whose value (53.6%) is a session-9 result that has not moved;
|
||||||
|
* the transport swept over the SAME `W` ladder 15_bus_occupancy.py uses,
|
||||||
|
every rung of it sourced or measured (buscost.py);
|
||||||
|
* the audio DMA charged, at the rate the IPL ROM's own channel-3 setup
|
||||||
|
implies (21_iplrom_dmac.py) -- 60.x's rule that a budget debits I/O;
|
||||||
|
* and the wire and the volume stated for each, because 44.7's answer was
|
||||||
|
never about clocks.
|
||||||
|
|
||||||
|
WHAT IT DOES NOT DO. It does not settle 47.4 -- whether buffer mode BLANKS the
|
||||||
|
graphics layer, which MAME asserts and px68k is silent about (48.1), and which
|
||||||
|
needs a real board. It PRICES both branches instead, and the blanking section
|
||||||
|
is where the measured paint earns its keep: the black interval is the paint, and
|
||||||
|
until now the paint was a range read off an unpacked measurement ("~27% to ~54%",
|
||||||
|
48.3) rather than a number.
|
||||||
|
"""
|
||||||
|
import sys, os, re, argparse, csv
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import numpy as np
|
||||||
|
from dlx import DLX
|
||||||
|
import buscost as B
|
||||||
|
|
||||||
|
CPUHZ = 10e6 # stock X68000, MAME 0.277 x68k.cpp:1133
|
||||||
|
GAME_S = 22.8 * 60 # the full-disc survey's runtime (ROADMAP C1)
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("container", nargs="?", default="tmp/rc_fr_singe_scsi_span.dlx",
|
||||||
|
help="the CODEC baseline this is compared against")
|
||||||
|
ap.add_argument("--csv", default="tmp/c68k_frames.csv",
|
||||||
|
help="per-frame C68K measurement of that container")
|
||||||
|
ap.add_argument("--blit-log", default="tmp/blit_v8.log",
|
||||||
|
help="tools/bench/blit.lua's log -- where the MEASURED paint "
|
||||||
|
"costs are read from. No defaults are compiled in.")
|
||||||
|
ap.add_argument("--kbps", type=float, default=None,
|
||||||
|
help="a delivery rate to score the wire against. OPTIONAL and "
|
||||||
|
"there is no default (FINDINGS 50).")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
# --- the measured paint, read out of the run's own log ---------------------
|
||||||
|
# NOT transcribed into this file. A constant copied out of a log is a constant
|
||||||
|
# that stops tracking the log, and this project has been caught by a stale
|
||||||
|
# number twice (60.8). If the log is not there the tool refuses rather than
|
||||||
|
# substituting a plausible one.
|
||||||
|
if not os.path.exists(a.blit_log):
|
||||||
|
sys.exit(f"missing {a.blit_log} -- run tools/bench/blit.lua first:\n"
|
||||||
|
f" cd tmp && mame x68000 -bios ipl10 -ramsize 2M -video soft "
|
||||||
|
f"-window -sound none -nothrottle -plugins \\\n"
|
||||||
|
f" -autoboot_script ../tools/bench/blit.lua -seconds_to_run 60")
|
||||||
|
blit = {}
|
||||||
|
for line in open(a.blit_log, errors="replace"):
|
||||||
|
m = re.search(r"V(\d+)\s+(\d+) cyc", line)
|
||||||
|
if m:
|
||||||
|
blit[int(m.group(1))] = int(m.group(2))
|
||||||
|
for v in (1, 2, 3, 4, 8, 9, 10):
|
||||||
|
if v not in blit:
|
||||||
|
sys.exit(f"{a.blit_log} has no V{v} result -- the summary is incomplete, "
|
||||||
|
f"so the run did not finish and nothing here can be quoted.")
|
||||||
|
|
||||||
|
# --- the codec baseline: the container, and its MEASURED decode ------------
|
||||||
|
d = DLX(a.container)
|
||||||
|
FPS = d.fps
|
||||||
|
FRAME_CLK = CPUHZ / FPS
|
||||||
|
meas = {}
|
||||||
|
if os.path.exists(a.csv):
|
||||||
|
for r in csv.DictReader(open(a.csv)):
|
||||||
|
meas[int(r["frame"])] = int(r["cycles"])
|
||||||
|
if not meas:
|
||||||
|
sys.exit(f"missing {a.csv} -- the codec row's decode term is MEASURED and "
|
||||||
|
f"there is no derived stand-in for it.")
|
||||||
|
NF = max(meas) + 1
|
||||||
|
codec_decode = np.mean([meas[f] for f in range(NF)])
|
||||||
|
codec_bpf = sum(d.record_lengths()[:NF]) / NF # the PADDED record (60.7)
|
||||||
|
|
||||||
|
# --- geometry, which is where the decoder-free rows come from -------------
|
||||||
|
W_PX, H_PX = d.W, d.H
|
||||||
|
NPX = W_PX * H_PX
|
||||||
|
UNPACKED_BPF = NPX * 2 # one pixel per word, high byte discarded
|
||||||
|
PACKED_BPF = NPX * 1 # R20 bit 11 + page scroll (47.2, measured)
|
||||||
|
|
||||||
|
aud_bpf = B.ADPCM_BYTES_PER_S / FPS
|
||||||
|
AUD_CLK = aud_bpf * B.ADPCM_CLK_BYTE_BEST # best case, so every row is
|
||||||
|
# the optimistic end
|
||||||
|
# A device->GVRAM channel cannot walk a 1024-byte line stride inside one
|
||||||
|
# transfer: it writes a contiguous run. 192 rows therefore need 192 array-chain
|
||||||
|
# entries -- and SESSION 29 RAN THAT, off the disc, through src/player/dma.i's
|
||||||
|
# DM_BARV/DM_BTCV: eight rows at the 1024 B stride landed from ONE channel start
|
||||||
|
# with the CPU halted throughout (tools/bench/dma_run.sh, `[chain]`). So the
|
||||||
|
# MECHANISM is demonstrated and the CPU does not restart the channel per row.
|
||||||
|
# The COST is still datasheet arithmetic -- 36 clocks an entry, Fig 4-25 sheet 1,
|
||||||
|
# buscost.DMA_CHAIN_CLK -- because MAME's DMAC runs on wall-clock attotimes and
|
||||||
|
# cannot be asked what anything costs (42.5).
|
||||||
|
CHAIN_CLK = H_PX * B.DMA_CHAIN_CLK
|
||||||
|
|
||||||
|
print(f"""{a.container}: {NF} frames of {W_PX}x{H_PX} at {FPS:g} fps
|
||||||
|
frame slot on a 10 MHz 68000: {FRAME_CLK:,.0f} clocks
|
||||||
|
paint costs MEASURED by tools/bench/blit.lua, read from {a.blit_log}:
|
||||||
|
V1 unpacked movem blit {blit[1]:>9,} clk {100*blit[1]/FRAME_CLK:5.1f}% (96 KB read + 96 KB write)
|
||||||
|
V2 byte-source expansion {blit[2]:>9,} clk {100*blit[2]/FRAME_CLK:5.1f}% (48 KB read + 96 KB write)
|
||||||
|
V3 write-only floor {blit[3]:>9,} clk {100*blit[3]/FRAME_CLK:5.1f}% (no source read at all)
|
||||||
|
V8 PACKED movem blit {blit[8]:>9,} clk {100*blit[8]/FRAME_CLK:5.1f}% (48 KB read + 48 KB write)
|
||||||
|
|
||||||
|
V8 is {100*blit[8]/blit[1]:.1f}% of V1 and {100*blit[8]/blit[3]:.1f}% of V3 -- so PACKED PAINT COSTS WHAT THE
|
||||||
|
UNPACKED PATH PAYS TO WRITE ALONE, with its source read thrown in free.
|
||||||
|
It is not exactly half of V1 because the 192-row loop does not halve with
|
||||||
|
the words: per word V1 is {blit[1]/(NPX):.3f} clk and V8 is {blit[8]/(NPX//2):.3f}.""")
|
||||||
|
|
||||||
|
# --- the architectures ----------------------------------------------------
|
||||||
|
# Each is (label, bytes on the wire per frame, CPU clocks per frame that are
|
||||||
|
# NOT the transport, and whether the transport lands in GVRAM or in RAM).
|
||||||
|
ARCH = [
|
||||||
|
("CODEC, CPU-decoded (the shipping design)", codec_bpf, codec_decode, "ring"),
|
||||||
|
("free / DMAC device->GVRAM / unpacked", UNPACKED_BPF, CHAIN_CLK, "gvram"),
|
||||||
|
("free / DMAC device->GVRAM / PACKED", PACKED_BPF, CHAIN_CLK, "gvram"),
|
||||||
|
("free / CPU-painted / unpacked, 2 B/px wire", UNPACKED_BPF, blit[1], "ring"),
|
||||||
|
("free / CPU-painted / unpacked, 1 B/px wire", PACKED_BPF, blit[2], "ring"),
|
||||||
|
("free / CPU-painted / PACKED", PACKED_BPF, blit[8], "ring"),
|
||||||
|
]
|
||||||
|
|
||||||
|
LADDER = [
|
||||||
|
(5.0, "single address, held -- needs a request line (B3)"),
|
||||||
|
(9.0, "dual address, held -- the FLOOR (59.2/59.7)"),
|
||||||
|
(12.0, "single address, arbitrated"),
|
||||||
|
(16.0, "what the ROM programs for SASI, best"),
|
||||||
|
(19.0, "what the ROM programs for SASI, worst"),
|
||||||
|
(87.28, "PIO -- MEASURED, 58.2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("WHAT EACH ARCHITECTURE COSTS A FRAME, over the transport ladder\n")
|
||||||
|
print(f" audio DMA is charged in every row at {AUD_CLK:,.0f} clk "
|
||||||
|
f"({100*AUD_CLK/FRAME_CLK:.2f}%), best case.\n")
|
||||||
|
hdr = f" {'architecture':<44}{'B/frame':>9}" + "".join(f"{f'W={w:g}':>9}" for w, _ in LADDER)
|
||||||
|
print(hdr)
|
||||||
|
print(" " + "-" * (len(hdr) - 2))
|
||||||
|
for label, bpf, cpu, dest in ARCH:
|
||||||
|
cells = []
|
||||||
|
for w, _ in LADDER:
|
||||||
|
tot = bpf * w + cpu + AUD_CLK
|
||||||
|
pct = 100 * tot / FRAME_CLK
|
||||||
|
cells.append(f"{pct:>8.1f}%" if pct < 1000 else f"{pct:>8.0f}%")
|
||||||
|
print(f" {label:<44}{bpf:>9,.0f}" + "".join(cells))
|
||||||
|
print(f"""
|
||||||
|
100% is the frame deadline. Every cell is CPU work plus transport plus
|
||||||
|
best-case audio; none of them overlap, because the 68000 has no cache and a
|
||||||
|
two-word prefetch queue that empties at once (buscost.DMA_OVERLAPS = False).
|
||||||
|
|
||||||
|
THE TWO ROWS THAT MATTER ARE THE FLOOR COLUMN, W=9, because 59.2 found that
|
||||||
|
the only configurations this machine can be shown to run are dual-address,
|
||||||
|
and a dual-address byte is a 4-clock read of the device plus a 5-clock write
|
||||||
|
to memory. Every column left of it is a hardware fact nobody here has.""")
|
||||||
|
|
||||||
|
# --- the wire, which is what 44.7 actually died on ------------------------
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("THE WIRE AND THE MEDIUM -- 44.7's real objection\n")
|
||||||
|
print(f" {'architecture':<44}{'B/frame':>9}{'KB/s':>9}{'GB for 22.8 min':>18}")
|
||||||
|
print(" " + "-" * 78)
|
||||||
|
seen = set()
|
||||||
|
for label, bpf, cpu, dest in ARCH:
|
||||||
|
kbs = bpf * FPS / 1024
|
||||||
|
gb = bpf * FPS * GAME_S / 1e9
|
||||||
|
print(f" {label:<44}{bpf:>9,.0f}{kbs:>9.1f}{gb:>18.2f}")
|
||||||
|
print(f"""
|
||||||
|
The codec row is the gate container, which is deliberately the heaviest thing
|
||||||
|
the encoder emits (59.7). The default `need` recipe is 267.9 KB/s and E7's
|
||||||
|
byte target at the 9 clk/B floor is 327 KB/s (60.7).
|
||||||
|
|
||||||
|
SO THE PACKED DECODER-FREE PLAYER ASKS FOR {PACKED_BPF*FPS/1024:.0f} KB/s -- {PACKED_BPF*FPS/1024/327:.2f}x E7's target and
|
||||||
|
{PACKED_BPF*FPS/1024/(codec_bpf*FPS/1024):.2f}x the gate container -- and it asks for it AT A FIXED RATE. A codec's
|
||||||
|
bitrate is a lever; a literal frame's is geometry, and there is no scene in
|
||||||
|
the picture that costs less than another.""")
|
||||||
|
if a.kbps:
|
||||||
|
R = a.kbps * 1024
|
||||||
|
print(f"\n against a supplied {a.kbps:g} KB/s:")
|
||||||
|
for label, bpf, cpu, dest in ARCH:
|
||||||
|
need = bpf * FPS
|
||||||
|
print(f" {label:<44}{'FITS' if need <= R else 'SHORT BY '}"
|
||||||
|
f"{'' if need <= R else f'{(need-R)/1024:.0f} KB/s'}"
|
||||||
|
f" ({need/1024:.0f} KB/s wanted)")
|
||||||
|
|
||||||
|
# --- 47.4's two branches, priced -----------------------------------------
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("IF BUFFER MODE BLANKS THE LAYER (47.4 / 48, MAME's reading)\n")
|
||||||
|
print(""" R20 bit 11 only has to be SET across the GVRAM writes, so the black
|
||||||
|
interval is the paint and not the frame -- and which paint depends on where
|
||||||
|
the transport lands. That asymmetry has not been stated before:\n""")
|
||||||
|
print(f" {'architecture':<44}{'black interval':>16} {'set for':<14}")
|
||||||
|
print(" " + "-" * 78)
|
||||||
|
for label, bpf, cpu, dest in ARCH[1:]:
|
||||||
|
if dest == "gvram":
|
||||||
|
# the channel writes GVRAM, so the bit is set for the whole transfer
|
||||||
|
# DMA rungs only: a PIO transport is not a channel writing GVRAM, so
|
||||||
|
# 87.28 has no meaning in a device->GVRAM row.
|
||||||
|
rows = [bpf * w + CHAIN_CLK for w, _ in LADDER if w < 20]
|
||||||
|
span = f"{100*min(rows)/FRAME_CLK:.0f}%..{100*max(rows)/FRAME_CLK:.0f}%"
|
||||||
|
note = "the whole DMA"
|
||||||
|
else:
|
||||||
|
span = f"{100*cpu/FRAME_CLK:.1f}%"
|
||||||
|
note = "the blit only"
|
||||||
|
print(f" {label:<44}{span:>16} {note:<14}")
|
||||||
|
print(f"""
|
||||||
|
THE CPU-PAINTED PACKED PATH HAS THE SMALLEST BLACK WINDOW OF ANY OF THEM --
|
||||||
|
{100*blit[8]/FRAME_CLK:.1f}% -- because its transport lands in RAM, where bit 11 is irrelevant,
|
||||||
|
and only the {blit[8]:,}-clock blit needs the bit set. The DMAC-direct path,
|
||||||
|
which is cheaper in clocks at every rung of the ladder, is the one that must
|
||||||
|
hold the bit across its whole transfer. Under MAME's reading the cheap
|
||||||
|
architecture is the dark one.
|
||||||
|
|
||||||
|
Both are a strobe at the frame rate over the whole picture, and the packed
|
||||||
|
layout has no page to flip to: both 256-colour pages carry picture, which is
|
||||||
|
the entire point of it (48.3). {100*blit[8]/FRAME_CLK:.1f}% black at 12 Hz is not a tear.
|
||||||
|
|
||||||
|
IF PX68K IS RIGHT AND IT DOES NOT BLANK, every number above stands as
|
||||||
|
written. Neither emulator is authority and 48.1 is why the prior leans
|
||||||
|
MAME's way: MAME asserts the semantic twice and deliberately, px68k's
|
||||||
|
display path never reads the bit at all. That is an assertion against a
|
||||||
|
silence, not a tie, and it is settled by a board and the two-line probe in
|
||||||
|
tools/bench/probe_bit11_blank.lua.""")
|
||||||
|
|
||||||
|
# --- 47.6.4: does the CODEC survive the packed layout? --------------------
|
||||||
|
# Open since session 16 and never touched: "under the packed layout a word spans
|
||||||
|
# two columns 128 apart. Whether the existing codec survives that is untouched."
|
||||||
|
# There are exactly two ways it could, and blit.s V9 and V10 are them.
|
||||||
|
sk_blocks = sk_tot = pair_sk = pair_tot = 0
|
||||||
|
for f in range(NF):
|
||||||
|
m = d.modes(f).reshape(d.nby, d.nbx)
|
||||||
|
L, R = m[:, :d.nbx // 2], m[:, d.nbx // 2:]
|
||||||
|
sk_blocks += int((m == 0).sum()); sk_tot += m.size
|
||||||
|
pair_sk += int(((L == 0) & (R == 0)).sum()); pair_tot += L.size
|
||||||
|
paint_now = 1 - sk_blocks / sk_tot
|
||||||
|
paint_pair = 1 - pair_sk / pair_tot
|
||||||
|
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("CAN THE CODEC BE PACKED TOO? -- 47.6.4, open since session 16\n")
|
||||||
|
print(f""" A 4x4 block owns four bytes at STRIDE 2 under the packed layout, because
|
||||||
|
the high bytes of its four words belong to the block 128 columns away. So a
|
||||||
|
block decoder has two options and neither is free:
|
||||||
|
|
||||||
|
{'V4 block order, UNPACKED (the shipping shape)':<52}{blit[4]:>9,} clk {100*blit[4]/FRAME_CLK:5.1f}%
|
||||||
|
{'V9 block order, PACKED, 16 move.b at stride 2':<52}{blit[9]:>9,} clk {100*blit[9]/FRAME_CLK:5.1f}%
|
||||||
|
{'V10 block order, PACKED, blocks PAIRED (movem back)':<52}{blit[10]:>9,} clk {100*blit[10]/FRAME_CLK:5.1f}%
|
||||||
|
|
||||||
|
V9 IS {100*blit[9]/blit[4]-100:.0f}% DEARER THAN V4, not cheaper. Packing buys a block decoder
|
||||||
|
nothing on the wire either -- a codeword is already one byte a pixel -- so
|
||||||
|
that route buys NOTHING and costs {blit[9]-blit[4]:,} clocks a frame to buy it.
|
||||||
|
|
||||||
|
V10 halves the paint, and pays for it in the mode map. A pair skips only if
|
||||||
|
BOTH its blocks skip, and on this container:
|
||||||
|
|
||||||
|
SKIP blocks now {100*sk_blocks/sk_tot:5.1f}% painted now {100*paint_now:5.1f}%
|
||||||
|
SKIP block PAIRS {100*pair_sk/pair_tot:5.1f}% painted paired {100*paint_pair:5.1f}%
|
||||||
|
|
||||||
|
So pairing paints {paint_pair/paint_now:.2f}x as many blocks for {blit[10]/blit[4]:.2f}x the paint per block --
|
||||||
|
{100*(paint_pair/paint_now)*(blit[10]/blit[4])-100:+.0f}% on the clock, and about {100*(paint_pair/paint_now-1):+.0f}% on the BYTES, because a coded
|
||||||
|
block is bytes in the container whether its half of the pair changed or not.
|
||||||
|
E7 needs the bytes DOWN {100*(codec_bpf*FPS/1024)/327-100:.0f}%.
|
||||||
|
|
||||||
|
SO PACKING BELONGS TO THE LITERAL PLAYER AND ONLY TO IT. 47.6.4 is closed:
|
||||||
|
the packed layout is not an upgrade the existing codec can take, it is the
|
||||||
|
thing you get INSTEAD of the codec.""")
|
||||||
|
|
||||||
|
# --- the palette, which is where the literal player stops being a compromise --
|
||||||
|
# 46.3 measured these while pricing the TEXT PLANE and the 256-colour rows were
|
||||||
|
# only there for scale. They answer a question nobody put to them: a literal
|
||||||
|
# player has no codebooks, so it is not tied to a scene-wide palette the way the
|
||||||
|
# codec is (vq.scene_palette exists BECAUSE codewords are indices into it), and
|
||||||
|
# per-frame palettes become legal. Re-run 18_text_plane_16col.py to reproduce.
|
||||||
|
PSNR_SHIPPED = 29.19 # docs/STATUS.md, --spans all, c=5, 496.7 KB/s
|
||||||
|
PSNR_SCENE_256 = 31.33 # 18_text_plane_16col.py, tmp/fr_singe, 120 frames
|
||||||
|
PSNR_FRAME_256 = 34.08 # the same window, per-frame palettes
|
||||||
|
PAL_BYTES = 512 # 256 entries x 1 word
|
||||||
|
|
||||||
|
pal_bpf = PACKED_BPF + PAL_BYTES
|
||||||
|
# The palette write, DERIVED from a MEASURED per-word constant: V8 moves a word
|
||||||
|
# into GVRAM for blit[8]/(NPX//2) clocks and the palette is 256 consecutive
|
||||||
|
# words at $E82000 in the same movem shape.
|
||||||
|
pal_clk = 256 * blit[8] / (NPX // 2)
|
||||||
|
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("AND THE PICTURE IS BETTER, WHICH NOBODY HAD ASKED\n")
|
||||||
|
print(f""" PSNR against the 24-bit source, 18_text_plane_16col.py over the same
|
||||||
|
120-frame window the whole tree is measured on:
|
||||||
|
|
||||||
|
{'shipping container (the codec, as it ships)':<48}{PSNR_SHIPPED:6.2f} dB
|
||||||
|
{'256 colours, SCENE palette -- the codec CEILING':<48}{PSNR_SCENE_256:6.2f} dB
|
||||||
|
{'256 colours, PER-FRAME palette':<48}{PSNR_FRAME_256:6.2f} dB
|
||||||
|
|
||||||
|
THE MIDDLE ROW IS A CEILING AND NOT A RIVAL. Every codeword the codec emits
|
||||||
|
is an index INTO the scene palette, so no amount of bitrate takes it past
|
||||||
|
{PSNR_SCENE_256:.2f} dB; it spends {codec_bpf*FPS/1024:.0f} KB/s to get within {PSNR_SCENE_256-PSNR_SHIPPED:.2f} dB of it.
|
||||||
|
|
||||||
|
A LITERAL FRAME HAS NO CODEBOOKS, so the scene palette is not forced on it,
|
||||||
|
and the bottom row is what it simply IS -- {PSNR_FRAME_256-PSNR_SHIPPED:+.2f} dB on the shipping
|
||||||
|
container and {PSNR_FRAME_256-PSNR_SCENE_256:+.2f} dB past the ceiling the codec cannot cross.
|
||||||
|
|
||||||
|
WHAT THE PER-FRAME PALETTE COSTS:
|
||||||
|
on the wire {PAL_BYTES} B a frame -> {pal_bpf:,} B, {pal_bpf*FPS/1024:.1f} KB/s (+{100*PAL_BYTES/PACKED_BPF:.1f}%)
|
||||||
|
in clocks ~{pal_clk:,.0f} ({100*pal_clk/FRAME_CLK:.2f}% of a frame) if the CPU writes it, DERIVED
|
||||||
|
from V8's measured {blit[8]/(NPX//2):.3f} clk/word in the same movem shape
|
||||||
|
in colours 254, not 256: the packed layout spends index 0 on the
|
||||||
|
transparency key and puts black at 255 (47.2,
|
||||||
|
prep_frame.py --pack-transparent), against --reserve-black's
|
||||||
|
one entry. The tree has already measured a reserved entry at
|
||||||
|
0.04 dB (60.3), so this is noise against {PSNR_FRAME_256-PSNR_SHIPPED:+.2f}.
|
||||||
|
|
||||||
|
SETTLED IN SESSION 30, AND THE ANSWER IS YES (FINDINGS 62): a channel writes
|
||||||
|
the palette registers at $E82000 byte-exact, and ONE array-chained start
|
||||||
|
crosses from those registers into GVRAM -- so the palette IS a 193rd chain
|
||||||
|
entry and the clocks row above is what the CPU pays only if it does the write
|
||||||
|
itself. dmagate.s runs 7-9. What that does NOT settle is the board: MAME maps
|
||||||
|
the palette to palette_device over memory_array, whose write16 is a plain
|
||||||
|
COMBINE_DATA, so there is no handler that could refuse a byte write and the
|
||||||
|
model cannot discriminate. ROADMAP B4.
|
||||||
|
|
||||||
|
AND THE PSNR FIGURES ARE PIL's MEDIANCUT, not this project's own palette
|
||||||
|
builder (vq.scene_palette / H.build). The DIRECTION is measured and the
|
||||||
|
magnitude is about right; if the packed player gets built, re-derive the
|
||||||
|
per-frame number against the builder that will actually ship it.""")
|
||||||
|
|
||||||
|
# --- the answer ----------------------------------------------------------
|
||||||
|
w9 = 9.0
|
||||||
|
free_packed_dma = PACKED_BPF * w9 + CHAIN_CLK + AUD_CLK
|
||||||
|
free_packed_cpu = PACKED_BPF * w9 + blit[8] + AUD_CLK
|
||||||
|
# ... and the same two rows with the PER-FRAME PALETTE actually charged, which
|
||||||
|
# is what a player ships. The picture rows above are the comparison against the
|
||||||
|
# codec and are left alone so the published 55.2% / 81.6% do not drift; these
|
||||||
|
# are the shipping figures. Session 30 (FINDINGS 62) made the DMAC row's
|
||||||
|
# version legal: the palette is a 193rd chain ENTRY, so it costs 512 more
|
||||||
|
# delivered bytes and one more entry rather than 256 CPU word writes.
|
||||||
|
pal_dma = (PACKED_BPF + PAL_BYTES) * w9 + CHAIN_CLK + B.DMA_CHAIN_CLK + AUD_CLK
|
||||||
|
pal_cpu = (PACKED_BPF + PAL_BYTES) * w9 + blit[8] + pal_clk + AUD_CLK
|
||||||
|
codec_9 = codec_bpf * w9 + codec_decode + AUD_CLK
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print(f"""THE ANSWER, AT THE ONE RUNG THIS MACHINE CAN BE SHOWN TO RUN (W=9)
|
||||||
|
|
||||||
|
CODEC, gate container {100*codec_9/FRAME_CLK:6.1f}% of the frame -- DOES NOT FIT
|
||||||
|
free / DMAC->GVRAM / PACKED {100*free_packed_dma/FRAME_CLK:6.1f}% -- FITS, with {100-100*free_packed_dma/FRAME_CLK:.0f}% to spare
|
||||||
|
free / CPU-painted / PACKED {100*free_packed_cpu/FRAME_CLK:6.1f}% -- FITS, with {100-100*free_packed_cpu/FRAME_CLK:.0f}% to spare
|
||||||
|
|
||||||
|
WITH THE PER-FRAME PALETTE CHARGED, which is what would ship:
|
||||||
|
|
||||||
|
DMAC-direct, palette on the CHAIN (62) {100*pal_dma/FRAME_CLK:6.1f}% of the frame, {(PACKED_BPF+PAL_BYTES)*FPS/1024:.0f} KB/s
|
||||||
|
CPU-painted, palette written by the CPU {100*pal_cpu/FRAME_CLK:6.1f}% of the frame, {(PACKED_BPF+PAL_BYTES)*FPS/1024:.0f} KB/s
|
||||||
|
|
||||||
|
The palette costs the same on the WIRE either way -- {PAL_BYTES} B a frame,
|
||||||
|
+{100*PAL_BYTES/PACKED_BPF:.1f}% -- and the wire is where this design is expensive. The gap
|
||||||
|
between the two rows is the PAINT, not the palette.
|
||||||
|
|
||||||
|
What session 30 bought is smaller than either and is worth stating exactly:
|
||||||
|
{pal_clk:,.0f} CPU clocks of palette writing replaced by one more chain entry at
|
||||||
|
{B.DMA_CHAIN_CLK} clocks, a net {100*(pal_clk-B.DMA_CHAIN_CLK)/FRAME_CLK:.2f}% of a frame -- plus the structural half,
|
||||||
|
which is that the video path then contains no per-frame PAINT at all. The
|
||||||
|
CPU still issues the READ(10) and starts the channel, and neither of those
|
||||||
|
is priced anywhere in this tree.
|
||||||
|
|
||||||
|
THE DECODER-FREE PACKED PLAYER FITS THE CLOCK BUDGET THAT THE CODEC MISSES.
|
||||||
|
That is not a small correction to 47.5, it is the reverse of the reason the
|
||||||
|
codec exists. 44.7 said it in advance and on a different cost model: "the
|
||||||
|
codec is not there to save CPU -- it is there to save the wire." The
|
||||||
|
measured model agrees, and now says the CPU side is not merely affordable
|
||||||
|
but strictly cheaper WITHOUT the codec: at the floor, decoding {codec_bpf:,.0f} bytes
|
||||||
|
costs {100*(codec_bpf*w9+codec_decode)/FRAME_CLK:.0f}% of a frame and NOT decoding {PACKED_BPF:,} costs {100*(PACKED_BPF*w9+blit[8])/FRAME_CLK:.0f}%.
|
||||||
|
|
||||||
|
SO THE QUESTION IS ENTIRELY A MEDIUM QUESTION, and it has two halves:
|
||||||
|
|
||||||
|
1. {PACKED_BPF*FPS/1024:.0f} KB/s SUSTAINED, with no lever to pull. ROADMAP B1 is
|
||||||
|
unmeasured; the 0.7-1.7 MB/s usually quoted for BlueSCSI on an X68000
|
||||||
|
is folklore with no published benchmark behind it. {PACKED_BPF*FPS/1024:.0f} KB/s sits
|
||||||
|
inside that range, which is exactly why the range has to be measured
|
||||||
|
rather than cited. A codec at 327 KB/s survives a slower answer; a
|
||||||
|
literal frame does not degrade, it drops.
|
||||||
|
2. {PACKED_BPF*FPS*GAME_S/1e9:.2f} GB for the whole game, against the codec's {codec_bpf*FPS*GAME_S/1e9:.2f} GB at the gate
|
||||||
|
recipe and ~{327*1024*GAME_S/1e9:.2f} GB at E7's target. That is a packaging fact (C3),
|
||||||
|
not a performance one.
|
||||||
|
|
||||||
|
AND 47.4 STILL SITS OVER ALL OF IT. Everything above assumes the layer is
|
||||||
|
visible while it is written. If it is not, the packed player is a {100*blit[8]/FRAME_CLK:.0f}% duty
|
||||||
|
strobe at best and there is no version of it that is merely expensive.""")
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""The PACKED container: does it hold, and what is the picture actually worth?
|
||||||
|
|
||||||
|
python3 tools/analysis/30_packed_container.py [packed.dlxp]
|
||||||
|
[--frames tmp/fr_singe] [--codec tmp/rc_fr_singe_scsi_span.dlx]
|
||||||
|
|
||||||
|
ROADMAP K2. Two jobs, and they are different kinds of claim.
|
||||||
|
|
||||||
|
1. THE FORMAT HOLDS. A packed record is written into the palette registers and
|
||||||
|
GVRAM by a DMA channel with no bounds test anywhere -- the channel has no
|
||||||
|
opinion about what it is copying (FINDINGS 62) -- so "the geometry is right"
|
||||||
|
is not a tidiness check, it is the whole of the container's correctness.
|
||||||
|
Round-trip, sector geometry, and the two reserved indices are gated here.
|
||||||
|
|
||||||
|
2. THE PICTURE IS RE-DERIVED, and this is the number session 30 asked for.
|
||||||
|
FINDINGS 61.9 measured the packed player at 34.08 dB against the codec's
|
||||||
|
29.19 and filed TWO caveats: the quantiser was PIL's free 256-colour
|
||||||
|
MEDIANCUT rather than this project's builder, and the figure was quoted in
|
||||||
|
the RGB888 palette domain. Both are paid here:
|
||||||
|
|
||||||
|
* `vq.frame_palette` is what ships it -- 254 colours, because the packed
|
||||||
|
layout spends index 0 on the transparency key and 255 on black (47.2).
|
||||||
|
* the GRB555+I WORD is charged. A palette entry in a packed record is
|
||||||
|
already a hardware word; the display renders 5 bits a channel with one
|
||||||
|
shared LSB (23.3). Every PSNR in this project's encoder is measured
|
||||||
|
upstream of that, so the codec is charged it here too and the comparison
|
||||||
|
stays like for like.
|
||||||
|
|
||||||
|
And the scene-palette CONTROL is built and scored, because "per-frame
|
||||||
|
palettes became legal" is the mechanism 61.9 credits and an unrun control is
|
||||||
|
an assumption. The codec cannot take this row: every codeword it emits is an
|
||||||
|
index INTO `vq.scene_palette`, so 31.33 dB is its ceiling at any bitrate.
|
||||||
|
|
||||||
|
WHAT THIS DOES NOT DO. It does not put a packed frame on a machine -- that is
|
||||||
|
K3, and the layout itself was already rendered pixel-exactly on both emulators
|
||||||
|
in 47.2. It does not price clocks: 29_packed_player.py owns that, off the
|
||||||
|
MEASURED blit, and nothing here moves it. And it settles nothing about the
|
||||||
|
medium: 582.0 KB/s is geometry, and whether anything sustains it is B1.
|
||||||
|
"""
|
||||||
|
import argparse, glob, os, sys
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
sys.path.insert(0, "tools/bench")
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
import vq as VQ
|
||||||
|
import dlxp as P
|
||||||
|
from dlx import DLX
|
||||||
|
from dlxload import pack_palette
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("packed", nargs="?", default="tmp/packed_singe.dlxp")
|
||||||
|
ap.add_argument("--frames", default="tmp/fr_singe")
|
||||||
|
ap.add_argument("--mismatch-png", default=None,
|
||||||
|
help="write the 62.5 mismatch as a picture: correct render | the "
|
||||||
|
"same frame under the NEXT frame's palette | the 24-bit "
|
||||||
|
"source. A dB is not a look, and this claim is about a look.")
|
||||||
|
ap.add_argument("--codec", default="tmp/rc_fr_singe_scsi_span.dlx",
|
||||||
|
help="the shipping container this replaces. Its PSNR is COMPUTED "
|
||||||
|
"from its own bytes, not transcribed from docs (60.8).")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
fail = []
|
||||||
|
d = P.DLXP(a.packed) # every format invariant is checked in here
|
||||||
|
print(f"{a.packed}: DLXP{d.version} {d.W}x{d.H} {d.fps}fps {d.nframes} frames")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# --- 1. the format -----------------------------------------------------------
|
||||||
|
print("THE FORMAT, and why each line is a gate and not a courtesy check:")
|
||||||
|
print(f" record {d.rec_bytes:,} B = {d.rec_bytes // P.SECTOR} sectors exactly, "
|
||||||
|
f"palette {d.pal_bytes} B "
|
||||||
|
f"{'LAST' if d.palette_last else 'first'}, picture {d.pic_bytes:,} B")
|
||||||
|
print(f" 1.0 B/pixel: {d.pic_bytes} bytes carry {d.W * d.H} pixels "
|
||||||
|
f"(the unpacked path needs {2 * d.W * d.H:,})")
|
||||||
|
|
||||||
|
zero = black = 0
|
||||||
|
for f in range(d.nframes):
|
||||||
|
idx = d.indices(f)
|
||||||
|
# The channel copies bytes; a container whose interleave is a byte out does
|
||||||
|
# not fail, it paints. So the round trip is the assertion that the bytes in
|
||||||
|
# the record ARE the picture, in the order GVRAM wants them.
|
||||||
|
if P.pack_picture(idx).tobytes() != d._split(f)[1]:
|
||||||
|
fail.append(f"frame {f}: the record does not round-trip through the "
|
||||||
|
f"interleave -- the container is not what it says it is")
|
||||||
|
break
|
||||||
|
zero += int((idx == 0).sum())
|
||||||
|
black += int((idx == 255).sum())
|
||||||
|
if zero:
|
||||||
|
fail.append(f"index 0 appears in the picture {zero:,} times -- it is the "
|
||||||
|
f"TRANSPARENCY KEY of the top page and must stay unused (47.2)")
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 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"\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 ----------------------------------------------------------
|
||||||
|
files = sorted(glob.glob(f"{a.frames}/f*.png"))[:d.nframes]
|
||||||
|
if len(files) < d.nframes:
|
||||||
|
sys.exit(f"{a.frames}: {len(files)} frames, container has {d.nframes}")
|
||||||
|
src = [np.asarray(Image.open(f).convert("RGB")) for f in files]
|
||||||
|
|
||||||
|
|
||||||
|
def rendered(pal):
|
||||||
|
"""RGB888 as the DISPLAY produces it, from the same maths the loader uses."""
|
||||||
|
return pack_palette(np.asarray(pal, np.uint8))[2]
|
||||||
|
|
||||||
|
|
||||||
|
def score(name, pal_rgb, idx_frames, note=""):
|
||||||
|
"""Two columns: the palette domain every encoder PSNR in this tree is
|
||||||
|
quoted in, and the hardware word the display actually renders."""
|
||||||
|
ren = rendered(pal_rgb)
|
||||||
|
p_pal = np.mean([VQ.psnr(s, np.asarray(pal_rgb)[i])
|
||||||
|
for s, i in zip(src, idx_frames)])
|
||||||
|
p_hw = np.mean([VQ.psnr(s, ren[i]) for s, i in zip(src, idx_frames)])
|
||||||
|
print(f" {name:<44s} {p_pal:6.2f} {p_hw:6.2f} {note}")
|
||||||
|
return p_pal, p_hw
|
||||||
|
|
||||||
|
|
||||||
|
print("PSNR vs the 24-bit source, mean over frames:")
|
||||||
|
print(f" {'':<44s} {'RGB888':>6} {'GRB555':>6}")
|
||||||
|
|
||||||
|
codec_pal = codec_hw = None
|
||||||
|
if os.path.exists(a.codec):
|
||||||
|
c = DLX(a.codec)
|
||||||
|
if c.nframes < d.nframes:
|
||||||
|
print(f" (the codec container has {c.nframes} frames and this has "
|
||||||
|
f"{d.nframes} -- its row is skipped rather than compared over a "
|
||||||
|
f"different window)")
|
||||||
|
else:
|
||||||
|
# Its rate is printed with it because this is the GATE container -- the
|
||||||
|
# heaviest stream the encoder emits, `--kbps 280 --span-kbps 488
|
||||||
|
# --spans all` (check.sh) -- and NOT the 496.7 KB/s / 29.19 dB "current
|
||||||
|
# encode" of the README. Two containers, two numbers; a row that named
|
||||||
|
# neither would invite the difference to be read as a drift.
|
||||||
|
ckbps = sum(c.record_lengths()) * c.fps / c.nframes / 1024
|
||||||
|
codec_pal, codec_hw = score("CODEC, the GATE container", c.pal,
|
||||||
|
c.decode_all()[:d.nframes],
|
||||||
|
f"{ckbps:.1f} KB/s, "
|
||||||
|
f"{os.path.basename(a.codec)}")
|
||||||
|
else:
|
||||||
|
print(f" (no codec container at {a.codec} -- its row is skipped)")
|
||||||
|
|
||||||
|
# The codec's CEILING: 256 colours, one palette for the scene, no VQ loss at
|
||||||
|
# all. Not a rival, a bound -- no bitrate takes the codec past this row.
|
||||||
|
ref, spal = VQ.scene_palette(src, reserve_black=True)
|
||||||
|
sidx = VQ.palettise(src, ref)
|
||||||
|
ceil_pal, ceil_hw = score("256c SCENE palette -- the CODEC'S CEILING",
|
||||||
|
spal, sidx, "no bitrate crosses this")
|
||||||
|
|
||||||
|
# The control for the mechanism 61.9 credits: same LAYOUT and the same 254
|
||||||
|
# picture colours, one palette for the scene instead of one per frame. It is
|
||||||
|
# built to 255 with black reserved and then black is MOVED from 0 to 255, which
|
||||||
|
# is the packed layout's convention (47.2) rather than the codec's -- so the
|
||||||
|
# only variable between this row and the container's is per-frame vs scene-wide.
|
||||||
|
cref, c255 = VQ.scene_palette(src, colors=255, reserve_black=True)
|
||||||
|
cpal = np.vstack([np.zeros((1, 3), np.uint8), c255[1:],
|
||||||
|
np.zeros((1, 3), np.uint8)])
|
||||||
|
cidx = [np.where(i == 0, np.uint8(255), i)
|
||||||
|
for i in VQ.palettise(src, cref)]
|
||||||
|
ctl_pal, ctl_hw = score("PACKED, 254c SCENE palette [the CONTROL]", cpal, cidx)
|
||||||
|
|
||||||
|
# And the container itself. The right-hand column is read out of the CONTAINER'S
|
||||||
|
# OWN BYTES -- `DLXP.render` unpacks the GRB555 words the record carries -- and
|
||||||
|
# the left-hand one is recomputed from the encoder, because a packed record has
|
||||||
|
# no RGB888 palette in it to score. The two are tied together by a gate rather
|
||||||
|
# than by trust: the palettes the encoder builds here must reproduce the
|
||||||
|
# container's indices exactly, or the left column is describing a different file.
|
||||||
|
pk_idx, pk_pal_rgb, mismatch, palbad = [], [], 0, 0
|
||||||
|
for n, s in enumerate(src):
|
||||||
|
pal, idx = VQ.frame_palette(s)
|
||||||
|
if not np.array_equal(idx, d.indices(n)):
|
||||||
|
mismatch += 1
|
||||||
|
# And the WORD. The encoder packed GRB555+I with `dlxload.pack_palette` and
|
||||||
|
# `DLXP.palette_rgb` unpacks it: two separate pieces of maths over the same
|
||||||
|
# 23.3 rule, and a container is the only place they meet. Required to agree,
|
||||||
|
# not assumed to -- a wrong shared LSB is a 1.96 dB bug that still renders.
|
||||||
|
if not np.array_equal(rendered(pal), d.palette_rgb(n)):
|
||||||
|
palbad += 1
|
||||||
|
pk_idx.append(idx)
|
||||||
|
pk_pal_rgb.append(pal)
|
||||||
|
if palbad:
|
||||||
|
fail.append(f"{palbad} of {d.nframes} records carry palette words that do "
|
||||||
|
f"not unpack to the RGB the encoder packed -- pack_palette and "
|
||||||
|
f"DLXP.palette_rgb disagree about GRB555+I")
|
||||||
|
if mismatch:
|
||||||
|
fail.append(f"{mismatch} of {d.nframes} frames re-quantise to different "
|
||||||
|
f"indices than the container holds -- the RGB888 column would "
|
||||||
|
f"be scoring a file that is not this one")
|
||||||
|
pk_pal = np.mean([VQ.psnr(s, p[i]) for s, p, i in zip(src, pk_pal_rgb, pk_idx)])
|
||||||
|
pk_hw = np.mean([VQ.psnr(s, d.render(f)) for f, s in enumerate(src)])
|
||||||
|
print(f" {'PACKED CONTAINER, 254c PER-FRAME':<44s} {pk_pal:6.2f} {pk_hw:6.2f} "
|
||||||
|
f"GRB555 read out of {os.path.basename(a.packed)}")
|
||||||
|
print()
|
||||||
|
print(" The right-hand column is the PLAYER'S number. Every PSNR this project")
|
||||||
|
print(" has quoted -- 29.19, 31.33, 34.08 -- lives in the left one, upstream of")
|
||||||
|
print(" the 5-bit hardware word (23.3), and 61.9's 34.08 is directly comparable")
|
||||||
|
print(" to the packed row's left-hand entry and to nothing else.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# --- 3. what it means --------------------------------------------------------
|
||||||
|
if codec_hw is not None:
|
||||||
|
print(f" packed vs the codec gate container, as the DISPLAY renders both: "
|
||||||
|
f"{pk_hw - codec_hw:+.2f} dB")
|
||||||
|
print(f" packed vs the codec's CEILING: "
|
||||||
|
f"{pk_hw - ceil_hw:+.2f} dB")
|
||||||
|
print(f" what the PER-FRAME palette is worth (vs the control): "
|
||||||
|
f"{pk_hw - ctl_hw:+.2f} dB")
|
||||||
|
print(f" what the GRB555 word costs the ceiling row: "
|
||||||
|
f"{ceil_hw - ceil_pal:+.2f} dB")
|
||||||
|
# 60.3 measured ONE reserved entry at 0.04 dB; the packed layout spends two.
|
||||||
|
# Scored here at scene scale, where the control makes it a clean subtraction.
|
||||||
|
print(f" what the packed layout's TWO reserved entries cost: "
|
||||||
|
f"{ctl_pal - ceil_pal:+.4f} dB (256c -> 254c, scene palette, RGB888)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# The three claims 61.9 makes, restated as gates. A tree where any of these
|
||||||
|
# flipped has a different answer to ROADMAP K and should say so out loud.
|
||||||
|
if codec_hw is not None and pk_hw <= codec_hw:
|
||||||
|
fail.append(f"the packed container is {pk_hw:.2f} dB and the codec it "
|
||||||
|
f"replaces is {codec_hw:.2f} -- 61.9's headline is inverted")
|
||||||
|
if pk_hw <= ceil_hw:
|
||||||
|
fail.append(f"the packed container is {pk_hw:.2f} dB and the codec's own "
|
||||||
|
f"CEILING is {ceil_hw:.2f} -- the per-frame palette bought "
|
||||||
|
f"nothing, and 61.9's reason for building this branch is gone")
|
||||||
|
if pk_hw <= ctl_hw:
|
||||||
|
fail.append(f"per-frame {pk_hw:.2f} dB is not better than the SCENE-palette "
|
||||||
|
f"control {ctl_hw:.2f} -- the mechanism 61.9 credits is absent")
|
||||||
|
|
||||||
|
# --- 4. FINDINGS 62.5, which needed this encoder to exist ---------------------
|
||||||
|
# 62.5 filed the chain's order -- palette first or 193rd -- as a free choice with
|
||||||
|
# a visible consequence, and said the severity "depends on how much the palette
|
||||||
|
# moves between consecutive frames, which is a property of the encoder K2 has
|
||||||
|
# not been written yet". It is written now, so the number exists.
|
||||||
|
#
|
||||||
|
# The mismatch is a WIPE, not a flash: rows arrive top to bottom, so at any
|
||||||
|
# instant part of the screen is right. What is bounded here is the WORST
|
||||||
|
# instant of each order -- the whole screen wrong -- which is the start of the
|
||||||
|
# transfer for palette-first and the end of it for palette-last. The mean over
|
||||||
|
# the transfer is about half of each, because the wipe is linear in rows.
|
||||||
|
print("FINDINGS 62.5 PRICED -- palette FIRST vs LAST, at the worst instant of each:")
|
||||||
|
churn = np.mean([int((d.palette_words(n) != d.palette_words(n - 1)).sum())
|
||||||
|
for n in range(1, d.nframes)])
|
||||||
|
first = np.mean([VQ.psnr(src[n - 1], d.palette_rgb(n)[d.indices(n - 1)])
|
||||||
|
for n in range(1, d.nframes)])
|
||||||
|
last = np.mean([VQ.psnr(src[n], d.palette_rgb(n - 1)[d.indices(n)])
|
||||||
|
for n in range(1, d.nframes)])
|
||||||
|
correct = np.mean([VQ.psnr(src[n], d.render(n)) for n in range(1, d.nframes)])
|
||||||
|
print(f" palette entries that CHANGE frame to frame: {churn:.1f} of 256 "
|
||||||
|
f"({100 * churn / 256:.0f}%) -- a per-frame palette is not a small delta")
|
||||||
|
print(f" palette FIRST, old rows under the new palette: {first:6.2f} dB "
|
||||||
|
f"({first - correct:+.2f} against the correct pairing)")
|
||||||
|
print(f" palette LAST, new rows under the old palette: {last:6.2f} dB "
|
||||||
|
f"({last - correct:+.2f})")
|
||||||
|
print(f" the container is currently palette "
|
||||||
|
f"{'LAST' if d.palette_last else 'FIRST'} (dlxp.py, --palette-last)")
|
||||||
|
if a.mismatch_png:
|
||||||
|
# The frame whose mismatch is CLOSEST TO THE MEAN, so the picture is not an
|
||||||
|
# outlier picked to make the point look worse than the number.
|
||||||
|
mis = np.array([VQ.psnr(src[n - 1], d.palette_rgb(n)[d.indices(n - 1)])
|
||||||
|
for n in range(1, d.nframes)])
|
||||||
|
n = int(np.argmin(np.abs(mis - mis.mean()))) + 1
|
||||||
|
z = lambda x: np.repeat(np.repeat(x, 2, 0), 2, 1)
|
||||||
|
gap = np.full((d.H * 2, 6, 3), 30, np.uint8)
|
||||||
|
Image.fromarray(np.concatenate(
|
||||||
|
[z(d.render(n - 1)), gap, z(d.palette_rgb(n)[d.indices(n - 1)]), gap,
|
||||||
|
z(src[n - 1])], axis=1)).save(a.mismatch_png)
|
||||||
|
print(f" wrote {a.mismatch_png}: frame {n-1} correct | frame {n-1} under "
|
||||||
|
f"frame {n}'s palette ({mis[n-1]:.2f} dB) | the 24-bit source")
|
||||||
|
print(" Both are one paint, and both are MOOT if buffer mode blanks the layer")
|
||||||
|
print(" (47.4/B2). This bounds the cost of being wrong; it does not decide it,")
|
||||||
|
print(" because dB over a whole frame is not what an eye sees in a wipe.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for x in fail:
|
||||||
|
print("FAIL " + x)
|
||||||
|
sys.exit(1 if fail else 0)
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""HOW LONG IS THE PICTURE ACTUALLY ON SCREEN? ROADMAP K3, FINDINGS 64.
|
||||||
|
|
||||||
|
python3 tools/analysis/31_display_duty.py [container.dlxp] [--rate KB/s ...]
|
||||||
|
|
||||||
|
THE QUESTION NOTHING IN THIS TREE HAD ASKED. Every budget in docs/FINDINGS.md
|
||||||
|
asks what a frame COSTS -- clocks, bus cycles, bytes on the wire. Session 32
|
||||||
|
built the packed player and ran it (src/player/packed.s), and the run reported a
|
||||||
|
number no budget has a column for: the write window was open on 99.5% of the
|
||||||
|
host frames, so the graphics layer was DARK for 99.5% of the scene. Every frame
|
||||||
|
was pixel-exact and almost none of them was visible.
|
||||||
|
|
||||||
|
WHY THAT IS ARITHMETIC AND NOT AN EMULATOR ARTEFACT. 256-colour GVRAM masks the
|
||||||
|
high byte of every write unless CRTC R20 bit 11 is set (46.5/47.1), and the
|
||||||
|
packed layout's whole 1.0 B/pixel claim is that one word carries two pixels --
|
||||||
|
so a packed write REQUIRES the bit. If buffer mode blanks the layer while the
|
||||||
|
bit is set (47.4/ROADMAP B2 -- MAME says it does, and 48.1's prior leans that
|
||||||
|
way), then the layer is dark for exactly as long as the window is open, and for
|
||||||
|
a DMAC-direct player the window is open for the whole transfer. There is no
|
||||||
|
second page to hide behind: the packed layout SPENDS both 256-colour pages,
|
||||||
|
which is the same fact that made a frame one channel start (FINDINGS 62).
|
||||||
|
|
||||||
|
dark fraction of a slot = record bytes / (data-phase rate x slot)
|
||||||
|
|
||||||
|
AND THE RATE IN THAT EXPRESSION IS THE BURST RATE, NOT THE SUSTAINED ONE. This
|
||||||
|
is the correction the session had to make to itself. The container's 582.0 KB/s
|
||||||
|
is a SUSTAINED requirement -- it decides whether record i arrives before slot i.
|
||||||
|
The dark fraction is set by how fast bytes move DURING THE DATA PHASE, which for
|
||||||
|
a drive with a read-ahead cache can be several times the sustained figure. The
|
||||||
|
two are independent, and a medium can pass one and fail the other:
|
||||||
|
|
||||||
|
sustained >= 582.0 KB/s or frames arrive late (B1, known)
|
||||||
|
data phase >> 582.0 KB/s or the frame is never displayed (NEW, and B1 has
|
||||||
|
no test for it)
|
||||||
|
|
||||||
|
THE OTHER PLAYER IN THE FAMILY DOES NOT HAVE THIS PROPERTY. A packed player
|
||||||
|
that DMAs the record into RAM and paints it with the CPU opens the window only
|
||||||
|
for the paint -- tools/bench/blit.s V8, MEASURED, not assumed -- which is a
|
||||||
|
fixed share of the slot no matter what the medium does. It costs more clocks
|
||||||
|
and 49 KB of RAM and it buys a picture that is on screen. FINDINGS 61.5 already
|
||||||
|
priced both in CLOCKS and ranked DMAC-direct first; this file is the column that
|
||||||
|
was missing from that table, and it reverses the ranking under B2-blanks.
|
||||||
|
"""
|
||||||
|
import argparse, os, re, sys
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from dlxp import DLXP
|
||||||
|
import buscost as B
|
||||||
|
|
||||||
|
CPUHZ = 10e6 # stock X68000, MAME 0.277 x68k.cpp:1133
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("container", nargs="?", default="tmp/packed_singe.dlxp")
|
||||||
|
ap.add_argument("--blit-log", default="tmp/blit_v8.log",
|
||||||
|
help="tools/bench/blit.lua's log -- the MEASURED packed paint. "
|
||||||
|
"Not a constant in this file: 47.6.1 filed the movem shape "
|
||||||
|
"as an assumption and session 29 measured it, and a second "
|
||||||
|
"copy of a measured number is how one of them goes stale.")
|
||||||
|
ap.add_argument("--rate", type=float, nargs="*", default=None,
|
||||||
|
help="data-phase rates to price, KB/s. REQUIRED to mean "
|
||||||
|
"anything: this project has no delivery figure and will "
|
||||||
|
"not default to one (FINDINGS 50).")
|
||||||
|
ap.add_argument("--run-log", default="tmp/packed_free_steal.log",
|
||||||
|
help="a free-running tools/bench/packed.lua log, for the "
|
||||||
|
"measured corroboration section")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
d = DLXP(a.container)
|
||||||
|
SLOT_S = 1.0 / d.fps
|
||||||
|
FRAME_CLK = CPUHZ * SLOT_S
|
||||||
|
wire = d.kbps() # KB/s, and fixed by geometry
|
||||||
|
|
||||||
|
blit = {}
|
||||||
|
if os.path.exists(a.blit_log):
|
||||||
|
for line in open(a.blit_log, errors="replace"):
|
||||||
|
m = re.search(r"V(\d+)\s+(\d+) cyc", line)
|
||||||
|
if m:
|
||||||
|
blit[int(m.group(1))] = int(m.group(2))
|
||||||
|
if 8 not in blit:
|
||||||
|
sys.exit(f"{a.blit_log} has no V8 result. The packed paint is a MEASUREMENT "
|
||||||
|
f"(tools/bench/blit.lua) and this tool will not substitute a "
|
||||||
|
f"constant for it -- run the blit bench, or point --blit-log at "
|
||||||
|
f"its log.")
|
||||||
|
PAINT_CLK = blit[8]
|
||||||
|
PAINT_FRAC = PAINT_CLK / FRAME_CLK
|
||||||
|
|
||||||
|
print(f"""THE CONTAINER (tools/encoder/dlxp.py)
|
||||||
|
{a.container}: {d.W}x{d.H} {d.fps} fps, {d.nframes} frames
|
||||||
|
record {d.rec_bytes:,} B = {d.rec_bytes // 512} sectors, palette \
|
||||||
|
{'LAST' if d.palette_last else 'FIRST'}
|
||||||
|
slot {SLOT_S*1000:.2f} ms = {FRAME_CLK:,.0f} clocks
|
||||||
|
wire {wire:.1f} KB/s -- FIXED by geometry. A codec's bitrate is a lever and a
|
||||||
|
literal frame's is not (61.6), so nothing an encoder does moves this.
|
||||||
|
|
||||||
|
THE TWO PACKED PLAYERS, and the difference is WHEN the write window is open
|
||||||
|
|
||||||
|
A. DMAC-DIRECT (src/player/packed.s, ROADMAP K3, and the one that is built).
|
||||||
|
One channel start, 193 destinations, the CPU halted or nearly. The window
|
||||||
|
must be open for the WHOLE data phase, because the channel writes when the
|
||||||
|
bytes arrive and the CPU cannot know when that is -- and a packed write
|
||||||
|
that lands with the bit clear is masked to its low byte and silently wrong.
|
||||||
|
B. DMA-TO-RAM + CPU PAINT. The record lands in RAM with the window shut; the
|
||||||
|
68000 then paints it with the packed movem blit. The window is open for the
|
||||||
|
PAINT and nothing else: {PAINT_CLK:,} clocks, {100*PAINT_FRAC:.1f}% of a slot,
|
||||||
|
MEASURED by tools/bench/blit.lua (V8), and INDEPENDENT of the medium.
|
||||||
|
|
||||||
|
Under 47.4-blanks the dark interval IS the open window, so B is on screen for
|
||||||
|
{100*(1-PAINT_FRAC):.1f}% of every slot at ANY rate that delivers the record at all,
|
||||||
|
and A's visibility is a function of the rate.
|
||||||
|
""")
|
||||||
|
|
||||||
|
rates = a.rate
|
||||||
|
if not rates:
|
||||||
|
print("""NO RATES GIVEN, so no table. This project retired its delivery
|
||||||
|
constant outright (FINDINGS 50, USER DECISION) and every tool requires an
|
||||||
|
explicit rate; a default here would be the same mistake in a new place. Pass
|
||||||
|
--rate with the figures you want priced. The three thresholds already derived
|
||||||
|
elsewhere, for reference and NOT as defaults:
|
||||||
|
453.6 KB/s the DLX5 codec gate container needs no prefill (49.5/60)
|
||||||
|
576.0 KB/s a packed container with no per-frame palette (61.5)
|
||||||
|
582.0 KB/s THIS container, palette included (63)
|
||||||
|
and note that all three are SUSTAINED figures. The dark fraction below is set by
|
||||||
|
the DATA-PHASE rate, which is a different measurement nothing has taken.""")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print("A's VISIBILITY, against the DATA-PHASE rate\n")
|
||||||
|
print(f" {'data phase':>12} | {'transfer':>9} | {'window open':>11} | "
|
||||||
|
f"{'PICTURE ON SCREEN':>17} | vs B")
|
||||||
|
print(f" {'KB/s':>12} | {'ms':>9} | {'% of slot':>11} | "
|
||||||
|
f"{'% of slot':>17} |")
|
||||||
|
print(" " + "-"*12 + "-+-" + "-"*9 + "-+-" + "-"*11 + "-+-" + "-"*17 + "-+-----")
|
||||||
|
for R in sorted(rates):
|
||||||
|
t_ms = d.rec_bytes / (R * 1024) * 1000
|
||||||
|
openf = min(1.0, t_ms / (SLOT_S * 1000))
|
||||||
|
vis = 1.0 - openf
|
||||||
|
verdict = ("A wins" if vis > 1 - PAINT_FRAC else
|
||||||
|
"B wins" if vis < 1 - PAINT_FRAC else "equal")
|
||||||
|
late = " LATE" if R < wire else ""
|
||||||
|
print(f" {R:>12.1f} | {t_ms:>9.2f} | {100*openf:>11.1f} | "
|
||||||
|
f"{100*vis:>17.1f} | {verdict}{late}")
|
||||||
|
|
||||||
|
# The crossover, stated as a rate rather than left to be read off the table: it
|
||||||
|
# is the one number in here a hardware acceptance test can be written against.
|
||||||
|
cross = d.rec_bytes / (PAINT_FRAC * SLOT_S) / 1024
|
||||||
|
print(f"""
|
||||||
|
A and B show the picture for the same share of the slot at a data-phase rate
|
||||||
|
of {cross:,.0f} KB/s. Below that, THE PLAYER WITH THE CPU IN THE LOOP IS ON
|
||||||
|
SCREEN LONGER than the one without it -- which is the reverse of FINDINGS
|
||||||
|
61.5's ranking, and 61.5 is not wrong: it ranked them in CLOCKS, and this is
|
||||||
|
the column that table does not have.
|
||||||
|
|
||||||
|
{cross:,.0f} KB/s is {cross/wire:.1f}x the container's own wire. So a medium that exactly
|
||||||
|
meets the sustained requirement puts the DMAC-direct player's picture on
|
||||||
|
screen for {100*max(0.0, 1-wire/wire):.0f}% of every slot: it delivers every frame, on time,
|
||||||
|
pixel-exact, and displays none of them.""")
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
THE CPU SIDE, so the trade is priced on both axes (FINDINGS 61.5's ladder)
|
||||||
|
|
||||||
|
W is clocks stolen per delivered byte. Only the dual-address rungs have a code
|
||||||
|
path on this machine (59.2), and 9 is the floor: a 4-clock read of the device
|
||||||
|
plus a 5-clock write to memory.
|
||||||
|
""")
|
||||||
|
print(f" {'W':>3} | {'A: DMAC-direct':>15} | {'B: DMA + CPU paint':>19}")
|
||||||
|
print(" " + "-"*3 + "-+-" + "-"*15 + "-+-" + "-"*19)
|
||||||
|
AUDIO = B.ADPCM_BYTES_PER_S / d.fps * B.ADPCM_CLK_BYTE_BEST
|
||||||
|
for W in (5, 9, 12, 16, 19):
|
||||||
|
xfer = d.rec_bytes * W
|
||||||
|
ca = (xfer + AUDIO) / FRAME_CLK
|
||||||
|
cb = (xfer + AUDIO + PAINT_CLK) / FRAME_CLK
|
||||||
|
print(f" {W:>3} | {100*ca:>14.1f}% | {100*cb:>18.1f}%")
|
||||||
|
print(f"""
|
||||||
|
Both include the audio DMA at {AUDIO:,.0f} clocks a frame ({100*AUDIO/FRAME_CLK:.2f}%), charged from
|
||||||
|
the IPL ROM's own channel-3 setup (21_iplrom_dmac.py, 52.5). Neither includes
|
||||||
|
a decoder, because neither has one.
|
||||||
|
|
||||||
|
So B costs the paint -- {100*PAINT_FRAC:.1f}% of a frame -- and TWO record buffers,
|
||||||
|
{2*d.rec_bytes:,} B of RAM. Two and not one: at any rate near the wire the delivery
|
||||||
|
of record i+1 occupies most of the slot the paint of record i happens in, so
|
||||||
|
they overlap by construction. On a 2 MB machine that is {200*d.rec_bytes/(2*1024*1024):.1f}% of memory and
|
||||||
|
it is the resource this design has spare -- the ring the packed branch deleted
|
||||||
|
was 256 KB (FINDINGS 49). That is what a visible picture costs if 47.4 blanks.""")
|
||||||
|
|
||||||
|
# ---- the measured corroboration. It is a SEPARATE section and it is bounded
|
||||||
|
# on purpose: MAME's device models carry no transfer timing (docs/BENCHMARK.md,
|
||||||
|
# 42.5), so the run cannot supply a rate for the table above -- what it can do
|
||||||
|
# is show that the mechanism is real and that the arithmetic predicts it.
|
||||||
|
if os.path.exists(a.run_log):
|
||||||
|
txt = open(a.run_log, errors="replace").read()
|
||||||
|
m_rate = re.search(r"record lands in ([\d.]+) ms, i\.e\. ([\d.]+) KB/s", txt)
|
||||||
|
m_open = re.search(r"WRITE WINDOW OPEN on (\d+) of (\d+) host frames", txt)
|
||||||
|
if m_rate and m_open:
|
||||||
|
ms, kbps = float(m_rate.group(1)), float(m_rate.group(2))
|
||||||
|
op, tot = int(m_open.group(1)), int(m_open.group(2))
|
||||||
|
pred = min(1.0, d.rec_bytes / (kbps * 1024) / SLOT_S)
|
||||||
|
print(f"""
|
||||||
|
MEASURED, on the emulated machine (tools/bench/packed_run.sh, free-running)
|
||||||
|
|
||||||
|
{a.run_log}: a {d.rec_bytes:,} B record landed in {ms:.2f} ms = {kbps:.1f} KB/s, and the
|
||||||
|
write window was open on {op} of {tot} host frames = {100*op/tot:.1f}%.
|
||||||
|
The expression above predicts {100*pred:.1f}% at that rate.
|
||||||
|
|
||||||
|
THIS IS NOT A RATE MEASUREMENT AND {kbps:.0f} KB/s IS NOT A MEDIUM. MAME's
|
||||||
|
device models carry no transfer timing (42.5); the figure is a property of the
|
||||||
|
apparatus. What the run DOES establish is that the mechanism is the one the
|
||||||
|
arithmetic describes -- and one thing more that no arithmetic could have
|
||||||
|
given: the DMAC CONFIGURATION DOES NOT MOVE IT. Held and stealing delivered
|
||||||
|
the same record within 0.5% of each other, so what a channel configuration
|
||||||
|
buys is who owns the CPU, not when the picture appears.""")
|
||||||
|
else:
|
||||||
|
print(f"""
|
||||||
|
NO MEASURED SECTION: {a.run_log} is absent. Run
|
||||||
|
tools/bench/packed_run.sh to produce it. The arithmetic above stands without
|
||||||
|
it -- it is geometry -- but the run is what showed the effect was there to be
|
||||||
|
derived at all.""")
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""WHAT DOES AUDIO DO TO THE CONTAINER? ROADMAP P6, the half that is not the bus.
|
||||||
|
|
||||||
|
python3 tools/analysis/32_audio_wire.py [packed.dlxp] [--audio tmp/au_singe.raw]
|
||||||
|
[--rate KB/s ...]
|
||||||
|
|
||||||
|
Session 20 (FINDINGS 52) closed the bus half of P6: a second DMA consumer at
|
||||||
|
7,812.5 B/s is 1.25%..1.48% of a frame, about 4% of what the decoder leaves, and
|
||||||
|
the 7.8 kB/s figure survived with a unit correction. ROADMAP P6 then says, in
|
||||||
|
as many words, that EVERYTHING ELSE in the item is open: extraction, an encoder,
|
||||||
|
the container interleave, and what a second stream does to `wire` and therefore
|
||||||
|
to `pipe - wire` and therefore to 51.3's refill climb.
|
||||||
|
|
||||||
|
This file is the container interleave and the wire. It is arithmetic over the
|
||||||
|
real container's real geometry -- no MAME run, no board.
|
||||||
|
|
||||||
|
THE THING THAT MAKES IT INTERESTING, and it is a property of DLXP1 rather than
|
||||||
|
of audio: **a packed container has no index and cannot have one.** A record's
|
||||||
|
address is `LBA0 + i*97` because a literal frame's length is geometry (FINDINGS
|
||||||
|
63, 64.1). Audio is a stream at a rate that has nothing to do with the frame
|
||||||
|
rate, so the naive interleave -- give record i the audio bytes belonging to slot
|
||||||
|
i -- makes records VARIABLE LENGTH, and the moment records are variable length
|
||||||
|
the format needs an index and stops being the format.
|
||||||
|
|
||||||
|
So the interleave has to be a FIXED CADENCE: every F frames, A whole sectors of
|
||||||
|
audio, placed between records. Then
|
||||||
|
|
||||||
|
LBA(i) = LBA0 + i*RECSEC + floor(i/F)*A
|
||||||
|
|
||||||
|
which is still two multiplies and a divide -- arithmetic, no index, nothing
|
||||||
|
walked -- and the only cost is that A*512 must be at least F frames' worth of
|
||||||
|
audio, so the padding is whatever A*512 exceeds it by. Choosing (F, A) is a
|
||||||
|
rational-approximation problem and the answer is NOT the obvious cadence.
|
||||||
|
"""
|
||||||
|
import argparse, os, sys
|
||||||
|
from fractions import Fraction
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
sys.path.insert(0, "tools/analysis")
|
||||||
|
import buscost as B
|
||||||
|
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",
|
||||||
|
help="raw s16le mono at the chip rate, from extract_audio.py")
|
||||||
|
ap.add_argument("--codec", default="tmp/rc_fr_singe_scsi_span.dlx",
|
||||||
|
help="the codec container, for the same arithmetic on the other branch")
|
||||||
|
ap.add_argument("--rate", type=float, nargs="*",
|
||||||
|
default=[453.6, 500.0, 582.0, 600.0, 650.0, 700.0],
|
||||||
|
help="explicit sustained delivery rates, KB/s")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
d = DLXP(a.container)
|
||||||
|
SLOT_S = 1.0 / d.fps
|
||||||
|
FRAME_CLK = B.CPU_HZ * SLOT_S if hasattr(B, "CPU_HZ") else 10_000_000 * SLOT_S
|
||||||
|
RECSEC = d.rec_bytes // SECTOR
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
=== THE STREAM =========================================================
|
||||||
|
The chip is an MSM6258V on an 8 MHz clock and it has three rates and no
|
||||||
|
others. Every budget in this tree is written against the first one.""")
|
||||||
|
|
||||||
|
RATES = {512: 15625.0, 768: 8_000_000/768, 1024: 7812.5}
|
||||||
|
print(f"\n {'divisor':>8} {'samples/s':>11} {'bytes/s':>10} {'B per 1/%d s slot' % d.fps:>19} exact?")
|
||||||
|
for div, hz in RATES.items():
|
||||||
|
bps = hz / 2
|
||||||
|
per = bps / d.fps
|
||||||
|
print(f" 8MHz/{div:<4} {hz:11,.1f} {bps:10,.1f} {per:19,.4f} "
|
||||||
|
f"{'yes' if per == int(per) else 'NO -- a remainder, like the frame clock (54)'}")
|
||||||
|
|
||||||
|
HZ = 15625.0
|
||||||
|
AU_BPS = HZ / 2 # 4 bits a sample, two samples to a byte
|
||||||
|
AU_FRAME = AU_BPS / d.fps # 651.0416... B, and the point is the dots
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
The shipping rate's per-slot figure is {AU_FRAME:,.4f} B and it is NOT an
|
||||||
|
integer -- 8 MHz / 512 / 2 / {d.fps} has a 12 in the denominator that 2**k
|
||||||
|
cannot clear. That is the same shape as FINDINGS 54's frame clock: what a
|
||||||
|
player carries is a remainder, not a count, and a container that rounds it
|
||||||
|
either drifts or underruns.""")
|
||||||
|
|
||||||
|
if os.path.exists(a.audio):
|
||||||
|
n16 = os.path.getsize(a.audio) // 2
|
||||||
|
secs = n16 / HZ
|
||||||
|
print(f"""
|
||||||
|
MEASURED, on the window this project gates everything on (00223 @539.4s,
|
||||||
|
{secs:.3f} s, tools/encoder/extract_audio.py):
|
||||||
|
{n16:,} samples -> {n16//2:,} B of ADPCM = {n16/2/secs:,.1f} B/s
|
||||||
|
which is {AU_BPS:,.1f} to the byte, so the rate is the rate.""")
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
=== THE INTERLEAVE, AND WHY THE OBVIOUS CADENCE IS THE WRONG ONE =======
|
||||||
|
A packed record is {d.rec_bytes:,} B = {RECSEC} sectors EXACTLY and its address is
|
||||||
|
arithmetic. Audio rides between records at a fixed cadence -- every F frames,
|
||||||
|
A whole sectors -- so that LBA(i) stays arithmetic. A must satisfy
|
||||||
|
|
||||||
|
A * {SECTOR} >= F * {AU_FRAME:,.4f} i.e. A/F >= {Fraction(int(AU_BPS*2), int(2*SECTOR*d.fps))} = {AU_FRAME/SECTOR:.9f}
|
||||||
|
|
||||||
|
and everything above that ratio is PADDING that the wire pays for and nothing
|
||||||
|
plays. Here is the whole small-F space, best A for each F:""")
|
||||||
|
|
||||||
|
target = Fraction(int(round(AU_BPS * 2)), 2 * SECTOR * d.fps) # sectors per frame, exact
|
||||||
|
|
||||||
|
rows, floor = [], None
|
||||||
|
for F in range(1, 241):
|
||||||
|
A = -(-(target.numerator * F) // target.denominator) # ceil(F * target)
|
||||||
|
have, need = A * SECTOR, F * AU_FRAME
|
||||||
|
waste = (have - need) / need
|
||||||
|
add = have / F * d.fps / 1024 # what the cadence puts on the wire, KB/s
|
||||||
|
rows.append((waste, F, A, have, need, add))
|
||||||
|
|
||||||
|
print(f"\n {'F':>4} {'A':>4} {'A*512 B':>10} {'needs':>12} {'padding':>9} {'waste':>7}"
|
||||||
|
f" {'wire adds':>10} {'player RAM':>11}")
|
||||||
|
seen = None
|
||||||
|
for waste, F, A, have, need, add in rows:
|
||||||
|
show = F <= 4 or seen is None or waste < seen - 1e-12
|
||||||
|
if seen is None or waste < seen: seen = waste
|
||||||
|
if show:
|
||||||
|
print(f" {F:4d} {A:4d} {have:10,} {need:12,.1f} {have-need:9,.1f} "
|
||||||
|
f"{100*waste:6.2f}% {add:9.2f} KB/s {have:9,} B")
|
||||||
|
|
||||||
|
best = sorted(rows)
|
||||||
|
w, F, A, have, need, add = best[0]
|
||||||
|
f1 = next(x for x in rows if x[1] == 1)
|
||||||
|
print(f""" THE FLOOR OF THAT SWEEP is F={F}, A={A}: {100*w:.3f}% padding, {add:.2f} KB/s of
|
||||||
|
wire for {AU_BPS/1024:.2f} KB/s of audio.
|
||||||
|
|
||||||
|
THE OBVIOUS CADENCE IS THE WORST ONE. F=1 -- one audio lump per record, which
|
||||||
|
is what "interleave the audio into the frame" means if nobody does the
|
||||||
|
arithmetic -- needs A={f1[2]} and costs {100*f1[0]:.1f}% padding: {AU_FRAME:,.1f} B rounded up to
|
||||||
|
{f1[3]:,}, so {f1[3]-AU_FRAME:,.1f} B of every record is nothing at all, and the wire pays
|
||||||
|
{f1[5]:.2f} KB/s for {AU_BPS/1024:.2f} KB/s of audio. That is {f1[5]-add:.2f} KB/s thrown away for
|
||||||
|
no reason but the cadence.
|
||||||
|
|
||||||
|
=== WHAT IT DOES TO THE WIRE ===========================================""")
|
||||||
|
|
||||||
|
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 "
|
||||||
|
f"({100*(tot/vid_kbs-1):+.2f}%)")
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
And this is what B1's acceptance test becomes. The packed container's
|
||||||
|
sustained requirement was {vid_kbs:.1f} KB/s SILENT (FINDINGS 61.5, 63) and it is
|
||||||
|
{vid_kbs + add:.1f} KB/s with sound. A literal frame's bitrate is geometry and cannot
|
||||||
|
be talked down; the audio on top of it is {add:.2f} KB/s and can only be talked down
|
||||||
|
by choosing a worse chip rate.""")
|
||||||
|
|
||||||
|
f11 = next(x for x in rows if x[1] == 11)
|
||||||
|
print(f"""
|
||||||
|
AND THE CADENCE HAS A SECOND PRICE, WHICH IS RAM. A cadence of F frames means
|
||||||
|
the player is holding F frames of audio, and holding it TWICE -- the channel
|
||||||
|
fills lump n+1 while the chip drains lump n, the same reason K4 needs two
|
||||||
|
record buffers (64.2). So the floor of the sweep is not the answer:
|
||||||
|
|
||||||
|
F={f1[1]:<3} {f1[3]:>7,} B a lump, {2*f1[3]:>7,} B held {100*f1[0]:6.2f}% padding {f1[5]:5.2f} KB/s
|
||||||
|
F={f11[1]:<3} {f11[3]:>7,} B a lump, {2*f11[3]:>7,} B held {100*f11[0]:6.2f}% padding {f11[5]:5.2f} KB/s <- the pick
|
||||||
|
F={F:<3} {have:>7,} B a lump, {2*have:>7,} B held {100*w:6.2f}% padding {add:5.2f} KB/s
|
||||||
|
|
||||||
|
F={f11[1]} buys {100*(f1[0]-f11[0]):.1f} points of padding for {2*f11[3]-2*f1[3]:,} B of RAM, and F={F} buys the
|
||||||
|
last {100*(f11[0]-w):.2f} of a point for {2*have-2*f11[3]:,} B more. On a machine where K4 already
|
||||||
|
wants 99,328 B for two record buffers, the second trade is not one.
|
||||||
|
|
||||||
|
=== THE ASYMMETRY: THE CODEC CONTAINER PAYS NONE OF THIS ===============""")
|
||||||
|
|
||||||
|
if os.path.exists(a.codec):
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
from dlx import DLX
|
||||||
|
c = DLX(a.codec)
|
||||||
|
lens = c.record_lengths() if callable(getattr(c, "record_lengths", None)) else c.record_lengths
|
||||||
|
cwire = sum(lens) / len(lens) * c.fps / 1024
|
||||||
|
print(f""" {os.path.basename(a.codec)}: {c.nframes} records, index {'PRESENT' if c.has_index else 'absent'},
|
||||||
|
records already VARIABLE ({min(lens):,}..{max(lens):,} B, mean {sum(lens)/len(lens):,.0f}) and
|
||||||
|
sector-aligned since DLX5 (60.1). A container that already carries an index
|
||||||
|
and already has variable records can put EXACTLY {AU_FRAME:,.1f} B of audio in record i
|
||||||
|
and pad only to the sector it was going to pad to anyway -- so its audio
|
||||||
|
padding is not 57.3% and not 1.11%, it is ZERO, and its wire goes
|
||||||
|
{cwire:.1f} -> {cwire + AU_BPS/1024:.1f} KB/s ({100*(AU_BPS/1024)/cwire:+.2f}%).
|
||||||
|
|
||||||
|
THAT IS THE FIRST COST THIS PROJECT HAS FOUND FOR THE PACKED BRANCH'S OWN
|
||||||
|
SIMPLIFICATION. "A record's length is geometry, so there is no index and none
|
||||||
|
can be needed" (63, 64.1) is what makes the packed player a page of arithmetic
|
||||||
|
instead of a parser -- and it is exactly the property that makes a second
|
||||||
|
stream at an unrelated rate cost padding, a cadence, and a buffer. It is a
|
||||||
|
small cost ({f11[5]-AU_BPS/1024:.2f} KB/s at the pick, {2*f11[3]:,} B of RAM) and it is not zero, and
|
||||||
|
nothing in FINDINGS 61-64 predicted it.""")
|
||||||
|
else:
|
||||||
|
print(f" SKIPPED: no codec container at {a.codec}")
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
=== WHAT IT DOES TO SLACK (51.3) =======================================
|
||||||
|
Slack is ACCUMULATED out of pipe - wire, so a second consumer does not cost a
|
||||||
|
fixed amount -- it costs the accumulation rate, and what a branch point costs is
|
||||||
|
set by that (51.3, 55.4). Silent vs sounded, at explicit rates:
|
||||||
|
|
||||||
|
{'pipe':>8} {'silent':>14} {'sounded':>14} what a second of play banks""")
|
||||||
|
for kbps in a.rate:
|
||||||
|
s_sl, a_sl = kbps - vid_kbs, kbps - (vid_kbs + add)
|
||||||
|
def fmt(x): return f"{x:+8.1f} KB/s" if x >= 0 else f"{x:+8.1f} KB/s"
|
||||||
|
print(f" {kbps:8.1f} {fmt(s_sl):>14} {fmt(a_sl):>14} "
|
||||||
|
+ ("both starve" if a_sl < 0 and s_sl < 0
|
||||||
|
else "SOUND IS WHAT BREAKS IT" if s_sl >= 0 > a_sl
|
||||||
|
else f"{a_sl/s_sl*100:.0f}% of the silent rate" if s_sl > 0 else ""))
|
||||||
|
|
||||||
|
AUCLK_LO = AU_FRAME * B.ADPCM_CLK_BYTE_BEST
|
||||||
|
AUCLK_HI = AU_FRAME * B.ADPCM_CLK_BYTE_WORST
|
||||||
|
print(f"""
|
||||||
|
=== AND WHAT IT DOES TO THE FRAME (the half session 20 already closed) ==
|
||||||
|
{AU_FRAME:,.1f} B a slot at {B.ADPCM_CLK_BYTE_BEST}..{B.ADPCM_CLK_BYTE_WORST} clocks a byte (the IPL ROM's OWN channel-3
|
||||||
|
configuration, read out of the ROM by 21_iplrom_dmac.py, not chosen here) is
|
||||||
|
{AUCLK_LO:,.0f}..{AUCLK_HI:,.0f} clocks = {100*AUCLK_LO/FRAME_CLK:.2f}%..{100*AUCLK_HI/FRAME_CLK:.2f}% of a {SLOT_S*1000:.2f} ms slot.
|
||||||
|
That reproduces FINDINGS 52 exactly, which is the point of printing it.
|
||||||
|
|
||||||
|
THE INTERACTION 52 COULD NOT HAVE HAD is with 64.2's write window. A
|
||||||
|
DMAC-direct packed player holds the GVRAM window open for the whole data
|
||||||
|
phase, and an audio channel stealing the bus during that phase makes the phase
|
||||||
|
LONGER -- so audio does not merely cost clocks, it costs DARKNESS:
|
||||||
|
|
||||||
|
extra dark per slot = {100*AUCLK_LO/FRAME_CLK:.2f}%..{100*AUCLK_HI/FRAME_CLK:.2f}% of the slot, on top of
|
||||||
|
record/(burst x slot), which is already 1.0 at the wire
|
||||||
|
|
||||||
|
It is small against a dark fraction that is already 1.0, and it is not small
|
||||||
|
against K4's {100*227553/FRAME_CLK:.1f}% paint. For the CPU-painted player the audio steals
|
||||||
|
from the paint and not from the picture, which is the third time this session
|
||||||
|
the two players have ranked differently on a column that is not clocks.
|
||||||
|
""")
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""What the chip's own decoder model costs the encoder. ROADMAP P6a, after it.
|
||||||
|
|
||||||
|
tools/bench/adpcm_run.sh MEASURED four things about the MSM6258 as this machine
|
||||||
|
models it, and tools/encoder/adpcm.py had a different value for every one:
|
||||||
|
|
||||||
|
axis encoder default the chip how it was measured
|
||||||
|
feed both-high-first both-LOW-first
|
||||||
|
formula shift terms 1,678 samples, sample-exact
|
||||||
|
clamp 12-bit 10-bit one model of sixteen matched
|
||||||
|
init 0 -2
|
||||||
|
|
||||||
|
This file prices them, on the same ten seconds of the same stream every audio
|
||||||
|
figure in this project is quoted against (tmp/au_singe.raw, FINDINGS 65). It
|
||||||
|
takes an explicit source file rather than defaulting to one, for the same reason
|
||||||
|
every rate in this tree is an explicit argument (FINDINGS 50).
|
||||||
|
|
||||||
|
THE ONE THAT IS NOT A UNIT SLIP is the CLAMP. The other three are conventions:
|
||||||
|
get one wrong and the decode is wrong, get it right and nothing is lost. A
|
||||||
|
10-bit accumulator is a smaller container, and it is INSIDE the recursion -- the
|
||||||
|
predictor cannot represent what will not fit -- so it costs SNR even when the
|
||||||
|
encoder knows about it and encodes for it. That is a ceiling on this format on
|
||||||
|
this machine and it is not recoverable by encoding harder.
|
||||||
|
"""
|
||||||
|
import math, os, sys
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "encoder"))
|
||||||
|
import adpcm
|
||||||
|
|
||||||
|
RAW = sys.argv[1] if len(sys.argv) > 1 else "tmp/au_singe.raw"
|
||||||
|
CHIP = dict(variant="terms", order="low", bits=10, init=-2)
|
||||||
|
ENC = dict(variant="shift", order="high", bits=12, init=0)
|
||||||
|
|
||||||
|
|
||||||
|
def snr_db(ref, got):
|
||||||
|
num = sum(float(s) * s for s in ref)
|
||||||
|
den = sum((float(a) - b) ** 2 for a, b in zip(ref, got))
|
||||||
|
if den == 0: return float("inf")
|
||||||
|
return 10.0 * math.log10(num / den) if num else float("-inf")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import struct
|
||||||
|
if not os.path.exists(RAW):
|
||||||
|
print(f"no {RAW} -- run tools/encoder/extract_audio.py first")
|
||||||
|
return 2
|
||||||
|
pcm = struct.unpack("<%dh" % (os.path.getsize(RAW) // 2), open(RAW, "rb").read())
|
||||||
|
src12 = [max(-2048, min(2047, x >> 4)) for x in pcm]
|
||||||
|
print(f"{RAW}: {len(src12):,} samples, peak {max(abs(v) for v in src12)} "
|
||||||
|
f"in 12-bit units")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("1. THE COST OF ENCODING FOR THE WRONG CHIP, all four axes at once")
|
||||||
|
print(" Encode under the encoder's defaults; play it on the chip. The")
|
||||||
|
print(" nibble ORDER is not a decode parameter -- it decides which nibble")
|
||||||
|
print(" of each byte the chip takes -- so it is applied by re-reading the")
|
||||||
|
print(" encoder's own packed bytes the way the chip reads them.")
|
||||||
|
nib = adpcm.encode(src12, ENC["variant"], init=ENC["init"], bits=ENC["bits"])
|
||||||
|
same = adpcm.decode(nib, ENC["variant"], init=ENC["init"], bits=ENC["bits"])
|
||||||
|
data = adpcm.pack(nib, ENC["order"])
|
||||||
|
asread = list(adpcm.unpack(data, len(nib), CHIP["order"]))
|
||||||
|
cross = adpcm.decode(asread, CHIP["variant"], init=CHIP["init"], bits=CHIP["bits"])
|
||||||
|
print(f" encoded and decoded on the encoder's model : {snr_db(src12, same):7.2f} dB")
|
||||||
|
print(f" encoded on the encoder's, played on the chip: {snr_db(src12, cross):7.2f} dB")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("2. ONE AXIS AT A TIME, so the bill is itemised rather than lumped")
|
||||||
|
for name, key, val in (("nibble order", "order", CHIP["order"]),
|
||||||
|
("delta formula", "variant", CHIP["variant"]),
|
||||||
|
("clamp", "bits", CHIP["bits"]),
|
||||||
|
("initial accumulator", "init", CHIP["init"])):
|
||||||
|
m = dict(ENC); m[key] = val
|
||||||
|
d = adpcm.pack(nib, ENC["order"])
|
||||||
|
rd = list(adpcm.unpack(d, len(nib), m["order"]))
|
||||||
|
got = adpcm.decode(rd, m["variant"], init=m["init"], bits=m["bits"])
|
||||||
|
print(f" {name:22s} wrong only here: {snr_db(src12, got):7.2f} dB")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("3. AND THE ONE THAT IS NOT A CONVENTION. Encode FOR the chip -- the")
|
||||||
|
print(" encoder knows the model and searches against it -- and compare a")
|
||||||
|
print(" 10-bit accumulator with a 12-bit one on the same seconds.")
|
||||||
|
for bits in (12, 10):
|
||||||
|
n = adpcm.encode(src12, CHIP["variant"], init=CHIP["init"], bits=bits)
|
||||||
|
r = adpcm.decode(n, CHIP["variant"], init=CHIP["init"], bits=bits)
|
||||||
|
clip = sum(1 for v in r if v in adpcm.clamp_bounds(bits))
|
||||||
|
print(f" encoded and decoded at {bits}-bit: {snr_db(src12, r):7.2f} dB"
|
||||||
|
f" ({clip:,} of {len(r):,} samples sit ON the clamp)")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("4. WHAT THE LEVEL DOES NOW, and it did nothing before (65.1).")
|
||||||
|
print(" At 12 bits the disc's -13.4 dBFS peak had headroom to spare and")
|
||||||
|
print(" normalising bought 0.00 dB. A 10-bit accumulator is 4x smaller,")
|
||||||
|
print(" so the same signal is no longer comfortably inside it.")
|
||||||
|
peak = max(abs(v) for v in src12)
|
||||||
|
for name, g in (("as recorded", 1.0),
|
||||||
|
("scaled to fit 10 bits", 500.0 / peak),
|
||||||
|
("half of that", 250.0 / peak)):
|
||||||
|
sc = [max(-512, min(511, int(round(v * g)))) for v in src12]
|
||||||
|
n = adpcm.encode(sc, CHIP["variant"], init=CHIP["init"], bits=CHIP["bits"])
|
||||||
|
r = adpcm.decode(n, CHIP["variant"], init=CHIP["init"], bits=CHIP["bits"])
|
||||||
|
print(f" {name:24s} x{g:5.2f} peak {max(abs(v) for v in sc):4d} "
|
||||||
|
f"{snr_db(sc, r):7.2f} dB")
|
||||||
|
print()
|
||||||
|
print("5. THE HEADROOM, which is the part of this that will bite later.")
|
||||||
|
hd = 20 * math.log10(511.0 / peak)
|
||||||
|
print(f" This window peaks at {peak} of the 10-bit accumulator's 511, so it")
|
||||||
|
print(f" has {hd:.1f} dB of headroom left -- and it is a QUIET passage: the")
|
||||||
|
print(" disc peaks at -13.4 dBFS here (65.1). A 10-bit accumulator is")
|
||||||
|
print(f" {20*math.log10(2047.0/511.0):.1f} dB smaller than the 12-bit word the encoder was")
|
||||||
|
print(" clamping to, so a passage only a few dB louder than this one does")
|
||||||
|
print(" not fit and the predictor CLIPS inside the recursion. Nothing in")
|
||||||
|
print(" this project has measured the loudest passage on the disc; until")
|
||||||
|
print(" something does, the audio level is an OPEN choice and not a")
|
||||||
|
print(" settled one, and 65.1's `the level is not a lever` is now wrong")
|
||||||
|
print(" in one direction: it is not a lever UPWARD.")
|
||||||
|
print()
|
||||||
|
print(" The rows in 4 are NOT comparable as absolute quality")
|
||||||
|
print(" -- each is scored against its OWN scaled reference, so what they")
|
||||||
|
print(" compare is how well the format tracks a signal of that size.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""HOW LOUD IS THE DISC? ROADMAP P6, the item FINDINGS 66.3 reopened.
|
||||||
|
|
||||||
|
python3 tools/analysis/35_audio_level.py [--streams 00000-00201] [--json out]
|
||||||
|
|
||||||
|
FINDINGS 66 asked MAME's MSM6258 which decoder it is and got four axes back.
|
||||||
|
The one with a bill attached is the CLAMP: the chip's accumulator is **10 bits**
|
||||||
|
and it clamps INSIDE the recursion, so the reachable set of reconstructed
|
||||||
|
samples is [-512, 511] in the 12-bit units everything in this project counts in
|
||||||
|
-- a quarter of the 12-bit word `adpcm.py` used to clamp at.
|
||||||
|
|
||||||
|
`pack.py` hands the encoder `s16 >> 4`, i.e. it maps the disc's full scale onto
|
||||||
|
the 12-bit word, and 66.3 measured the Singe window peaking at **435 of 511**.
|
||||||
|
That fit with 1.4 dB to spare, and it fit BY ACCIDENT: the window is a -13.4
|
||||||
|
dBFS passage. Any passage more than 1.4 dB louder does not merely distort at the
|
||||||
|
top, it drives the predictor -- a clamped accumulator is a WRONG STATE that the
|
||||||
|
next nibble is applied to, so the error outlives the loud sample.
|
||||||
|
|
||||||
|
So the level cannot be chosen from the ten seconds this project gates on. It has
|
||||||
|
to be chosen from the loudest thing the game will ever play, and this file
|
||||||
|
measures that: every stream of the unique scene footage -- `00000`-`00201`,
|
||||||
|
1366.6 s, FINDINGS 32.1 -- through the SAME chain `extract_audio.py` uses (AC-3
|
||||||
|
5.1, ffmpeg's default downmix matrix, mono, 15,625 Hz), because a level measured
|
||||||
|
through a different resampler is a level for a different encoder.
|
||||||
|
|
||||||
|
Two statistics, and the difference between them is the whole argument:
|
||||||
|
|
||||||
|
PEAK max |x| over the disc. What must fit under 511 for NOTHING to clamp.
|
||||||
|
PASSAGE the loudest ~1 s window's peak and RMS. What the ear gets. A single
|
||||||
|
sample 6 dB above everything else is a click and costs one clamp; a
|
||||||
|
passage 6 dB above the gate window is where the recursion lives for
|
||||||
|
fifteen thousand samples.
|
||||||
|
|
||||||
|
It prints the attenuation each choice implies, in dB and as the shift `pack.py`
|
||||||
|
would have to make, and it does NOT choose. Choosing needs the other half --
|
||||||
|
what attenuation costs at the quiet end, where the OKI step table's floor of 16
|
||||||
|
(12-bit units) does not scale with the signal -- and that is `--ladder`, which
|
||||||
|
encodes real passages at real gains with `adpcm.CHIP` and reports the SNR.
|
||||||
|
"""
|
||||||
|
import argparse, getpass, json, os, subprocess, sys
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
import adpcm
|
||||||
|
|
||||||
|
BDROM = os.environ.get("DLX_BDROM") or f"/media/{getpass.getuser()}/BDROM"
|
||||||
|
STREAM_DIR = f"{BDROM}/BDMV/STREAM"
|
||||||
|
|
||||||
|
HZ = 15625 # the chip's rate, and the only one budgeted for
|
||||||
|
FPS = 12
|
||||||
|
LUMP_FRAMES = 11 # FINDINGS 65.3's cadence: 11 frames of audio
|
||||||
|
WIN = LUMP_FRAMES * HZ // FPS # 14,322 samples ~ 0.917 s -- one audio lump
|
||||||
|
HOP = HZ // 4 # 0.25 s blocks; the window is 4 of them (rounded)
|
||||||
|
|
||||||
|
CLAMP_LO, CLAMP_HI = adpcm.clamp_bounds(adpcm.CHIP["bits"]) # -512, 511
|
||||||
|
FULL12 = 2048 # what `s16 >> 4` maps full scale to
|
||||||
|
|
||||||
|
|
||||||
|
def db(x, ref=FULL12):
|
||||||
|
return -np.inf if x <= 0 else 20 * np.log10(x / ref)
|
||||||
|
|
||||||
|
|
||||||
|
_PCM_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
|
def pcm12(stream, start=None, dur=None):
|
||||||
|
"""One stream as 12-bit signed samples, through extract_audio.py's chain.
|
||||||
|
|
||||||
|
Cached, because the scan, the census and the event walk are three passes
|
||||||
|
over the same 20 million samples and the whole game is 40 MB of int16.
|
||||||
|
"""
|
||||||
|
ck = (stream, start, dur)
|
||||||
|
if ck in _PCM_CACHE:
|
||||||
|
return _PCM_CACHE[ck]
|
||||||
|
cmd = ["ffmpeg", "-v", "error"]
|
||||||
|
if start is not None: cmd += ["-ss", str(start)]
|
||||||
|
if dur is not None: cmd += ["-t", str(dur)]
|
||||||
|
cmd += ["-i", f"{STREAM_DIR}/{stream}.m2ts", "-vn", "-ac", "1",
|
||||||
|
"-ar", str(HZ), "-f", "s16le", "-acodec", "pcm_s16le", "-"]
|
||||||
|
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
if p.returncode:
|
||||||
|
# 00176 is 3.0 s of mpeg2video with NO AUDIO TRACK AT ALL. That is a
|
||||||
|
# fact about the disc, not a failure here, so it is reported rather
|
||||||
|
# than swallowed -- but anything else is a real error.
|
||||||
|
if b"does not contain any stream" not in p.stderr:
|
||||||
|
raise SystemExit(f"ffmpeg failed on {stream}: "
|
||||||
|
f"{p.stderr.decode(errors='replace')[:400]}")
|
||||||
|
_PCM_CACHE[ck] = None
|
||||||
|
return None
|
||||||
|
x = np.frombuffer(p.stdout, "<i2").astype(np.int32)
|
||||||
|
# The SAME requantisation pack.py makes. It is a shift and not a divide, so
|
||||||
|
# it floors toward -inf, and that asymmetry is real: -1 >> 4 is -1.
|
||||||
|
out = np.clip(x >> 4, -FULL12, FULL12 - 1).astype(np.int16)
|
||||||
|
_PCM_CACHE[ck] = out
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scan(streams):
|
||||||
|
"""Per-stream peak and loudest-passage statistics, in 12-bit units."""
|
||||||
|
rows, mute = [], []
|
||||||
|
for s in streams:
|
||||||
|
x = pcm12(s)
|
||||||
|
if x is None:
|
||||||
|
mute.append(s)
|
||||||
|
continue
|
||||||
|
if x.size == 0:
|
||||||
|
continue
|
||||||
|
a = np.abs(x).astype(np.float64)
|
||||||
|
nb = a.size // HOP
|
||||||
|
if nb:
|
||||||
|
bmax = a[:nb * HOP].reshape(nb, HOP).max(1)
|
||||||
|
bsq = (a[:nb * HOP].reshape(nb, HOP) ** 2).sum(1)
|
||||||
|
k = max(1, round(WIN / HOP))
|
||||||
|
if nb >= k:
|
||||||
|
# sliding sum over k blocks == the ~1 s lump window
|
||||||
|
cs = np.concatenate(([0.0], np.cumsum(bsq)))
|
||||||
|
wrms = np.sqrt((cs[k:] - cs[:-k]) / (k * HOP))
|
||||||
|
wpk = np.array([bmax[i:i + k].max() for i in range(nb - k + 1)])
|
||||||
|
else:
|
||||||
|
wrms = np.array([np.sqrt((a ** 2).mean())])
|
||||||
|
wpk = np.array([a.max()])
|
||||||
|
else:
|
||||||
|
wrms = np.array([np.sqrt((a ** 2).mean())])
|
||||||
|
wpk = np.array([a.max()])
|
||||||
|
ipk = int(np.argmax(a))
|
||||||
|
irms = int(np.argmax(wrms))
|
||||||
|
rows.append(dict(stream=s, n=int(x.size), secs=x.size / HZ,
|
||||||
|
peak=float(a.max()), peak_t=ipk / HZ,
|
||||||
|
rms=float(np.sqrt((a ** 2).mean())),
|
||||||
|
wpeak=float(wpk.max()),
|
||||||
|
wrms=float(wrms.max()), wrms_t=irms * HOP / HZ))
|
||||||
|
return rows, mute
|
||||||
|
|
||||||
|
|
||||||
|
def report(rows, mute):
|
||||||
|
tot = sum(r["secs"] for r in rows)
|
||||||
|
peak = max(rows, key=lambda r: r["peak"])
|
||||||
|
loud = max(rows, key=lambda r: r["wrms"])
|
||||||
|
disc_peak = peak["peak"]
|
||||||
|
|
||||||
|
print(f"=== THE DISC, {len(rows)} streams, {tot:,.1f} s = {tot/60:.1f} min "
|
||||||
|
f"(FINDINGS 32.1 says 1,366.6) ===\n")
|
||||||
|
if mute:
|
||||||
|
print(f" {len(mute)} stream(s) carry NO AUDIO TRACK: {', '.join(mute)}"
|
||||||
|
f" -- a fact about the disc, and a case a shipping encoder has\n"
|
||||||
|
f" to have an answer for (silence of the right length).\n")
|
||||||
|
print(f'{"stream":>8}{"secs":>8}{"peak":>7}{"dBFS":>8}{"passage pk":>12}'
|
||||||
|
f'{"passage rms":>13}{"dBFS":>8} at')
|
||||||
|
for r in sorted(rows, key=lambda r: -r["wrms"])[:12]:
|
||||||
|
print(f'{r["stream"]:>8}{r["secs"]:8.1f}{r["peak"]:7.0f}{db(r["peak"]):8.2f}'
|
||||||
|
f'{r["wpeak"]:12.0f}{r["wrms"]:13.1f}{db(r["wrms"]):8.2f}'
|
||||||
|
f' {r["wrms_t"]:6.2f} s')
|
||||||
|
print(" (the twelve loudest PASSAGES; the table is sorted by the window "
|
||||||
|
"RMS, not the peak)\n")
|
||||||
|
|
||||||
|
print(f" DISC PEAK {disc_peak:.0f} of {FULL12} = {db(disc_peak):.2f} dBFS"
|
||||||
|
f" ({peak['stream']} @ {peak['peak_t']:.2f} s)")
|
||||||
|
print(f" LOUDEST PASSAGE peak {loud['wpeak']:.0f}, rms {loud['wrms']:.1f}"
|
||||||
|
f" = {db(loud['wrms']):.2f} dBFS ({loud['stream']} @ {loud['wrms_t']:.2f} s)")
|
||||||
|
print(f" THE CLAMP +{CLAMP_HI} / {CLAMP_LO} (it is not symmetric), "
|
||||||
|
f"{db(CLAMP_HI):.2f} dBFS in the same units\n")
|
||||||
|
|
||||||
|
need = disc_peak / CLAMP_HI
|
||||||
|
print("=== WHAT THAT COSTS, AS A LEVEL ===\n")
|
||||||
|
print(f" `s16 >> 4` is what pack.py does today and it puts the disc's own")
|
||||||
|
print(f" peak at {disc_peak:.0f} against a clamp of {CLAMP_HI}: "
|
||||||
|
f"{'OVER by' if need > 1 else 'under by'} {abs(20*np.log10(need)):.2f} dB.")
|
||||||
|
print(f" Fitting the whole disc under the clamp with no sample clamped at")
|
||||||
|
print(f" all needs a gain of {1/need:.4f} = {-20*np.log10(need):.2f} dB, i.e.")
|
||||||
|
for sh in (4, 5, 6, 7):
|
||||||
|
pk = disc_peak / (1 << (sh - 4))
|
||||||
|
mark = " <- fits" if pk <= CLAMP_HI else ""
|
||||||
|
# ~ because a further right shift floors again and this halves; the
|
||||||
|
# difference is one count and the column is a signpost, not a spec.
|
||||||
|
print(f" s16 >> {sh} disc peak ~{pk:7.1f} "
|
||||||
|
f"{'clamps' if pk > CLAMP_HI else 'clear':>6} by "
|
||||||
|
f"{abs(20*np.log10(pk/CLAMP_HI)):5.2f} dB{mark}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# How much of the disc is actually above the clamp at today's level: the
|
||||||
|
# number that decides whether this is a level question or a limiter question.
|
||||||
|
return dict(rows=rows, disc_peak=disc_peak, peak_stream=peak["stream"],
|
||||||
|
peak_t=peak["peak_t"], loud_stream=loud["stream"],
|
||||||
|
loud_t=loud["wrms_t"], loud_rms=loud["wrms"],
|
||||||
|
loud_wpeak=loud["wpeak"], clamp=CLAMP_HI, mute=mute)
|
||||||
|
|
||||||
|
|
||||||
|
def clip_census(rows, streams, gains):
|
||||||
|
"""At each candidate gain, how many samples of the WHOLE DISC clamp?
|
||||||
|
|
||||||
|
A peak is one number and this is the distribution behind it. A gain that
|
||||||
|
clamps 12 samples in 22 minutes is a different object from one that clamps
|
||||||
|
thousands, and the peak alone cannot tell them apart.
|
||||||
|
"""
|
||||||
|
print("=== THE CENSUS: how much of the disc is ABOVE the clamp, by gain ===\n")
|
||||||
|
print(f'{"gain":>8}{"dB":>8}{"samples over":>14}{"of":>12}{"share":>10}'
|
||||||
|
f'{"worst over":>12}')
|
||||||
|
tot = 0
|
||||||
|
over = {g: 0 for g in gains}
|
||||||
|
worst = {g: 0.0 for g in gains}
|
||||||
|
for s in streams:
|
||||||
|
x = pcm12(s)
|
||||||
|
if x is None:
|
||||||
|
continue
|
||||||
|
a = np.abs(x).astype(np.float64)
|
||||||
|
tot += a.size
|
||||||
|
for g in gains:
|
||||||
|
# ROUNDED, exactly as the ladder and pack.py requantise. Comparing
|
||||||
|
# the float product instead makes 511/946 report one sample over
|
||||||
|
# its own clamp, which is arithmetic about floats and not about
|
||||||
|
# the disc.
|
||||||
|
v = np.round(a * g)
|
||||||
|
m = v > CLAMP_HI
|
||||||
|
over[g] += int(m.sum())
|
||||||
|
if m.any():
|
||||||
|
worst[g] = max(worst[g], float(v.max() / CLAMP_HI))
|
||||||
|
for g in gains:
|
||||||
|
w = f"{20*np.log10(worst[g]):.2f} dB" if worst[g] else "-"
|
||||||
|
print(f'{g:8.4f}{20*np.log10(g):8.2f}{over[g]:14,}{tot:12,}'
|
||||||
|
f'{100*over[g]/tot:9.4f}%{w:>12}')
|
||||||
|
print()
|
||||||
|
return dict(total=tot, over={f"{g:.4f}": over[g] for g in gains})
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_events(streams, gain=1.0):
|
||||||
|
"""WHERE the over-clamp samples are, not just how many.
|
||||||
|
|
||||||
|
687 isolated samples in 22 minutes and one sustained 44 ms burst are the
|
||||||
|
same census row and completely different sounds, and a clamp inside a
|
||||||
|
recursion is not a clipped sample -- it is a wrong predictor state that the
|
||||||
|
next nibble is applied to. So the run lengths are the statistic.
|
||||||
|
"""
|
||||||
|
runs = []
|
||||||
|
for st in streams:
|
||||||
|
x = pcm12(st)
|
||||||
|
if x is None:
|
||||||
|
continue
|
||||||
|
m = np.round(np.abs(x).astype(np.float64) * gain) > CLAMP_HI
|
||||||
|
if not m.any():
|
||||||
|
continue
|
||||||
|
d = np.diff(np.concatenate(([0], m.view(np.int8), [0])))
|
||||||
|
beg = np.where(d == 1)[0]
|
||||||
|
end = np.where(d == -1)[0]
|
||||||
|
for b, e in zip(beg, end):
|
||||||
|
runs.append((int(e - b), st, b / HZ))
|
||||||
|
runs.sort(reverse=True)
|
||||||
|
n = sum(r[0] for r in runs)
|
||||||
|
print(f"=== WHERE THE CLAMPS ARE at gain {gain:.4f} "
|
||||||
|
f"({len(runs)} events, {n:,} samples = {1000*n/HZ:.1f} ms) ===\n")
|
||||||
|
print(f'{"run":>6}{"ms":>8} stream at')
|
||||||
|
for r, st, t in runs[:10]:
|
||||||
|
print(f'{r:6}{1000*r/HZ:8.2f} {st} {t:7.2f} s')
|
||||||
|
if runs:
|
||||||
|
print(f" longest run {runs[0][0]} samples = {1000*runs[0][0]/HZ:.2f} ms; "
|
||||||
|
f"median run {sorted(r[0] for r in runs)[len(runs)//2]}")
|
||||||
|
print()
|
||||||
|
return dict(events=len(runs), samples=n,
|
||||||
|
longest=runs[0][0] if runs else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def ladder(where, gains, dur, label):
|
||||||
|
"""Encode a real passage at each gain with adpcm.CHIP and report the SNR.
|
||||||
|
|
||||||
|
This is the half a peak measurement cannot do. Attenuation buys headroom at
|
||||||
|
the top and spends resolution at the bottom, because the OKI step table's
|
||||||
|
floor is a constant 16 in 12-bit units and does not scale with the signal.
|
||||||
|
The SNR is reported against the SCALED source, which is the honest
|
||||||
|
comparison: the encoder's job is to reproduce what it was handed, and the
|
||||||
|
listener's volume knob is not this project's problem.
|
||||||
|
"""
|
||||||
|
stream, start = where
|
||||||
|
x = pcm12(stream, start, dur)
|
||||||
|
print(f"=== THE LADDER: {label} -- {stream} @ {start:.2f} s, {dur:.2f} s, "
|
||||||
|
f"{x.size:,} samples ===\n")
|
||||||
|
print(f'{"gain":>8}{"dB":>8}{"src peak":>10}{"clamped":>9}{"SNR dB":>9}'
|
||||||
|
f'{"vs 1.0":>8}')
|
||||||
|
base = None
|
||||||
|
out = []
|
||||||
|
for g in gains:
|
||||||
|
src = np.clip(np.round(x * g), -FULL12, FULL12 - 1).astype(int).tolist()
|
||||||
|
nib = adpcm.encode(src, variant=adpcm.CHIP["variant"],
|
||||||
|
init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"])
|
||||||
|
rec = adpcm.decode(nib, variant=adpcm.CHIP["variant"],
|
||||||
|
init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"])
|
||||||
|
s = np.array(src, dtype=np.float64)
|
||||||
|
r = np.array(rec, dtype=np.float64)
|
||||||
|
e = ((s - r) ** 2).sum()
|
||||||
|
snr = 10 * np.log10((s ** 2).sum() / e) if e else np.inf
|
||||||
|
nclamp = int((np.abs(s) > CLAMP_HI).sum())
|
||||||
|
if base is None:
|
||||||
|
base = snr
|
||||||
|
print(f'{g:8.4f}{20*np.log10(g):8.2f}{np.abs(s).max():10.0f}{nclamp:9,}'
|
||||||
|
f'{snr:9.2f}{snr-base:+8.2f}')
|
||||||
|
out.append(dict(gain=g, snr=snr, clamped=nclamp,
|
||||||
|
peak=float(np.abs(s).max())))
|
||||||
|
print()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def survey(rows, gains, n, dur, seed=20260825):
|
||||||
|
"""THE DISC, not three passages of it.
|
||||||
|
|
||||||
|
Three hand-picked passages can be argued with; a sample cannot. `n` windows
|
||||||
|
are drawn uniformly over the game's own timeline -- weighted by stream
|
||||||
|
length, so a 24 s stream gets twenty times the draws of a 1.2 s one -- and
|
||||||
|
every one is encoded at every gain with `adpcm.CHIP`. What is reported is
|
||||||
|
the distribution: the mean SNR is what the level costs on average, and the
|
||||||
|
WORST window is what it costs where it matters, because a level is chosen
|
||||||
|
for the passage it fails on.
|
||||||
|
"""
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
pool = [r for r in rows if r["secs"] >= dur]
|
||||||
|
w = np.array([r["secs"] for r in pool], dtype=np.float64)
|
||||||
|
w /= w.sum()
|
||||||
|
picks = []
|
||||||
|
for _ in range(n):
|
||||||
|
r = pool[int(rng.choice(len(pool), p=w))]
|
||||||
|
t = float(rng.uniform(0, r["secs"] - dur))
|
||||||
|
picks.append((r["stream"], t))
|
||||||
|
print(f"=== THE SURVEY: {n} windows of {dur:.1f} s drawn over the whole "
|
||||||
|
f"{sum(r['secs'] for r in rows)/60:.1f} min, encoded at every gain ===\n")
|
||||||
|
src = [pcm12(st, t, dur) for st, t in picks]
|
||||||
|
print(f'{"gain":>8}{"dB":>8}{"mean SNR":>10}{"median":>9}{"WORST":>8}'
|
||||||
|
f'{"windows w/ clamp":>18}{"samples":>9}')
|
||||||
|
out = []
|
||||||
|
for g in gains:
|
||||||
|
snrs, nclamp, ncw = [], 0, 0
|
||||||
|
for x in src:
|
||||||
|
v = np.clip(np.round(x * g), -FULL12, FULL12 - 1).astype(int)
|
||||||
|
k = int((np.abs(v) > CLAMP_HI).sum())
|
||||||
|
nclamp += k
|
||||||
|
ncw += 1 if k else 0
|
||||||
|
nib = adpcm.encode(v.tolist(), variant=adpcm.CHIP["variant"],
|
||||||
|
init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"])
|
||||||
|
rec = np.array(adpcm.decode(nib, variant=adpcm.CHIP["variant"],
|
||||||
|
init=adpcm.CHIP["init"],
|
||||||
|
bits=adpcm.CHIP["bits"]), dtype=np.float64)
|
||||||
|
f = v.astype(np.float64)
|
||||||
|
e = ((f - rec) ** 2).sum()
|
||||||
|
snrs.append(10 * np.log10((f ** 2).sum() / e) if e else np.inf)
|
||||||
|
a = np.array(snrs)
|
||||||
|
print(f'{g:8.4f}{20*np.log10(g):8.2f}{a.mean():10.2f}'
|
||||||
|
f'{np.median(a):9.2f}{a.min():8.2f}{ncw:14} of {len(src)}{nclamp:9,}',
|
||||||
|
flush=True)
|
||||||
|
out.append(dict(gain=g, mean=float(a.mean()), median=float(np.median(a)),
|
||||||
|
worst=float(a.min()), clamped=nclamp, windows=ncw))
|
||||||
|
print()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def recover(stream, gain, control, K=64):
|
||||||
|
"""DOES A CLAMP OUTLIVE THE SAMPLE IT HAPPENS ON? 66.3 said it would.
|
||||||
|
|
||||||
|
The worry was exact and it is the right worry for a recursive codec: a
|
||||||
|
clamped accumulator is a WRONG STATE and the next nibble is applied to it,
|
||||||
|
so the error should persist after the loud sample has gone. Measuring the
|
||||||
|
error after a clamp run does show it elevated -- and that is not evidence,
|
||||||
|
because the samples after a clamp run are LOUD samples, where the step is
|
||||||
|
large and the error is large anyway.
|
||||||
|
|
||||||
|
So the control is the same window at the gain that never clamps, rescaled
|
||||||
|
to the same units and read at the SAME sample indices. What the ratio
|
||||||
|
isolates is the clamp and nothing else.
|
||||||
|
"""
|
||||||
|
x = pcm12(stream).astype(np.float64)
|
||||||
|
|
||||||
|
def enc(g):
|
||||||
|
src = np.clip(np.round(x * g), -FULL12, FULL12 - 1).astype(int)
|
||||||
|
nib = adpcm.encode(src.tolist(), variant=adpcm.CHIP["variant"],
|
||||||
|
init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"])
|
||||||
|
rec = adpcm.decode(nib, variant=adpcm.CHIP["variant"],
|
||||||
|
init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"])
|
||||||
|
return src.astype(np.float64), np.array(rec, dtype=np.float64)
|
||||||
|
|
||||||
|
s1, r1 = enc(gain)
|
||||||
|
s2, r2 = enc(control)
|
||||||
|
e1 = np.abs(s1 - r1)
|
||||||
|
e2 = np.abs(s2 - r2) / control * gain # the control, in gain's units
|
||||||
|
m = np.abs(s1) > CLAMP_HI
|
||||||
|
d = np.diff(np.concatenate(([0], m.view(np.int8), [0])))
|
||||||
|
ends = [e for e in np.where(d == -1)[0] if e + K <= e1.size]
|
||||||
|
p1 = np.array([e1[e:e + K] for e in ends], dtype=np.float64).mean(0)
|
||||||
|
p2 = np.array([e2[e:e + K] for e in ends], dtype=np.float64).mean(0)
|
||||||
|
|
||||||
|
print(f"=== DOES THE CLAMP OUTLIVE THE SAMPLE? {stream}, gain {gain:g} "
|
||||||
|
f"against a control at {control:g} ===\n")
|
||||||
|
print(f" {int(m.sum())} samples clamp in {len(ends)} runs; the profile is "
|
||||||
|
f"the mean |error| at each\n offset after a run ENDS, in 12-bit units, "
|
||||||
|
f"against the same offsets of a\n window that never clamps at all.\n")
|
||||||
|
print(f'{"after":>7}{"clamped":>10}{"control":>10}{"ratio":>8}')
|
||||||
|
for i in (0, 1, 2, 4, 8, 16, 32, K - 1):
|
||||||
|
print(f'{"+" + str(i):>7}{p1[i]:10.2f}{p2[i]:10.2f}{p1[i]/p2[i]:8.2f}')
|
||||||
|
off = ~m
|
||||||
|
print(f'\n off-clamp mean |err| {e1[off].mean():.2f} vs {e2[off].mean():.2f}')
|
||||||
|
print(f' whole-window mean |err| {e1.mean():.2f} vs {e2.mean():.2f}')
|
||||||
|
print(f' worst ratio over the {K} offsets: {(p1/p2).max():.2f}\n')
|
||||||
|
return dict(stream=stream, gain=gain, control=control,
|
||||||
|
runs=len(ends), clamped=int(m.sum()),
|
||||||
|
worst_ratio=float((p1 / p2).max()),
|
||||||
|
mean_err=float(e1.mean()), mean_err_control=float(e2.mean()))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--first", type=int, default=0)
|
||||||
|
ap.add_argument("--last", type=int, default=201,
|
||||||
|
help="the unique scene footage is 00000-00201 (FINDINGS 32.1); "
|
||||||
|
"00215/00216/00223 are compilations of the same material")
|
||||||
|
ap.add_argument("--ladder", action="store_true",
|
||||||
|
help="also encode the loudest and a quiet passage at each gain")
|
||||||
|
ap.add_argument("--dur", type=float, default=4.0, help="ladder passage seconds")
|
||||||
|
ap.add_argument("--survey", type=int, default=0,
|
||||||
|
help="encode N windows drawn over the whole game at each gain")
|
||||||
|
ap.add_argument("--survey-dur", type=float, default=2.0)
|
||||||
|
ap.add_argument("--recover", action="store_true",
|
||||||
|
help="does a clamp outlive its sample? 66.3 said it would")
|
||||||
|
ap.add_argument("--gate", action="store_true",
|
||||||
|
help="assert FINDINGS 69's headline numbers, exit 1 if not")
|
||||||
|
ap.add_argument("--json")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
streams = [f"{i:05d}" for i in range(a.first, a.last + 1)]
|
||||||
|
streams = [s for s in streams if os.path.exists(f"{STREAM_DIR}/{s}.m2ts")]
|
||||||
|
if not streams:
|
||||||
|
sys.exit(f"no streams under {STREAM_DIR} -- is the Blu-ray mounted? "
|
||||||
|
f"(DLX_BDROM)")
|
||||||
|
|
||||||
|
rows, mute = scan(streams)
|
||||||
|
summary = report(rows, mute)
|
||||||
|
|
||||||
|
# The odd one is not a round number and is not meant to be: it is
|
||||||
|
# CLAMP/disc peak, the gain at which the disc's own loudest sample lands
|
||||||
|
# EXACTLY on the clamp, computed from the scan rather than typed in.
|
||||||
|
exact = round(CLAMP_HI / summary["disc_peak"], 4)
|
||||||
|
gains = sorted({1.0, 0.7071, exact, 0.5, 0.3536, 0.25}, reverse=True)
|
||||||
|
summary["exact_gain"] = exact
|
||||||
|
summary["census"] = clip_census(rows, streams, gains)
|
||||||
|
summary["events"] = clamp_events(streams, 1.0)
|
||||||
|
|
||||||
|
if a.ladder:
|
||||||
|
# Three passages, because they answer three different questions.
|
||||||
|
# PEAK what CLAMPING costs, since this is the only place on the disc
|
||||||
|
# that clamps at today's level.
|
||||||
|
# LOUD the loudest sustained window that is long enough to encode.
|
||||||
|
# QUIET what ATTENUATION costs, which is the other end of the same
|
||||||
|
# decision and the reason -15 dB is not free.
|
||||||
|
long = [r for r in rows if r["secs"] >= a.dur]
|
||||||
|
pk = max(rows, key=lambda r: r["peak"]) # the DISC peak, however short
|
||||||
|
loud = max(long, key=lambda r: r["wrms"])
|
||||||
|
quiet = min(long, key=lambda r: r["wrms"])
|
||||||
|
at = lambda r, t: (r["stream"], min(max(0.0, t - a.dur / 2),
|
||||||
|
max(0.0, r["secs"] - a.dur)))
|
||||||
|
pkdur = min(a.dur, pk["secs"])
|
||||||
|
summary["ladder_peak"] = ladder(
|
||||||
|
(pk["stream"], min(max(0.0, pk["peak_t"] - pkdur / 2),
|
||||||
|
max(0.0, pk["secs"] - pkdur))),
|
||||||
|
gains, pkdur, "THE DISC PEAK ITSELF")
|
||||||
|
summary["ladder_loud"] = ladder(at(loud, loud["wrms_t"]), gains, a.dur,
|
||||||
|
"THE LOUDEST SUSTAINED PASSAGE")
|
||||||
|
summary["ladder_quiet"] = ladder(at(quiet, quiet["wrms_t"]), gains, a.dur,
|
||||||
|
"A QUIET PASSAGE, for the other end")
|
||||||
|
|
||||||
|
if a.survey:
|
||||||
|
summary["survey"] = survey(rows, gains, a.survey, a.survey_dur)
|
||||||
|
|
||||||
|
if a.recover:
|
||||||
|
summary["recover"] = recover(summary["peak_stream"], 1.0, exact)
|
||||||
|
|
||||||
|
if a.gate:
|
||||||
|
expect = dict(disc_peak=946.0, peak_stream="00200", clamp=511,
|
||||||
|
events=402, over=687)
|
||||||
|
bad = []
|
||||||
|
for k, v in expect.items():
|
||||||
|
got = (summary["events"]["events"] if k == "events" else
|
||||||
|
summary["events"]["samples"] if k == "over" else summary[k])
|
||||||
|
if got != v:
|
||||||
|
bad.append(f"{k}: expected {v}, measured {got}")
|
||||||
|
if bad:
|
||||||
|
print("LEVEL GATE RED -- the disc does not measure as FINDINGS 69 "
|
||||||
|
"recorded it:")
|
||||||
|
for b in bad:
|
||||||
|
print(" " + b)
|
||||||
|
print(" (a different pressing is a legitimate cause; a different "
|
||||||
|
"ffmpeg downmix is not)")
|
||||||
|
sys.exit(1)
|
||||||
|
print("LEVEL GATE GREEN: disc peak 946 of 2048 at 00200, 5.35 dB over "
|
||||||
|
"the chip's 511,\n 687 samples in 402 events = 44.0 ms of the "
|
||||||
|
"game's 21.5 min of audio.")
|
||||||
|
|
||||||
|
if a.json:
|
||||||
|
json.dump(summary, open(a.json, "w"), indent=1)
|
||||||
|
print(f"-> {a.json}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""51.3's REFILL CLIMB WITH A SECOND CONSUMER, THROUGH A REAL BRANCH POINT.
|
||||||
|
|
||||||
|
python3 tools/analysis/36_branch_audio.py --kbps R [R ...]
|
||||||
|
[--ring KB ...] [--gate]
|
||||||
|
|
||||||
|
The oldest open item in ROADMAP P6, named by FINDINGS 65.6 and again by 67.6:
|
||||||
|
|
||||||
|
"the slack table is here, but 51.3's refill climb with a second consumer
|
||||||
|
through a real branch point is not."
|
||||||
|
|
||||||
|
Everything it needs already exists and none of it has ever been put in the same
|
||||||
|
room:
|
||||||
|
|
||||||
|
* 51.3 -- slack is ACCUMULATED out of `pipe - wire`, at `pipe - wire` bytes a
|
||||||
|
second, and a seek spends all of it. `tools/analysis/20_seek_slack.py`.
|
||||||
|
* 56.3 -- where the branch points ARE: 612 distinct transitions into a seek
|
||||||
|
over the arcade's own graph, worst gap 0.000 s, median 3.473 s.
|
||||||
|
`tools/analysis/25_scene_graph.py`, reading only DLXSCENE1.
|
||||||
|
* 56.4 -- the climb against that distribution, for the codec container. It
|
||||||
|
charged audio as `ratectl.AUDIO_KBPS`, a flat 7.8 KB/s placeholder that
|
||||||
|
predates any of the audio work.
|
||||||
|
* 65.3/67.1 -- what a second consumer ACTUALLY costs a container: a fixed
|
||||||
|
cadence of F frames per A sectors, because a packed record's address is
|
||||||
|
arithmetic and cannot be an index. `tools/analysis/32_audio_wire.py`.
|
||||||
|
* 68 -- the player that holds both streams at once, and its buffers.
|
||||||
|
|
||||||
|
Three questions, and the tree has never asked any of them:
|
||||||
|
|
||||||
|
1. What does the SECOND CONSUMER do to the climb? Not to the wire -- 32
|
||||||
|
answered that and it is 1.3% -- but to `pipe - wire`, which is a small
|
||||||
|
difference of two large numbers and is the thing the climb is made of.
|
||||||
|
|
||||||
|
2. What is the climb on the PACKED branch? This is the branch the player
|
||||||
|
runs (68) and the one B1's acceptance is written against.
|
||||||
|
|
||||||
|
3. What does the CADENCE do at a branch point? A group is `lump k, then F
|
||||||
|
records`, so lump k sits at a LOWER address than every record in its group
|
||||||
|
but the first. A seek to record i lands inside a group whose audio is
|
||||||
|
BEHIND it. Nobody has ever priced entering a group off-boundary, and the
|
||||||
|
game's own seek targets say how often it happens.
|
||||||
|
|
||||||
|
THE FRAME INDEX OF A SEEK TARGET IS A DESIGN ASSUMPTION AND IS LABELLED ONE.
|
||||||
|
DLXSCENE1 carries positions on the laserdisc timeline in ms. This tree's design
|
||||||
|
puts ONE CONTAINER PER SCENE -- 53 and 55.1 charge a scene change 6,164 header
|
||||||
|
bytes, and 56.3 counts 203 of the 612 transitions as container changes for
|
||||||
|
exactly that reason -- so a seek target's frame index inside its container is
|
||||||
|
`(target start - the scene's own earliest start) * fps / 1000`. If the design
|
||||||
|
ever puts one container per SEQUENCE instead, every seek lands on frame 0, the
|
||||||
|
group offset is always zero and section 3 collapses to nothing. That is the
|
||||||
|
assumption, said out loud, in the one place the answer depends on it.
|
||||||
|
"""
|
||||||
|
import sys, os, json, argparse, importlib.util
|
||||||
|
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
sys.path.insert(0, "tools/analysis")
|
||||||
|
|
||||||
|
TABLE = os.environ.get("DLX_SCENEGRAPH", "tmp/scenegraph.json")
|
||||||
|
FPS = 12
|
||||||
|
CHIP_HZ = 15625.0 # MSM6258V, 8 MHz / 512 (FINDINGS 32, 65)
|
||||||
|
AU_BPS = CHIP_HZ / 2 # 4 bits a sample, two samples to a byte
|
||||||
|
AU_FRAME = AU_BPS / FPS # 651.0416... B a slot, and the dots are 65.3
|
||||||
|
SECTOR = 512
|
||||||
|
|
||||||
|
|
||||||
|
def load(path, name):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def cadence(F):
|
||||||
|
"""Best A for this F: the fewest whole sectors that hold F frames of chip."""
|
||||||
|
num, den = int(round(AU_BPS * 2)), 2 * SECTOR * FPS
|
||||||
|
A = -(-(num * F) // den) # ceil(F * bytes/sector)
|
||||||
|
return A, A * SECTOR
|
||||||
|
|
||||||
|
|
||||||
|
def climb_s(records, rec_b, pipe_kbps, wire_kbps):
|
||||||
|
"""Seconds of play to accumulate `records` records of lookahead (51.3)."""
|
||||||
|
surplus = (pipe_kbps - wire_kbps) * 1024.0
|
||||||
|
return float("inf") if surplus <= 0 else records * rec_b / surplus
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- the branches
|
||||||
|
|
||||||
|
def branch_points(doc, nodes, sg):
|
||||||
|
"""The 612 transitions into a seek, plus each one's target frame index.
|
||||||
|
|
||||||
|
Returns (gaps_s, within, changes). `within` entries carry the frame index
|
||||||
|
the seek lands on inside its container; `changes` are scene changes, which
|
||||||
|
land on frame 0 of a new one.
|
||||||
|
"""
|
||||||
|
gaps, _ = sg.worst_gap(nodes)
|
||||||
|
play = [g for g in gaps if g[5] != "attract_mode"]
|
||||||
|
scene_start = {}
|
||||||
|
for scene, seqs in doc["scenes"].items():
|
||||||
|
st = [s["start_ms"] for s in seqs.values() if s["start_ms"] >= 0]
|
||||||
|
scene_start[scene] = min(st) if st else None
|
||||||
|
|
||||||
|
within, changes = [], []
|
||||||
|
for g, src, tgt, kind, ends, scene in play:
|
||||||
|
if ends:
|
||||||
|
changes.append((g / 1000.0, src))
|
||||||
|
continue
|
||||||
|
n = nodes.get(f"{scene}.{tgt}")
|
||||||
|
if n is None or not n.seeks or scene_start[scene] is None:
|
||||||
|
continue
|
||||||
|
i = int(round((n.start - scene_start[scene]) / 1000.0 * FPS))
|
||||||
|
within.append((g / 1000.0, max(0, i), f"{scene}.{tgt}"))
|
||||||
|
return sorted(g[0] / 1000.0 for g in play), within, changes
|
||||||
|
|
||||||
|
|
||||||
|
def silences(within, F):
|
||||||
|
"""Ms of silence entering each branch's group off-boundary, at cadence F.
|
||||||
|
|
||||||
|
A group is `lump k, then F records`. Seek to record i, take the next lump
|
||||||
|
that lies AHEAD of the read point -- lump k+1, which arrives at frame
|
||||||
|
(k+1)*F -- and the frames from i to (k+1)*F-1 have no audio. The chip's
|
||||||
|
second is a real second (15,625 samples, 2 to a byte), so the missing
|
||||||
|
time is exactly `(F - i mod F) mod F` frames of 1/12 s and none of
|
||||||
|
FINDINGS 54's frame-clock remainder gets into it.
|
||||||
|
"""
|
||||||
|
return sorted(((F - (i % F)) % F) / FPS * 1000.0 for _, i, _ in within)
|
||||||
|
|
||||||
|
|
||||||
|
def pct(xs, p):
|
||||||
|
return xs[min(len(xs) - 1, int(p * len(xs)))] if xs else float("nan")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--kbps", type=float, nargs="+",
|
||||||
|
help="delivered pipe rates, KB/s. REQUIRED (FINDINGS 50) "
|
||||||
|
"unless --gate, which carries its own.")
|
||||||
|
ap.add_argument("--ring", type=float, nargs="+", default=[256, 512])
|
||||||
|
ap.add_argument("--pkbps", type=float, nargs="+",
|
||||||
|
default=[589.6, 600.0, 650.0, 700.0, 900.0],
|
||||||
|
help="pipe rates for the PACKED table. Its own list, "
|
||||||
|
"because a packed record is 1.3x the codec's and "
|
||||||
|
"every rate that serves one starves the other.")
|
||||||
|
ap.add_argument("--table", default=TABLE)
|
||||||
|
ap.add_argument("--packed", default="tmp/packed_singe.dlxp")
|
||||||
|
ap.add_argument("--codec", default="tmp/rc_fr_singe_scsi_span.dlx")
|
||||||
|
ap.add_argument("--fsweep", type=int, default=12,
|
||||||
|
help="highest cadence F in the pick table")
|
||||||
|
ap.add_argument("--gate", action="store_true",
|
||||||
|
help="check.sh mode: fixed rates, and assert the structural "
|
||||||
|
"results rather than print the essay")
|
||||||
|
a = ap.parse_args()
|
||||||
|
if a.gate and not a.kbps:
|
||||||
|
a.kbps = [451.4, 488.0, 600.0]
|
||||||
|
if not a.kbps:
|
||||||
|
ap.error("--kbps is required and has no default (FINDINGS 50)")
|
||||||
|
|
||||||
|
if not os.path.exists(a.table):
|
||||||
|
print(f"no scene table at {a.table} -- run:\n"
|
||||||
|
f" python3 tools/import/scenegraph.py")
|
||||||
|
return 2
|
||||||
|
doc = json.load(open(a.table))
|
||||||
|
if doc.get("format") != "DLXSCENE1":
|
||||||
|
print(f"{a.table}: not a DLXSCENE1 table")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
sg = load("tools/analysis/25_scene_graph.py", "scene_graph")
|
||||||
|
ss = load("tools/analysis/20_seek_slack.py", "seek_slack")
|
||||||
|
from dlxp import DLXP
|
||||||
|
import ratectl as RC
|
||||||
|
|
||||||
|
nodes = sg.build_graph(doc["scenes"])
|
||||||
|
gaps, within, changes = branch_points(doc, nodes, sg)
|
||||||
|
med = gaps[len(gaps) // 2]
|
||||||
|
|
||||||
|
d = DLXP(a.packed)
|
||||||
|
P_REC = d.rec_bytes
|
||||||
|
P_VID = P_REC * FPS / 1024
|
||||||
|
F0, A0 = d.cad_f, d.cad_a
|
||||||
|
P_AUD = A0 * SECTOR / F0 * FPS / 1024
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
=== WHAT EACH BRANCH ACTUALLY HOLDS ====================================
|
||||||
|
51.3's climb is a statement about an ACCUMULATOR. The two branches of this
|
||||||
|
project do not have the same one, and one of them does not have one at all.
|
||||||
|
|
||||||
|
codec ({os.path.basename(a.codec)})
|
||||||
|
a {a.ring[0]:.0f} KB ring of variable records with an index in front of it;
|
||||||
|
lookahead is whole records and the ceiling is what 20_seek_slack.py
|
||||||
|
simulates.
|
||||||
|
packed ({os.path.basename(a.packed)})
|
||||||
|
a record is {P_REC:,} B of literal picture and the channel puts it
|
||||||
|
STRAIGHT INTO GVRAM (FINDINGS 61, 62, 64). There is no record
|
||||||
|
buffer, so the VIDEO lookahead is ZERO records and there is nothing
|
||||||
|
to climb. The only consumer on that branch with any lookahead at
|
||||||
|
all is the AUDIO one: {d.cad_a * SECTOR:,} B a lump, PG_ANBUF={3} slots and
|
||||||
|
PG_APRE prefilled at {2} for the run 68 measured (it is a mailbox,
|
||||||
|
not a constant: src/player/packed.s, 68.6).
|
||||||
|
That is {2 * F0 / FPS:.3f} s of sound held against {0.0:.3f} s of picture.
|
||||||
|
|
||||||
|
The CPU-painted packed variant (64.2's column B) is the one that holds two
|
||||||
|
record buffers and 99,328 B, and it is the only packed configuration the
|
||||||
|
word "climb" applies to. Both are priced below.
|
||||||
|
|
||||||
|
=== THE BRANCH POINTS, OUT OF THE ARCADE'S OWN GRAPH ====================
|
||||||
|
{len(gaps)} transitions into a seek (attract mode excluded, 56.3)
|
||||||
|
worst {gaps[0]:.3f} s p10 {pct(gaps,.10):.3f} median {med:.3f} p90 {pct(gaps,.90):.3f}
|
||||||
|
{len(changes)} of them END THE SCENE and are container changes;
|
||||||
|
{len(within)} land INSIDE a container, at frame indices 0..{max(i for _, i, _ in within)}""")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 1
|
||||||
|
dc, rec = ss.records(a.codec)
|
||||||
|
C_REC = float(rec.mean())
|
||||||
|
C_VID = C_REC * FPS / 1024
|
||||||
|
print(f"""
|
||||||
|
=== 1. THE CLIMB WITH THE SECOND CONSUMER (the codec branch) ============
|
||||||
|
Audio is {AU_BPS/1024:.3f} KB/s and this container pays no padding for it (65.4: it
|
||||||
|
already has an index and already has variable records). Against a video wire
|
||||||
|
of {C_VID:.1f} KB/s that is {100*(AU_BPS/1024)/C_VID:+.2f}% -- and the climb is not built out of the wire,
|
||||||
|
it is built out of `pipe - wire`, so that is not the number that matters.
|
||||||
|
|
||||||
|
{'ring':>5} {'pipe':>7} {'ceil':>5} {'climb SILENT':>13} {'SOUNDED':>9} {'x':>6}"""
|
||||||
|
f" {'under, silent':>13} {'under, sounded':>14}")
|
||||||
|
tab1 = []
|
||||||
|
for ring_kb in a.ring:
|
||||||
|
ring = int(ring_kb * 1024)
|
||||||
|
for kbps in a.kbps:
|
||||||
|
fill = (kbps - AU_BPS / 1024) * 1024 / FPS
|
||||||
|
lo, hi, ring_ref, rate_ref = ss.paced_sim(rec, ring, fill)
|
||||||
|
ceil = int(hi.max())
|
||||||
|
cs = climb_s(ceil, C_REC, kbps, C_VID)
|
||||||
|
ca = climb_s(ceil, C_REC, kbps, C_VID + AU_BPS / 1024)
|
||||||
|
us = sum(1 for g in gaps if g < cs)
|
||||||
|
ua = sum(1 for g in gaps if g < ca)
|
||||||
|
ratio = ca / cs if cs not in (0.0, float("inf")) else float("inf")
|
||||||
|
tab1.append((ring_kb, kbps, ceil, cs, ca, ratio, us, ua))
|
||||||
|
print(f" {ring_kb:5.0f} {kbps:7.1f} {ceil:5d} {cs:12.2f}s "
|
||||||
|
f"{ca:8.2f}s {ratio:5.2f}x {f'{us}/{len(gaps)}':>13} "
|
||||||
|
f"{f'{ua}/{len(gaps)}':>14}")
|
||||||
|
|
||||||
|
worst = max(tab1, key=lambda r: r[5])
|
||||||
|
print(f"""
|
||||||
|
THE SECOND CONSUMER IS {100*(AU_BPS/1024)/C_VID:.1f}% OF THE WIRE AND UP TO {worst[5]:.2f}x OF THE CLIMB.
|
||||||
|
At {worst[1]:.1f} KB/s in a {worst[0]:.0f} KB ring the climb goes {worst[3]:.2f} s -> {worst[4]:.2f} s and the
|
||||||
|
branch points that arrive under it go {worst[6]}/{len(gaps)} -> {worst[7]}/{len(gaps)}
|
||||||
|
({100*worst[6]/len(gaps):.0f}% -> {100*worst[7]/len(gaps):.0f}%). Nothing about audio got bigger; the
|
||||||
|
DIFFERENCE it is subtracted from got smaller, and the climb is made of the
|
||||||
|
difference. This is why 51.4's rate/ring distinction matters more with a
|
||||||
|
second consumer than without one, and why quoting audio as a share of the
|
||||||
|
wire (32, and every budget before it) understates it at every rate close to
|
||||||
|
the wire.
|
||||||
|
|
||||||
|
A CORRECTION TO 56.4, and it is small: that table charged audio at
|
||||||
|
ratectl.AUDIO_KBPS = {RC.AUDIO_KBPS} KB/s, which is {AU_BPS:.1f} B/s expressed in decimal
|
||||||
|
kB (21_iplrom_dmac.py says so). In binary KB the figure is {AU_BPS/1024:.4f}, i.e.
|
||||||
|
{100*(RC.AUDIO_KBPS-AU_BPS/1024)/(AU_BPS/1024):+.2f}%. Every column of 56.4 moves in the flattering direction by
|
||||||
|
less than one part in six hundred of the wire. It is recorded because a
|
||||||
|
placeholder that turns out to be right is still a placeholder.""")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 2
|
||||||
|
print(f"""
|
||||||
|
=== 2. THE PACKED BRANCH: THERE IS NO CLIMB, AND THAT IS THE FINDING ====
|
||||||
|
video {P_VID:.1f} + audio {P_AUD:.4f} (F={F0}, A={A0}, {A0*SECTOR:,} B a lump) = {P_VID+P_AUD:.1f} KB/s,
|
||||||
|
which is B1's acceptance figure and is where it comes from.
|
||||||
|
|
||||||
|
DMAC-direct (what src/player/packed.s runs, FINDINGS 64/68):
|
||||||
|
video lookahead 0 records. The climb does not exist, the ceiling does not
|
||||||
|
exist, and the {len(gaps)} gaps buy it NOTHING -- there is no accumulator for
|
||||||
|
play to fill. Its acceptance is a PER-FRAME deadline: {P_REC:,} B must land
|
||||||
|
inside every slot, and a rate that averages {P_VID+P_AUD:.1f} KB/s over a second is
|
||||||
|
not the same claim. 56.4's alarming column -- most branch points arrive
|
||||||
|
with less lookahead than the one before them -- does not apply to it,
|
||||||
|
because every frame arrives with less lookahead than the one before it.
|
||||||
|
|
||||||
|
CPU-painted (64.2 column B, 99,328 B, two record buffers -> 1 record of
|
||||||
|
lookahead):
|
||||||
|
{'pipe':>7} {'climb SILENT':>13} {'SOUNDED':>9} {'x':>6} {'under, silent':>13} {'under, sounded':>14}""")
|
||||||
|
tab2 = []
|
||||||
|
def secs(x, w):
|
||||||
|
return f"{x:{w}.2f}s" if x != float("inf") else f"{'never':>{w+1}}"
|
||||||
|
for kbps in a.pkbps:
|
||||||
|
cs = climb_s(1, P_REC, kbps, P_VID)
|
||||||
|
ca = climb_s(1, P_REC, kbps, P_VID + P_AUD)
|
||||||
|
us = sum(1 for g in gaps if g < cs)
|
||||||
|
ua = sum(1 for g in gaps if g < ca)
|
||||||
|
r = ca / cs if cs not in (0.0, float("inf")) else float("inf")
|
||||||
|
tab2.append((kbps, cs, ca, r, us, ua))
|
||||||
|
print(f" {kbps:7.1f} {secs(cs,12)} {secs(ca,8)} "
|
||||||
|
f"{(f'{r:5.2f}x' if r != float('inf') else ' inf ')} "
|
||||||
|
f"{f'{us}/{len(gaps)}':>13} {f'{ua}/{len(gaps)}':>14}")
|
||||||
|
print(f" -- and EVERY rate in section 1's table is below {P_VID:.1f} KB/s, so "
|
||||||
|
f"none of\n them serves this container at all.")
|
||||||
|
print(f"""
|
||||||
|
READ THE FIRST ROW. At {P_VID+P_AUD:.1f} KB/s -- the acceptance figure this project
|
||||||
|
quotes -- the SILENT container still climbs its one record in {tab2[0][1]:.2f} s and
|
||||||
|
the SOUNDED one NEVER DOES, because {P_VID+P_AUD:.1f} is where its surplus is exactly
|
||||||
|
zero. The acceptance figure is the rate at which the sounded container has
|
||||||
|
no lookahead at any amount of play, which is a different thing from the rate
|
||||||
|
at which it plays.
|
||||||
|
|
||||||
|
A packed record is 1.3x the codec's mean record and the packed wire is 1.3x
|
||||||
|
the codec's, so a rate that is generous to one is tight for the other and the
|
||||||
|
same audio debit costs the packed climb more. ONE RECORD of lookahead is
|
||||||
|
1/12 s of tolerance and it takes seconds of play to earn.
|
||||||
|
|
||||||
|
THE TWO BRANCHES DIFFER HERE ON A COLUMN THAT IS NOT CLOCKS, which is the
|
||||||
|
third time (61.9, 64.2, and this). The packed branch spent its ring to
|
||||||
|
delete a decoder; what it bought with the RAM is a player with no tolerance
|
||||||
|
for a slow record at ANY time, not merely after a branch.""")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 3
|
||||||
|
sil = silences(within, F0)
|
||||||
|
free = sum(1 for x in sil if x == 0.0)
|
||||||
|
P_PIPE = a.pkbps[1] if len(a.pkbps) > 1 else a.pkbps[0]
|
||||||
|
lump_ms = A0 * SECTOR / (P_PIPE * 1024) * 1000
|
||||||
|
behind = (F0 - 1) * P_REC + A0 * SECTOR
|
||||||
|
print(f"""
|
||||||
|
=== 3. THE COST NOBODY HAD COUNTED: entering a group off-boundary =======
|
||||||
|
A DLXP2 group is `lump k, then F records` (dlxp.py), so lump k is at a LOWER
|
||||||
|
address than every record of its group except the first. Reading forward from
|
||||||
|
record i, the next lump to arrive is k+1, and it carries frame (k+1)*F. The
|
||||||
|
frames from i to (k+1)*F-1 therefore have picture and no sound.
|
||||||
|
|
||||||
|
Measured on the {len(within)} within-container seek targets of the arcade's own
|
||||||
|
graph, at the shipped cadence F={F0}:
|
||||||
|
|
||||||
|
mean {sum(sil)/len(sil):7.1f} ms of silence entering the branch
|
||||||
|
median {pct(sil,.50):7.1f} p90 {pct(sil,.90):7.1f} worst {sil[-1]:7.1f}
|
||||||
|
free {free}/{len(within)} land on a group boundary and cost nothing
|
||||||
|
|
||||||
|
The other {len(changes)} branch points -- the scene changes -- are FREE, and by
|
||||||
|
construction: lump 0 sits at sector 1 and record 0 at {d.off_frm:,}, so a
|
||||||
|
container's own first bytes are header, lump, record and a scene change reads
|
||||||
|
them in one forward pass. **The container's start is the one branch point
|
||||||
|
the cadence costs nothing at, and it is the only one anybody had looked at.**
|
||||||
|
|
||||||
|
THE FIX IS A SECOND READ AND NOBODY HAS ONE. Lump k is {behind:,} B behind
|
||||||
|
record i at worst, so it cannot be picked up by reading early -- it is a
|
||||||
|
separate command at a separate LBA, of {A0*SECTOR:,} B, which at {P_PIPE:.1f} KB/s is
|
||||||
|
{lump_ms:.1f} ms against a mean {sum(sil)/len(sil):.0f} ms of silence -- {sum(sil)/len(sil)/lump_ms:.0f}x cheaper in TIME,
|
||||||
|
one more command per branch, and the command overhead is B1's and unmeasured.
|
||||||
|
src/player/packed.s starts PG_AK and PG_AKF at lump 0 and has no audio seek
|
||||||
|
path at all; the player that branches needs one.
|
||||||
|
|
||||||
|
=== 4. THE CADENCE PICK, WITH THE THIRD COLUMN IT DID NOT HAVE ==========
|
||||||
|
32_audio_wire.py chose F={F0} on two columns, padding and RAM. Here is the same
|
||||||
|
sweep with the branch column, measured on the game's own seek targets rather
|
||||||
|
than assumed uniform:
|
||||||
|
|
||||||
|
{'F':>3} {'A':>3} {'lump B':>8} {'pad%':>7} {'aud KB/s':>9} {'RAM x2':>8} {'mean sil':>9} {'p90':>8} {'worst':>8} {'free':>10} {'vs uniform':>11}""")
|
||||||
|
for F in range(1, a.fsweep + 1):
|
||||||
|
A, lump = cadence(F)
|
||||||
|
need = F * AU_FRAME
|
||||||
|
s = silences(within, F)
|
||||||
|
uni = (F - 1) / 2 / FPS * 1000.0
|
||||||
|
mean = sum(s) / len(s)
|
||||||
|
mark = " <- shipped" if F == F0 else ""
|
||||||
|
print(f" {F:3d} {A:3d} {lump:8,} {100*(lump-need)/need:6.2f}% "
|
||||||
|
f"{lump/F*FPS/1024:8.3f} {2*lump:8,} {mean:8.1f} "
|
||||||
|
f"{pct(s,.90):8.1f} {s[-1]:8.1f} "
|
||||||
|
f"{f'{sum(1 for x in s if x == 0)}/{len(s)}':>10} "
|
||||||
|
f"{(mean/uni if uni else 1.0):10.2f}x{mark}")
|
||||||
|
|
||||||
|
ratios = []
|
||||||
|
for F in range(2, a.fsweep + 1):
|
||||||
|
sF_ = silences(within, F)
|
||||||
|
ratios.append((sum(sF_) / len(sF_)) / ((F - 1) / 2 / FPS * 1000.0))
|
||||||
|
min_r, max_r = min(ratios), max(ratios)
|
||||||
|
A1, l1 = cadence(1)
|
||||||
|
AF, lF = cadence(F0)
|
||||||
|
s1, sF = silences(within, 1), silences(within, F0)
|
||||||
|
print(f"""
|
||||||
|
F=1 -- "one lump a record", the cadence 32 called THE WORST ONE -- has no
|
||||||
|
group to enter off-boundary, no second read, no audio seek path and 2,048 B
|
||||||
|
of held lump instead of {2*lF:,}. It costs {l1/1*FPS/1024 - lF/F0*FPS/1024:+.3f} KB/s of wire, which is
|
||||||
|
{100*(l1/1*FPS/1024 - lF/F0*FPS/1024)/(P_VID+P_AUD):+.2f}% of the packed acceptance figure, and it BUYS BACK {2*lF-2*l1:,} B
|
||||||
|
of RAM on the branch whose whole argument is that RAM is what it has spare.
|
||||||
|
|
||||||
|
THE PICK IS THEREFORE REOPENED, and it is a real trade rather than an error:
|
||||||
|
padding is what F={F0} minimises and padding is not the only thing F sets.
|
||||||
|
A player that gets its audio seek right is indifferent; a player that does
|
||||||
|
not pays a mean {sum(sF)/len(sF):.0f} ms of silence at {len(within)} of the game's {len(gaps)} branch
|
||||||
|
points. Nothing here decides it -- the deciding number is the SCSI command
|
||||||
|
overhead of the extra read, and that is B1's.
|
||||||
|
|
||||||
|
AND THE CONTENT IS NOT UNIFORM MOD F. A uniform assumption would put the
|
||||||
|
mean at (F-1)/2 frames; the arcade's seek targets land where they land, and
|
||||||
|
the ratio column above runs {min_r:.2f}x..{max_r:.2f}x over the sweep, so a design
|
||||||
|
that assumed uniform would be out by a quarter at F=3. At the shipped F={F0} it is
|
||||||
|
{(sum(sF)/len(sF))/((F0-1)/2/FPS*1000):.2f}x, which is a coincidence and is reported as one.
|
||||||
|
|
||||||
|
=== 5. WHAT THIS DOES NOT ESTABLISH ====================================
|
||||||
|
1. NO RATE HERE IS MEASURED. Every pipe column is a sensitivity (FINDINGS
|
||||||
|
50), and B1 -- sustained AND data-phase burst -- is still the user's.
|
||||||
|
2. THE FRAME INDEX OF A SEEK TARGET IS A DESIGN ASSUMPTION. One container
|
||||||
|
per SCENE (53, 55.1, 56.3). One container per SEQUENCE makes section 3
|
||||||
|
zero and section 4 moot; nothing else in the file changes.
|
||||||
|
3. NOTHING RAN ON THE MACHINE. This is arithmetic over a scene table, two
|
||||||
|
containers and a player's own constants. 68's player has never seeked.
|
||||||
|
4. THE MECHANICAL SEEK IS STILL UNMODELLED (51.7.5) and is charged on top of
|
||||||
|
every millisecond here.
|
||||||
|
5. THE SILENCE IS A CONTAINER PROPERTY, NOT A CHIP ONE. What the MSM6258
|
||||||
|
does when it is not fed -- hold the last sample, or click -- is a board
|
||||||
|
question and belongs with session 34's fifth hardware item.""")
|
||||||
|
|
||||||
|
if a.gate:
|
||||||
|
# Structural assertions. Not the milliseconds -- those move with the
|
||||||
|
# scene table -- but the ORDER and the SIGNS, which are the finding.
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
nonlocal ok
|
||||||
|
print(f" {'OK ' if cond else 'FAIL'} {msg}")
|
||||||
|
ok = ok and bool(cond)
|
||||||
|
|
||||||
|
print("\n=== GATE ===============================================")
|
||||||
|
check(len(gaps) == 612, f"612 transitions into a seek, got {len(gaps)}")
|
||||||
|
check(len(within) + len(changes) == len(gaps),
|
||||||
|
f"{len(within)} within + {len(changes)} scene changes = {len(gaps)}")
|
||||||
|
check(all(r[5] >= 1.0 for r in tab1),
|
||||||
|
"audio never SHORTENS the codec climb")
|
||||||
|
check(max(r[5] for r in tab1) > 1.5,
|
||||||
|
f"and at some rate it more than 1.5x's it "
|
||||||
|
f"({max(r[5] for r in tab1):.2f}x)")
|
||||||
|
check(all(r[7] >= r[6] for r in tab1),
|
||||||
|
"and never lowers the count of branch points under the climb")
|
||||||
|
check(free < len(within) // 2,
|
||||||
|
f"most within-container branches enter a group off-boundary "
|
||||||
|
f"({len(within)-free}/{len(within)})")
|
||||||
|
check(silences(within, 1) == [0.0] * len(within),
|
||||||
|
"F=1 has no off-boundary case at all")
|
||||||
|
check(sum(sil) / len(sil) > 10 * lump_ms,
|
||||||
|
f"the silence F={F0} costs is >10x the lump read that removes it "
|
||||||
|
f"({sum(sil)/len(sil):.0f} ms vs {lump_ms:.1f} ms)")
|
||||||
|
print(" " + ("BRANCH-AUDIO GATE GREEN" if ok else "BRANCH-AUDIO GATE RED"))
|
||||||
|
return 0 if ok else 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""What a BRANCH costs the chip, and what an encoder could do about it.
|
||||||
|
|
||||||
|
python3 tools/analysis/37_audio_seek.py [container.dlxp] [--raw au.raw]
|
||||||
|
python3 tools/analysis/37_audio_seek.py --gate # the check.sh stage
|
||||||
|
|
||||||
|
FINDINGS 71. Session 39 put an audio seek path in src/player/packed.s and ran
|
||||||
|
it: 132,162 B across a real branch, every byte accounted for in MAME's own
|
||||||
|
capture. That settled the BYTES. This settles what is left, which is a
|
||||||
|
property of the codec rather than of the player and which no counter in the
|
||||||
|
player can reach.
|
||||||
|
|
||||||
|
THE MSM6258'S ACCUMULATOR HAS NO LEAKAGE TERM. It is a pure integrator of
|
||||||
|
deltas, clamped, and nothing pulls it back toward zero. So a branch that hands
|
||||||
|
the chip bytes chosen for a state it is not in does not produce a transient with
|
||||||
|
a time constant -- it produces a DC OFFSET THAT NEVER DECAYS. The machine run
|
||||||
|
measures both designs at one branch point; this measures the CENSUS, over every
|
||||||
|
frame boundary of the container, and prices the only fix that is worth anything,
|
||||||
|
which is in the encoder and not in the player.
|
||||||
|
|
||||||
|
* PLAY THROUGH the branch: the chip keeps whatever accumulator and step index
|
||||||
|
the previous scene's audio left it in. Unbounded, and its decay is the
|
||||||
|
signal's own clamping rather than the recursion forgetting.
|
||||||
|
|
||||||
|
* STOP and re-PLAY: the accumulator goes to the container's `init` and the
|
||||||
|
step index to 0 -- a state this script knows exactly, so the error is
|
||||||
|
EXACTLY `init - acc(target)`, constant, forever.
|
||||||
|
|
||||||
|
* ...and the third option is the ENCODER'S: encode the stream with the
|
||||||
|
predictor RESET at every point a branch can land on. Then a re-PLAYing
|
||||||
|
player is not close, it is exact. What that costs is a codec question and
|
||||||
|
is measured below.
|
||||||
|
|
||||||
|
NAME THE LAYER. Everything here is host arithmetic over one container and its
|
||||||
|
source PCM. The chip's four axes are the ones FINDINGS 66 measured on the
|
||||||
|
machine and 67.3 put in the header; the branch behaviour is the one session 39
|
||||||
|
ran. No emulator is involved and no rate is claimed.
|
||||||
|
"""
|
||||||
|
import argparse, math, os, statistics, sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(HERE, "..", "encoder"))
|
||||||
|
import adpcm
|
||||||
|
from dlxp import DLXP
|
||||||
|
|
||||||
|
|
||||||
|
def acc_trajectory(nibbles, dec):
|
||||||
|
"""The decoder's accumulator after every sample. This IS the encoder's
|
||||||
|
assumed state, because adpcm.encode runs its decoder inside its own search
|
||||||
|
loop -- the encoder cannot hold a state the decoder will not reach."""
|
||||||
|
lo, hi = adpcm.clamp_bounds(dec["bits"])
|
||||||
|
sig, idx = dec["init"], 0
|
||||||
|
out = []
|
||||||
|
for n in nibbles:
|
||||||
|
sig += adpcm.delta(n, adpcm.STEP[idx], dec["variant"])
|
||||||
|
sig = lo if sig < lo else (hi if sig > hi else sig)
|
||||||
|
idx += adpcm.INDEX_ADJUST[n & 7]
|
||||||
|
idx = 0 if idx < 0 else (48 if idx > 48 else idx)
|
||||||
|
out.append((sig, idx))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def encode_reset(src12, dec, period):
|
||||||
|
"""adpcm.encode with the predictor forced back to (init, 0) every `period`
|
||||||
|
samples. period=None is the ordinary encode.
|
||||||
|
|
||||||
|
THIS CHANGES THE BYTES, so it is a container property and not a flag a
|
||||||
|
player can set. It is written here rather than in tools/encoder/adpcm.py
|
||||||
|
because nothing has decided to ship it: 71.5 is the trade and the deciding
|
||||||
|
number is a hardware one."""
|
||||||
|
if period is None:
|
||||||
|
return adpcm.encode(src12, variant=dec["variant"], init=dec["init"],
|
||||||
|
bits=dec["bits"])
|
||||||
|
out = bytearray()
|
||||||
|
for i in range(0, len(src12), period):
|
||||||
|
out += adpcm.encode(src12[i:i + period], variant=dec["variant"],
|
||||||
|
init=dec["init"], bits=dec["bits"])
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def snr(ref, got):
|
||||||
|
n = min(len(ref), len(got))
|
||||||
|
sig = sum(x * x for x in ref[:n])
|
||||||
|
err = sum((ref[i] - got[i]) ** 2 for i in range(n))
|
||||||
|
if err == 0:
|
||||||
|
return float("inf")
|
||||||
|
return 10 * math.log10(sig / err) if sig else float("-inf")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_reset(nib, dec, period):
|
||||||
|
if period is None:
|
||||||
|
return list(adpcm.decode_state(nib, variant=dec["variant"],
|
||||||
|
init=dec["init"], bits=dec["bits"])[0])
|
||||||
|
out = []
|
||||||
|
for i in range(0, len(nib), period):
|
||||||
|
out += list(adpcm.decode_state(nib[i:i + period], variant=dec["variant"],
|
||||||
|
init=dec["init"], bits=dec["bits"])[0])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("container", nargs="?", default="tmp/packed_singe.dlxp")
|
||||||
|
ap.add_argument("--raw", default="tmp/au_singe.raw")
|
||||||
|
ap.add_argument("--gate", action="store_true")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
d = DLXP(a.container)
|
||||||
|
if not d.has_audio:
|
||||||
|
sys.exit("this container is silent -- there is no branch to price")
|
||||||
|
dec = d.decoder()
|
||||||
|
lo, hi = adpcm.clamp_bounds(dec["bits"])
|
||||||
|
data = d.audio()
|
||||||
|
nib = adpcm.unpack(data, len(data) * 2, order=dec["order"])
|
||||||
|
traj = acc_trajectory(nib, dec)
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
def ck(ok, msg):
|
||||||
|
print(("OK " if ok else "FAIL ") + msg)
|
||||||
|
if not ok:
|
||||||
|
fails.append(msg)
|
||||||
|
|
||||||
|
# ---- 1. THE CENSUS. What a re-PLAY costs at every frame boundary the
|
||||||
|
# container has, which is every point a branch in this design can land on:
|
||||||
|
# 56.3's targets are frame indices and this player seeks to a frame.
|
||||||
|
den = 2 * d.fps
|
||||||
|
# THE INDEX IS A NIBBLE INDEX AND THE POSITION IS A BYTE ONE, which is the
|
||||||
|
# one conversion in this file and it is worth the line: getting it wrong
|
||||||
|
# reads the trajectory at HALF the target and produces a census that is
|
||||||
|
# entirely plausible -- a distribution of the right shape over the wrong
|
||||||
|
# instants. The cross-check against the machine's own branch point below is
|
||||||
|
# what caught it.
|
||||||
|
pos = lambda f: 2 * (f * d.aud_hz // den) - 1
|
||||||
|
frames = [f for f in range(1, d.nframes) if pos(f) < len(traj)]
|
||||||
|
dcs = [abs(dec["init"] - traj[pos(f)][0]) for f in frames]
|
||||||
|
idxs = [traj[pos(f)][1] for f in frames]
|
||||||
|
dcs_s = sorted(dcs)
|
||||||
|
p = lambda q: dcs_s[min(len(dcs_s) - 1, int(q * len(dcs_s)))]
|
||||||
|
print(f"--- 1. A RE-PLAYED BRANCH COSTS `init - acc(target)`, EXACTLY AND "
|
||||||
|
f"FOREVER. {len(frames)} frame boundaries of {a.container}:")
|
||||||
|
print(f" |DC| against the {hi}-unit clamp: mean {statistics.mean(dcs):.1f} "
|
||||||
|
f"({statistics.mean(dcs)*100/hi:.1f}%), median {statistics.median(dcs):.0f}, "
|
||||||
|
f"p90 {p(0.90)}, worst {max(dcs)} ({max(dcs)*100/hi:.1f}%)")
|
||||||
|
print(f" ...and the step index the encoder assumed at those points runs "
|
||||||
|
f"{min(idxs)}..{max(idxs)} of 48, median {statistics.median(idxs):.0f} "
|
||||||
|
f"-- a re-PLAY sets it to 0, so a branch into a LOUD passage gets the "
|
||||||
|
f"offset AND a step index that has to climb back")
|
||||||
|
# The machine run's own branch, so the two layers are checked against each
|
||||||
|
# other rather than merely agreeing in prose.
|
||||||
|
F37 = 37
|
||||||
|
dc37 = dec["init"] - traj[pos(F37)][0]
|
||||||
|
print(f" frame {F37}, the branch tools/bench/packed_run.sh runs on the "
|
||||||
|
f"machine: DC {dc37} -- and MAME's capture measured the chip at "
|
||||||
|
f"exactly that, constant over 62,500 samples (FINDINGS 71.3)")
|
||||||
|
ck(abs(dc37) == 65,
|
||||||
|
f"the host's arithmetic for the machine's own branch point is {abs(dc37)} "
|
||||||
|
f"and the capture said 65 -- one number, two layers")
|
||||||
|
|
||||||
|
# ---- 2. THE DECAY THAT ISN'T. A re-PLAY's error is constant BY
|
||||||
|
# CONSTRUCTION -- same step index, same nibbles, one offset -- and playing
|
||||||
|
# through is not, because the step indices differ too. The point of
|
||||||
|
# measuring it here is that the constancy is a PROPERTY OF THE PREDICTOR
|
||||||
|
# and not of the ten seconds this container happens to hold.
|
||||||
|
print(f"--- 2. AND IT DOES NOT DECAY. The accumulator is an integrator with "
|
||||||
|
f"no leak: a re-PLAY changes the STARTING value and nothing else, so "
|
||||||
|
f"the same nibbles produce the same deltas and the offset is carried "
|
||||||
|
f"to the end of the stream. The machine agrees -- AC 0.00 over four "
|
||||||
|
f"seconds (FINDINGS 71.3). Playing THROUGH the branch is worse and is "
|
||||||
|
f"not constant, because the step index differs as well: -355 falling "
|
||||||
|
f"to -108 over four seconds, which is clamping and not forgetting.")
|
||||||
|
|
||||||
|
# ---- 3. THE ENCODER'S FIX, PRICED. Reset the predictor where a branch can
|
||||||
|
# land and a re-PLAYing player is EXACT rather than close.
|
||||||
|
if not os.path.exists(a.raw):
|
||||||
|
print(f" (no {a.raw}: the encoder trade below needs the source PCM)")
|
||||||
|
return 1 if fails else 0
|
||||||
|
import struct
|
||||||
|
pcm = struct.unpack("<%dh" % (os.path.getsize(a.raw) // 2),
|
||||||
|
open(a.raw, "rb").read())
|
||||||
|
src12 = [max(-2048, min(2047, x >> 4)) for x in pcm][:len(nib)]
|
||||||
|
per_frame = d.aud_hz // den * 2 # samples in one frame slot
|
||||||
|
print(f"--- 3. THE ONLY FIX THAT MAKES A BRANCH FREE IS THE ENCODER'S, and "
|
||||||
|
f"here is its bill. Reset the predictor every N frames when encoding; "
|
||||||
|
f"a player that re-PLAYs at a branch landing on one of those points is "
|
||||||
|
f"then EXACT, not close:")
|
||||||
|
print(f" {"reset every":>24} {'SNR dB':>8} {'vs shipped':>10} "
|
||||||
|
f"{'branch points made free':>24}")
|
||||||
|
base = None
|
||||||
|
rows = []
|
||||||
|
for label, period in [("never (shipped)", None),
|
||||||
|
(f"{d.cad_f} frames (the cadence)", d.cad_f * per_frame),
|
||||||
|
("1 frame", per_frame)]:
|
||||||
|
nb = encode_reset(src12, dec, period)
|
||||||
|
got = decode_reset(nb, dec, period)
|
||||||
|
v = snr(src12, got)
|
||||||
|
if base is None:
|
||||||
|
base = v
|
||||||
|
free = (0 if period is None
|
||||||
|
else (len(frames) // d.cad_f if period != per_frame
|
||||||
|
else len(frames)))
|
||||||
|
rows.append((label, v, v - base, free))
|
||||||
|
print(f" {label:>24} {v:8.2f} {v-base:+10.2f} "
|
||||||
|
f"{free:>15} of {len(frames)}")
|
||||||
|
# THE ASSERTION IS THE ORDER AND THE SIGN, not the decibel: the source PCM
|
||||||
|
# is a property of the disc and the encoder is greedy, so the exact figures
|
||||||
|
# move with the window. What must not move is that resetting COSTS SNR and
|
||||||
|
# that resetting more often costs more -- if it ever came out free, the
|
||||||
|
# predictor would not be doing anything and the codec would be pointless.
|
||||||
|
ck(rows[1][1] <= rows[0][1] + 1e-9 and rows[2][1] <= rows[1][1] + 1e-9,
|
||||||
|
f"resetting the predictor costs SNR, and resetting it more often costs "
|
||||||
|
f"more: {rows[0][1]:.2f} -> {rows[1][1]:.2f} -> {rows[2][1]:.2f} dB")
|
||||||
|
ck(rows[2][1] > rows[0][1] - 3.0,
|
||||||
|
f"...and a reset EVERY FRAME is {rows[0][1]-rows[2][1]:.2f} dB, which is "
|
||||||
|
f"the price of making all {len(frames)} of this container's branch points "
|
||||||
|
f"exact. The step table's floor is a constant 16 and the recursion "
|
||||||
|
f"re-converges in a few samples, which is why twelve resets a second is "
|
||||||
|
f"not twelve times anything")
|
||||||
|
|
||||||
|
print(f"--- 4. WHAT THIS DOES NOT SETTLE.")
|
||||||
|
print(f" * Nothing here is a rate and nothing here ran on silicon. The "
|
||||||
|
f"branch behaviour is MAME's okim6258 -- PLAY sets the accumulator to "
|
||||||
|
f"-2, the step index to 0 and the nibble select to 0 -- which is the "
|
||||||
|
f"model FINDINGS 66 fitted to the machine and NOT a measurement of an "
|
||||||
|
f"MSM6258V. It joins session 34's fifth hardware item.")
|
||||||
|
print(f" * The census is ONE container, ten seconds, one passage at "
|
||||||
|
f"-13.4 dBFS (FINDINGS 69). The offset a re-PLAY costs is the signal's "
|
||||||
|
f"own value at the cut, so a louder passage costs more, up to the "
|
||||||
|
f"clamp -- and the disc peaks at 946 of 2048 (69.2).")
|
||||||
|
print(f" * The reset-every-frame encode is NOT in tools/encoder. It is "
|
||||||
|
f"a container change (a DLXP3), it costs bytes nothing and SNR "
|
||||||
|
f"something, and what decides it is whether a branch is allowed to "
|
||||||
|
f"land anywhere or only on frames the encoder was told about.")
|
||||||
|
print("AUDIO SEEK GATE " + ("GREEN" if not fails else f"RED: {len(fails)}"))
|
||||||
|
return 1 if fails else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Bus-cycle cost of src/player/decode.s and of tools/bench/blit.s's v6 spans.
|
||||||
|
|
||||||
|
A 68000 bus cycle is 4 clocks (S0-S7) with no wait states, and the 68000
|
||||||
|
prefetches every instruction word over the same bus. So a block's bus cost is
|
||||||
|
`instruction words + data accesses`, a long access counting twice on the 16-bit
|
||||||
|
bus and `movem.l` of N registers counting 2N.
|
||||||
|
|
||||||
|
The per-path word counts are read off tools/bench/decode.lst and
|
||||||
|
tools/bench/blit.s. tools/analysis/15_bus_occupancy.py checks the DATA half of
|
||||||
|
this table against tools/bench/c68k/c68k_bench, which counts every bus callback
|
||||||
|
the C68K core makes: they agree to 0.04%. The prefetch half cannot be measured
|
||||||
|
from either emulator -- MAME does not expose a fetch count and C68K reads
|
||||||
|
opcodes through a host pointer with no callback -- so it rests on that check.
|
||||||
|
"""
|
||||||
|
BUS_CLK = 4
|
||||||
|
|
||||||
|
# --- decode.s, per block ---------------------------------------------------
|
||||||
|
# dispatch move.b (a1),d0 / lsr.b / and.w #3 / beq .sk 6w, 1 read
|
||||||
|
# + subq / beq .v1 -> 8w
|
||||||
|
# + subq / bne .rw -> 10w
|
||||||
|
# V4 body $10090..$100E2 = 82 B = 41w; 4 x (1 byte read
|
||||||
|
# + movem.l 2 = 4 reads + 2 move.l = 4 writes) = 36
|
||||||
|
# V1 body $100E2..$10106 = 36 B = 18w; 1 byte read
|
||||||
|
# + movem.l 8 = 16 reads + 4 x movem.l 2 = 16 wr = 33
|
||||||
|
# RAW body $10106..$10164 = 94 B = 47w; 8 x (2 byte reads
|
||||||
|
# + 1 move.l = 2 writes) = 32
|
||||||
|
BODY = {0: (0, 0), 1: (18, 33), 2: (41, 36), 3: (47, 32)}
|
||||||
|
DISPATCH = {0: 6, 1: 8, 2: 10, 3: 10}
|
||||||
|
SK_TAIL = 1 # addq.l #8,a4
|
||||||
|
GROUP_HEAD = 3 # tst.b (a1) + beq allskip
|
||||||
|
GROUP_TAIL = 4 # addq.l #1,a1 / cmpa.l a5,a4 / bne byteloop
|
||||||
|
ALLSKIP = 9 # the whole four-block fast path, tst.b included
|
||||||
|
ROW_HEAD, ROW_TAIL = 3, 7
|
||||||
|
|
||||||
|
# --- blit.s v6 spans -------------------------------------------------------
|
||||||
|
# One chain unit moves 12 registers = 48 B = 24 pixels:
|
||||||
|
# movem.l (a0)+,12 = 2w instr + 24 word reads = 26
|
||||||
|
# movem.l 12,(a2) = 2w instr + 24 word writes = 26
|
||||||
|
# lea 48(a2),a2 = 2w instr = 2
|
||||||
|
# Per span: move.l (a0)+,a2 (1w + 2 reads) + move.w (a0)+,d0 (1w + 1 read)
|
||||||
|
# + jmp v6ch(pc,d0.w) (2w) + dbra (2w) = 9
|
||||||
|
V6_UNIT_PX = 24
|
||||||
|
V6_UNIT_BUS = 54
|
||||||
|
V6_SPAN_BUS = 9
|
||||||
|
V6_SPAN_CYC = 43.7 # MEASURED, FINDINGS 30
|
||||||
|
V6_PX_CYC = 9.152 # MEASURED, FINDINGS 30
|
||||||
|
|
||||||
|
# --- a DMAC array-chaining span -------------------------------------------
|
||||||
|
# SOURCED, MC68450 Direct Memory Access Controller, Motorola, Jul 1989
|
||||||
|
# (bitsavers). These replace session-10's first pass, which guessed 2 bus
|
||||||
|
# cycles a pixel from bus arithmetic and was 12% optimistic.
|
||||||
|
#
|
||||||
|
# Fig 4-25 sheet 4, DUAL ADDRESS / OPERAND SIZE IS WORD / DEVICE SIZE IS
|
||||||
|
# 16-BITS, D->M or M->D: {WORD READ, WORD WRITE} = 9 CLOCKS.
|
||||||
|
# Confirmed by the long-operand row: two of each = 18 clocks.
|
||||||
|
# Fig 4-25 note 2: reads are 4 clocks and WRITES ARE 5. That extra clock on
|
||||||
|
# every write is the whole story -- it is why the DMAC does not beat a 68000
|
||||||
|
# movem chain, which writes in 4.
|
||||||
|
DMA_PX_CLK = 9
|
||||||
|
# Fig 4-25 sheet 1, SEQUENTIAL ARRAY CHAINING: 36 CLOCKS per entry (three
|
||||||
|
# word reads to fetch the 6-byte entry, plus reload).
|
||||||
|
DMA_CHAIN_CLK = 36
|
||||||
|
# Sect 4.5.2.1 front-end overhead 5 clocks best case, 8 worst; 4.5.2.2
|
||||||
|
# back-end 2 clocks best. Once per period of bus ownership, not per span.
|
||||||
|
DMA_FRONT_CLK, DMA_BACK_CLK = 5, 2
|
||||||
|
DMA_FRONT_CLK_WORST = 8
|
||||||
|
# Fig 4-25 note 2 again, split out because the ADPCM channel needs the halves
|
||||||
|
# apart: a DMAC READ is 4 clocks and a WRITE is 5, on either bus width. A
|
||||||
|
# dual-address BYTE transfer is therefore one 4 and one 5.
|
||||||
|
DMA_READ_CLK, DMA_WRITE_CLK = 4, 5
|
||||||
|
DMA_DUAL_BYTE_CLK = DMA_READ_CLK + DMA_WRITE_CLK
|
||||||
|
# Fig 4-25 sheet 3, SINGLE ADDRESS: W/B READ 4 clocks, W/B WRITE 5 clocks.
|
||||||
|
# A device->memory disk transfer is one memory WRITE = 5 clocks if the DMAC
|
||||||
|
# holds the bus, or 5 + front + back = 12 if it arbitrates per word.
|
||||||
|
# FINDINGS 5's long-standing 8 clk/word ESTIMATE sits inside that range.
|
||||||
|
DMA_DISK_CLK_WORD_HELD, DMA_DISK_CLK_WORD_ARB = 5, 12
|
||||||
|
|
||||||
|
# --- the ADPCM stream, as the IPL ROM actually programs it -----------------
|
||||||
|
# READ OUT OF THE ROM, not recalled: tools/analysis/21_iplrom_dmac.py decodes
|
||||||
|
# the HD63450 registers Sharp's own IPL 1.0 writes, and gates on the bytes still
|
||||||
|
# being there. Channel 3, DCR = $80, OCR = $32 for playback:
|
||||||
|
#
|
||||||
|
# DTYP = 00 explicitly addressed -> DUAL ADDRESS (memory read, device write)
|
||||||
|
# DPS = 0 8-bit port -> one byte per operand
|
||||||
|
# XRM = 10 cycle steal WITHOUT hold, and REQG = 10 external request
|
||||||
|
# -> the DMAC arbitrates ONCE PER BYTE. No burst to amortise over.
|
||||||
|
#
|
||||||
|
# So an audio byte costs the dual-address transfer PLUS a full arbitration,
|
||||||
|
# every time -- unlike a disk record, which can at least be argued to hold the
|
||||||
|
# bus for a run of bytes. This is the number the audio side of the I/O debit
|
||||||
|
# should be denominated in; DISK_CLK_BYTE is not it.
|
||||||
|
ADPCM_SAMPLE_HZ = 15625.0 # MSM6258V, 8 MHz clock / 512 (the 15.6 kHz mode)
|
||||||
|
ADPCM_BYTES_PER_S = ADPCM_SAMPLE_HZ / 2 # 4-bit samples, two to a byte
|
||||||
|
ADPCM_CLK_BYTE_BEST = DMA_DUAL_BYTE_CLK + DMA_FRONT_CLK + DMA_BACK_CLK # 16
|
||||||
|
ADPCM_CLK_BYTE_WORST = DMA_DUAL_BYTE_CLK + DMA_FRONT_CLK_WORST + DMA_BACK_CLK # 19
|
||||||
|
|
||||||
|
# The 68000 cannot execute while another master owns the bus: no cache, and a
|
||||||
|
# two-word prefetch queue that empties immediately. So DMA time is ADDITIVE to
|
||||||
|
# CPU time, not overlapped -- which is what FINDINGS 35's flat debit assumed
|
||||||
|
# and session 10's first pass wrongly "refined".
|
||||||
|
DMA_OVERLAPS = False
|
||||||
|
|
||||||
|
|
||||||
|
def pad24(npix):
|
||||||
|
return -(-npix // V6_UNIT_PX) * V6_UNIT_PX
|
||||||
|
|
||||||
|
|
||||||
|
def block_bus(mode_map, spanned=None):
|
||||||
|
"""(instruction words, data accesses) for one frame's CPU block decode.
|
||||||
|
|
||||||
|
`spanned` is a boolean array the same shape as mode_map marking blocks a
|
||||||
|
span will paint instead; those blocks still cost their dispatch, because
|
||||||
|
the mode map is walked either way, but not their body."""
|
||||||
|
nby, nbx = mode_map.shape
|
||||||
|
pref = nby * (ROW_HEAD + ROW_TAIL)
|
||||||
|
data = 0
|
||||||
|
for by in range(nby):
|
||||||
|
row = mode_map[by]
|
||||||
|
sp = spanned[by] if spanned is not None else None
|
||||||
|
for gi in range(0, nbx, 4):
|
||||||
|
g = row[gi:gi + 4]
|
||||||
|
if (g == 0).all():
|
||||||
|
pref += ALLSKIP
|
||||||
|
data += 1
|
||||||
|
continue
|
||||||
|
pref += GROUP_HEAD + GROUP_TAIL - 1 # BLOCK 0 has no lsr.b
|
||||||
|
data += 1
|
||||||
|
for k, b in enumerate(g):
|
||||||
|
b = int(b)
|
||||||
|
if sp is not None and sp[gi + k]:
|
||||||
|
b = 0 # the span paints it
|
||||||
|
pw, pd = BODY[b]
|
||||||
|
pref += DISPATCH[b] + pw + SK_TAIL
|
||||||
|
data += 1 + pd
|
||||||
|
return pref, data
|
||||||
|
|
||||||
|
|
||||||
|
# --- v7: v6 with a finer tail (MEASURED, session 11, FINDINGS 40) ----------
|
||||||
|
# v6 pads every span up to 24 pixels because its unrolled chain is built from
|
||||||
|
# 12-register movem units, and FINDINGS 39.3 attributed 86% of the DMAC array
|
||||||
|
# chain's advantage over v6 to exactly that padding. v7 keeps the coarse chain
|
||||||
|
# and appends a second chain whose unit is one `move.l (a0)+,(a2)+` -- 2 pixels,
|
||||||
|
# so the quantum is 2 and a run of 4x4 blocks pads to NOTHING.
|
||||||
|
#
|
||||||
|
# Session 10 proposed a 2-REGISTER MOVEM tail (4 pixels, derived at 56 clocks)
|
||||||
|
# and that would have been the wrong instruction: movem.l (a0)+,d0-d1 plus
|
||||||
|
# movem.l d0-d1,(a2) plus the lea is 14 bus cycles for 4 pixels, where two plain
|
||||||
|
# move.l are 10. The plainest instruction on the machine wins the tail.
|
||||||
|
#
|
||||||
|
# The second entry point needs a second dispatch, and the fine displacement is
|
||||||
|
# carried MID-STREAM (after the coarse pixels, before the fine ones) rather than
|
||||||
|
# in the span record, so the decoder holds nothing extra across the copy and
|
||||||
|
# keeps all 12 payload registers. Costed as 2 more bytes per span.
|
||||||
|
#
|
||||||
|
# MEASURED by tools/bench/span.sh (blit.s v7, 13 span lengths, every config
|
||||||
|
# pixel-exact): cycles = 66.0/span + 9.143/coarse pixel + 9.978/fine pixel,
|
||||||
|
# fitting all 13 to within 0.2%.
|
||||||
|
V7_SPAN_CYC = 66.0 # MEASURED, FINDINGS 40
|
||||||
|
V7_CPX_CYC = 9.143 # MEASURED, FINDINGS 40 (24-pixel coarse unit)
|
||||||
|
V7_FPX_CYC = 9.978 # MEASURED, FINDINGS 40 (2-pixel fine unit)
|
||||||
|
V7_FINE_PX = 2
|
||||||
|
# Bus: per span v6's 9 plus a second {move.w (a0)+,d0 ; jmp} = 2 + 2.
|
||||||
|
# Per fine unit: move.l (a0)+,(a2)+ = 1 instruction word + 2 reads + 2 writes.
|
||||||
|
V7_SPAN_BUS = 13
|
||||||
|
V7_FINE_BUS = 5
|
||||||
|
V7_SPAN_HDR = 8 # {u32 address, u16 coarse disp} + u16 fine disp
|
||||||
|
|
||||||
|
|
||||||
|
def pad2(npix):
|
||||||
|
return -(-npix // V7_FINE_PX) * V7_FINE_PX
|
||||||
|
|
||||||
|
|
||||||
|
def v7_span(npix):
|
||||||
|
"""(pixels carried, CPU clocks) for a v7 span of npix pixels."""
|
||||||
|
k, r = divmod(pad2(npix), V6_UNIT_PX)
|
||||||
|
return (k * V6_UNIT_PX + r,
|
||||||
|
V7_SPAN_CYC + k * V6_UNIT_PX * V7_CPX_CYC + r * V7_FPX_CYC)
|
||||||
|
|
||||||
|
|
||||||
|
def v7_span_bus(npix):
|
||||||
|
"""Bus CYCLES a v7 span occupies -- instruction words plus data accesses."""
|
||||||
|
p, d = v7_span_split(npix)
|
||||||
|
return p + d
|
||||||
|
|
||||||
|
|
||||||
|
def v7_span_split(npix):
|
||||||
|
"""(instruction words, data accesses) for one v7 span, separately.
|
||||||
|
|
||||||
|
15_bus_occupancy.py needs the two apart, because the DATA half is what the
|
||||||
|
C68K harness can check and the PREFETCH half is what rides on that check.
|
||||||
|
|
||||||
|
per span move.l (a0)+,a2 1 word + 2 reads
|
||||||
|
move.w (a0)+,d0 1 word + 1 read (coarse displacement)
|
||||||
|
jmp (pc,d0.w) 2 words
|
||||||
|
move.w (a0)+,d0 1 word + 1 read (fine, from mid-stream)
|
||||||
|
jmp (pc,d0.w) 2 words
|
||||||
|
dbra 2 words -> 9 words, 4 accesses
|
||||||
|
per coarse 2 movem.l of 12 + lea = 6 words, 24 reads + 24 writes
|
||||||
|
per fine move.l (a0)+,(a2)+ = 1 word, 2 reads + 2 writes
|
||||||
|
"""
|
||||||
|
k, r = divmod(pad2(npix), V6_UNIT_PX)
|
||||||
|
f = r // V7_FINE_PX
|
||||||
|
return (9 + k * 6 + f * 1,
|
||||||
|
4 + k * 48 + f * 4)
|
||||||
|
|
||||||
|
|
||||||
|
# Per FRAME, decode.s's paint_spans entry and exit: the span count read, the
|
||||||
|
# guard branch, and the push/pop of a1 that buys back a twelfth payload
|
||||||
|
# register. Two long accesses a frame against 24 pixels a chain unit.
|
||||||
|
V7_FRAME_PREF, V7_FRAME_DATA = 7, 7
|
||||||
|
|
||||||
|
|
||||||
|
def v6_span_bus(npix):
|
||||||
|
k = pad24(npix) // V6_UNIT_PX
|
||||||
|
return V6_SPAN_BUS + k * V6_UNIT_BUS
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""MC68450 / HD63450 register field layouts, in ONE copy.
|
||||||
|
|
||||||
|
Read by tools/analysis/21_iplrom_dmac.py, which decodes what the X68000's IPL
|
||||||
|
ROM programs into the DMAC, and by tools/analysis/27_dmac_config.py, which
|
||||||
|
decodes what src/player/dma.i programs into it. The two exist to be COMPARED
|
||||||
|
-- the ROM's own disk channel costs 16..19 clocks a byte (FINDINGS 52.5) and
|
||||||
|
the player's job is to be cheaper -- and a comparison between two decodings
|
||||||
|
that used two copies of these tables would not be one. This tree has already
|
||||||
|
paid twice for a transform with two copies of itself (FINDINGS 49.7.5).
|
||||||
|
|
||||||
|
SOURCED: MC68450 Direct Memory Access Controller, Motorola, Jul 1989
|
||||||
|
(bitsavers) -- the same document FINDINGS 39 cites for the transfer timings in
|
||||||
|
tools/analysis/buscost.py.
|
||||||
|
"""
|
||||||
|
# --- MC68450 register map, by offset inside a channel's 0x40 block ----------
|
||||||
|
REG = {0x00: "CSR", 0x01: "CER", 0x04: "DCR", 0x05: "OCR", 0x06: "SCR",
|
||||||
|
0x07: "CCR", 0x0A: "MTC", 0x0C: "MAR", 0x14: "DAR", 0x1A: "BTC",
|
||||||
|
0x1C: "BAR", 0x25: "NIV", 0x27: "EIV", 0x29: "MFC", 0x2D: "CPR",
|
||||||
|
0x31: "DFC", 0x39: "BFC"}
|
||||||
|
|
||||||
|
XRM = {0: "burst",
|
||||||
|
1: "UNDEFINED",
|
||||||
|
2: "cycle steal WITHOUT hold (bus released between operands)",
|
||||||
|
3: "cycle steal with hold"}
|
||||||
|
DTYP = {0: "68000-compatible, EXPLICITLY addressed -> DUAL ADDRESS",
|
||||||
|
1: "6800-compatible, EXPLICITLY addressed -> DUAL ADDRESS",
|
||||||
|
2: "device with ACK, implicitly addressed -> SINGLE ADDRESS",
|
||||||
|
3: "device with ACK and RDY, implicit -> SINGLE ADDRESS"}
|
||||||
|
DPS = {0: "8-bit port", 1: "16-bit port"}
|
||||||
|
PCL = {0: "status input", 1: "status input with interrupt",
|
||||||
|
2: "start pulse", 3: "abort input"}
|
||||||
|
SIZE = {0: "byte", 1: "word", 2: "long word", 3: "byte, unpacked"}
|
||||||
|
CHAIN= {0: "none", 1: "UNDEFINED", 2: "array", 3: "linked array"}
|
||||||
|
REQG = {0: "auto-request at limited rate", 1: "auto-request at max rate",
|
||||||
|
2: "EXTERNAL request (one operand per device request)",
|
||||||
|
3: "auto-request first operand, external thereafter"}
|
||||||
|
|
||||||
|
|
||||||
|
def dcr(v):
|
||||||
|
return [f"XRM = {v>>6&3:02b} {XRM[v>>6&3]}",
|
||||||
|
f"DTYP = {v>>4&3:02b} {DTYP[v>>4&3]}",
|
||||||
|
f"DPS = {v>>3&1:b} {DPS[v>>3&1]}",
|
||||||
|
f"PCL = {v&3:02b} {PCL[v&3]}"]
|
||||||
|
|
||||||
|
|
||||||
|
def ocr(v):
|
||||||
|
return [f"DIR = {v>>7&1:b} " +
|
||||||
|
("device -> memory (read)" if v & 0x80 else "memory -> device (write)"),
|
||||||
|
f"SIZE = {v>>4&3:02b} {SIZE[v>>4&3]}",
|
||||||
|
f"CHAIN= {v>>2&3:02b} {CHAIN[v>>2&3]}",
|
||||||
|
f"REQG = {v&3:02b} {REQG[v&3]}"]
|
||||||
|
|
||||||
|
|
||||||
|
def scr(v):
|
||||||
|
m = {0: "no count", 1: "increment", 2: "decrement", 3: "UNDEFINED"}
|
||||||
|
return [f"MAC = {v>>2&3:02b} memory address {m[v>>2&3]}",
|
||||||
|
f"DAC = {v&3:02b} device address {m[v&3]}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- Play one buffer of ADPCM nibbles on the emulated MSM6258V, from 68000 code
|
||||||
|
-- (ROADMAP P6a). The Lua here does what Lua is allowed to do in this tree:
|
||||||
|
-- push bytes in, start the CPU, read the mailbox out. It is NOT in the feed
|
||||||
|
-- path -- session 33's probe was, and a host that writes the data register at
|
||||||
|
-- host-frame rate is not feeding a chip that consumes at 15,625 Hz (65.5).
|
||||||
|
--
|
||||||
|
-- WHAT THE MEASUREMENT IS. MAME's -wavwrite capture, at a sample rate chosen
|
||||||
|
-- to EQUAL the chip's stream rate so nothing resamples it, is the chip's own
|
||||||
|
-- output. tools/bench/verify_adpcm_chip.py reads the four model axes out of it.
|
||||||
|
M = manager.machine
|
||||||
|
SP = M.devices[":maincpu"].spaces["program"]
|
||||||
|
|
||||||
|
local META = loadfile("adpcm_meta.lua")()
|
||||||
|
local AD_FLAG, AD_BUF, AD_LEN = 0x18600, 0x18604, 0x18608
|
||||||
|
local AD_MTC0, AD_CSRF, AD_CERF = 0x1860C, 0x18610, 0x18614
|
||||||
|
local AD_MTCF, AD_MARF, AD_SPIN, AD_STAT = 0x18618, 0x1861C, 0x18620, 0x18624
|
||||||
|
|
||||||
|
local code do local f=io.open("adpcmgate.bin","rb"); code=f:read("a"); f:close() end
|
||||||
|
local data do local f=io.open("adpcm_data.bin","rb"); data=f:read("a"); f:close() end
|
||||||
|
|
||||||
|
local function P(s) print("[ADP] "..s) end
|
||||||
|
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
|
||||||
|
|
||||||
|
local st, t0, tplay = "boot", nil, nil
|
||||||
|
SUB = emu.add_machine_frame_notifier(function()
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
local t = T()
|
||||||
|
if st == "boot" then
|
||||||
|
if t < 3.0 then return end
|
||||||
|
for i = 1, #data do SP:write_u8(META.buf + i - 1, string.byte(data, i)) end
|
||||||
|
for i = 1, #code do SP:write_u8(0x10000 + i - 1, string.byte(code, i)) end
|
||||||
|
SP:write_u32(AD_FLAG, 0)
|
||||||
|
SP:write_u32(AD_BUF, META.buf)
|
||||||
|
SP:write_u32(AD_LEN, META.nbytes)
|
||||||
|
local cpu = M.devices[":maincpu"]
|
||||||
|
cpu.state["SR"].value = 0x2700 -- supervisor, ALL interrupts masked
|
||||||
|
cpu.state["SP"].value = 0x8000
|
||||||
|
cpu.state["PC"].value = 0x10000
|
||||||
|
P(string.format("pushed %d B of code and %d B of nibbles at 0x%X",
|
||||||
|
#code, META.nbytes, META.buf))
|
||||||
|
-- THE CAPTURE'S OWN CLOCK. The wav starts at t=0 of the run, so the host
|
||||||
|
-- has to know when PLAY happened to find the stream in it -- but it is
|
||||||
|
-- NOT used as the alignment: the verifier searches a small window around
|
||||||
|
-- it, because a host frame is 17.6 ms and a sample is 64 us.
|
||||||
|
st, t0 = "running", t
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if st == "running" then
|
||||||
|
local fl = SP:read_u32(AD_FLAG)
|
||||||
|
if fl == 2 and not tplay then
|
||||||
|
tplay = t
|
||||||
|
P(string.format("PLAY at t=%.4f s, chip status $%02X (bit7 clear = playing), "
|
||||||
|
.."MTC then = %d of %d", t, SP:read_u32(AD_STAT),
|
||||||
|
SP:read_u32(AD_MTC0), META.nbytes))
|
||||||
|
end
|
||||||
|
if fl == 0xFF or fl == 0xEE then
|
||||||
|
P(string.format("channel finished: CSR=$%02X CER=$%02X MTC=%d MAR=$%06X "
|
||||||
|
.."spin=%d", SP:read_u32(AD_CSRF), SP:read_u32(AD_CERF),
|
||||||
|
SP:read_u32(AD_MTCF), SP:read_u32(AD_MARF),
|
||||||
|
SP:read_u32(AD_SPIN)))
|
||||||
|
local dt = t - (tplay or t)
|
||||||
|
P(string.format("%d bytes took %.4f s = %.1f B/s "
|
||||||
|
.."(15,625 nibbles/s wants 7,812.5)",
|
||||||
|
META.nbytes, dt, META.nbytes/dt))
|
||||||
|
if fl == 0xEE then P("ERROR: the gate flagged a channel error or a timeout") end
|
||||||
|
local f = io.open("adpcm_run.lua", "w")
|
||||||
|
f:write(string.format("return { tplay = %.9f, ok = %s, nbytes = %d,\n"
|
||||||
|
.." csr = %d, cer = %d, mtc = %d, spin = %d }\n",
|
||||||
|
tplay or -1, tostring(fl == 0xFF), META.nbytes,
|
||||||
|
SP:read_u32(AD_CSRF), SP:read_u32(AD_CERF),
|
||||||
|
SP:read_u32(AD_MTCF), SP:read_u32(AD_SPIN)))
|
||||||
|
f:close()
|
||||||
|
st = "drain"; t0 = t
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if t - t0 > 60 then P("TIMEOUT flag="..string.format("%08X", fl)); M:exit() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if st == "drain" then
|
||||||
|
-- let the capture run past the end of the stream, so a truncated wav is
|
||||||
|
-- never mistaken for a short stream
|
||||||
|
if t - t0 < 0.3 then return end
|
||||||
|
P("done")
|
||||||
|
M:exit()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if not ok then print("[ADP] LUA ERROR: "..tostring(err)); M:exit() end
|
||||||
|
end)
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ROADMAP P6a: ask the machine's own MSM6258V which decoder it is.
|
||||||
|
#
|
||||||
|
# tools/bench/adpcm_run.sh
|
||||||
|
#
|
||||||
|
# WHAT A GREEN RUN MEANS: 68000 code programmed HD63450 channel 3 exactly as the
|
||||||
|
# IPL ROM programs it -- dual address, 8-bit port, cycle steal, EXTERNAL request
|
||||||
|
# -- fed the chip a designed nibble stream at the chip's own pace, and exactly
|
||||||
|
# ONE of sixteen candidate decoder models reproduces MAME's capture of the
|
||||||
|
# result SAMPLE-EXACT, with every one of the four axes shown to matter.
|
||||||
|
#
|
||||||
|
# WHAT IT DOES NOT MEAN: anything about an MSM6258. This is MAME's device model
|
||||||
|
# measured end to end through the machine's real transport. It settles the RIG.
|
||||||
|
# The silicon stays on the hardware list.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
tools/vasm/vasmm68k_mot -Fbin -o tmp/adpcmgate.bin src/player/adpcmgate.s > /dev/null
|
||||||
|
python3 tools/bench/prep_adpcm.py
|
||||||
|
|
||||||
|
# -samplerate 15625 is not a preference: 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. The first cut of this ran at the default 48000 and
|
||||||
|
# every reconstructed sample arrived as an interpolated pair.
|
||||||
|
( cd tmp && SDL_VIDEODRIVER=dummy stdbuf -oL timeout -k 5 300 \
|
||||||
|
mame x68000 -bios ipl10 -ramsize 2M -video soft -window \
|
||||||
|
-samplerate 15625 -wavwrite adpcm.wav -nothrottle -plugins \
|
||||||
|
-autoboot_script ../tools/bench/adpcm.lua \
|
||||||
|
-seconds_to_run 12 > adpcm_run.log 2>&1 )
|
||||||
|
grep -aq "^\[ADP\] done" tmp/adpcm_run.log || {
|
||||||
|
echo "FAIL: the ADPCM gate did not finish -- no completion marker."
|
||||||
|
tail -8 tmp/adpcm_run.log; exit 1; }
|
||||||
|
grep -a "^\[ADP\]" tmp/adpcm_run.log | sed 's/^\[ADP\] / /'
|
||||||
|
|
||||||
|
fail() { echo "FAIL: $1"; exit 1; }
|
||||||
|
if grep -aq "^\[ADP\] ERROR" tmp/adpcm_run.log; then
|
||||||
|
fail "the channel reported an error or the gate timed out -- see CSR/CER above."
|
||||||
|
fi
|
||||||
|
grep -aq "bit7 clear = playing" tmp/adpcm_run.log || \
|
||||||
|
fail "the chip never reported itself playing."
|
||||||
|
# THE FEED RATE IS A GATE, not a note. The chip is the pacemaker: one byte per
|
||||||
|
# #DRQ3 and #DRQ3 at half the sample rate. If the bytes went out at some other
|
||||||
|
# rate then the channel was NOT being paced by the device, and every sample
|
||||||
|
# below is of a stream that arrived faster or slower than it was consumed --
|
||||||
|
# which is precisely the failure session 33 hit from Lua.
|
||||||
|
RATE=$(sed -n 's/.*= \([0-9.]*\) B\/s .*/\1/p' tmp/adpcm_run.log | head -1)
|
||||||
|
python3 - "$RATE" <<'PY' || fail "the feed was not paced by the chip (see above)."
|
||||||
|
import sys
|
||||||
|
r = float(sys.argv[1])
|
||||||
|
want = 7812.5
|
||||||
|
print(f" feed rate {r:,.1f} B/s against the chip's own {want:,.1f} B/s "
|
||||||
|
f"({100*(r-want)/want:+.2f}%)")
|
||||||
|
sys.exit(0 if abs(r - want) / want < 0.02 else 1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
python3 tools/bench/verify_adpcm_chip.py tmp/adpcm.wav tmp/adpcm_seq.json
|
||||||
+25
-2
@@ -11,7 +11,8 @@
|
|||||||
-- a LOWER BOUND, not a prediction. Interrupts are masked (SR=$2700) so the
|
-- a LOWER BOUND, not a prediction. Interrupts are masked (SR=$2700) so the
|
||||||
-- IPL's timer and VBL handlers cannot steal cycles into the measurement.
|
-- IPL's timer and VBL handlers cannot steal cycles into the measurement.
|
||||||
--
|
--
|
||||||
-- Timing resolution is one video frame (1/55.46 s = 18.03 ms), because Lua
|
-- Timing resolution is one video frame (1/56.69 s = 17.64 ms -- MAME's, not
|
||||||
|
-- the hardware's 55.46; see crtc_mode.lua), because Lua
|
||||||
-- gets no cycle counter -- luaengine.cpp exposes machine.time and nothing
|
-- gets no cycle counter -- luaengine.cpp exposes machine.time and nothing
|
||||||
-- from device_execute_interface. Each variant therefore loops enough times
|
-- from device_execute_interface. Each variant therefore loops enough times
|
||||||
-- to run ~4 emulated seconds, putting the granularity error near 0.4%.
|
-- to run ~4 emulated seconds, putting the granularity error near 0.4%.
|
||||||
@@ -28,7 +29,7 @@ end
|
|||||||
local MODE = load_mode()
|
local MODE = load_mode()
|
||||||
|
|
||||||
local FLAG, VAR, ITER = 0x18000, 0x18004, 0x18008
|
local FLAG, VAR, ITER = 0x18000, 0x18004, 0x18008
|
||||||
local SRCW, SRCB = 0x60000, 0x80000
|
local SRCW, SRCB, SRCP = 0x60000, 0x80000, 0x90000
|
||||||
local GVRAM, GPAL = 0xC00000, 0xE82000
|
local GVRAM, GPAL = 0xC00000, 0xE82000
|
||||||
local CPUHZ = 10000000 -- x68k.cpp:1133, 40_MHz_XTAL/4
|
local CPUHZ = 10000000 -- x68k.cpp:1133, 40_MHz_XTAL/4
|
||||||
local FRAME12 = CPUHZ / 12 -- 833333 cycles at 12 fps
|
local FRAME12 = CPUHZ / 12 -- 833333 cycles at 12 fps
|
||||||
@@ -39,6 +40,16 @@ local PLAN = {
|
|||||||
{var=2, iter= 50, name="V2 naive byte-source expansion (move.b/move.w per pixel)"},
|
{var=2, iter= 50, name="V2 naive byte-source expansion (move.b/move.w per pixel)"},
|
||||||
{var=3, iter=200, name="V3 write-only floor (no source read at all)"},
|
{var=3, iter=200, name="V3 write-only floor (no source read at all)"},
|
||||||
{var=4, iter= 60, name="V4 same 96KB of writes, issued in 4x4 BLOCK order (decoder access pattern)"},
|
{var=4, iter= 60, name="V4 same 96KB of writes, issued in 4x4 BLOCK order (decoder access pattern)"},
|
||||||
|
-- V8 is V1 with R20 bit 11's packing: 48KB read + 48KB write for the SAME
|
||||||
|
-- 49,152 pixels. It is the per-frame work of a decoder-free packed player
|
||||||
|
-- (FINDINGS 44.7 / 46.6 / 47.5), and 47.6.1 filed its `movem` shape as an
|
||||||
|
-- ASSUMPTION -- this is the measurement that assumption was standing in for.
|
||||||
|
{var=8, iter=200, name="V8 PACKED movem.l blit (48KB read + 48KB write, same 49,152 pixels as V1)"},
|
||||||
|
-- V9/V10 are the two ways a BLOCK decoder could survive the packed layout
|
||||||
|
-- (47.6.4, open since session 16): sixteen move.b at stride 2 per block, or
|
||||||
|
-- pair the blocks 128 columns apart in the encoder and get V4's movem back.
|
||||||
|
{var= 9, iter= 40, name="V9 PACKED block order, 16 move.b at stride 2 per 4x4 block"},
|
||||||
|
{var=10, iter=120, name="V10 PACKED block order, blocks PAIRED so a movem writes whole words"},
|
||||||
}
|
}
|
||||||
|
|
||||||
local code do
|
local code do
|
||||||
@@ -96,6 +107,18 @@ local function setup()
|
|||||||
SP:write_u8 (SRCB + y*256 + x, px)
|
SP:write_u8 (SRCB + y*256 + x, px)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- SRCP: the PACKED frame, interleaved the way tools/bench/show_frame256_packed.lua
|
||||||
|
-- lays it out -- word i of a row is (column i+128) << 8 | (column i), because
|
||||||
|
-- page 0 is the low byte at screen column i and page 1 the high byte at i+128.
|
||||||
|
-- Only V8 reads it, and only its SIZE (128 words a row) affects the timing;
|
||||||
|
-- the interleave is written correctly so the buffer is the real artefact and
|
||||||
|
-- not a same-sized stand-in.
|
||||||
|
for y = 0, H-1 do
|
||||||
|
local row = PIX0 + y*W
|
||||||
|
for i = 0, (W//2)-1 do
|
||||||
|
SP:write_u16(SRCP + y*(W//2)*2 + i*2, (B(row+i+W//2) << 8) | B(row+i))
|
||||||
|
end
|
||||||
|
end
|
||||||
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
|
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
|
||||||
P(string.format("loaded blit.bin=%d bytes, source frame %dx%d at yoff=%d", #code, W, H, YOFF))
|
P(string.format("loaded blit.bin=%d bytes, source frame %dx%d at yoff=%d", #code, W, H, YOFF))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,6 +31,64 @@
|
|||||||
; the block needs only one base pointer. V4 deliberately scrambles the
|
; the block needs only one base pointer. V4 deliberately scrambles the
|
||||||
; picture (it reads a row-linear source in block order); it is a timing
|
; picture (it reads a row-linear source in block order); it is a timing
|
||||||
; probe, which is why the correctness snapshot is taken after V1.
|
; probe, which is why the correctness snapshot is taken after V1.
|
||||||
|
; V5 ROW-LINEAR LITERAL SPANS, the mode priced in FINDINGS 29 and never
|
||||||
|
; measured. Walks a stream of per-row span records
|
||||||
|
; row: u16 nspans, then nspans * { u16 x, u16 npix, npix*u16 pixels }
|
||||||
|
; for 192 rows, copying each span's word-expanded pixels straight from
|
||||||
|
; the stream buffer into GVRAM. Unlike V1-V4 the work per call is set by
|
||||||
|
; the STREAM, not by the code, so one variant measures every span length:
|
||||||
|
; tools/bench/prep_spans.py generates a stream per span length and
|
||||||
|
; tools/bench/span.lua times them and fits cycles = A*spans + B*pixels.
|
||||||
|
; The point of the measurement is A -- the per-span overhead FINDINGS 29
|
||||||
|
; guessed at 50 cycles -- and how much B degrades from V1's 9.08 when a
|
||||||
|
; span is too short to burst. Every config covers the whole frame, so
|
||||||
|
; V5 draws the SAME picture V1 does and can be verified, not just timed.
|
||||||
|
;
|
||||||
|
; Bursts are 8 registers (d0-d3/a3-a6 = 32 bytes = 16 pixels), not V1's
|
||||||
|
; 12: a0/a1/a2 and d4-d7 are all live across a span (stream, row base,
|
||||||
|
; destination, and three counters). The remainder is copied move.l at a
|
||||||
|
; time with a leading move.w when it is odd, so a 4-pixel span never
|
||||||
|
; reaches a movem at all -- which is exactly the case FINDINGS 29's
|
||||||
|
; full-row-width extrapolation flatters.
|
||||||
|
;
|
||||||
|
; V6 the SAME spans with the arithmetic moved into the encoder. V5 measures
|
||||||
|
; a decoder that is handed (x, npix) and has to work out how to copy it;
|
||||||
|
; most of its per-span cost is that working-out, and an encoder can do it
|
||||||
|
; once at build time instead of 12 times a second. V6's record is
|
||||||
|
; { u32 absolute GVRAM address, u16 jump displacement } -- no row
|
||||||
|
; structure, no counters, no remainder logic -- and the displacement
|
||||||
|
; jumps into an unrolled chain of 24-pixel copy units, so a span of any
|
||||||
|
; supported length is straight-line code with no loop at all.
|
||||||
|
; GVRAM sits at a fixed $C00000 on every X68000, so absolute destinations
|
||||||
|
; are a legitimate thing for an encoder to bake in.
|
||||||
|
;
|
||||||
|
; Two consequences of the format. Span lengths are multiples of 24
|
||||||
|
; pixels, and a span may overrun the 256 visible pixels of its row by up
|
||||||
|
; to 23 -- harmless, because the line stride is 1024 bytes and only the
|
||||||
|
; first 512 are displayed, so the overrun lands in the invisible half.
|
||||||
|
; And with row and remainder handling gone, 12 registers are free again
|
||||||
|
; (d0-d6/a1/a3-a6), which is why the unit is 24 pixels and not V5's 16.
|
||||||
|
;
|
||||||
|
; V7 v6 with a SECOND, finer chain for the tail (FINDINGS 39.4). v6 pays for
|
||||||
|
; its 24-pixel quantum in padding: an average span wastes ~11 pixels, and
|
||||||
|
; FINDINGS 39.3 attributes 86% of the DMAC array-chain's advantage over v6
|
||||||
|
; to exactly that. V7 keeps the 24-pixel coarse chain and appends a chain
|
||||||
|
; of 2-pixel units, so a span is 24*c + 2*f pixels and the padding is at
|
||||||
|
; most one pixel -- ZERO for the real case, where a span is a run of 4x4
|
||||||
|
; blocks and its length is a multiple of 4.
|
||||||
|
;
|
||||||
|
; The fine unit is `move.l (a0)+,(a2)+` (20 cycles, 2 pixels), NOT a
|
||||||
|
; 2-register movem: movem.l (a0)+,d0-d1 plus movem.l d0-d1,(a2) plus the
|
||||||
|
; lea is 52+8 cycles for 4 pixels, so the obvious "smaller movem" tail is
|
||||||
|
; 50% dearer per pixel than the plainest instruction on the machine.
|
||||||
|
;
|
||||||
|
; The second entry point costs a second dispatch, and the trick that pays
|
||||||
|
; for it is that the fine displacement is NOT in the span record: it sits
|
||||||
|
; in the STREAM, after the coarse pixels and before the fine ones. The
|
||||||
|
; coarse chain falls out into `move.w (a0)+,d0 / jmp`, by which point d0
|
||||||
|
; is dead payload and a0 is pointing exactly at it. So v7 holds nothing
|
||||||
|
; extra across the copy and keeps all 12 payload registers -- a record is
|
||||||
|
; still {u32 address, u16 displacement}, with one more u16 mid-span.
|
||||||
;
|
;
|
||||||
; 12 registers per movem burst (d0-d7/a2-a5 = 48 bytes) is the maximum
|
; 12 registers per movem burst (d0-d7/a2-a5 = 48 bytes) is the maximum
|
||||||
; available: a0=src, a1=dst, a6=end sentinel. The row counter lives in the
|
; available: a0=src, a1=dst, a6=end sentinel. The row counter lives in the
|
||||||
@@ -43,10 +101,21 @@
|
|||||||
FLAG = $18000 ; 0 idle / 1 running / $FF done
|
FLAG = $18000 ; 0 idle / 1 running / $FF done
|
||||||
VAR = $18004 ; variant selector, written by Lua
|
VAR = $18004 ; variant selector, written by Lua
|
||||||
ITER = $18008 ; iteration count, written by Lua
|
ITER = $18008 ; iteration count, written by Lua
|
||||||
|
SPTR = $1800C ; V5 span stream pointer, written by Lua
|
||||||
SRCW = $60000 ; word-expanded frame 192*512 = 96KB
|
SRCW = $60000 ; word-expanded frame 192*512 = 96KB
|
||||||
SRCB = $80000 ; byte-per-pixel frame 192*256 = 48KB
|
SRCB = $80000 ; byte-per-pixel frame 192*256 = 48KB
|
||||||
|
SRCP = $90000 ; PACKED frame 192*256 = 48KB (V8): two picture
|
||||||
|
; bytes per word, already interleaved by the
|
||||||
|
; encoder, so the blit is a straight copy
|
||||||
DST0 = $C08000 ; GVRAM + 32*1024 (first picture row)
|
DST0 = $C08000 ; GVRAM + 32*1024 (first picture row)
|
||||||
DSTE = $C38000 ; GVRAM + 224*1024 (one past last)
|
DSTE = $C38000 ; GVRAM + 224*1024 (one past last)
|
||||||
|
ROWS = 192 ; picture rows a V5 stream describes
|
||||||
|
V6UNIT = 12 ; bytes of code per V6 chain unit
|
||||||
|
V6MAX = 11 ; chain units = 11*24 = 264 pixels >= one row
|
||||||
|
V7CU = 12 ; bytes of code per V7 COARSE unit (24 px)
|
||||||
|
V7CN = 11 ; coarse units: 11*24 = 264 px >= one row
|
||||||
|
V7FU = 2 ; bytes of code per V7 FINE unit (2 px)
|
||||||
|
V7FN = 11 ; fine units: 11*2 = 22 px > one coarse unit
|
||||||
|
|
||||||
org $10000
|
org $10000
|
||||||
start:
|
start:
|
||||||
@@ -58,6 +127,18 @@ start:
|
|||||||
beq v2
|
beq v2
|
||||||
cmp.l #4,d0
|
cmp.l #4,d0
|
||||||
beq v4
|
beq v4
|
||||||
|
cmp.l #5,d0
|
||||||
|
beq v5
|
||||||
|
cmp.l #6,d0
|
||||||
|
beq v6
|
||||||
|
cmp.l #7,d0
|
||||||
|
beq v7
|
||||||
|
cmp.l #8,d0
|
||||||
|
beq v8
|
||||||
|
cmp.l #9,d0
|
||||||
|
beq v9
|
||||||
|
cmp.l #10,d0
|
||||||
|
beq v10
|
||||||
bra v3
|
bra v3
|
||||||
|
|
||||||
; ---------------------------------------------------------------- V1
|
; ---------------------------------------------------------------- V1
|
||||||
@@ -152,5 +233,290 @@ v4blk: movem.l (a0)+,d0-d7 ; 32 bytes = one 4x4 block, expanded
|
|||||||
bne v4
|
bne v4
|
||||||
bra done
|
bra done
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- V5
|
||||||
|
; a0 stream, a1 row base, a2 span destination, d7 rows, d6 spans, d5 pixels,
|
||||||
|
; d4 burst/tail counter. Everything else (d0-d3/a3-a6) is burst payload.
|
||||||
|
v5: move.l SPTR.l,a0
|
||||||
|
lea DST0,a1
|
||||||
|
move.w #ROWS-1,d7
|
||||||
|
v5row: move.w (a0)+,d6 ; spans in this row
|
||||||
|
subq.w #1,d6
|
||||||
|
bmi.s v5eor ; a row may legitimately have none
|
||||||
|
v5span: move.w (a0)+,d0 ; x, in pixels
|
||||||
|
add.w d0,d0 ; one pixel = one word
|
||||||
|
lea 0(a1,d0.w),a2
|
||||||
|
move.w (a0)+,d5 ; pixels in this span
|
||||||
|
move.w d5,d4
|
||||||
|
lsr.w #4,d4 ; 16-pixel bursts
|
||||||
|
beq.s v5tail
|
||||||
|
subq.w #1,d4
|
||||||
|
v5burst: movem.l (a0)+,d0-d3/a3-a6 ; 32 bytes straight out of the stream
|
||||||
|
movem.l d0-d3/a3-a6,(a2)
|
||||||
|
lea 32(a2),a2
|
||||||
|
dbra d4,v5burst
|
||||||
|
v5tail: moveq #15,d4
|
||||||
|
and.w d5,d4 ; 0..15 pixels left
|
||||||
|
beq.s v5eos
|
||||||
|
lsr.w #1,d4 ; C = odd pixel count
|
||||||
|
bcc.s v5t2
|
||||||
|
move.w (a0)+,(a2)+
|
||||||
|
v5t2: subq.w #1,d4
|
||||||
|
bmi.s v5eos
|
||||||
|
v5tl: move.l (a0)+,(a2)+
|
||||||
|
dbra d4,v5tl
|
||||||
|
v5eos: dbra d6,v5span
|
||||||
|
v5eor: lea 1024(a1),a1
|
||||||
|
dbra d7,v5row
|
||||||
|
subq.l #1,ITER.l
|
||||||
|
bne v5
|
||||||
|
bra done
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- V6
|
||||||
|
; a0 stream, a2 destination, d7 spans remaining; everything else is payload.
|
||||||
|
v6: move.l SPTR.l,a0
|
||||||
|
move.w (a0)+,d7 ; total spans in the frame
|
||||||
|
subq.w #1,d7
|
||||||
|
v6span: move.l (a0)+,a2 ; absolute GVRAM destination
|
||||||
|
move.w (a0)+,d0 ; (V6MAX - units) * V6UNIT, from the encoder
|
||||||
|
jmp v6ch(pc,d0.w)
|
||||||
|
v6ch:
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
dbra d7,v6span
|
||||||
|
subq.l #1,ITER.l
|
||||||
|
bne v6
|
||||||
|
bra done
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- V7
|
||||||
|
; a0 stream, a2 destination, d7 spans remaining; everything else is payload.
|
||||||
|
; Stream per span: u32 dest, u16 coarse disp, c*48 B pixels,
|
||||||
|
; u16 fine disp, f*4 B pixels.
|
||||||
|
v7: move.l SPTR.l,a0
|
||||||
|
move.w (a0)+,d7 ; total spans in the frame
|
||||||
|
subq.w #1,d7
|
||||||
|
v7span: move.l (a0)+,a2 ; absolute GVRAM destination
|
||||||
|
move.w (a0)+,d0 ; (V7CN - coarse) * V7CU
|
||||||
|
jmp v7ch(pc,d0.w)
|
||||||
|
v7ch:
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
movem.l (a0)+,d0-d6/a1/a3-a6
|
||||||
|
movem.l d0-d6/a1/a3-a6,(a2)
|
||||||
|
lea 48(a2),a2
|
||||||
|
v7cx: move.w (a0)+,d0 ; (V7FN - fine) * V7FU, from mid-stream
|
||||||
|
jmp v7fh(pc,d0.w)
|
||||||
|
v7fh:
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
move.l (a0)+,(a2)+
|
||||||
|
dbra d7,v7span
|
||||||
|
subq.l #1,ITER.l
|
||||||
|
bne v7
|
||||||
|
bra done
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- V8
|
||||||
|
; THE PACKED FULL-FRAME BLIT (FINDINGS 46.6/47.2). Identical in shape to V1 --
|
||||||
|
; a row-linear movem.l chain out of a RAM frame into GVRAM -- and different in
|
||||||
|
; exactly one thing: a row is 128 WORDS, not 256, because R20 bit 11 lets one
|
||||||
|
; word carry two picture bytes. 256 = 5*48 + 16, so five 12-register bursts
|
||||||
|
; and a 4-register tail, against V1's ten and one.
|
||||||
|
;
|
||||||
|
; TIMING ONLY, and it does not set bit 11. MAME's gvram_w carries no timing in
|
||||||
|
; either arm (blit.lua's header), so the bit cannot move a cycle here; what it
|
||||||
|
; moves is the PICTURE, and the picture is what tools/bench/show_frame256_packed.lua
|
||||||
|
; and tools/bench/gvpack already verify pixel-exactly. Setting it here would
|
||||||
|
; make this variant's snapshot right and its measurement no different, and
|
||||||
|
; would put a display-mode change inside a timing loop for no gain.
|
||||||
|
;
|
||||||
|
; The source is PRE-INTERLEAVED by the host, which is the honest half of the
|
||||||
|
; claim: the packing is an encoder-side transform (46.3's argument for the text
|
||||||
|
; plane, and the same one here), so the decoder-free player's per-frame work is
|
||||||
|
; this copy and nothing else. If the interleave had to happen at run time this
|
||||||
|
; variant would be V2, not V1.
|
||||||
|
v8: lea SRCP,a0
|
||||||
|
lea DST0,a1
|
||||||
|
lea DSTE,a6
|
||||||
|
v8row: movem.l (a0)+,d0-d7/a2-a5
|
||||||
|
movem.l d0-d7/a2-a5,(a1)
|
||||||
|
movem.l (a0)+,d0-d7/a2-a5
|
||||||
|
movem.l d0-d7/a2-a5,48(a1)
|
||||||
|
movem.l (a0)+,d0-d7/a2-a5
|
||||||
|
movem.l d0-d7/a2-a5,96(a1)
|
||||||
|
movem.l (a0)+,d0-d7/a2-a5
|
||||||
|
movem.l d0-d7/a2-a5,144(a1)
|
||||||
|
movem.l (a0)+,d0-d7/a2-a5
|
||||||
|
movem.l d0-d7/a2-a5,192(a1)
|
||||||
|
movem.l (a0)+,d0-d3
|
||||||
|
movem.l d0-d3,240(a1)
|
||||||
|
lea 1024(a1),a1
|
||||||
|
cmpa.l a6,a1
|
||||||
|
bne v8row
|
||||||
|
subq.l #1,ITER.l
|
||||||
|
bne v8
|
||||||
|
bra done
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- V9
|
||||||
|
; WHAT THE PACKED LAYOUT COSTS A BLOCK DECODER (FINDINGS 47.6.4, open).
|
||||||
|
;
|
||||||
|
; V4 is the access pattern of a decoder that writes 4x4 codewords straight into
|
||||||
|
; GVRAM: 4 rows of 8 contiguous bytes at a 1024-byte stride, so each row is one
|
||||||
|
; `movem.l` of two registers. Under the packed layout that pattern is GONE.
|
||||||
|
; A block at columns x..x+3 owns the LOW bytes of four consecutive words -- four
|
||||||
|
; bytes at STRIDE 2 -- and the high bytes of those same words belong to the
|
||||||
|
; block 128 columns away. There is no burst that writes every other byte, so
|
||||||
|
; the block is sixteen `move.b`s.
|
||||||
|
;
|
||||||
|
; V9 does the pair together, low block then high block off one base, so it
|
||||||
|
; writes every byte it touches and covers the same 49,152 pixels V1/V4/V8 do.
|
||||||
|
; It is the HONEST version of "keep the codec and pack the screen": the mode
|
||||||
|
; map is unchanged, SKIP still works per block, and the writes go byte at a
|
||||||
|
; time. V10 below is the other option, and the comparison is the point.
|
||||||
|
v9: lea SRCB,a0
|
||||||
|
lea DST0,a3
|
||||||
|
lea DSTE,a4
|
||||||
|
v9brow: move.l a3,a1
|
||||||
|
lea 256(a3),a5 ; 32 block PAIRS * 8 bytes
|
||||||
|
v9blk:
|
||||||
|
move.b (a0)+,(a1)
|
||||||
|
move.b (a0)+,2(a1)
|
||||||
|
move.b (a0)+,4(a1)
|
||||||
|
move.b (a0)+,6(a1)
|
||||||
|
move.b (a0)+,1024(a1)
|
||||||
|
move.b (a0)+,1026(a1)
|
||||||
|
move.b (a0)+,1028(a1)
|
||||||
|
move.b (a0)+,1030(a1)
|
||||||
|
move.b (a0)+,2048(a1)
|
||||||
|
move.b (a0)+,2050(a1)
|
||||||
|
move.b (a0)+,2052(a1)
|
||||||
|
move.b (a0)+,2054(a1)
|
||||||
|
move.b (a0)+,3072(a1)
|
||||||
|
move.b (a0)+,3074(a1)
|
||||||
|
move.b (a0)+,3076(a1)
|
||||||
|
move.b (a0)+,3078(a1)
|
||||||
|
move.b (a0)+,1(a1)
|
||||||
|
move.b (a0)+,3(a1)
|
||||||
|
move.b (a0)+,5(a1)
|
||||||
|
move.b (a0)+,7(a1)
|
||||||
|
move.b (a0)+,1025(a1)
|
||||||
|
move.b (a0)+,1027(a1)
|
||||||
|
move.b (a0)+,1029(a1)
|
||||||
|
move.b (a0)+,1031(a1)
|
||||||
|
move.b (a0)+,2049(a1)
|
||||||
|
move.b (a0)+,2051(a1)
|
||||||
|
move.b (a0)+,2053(a1)
|
||||||
|
move.b (a0)+,2055(a1)
|
||||||
|
move.b (a0)+,3073(a1)
|
||||||
|
move.b (a0)+,3075(a1)
|
||||||
|
move.b (a0)+,3077(a1)
|
||||||
|
move.b (a0)+,3079(a1)
|
||||||
|
addq.l #8,a1
|
||||||
|
cmpa.l a5,a1
|
||||||
|
bne v9blk
|
||||||
|
lea 4096(a3),a3
|
||||||
|
cmpa.l a4,a3
|
||||||
|
bne v9brow
|
||||||
|
subq.l #1,ITER.l
|
||||||
|
bne v9
|
||||||
|
bra done
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------- V10
|
||||||
|
; THE OTHER OPTION: PAIR THE BLOCKS IN THE ENCODER. If the codec codes the
|
||||||
|
; block at x and the block at x+128 as ONE unit, the destination is whole words
|
||||||
|
; again and V4's `movem.l` shape comes straight back -- the same instructions,
|
||||||
|
; the same 32 bytes of source per unit, and TWICE the pixels, because a word now
|
||||||
|
; carries two of them. So V10 is V4's inner loop run half as many times.
|
||||||
|
;
|
||||||
|
; WHAT IT COSTS IS NOT IN THIS MEASUREMENT. A pair skips only if BOTH of its
|
||||||
|
; blocks skip, and the two are 128 columns apart with nothing in the picture
|
||||||
|
; relating them. That is a CONTAINER question -- what fraction of the mode map
|
||||||
|
; survives pairing -- and 08_mode_map.py has the data to answer it. V10 prices
|
||||||
|
; the paint; it does not price the SKIPs the pairing loses.
|
||||||
|
v10: lea SRCP,a0
|
||||||
|
lea DST0,a3
|
||||||
|
lea DSTE,a4
|
||||||
|
v10brow: move.l a3,a1
|
||||||
|
lea 256(a3),a5 ; 32 block PAIRS * 8 bytes
|
||||||
|
v10blk: movem.l (a0)+,d0-d7 ; 32 bytes = one PAIR of 4x4 blocks
|
||||||
|
movem.l d0-d1,(a1)
|
||||||
|
movem.l d2-d3,1024(a1)
|
||||||
|
movem.l d4-d5,2048(a1)
|
||||||
|
movem.l d6-d7,3072(a1)
|
||||||
|
addq.l #8,a1
|
||||||
|
cmpa.l a5,a1
|
||||||
|
bne.s v10blk
|
||||||
|
lea 4096(a3),a3
|
||||||
|
cmpa.l a4,a3
|
||||||
|
bne v10brow
|
||||||
|
subq.l #1,ITER.l
|
||||||
|
bne v10
|
||||||
|
bra done
|
||||||
|
|
||||||
done: move.l #$FF,FLAG.l ; timer stops here
|
done: move.l #$FF,FLAG.l ; timer stops here
|
||||||
halt: bra.s halt
|
halt: bra.s halt
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Build the headless C68K cycle harness. PX68K points at a px68k checkout;
|
||||||
|
# only m68000/c68k.c and the two header dirs are used -- no SDL, no ROMs.
|
||||||
|
PX68K ?= $(HOME)/src/px68k
|
||||||
|
# -no-pie is LOAD-BEARING, not a tidy-up. C68K is 64-bit-unsafe on purpose:
|
||||||
|
# its MOVEM macros do `src = (UINT32)(&D0)` -- they truncate the host address of
|
||||||
|
# the CPU register file to 32 bits and dereference it -- and C68k_Set_Fetch
|
||||||
|
# stores the opcode-fetch base in a UINT32 too. Under the default PIE the
|
||||||
|
# binary loads near 0x555555550000 and the first movem segfaults. -no-pie puts
|
||||||
|
# the image at 0x400000, and the harness mmaps its arena with MAP_32BIT, so
|
||||||
|
# every pointer C68K truncates still round-trips.
|
||||||
|
CFLAGS = -O2 -fno-strict-aliasing -no-pie -Wall -Wno-unused-result \
|
||||||
|
-Wno-int-to-pointer-cast -Wno-pointer-to-int-cast \
|
||||||
|
-I$(PX68K)/m68000 -I$(PX68K)/x11 -I$(PX68K)/win32api
|
||||||
|
|
||||||
|
c68k_bench: harness.c $(PX68K)/m68000/c68k.c
|
||||||
|
$(CC) $(CFLAGS) -no-pie -o $@ harness.c $(PX68K)/m68000/c68k.c
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f c68k_bench
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Two emulators, one decoder: MAME's cycles against px68k's C68K core.
|
||||||
|
|
||||||
|
python3 tools/bench/c68k/compare.py [--mame tmp/mame_timed.log]
|
||||||
|
[--c68k tmp/c68k.log]
|
||||||
|
|
||||||
|
WHY THIS EXISTS. Every 68000 cycle figure in FINDINGS 24-35 comes from one
|
||||||
|
instrument. This puts a second, structurally different one next to it:
|
||||||
|
|
||||||
|
MAME 0.277 M68000 is the microcode core (src/devices/cpu/m68000/m68000.lst
|
||||||
|
+ m68000gen.py), NOT Musashi -- timing emerges from the 68000's
|
||||||
|
modelled micro-sequence and 4-clock bus cycles.
|
||||||
|
C68K a static per-instruction cycle table hand-transcribed from the
|
||||||
|
Motorola manual (ORI_CLOCKS_* / EA_CLOCKS_* in c68kmacro.h).
|
||||||
|
|
||||||
|
Those are two different ways of being right, so agreement is evidence and
|
||||||
|
disagreement localises to whichever instruction the anchors separate. NEITHER
|
||||||
|
charges GVRAM wait states, so both are the same lower bound on real hardware.
|
||||||
|
"""
|
||||||
|
import argparse, re, sys
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--mame", default="tmp/mame_timed.log")
|
||||||
|
ap.add_argument("--c68k", default="tmp/c68k.log")
|
||||||
|
ap.add_argument("--meta", default="tmp/decode_meta.lua")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
meta = open(a.meta).read()
|
||||||
|
fps = int(re.search(r"fps=(\d+)", meta).group(1))
|
||||||
|
budget = 10_000_000 / fps
|
||||||
|
# anchor name -> stream offset, so the two logs can be joined: decode.lua
|
||||||
|
# reports by name, the C68K harness by offset.
|
||||||
|
names = {int(o): n for n, o in re.findall(r'name="([^"]+)", off=(\d+)', meta)}
|
||||||
|
|
||||||
|
mame = {}
|
||||||
|
txt = open(a.mame, errors="replace").read()
|
||||||
|
for nm, cyc in re.findall(r"\[DEC\] frame @ (.+?)\n.*?->\s+(\d+) cycles/frame", txt):
|
||||||
|
mame[nm.strip()] = int(cyc)
|
||||||
|
m_seq = re.search(r"full \d+-frame pass.*?\n.*?->\s+(\d+) cycles/frame", txt)
|
||||||
|
|
||||||
|
c68k, c_seq = {}, None
|
||||||
|
for line in open(a.c68k, errors="replace"):
|
||||||
|
m = re.search(r"anchor off=(\d+)\s+(\d+) cyc", line)
|
||||||
|
if m and int(m.group(1)) in names:
|
||||||
|
c68k[names[int(m.group(1))]] = int(m.group(2))
|
||||||
|
m = re.search(r"sequential pass = (\d+) cyc, mean (\d+)", line)
|
||||||
|
if m:
|
||||||
|
c_seq = int(m.group(2))
|
||||||
|
|
||||||
|
if not mame:
|
||||||
|
sys.exit(f"no MAME anchor timings in {a.mame} -- run decode.lua WITHOUT "
|
||||||
|
f"DLX_VERIFY_ONLY=1 and give -seconds_to_run enough to finish")
|
||||||
|
|
||||||
|
w = max(len(n) for n in c68k) + 2
|
||||||
|
print(f"{'anchor':<{w}}{'MAME':>10}{'C68K':>10}{'delta':>9} {'MAME':>7}{'C68K':>7} of a {fps}fps frame")
|
||||||
|
rows = []
|
||||||
|
for nm, c in c68k.items():
|
||||||
|
m = mame.get(nm)
|
||||||
|
if m is None:
|
||||||
|
print(f"{nm:<{w}}{'--':>10}{c:>10}{'':>9} {'--':>7}{100*c/budget:>6.1f}% (MAME run did not reach it)")
|
||||||
|
continue
|
||||||
|
d = 100 * (c - m) / m
|
||||||
|
rows.append(d)
|
||||||
|
print(f"{nm:<{w}}{m:>10}{c:>10}{d:>+8.2f}% {100*m/budget:>6.1f}%{100*c/budget:>6.1f}%")
|
||||||
|
|
||||||
|
if m_seq and c_seq:
|
||||||
|
m, c = int(m_seq.group(1)), c_seq
|
||||||
|
d = 100 * (c - m) / m
|
||||||
|
print(f"{'MEAN over the window':<{w}}{m:>10}{c:>10}{d:>+8.2f}% "
|
||||||
|
f"{100*m/budget:>6.1f}%{100*c/budget:>6.1f}%")
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
print(f"\nspread over {len(rows)} anchors: {min(rows):+.2f}% .. {max(rows):+.2f}%")
|
||||||
|
print("C68K reads HIGH throughout." if min(rows) > 0 else
|
||||||
|
"C68K reads high on some anchors and low on others.")
|
||||||
|
print("Neither instrument charges GVRAM wait states, so both are the same\n"
|
||||||
|
"LOWER BOUND: this bounds cycle-table error, not the distance to a\n"
|
||||||
|
"real X68000 (docs/BENCHMARK.md Tier 3).")
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
/* Headless C68K cycle harness -- an independent second opinion on every
|
||||||
|
* 68000 cycle figure in FINDINGS 24-35.
|
||||||
|
*
|
||||||
|
* WHY. Every one of those numbers comes from ONE instrument: MAME 0.277's
|
||||||
|
* Musashi core, timed host-side from manager.machine.time. A cycle table is a
|
||||||
|
* hand-transcribed artefact; if Musashi's is wrong for our instruction mix, the
|
||||||
|
* 833,333-cycle budget is wrong by the same amount and nothing in the tree
|
||||||
|
* would show it. This runs the SAME decode.bin against the SAME
|
||||||
|
* decode_data.bin under px68k's C68K core, which has a completely separate
|
||||||
|
* cycle table (ORI_CLOCKS_* + EA_CLOCKS_* in c68kmacro.h) written by a
|
||||||
|
* different author from the same Motorola manual.
|
||||||
|
*
|
||||||
|
* WHAT IT DOES AND DOES NOT SETTLE. C68K, like MAMEs x68000, charges NO
|
||||||
|
* GVRAM wait states -- grep the px68k tree, there is no bus-timing model
|
||||||
|
* anywhere in x68k/*.c. So this is the same LOWER BOUND, measured twice. It
|
||||||
|
* cross-checks the cycle table. It says nothing about real-hardware wait
|
||||||
|
* states; that needs XM6 TypeG or an actual X68000 (docs/BENCHMARK.md Tier 3).
|
||||||
|
*
|
||||||
|
* WHY NOT JUST RUN px68k. The decoder touches nothing but RAM, the control
|
||||||
|
* block and GVRAM: no IPL, no CRTC, no MFP, no interrupts (the MAME rig masks
|
||||||
|
* them with SR=$2700). Booting a whole emulated machine would add SDL, ROMs
|
||||||
|
* and a 55Hz sampling clock to a measurement that wants none of them. Linking
|
||||||
|
* the core alone also buys EXACTNESS: the stop cycle is captured inside the
|
||||||
|
* write callback, so a frame's cost is known to within one instruction rather
|
||||||
|
* than MAME's 1/55.46 s. That is why the anchors here run iter=1 -- decode.lua
|
||||||
|
* only iterates to beat its own timing granularity.
|
||||||
|
*
|
||||||
|
* MEMORY MODEL mirrors px68k exactly, because the core requires it: RAM is
|
||||||
|
* stored BYTE-SWAPPED (MEM[addr ^ 1], mem_wrap.c:420) so C68K's
|
||||||
|
* READ_IMM_16() = *(UINT16 *)PC works with no swap on a little-endian host.
|
||||||
|
* GVRAM word writes discard the high byte, as the hardware and MAME's
|
||||||
|
* gvram_w case 0x0100 both do.
|
||||||
|
*
|
||||||
|
* The harness is self-validating: --dump writes the decoded screen and
|
||||||
|
* verify_c68k.py checks it pixel-for-pixel against tools/encoder/dlx.py. If
|
||||||
|
* the byte-swap or the memory map were wrong the decode could not come out
|
||||||
|
* exact, so a green verify is what licenses the cycle numbers next to it.
|
||||||
|
*/
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include "c68k.h"
|
||||||
|
|
||||||
|
/* c68k.c declares these extern and tests BusErrHandling every instruction. */
|
||||||
|
unsigned int BusErrHandling = 0;
|
||||||
|
unsigned int BusErrAdr = 0;
|
||||||
|
void Error(const char *s) { fprintf(stderr, "c68k: %s\n", s); exit(3); }
|
||||||
|
void p6logd(const char *fmt, ...) { (void)fmt; }
|
||||||
|
|
||||||
|
#define ADRMASK 0xFFFFFFu
|
||||||
|
#define ARENA (16u << 20)
|
||||||
|
#define RAMTOP 0x200000u
|
||||||
|
#define GV_LO 0xC00000u
|
||||||
|
#define GV_HI 0xC80000u
|
||||||
|
|
||||||
|
#define FLAG 0x18000u
|
||||||
|
#define LFLAG 0x18040u /* src/player/load.i's control block */
|
||||||
|
#define LHDR 0x18044u
|
||||||
|
#define LDARK 0x18048u
|
||||||
|
#define LMODE 0x18054u
|
||||||
|
#define LITER 0x18058u
|
||||||
|
#define GPAL 0xE82000u
|
||||||
|
#define ITER 0x18008u
|
||||||
|
#define NFR 0x1800Cu
|
||||||
|
#define FPTR 0x18010u
|
||||||
|
#define CB1 0x20000u
|
||||||
|
#define CB4 0x22000u
|
||||||
|
#define STREAM 0x30000u
|
||||||
|
#define CODE 0x10000u
|
||||||
|
#define STACK 0x8000u
|
||||||
|
#define GVBASE 0xC00000u
|
||||||
|
#define ROWBYTES 1024u
|
||||||
|
#define CPUHZ 10000000.0
|
||||||
|
|
||||||
|
static unsigned char *buf; /* byte-swapped, px68k convention */
|
||||||
|
|
||||||
|
/* Data bus cycles the 68000 issues. Every callback below is exactly one
|
||||||
|
* 68000 bus cycle -- C68K splits a long access into two word calls, which is
|
||||||
|
* what the 16-bit bus does too -- so counting calls counts bus cycles. This
|
||||||
|
* does NOT include instruction prefetch, which C68K reads straight through the
|
||||||
|
* fetch pointer with no callback; the count is therefore a LOWER BOUND on the
|
||||||
|
* CPU's bus occupancy, and the headroom it implies is an UPPER BOUND.
|
||||||
|
* It is still the measurement that matters for FINDINGS 29.6: if the decoder's
|
||||||
|
* data accesses alone left no room, a DMAC could not overlap with it at all. */
|
||||||
|
static long long bus_r, bus_w;
|
||||||
|
static int in_exec = 0;
|
||||||
|
|
||||||
|
/* Cycle capture. A single C68k_Exec slice runs the whole pass; the FLAG
|
||||||
|
* writes inside it record where the timed region starts and ends, so the
|
||||||
|
* count excludes nothing and includes no spin-loop tail. */
|
||||||
|
static long long slice;
|
||||||
|
static long long cyc_start = -1, cyc_stop = -1;
|
||||||
|
static int desync = 0;
|
||||||
|
|
||||||
|
static unsigned char rd8 (unsigned int a){ if (in_exec) bus_r++; return buf[(a & ADRMASK) ^ 1]; }
|
||||||
|
static unsigned short rd16(unsigned int a){ if (in_exec) bus_r++; a &= ADRMASK; return (unsigned short)(buf[a] | (buf[a+1] << 8)); }
|
||||||
|
static unsigned short peek16(unsigned int a){ a &= ADRMASK; return (unsigned short)(buf[a] | (buf[a+1] << 8)); }
|
||||||
|
static unsigned int rd32(unsigned int a){ return ((unsigned int)peek16(a) << 16) | peek16(a+2); }
|
||||||
|
|
||||||
|
static void wr8(unsigned int a, unsigned char d)
|
||||||
|
{
|
||||||
|
if (in_exec) bus_w++;
|
||||||
|
a &= ADRMASK;
|
||||||
|
if (a >= GV_LO && a < GV_HI) { if (a & 1) buf[a ^ 1] = d; return; } /* high byte discarded */
|
||||||
|
buf[a ^ 1] = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only writes made BY the 68000 mean anything here. The harness sets FLAG
|
||||||
|
* itself during setup, and a `move.l` to FLAG arrives as two word writes, so
|
||||||
|
* the hook sees a half-updated long in between -- clearing FLAG from $FF to 0
|
||||||
|
* momentarily reads back as $FF again. Without in_exec that transient
|
||||||
|
* recorded a run's stop cycle before the run had started, and every frame
|
||||||
|
* after the first came out as the whole slice. */
|
||||||
|
/* Which flag word the run watches. decode.s and stream.s use FLAG; the
|
||||||
|
* load-time transforms of src/player/load.i use their own, so that a player
|
||||||
|
* could eventually contain both without one clearing the other's state. The
|
||||||
|
* VALUES mean the same thing in both (1 running, $FF done, $EE failed), which
|
||||||
|
* is why one hook serves both. */
|
||||||
|
static unsigned int flag_adr = FLAG;
|
||||||
|
|
||||||
|
static void note_flag(void)
|
||||||
|
{
|
||||||
|
unsigned int v = rd32(flag_adr);
|
||||||
|
long long now = slice - C68K.ICount;
|
||||||
|
if (!in_exec) return;
|
||||||
|
if (v == 1 && cyc_start < 0) cyc_start = now;
|
||||||
|
else if (v == 0xFF || v == 0xEE) {
|
||||||
|
if (cyc_stop < 0) { cyc_stop = now; desync = (v == 0xEE); }
|
||||||
|
C68K.ICount = 0; /* stop the slice; we keep our own count */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void wr16(unsigned int a, unsigned short d)
|
||||||
|
{
|
||||||
|
if (in_exec) bus_w++;
|
||||||
|
a &= ADRMASK;
|
||||||
|
if (a >= GV_LO && a < GV_HI) { buf[a] = (unsigned char)d; buf[a+1] = 0; return; }
|
||||||
|
buf[a] = (unsigned char)d; buf[a+1] = (unsigned char)(d >> 8);
|
||||||
|
if (a >= flag_adr && a < flag_adr + 4) note_flag();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void wr32(unsigned int a, unsigned int d){ wr16(a, (unsigned short)(d >> 16)); wr16(a+2, (unsigned short)d); }
|
||||||
|
|
||||||
|
static void push(unsigned int a, const unsigned char *s, size_t n)
|
||||||
|
{
|
||||||
|
for (size_t i = 0; i < n; i++) wr8((unsigned int)(a + i), s[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prime the screen exactly as decode.lua's setup() does: active area at index
|
||||||
|
* 0, letterbox at the darkest palette entry. A SKIP block in frame 0 is a
|
||||||
|
* claim about THIS, so it is part of the decode contract. Pass 2 re-primes,
|
||||||
|
* because pass 1 left one frame's worth of residue on the screen and frame 0's
|
||||||
|
* SKIP blocks would otherwise inherit it. */
|
||||||
|
static void prime(unsigned int W, unsigned int H, unsigned int yoff, unsigned int dark)
|
||||||
|
{
|
||||||
|
for (unsigned int y = 0; y < 256; y++) {
|
||||||
|
unsigned short v = (y < yoff || y >= yoff + H) ? (unsigned short)dark : 0;
|
||||||
|
for (unsigned int x = 0; x < W; x++) wr16(GVBASE + y*ROWBYTES + x*2, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned char *slurp(const char *p, size_t *n)
|
||||||
|
{
|
||||||
|
FILE *f = fopen(p, "rb");
|
||||||
|
if (!f) { fprintf(stderr, "cannot open %s\n", p); exit(2); }
|
||||||
|
fseek(f, 0, SEEK_END); long L = ftell(f); fseek(f, 0, SEEK_SET);
|
||||||
|
unsigned char *b = malloc((size_t)L);
|
||||||
|
if (fread(b, 1, (size_t)L, f) != (size_t)L) { fprintf(stderr, "short read %s\n", p); exit(2); }
|
||||||
|
fclose(f); *n = (size_t)L; return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Run one pass and return its exact cycle count. */
|
||||||
|
static long long run(unsigned int off, unsigned int nfr, unsigned int iter)
|
||||||
|
{
|
||||||
|
cyc_start = cyc_stop = -1; desync = 0; bus_r = bus_w = 0;
|
||||||
|
wr32(FLAG, 0); wr32(ITER, iter); wr32(NFR, nfr); wr32(FPTR, STREAM + off);
|
||||||
|
C68k_Reset(&C68K);
|
||||||
|
C68k_Set_Reg(&C68K, C68K_SR, 0x2700); /* supervisor, all IRQs masked */
|
||||||
|
C68k_Set_Reg(&C68K, C68K_A7, STACK);
|
||||||
|
C68k_Set_Reg(&C68K, C68K_PC, CODE);
|
||||||
|
slice = 2000000000LL;
|
||||||
|
in_exec = 1;
|
||||||
|
C68k_Exec(&C68K, (INT32)slice);
|
||||||
|
in_exec = 0;
|
||||||
|
if (cyc_stop < 0) { fprintf(stderr, "TIMEOUT off=%u nfr=%u -- decoder never set FLAG\n", off, nfr); exit(4); }
|
||||||
|
if (desync) { fprintf(stderr, "BITSTREAM DESYNC off=%u nfr=%u\n", off, nfr); exit(5); }
|
||||||
|
/* A runaway is not a slow frame. Without this a bad record walk reports a
|
||||||
|
* two-billion-cycle "frame" as if it were a measurement. */
|
||||||
|
if (cyc_stop - cyc_start > 40LL * nfr * iter * 833333LL) {
|
||||||
|
fprintf(stderr, "RUNAWAY off=%u nfr=%u: %lld cyc (start=%lld stop=%lld) "
|
||||||
|
"PC=%06X FLAG=%08X SCR_N=%08X SCR_END=%08X len=%u\n",
|
||||||
|
off, nfr, cyc_stop - cyc_start, cyc_start, cyc_stop,
|
||||||
|
C68k_Get_Reg(&C68K, C68K_PC) & 0xFFFFFF, rd32(FLAG),
|
||||||
|
rd32(0x18014), rd32(0x18018), rd32(STREAM + off));
|
||||||
|
exit(6);
|
||||||
|
}
|
||||||
|
return cyc_stop - cyc_start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- the load-time transforms (ROADMAP P1+P2, FINDINGS 53) --------------
|
||||||
|
* The same question this harness asks of the decoder, asked of the loader: does
|
||||||
|
* a SECOND 68000 core, with its own cycle table and its own memory model,
|
||||||
|
* produce the same bytes and agree about what they cost? It also counts BUS
|
||||||
|
* cycles, which MAME cannot report -- and the bus is the resource this project
|
||||||
|
* established is the binding one (FINDINGS 38).
|
||||||
|
*/
|
||||||
|
static int run_load(const char *fcode, const char *fraw, const char *dump,
|
||||||
|
unsigned int mode, unsigned int iter,
|
||||||
|
unsigned int cb1_len, unsigned int cb4_len)
|
||||||
|
{
|
||||||
|
size_t nc, nr;
|
||||||
|
unsigned char *code = slurp(fcode, &nc), *raw = slurp(fraw, &nr);
|
||||||
|
push(STREAM, raw, nr); /* the RAW container header */
|
||||||
|
push(CODE, code, nc);
|
||||||
|
/* Poison every destination, so that a transform which writes NOTHING
|
||||||
|
* cannot pass by leaving the harness's own zeros in place. */
|
||||||
|
for (unsigned int a = CB1; a < CB1 + cb1_len; a += 2) wr16(a, 0xDEAD);
|
||||||
|
for (unsigned int a = CB4; a < CB4 + cb4_len; a += 2) wr16(a, 0xDEAD);
|
||||||
|
for (unsigned int c = 0; c < 256; c++) wr16(GPAL + c*2, 0xDEAD);
|
||||||
|
wr32(LDARK, 0xFFFFFFFFu);
|
||||||
|
/* The three scratch tables are poisoned only before a run that claims to
|
||||||
|
* build them. A run that only PACKS the palette is entitled to find them
|
||||||
|
* already built -- that is the point of pricing it separately -- so when
|
||||||
|
* this process is asked for one, it does the boot pass first, untimed,
|
||||||
|
* exactly as a player would have done at boot. Without that the pack runs
|
||||||
|
* on zeros: every entry then takes the same branch and the darkest entry
|
||||||
|
* comes out 0, which is a measurement of nothing. */
|
||||||
|
if (mode & 4)
|
||||||
|
for (unsigned int a = 0x19000; a < 0x19340; a += 2) wr16(a, 0xDEAD);
|
||||||
|
|
||||||
|
flag_adr = LFLAG;
|
||||||
|
if ((mode & 2) && !(mode & 4)) {
|
||||||
|
cyc_start = cyc_stop = -1; desync = 0;
|
||||||
|
wr32(LFLAG, 0); wr32(LHDR, STREAM); wr32(LMODE, 4); wr32(LITER, 1);
|
||||||
|
C68k_Reset(&C68K);
|
||||||
|
C68k_Set_Reg(&C68K, C68K_SR, 0x2700);
|
||||||
|
C68k_Set_Reg(&C68K, C68K_A7, STACK);
|
||||||
|
C68k_Set_Reg(&C68K, C68K_PC, CODE);
|
||||||
|
slice = 2000000000LL; in_exec = 1;
|
||||||
|
C68k_Exec(&C68K, (INT32)slice);
|
||||||
|
in_exec = 0;
|
||||||
|
if (cyc_stop < 0) { fprintf(stderr, "TIMEOUT in the table pre-pass\n"); return 4; }
|
||||||
|
}
|
||||||
|
cyc_start = cyc_stop = -1; desync = 0; bus_r = bus_w = 0;
|
||||||
|
wr32(LFLAG, 0); wr32(LHDR, STREAM); wr32(LMODE, mode); wr32(LITER, iter);
|
||||||
|
C68k_Reset(&C68K);
|
||||||
|
C68k_Set_Reg(&C68K, C68K_SR, 0x2700);
|
||||||
|
C68k_Set_Reg(&C68K, C68K_A7, STACK);
|
||||||
|
C68k_Set_Reg(&C68K, C68K_PC, CODE);
|
||||||
|
slice = 2000000000LL;
|
||||||
|
in_exec = 1;
|
||||||
|
C68k_Exec(&C68K, (INT32)slice);
|
||||||
|
in_exec = 0;
|
||||||
|
if (cyc_stop < 0) { fprintf(stderr, "TIMEOUT -- loader never set LFLAG\n"); return 4; }
|
||||||
|
if (desync) { fprintf(stderr, "BAD HEADER -- load.i found no 'DLX3' magic\n"); return 5; }
|
||||||
|
|
||||||
|
long long cyc = (cyc_stop - cyc_start) / (iter ? iter : 1);
|
||||||
|
fprintf(stderr, "[C68K] load mode %u: %lld cyc/pass (%.2f ms at 10MHz, "
|
||||||
|
"%.1f%% of a 12fps frame), dark=%u\n", mode, cyc, cyc / 10000.0,
|
||||||
|
100.0 * cyc / (10000000.0 / 12), rd32(LDARK));
|
||||||
|
/* A 68000 bus cycle is 4 clocks. Prefetch is not counted (C68K reads
|
||||||
|
* opcodes straight through the fetch pointer), so this is a LOWER bound on
|
||||||
|
* occupancy and the headroom it implies is an UPPER bound -- same caveat as
|
||||||
|
* the decoder's figure above. */
|
||||||
|
{
|
||||||
|
double slots = (double)cyc / 4.0;
|
||||||
|
double used = (double)(bus_r + bus_w) / (iter ? iter : 1);
|
||||||
|
fprintf(stderr, "[C68K] data bus: %.0f reads + %.0f writes = %.0f of "
|
||||||
|
"%.0f cycles = %.1f%% occupied (prefetch NOT counted)\n",
|
||||||
|
(double)bus_r / iter, (double)bus_w / iter, used, slots,
|
||||||
|
100.0 * used / slots);
|
||||||
|
}
|
||||||
|
if (dump) {
|
||||||
|
FILE *g = fopen(dump, "wb");
|
||||||
|
if (!g) { perror(dump); return 2; }
|
||||||
|
for (unsigned int a = CB1; a < CB1 + cb1_len; a++) { unsigned char b = rd8(a); fwrite(&b,1,1,g); }
|
||||||
|
for (unsigned int a = CB4; a < CB4 + cb4_len; a++) { unsigned char b = rd8(a); fwrite(&b,1,1,g); }
|
||||||
|
for (unsigned int c = 0; c < 256; c++) {
|
||||||
|
unsigned short w = rd16(GPAL + c*2);
|
||||||
|
unsigned char b[2] = { (unsigned char)(w >> 8), (unsigned char)w };
|
||||||
|
fwrite(b, 1, 2, g);
|
||||||
|
}
|
||||||
|
fclose(g);
|
||||||
|
fprintf(stderr, "[C68K] load output dumped to %s (%u B)\n",
|
||||||
|
dump, cb1_len + cb4_len + 512);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
const char *fcode = "tmp/decode.bin", *fdata = "tmp/decode_data.bin", *dump = NULL;
|
||||||
|
unsigned int cb1_len=0, cb4_len=0, pal_len=0, stream_len=0, nframes=0, H=192, W=256, fps=12;
|
||||||
|
unsigned int dark = 255;
|
||||||
|
unsigned int anch[32]; int nanch = 0;
|
||||||
|
const char *fraw = NULL, *loaddump = NULL;
|
||||||
|
unsigned int loadmode = 7, loaditer = 1;
|
||||||
|
for (int i = 1; i < argc; i++) {
|
||||||
|
if (!strcmp(argv[i], "--code")) fcode = argv[++i];
|
||||||
|
else if (!strcmp(argv[i], "--data")) fdata = argv[++i];
|
||||||
|
else if (!strcmp(argv[i], "--dump")) dump = argv[++i];
|
||||||
|
else if (!strcmp(argv[i], "--cb1")) cb1_len = (unsigned)atoi(argv[++i]);
|
||||||
|
else if (!strcmp(argv[i], "--cb4")) cb4_len = (unsigned)atoi(argv[++i]);
|
||||||
|
else if (!strcmp(argv[i], "--pal")) pal_len = (unsigned)atoi(argv[++i]);
|
||||||
|
else if (!strcmp(argv[i], "--stream")) stream_len = (unsigned)atoi(argv[++i]);
|
||||||
|
else if (!strcmp(argv[i], "--nframes"))nframes = (unsigned)atoi(argv[++i]);
|
||||||
|
else if (!strcmp(argv[i], "--W")) W = (unsigned)atoi(argv[++i]);
|
||||||
|
else if (!strcmp(argv[i], "--H")) H = (unsigned)atoi(argv[++i]);
|
||||||
|
else if (!strcmp(argv[i], "--fps")) fps = (unsigned)atoi(argv[++i]);
|
||||||
|
else if (!strcmp(argv[i], "--dark")) dark = (unsigned)atoi(argv[++i]);
|
||||||
|
else if (!strcmp(argv[i], "--loadraw")) fraw = argv[++i];
|
||||||
|
else if (!strcmp(argv[i], "--loaddump")) loaddump = argv[++i];
|
||||||
|
else if (!strcmp(argv[i], "--loadmode")) loadmode = (unsigned)atoi(argv[++i]);
|
||||||
|
else if (!strcmp(argv[i], "--loaditer")) loaditer = (unsigned)atoi(argv[++i]);
|
||||||
|
else if (!strcmp(argv[i], "--anchor")) { if (nanch < 32) anch[nanch++] = (unsigned)strtoul(argv[++i], NULL, 10); }
|
||||||
|
else { fprintf(stderr, "unknown arg %s\n", argv[i]); return 2; }
|
||||||
|
}
|
||||||
|
if (!fraw && (!nframes || !stream_len)) {
|
||||||
|
fprintf(stderr, "need --nframes and --stream (from decode_meta.lua)\n"); return 2; }
|
||||||
|
|
||||||
|
/* MAP_32BIT: C68K keeps its fetch base in a UINT32, so the arena must live
|
||||||
|
* below 4 GB or every opcode fetch reads a truncated pointer. */
|
||||||
|
buf = mmap(NULL, ARENA, PROT_READ|PROT_WRITE,
|
||||||
|
MAP_PRIVATE|MAP_ANONYMOUS|MAP_32BIT, -1, 0);
|
||||||
|
if (buf == MAP_FAILED) { perror("mmap MAP_32BIT"); return 2; }
|
||||||
|
fprintf(stderr, "[C68K] arena at %p\n", (void *)buf);
|
||||||
|
|
||||||
|
if (fraw) {
|
||||||
|
C68k_Init(&C68K);
|
||||||
|
C68k_Set_ReadB (&C68K, rd8);
|
||||||
|
C68k_Set_ReadW (&C68K, rd16);
|
||||||
|
C68k_Set_WriteB(&C68K, wr8);
|
||||||
|
C68k_Set_WriteW(&C68K, wr16);
|
||||||
|
C68k_Set_Fetch (&C68K, 0x000000, 0xFFFFFF, (UINT32)(unsigned long)buf);
|
||||||
|
return run_load(fcode, fraw, loaddump, loadmode, loaditer,
|
||||||
|
cb1_len ? cb1_len : 8192, cb4_len ? cb4_len : 2048);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t nc, nd;
|
||||||
|
unsigned char *code = slurp(fcode, &nc), *data = slurp(fdata, &nd);
|
||||||
|
size_t need = (size_t)cb1_len + cb4_len + pal_len + stream_len;
|
||||||
|
if (nd < need) { fprintf(stderr, "data blob %zu B < meta's %zu B\n", nd, need); return 2; }
|
||||||
|
|
||||||
|
size_t o = 0;
|
||||||
|
push(CB1, data + o, cb1_len); o += cb1_len;
|
||||||
|
push(CB4, data + o, cb4_len); o += cb4_len;
|
||||||
|
o += pal_len; /* palette: display only */
|
||||||
|
push(STREAM, data + o, stream_len);
|
||||||
|
push(CODE, code, nc);
|
||||||
|
|
||||||
|
/* Prime the screen exactly as decode.lua's setup() does: the active area
|
||||||
|
* starts at index 0 and the letterbox gets the darkest palette entry.
|
||||||
|
* A SKIP block in frame 0 is a claim about THIS, so it is part of the
|
||||||
|
* decode contract, not decoration. */
|
||||||
|
unsigned int yoff = (256u - H) / 2;
|
||||||
|
prime(W, H, yoff, dark);
|
||||||
|
|
||||||
|
C68k_Init(&C68K);
|
||||||
|
C68k_Set_ReadB (&C68K, rd8);
|
||||||
|
C68k_Set_ReadW (&C68K, rd16);
|
||||||
|
C68k_Set_WriteB(&C68K, wr8);
|
||||||
|
C68k_Set_WriteW(&C68K, wr16);
|
||||||
|
C68k_Set_Fetch (&C68K, 0x000000, 0xFFFFFF, (UINT32)(unsigned long)buf);
|
||||||
|
|
||||||
|
double frame_budget = CPUHZ / fps;
|
||||||
|
fprintf(stderr, "[C68K] %u frames, stream %u B, budget %.0f cyc/frame @ %u fps\n",
|
||||||
|
nframes, stream_len, frame_budget, fps);
|
||||||
|
|
||||||
|
/* Pass 1 -- every frame timed on its own. MAME could only afford eight
|
||||||
|
* anchor frames because its clock is 1/55.46 s; here each frame is exact,
|
||||||
|
* so the whole distribution comes out, which is what FINDINGS 31/35 score
|
||||||
|
* against. Record layout: [u32 len][768 mode][payload], next record start
|
||||||
|
* rounded up to 4 (FINDINGS 28.3). `len` counts the mode header TOO --
|
||||||
|
* decode.s sets SCR_END from the address AFTER the length word, so the
|
||||||
|
* record is 4 + len bytes, not 4 + 768 + len. */
|
||||||
|
printf("frame,offset,cycles,pct_of_frame,bus_reads,bus_writes,bus_pct\n");
|
||||||
|
unsigned int off = 0;
|
||||||
|
long long sum = 0, busr_tot = 0, busw_tot = 0;
|
||||||
|
for (unsigned int f = 0; f < nframes; f++) {
|
||||||
|
long long c = run(off, 1, 1);
|
||||||
|
sum += c;
|
||||||
|
long long br = bus_r, bw = bus_w;
|
||||||
|
busr_tot += br; busw_tot += bw;
|
||||||
|
printf("%u,%u,%lld,%.2f,%lld,%lld,%.2f\n", f, off, c,
|
||||||
|
100.0 * c / frame_budget, br, bw, 100.0 * 4.0 * (br + bw) / c);
|
||||||
|
unsigned int len = rd32(STREAM + off);
|
||||||
|
off = (off + 4 + len + 3) & ~3u;
|
||||||
|
}
|
||||||
|
fprintf(stderr, "[C68K] per-frame sum = %lld cyc, mean %.0f (%.1f%% of a %u fps frame)\n",
|
||||||
|
sum, (double)sum / nframes, 100.0 * sum / nframes / frame_budget, fps);
|
||||||
|
/* The number FINDINGS 29.6 needs. A 68000 bus cycle is 4 clocks, so a
|
||||||
|
* frame of `sum/nframes` clocks has room for a quarter that many bus
|
||||||
|
* cycles. What the decoder's DATA accesses do not use is the headroom a
|
||||||
|
* DMAC could paint spans in -- minus instruction prefetch, which is not
|
||||||
|
* counted here, so this OVERSTATES the headroom. */
|
||||||
|
{
|
||||||
|
double mean_cyc = (double)sum / nframes;
|
||||||
|
double slots = mean_cyc / 4.0;
|
||||||
|
double used = (double)(busr_tot + busw_tot) / nframes;
|
||||||
|
fprintf(stderr, "[C68K] data bus: %.0f reads + %.0f writes = %.0f cycles/frame "
|
||||||
|
"of %.0f slots = %.1f%% occupied\n",
|
||||||
|
(double)busr_tot / nframes, (double)busw_tot / nframes, used, slots,
|
||||||
|
100.0 * used / slots);
|
||||||
|
fprintf(stderr, "[C68K] headroom >= %.0f bus cycles/frame "
|
||||||
|
"(%.1f%%), MINUS instruction prefetch, which is not counted\n",
|
||||||
|
slots - used, 100.0 * (slots - used) / slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pass 2 -- one sequential run of the whole window. Two jobs: it is the
|
||||||
|
* only honest correctness test (SKIP makes every frame a claim about the
|
||||||
|
* one before it), and its total against pass 1's sum prices the outer
|
||||||
|
* frame-loop overhead the per-frame runs each pay once. */
|
||||||
|
prime(W, H, yoff, dark);
|
||||||
|
long long seq = run(0, nframes, 1);
|
||||||
|
fprintf(stderr, "[C68K] sequential pass = %lld cyc, mean %.0f (%.1f%%); "
|
||||||
|
"per-frame sum is %+.3f%% of it\n",
|
||||||
|
seq, (double)seq / nframes, 100.0 * seq / nframes / frame_budget,
|
||||||
|
100.0 * (sum - seq) / seq);
|
||||||
|
|
||||||
|
/* Dump BEFORE the anchors run. They decode single frames onto this same
|
||||||
|
* screen, so anything after them is not the sequential reconstruction and
|
||||||
|
* verify_c68k.py would report every pixel wrong. */
|
||||||
|
if (dump) {
|
||||||
|
/* Active area only, one byte per pixel -- the low byte of each GVRAM
|
||||||
|
* word, which is all the hardware keeps. */
|
||||||
|
FILE *g = fopen(dump, "wb");
|
||||||
|
if (!g) { perror(dump); return 2; }
|
||||||
|
for (unsigned int y = 0; y < H; y++)
|
||||||
|
for (unsigned int x = 0; x < W; x++) {
|
||||||
|
unsigned char p = (unsigned char)rd16(GVBASE + (yoff + y)*ROWBYTES + x*2);
|
||||||
|
fwrite(&p, 1, 1, g);
|
||||||
|
}
|
||||||
|
fclose(g);
|
||||||
|
fprintf(stderr, "[C68K] screen dumped to %s (%ux%u indices)\n", dump, W, H);
|
||||||
|
}
|
||||||
|
/* Pass 3 -- decode.lua's timing anchors, at the same stream offsets, so the
|
||||||
|
* two instruments are quoted on the same eight frames. The four synthetic
|
||||||
|
* single-mode frames live past the end of the real stream and so are not
|
||||||
|
* reachable by the record walk in pass 1; they are the ones that price the
|
||||||
|
* modes separately (prep_dlx.py), which is where two cycle tables are most
|
||||||
|
* likely to disagree. */
|
||||||
|
for (int i = 0; i < nanch; i++) {
|
||||||
|
long long c = run(anch[i], 1, 1);
|
||||||
|
fprintf(stderr, "[C68K] anchor off=%-8u %8lld cyc %5.1f%% of a %u fps frame\n",
|
||||||
|
anch[i], c, 100.0 * c / frame_budget, fps);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run the C68K harness against whatever tools/bench/prep_dlx.py last laid out,
|
||||||
|
# so it measures byte-for-byte the same code and container MAME did.
|
||||||
|
# tools/bench/c68k/run.sh [out.csv]
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../../.."
|
||||||
|
M=tmp/decode_meta.lua
|
||||||
|
[ -f "$M" ] || { echo "no $M -- run tools/bench/prep_dlx.py first"; exit 2; }
|
||||||
|
g() { sed -n "s/.*[ ,{]$1=\([0-9]*\).*/\1/p" "$M" | head -1; }
|
||||||
|
# Same anchor offsets decode.lua times, so the two instruments are quoted on the
|
||||||
|
# same frames -- including the four synthetic single-mode ones, which sit past
|
||||||
|
# the end of the real stream and price each block mode on its own.
|
||||||
|
ANCH=()
|
||||||
|
while read -r o; do ANCH+=(--anchor "$o"); done < <(sed -n 's/.*off=\([0-9]*\).*/\1/p' "$M")
|
||||||
|
tools/bench/c68k/c68k_bench \
|
||||||
|
--code tmp/decode.bin --data tmp/decode_data.bin \
|
||||||
|
--cb1 "$(g cb1_len)" --cb4 "$(g cb4_len)" --pal "$(g pal_len)" \
|
||||||
|
--stream "$(g stream_len)" --nframes "$(g nframes)" \
|
||||||
|
--W "$(g W)" --H "$(g H)" --fps "$(g fps)" --dark "$(g dark)" \
|
||||||
|
"${ANCH[@]}" \
|
||||||
|
--dump tmp/c68k_screen.bin > "${1:-tmp/c68k_frames.csv}" 2> >(tee tmp/c68k.log >&2)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Is the C68K harness's decode pixel-exact against the reference decoder?
|
||||||
|
|
||||||
|
python3 tools/bench/c68k/verify_c68k.py <in.dlx> --nframes N
|
||||||
|
|
||||||
|
This is the licence for every cycle number the harness prints. The harness
|
||||||
|
rebuilds px68k's memory model from scratch -- byte-swapped RAM, GVRAM word
|
||||||
|
writes that discard the high byte, a hand-rolled 24-bit map -- and any of that
|
||||||
|
being subtly wrong would still produce plausible-looking cycle counts. It could
|
||||||
|
not produce a pixel-exact 80-frame temporal recursion.
|
||||||
|
|
||||||
|
Unlike tools/bench/verify_decode.py this compares palette INDICES, not rendered
|
||||||
|
RGB: the harness dumps the low byte of each GVRAM word directly, so there is no
|
||||||
|
palette round-trip to model and no snapshot geometry to unpick.
|
||||||
|
"""
|
||||||
|
import argparse, sys
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
import numpy as np
|
||||||
|
from dlx import DLX
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("container")
|
||||||
|
ap.add_argument("--dump", default="tmp/c68k_screen.bin")
|
||||||
|
ap.add_argument("--nframes", type=int, default=None)
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
d = DLX(a.container)
|
||||||
|
NF = a.nframes if a.nframes is not None else d.nframes
|
||||||
|
if NF > d.nframes:
|
||||||
|
sys.exit(f"--nframes {NF} exceeds the container's {d.nframes}")
|
||||||
|
|
||||||
|
canvas = np.zeros((d.H, d.W), np.uint8)
|
||||||
|
for f in range(NF):
|
||||||
|
d.paint(canvas, f)
|
||||||
|
|
||||||
|
got = np.fromfile(a.dump, np.uint8)
|
||||||
|
if got.size != d.H * d.W:
|
||||||
|
sys.exit(f"FAIL 1. dump is {got.size} B, expected {d.H*d.W}")
|
||||||
|
got = got.reshape(d.H, d.W)
|
||||||
|
|
||||||
|
if not np.array_equal(got, canvas):
|
||||||
|
bad = got != canvas
|
||||||
|
by, bx = np.where(bad)
|
||||||
|
blocks = sorted(set(zip((by // 4).tolist(), (bx // 4).tolist())))
|
||||||
|
sys.exit(f"FAIL 2. frame {NF-1} not pixel-exact under C68K: {bad.sum()} px in "
|
||||||
|
f"{len(blocks)} blocks differ; first block "
|
||||||
|
f"(by={blocks[0][0]}, bx={blocks[0][1]})")
|
||||||
|
|
||||||
|
print(f"OK {NF} frames decoded on px68k's C68K core, final frame pixel-exact "
|
||||||
|
f"against tools/encoder/dlx.py")
|
||||||
|
print(f" {d.W}x{d.H}, {d.nb} blocks/frame, k1={d.k1} k4={d.k4}; the memory "
|
||||||
|
f"model (byte-swapped RAM, high-byte-discarding GVRAM) is therefore right")
|
||||||
+843
-9
@@ -4,12 +4,37 @@
|
|||||||
# exit means something drifted.
|
# exit means something drifted.
|
||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")/../.."
|
cd "$(dirname "$0")/../.."
|
||||||
[ -d /media/reala-misaki/BDROM ] || {
|
# No media ships with this repo. Bring your own disc; DLX_BDROM overrides the
|
||||||
echo "Blu-ray not mounted. udisksctl loop-setup -r -f DRAGONS_LAIR.iso"; exit 2; }
|
# mount point, and every tool that reads the disc honours the same variable.
|
||||||
|
DLX_BDROM=${DLX_BDROM:-/media/${USER:-$(id -un)}/BDROM}
|
||||||
|
export DLX_BDROM
|
||||||
|
[ -d "$DLX_BDROM" ] || {
|
||||||
|
echo "Blu-ray not mounted at $DLX_BDROM."
|
||||||
|
echo " udisksctl loop-setup -r -f DRAGONS_LAIR.iso"
|
||||||
|
echo " or set DLX_BDROM to where yours is mounted."; exit 2; }
|
||||||
|
|
||||||
python3 tools/encoder/extract.py 00020 tmp/fr_00020 12 crop
|
python3 tools/encoder/extract.py 00020 tmp/fr_00020 12 crop
|
||||||
mkdir -p tmp/snap_verify tmp/snap256
|
mkdir -p tmp/snap_verify tmp/snap256
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# THE ONE PLACE THE RETIRED PIPE FIGURE STILL LIVES. Session 18 removed it as
|
||||||
|
# a default from every analysis tool and from tools/bench/stream.lua, because it
|
||||||
|
# was never a bus measurement -- a user-supplied "4 Mbps" with no provenance,
|
||||||
|
# 10% of SCSI-1's asynchronous rating (FINDINGS 42.1) -- and a default let table
|
||||||
|
# after table be scored against it without anyone restating what it was.
|
||||||
|
#
|
||||||
|
# It survives HERE and only here because the gate container was ENCODED with it,
|
||||||
|
# and every per-block and span constant in FINDINGS 41/43/45/49 is fitted to that
|
||||||
|
# container. Changing this number is not an edit, it is a re-encode plus a
|
||||||
|
# re-measurement of all of them.
|
||||||
|
#
|
||||||
|
# It is a CONTAINER RECIPE, not a claim about any medium. Do not read a delivery
|
||||||
|
# rate out of it, do not copy it into a tool, and do not add a default anywhere
|
||||||
|
# that would resurrect it. When the pipe is finally measured, this becomes an
|
||||||
|
# ordinary encoder setting and the comment goes.
|
||||||
|
GATE_SPAN_KBPS=488
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
run() { # run <script> <snapdir>
|
run() { # run <script> <snapdir>
|
||||||
rm -f "tmp/$2/x68000"/*.png
|
rm -f "tmp/$2/x68000"/*.png
|
||||||
( cd tmp && SDL_VIDEODRIVER=dummy timeout -k 5 120 mame x68000 -bios ipl10 \
|
( cd tmp && SDL_VIDEODRIVER=dummy timeout -k 5 120 mame x68000 -bios ipl10 \
|
||||||
@@ -40,6 +65,19 @@ python3 tools/analysis/09_ratectl_drift.py > tmp/drift_check.log 2>&1 \
|
|||||||
|| { cat tmp/drift_check.log; exit 1; }
|
|| { cat tmp/drift_check.log; exit 1; }
|
||||||
tail -9 tmp/drift_check.log
|
tail -9 tmp/drift_check.log
|
||||||
|
|
||||||
|
echo "--- session 12: the DLX3 span container round-trips (FINDINGS 41) ---"
|
||||||
|
# 09 above replays SKIP semantics in Python and never reads a container. A v7
|
||||||
|
# span breaks exactly that shortcut -- a spanned block reads SKIP in the mode
|
||||||
|
# header and is painted by the span section instead -- so this encodes, WRITES
|
||||||
|
# the container, reads it back with the reference decoder and compares. It also
|
||||||
|
# asserts that it emitted enough spans to have tested anything.
|
||||||
|
# --kbps is required now (session 18): the tool has no default rate, so the gate
|
||||||
|
# has to say which one it is testing at. Same recipe constant as the container.
|
||||||
|
python3 tools/analysis/16_span_roundtrip.py --kbps $GATE_SPAN_KBPS \
|
||||||
|
> tmp/span_roundtrip.log 2>&1 \
|
||||||
|
|| { cat tmp/span_roundtrip.log; exit 1; }
|
||||||
|
tail -4 tmp/span_roundtrip.log
|
||||||
|
|
||||||
echo "--- session 7: display-path coherency (FINDINGS 28.1) ---"
|
echo "--- session 7: display-path coherency (FINDINGS 28.1) ---"
|
||||||
# 10_pathmix_drift.py is a COUNTEREXAMPLE, kept runnable: the dual-path plan of
|
# 10_pathmix_drift.py is a COUNTEREXAMPLE, kept runnable: the dual-path plan of
|
||||||
# FINDINGS 24.5/25.6 must still be shown to corrupt frames, and the strategy the
|
# FINDINGS 24.5/25.6 must still be shown to corrupt frames, and the strategy the
|
||||||
@@ -57,17 +95,813 @@ echo "--- session 7: 68000 decoder is pixel-exact (FINDINGS 28) ---"
|
|||||||
# 68000 code, every block mode, full temporal recursion. A SKIP block is a claim
|
# 68000 code, every block mode, full temporal recursion. A SKIP block is a claim
|
||||||
# about the previous frame still being on screen, so the last frame is only
|
# about the previous frame still being on screen, so the last frame is only
|
||||||
# right if all 120 were.
|
# right if all 120 were.
|
||||||
DLX=tmp/rc_fr_singe_sasi_rcprofile.dlx
|
# The gate container is the HEAVIEST stream the encoder emits: the scsi mode
|
||||||
[ -f "$DLX" ] || python3 tools/encoder/encode.py tmp/fr_singe "$DLX" --profile sasi
|
# decision (the only profile left after session 9 dropped sasi on capacity,
|
||||||
python3 tools/bench/prep_dlx.py "$DLX" > tmp/prep_dlx.log
|
# FINDINGS 32) with the span pass drawing on a byte ceiling wide enough that
|
||||||
|
# every frame carries a span table and all four block modes are still exercised.
|
||||||
|
# That ceiling is GATE_SPAN_KBPS above -- a recipe, not a delivery rate.
|
||||||
|
# Spans are the newest and least-proven path in decode.s; gating on a container
|
||||||
|
# where they are rare would be gating on the old decoder. FINDINGS 41.
|
||||||
|
DLX=tmp/rc_fr_singe_scsi_span.dlx
|
||||||
|
[ -f "$DLX" ] || python3 tools/encoder/encode.py tmp/fr_singe "$DLX" --profile scsi \
|
||||||
|
--kbps 280 --span-kbps $GATE_SPAN_KBPS --spans all
|
||||||
|
# RIG_RAM is the EMULATED MACHINE's memory, and it is not a claim about the
|
||||||
|
# target. The rig preloads the whole container into RAM at 0x30000; the shipping
|
||||||
|
# player streams from disk into a ring buffer and never holds a window at once,
|
||||||
|
# so preloading is unlike the player at ANY size. At the 2 MB of a stock machine
|
||||||
|
# this gate covered 37 of 120 frames (FINDINGS 44.6.4) -- the span-heavy
|
||||||
|
# container is 5,261,814 B of stream, ending at 0x534BF6. 6 MB covers all 120.
|
||||||
|
#
|
||||||
|
# Raising it is licensed by measurement, not by convenience: at 2M and 6M the
|
||||||
|
# five synthetic anchors come out BIT-IDENTICAL (40,729 / 921,187 / 1,376,881 /
|
||||||
|
# 1,229,883 / 506,533 cycles) despite sitting at different addresses in the two
|
||||||
|
# layouts, so MAME's cycle model does not depend on ramsize over this range.
|
||||||
|
# FINDINGS 45. What is still NOT tested, at either size, is the streaming path.
|
||||||
|
RIG_RAM=${RIG_RAM:-6}
|
||||||
|
python3 tools/bench/prep_dlx.py "$DLX" --ram $((RIG_RAM * 0x100000)) > tmp/prep_dlx.log
|
||||||
|
# Verify against exactly the frame list prep_dlx emitted. It no longer truncates
|
||||||
|
# at the default RIG_RAM, but the guard stays: lower RIG_RAM, or a heavier
|
||||||
|
# container, brings truncation straight back and it must stay announced.
|
||||||
|
NF=$(sed -n 's/.*nframes=\([0-9]*\),.*/\1/p' tmp/decode_meta.lua)
|
||||||
|
grep -a "TRUNCATED" tmp/prep_dlx.log || true
|
||||||
tools/vasm/vasmm68k_mot -Fbin -o tmp/decode.bin src/player/decode.s > /dev/null
|
tools/vasm/vasmm68k_mot -Fbin -o tmp/decode.bin src/player/decode.s > /dev/null
|
||||||
mkdir -p tmp/snap_decode
|
mkdir -p tmp/snap_decode
|
||||||
rm -f tmp/snap_decode/x68000/*.png
|
rm -f tmp/snap_decode/x68000/*.png
|
||||||
( cd tmp && DLX_VERIFY_ONLY=1 SDL_VIDEODRIVER=dummy timeout -k 5 300 mame x68000 \
|
# stdbuf -oL: a FILE is block-buffered too, so without it a long MAME run is
|
||||||
-bios ipl10 -ramsize 2M -video soft -window -sound none -nothrottle -plugins \
|
# unobservable until it exits and a run that is merely finishing looks exactly
|
||||||
|
# like one that is wedged (FINDINGS 34.1).
|
||||||
|
# -seconds_to_run must cover the WHOLE sequential pass. The scsi container is
|
||||||
|
# 2.7x the payload of the session-7 one this gate used to run on, and at 20 s
|
||||||
|
# the pass was truncated -- MAME exited mid-decode and verify_decode.py then
|
||||||
|
# compared a partially drawn screen and reported 49,005 differing pixels, which
|
||||||
|
# reads as a decoder bug and is not one.
|
||||||
|
( cd tmp && DLX_VERIFY_ONLY=1 SDL_VIDEODRIVER=dummy stdbuf -oL timeout -k 5 300 mame x68000 \
|
||||||
|
-bios ipl10 -ramsize ${RIG_RAM}M -video soft -window -sound none -nothrottle -plugins \
|
||||||
-autoboot_script ../tools/bench/decode.lua \
|
-autoboot_script ../tools/bench/decode.lua \
|
||||||
-snapshot_directory ./snap_decode -snapview native -seconds_to_run 20 \
|
-snapshot_directory ./snap_decode -snapview native -seconds_to_run 60 \
|
||||||
> decode_check.log 2>&1 )
|
> decode_check.log 2>&1 )
|
||||||
python3 tools/bench/verify_decode.py "$DLX"
|
# A truncated run must fail as a truncated run. Without this the only symptom is
|
||||||
|
# a pixel diff against a half-drawn frame.
|
||||||
|
grep -q "snapshot taken" tmp/decode_check.log || {
|
||||||
|
echo "FAIL: the 68000 sequential pass did not complete -- no snapshot marker."
|
||||||
|
echo " Raise -seconds_to_run; the pass needs the whole container decoded."
|
||||||
|
tail -5 tmp/decode_check.log; exit 1; }
|
||||||
|
python3 tools/bench/verify_decode.py "$DLX" --nframes "$NF"
|
||||||
|
|
||||||
|
echo "--- session 10: the same decode on a second CPU core (FINDINGS 37) ---"
|
||||||
|
# A SECOND emulator, and the cheapest strong test in the tree: seconds, no MAME,
|
||||||
|
# no ROMs. px68k's C68K core has its own cycle table and its own memory model,
|
||||||
|
# so a pass here says decode.s is pixel-exact under two independent cores and
|
||||||
|
# that the harness's byte-swapped RAM / high-byte-discarding GVRAM is right --
|
||||||
|
# which is what licenses its cycle and bus numbers.
|
||||||
|
# Skipped rather than failed when px68k is not checked out: it is an external
|
||||||
|
# tree, not part of this repo.
|
||||||
|
PX68K=${PX68K:-$HOME/src/px68k}
|
||||||
|
if [ -f "$PX68K/m68000/c68k.c" ]; then
|
||||||
|
make -s -C tools/bench/c68k PX68K="$PX68K"
|
||||||
|
bash tools/bench/c68k/run.sh tmp/c68k_frames.csv 2>tmp/c68k.log
|
||||||
|
grep -a "sequential pass" tmp/c68k.log
|
||||||
|
python3 tools/bench/c68k/verify_c68k.py "$DLX" --nframes "$NF"
|
||||||
|
|
||||||
|
echo "--- session 10: the bus model still matches the machine (FINDINGS 38) ---"
|
||||||
|
# 15_bus_occupancy.py derives instruction prefetch, which no emulator here can
|
||||||
|
# report, and validates itself against the DATA accesses the harness counts.
|
||||||
|
# If that check ever stops holding, every bus figure in FINDINGS 38/39 is
|
||||||
|
# unfounded -- so it is a gate, not a report.
|
||||||
|
python3 tools/analysis/15_bus_occupancy.py "$DLX" | sed -n '3,7p'
|
||||||
|
else
|
||||||
|
echo " SKIPPED: no px68k at $PX68K (set PX68K= to point at a checkout)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- session 20: the DMAC config, read out of the IPL ROM (FINDINGS 52) ---"
|
||||||
|
# The audio and disk per-byte debits are no longer a recollection about the
|
||||||
|
# HD63450: they are bytes at named addresses in the ROM MAME boots this rig
|
||||||
|
# with. This gate re-reads them. It is cheap, it needs no emulator, and if a
|
||||||
|
# different ROM revision is ever pointed at it, it says so rather than decoding
|
||||||
|
# some other code and reporting a number.
|
||||||
|
# Skipped rather than failed when the ROM is not where MAME keeps it: that is a
|
||||||
|
# path outside this repo.
|
||||||
|
IPLROM=${IPLROM:-$HOME/mame/roms/iplrom.dat}
|
||||||
|
if [ -f "$IPLROM" ]; then
|
||||||
|
python3 tools/analysis/21_iplrom_dmac.py "$IPLROM" > tmp/iplrom_dmac.log 2>&1 \
|
||||||
|
|| { cat tmp/iplrom_dmac.log; exit 1; }
|
||||||
|
grep -ac "^ OK " tmp/iplrom_dmac.log | xargs printf " %s evidence sites hold; "
|
||||||
|
sed -n 's/^ = \(.*clocks per audio byte\)/audio is \1/p' tmp/iplrom_dmac.log
|
||||||
|
else
|
||||||
|
echo " SKIPPED: no IPL ROM at $IPLROM (set IPLROM= to point at it)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- session 18: the shared-body split is a no-op (FINDINGS 49.7.5) ---"
|
||||||
|
# src/player/decode.s and src/player/stream.s assemble from ONE copy of the block
|
||||||
|
# loop and the span chain (src/player/frame.i) so that the two front-ends cannot
|
||||||
|
# drift apart. The drift would be silent -- both would still decode correctly,
|
||||||
|
# and only the cost model would be wrong, because the 66.0 clocks/span, 9.143
|
||||||
|
# clocks/coarse pixel and every per-block constant in FINDINGS 24/30/40/41 are
|
||||||
|
# fitted to those exact bytes. So the split is asserted to be a no-op rather than
|
||||||
|
# assumed to be one.
|
||||||
|
DECODE_MD5=7a7a06f8c6d097ee0041bca4aefa3eb2 # decode.bin before the split, 1296 B
|
||||||
|
GOT=$(md5sum tmp/decode.bin | cut -d" " -f1)
|
||||||
|
[ "$GOT" = "$DECODE_MD5" ] || {
|
||||||
|
echo "FAIL: decode.bin is $GOT, expected $DECODE_MD5 ($(stat -c%s tmp/decode.bin) B)."
|
||||||
|
echo " The block loop or the span chain changed. That is allowed -- but"
|
||||||
|
echo " every cycle constant in FINDINGS 24/30/40/41 is fitted to the old"
|
||||||
|
echo " bytes, so re-measure them and move this hash, do not just move it."
|
||||||
|
exit 1; }
|
||||||
|
echo " decode.bin unchanged at $(stat -c%s tmp/decode.bin) B ($DECODE_MD5)"
|
||||||
|
# Same argument for the loader maths, which prep_dlx.py and prep_stream.py now
|
||||||
|
# share via tools/bench/dlxload.py: a second copy of the palette packing would
|
||||||
|
# drift and the symptom would be wrong colours in one rig only.
|
||||||
|
python3 tools/bench/prep_dlx.py "$DLX" --ram $((RIG_RAM * 0x100000)) --out tmp/_pdchk > /dev/null
|
||||||
|
cmp -s tmp/_pdchk_data.bin tmp/decode_data.bin || {
|
||||||
|
echo "FAIL: prep_dlx.py is not reproducible"; exit 1; }
|
||||||
|
echo " prep_dlx.py blob reproducible ($(stat -c%s tmp/decode_data.bin) B)"
|
||||||
|
rm -f tmp/_pdchk_data.bin tmp/_pdchk_meta.lua
|
||||||
|
|
||||||
|
echo "--- session 18: 120 frames through a bounded RING (FINDINGS 49) ---"
|
||||||
|
# The gate above preloads the whole container into RAM and proves the DECODER.
|
||||||
|
# This proves the DELIVERY path: the same 120 frames decoded out of a 256 KB
|
||||||
|
# ring on a STOCK 2 MB machine, with the container in a host file. The block
|
||||||
|
# loop reads with a monotonically increasing a0 and no bounds check, so a record
|
||||||
|
# placed wrongly by the wrap policy corrupts pixels rather than faulting -- which
|
||||||
|
# is why this is gated on the same pixel-exact comparison and not on a checksum.
|
||||||
|
tools/vasm/vasmm68k_mot -Fbin -o tmp/stream.bin src/player/stream.s > /dev/null
|
||||||
|
python3 tools/bench/prep_stream.py "$DLX" > tmp/prep_stream.log
|
||||||
|
mkdir -p tmp/snap_stream
|
||||||
|
rm -f tmp/snap_stream/x68000/*.png
|
||||||
|
( cd tmp && DLX_STREAM_KBPS=0 SDL_VIDEODRIVER=dummy stdbuf -oL timeout -k 5 600 \
|
||||||
|
mame x68000 -bios ipl10 -ramsize 2M -video soft -window -sound none \
|
||||||
|
-nothrottle -plugins -autoboot_script ../tools/bench/stream.lua \
|
||||||
|
-snapshot_directory ./snap_stream -snapview native -seconds_to_run 90 \
|
||||||
|
> stream_check.log 2>&1 )
|
||||||
|
# Same truncation trap as the decode stage: without this, a run that exited
|
||||||
|
# mid-decode is compared against a half-drawn screen and reads as a wrap bug.
|
||||||
|
grep -q "snapshot taken" tmp/stream_check.log || {
|
||||||
|
echo "FAIL: the ring-buffer pass did not complete -- no snapshot marker."
|
||||||
|
tail -5 tmp/stream_check.log; exit 1; }
|
||||||
|
grep -a "ring: \|DEADLINE" tmp/stream_check.log | sed "s/\[STR\] / /"
|
||||||
|
python3 tools/bench/verify_decode.py "$DLX" --snap tmp/snap_stream
|
||||||
|
|
||||||
|
echo "--- session 19: the PACED ring, and what a branch point costs (FINDINGS 51) ---"
|
||||||
|
# The stage above runs the ring FREE-RUNNING, which is right for what it gates:
|
||||||
|
# an unlimited pipe removes delivery as a variable and leaves the wrap policy
|
||||||
|
# alone under test. It cannot see buffering, because a decoder that never waits
|
||||||
|
# never lets the ring back up -- 49.7.2, and it is why 48 KB passed while
|
||||||
|
# holding one record. This runs the same 120 frames with the decoder held to
|
||||||
|
# 12 fps, which is the only configuration in which FR_HEAD-FR_TAIL means what
|
||||||
|
# it is read to mean.
|
||||||
|
#
|
||||||
|
# Gated on: pixel-exact, zero UNDERRUNS, and a ceiling that has not moved. The
|
||||||
|
# ceiling is a property of THIS container in a 256 KB ring; it is asserted
|
||||||
|
# rather than printed because a change in it is a change in how much a branch
|
||||||
|
# point can afford, and that should not slip through as a line in a log.
|
||||||
|
bash tools/bench/pace_run.sh 256 0 > tmp/pace_check.log 2>&1 || {
|
||||||
|
echo "FAIL: the paced ring pass did not complete."; tail -8 tmp/pace_check.log
|
||||||
|
exit 1; }
|
||||||
|
grep -aE "SEEK SLACK|UNDERRUNS" tmp/pace_check.log
|
||||||
|
grep -q "UNDERRUNS: 0/120" tmp/pace_check.log || {
|
||||||
|
echo "FAIL: the paced decoder underran -- a frame's slot arrived before its"
|
||||||
|
echo " record did. Free-running this is earliness (49.6); paced it is not."
|
||||||
|
exit 1; }
|
||||||
|
grep -q "ceiling 8 frames" tmp/pace_check.log || {
|
||||||
|
echo "FAIL: the 256 KB seek-slack ceiling is no longer 8 frames (FINDINGS 51)."
|
||||||
|
echo " Re-run tools/bench/pace_sweep.sh and re-derive 51 before editing"
|
||||||
|
echo " this number -- it is what a branch point can spend."
|
||||||
|
exit 1; }
|
||||||
|
grep -q "^OK" tmp/pace_check.log || { echo "FAIL: paced pass not pixel-exact";
|
||||||
|
tail -4 tmp/pace_check.log; exit 1; }
|
||||||
|
|
||||||
|
echo "--- session 21: the 68000 builds its own codebooks and palette (FINDINGS 53) ---"
|
||||||
|
# ROADMAP P1+P2. Until now tools/bench/dlxload.py expanded the codebooks and
|
||||||
|
# packed the palette HOST-SIDE and the rigs pushed the result into emulated RAM.
|
||||||
|
# A player has no host. src/player/load.i does both on the 68000, out of the RAW
|
||||||
|
# container header, and this gates it byte-for-byte against dlxload.py -- which
|
||||||
|
# stays the reference, because what changed is where the transforms RUN, not
|
||||||
|
# what they produce.
|
||||||
|
#
|
||||||
|
# Byte-for-byte and not "close enough": a wrong codebook byte is a wrong colour
|
||||||
|
# in every block that uses that codeword, and a wrong shared LSB is a slightly
|
||||||
|
# wrong colour that looks like a codec artefact rather than a loader bug.
|
||||||
|
# The palette half is read back out of the PALETTE REGISTERS at $E82000, so
|
||||||
|
# "the words reached the hardware" is part of what passes.
|
||||||
|
#
|
||||||
|
# NOT gated on the cycle counts, and the reason is NOT the one blit.s has. These
|
||||||
|
# are emulated time and reproduce exactly run to run; what they are not is
|
||||||
|
# sharp, because MAME samples them on a 1/56.69 s clock and the job takes
|
||||||
|
# milliseconds. Nothing in the tree's cost models depends on them either. A
|
||||||
|
# change in them is a re-derivation in FINDINGS 53, not a red light here.
|
||||||
|
bash tools/bench/load_run.sh "$DLX" > tmp/load_gate.log 2>&1 || {
|
||||||
|
echo "FAIL: the load-time transforms did not pass."; tail -12 tmp/load_gate.log
|
||||||
|
exit 1; }
|
||||||
|
grep -aE "^ *OK|both CPU cores|SCENE CHANGE" tmp/load_gate.log | sed 's/^ *//;s/^/ /'
|
||||||
|
|
||||||
|
echo "--- session 22: the 68000 keeps its own frame clock (FINDINGS 54) ---"
|
||||||
|
# ROADMAP P3. Until now the 12 fps tick came from tools/bench/stream.lua -- a
|
||||||
|
# host writing a word into emulated RAM. A player has no host. src/player/
|
||||||
|
# clock.i derives the tick from the CRTC's own V-DISP output through the MFP,
|
||||||
|
# with a remainder-keeping divider whose two constants are READ OUT OF THE CRTC
|
||||||
|
# at init, so the clock and the raster it counts cannot disagree.
|
||||||
|
#
|
||||||
|
# WHAT IS GATED, and it is deliberately structural rather than numeric:
|
||||||
|
# * the interrupt count equals the raster frame count -- the tick IS the
|
||||||
|
# raster, not something that merely resembles it;
|
||||||
|
# * the divider does not accumulate drift, stated in TICKS (a remainder can
|
||||||
|
# hold back at most one) rather than in ppm, which would let a longer
|
||||||
|
# window advertise a tighter clock for free;
|
||||||
|
# * every frame tick waits 4 or 5 refreshes and nothing else, which is what a
|
||||||
|
# remainder-keeping divider can produce and a broken one cannot.
|
||||||
|
# The interrupt COST is printed and not gated, for the same reason FINDINGS 53's
|
||||||
|
# cycle counts are not: it is a measurement, and a change in it is a
|
||||||
|
# re-derivation in FINDINGS 54 rather than a red light here.
|
||||||
|
bash tools/bench/clock_run.sh 3000 12 > tmp/clock_gate.log 2>&1 || {
|
||||||
|
echo "FAIL: the frame clock did not pass."; tail -12 tmp/clock_gate.log
|
||||||
|
exit 1; }
|
||||||
|
grep -aE "INTERRUPT:|PER FRAME:|DRIFT:|CADENCE:" tmp/clock_gate.log
|
||||||
|
grep -q "V-DISP interrupts 3000" tmp/clock_gate.log || {
|
||||||
|
echo "FAIL: the tick is not the raster -- the interrupt count and the frame"
|
||||||
|
echo " count disagree. Everything else in FINDINGS 54 rests on that."
|
||||||
|
exit 1; }
|
||||||
|
|
||||||
|
echo "--- session 22: 120 frames decoded on the machine's own clock (FINDINGS 54) ---"
|
||||||
|
# The strongest form of the claim: the same pixel-exact 120-frame decode out of
|
||||||
|
# the same 256 KB ring, with NOTHING outside the machine deciding when a frame
|
||||||
|
# may start. The pace gate in src/player/stream.s is byte-for-byte the one
|
||||||
|
# FINDINGS 51 measured -- it cannot tell a host-written tick from a machine-
|
||||||
|
# written one, which is why this is a test of the clock and not of a new rig.
|
||||||
|
DLX_PACE=2 bash tools/bench/pace_run.sh 256 0 > tmp/selfpace_check.log 2>&1 || {
|
||||||
|
echo "FAIL: the self-paced pass did not complete."; tail -8 tmp/selfpace_check.log
|
||||||
|
exit 1; }
|
||||||
|
grep -aE "decoder SELF-PACED|FRAME CLOCK|UNDERRUNS|NO IDLE" tmp/selfpace_check.log
|
||||||
|
grep -q "UNDERRUNS: 0/120" tmp/selfpace_check.log || {
|
||||||
|
echo "FAIL: the self-paced decoder underran."; exit 1; }
|
||||||
|
grep -q "^OK" tmp/selfpace_check.log || {
|
||||||
|
echo "FAIL: the self-paced pass was not pixel-exact. The clock changed WHEN"
|
||||||
|
echo " frames start; if it changed WHAT they draw, the interrupt is"
|
||||||
|
echo " corrupting decoder state."; tail -4 tmp/selfpace_check.log; exit 1; }
|
||||||
|
|
||||||
|
echo "--- session 23: the 68000 fills its own ring (FINDINGS 55) ---"
|
||||||
|
# ROADMAP P5. Until now the RING was filled by tools/bench/stream.lua: the host
|
||||||
|
# held the record index, chose where each record went, wrote the descriptor and
|
||||||
|
# advertised it. A player has no host. src/player/ring.i does all of that on the
|
||||||
|
# 68000, out of the DLX4 record index in the scene header, and this script keeps
|
||||||
|
# only the part that is not the CPU's -- a transport that answers one request at
|
||||||
|
# a time at a modelled rate.
|
||||||
|
#
|
||||||
|
# WHAT IS GATED:
|
||||||
|
# * pixel-exact, which is the only test that can see a wrong placement: the
|
||||||
|
# block loop reads with a monotonically increasing a0 and no bounds check,
|
||||||
|
# so a record placed over one the decoder has not finished corrupts pixels
|
||||||
|
# rather than faulting (49.2);
|
||||||
|
# * the host AUDITS every placement against its own index and its own list of
|
||||||
|
# live records, and refuses the run on the first disagreement;
|
||||||
|
# * the wrap policy still produces the SAME 18 wraps the
|
||||||
|
# host producer produced in FINDINGS 49.4 -- a third independent
|
||||||
|
# implementation of `aligned` landing on the same tiling;
|
||||||
|
# * zero underruns at a two-deep request queue, which is the finding: a
|
||||||
|
# one-deep queue leaves the channel idle between records and underran 59 of
|
||||||
|
# 120 frames on this same container and rate.
|
||||||
|
DLX_PACE=2 DLX_RINGOWN=1 DLX_QDEPTH=2 bash tools/bench/pace_run.sh 256 488 \
|
||||||
|
> tmp/ringown_check.log 2>&1 || {
|
||||||
|
echo "FAIL: the machine-owned ring pass did not complete."
|
||||||
|
tail -10 tmp/ringown_check.log; exit 1; }
|
||||||
|
grep -aE "MACHINE-OWNED|PREFILL:|CHANNEL IDLE|UNDERRUNS|SEEK SLACK" tmp/ringown_check.log \
|
||||||
|
| sed "s/\[STR\] / /"
|
||||||
|
grep -q "MISPLACED" tmp/ringown_check.log && {
|
||||||
|
echo "FAIL: the 68000 placed a record over one the decoder still owned."
|
||||||
|
exit 1; }
|
||||||
|
grep -q "UNDERRUNS: 0/120" tmp/ringown_check.log || {
|
||||||
|
echo "FAIL: the machine-owned ring underran at a two-deep queue. That is the"
|
||||||
|
echo " configuration FINDINGS 55 says keeps the channel busy; if it no"
|
||||||
|
echo " longer does, the poll site in src/player/stream.s moved."
|
||||||
|
exit 1; }
|
||||||
|
grep -q "ring: 18 wraps" tmp/ringown_check.log || {
|
||||||
|
echo "FAIL: the machine's own \`aligned\` no longer tiles this container the"
|
||||||
|
echo " way FINDINGS 49.4's host producer did (18 wraps). The policy is"
|
||||||
|
echo " meant to be the SAME policy in a different place."
|
||||||
|
exit 1; }
|
||||||
|
grep -q "^OK" tmp/ringown_check.log || {
|
||||||
|
echo "FAIL: the machine-owned ring pass was not pixel-exact -- a record was"
|
||||||
|
echo " placed or described wrongly."; tail -4 tmp/ringown_check.log
|
||||||
|
exit 1; }
|
||||||
|
|
||||||
|
echo "--- session 23: a seek, and the decode after it (FINDINGS 55) ---"
|
||||||
|
# The branch point rehearsed. A second pass over the scene begins with a real
|
||||||
|
# seek in src/player/ring.i: the channel is waited quiet, the ring is declared
|
||||||
|
# empty, the disc address of record 0 comes out of the index rather than from a
|
||||||
|
# walk, and the whole lookahead 51.3 says takes seconds of play to accumulate is
|
||||||
|
# thrown away and rebuilt from the prefill. What is gated afterwards is the one
|
||||||
|
# thing that can see a wrong seek: the last frame of the SECOND pass has to be
|
||||||
|
# pixel-exact, and a SKIP block is a claim about the previous frame, so it is
|
||||||
|
# only right if every frame after the seek was.
|
||||||
|
DLX_PACE=2 DLX_RINGOWN=1 DLX_QDEPTH=2 DLX_ITER=2 \
|
||||||
|
bash tools/bench/pace_run.sh 256 488 > tmp/ringseek_check.log 2>&1 || {
|
||||||
|
echo "FAIL: the seek pass did not complete."; tail -10 tmp/ringseek_check.log
|
||||||
|
exit 1; }
|
||||||
|
grep -aE "SEEK PASS|CHANNEL IDLE|UNDERRUNS" tmp/ringseek_check.log | sed "s/\[STR\] / /"
|
||||||
|
grep -q "SEEK PASS 2" tmp/ringseek_check.log || {
|
||||||
|
echo "FAIL: no second pass -- the seek never happened, so this gated nothing."
|
||||||
|
exit 1; }
|
||||||
|
grep -q "UNDERRUNS: 0/120" tmp/ringseek_check.log || {
|
||||||
|
echo "FAIL: the pass after the seek underran."; exit 1; }
|
||||||
|
grep -q "^OK" tmp/ringseek_check.log || {
|
||||||
|
echo "FAIL: the decode after the seek was not pixel-exact."
|
||||||
|
tail -4 tmp/ringseek_check.log; exit 1; }
|
||||||
|
|
||||||
|
echo "--- session 25: the 68000 reads the disc itself (FINDINGS 57) ---"
|
||||||
|
# ROADMAP P4, first half. Until now every byte the player consumed was placed in
|
||||||
|
# emulated RAM by a host: decode.lua preloaded a container, stream.lua answered a
|
||||||
|
# mailbox at a modelled rate. A player has no host. src/player/scsi.i selects a
|
||||||
|
# SCSI target on a real MB89352 and issues READ(10) itself.
|
||||||
|
#
|
||||||
|
# Session 21 recorded this as blocked -- "MAME's x68000 has no MB89352 path" --
|
||||||
|
# and that was wrong: `-exp1 cz6bs1` instantiates one, and FINDINGS 32.4 had
|
||||||
|
# already read its DMA glue in session 9. What was actually missing was the
|
||||||
|
# card's 8 KB boot ROM, which MAME requires to instantiate the device and which
|
||||||
|
# the player never executes. scsi_run.sh supplies a blank one on its own rompath.
|
||||||
|
#
|
||||||
|
# WHAT IS GATED: the register window (60 of 64 addresses -- the two holes ARE the
|
||||||
|
# MB89352's missing TMOD and EXBF, and they are what put DREG at $EA0015), and
|
||||||
|
# two READ(10)s verified byte-for-byte against the host's copy of the same image,
|
||||||
|
# one of them at a NON-ZERO LBA. Nothing here is gated on rate, and nothing here
|
||||||
|
# can be: MAME's device models are functional, not transfer-timing accurate.
|
||||||
|
# Skipped rather than failed when chdman is absent -- it ships with mame-tools.
|
||||||
|
if command -v chdman > /dev/null; then
|
||||||
|
bash tools/bench/scsi_run.sh "$DLX" > tmp/scsi_gate.log 2>&1 || {
|
||||||
|
echo "FAIL: the 68000 could not read the disc."; tail -14 tmp/scsi_gate.log
|
||||||
|
exit 1; }
|
||||||
|
grep -aE "ANSWERED|READ\(10\) OK" tmp/scsi_gate.log
|
||||||
|
else
|
||||||
|
echo " SKIPPED: no chdman (ships with mame-tools) -- cannot build the volume"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- session 26: the ring is filled off a real SCSI volume (FINDINGS 58) ---"
|
||||||
|
# ROADMAP P4b. The stage above shows the 68000 can READ the disc. This shows it
|
||||||
|
# can RUN off it: src/player/xfer.i sits behind src/player/ring.i's XF_* mailbox
|
||||||
|
# in place of tools/bench/stream.lua's modelled transport, and the same 120
|
||||||
|
# frames are decoded out of the same 256 KB ring with NOTHING outside the
|
||||||
|
# machine in the transfer path -- no host file, no modelled rate, no synthesised
|
||||||
|
# ack.
|
||||||
|
#
|
||||||
|
# WHAT IS GATED, and it is correctness rather than rate on purpose:
|
||||||
|
# * pixel-exact, which is the only test that can see a wrong record: the
|
||||||
|
# window in scsi.i decides which of a sector's bytes reach the ring, and a
|
||||||
|
# window off by one byte desyncs the bitstream rather than faulting (49.2);
|
||||||
|
# * the SAME 18 wraps -- ring.i's placement policy must not be able to tell
|
||||||
|
# which transport answered it, and this is the assertion that says it could
|
||||||
|
# not. The WRAP COUNT is gated and the mean hole is only reported: DLX5's
|
||||||
|
# records are up to 511 B longer than DLX4's, so the hole moved (14.7 KB ->
|
||||||
|
# 13.5 KB) while the tiling did not. Gating a number that the container's
|
||||||
|
# record lengths move would gate the container, not the policy;
|
||||||
|
# * every record accounted for: 120 READ(10)s, and the bytes into the ring
|
||||||
|
# EQUAL to the bytes off the disc -- both read out of the container rather
|
||||||
|
# than written here. Under DLX4 they differed by 1.34% because a record was
|
||||||
|
# not a sector (58.3); DLX5 aligns records to sectors and the covering-sector
|
||||||
|
# read disappears, so the gate is now their IDENTITY;
|
||||||
|
# * a real mid-stream SEEK with the real transport, in the second pass. This
|
||||||
|
# is the one path that could not exist before: ring_seek waits for the
|
||||||
|
# channel to go quiet, and with the transport INSIDE the machine the only
|
||||||
|
# thing that can retire an outstanding request is that wait loop itself.
|
||||||
|
#
|
||||||
|
# NOTHING HERE IS GATED ON RATE and nothing here can be. What the run DOES cost
|
||||||
|
# is printed by tools/bench/xfer_cost.sh and recorded in FINDINGS 58.2; it is a
|
||||||
|
# measurement, and a change in it is a re-derivation there rather than a red
|
||||||
|
# light here. Skipped rather than failed when chdman is absent.
|
||||||
|
if command -v chdman > /dev/null; then
|
||||||
|
DLX_PACE=0 DLX_RINGOWN=1 DLX_QDEPTH=2 DLX_XFER=scsi \
|
||||||
|
bash tools/bench/pace_run.sh 256 0 > tmp/p4b_check.log 2>&1 || {
|
||||||
|
echo "FAIL: the 68000 could not run the ring off a real SCSI volume."
|
||||||
|
tail -12 tmp/p4b_check.log; exit 1; }
|
||||||
|
grep -aE "REAL TRANSPORT:|SECTOR OVERHEAD|ring: " tmp/p4b_check.log \
|
||||||
|
| sed "s/^ *//;s/^/ /"
|
||||||
|
grep -aq "TRANSPORT FAILED" tmp/p4b_check.log && {
|
||||||
|
echo "FAIL: a record's READ(10) reported an error."; exit 1; }
|
||||||
|
# THE BYTE COUNTS COME OUT OF THE CONTAINER, not out of this file. They were
|
||||||
|
# two hardcoded constants fitted to the DLX4 gate container, and session 28's
|
||||||
|
# re-encode went red on both of them for the right reason -- the container had
|
||||||
|
# changed and the expectation had not. A gate whose expected value is a
|
||||||
|
# literal tests the literal.
|
||||||
|
EXPECT_B=$(python3 -c "
|
||||||
|
import sys; sys.path.insert(0, 'tools/encoder')
|
||||||
|
from dlx import DLX
|
||||||
|
print(sum(DLX('$DLX').record_lengths()))")
|
||||||
|
grep -aq "REAL TRANSPORT: 120 READ(10)s by the 68000, $EXPECT_B B into the ring" \
|
||||||
|
tmp/p4b_check.log || {
|
||||||
|
echo "FAIL: the 68000 did not fetch all 120 records, or did not fetch"
|
||||||
|
echo " $EXPECT_B B of them. A short record is a desync, not a shortfall."
|
||||||
|
exit 1; }
|
||||||
|
# DLX5 MAKES THESE THE SAME NUMBER, and that identity IS the finding (59.4,
|
||||||
|
# and 58.3 option C): a sector-aligned container has no covering-sector read,
|
||||||
|
# so the disc moves exactly the records and nothing else. Under DLX4 they
|
||||||
|
# differed by 1.34% and both were gated so neither could drift into the other;
|
||||||
|
# under DLX5 the gate is that they are EQUAL. If a windowed read ever came
|
||||||
|
# back -- a container that was not aligned, or a layout that lost the
|
||||||
|
# alignment -- the disc figure would exceed the ring figure and this goes red.
|
||||||
|
grep -aq "SECTOR OVERHEAD: $EXPECT_B B off the disc for $EXPECT_B B of record = 0.00%" \
|
||||||
|
tmp/p4b_check.log || {
|
||||||
|
echo "FAIL: the disc no longer moves EXACTLY the records. On a sector-"
|
||||||
|
echo " aligned container (DLX5) there is no covering-sector read at"
|
||||||
|
echo " all, so these two counts must be the same $EXPECT_B B. If they"
|
||||||
|
echo " differ, either the container lost its alignment or scsi.i is"
|
||||||
|
echo " windowing again -- and a DMA channel cannot window (59.4)."
|
||||||
|
exit 1; }
|
||||||
|
grep -aq "ring: 18 wraps" tmp/p4b_check.log || {
|
||||||
|
echo "FAIL: the placement policy tiled this container differently with a"
|
||||||
|
echo " real transport behind it than with a modelled one. ring.i is"
|
||||||
|
echo " not supposed to be able to tell them apart."; exit 1; }
|
||||||
|
grep -aq "^OK" tmp/p4b_check.log || {
|
||||||
|
echo "FAIL: the pass off the SCSI volume was not pixel-exact."
|
||||||
|
tail -4 tmp/p4b_check.log; exit 1; }
|
||||||
|
DLX_PACE=2 DLX_RINGOWN=1 DLX_QDEPTH=2 DLX_ITER=2 DLX_XFER=scsi \
|
||||||
|
DLX_SECONDS=240 bash tools/bench/pace_run.sh 256 0 \
|
||||||
|
> tmp/p4b_seek_check.log 2>&1 || {
|
||||||
|
echo "FAIL: the seek pass off the SCSI volume did not complete."
|
||||||
|
tail -12 tmp/p4b_seek_check.log; exit 1; }
|
||||||
|
grep -aE "SEEK PASS|IS VACUOUS" tmp/p4b_seek_check.log | sed "s/^ *//;s/^/ /"
|
||||||
|
grep -aq "SEEK PASS 2" tmp/p4b_seek_check.log || {
|
||||||
|
echo "FAIL: no real seek -- the second pass never threw its ring away, so"
|
||||||
|
echo " ring_seek's quiet-wait was never asked to retire an outstanding"
|
||||||
|
echo " transfer and this gated nothing."; exit 1; }
|
||||||
|
grep -aq "^OK" tmp/p4b_seek_check.log || {
|
||||||
|
echo "FAIL: the decode after a seek off the SCSI volume was not pixel-exact."
|
||||||
|
tail -4 tmp/p4b_seek_check.log; exit 1; }
|
||||||
|
else
|
||||||
|
echo " SKIPPED: no chdman (ships with mame-tools) -- cannot build the volume"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- session 27: the DMAC drives the data phase, and holds the bus (FINDINGS 59) ---"
|
||||||
|
# ROADMAP P4a, the last item before M2. The two stages above have the CPU moving
|
||||||
|
# every byte itself, at the 87.28 clocks per delivered byte FINDINGS 58.2
|
||||||
|
# measured -- 391.8% of a 12 fps frame. This one hands the DATA IN phase to the
|
||||||
|
# HD63450 and gates on the thing 57.3 said would be hard to show: that the DMAC,
|
||||||
|
# and not the CPU, is driving it.
|
||||||
|
#
|
||||||
|
# IT IS GATED WITHOUT LOOKING AT $EA0015, and that is the design. With the
|
||||||
|
# DMAC's OWN asserted -- which it is at idle here -- MAME cannot distinguish a
|
||||||
|
# CPU-driven byte at that address from a DMAC-driven one, so watching it proves
|
||||||
|
# nothing. What is gated instead is THE CPU'S OWN PROGRESS:
|
||||||
|
# * the same 2,048 B off the disc three ways -- PIO, held, stealing -- all
|
||||||
|
# three byte-exact against the host's copy, so the configuration is being
|
||||||
|
# compared against a delivery that works and not against nothing;
|
||||||
|
# * MTC sampled by the INSTRUCTION AFTER the one that starts the channel: zero
|
||||||
|
# in the held configuration (the whole transfer happened between two
|
||||||
|
# instructions, because the 68000 did not execute in between) and the full
|
||||||
|
# count in the stealing one;
|
||||||
|
# * the CPU's own trip count round its wait loop: 1 against hundreds. A
|
||||||
|
# counter that CANNOT come out different is 58.3's vacuous "UNDERRUNS: 0/120"
|
||||||
|
# again, so the run asserts the contrast and not just the held value;
|
||||||
|
# * the channel's own CSR/CER/MTC/MAR, which must say it moved every byte
|
||||||
|
# without error;
|
||||||
|
# * THE PALETTE REGISTERS AT $E82000 (session 30, ROADMAP K1): the same
|
||||||
|
# transfer aimed at the palette, byte-exact into 256 register words read
|
||||||
|
# back by the 68000; the SAME transfer aimed 20 KB away leaving the palette
|
||||||
|
# as the CPU poisoned it, which is what attributes the first run to the
|
||||||
|
# channel's MAR; and ONE array-chained start crossing from the registers
|
||||||
|
# into GVRAM, which is the shape of a whole frame -- a palette entry and
|
||||||
|
# 192 row entries, started once. What this does NOT settle is the board:
|
||||||
|
# MAME models the palette as a generic palette_device over memory_array,
|
||||||
|
# whose write16 is a plain COMBINE_DATA, so it cannot tell a register file
|
||||||
|
# that takes byte writes from one that does not (FINDINGS 62.4);
|
||||||
|
# * and a WINDOWED read through the channel REFUSED. 117 of 120 records start
|
||||||
|
# part way into a sector (58.3); a channel writes a contiguous run and cannot
|
||||||
|
# drop the bytes in front of one, so it would write the neighbouring records
|
||||||
|
# into the ring with no bounds check to catch it (49.2). The refusal is what
|
||||||
|
# makes "sector-aligned container" a precondition the transport states.
|
||||||
|
#
|
||||||
|
# NOT GATED ON RATE, and it cannot be: MAME's DMAC runs on wall-clock attotimes
|
||||||
|
# (42.5) and models a held bus by HALTING the CPU rather than by charging it
|
||||||
|
# cycles per operand. `W` is untouched. tools/analysis/28_autorequest_cost.py
|
||||||
|
# prices what this configuration costs, from the datasheet and an explicit rate.
|
||||||
|
# Skipped rather than failed when chdman is absent.
|
||||||
|
if command -v chdman > /dev/null; then
|
||||||
|
bash tools/bench/dma_run.sh "$DLX" > tmp/dma_gate.log 2>&1 || {
|
||||||
|
echo "FAIL: the DMAC did not drive the SCSI data phase."
|
||||||
|
tail -16 tmp/dma_gate.log; exit 1; }
|
||||||
|
grep -aE "BYTES OK|MTC one instruction|trips round|REFUSED|PALETTE|ONE START" \
|
||||||
|
tmp/dma_gate.log \
|
||||||
|
| sed 's/^ *//;s/^/ /'
|
||||||
|
else
|
||||||
|
echo " SKIPPED: no chdman (ships with mame-tools) -- cannot build the volume"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- session 24: the scene graph, and the gap between branch points (FINDINGS 56) ---"
|
||||||
|
# The arcade scene graph is not in this repo and is not redistributable from
|
||||||
|
# here. tools/import/scenegraph.py is the ONE file in the tree that knows the
|
||||||
|
# outside projects exist; it writes tmp/scenegraph.json in this project's own
|
||||||
|
# DLXSCENE1 schema and everything downstream reads only that.
|
||||||
|
# What is gated is the IMPORT, not the numbers: 516 sequences and 906 input
|
||||||
|
# windows, and the four timing helpers still being the formulas the importer
|
||||||
|
# evaluates. Skipped when the checkout is absent.
|
||||||
|
DIRKSIMPLE=${DLX_DIRKSIMPLE:-tmp/scenegraph/DirkSimple}
|
||||||
|
if [ -f "$DIRKSIMPLE/data/games/lair/game.lua" ]; then
|
||||||
|
DLX_DIRKSIMPLE="$DIRKSIMPLE" python3 tools/import/scenegraph.py \
|
||||||
|
-o tmp/scenegraph.json > tmp/scenegraph_import.log 2>&1 \
|
||||||
|
|| { cat tmp/scenegraph_import.log; exit 1; }
|
||||||
|
sed "s/^/ /" tmp/scenegraph_import.log
|
||||||
|
grep -q "516 sequences, 906 input windows" tmp/scenegraph_import.log || {
|
||||||
|
echo "FAIL: the scene graph did not import to 516/906 -- upstream changed,"
|
||||||
|
echo " or the parser silently dropped branches."; exit 1; }
|
||||||
|
python3 tools/analysis/25_scene_graph.py --kbps 488 --ring 256 \
|
||||||
|
> tmp/scenegraph_check.log 2>&1 \
|
||||||
|
|| { tail -20 tmp/scenegraph_check.log; exit 1; }
|
||||||
|
grep -aE "^ WORST |^ ZERO-PLAY|^ BRANCH STRUCTURE" tmp/scenegraph_check.log
|
||||||
|
else
|
||||||
|
echo " SKIPPED: no DirkSimple checkout at $DIRKSIMPLE"
|
||||||
|
echo " (git clone --depth 1 https://github.com/icculus/DirkSimple)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- session 29: the packed paint, and what it does to the codec (FINDINGS 61) ---"
|
||||||
|
# tools/bench/blit.s gained V8/V9/V10 -- the packed full-frame blit, and the two
|
||||||
|
# ways a 4x4 BLOCK decoder could survive the packed layout. 47.6.1 had filed the
|
||||||
|
# packed paint's `movem` shape as an ASSUMPTION since session 16; this measures
|
||||||
|
# it, in the same run as V1/V3/V4 so it is quoted against numbers that have not
|
||||||
|
# moved since session 9.
|
||||||
|
#
|
||||||
|
# WHAT IS GATED IS STRUCTURAL, not numeric, for the reason the load stage gives:
|
||||||
|
# MAME samples these on a 1/56.69 s clock and no cost model in the tree depends
|
||||||
|
# on their exact value. What DOES depend on them is the ORDER, and the order is
|
||||||
|
# the whole of FINDINGS 61:
|
||||||
|
# V8 < V1 packing halves the full-frame literal paint
|
||||||
|
# V9 > V4 packing makes a BLOCK decoder DEARER, not cheaper
|
||||||
|
# V10 < V4 unless the blocks are paired, which costs SKIPs instead
|
||||||
|
# A tree where any of those flipped has a different answer to 44.7 and should
|
||||||
|
# say so out loud rather than let 29_packed_player.py narrate the old one.
|
||||||
|
python3 tools/bench/prep_frame.py tmp/fr_00020 tmp/frame256.bin 0 --reserve-black
|
||||||
|
rm -f tmp/blit_v8.log
|
||||||
|
( cd tmp && SDL_VIDEODRIVER=dummy stdbuf -oL timeout -k 5 300 mame x68000 \
|
||||||
|
-bios ipl10 -ramsize 2M -video soft -window -sound none -nothrottle -plugins \
|
||||||
|
-autoboot_script ../tools/bench/blit.lua -seconds_to_run 120 \
|
||||||
|
> blit_v8.log 2>&1 )
|
||||||
|
grep -aq "summary (instruction cycles only" tmp/blit_v8.log || {
|
||||||
|
echo "FAIL: the blit timing run produced no summary -- it did not finish."
|
||||||
|
tail -8 tmp/blit_v8.log; exit 1; }
|
||||||
|
python3 - <<'EOF' || exit 1
|
||||||
|
import re, sys
|
||||||
|
v = {}
|
||||||
|
for line in open("tmp/blit_v8.log", errors="replace"):
|
||||||
|
m = re.search(r"V(\d+)\s+(\d+) cyc", line)
|
||||||
|
if m: v[int(m.group(1))] = int(m.group(2))
|
||||||
|
need = (1, 2, 3, 4, 8, 9, 10)
|
||||||
|
missing = [n for n in need if n not in v]
|
||||||
|
if missing: sys.exit(f"FAIL: blit.lua reported no V{missing} -- run incomplete.")
|
||||||
|
for a, op, b, why in ((8, "<", 1, "packing did not halve the literal paint"),
|
||||||
|
(9, ">", 4, "packed BLOCK order came out CHEAPER than "
|
||||||
|
"unpacked -- 61.3's conclusion is inverted"),
|
||||||
|
(10, "<", 4, "pairing the blocks did not buy back the "
|
||||||
|
"movem shape")):
|
||||||
|
ok = v[a] < v[b] if op == "<" else v[a] > v[b]
|
||||||
|
if not ok:
|
||||||
|
sys.exit(f"FAIL: V{a} {v[a]:,} is not {op} V{b} {v[b]:,} -- {why}.")
|
||||||
|
print(f" V1 {v[1]:,} / V8 PACKED {v[8]:,} = {100*v[8]/v[1]:.0f}% -- "
|
||||||
|
f"and V3, the unpacked WRITE-ONLY floor, is {v[3]:,}")
|
||||||
|
print(f" V4 {v[4]:,} / V9 packed-block {v[9]:,} = {100*v[9]/v[4]:.0f}% -- "
|
||||||
|
f"packing costs a BLOCK decoder {100*v[9]/v[4]-100:.0f}%")
|
||||||
|
print(f" V10 paired blocks {v[10]:,} = {100*v[10]/v[4]:.0f}% of V4, and pairing "
|
||||||
|
f"is paid for in SKIPs")
|
||||||
|
EOF
|
||||||
|
python3 tools/analysis/29_packed_player.py "$DLX" > tmp/packed_player.log 2>&1 \
|
||||||
|
|| { tail -20 tmp/packed_player.log; exit 1; }
|
||||||
|
grep -aE "SKIP block PAIRS|free / DMAC->GVRAM / PACKED|^ CODEC, gate" \
|
||||||
|
tmp/packed_player.log
|
||||||
|
|
||||||
|
echo "--- session 31: the PACKED container, and the picture re-derived (FINDINGS 63) ---"
|
||||||
|
# ROADMAP K2. The container is REBUILT every run rather than reused when
|
||||||
|
# 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" --audio tmp/au_singe.raw > tmp/pack_encode.log 2>&1 \
|
||||||
|
|| { cat tmp/pack_encode.log; exit 1; }
|
||||||
|
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:
|
||||||
|
# round-trip, sector geometry, index 0 unused, palette words agree
|
||||||
|
# packed > the shipping codec / > the codec's CEILING / > a SCENE-palette control
|
||||||
|
# A tree where any of the last three flipped has a different answer to ROADMAP K
|
||||||
|
# and should say so out loud rather than let the branch keep building.
|
||||||
|
python3 tools/analysis/30_packed_container.py tmp/packed_singe.dlxp \
|
||||||
|
--codec "$DLX" > tmp/packed_container.log 2>&1 \
|
||||||
|
|| { cat tmp/packed_container.log; exit 1; }
|
||||||
|
sed -n '/^PSNR/,$p' tmp/packed_container.log | grep -aE "RGB888|CODEC|CEILING|CONTROL|PER-FRAME|packed vs|worth|costs"
|
||||||
|
# And the container's OWN BYTES through px68k's real gvram.c, with the harness
|
||||||
|
# computing no interleave -- the only test that can catch an encoder whose byte
|
||||||
|
# order is wrong, because the container round-trips against its own inverse
|
||||||
|
# either way. Same skip-not-fail rule as the C68K stage: px68k is not in here.
|
||||||
|
if [ -f "$PX68K/x68k/gvram.c" ]; then
|
||||||
|
make -s -C tools/bench/gvpack PX68K="$PX68K"
|
||||||
|
# Frame 0 carries the two negative controls; the other two are there because
|
||||||
|
# one frame rendering does not say the container's 120th record is placed
|
||||||
|
# right, and record placement is arithmetic this format has no index to check.
|
||||||
|
python3 tools/bench/gvpack/verify_dlxp.py tmp/packed_singe.dlxp 0 --controls || exit 1
|
||||||
|
for f in $((NF / 2)) $((NF - 1)); do
|
||||||
|
# NOT piped into head: `set -e` reads a pipeline's status from its LAST
|
||||||
|
# command, so a piped verifier that failed would be reported by head's zero.
|
||||||
|
python3 tools/bench/gvpack/verify_dlxp.py tmp/packed_singe.dlxp "$f" \
|
||||||
|
> "tmp/dlxp_f$f.log" 2>&1 || { cat "tmp/dlxp_f$f.log"; exit 1; }
|
||||||
|
head -1 "tmp/dlxp_f$f.log"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo " SKIPPED: no px68k at $PX68K -- the container's bytes were not rendered"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- session 32: the PACKED PLAYER, end to end off the disc (FINDINGS 64) ---"
|
||||||
|
# ROADMAP K3. src/player/packed.s brings up its own display, builds its own
|
||||||
|
# 193-entry DMA chain, keeps its own frame clock off V-DISP and fetches every
|
||||||
|
# record itself with READ(10) off a CZ-6BS1. This script writes no picture byte
|
||||||
|
# and no palette entry.
|
||||||
|
#
|
||||||
|
# WHY EVERY FRAME IS COMPARED AND THE CODEC'S GATE COMPARES ONE. The codec is
|
||||||
|
# temporally recursive -- a SKIP block is a claim about the previous frame -- so
|
||||||
|
# its last frame audits all 120. A packed frame is a LITERAL and frame 119 says
|
||||||
|
# nothing about frame 60. The simplification that deleted the ring also deleted
|
||||||
|
# the gate's free lunch.
|
||||||
|
#
|
||||||
|
# AND WHY IT IS PACED AT HALF RATE. The write window has to be OPEN for the
|
||||||
|
# whole transfer and buffer mode blanks the graphics layer, so at the
|
||||||
|
# container's own 12 fps there is no instant at which a complete frame is
|
||||||
|
# displayable and there is nothing to snapshot (FINDINGS 64.2 -- that is the
|
||||||
|
# session's finding, not a rig limitation being worked around). Half rate opens
|
||||||
|
# a display interval without changing one byte of the transfer.
|
||||||
|
#
|
||||||
|
# GATE ONLY: runs 2-4 of packed_run.sh measure the apparatus rather than gate
|
||||||
|
# it, and they are three more MAME jobs for numbers that cannot change unless
|
||||||
|
# MAME does. tools/bench/packed_run.sh with no DLX_PK_GATE_ONLY runs all four.
|
||||||
|
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; }
|
||||||
|
# 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/,/--- 7. THE SEEK/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
|
||||||
|
# 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.
|
||||||
|
# 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.
|
||||||
|
# An encoder that agrees with its own wrong decoder is the failure this catches.
|
||||||
|
python3 tools/bench/verify_adpcm.py tmp/au_singe.raw || exit 1
|
||||||
|
# And the container arithmetic. The interesting line is the padding: a packed
|
||||||
|
# record has no index BY DESIGN, so audio has to ride a fixed cadence, and the
|
||||||
|
# obvious cadence throws away a third of every audio sector.
|
||||||
|
python3 tools/analysis/32_audio_wire.py tmp/packed_singe.dlxp \
|
||||||
|
> tmp/audio_wire.log 2>&1 || { cat tmp/audio_wire.log; exit 1; }
|
||||||
|
grep -aE "^ ( 1| 11| 81) |THE FLOOR|F=1 |F=11|is ZERO|SOUND IS WHAT" tmp/audio_wire.log
|
||||||
|
|
||||||
|
echo "--- session 34: THE CHIP'S OWN DECODER, off the machine (FINDINGS 66) ---"
|
||||||
|
# ROADMAP P6a. 68000 code programs HD63450 channel 3 exactly as the IPL ROM
|
||||||
|
# programs it and feeds the MSM6258 a designed nibble stream at the chip's own
|
||||||
|
# pace; ONE of sixteen candidate decoder models reproduces MAME's capture
|
||||||
|
# sample-exact, and every axis has a negative control. The encoder disagreed
|
||||||
|
# with the chip on ALL FOUR axes, and the largest of them is not the delta
|
||||||
|
# formula 65 named -- it is the NIBBLE ORDER, at -25.7 dB.
|
||||||
|
bash tools/bench/adpcm_run.sh > tmp/adpcm_gate.log 2>&1 || {
|
||||||
|
cat tmp/adpcm_gate.log; exit 1; }
|
||||||
|
grep -aE "^(OK|FAIL) |^ (feed|nibbles|delta|clamp|accumulator)" tmp/adpcm_gate.log \
|
||||||
|
| sed 's/^/ /'
|
||||||
|
# And the bill, on the same ten seconds every other audio figure is quoted on.
|
||||||
|
python3 tools/analysis/33_adpcm_model.py tmp/au_singe.raw > tmp/adpcm_model.log 2>&1 \
|
||||||
|
|| { cat tmp/adpcm_model.log; exit 1; }
|
||||||
|
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 "--- session 37: THE AUDIO LEVEL, measured off the whole disc (FINDINGS 69) ---"
|
||||||
|
# ROADMAP P6, the item 66.3 reopened and two sessions deferred. The chip clamps
|
||||||
|
# its accumulator at 10 bits INSIDE the recursion, and the ten seconds every
|
||||||
|
# audio figure in this tree is quoted on peak at 435 of 511 -- which fits, and
|
||||||
|
# fits BY ACCIDENT, because that window is a -13.4 dBFS passage.
|
||||||
|
#
|
||||||
|
# So the level is measured against the loudest thing the game can play, which
|
||||||
|
# means every stream of the unique scene footage (00000-00201, FINDINGS 32.1)
|
||||||
|
# through extract_audio.py's own chain. The gate asserts the disc's peak and
|
||||||
|
# the census behind it; a different pressing is a legitimate reason for it to
|
||||||
|
# go red, a different ffmpeg downmix is not.
|
||||||
|
#
|
||||||
|
# ~18 s, and it needs the Blu-ray mounted like every other stage here.
|
||||||
|
python3 tools/analysis/35_audio_level.py --gate > tmp/audio_level.log 2>&1 \
|
||||||
|
|| { cat tmp/audio_level.log; exit 1; }
|
||||||
|
grep -aE "DISC PEAK|LOUDEST PASSAGE|THE CLAMP|LEVEL GATE|NO AUDIO TRACK|^ 1\.0000|^ 0\.5" \
|
||||||
|
tmp/audio_level.log
|
||||||
|
|
||||||
|
echo "--- session 39: THE SEEK, on the machine, with sound across it (FINDINGS 71) ---"
|
||||||
|
# Run 7 of packed_run.sh, split out of the same log for the same reason session
|
||||||
|
# 36's stage is split out of it: three verifiers in one file all emit OK lines,
|
||||||
|
# and one grep over the whole thing prints this session's result under session
|
||||||
|
# 31's heading.
|
||||||
|
if [ -f tmp/packed_gate_stage.log ] && \
|
||||||
|
grep -aq -- "--- 7. THE SEEK" tmp/packed_gate_stage.log; then
|
||||||
|
sed -n '/--- 7. THE SEEK/,$p' tmp/packed_gate_stage.log | \
|
||||||
|
grep -aE "^(OK|FAIL) |^ {4}-- the chip|^ {5}(the chip was|error against|-> )|DC +-?[0-9.]+ +AC"
|
||||||
|
else
|
||||||
|
echo " SKIPPED: no x68000 romset, or the container is silent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- session 39: the audio seek path, run across a real branch (FINDINGS 71) ---"
|
||||||
|
# ROADMAP P6d, and it is the item session 38's handoff put first: 70.3 named
|
||||||
|
# what was missing -- "src/player/packed.s starts PG_AK/PG_AKF at lump 0 and has
|
||||||
|
# no audio seek path at all" -- and priced its absence at a mean 416.5 ms of
|
||||||
|
# silence over the arcade's 409 within-container seek targets.
|
||||||
|
#
|
||||||
|
# The MACHINE half of this is run 7 of tools/bench/packed_run.sh, above: two
|
||||||
|
# passes over the container with the second starting at frame 37, which is four
|
||||||
|
# frames into lump 3 and therefore NOT on a group boundary, so the byte offset
|
||||||
|
# inside the lump is load-bearing. It runs in both chip configurations.
|
||||||
|
#
|
||||||
|
# THIS STAGE IS THE HOST HALF, and what it adds is the census the one branch
|
||||||
|
# point on the machine cannot give. The chip's accumulator is an integrator with
|
||||||
|
# NO LEAKAGE TERM, so the state error a branch creates is a DC offset that never
|
||||||
|
# decays -- and the only fix that makes a branch free is in the ENCODER, not the
|
||||||
|
# player. Both are priced here, and section 1 checks the host's arithmetic for
|
||||||
|
# the machine's own branch point against what MAME's capture measured.
|
||||||
|
python3 tools/analysis/37_audio_seek.py > tmp/audio_seek.log 2>&1 \
|
||||||
|
|| { cat tmp/audio_seek.log; exit 1; }
|
||||||
|
grep -aE "^(OK|FAIL) |RE-PLAYED BRANCH|\|DC\| against|step index the encoder|frame 37, the branch|reset every|never \(shipped\)|frames \(the cadence\)|1 frame |ONLY FIX" \
|
||||||
|
tmp/audio_seek.log
|
||||||
|
|
||||||
|
echo "--- session 38: the refill climb with a second consumer, through a real branch point (FINDINGS 70) ---"
|
||||||
|
# ROADMAP P6, the oldest item in it: 65.6 and 67.6 both recorded that the slack
|
||||||
|
# table existed and that 51.3's climb had never met a branch point with audio on
|
||||||
|
# the wire. This is that run. It needs the scene graph, so it skips with the
|
||||||
|
# session-24 stage when there is no checkout.
|
||||||
|
#
|
||||||
|
# WHAT IS GATED IS STRUCTURAL, and deliberately not the milliseconds: the
|
||||||
|
# silences move with the scene table and with the one-container-per-scene
|
||||||
|
# assumption the tool prints in its own section 5. What must not move is the
|
||||||
|
# ORDER and the SIGNS -- audio never shortens a climb, the shipped cadence
|
||||||
|
# strands most within-container branch points off a group boundary, F=1 strands
|
||||||
|
# none, and the lump read that removes the silence is an order of magnitude
|
||||||
|
# cheaper than the silence. A tree where any of those flipped has a different
|
||||||
|
# answer to the cadence pick.
|
||||||
|
if [ -f "$DIRKSIMPLE/data/games/lair/game.lua" ]; then
|
||||||
|
python3 tools/analysis/36_branch_audio.py --gate \
|
||||||
|
> tmp/branch_audio.log 2>&1 || { cat tmp/branch_audio.log; exit 1; }
|
||||||
|
grep -aE "^ (OK|FAIL) |SECOND CONSUMER IS|BRANCH-AUDIO GATE|^ mean |^ free " \
|
||||||
|
tmp/branch_audio.log
|
||||||
|
else
|
||||||
|
echo " SKIPPED: no DirkSimple checkout at $DIRKSIMPLE"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "ALL GREEN"
|
echo "ALL GREEN"
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
-- Drive src/player/clockgate.s: measure the 68000's own FRAME CLOCK.
|
||||||
|
-- ROADMAP P3.
|
||||||
|
--
|
||||||
|
-- Two things are being measured and they need different instruments.
|
||||||
|
--
|
||||||
|
-- THE RATE AND THE CADENCE are counted, not timed. The clock's tick is a
|
||||||
|
-- V-DISP interrupt, and MAME's Lua sees the machine once per screen frame --
|
||||||
|
-- which is once per V-DISP. So the host's sampling granularity is exactly the
|
||||||
|
-- clock's own granularity, and the cadence comes out as integers: how many
|
||||||
|
-- refreshes each frame tick waited. There is no timing error to argue about
|
||||||
|
-- in a count of 4s and 5s.
|
||||||
|
--
|
||||||
|
-- THE COST IS TIMED BY THE 68000, because the host cannot. 1/55.46 s of host
|
||||||
|
-- granularity is 18 ms and the interrupt costs microseconds. So the 68000 runs
|
||||||
|
-- a one-instruction loop for a window of thousands of refreshes and the host
|
||||||
|
-- reads the iteration count at both ends; the interrupt cost falls out of the
|
||||||
|
-- difference between a run with the clock armed and one without. See the head
|
||||||
|
-- of src/player/clockgate.s for the arithmetic. This script emits the raw
|
||||||
|
-- counts; tools/bench/clock_cost.py does the subtraction, so that the two runs
|
||||||
|
-- it needs can be separate MAME invocations.
|
||||||
|
--
|
||||||
|
-- MEASUREMENT SCOPE. This is MAME 0.277's emulated X68000, not real hardware.
|
||||||
|
-- What is being priced is the interrupt sequence of MAME's cycle-accurate
|
||||||
|
-- M68000 core (src/devices/cpu/m68000, the `M68000` device x68k.cpp:1133 asks
|
||||||
|
-- for) against zero-wait-state RAM. Real DRAM adds wait states to the six bus
|
||||||
|
-- cycles of the exception and the four of the handler alike, so this is a LOWER
|
||||||
|
-- BOUND in the same way every other 68000 figure in this project is.
|
||||||
|
--
|
||||||
|
-- Env:
|
||||||
|
-- DLX_CLK_ON 1 = arm the frame clock, 0 = leave it off (the calibration
|
||||||
|
-- run). REQUIRED -- the two runs are not interchangeable and a
|
||||||
|
-- default would let one be reported as the other.
|
||||||
|
-- DLX_CLK_FPS frame rate to ask clk_init for (default 12)
|
||||||
|
-- DLX_CLK_WIN measurement window, in raster frames (default 3000 = 54.1 s)
|
||||||
|
-- DLX_CLK_OUT where to write the raw counts (default tmp/clock_run.txt)
|
||||||
|
|
||||||
|
M = manager.machine
|
||||||
|
SP = M.devices[":maincpu"].spaces["program"]
|
||||||
|
|
||||||
|
local function findfile(n)
|
||||||
|
for _,p in ipairs{"../tools/bench/"..n, "tools/bench/"..n, n} do
|
||||||
|
local f = io.open(p,"rb"); if f then f:close(); return p end
|
||||||
|
end
|
||||||
|
error(n.." not found")
|
||||||
|
end
|
||||||
|
local MODE = loadfile(findfile("crtc_mode.lua"))()
|
||||||
|
|
||||||
|
local CGFLAG, CGON, CGCNT = 0x18070, 0x18074, 0x18078
|
||||||
|
local CLK_PACE = 0x18034
|
||||||
|
local CLK_ACC, CLK_INCR = 0x18060, 0x18062
|
||||||
|
local CLK_VDISP, CLK_FPS = 0x18064, 0x18068
|
||||||
|
local CLK_ERR = 0x1806C
|
||||||
|
local CPUHZ = 10000000
|
||||||
|
|
||||||
|
local ONS = os.getenv("DLX_CLK_ON")
|
||||||
|
local FPS = tonumber(os.getenv("DLX_CLK_FPS") or "") or 12
|
||||||
|
local WIN = tonumber(os.getenv("DLX_CLK_WIN") or "") or 3000
|
||||||
|
local OUT = os.getenv("DLX_CLK_OUT") or "clock_run.txt"
|
||||||
|
|
||||||
|
local function P(s) print("[CLK] "..s) end
|
||||||
|
|
||||||
|
if ONS ~= "0" and ONS ~= "1" then
|
||||||
|
P("DLX_CLK_ON must be 0 (calibration, clock off) or 1 (clock armed). The "
|
||||||
|
.."cost figure is the DIFFERENCE between the two runs, so neither is "
|
||||||
|
.."meaningful alone and neither gets to be the default.")
|
||||||
|
M:exit()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local ON = (ONS == "1")
|
||||||
|
|
||||||
|
local code do local f=io.open("clockgate.bin","rb"); code=f:read("a"); f:close() end
|
||||||
|
|
||||||
|
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
|
||||||
|
|
||||||
|
-- Settling frames between the gate reporting `running` and the window opening.
|
||||||
|
-- The CPU may still be inside clk_init when the host first sees CGFLAG=1, and
|
||||||
|
-- the first V-DISP edge after arming lands wherever the raster happens to be.
|
||||||
|
-- Two frames puts the window entirely inside the steady state.
|
||||||
|
local SETTLE = 2
|
||||||
|
|
||||||
|
local st, n = "boot", 0
|
||||||
|
local f_ready, f0, f1 = nil, nil, nil
|
||||||
|
local c0, c1, v0, v1, p0, p1, t0, t1
|
||||||
|
-- Cadence: refreshes between consecutive frame ticks. Recorded as a histogram
|
||||||
|
-- and as the raw first few, because the interesting claim is not the mean (the
|
||||||
|
-- divider makes that exact by construction) but that the SPREAD is only ever
|
||||||
|
-- the two values either side of fps*VTOTAL/HFREQ.
|
||||||
|
local last_pace, last_pace_f, cad, seen_tick = nil, nil, {}, false
|
||||||
|
|
||||||
|
SUB = emu.add_machine_frame_notifier(function()
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
n = n + 1
|
||||||
|
if st == "boot" then
|
||||||
|
if T() < 3.0 then return end
|
||||||
|
MODE.apply(SP)
|
||||||
|
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
|
||||||
|
SP:write_u32(CGFLAG, 0)
|
||||||
|
SP:write_u32(CGON, ON and 1 or 0)
|
||||||
|
SP:write_u32(CLK_FPS, FPS)
|
||||||
|
local cpu = M.devices[":maincpu"]
|
||||||
|
cpu.state["SR"].value = 0x2700 -- clk_init lowers it to $2500 itself
|
||||||
|
cpu.state["SP"].value = 0x8000
|
||||||
|
cpu.state["PC"].value = 0x10000
|
||||||
|
P(string.format("clockgate.bin=%d B, clock %s, asking for %d fps, "
|
||||||
|
.."window %d raster frames", #code,
|
||||||
|
ON and "ARMED" or "OFF (calibration run)", FPS, WIN))
|
||||||
|
st = "wait"; return
|
||||||
|
end
|
||||||
|
if st == "wait" then
|
||||||
|
local fl = SP:read_u32(CGFLAG)
|
||||||
|
if fl == 0xEE then
|
||||||
|
local e = SP:read_u32(CLK_ERR)
|
||||||
|
P("clk_init REFUSED: CLK_ERR="..e..(e == 1 and
|
||||||
|
" (CRTC is not in a 31.5 kHz mode, so HFREQ=31500 would be wrong)" or
|
||||||
|
e == 2 and " (fps*VTOTAL does not fit the 16-bit accumulator)" or ""))
|
||||||
|
M:exit(); return
|
||||||
|
end
|
||||||
|
if fl ~= 1 then
|
||||||
|
if T() > 60 then P("TIMEOUT: the gate never started"); M:exit() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
f_ready = n; st = "settle"; return
|
||||||
|
end
|
||||||
|
if st == "settle" then
|
||||||
|
if n < f_ready + SETTLE then return end
|
||||||
|
f0, t0 = n, T()
|
||||||
|
c0 = SP:read_u32(CGCNT)
|
||||||
|
v0 = SP:read_u32(CLK_VDISP)
|
||||||
|
p0 = SP:read_u32(CLK_PACE)
|
||||||
|
last_pace, last_pace_f = p0, n
|
||||||
|
if ON then
|
||||||
|
P(string.format("armed: incr=%d (fps*VTOTAL), acc=%d, first tick "
|
||||||
|
.."pending", SP:read_u16(CLK_INCR),
|
||||||
|
SP:read_u16(CLK_ACC)))
|
||||||
|
end
|
||||||
|
st = "run"; return
|
||||||
|
end
|
||||||
|
if st == "run" then
|
||||||
|
if ON then
|
||||||
|
local pc = SP:read_u32(CLK_PACE)
|
||||||
|
if pc ~= last_pace then
|
||||||
|
-- The FIRST change is dropped. Its interval runs from the window
|
||||||
|
-- opening rather than from a tick, so it measures where the window
|
||||||
|
-- happened to start and would show up as a spurious short bucket.
|
||||||
|
if seen_tick then
|
||||||
|
-- More than one tick in a single refresh would mean fps above the
|
||||||
|
-- raster rate; give it its own bucket rather than averaging it in.
|
||||||
|
local gap = n - last_pace_f
|
||||||
|
if pc - last_pace > 1 then gap = 0 end
|
||||||
|
cad[gap] = (cad[gap] or 0) + 1
|
||||||
|
end
|
||||||
|
seen_tick = true
|
||||||
|
last_pace, last_pace_f = pc, n
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n < f0 + WIN then return end
|
||||||
|
f1, t1 = n, T()
|
||||||
|
c1 = SP:read_u32(CGCNT)
|
||||||
|
v1 = SP:read_u32(CLK_VDISP)
|
||||||
|
p1 = SP:read_u32(CLK_PACE)
|
||||||
|
st = "done"
|
||||||
|
|
||||||
|
local frames = f1 - f0
|
||||||
|
local secs = t1 - t0
|
||||||
|
local clocks = secs * CPUHZ
|
||||||
|
local iters = c1 - c0
|
||||||
|
local ints = v1 - v0
|
||||||
|
local ticks = p1 - p0
|
||||||
|
P(string.format("window: %d raster frames, %.6f s emulated -> %.0f "
|
||||||
|
.."68000 clocks", frames, secs, clocks))
|
||||||
|
-- THE INSTRUMENT IS 2.22% FAST AND IT IS WORTH SAYING SO EVERY RUN.
|
||||||
|
-- The CRTC registers describe a 31,500 lines/s raster of VTOTAL lines.
|
||||||
|
-- MAME does not run it at that rate: x68k_crtc.cpp refresh_mode()
|
||||||
|
-- computes the frame period as (scr.max_x * scr.max_y) dots with
|
||||||
|
-- scr.max_x = m_htotal - 8, one character cell short and an INCLUSIVE
|
||||||
|
-- rectangle bound used as a count. So the emulated raster is fast by
|
||||||
|
-- htotal/(htotal-8) -- 368/360 in this mode -- and every rate derived
|
||||||
|
-- from it here is fast by the same factor. The divider under test is
|
||||||
|
-- built on the registers, so its HARDWARE rate is the asked-for one and
|
||||||
|
-- what this rig can check is that it tracks whatever raster it is given.
|
||||||
|
local vtotal = SP:read_u16(0xE80008) + 1
|
||||||
|
local htotal = (SP:read_u16(0xE80000) + 1) * 8
|
||||||
|
local hw_hz = 31500 / vtotal
|
||||||
|
local skew = htotal / (htotal - 8)
|
||||||
|
P(string.format(" raster period %.4f ms = %.4f Hz", 1000*secs/frames,
|
||||||
|
frames/secs))
|
||||||
|
P(string.format(" the CRTC registers describe 31500/%d = %.4f Hz; "
|
||||||
|
.."MAME is fast by htotal/(htotal-8) = %d/%d = %.4f",
|
||||||
|
vtotal, hw_hz, htotal, htotal-8, skew))
|
||||||
|
P(string.format(" loop iterations %d", iters))
|
||||||
|
if ON then
|
||||||
|
P(string.format(" V-DISP interrupts %d, frame ticks %d", ints,
|
||||||
|
ticks))
|
||||||
|
-- The self-check that makes the rest of it worth reading: the interrupt
|
||||||
|
-- count and the host's screen-frame count are supposed to be the SAME
|
||||||
|
-- clock seen from two sides. If they disagree by more than the one
|
||||||
|
-- edge the window boundaries can straddle, the tick is not the raster.
|
||||||
|
if math.abs(ints - frames) > 1 then
|
||||||
|
P(string.format("FAIL: %d V-DISP interrupts over %d raster frames. "
|
||||||
|
.."The tick is not coming from the raster.", ints,
|
||||||
|
frames))
|
||||||
|
M:exit(); return
|
||||||
|
end
|
||||||
|
-- Two numbers, and confusing them is the whole trap. The measured rate
|
||||||
|
-- is against MAME's fast raster; dividing the skew out gives the rate
|
||||||
|
-- the same code produces on a machine whose raster matches its own
|
||||||
|
-- registers, which is the number the player is judged on.
|
||||||
|
local meas = ticks/secs
|
||||||
|
P(string.format(" measured rate %.6f fps against MAME's raster "
|
||||||
|
.."(%+.0f ppm vs the asked %d)", meas,
|
||||||
|
1e6*(meas/FPS - 1), FPS))
|
||||||
|
P(string.format(" de-skewed %.6f fps -> %+.1f ppm from %d, "
|
||||||
|
.."which is the tick quantisation of %d ticks and not "
|
||||||
|
.."drift", meas/skew, 1e6*(meas/skew/FPS - 1), FPS,
|
||||||
|
ticks))
|
||||||
|
local ks = {}
|
||||||
|
for k in pairs(cad) do ks[#ks+1] = k end
|
||||||
|
table.sort(ks)
|
||||||
|
local s = ""
|
||||||
|
for _,k in ipairs(ks) do
|
||||||
|
s = s .. string.format("%d:%d ", k, cad[k])
|
||||||
|
end
|
||||||
|
P(" cadence, refreshes per frame tick: "..s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local fh = io.open(OUT, "w")
|
||||||
|
fh:write(string.format("on %d\nfps %d\nframes %d\nsecs %.15g\n"
|
||||||
|
.."clocks %.15g\niters %d\nints %d\nticks %d\n"
|
||||||
|
.."vtotal %d\nhtotal %d\nhw_hz %.15g\nskew %.15g\n",
|
||||||
|
ON and 1 or 0, FPS, frames, secs, clocks, iters,
|
||||||
|
ints, ticks, vtotal, htotal, hw_hz, skew))
|
||||||
|
for k, v in pairs(cad) do fh:write(string.format("cad %d %d\n", k, v)) end
|
||||||
|
fh:close()
|
||||||
|
P("counts -> "..OUT)
|
||||||
|
P("done")
|
||||||
|
M:exit(); return
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if not ok then print("[CLK] LUA ERROR: "..tostring(err)); M:exit() end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""What the 68000's own frame clock costs, out of the two clock.lua runs.
|
||||||
|
|
||||||
|
ROADMAP P3. Usage: clock_cost.py <off-run.txt> <on-run.txt>
|
||||||
|
|
||||||
|
THE SUBTRACTION. Both runs execute the same one-instruction loop over a window
|
||||||
|
of the same number of raster frames, so the window is the same number of 68000
|
||||||
|
clocks in both. With the clock off, every clock in the window went into loop
|
||||||
|
iterations:
|
||||||
|
|
||||||
|
L = clocks / iters_off clocks per iteration
|
||||||
|
|
||||||
|
With it armed, the interrupts took some of them:
|
||||||
|
|
||||||
|
H = (clocks - iters_on * L) / ints clocks per V-DISP interrupt
|
||||||
|
|
||||||
|
L is CALIBRATED rather than looked up. That is the point: this project's cost
|
||||||
|
model (tools/analysis/buscost.py) says a 68000 bus cycle is 4 clocks and an
|
||||||
|
instruction costs 4 * (instruction words + data accesses), and the whole reason
|
||||||
|
to measure is to avoid scoring the clock against the table the table is meant to
|
||||||
|
be checked by. L falling on a whole number of clocks is therefore a RESULT, not
|
||||||
|
an assumption, and it is reported as one.
|
||||||
|
|
||||||
|
WHAT THE FIGURE IS PER FRAME. Not H -- the interrupt fires once per refresh and
|
||||||
|
a frame is several refreshes. On the hardware raster that is 31500/VTOTAL over
|
||||||
|
fps interrupts per frame, and the de-skewed rate is the one to use: MAME's
|
||||||
|
raster is fast by htotal/(htotal-8) (see tools/bench/clock.lua), and charging
|
||||||
|
the player the emulator's extra interrupts would overstate the cost by that
|
||||||
|
same 2.2%.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
d, cad = {}, {}
|
||||||
|
for line in open(path):
|
||||||
|
f = line.split()
|
||||||
|
if f[0] == "cad":
|
||||||
|
cad[int(f[1])] = int(f[2])
|
||||||
|
else:
|
||||||
|
d[f[0]] = float(f[1])
|
||||||
|
d["cad"] = cad
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def main(off_path, on_path):
|
||||||
|
off, on = read(off_path), read(on_path)
|
||||||
|
if off["on"] != 0 or on["on"] != 1:
|
||||||
|
sys.exit("FAIL: expected the calibration run first and the armed run "
|
||||||
|
"second; got on=%d then on=%d" % (off["on"], on["on"]))
|
||||||
|
for k in ("frames", "clocks", "fps", "vtotal"):
|
||||||
|
if off[k] != on[k]:
|
||||||
|
sys.exit("FAIL: the two runs do not share a window: %s is %g in "
|
||||||
|
"the calibration run and %g in the armed one"
|
||||||
|
% (k, off[k], on[k]))
|
||||||
|
|
||||||
|
clocks = off["clocks"]
|
||||||
|
L = clocks / off["iters"]
|
||||||
|
ints = on["ints"]
|
||||||
|
H = (clocks - on["iters"] * L) / ints
|
||||||
|
|
||||||
|
# The self-check that licenses the subtraction: the interrupt count must be
|
||||||
|
# the raster frame count. clock.lua already fails on this, restated here
|
||||||
|
# because this file is also read on its own.
|
||||||
|
if abs(ints - on["frames"]) > 1:
|
||||||
|
sys.exit("FAIL: %d interrupts over %g raster frames -- not the raster"
|
||||||
|
% (ints, on["frames"]))
|
||||||
|
|
||||||
|
fps, skew = on["fps"], on["skew"]
|
||||||
|
hw_hz = on["hw_hz"]
|
||||||
|
per_frame_ints = hw_hz / fps
|
||||||
|
per_frame = H * per_frame_ints
|
||||||
|
FRAME_CLK = 10e6 / fps
|
||||||
|
|
||||||
|
print(" calibration: %.6f clocks per loop iteration over %d iterations"
|
||||||
|
% (L, off["iters"]))
|
||||||
|
print(" (%s a whole number of clocks -- the loop is one "
|
||||||
|
"`addq.l #1,abs.l` at 7 bus cycles plus a `bra.s`)"
|
||||||
|
% ("lands on" if abs(L - round(L)) < 1e-3 else "does NOT land on"))
|
||||||
|
print(" INTERRUPT: %.2f clocks per V-DISP, measured over %d of them"
|
||||||
|
% (H, ints))
|
||||||
|
print(" PER FRAME: %.2f interrupts x %.2f = %.0f clocks = %.4f%% of a "
|
||||||
|
"%g fps frame" % (per_frame_ints, H, per_frame,
|
||||||
|
100 * per_frame / FRAME_CLK, fps))
|
||||||
|
print(" (%.4f refreshes per frame on the HARDWARE raster of "
|
||||||
|
"31500/%d = %.4f Hz, not on MAME's, which is %.4fx fast)"
|
||||||
|
% (per_frame_ints, on["vtotal"], hw_hz, skew))
|
||||||
|
|
||||||
|
# THE DRIFT GATE, and it is stated in TICKS rather than in ppm on purpose.
|
||||||
|
# A remainder-keeping divider emits floor() or ceil() of the exact tick
|
||||||
|
# count over any window and never accumulates -- so the only honest
|
||||||
|
# tolerance is one tick, and any ppm figure is that one tick divided by
|
||||||
|
# however long the window happened to be. Quoting ppm would let a longer
|
||||||
|
# window advertise a tighter clock for no reason.
|
||||||
|
want = on["frames"] * fps * on["vtotal"] / 31500.0
|
||||||
|
ticks = on["ticks"]
|
||||||
|
print(" DRIFT: %d ticks over %d refreshes; exact is %.4f, so the "
|
||||||
|
"error is %+.4f ticks" % (ticks, on["frames"], want, ticks - want))
|
||||||
|
if abs(ticks - want) > 1.0:
|
||||||
|
sys.exit("FAIL: %d ticks where %.4f were due -- off by %.2f, which is "
|
||||||
|
"more than the one tick a remainder can hold back. The "
|
||||||
|
"divider is accumulating drift." % (ticks, want, ticks - want))
|
||||||
|
|
||||||
|
cad = on["cad"]
|
||||||
|
tot = sum(cad.values())
|
||||||
|
if tot:
|
||||||
|
# Refreshes per frame is 31500 / (fps * VTOTAL) exactly -- the divider's
|
||||||
|
# own ratio, upside down. A remainder-keeping divider can only ever
|
||||||
|
# emit the two whole numbers either side of it, so anything else in the
|
||||||
|
# histogram is a bug in the divider and not a rounding taste.
|
||||||
|
rpf = 31500.0 / (fps * on["vtotal"])
|
||||||
|
lo, hi = int(rpf), int(rpf) + 1
|
||||||
|
print(" CADENCE: %s (%d intervals; %.4f refreshes per frame, so "
|
||||||
|
"only %d and %d are possible)"
|
||||||
|
% (", ".join("%dx%d (%.1f%%)" % (k, v, 100.0 * v / tot)
|
||||||
|
for k, v in sorted(cad.items())), tot, rpf, lo, hi))
|
||||||
|
for k in cad:
|
||||||
|
if k not in (lo, hi):
|
||||||
|
sys.exit("FAIL: a frame tick waited %d refreshes, which a "
|
||||||
|
"remainder-keeping divider cannot produce" % k)
|
||||||
|
# The mix is forced too: lo*a + hi*b = refreshes, a + b = ticks.
|
||||||
|
b = tot * rpf - lo * tot
|
||||||
|
print(" expected %d:%d split %.1f%% / %.1f%%, got "
|
||||||
|
"%.1f%% / %.1f%%"
|
||||||
|
% (lo, hi, 100 * (tot - b) / tot, 100 * b / tot,
|
||||||
|
100.0 * cad.get(lo, 0) / tot, 100.0 * cad.get(hi, 0) / tot))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
sys.exit(main(sys.argv[1], sys.argv[2]))
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# One frame-clock run: the 68000 derives its own 12 fps tick from the raster
|
||||||
|
# (ROADMAP P3, FINDINGS 54).
|
||||||
|
#
|
||||||
|
# tools/bench/clock_run.sh [window-in-raster-frames] [fps]
|
||||||
|
#
|
||||||
|
# TWO MAME INVOCATIONS, and they are not interchangeable. The first leaves the
|
||||||
|
# clock off and calibrates the cost of the gate's own loop; the second arms it.
|
||||||
|
# The interrupt cost is the difference, so a run that reported only the second
|
||||||
|
# would be reporting a number it cannot compute. See src/player/clockgate.s.
|
||||||
|
#
|
||||||
|
# Only MAME can run this: the frame clock is an MFP interrupt driven by the
|
||||||
|
# CRTC's V-DISP output, and tools/bench/c68k has neither device. That is why
|
||||||
|
# this stage has no second-core half, unlike load_run.sh.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
WIN=${1:-3000}
|
||||||
|
FPS=${2:-12}
|
||||||
|
|
||||||
|
tools/vasm/vasmm68k_mot -Fbin -o tmp/clockgate.bin src/player/clockgate.s > /dev/null
|
||||||
|
|
||||||
|
for ON in 0 1; do
|
||||||
|
# stdbuf -oL: without it a long MAME run is unobservable until it exits, and
|
||||||
|
# a run that is merely finishing looks exactly like one that is wedged (34.1).
|
||||||
|
( cd tmp && DLX_CLK_ON=$ON DLX_CLK_FPS=$FPS DLX_CLK_WIN=$WIN \
|
||||||
|
DLX_CLK_OUT=clock_$ON.txt SDL_VIDEODRIVER=dummy stdbuf -oL \
|
||||||
|
timeout -k 5 600 mame x68000 -bios ipl10 -ramsize 2M -video soft -window \
|
||||||
|
-sound none -nothrottle -plugins -autoboot_script ../tools/bench/clock.lua \
|
||||||
|
-seconds_to_run 240 > clock_$ON.log 2>&1 )
|
||||||
|
# A run that never reached the counts must fail as that, not as bad arithmetic.
|
||||||
|
grep -q "^\[CLK\] done" tmp/clock_$ON.log || {
|
||||||
|
echo "FAIL: the clock rig did not finish run ON=$ON -- no completion marker."
|
||||||
|
tail -8 tmp/clock_$ON.log; exit 1; }
|
||||||
|
done
|
||||||
|
grep -a "^\[CLK\]" tmp/clock_1.log | sed -n '/window:/,/cadence/p' | sed 's/\[CLK\] / /'
|
||||||
|
python3 tools/bench/clock_cost.py tmp/clock_0.txt tmp/clock_1.txt
|
||||||
@@ -23,6 +23,26 @@
|
|||||||
-- Total blanking time is identical to the 768 mode (112 dots @ 11.592MHz =
|
-- Total blanking time is identical to the 768 mode (112 dots @ 11.592MHz =
|
||||||
-- 336 dots @ 34.776MHz = 9.66us), which is what a real monitor needs.
|
-- 336 dots @ 34.776MHz = 9.66us), which is what a real monitor needs.
|
||||||
--
|
--
|
||||||
|
-- THE EMULATOR DOES NOT RUN THE RASTER THESE REGISTERS DESCRIBE, and every
|
||||||
|
-- rig in this tree samples the machine at ITS rate, not at the hardware's.
|
||||||
|
-- x68k_crtc.cpp refresh_mode() builds the frame period as
|
||||||
|
--
|
||||||
|
-- (scr.max_x * scr.max_y) dots / dotclock, scr.max_x = m_htotal - 8
|
||||||
|
--
|
||||||
|
-- which is one character cell short AND uses an inclusive rectangle bound as a
|
||||||
|
-- count. So MAME's refresh is fast by htotal/(htotal-8) = 368/360 = 1.02222:
|
||||||
|
-- 56.6901 Hz where the registers say 55.4577. MEASURED, not read off the
|
||||||
|
-- source alone -- tools/bench/clock.lua reports both every run, and they agree
|
||||||
|
-- to six digits (FINDINGS 54.5).
|
||||||
|
--
|
||||||
|
-- It matters in exactly two places and is harmless in the rest. Anything timed
|
||||||
|
-- by counting host frames has 1/56.69 s of granularity, not 1/55.46; and
|
||||||
|
-- anything PACED by the raster runs 2.22% fast under MAME. It does NOT touch
|
||||||
|
-- 68000 cycle figures: the CPU clock is 40 MHz/4 and has nothing to do with the
|
||||||
|
-- screen. Do not "correct" the 55.4577 below to match a measurement -- it is
|
||||||
|
-- the hardware's, derived from the dot clocks above, and it is what
|
||||||
|
-- src/player/clock.i builds its divider on.
|
||||||
|
--
|
||||||
-- VERTICAL registers are NOT halved. The CRTC still generates a 568-line
|
-- VERTICAL registers are NOT halved. The CRTC still generates a 568-line
|
||||||
-- 31.5kHz raster (31500/568 = 55.46 Hz); "256 lines" is a graphics-layer
|
-- 31.5kHz raster (31500/568 = 55.46 Hz); "256 lines" is a graphics-layer
|
||||||
-- double-scan (draw_gfx() halves gfxrect, x68k_v.cpp:401). Halving them would
|
-- double-scan (draw_gfx() halves gfxrect, x68k_v.cpp:401). Halving them would
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ end
|
|||||||
|
|
||||||
-- The plan: one sequential correctness pass, then the cost anchors, then a
|
-- The plan: one sequential correctness pass, then the cost anchors, then a
|
||||||
-- full pass timed. Iteration counts target ~4 emulated seconds each so the
|
-- full pass timed. Iteration counts target ~4 emulated seconds each so the
|
||||||
-- 1/55.46 s timing granularity costs under 0.5%.
|
-- 1/56.69 s timing granularity (crtc_mode.lua) costs under 0.5%.
|
||||||
-- DLX_VERIFY_ONLY=1 drops the cost anchors and runs only the correctness pass,
|
-- DLX_VERIFY_ONLY=1 drops the cost anchors and runs only the correctness pass,
|
||||||
-- so tools/bench/check.sh can gate the decoder without paying for ~2 minutes of
|
-- so tools/bench/check.sh can gate the decoder without paying for ~2 minutes of
|
||||||
-- timing runs that would make the green light sensitive to host load anyway.
|
-- timing runs that would make the green light sensitive to host load anyway.
|
||||||
@@ -157,6 +157,11 @@ SUB = emu.add_machine_frame_notifier(function()
|
|||||||
M.video:snapshot()
|
M.video:snapshot()
|
||||||
P("snapshot taken after the sequential pass -- last frame, 68000-decoded")
|
P("snapshot taken after the sequential pass -- last frame, 68000-decoded")
|
||||||
step = step + 1
|
step = step + 1
|
||||||
|
-- DLX_VERIFY_ONLY leaves nothing after the correctness pass, and this
|
||||||
|
-- used to walk off the end of PLAN and raise a Lua error AFTER the
|
||||||
|
-- snapshot was already on disk -- harmless to check.sh, and exactly the
|
||||||
|
-- kind of thing that gets mistaken for a decoder failure later.
|
||||||
|
if not PLAN[step] then st = "finish"; return end
|
||||||
launch(PLAN[step].off, PLAN[step].nfr, PLAN[step].iter)
|
launch(PLAN[step].off, PLAN[step].nfr, PLAN[step].iter)
|
||||||
st, t0 = "running", nil; return
|
st, t0 = "running", nil; return
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Load-time transforms every src/player/ front-end's loader has to do.
|
||||||
|
|
||||||
|
Split out of prep_dlx.py in session 18 so that prep_dlx.py (the preloaded-stream
|
||||||
|
rig) and prep_stream.py (the ring-buffer streaming rig, FINDINGS 49) share ONE
|
||||||
|
copy of them. Two copies would drift, and the drift would be silent: both rigs
|
||||||
|
would still decode, and only the colours or the codebook scaling would be
|
||||||
|
subtly wrong in one of them.
|
||||||
|
|
||||||
|
The split is a no-op by construction -- tools/bench/check.sh asserts prep_dlx.py
|
||||||
|
still emits a byte-identical blob for the gate container.
|
||||||
|
|
||||||
|
Neither transform is part of the per-frame cost being measured. The 68000 would
|
||||||
|
do both once at load time; charging them to the inner loop would flatter or damn
|
||||||
|
it for no reason.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def expand_codebooks(d):
|
||||||
|
"""CB1/CB4 to one WORD per pixel, so the inner loop movems them straight out.
|
||||||
|
|
||||||
|
The high byte of every GVRAM word write is discarded by the hardware, so it
|
||||||
|
is left zero and never has to be cleared. Word-per-pixel form is also what
|
||||||
|
makes index scaling a shift rather than a multiply: lsl.w #5 and lsl.w #3.
|
||||||
|
"""
|
||||||
|
cb1 = np.zeros((d.k1, 16, 2), np.uint8); cb1[:, :, 1] = d.cb1.reshape(d.k1, 16)
|
||||||
|
cb4 = np.zeros((d.k4, 4, 2), np.uint8); cb4[:, :, 1] = d.cb4.reshape(d.k4, 4)
|
||||||
|
return cb1, cb4
|
||||||
|
|
||||||
|
|
||||||
|
def pack_palette(d):
|
||||||
|
"""24-bit palette -> GGGGGRRRRRBBBBBI, shared LSB chosen PER ENTRY.
|
||||||
|
|
||||||
|
Choosing I per entry by minimum squared error rather than fixing it is worth
|
||||||
|
1.96 dB (FINDINGS 23.3). Identical maths to tools/bench/verify_frame256.py,
|
||||||
|
which is the point: the verifier and the loader must agree or a colour bug
|
||||||
|
reads as a decoder bug.
|
||||||
|
|
||||||
|
Returns (palette bytes 256x2 big-endian, index of the darkest entry, and the
|
||||||
|
RGB888 the hardware actually RENDERS from those words). The encoder does not
|
||||||
|
reserve a black entry in the CODEC container (docs/STATUS.md, encoder gaps),
|
||||||
|
so the letterbox gets the closest thing to black the palette has; the PACKED
|
||||||
|
container does reserve one (tools/encoder/dlxp.py, index 255).
|
||||||
|
|
||||||
|
`d` is a DLX container OR a bare (256,3) uint8 palette. The packed path has
|
||||||
|
no codebooks and so no DLX object to carry a palette on, and this had to stay
|
||||||
|
the ONE copy of the GRB555+I maths -- the verifier, the loader and now the
|
||||||
|
packed encoder all have to agree or a colour bug reads as a decoder bug.
|
||||||
|
"""
|
||||||
|
pal = (d if isinstance(d, np.ndarray) else d.pal).astype(int)
|
||||||
|
p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF
|
||||||
|
f = pal >> 3
|
||||||
|
render = lambda I: p6((f << 1) | I[:, None])
|
||||||
|
I = (((render(np.ones(256, int)) - pal) ** 2).sum(1)
|
||||||
|
< ((render(np.zeros(256, int)) - pal) ** 2).sum(1)).astype(int)
|
||||||
|
words = (f[:, 1] << 11) | (f[:, 0] << 6) | (f[:, 2] << 1) | I
|
||||||
|
palb = np.zeros((256, 2), np.uint8)
|
||||||
|
palb[:, 0], palb[:, 1] = words >> 8, words & 0xFF
|
||||||
|
dark = int(((render(I).astype(int)) ** 2).sum(1).argmin())
|
||||||
|
return palb, dark, render(I)
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
-- Drive src/player/dmagate.s: does the HD63450 drive the SCSI data phase, and
|
||||||
|
-- does it HOLD THE BUS? (ROADMAP P4a)
|
||||||
|
--
|
||||||
|
-- THE APPARATUS is tools/bench/scsi_run.sh's, unchanged and stated again
|
||||||
|
-- because it is two substitutions deep: `x68000 -exp1 cz6bs1` (the board 42.5
|
||||||
|
-- says to benchmark, never x68ksupr, whose internal SCSI is PIO-only in MAME),
|
||||||
|
-- and a ZERO-FILLED scsiexrom.bin on a private rompath, which is honest only
|
||||||
|
-- because the player drives the SPC registers directly and never executes a
|
||||||
|
-- byte of that ROM.
|
||||||
|
--
|
||||||
|
-- WHAT THIS RIG DOES NOT DO, and it is the point of the whole design: it never
|
||||||
|
-- looks at $EA0015. 57.3 showed that address cannot answer the question --
|
||||||
|
-- with the DMAC's OWN asserted MAME cannot tell a CPU-driven byte there from a
|
||||||
|
-- DMAC-driven one. What separates the two configurations below is whether the
|
||||||
|
-- 68000 EXECUTED ANYTHING while the bytes were arriving, which is a fact about
|
||||||
|
-- the CPU and is read out of the DMAC's own registers plus a counter the
|
||||||
|
-- machine incremented itself.
|
||||||
|
--
|
||||||
|
-- AND IT IS NOT A RATE. MAME's DMAC is configured in wall-clock attotimes
|
||||||
|
-- (42.5); its burst mode halts the CPU outright rather than charging it cycles
|
||||||
|
-- per operand. `W` is untouched here and still wants a board.
|
||||||
|
local M = manager.machine
|
||||||
|
local SP = M.devices[":maincpu"].spaces["program"]
|
||||||
|
local function P(s) print("[DMA] "..s) end
|
||||||
|
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
|
||||||
|
|
||||||
|
local DGFLAG, DGREC, DGREC_SZ = 0x18600, 0x18800, 32
|
||||||
|
local DGWIN, DGWERR, DGR20, DGR20N, DGR20C = 0x18700, 0x18704, 0x18708, 0x1870C, 0x18710
|
||||||
|
local DGR20P = 0x18714
|
||||||
|
local CHROW, CHN, CHBASE = 256, 8, 0xC10000
|
||||||
|
local R20OF -- filled in after the mailbox addresses are known
|
||||||
|
local GV = 0xC00000
|
||||||
|
local DGLBA, DGBLK = 1000, 4
|
||||||
|
-- the palette runs (ROADMAP K1). PS7/PS8/PS9 are the SNAPSHOTS dmagate.s takes
|
||||||
|
-- by reading $E82000 back with the 68000 after each run; the registers
|
||||||
|
-- themselves hold only the last of the three by the time the host looks.
|
||||||
|
local PAL, PALN, PALB, POIS = 0xE82000, 256, 512, 0xA500
|
||||||
|
local PS7, PS8, PS9 = 0x1A000, 0x1A200, 0x1A400
|
||||||
|
local CHROW2, CH2BASE, CHN2ROWS = 256, 0xC14000, 6
|
||||||
|
local DST = {0x20000, 0x24000, 0x28000, 0xC08000, 0xC0C000, 0xC10000,
|
||||||
|
PAL, 0x2C000, CH2BASE}
|
||||||
|
local NAME = {"PIO (the path FINDINGS 58 measured)",
|
||||||
|
"DMA, BUS HELD (DCR $00 burst, OCR $81 max rate)",
|
||||||
|
"DMA, STEALING (DCR $80 cycle steal, OCR $80 limited)",
|
||||||
|
"DMA -> GVRAM (bus held, R20 bit 11 = BUFFER MODE) [47.6.2]",
|
||||||
|
"DMA -> GVRAM (the SAME, bit 11 CLEAR -- NEGATIVE CONTROL)",
|
||||||
|
"DMA -> GVRAM (ARRAY CHAINED, 8 rows at the 1024 B line stride)",
|
||||||
|
"DMA -> PALETTE (bus held, 512 B into $E82000) [K1, 61.9]",
|
||||||
|
"DMA -> RAM (the SAME read aimed elsewhere -- NEGATIVE CONTROL:"
|
||||||
|
.." the palette must still read poison)",
|
||||||
|
"DMA -> PALETTE + SIX ROWS (ONE array-chained start across two"
|
||||||
|
.." kinds of destination)"}
|
||||||
|
local SHORT = {"pio", "held", "steal", "gvram", "masked", "chain",
|
||||||
|
"pal", "palctl", "palchain"}
|
||||||
|
local LENOF = {[6]=PALB, [7]=PALB} -- everything else is DGBLK*512
|
||||||
|
local ERRNAME = {[0]="OK", "SELECTION TIMEOUT -- no target answered",
|
||||||
|
"UNEXPECTED PHASE", "POLL TIMEOUT -- a phase never arrived",
|
||||||
|
"NON-ZERO SCSI STATUS",
|
||||||
|
"WINDOWED READ REFUSED -- a channel cannot drop bytes"}
|
||||||
|
R20OF = {[3]=DGR20, [4]=DGR20N, [5]=DGR20C, [8]=DGR20P}
|
||||||
|
local DISK = os.getenv("DLX_SCSI_IMG") or "dlxdisk.img"
|
||||||
|
|
||||||
|
local code do local f=io.open("dmagate.bin","rb"); code=f:read("a"); f:close() end
|
||||||
|
|
||||||
|
-- the disc's own bytes, once, for all three comparisons
|
||||||
|
local want do
|
||||||
|
local f = io.open(DISK, "rb")
|
||||||
|
if f then f:seek("set", DGLBA*512); want = f:read(DGBLK*512); f:close() end
|
||||||
|
end
|
||||||
|
|
||||||
|
local st = "boot"
|
||||||
|
SUB = emu.add_machine_frame_notifier(function()
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
if st == "boot" then
|
||||||
|
if T() < 3.0 then return end
|
||||||
|
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
|
||||||
|
SP:write_u32(DGFLAG, 0)
|
||||||
|
local cpu = M.devices[":maincpu"]
|
||||||
|
cpu.state["SR"].value = 0x2700
|
||||||
|
cpu.state["SP"].value = 0x8000
|
||||||
|
cpu.state["PC"].value = 0x10000
|
||||||
|
P(string.format("dmagate.bin=%d B loaded at $10000; reading LBA %d, %d B, "
|
||||||
|
.."three ways, then once more into GVRAM",
|
||||||
|
#code, DGLBA, DGBLK*512))
|
||||||
|
st = "wait"; return
|
||||||
|
end
|
||||||
|
if st == "wait" then
|
||||||
|
if SP:read_u32(DGFLAG) ~= 1 then
|
||||||
|
if T() > 60 then P("TIMEOUT: the gate never finished"); P("done"); M:exit() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not want then P("no "..DISK.." to check against"); P("done"); M:exit(); return end
|
||||||
|
for i = 0, 8 do
|
||||||
|
local LEN = LENOF[i] or DGBLK*512
|
||||||
|
local b = DGREC + i*DGREC_SZ
|
||||||
|
local rc = SP:read_u32(b)
|
||||||
|
local e = SP:read_u32(b+4)
|
||||||
|
local mtc0 = SP:read_u32(b+8)
|
||||||
|
local spin = SP:read_u32(b+12)
|
||||||
|
local csr = SP:read_u32(b+16)
|
||||||
|
local cer = SP:read_u32(b+20)
|
||||||
|
local mtcf = SP:read_u32(b+24)
|
||||||
|
local marf = SP:read_u32(b+28)
|
||||||
|
P(NAME[i+1])
|
||||||
|
if rc ~= 0 then
|
||||||
|
P(string.format(" FAILED: err=%d (%s)", e, ERRNAME[e] or "?"))
|
||||||
|
else
|
||||||
|
local bad, first = 0, nil
|
||||||
|
-- The GVRAM run is read back a WORD at a time and split by hand.
|
||||||
|
-- SP:read_u8 on $C00000 goes through gvram_r, which in buffer mode
|
||||||
|
-- returns the whole word; asking for one byte of it would hand back
|
||||||
|
-- whichever half MAME's address space happens to hand over, and the
|
||||||
|
-- question here is precisely WHICH HALF each disc byte landed in.
|
||||||
|
-- Even disc byte -> high half (page 1), odd -> low half (page 0),
|
||||||
|
-- because the 68000 is big-endian and an even address is the MS byte.
|
||||||
|
local pg1, pg0, bad_hi, bad_lo = 0, 0, 0, 0
|
||||||
|
for k = 1, LEN do
|
||||||
|
local got
|
||||||
|
-- `a` is set for every destination that has to be read a WORD at a
|
||||||
|
-- time and split by hand -- GVRAM in buffer mode, and the palette
|
||||||
|
-- snapshots, whose words are what the 68000 read back out of
|
||||||
|
-- $E82000. Where it stays nil the destination is plain RAM.
|
||||||
|
local off, a
|
||||||
|
if i == 5 then
|
||||||
|
-- The chained run's destination is not linear: byte k of the
|
||||||
|
-- transfer is byte k%256 of row k//256, and the rows are a full
|
||||||
|
-- 1024 B line stride apart. If the channel had ignored the array
|
||||||
|
-- and run contiguously, every byte past the first row would be
|
||||||
|
-- in the wrong place and this comparison would say so.
|
||||||
|
off = (k-1) % CHROW
|
||||||
|
a = CHBASE + ((k-1) // CHROW) * 1024 + (off & ~1)
|
||||||
|
elseif i == 6 then
|
||||||
|
off = (k-1) % 2
|
||||||
|
a = PS7 + ((k-1) & ~1)
|
||||||
|
elseif i == 8 then
|
||||||
|
-- ONE transfer across two kinds of destination: the first sector
|
||||||
|
-- is the palette, the rest is six picture rows at the line
|
||||||
|
-- stride. The split is the array's, and this walks it the same
|
||||||
|
-- way the channel was told to.
|
||||||
|
if k <= PALB then
|
||||||
|
off = (k-1) % 2
|
||||||
|
a = PS9 + ((k-1) & ~1)
|
||||||
|
else
|
||||||
|
local idx = k - PALB - 1
|
||||||
|
off = idx % 2
|
||||||
|
a = CH2BASE + (idx // CHROW2) * 1024 + ((idx % CHROW2) & ~1)
|
||||||
|
end
|
||||||
|
elseif i >= 3 and i ~= 7 then
|
||||||
|
off = (k-1) % 2
|
||||||
|
a = DST[i+1] + ((k-1) & ~1)
|
||||||
|
end
|
||||||
|
if a then
|
||||||
|
local w = SP:read_u16(a)
|
||||||
|
if (off % 2) == 0 then got = (w >> 8) & 0xff; pg1 = pg1 + 1
|
||||||
|
else got = w & 0xff; pg0 = pg0 + 1 end
|
||||||
|
else
|
||||||
|
got = SP:read_u8(DST[i+1]+k-1)
|
||||||
|
end
|
||||||
|
if got ~= string.byte(want, k) then
|
||||||
|
bad = bad + 1; first = first or (k-1)
|
||||||
|
if ((k-1) % 2) == 0 then bad_hi = bad_hi + 1
|
||||||
|
else bad_lo = bad_lo + 1 end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if R20OF[i] then
|
||||||
|
P(string.format(" R20 during the run = $%04X (bit 11 %s); %d bytes "
|
||||||
|
.."read back out of the HIGH half of a destination "
|
||||||
|
.."word and %d out of the LOW half",
|
||||||
|
SP:read_u32(R20OF[i]),
|
||||||
|
((SP:read_u32(R20OF[i]) & 0x0800) ~= 0)
|
||||||
|
and "SET" or "CLEAR",
|
||||||
|
pg1, pg0))
|
||||||
|
end
|
||||||
|
-- THE PALETTE RUNS' OWN VACUITY CHECK. Run 7's destination was
|
||||||
|
-- poisoned by the 68000 first, so "it matches the disc" cannot be
|
||||||
|
-- satisfied by a channel that did nothing -- but only if the poison
|
||||||
|
-- and the disc actually differ everywhere they are compared. That is
|
||||||
|
-- a property of THIS record and is counted rather than assumed.
|
||||||
|
if i == 6 then
|
||||||
|
local diff = 0
|
||||||
|
for j = 0, PALN-1 do
|
||||||
|
local w = (POIS | j) & 0xffff
|
||||||
|
if ((w >> 8) & 0xff) ~= string.byte(want, 2*j+1) then diff = diff + 1 end
|
||||||
|
if (w & 0xff) ~= string.byte(want, 2*j+2) then diff = diff + 1 end
|
||||||
|
end
|
||||||
|
P(string.format(" PALETTE POISON IS A DISCRIMINATOR: %d of %d "
|
||||||
|
.."positions differ from the disc's bytes -- a "
|
||||||
|
.."channel that wrote nothing could not have passed "
|
||||||
|
.."in those.", diff, PALB))
|
||||||
|
P(string.format(" %d bytes read back out of the HIGH half of a "
|
||||||
|
.."palette word (G and the top of R) and %d out of "
|
||||||
|
.."the LOW half", pg1, pg0))
|
||||||
|
end
|
||||||
|
if bad == 0 then
|
||||||
|
P(string.format(" BYTES OK: %d B from LBA %d match %s byte for byte "
|
||||||
|
.."[%s]", LEN, DGLBA, DISK, SHORT[i+1]))
|
||||||
|
if i == 3 then
|
||||||
|
P(" A CHANNEL FILLS THE PACKED LAYOUT: every disc byte landed in "
|
||||||
|
.."its own half of a GVRAM word, with the CPU halted -- so a "
|
||||||
|
.."stream interleaved (right<<8)|left goes from disc to screen "
|
||||||
|
.."with no CPU in the loop (47.6.2, first half).")
|
||||||
|
end
|
||||||
|
if i == 5 then
|
||||||
|
P(string.format(" THE CHANNEL WALKED THE ARRAY ITSELF: %d rows of "
|
||||||
|
.."%d B landed at a %d B line stride from ONE start, CPU halted "
|
||||||
|
.."throughout. A frame is %d such entries; the CPU does not "
|
||||||
|
.."restart the channel per row.", CHN, CHROW, 1024, 192))
|
||||||
|
end
|
||||||
|
if i == 4 then
|
||||||
|
P(" CONTROL DID NOT FAIL: the masked write path delivered every "
|
||||||
|
.."byte too, so the run above is not evidence about R20 bit 11.")
|
||||||
|
end
|
||||||
|
if i == 6 then
|
||||||
|
P(" A CHANNEL WRITES THE PALETTE REGISTERS: 512 B off the disc "
|
||||||
|
.."became 256 palette words, read back OUT OF $E82000 by the "
|
||||||
|
.."68000 itself, with the CPU halted for the transfer. Each "
|
||||||
|
.."disc byte landed in its own half of a register word, so a "
|
||||||
|
.."per-frame palette needs no CPU (61.9, ROADMAP K1).")
|
||||||
|
end
|
||||||
|
if i == 8 then
|
||||||
|
P(string.format(" ONE START PAINTED THE PALETTE AND %d ROWS: a "
|
||||||
|
.."single array-chained transfer crossed from device registers "
|
||||||
|
.."at $%06X into GVRAM at $%06X, %d B in %d entries, CPU halted "
|
||||||
|
.."throughout. A frame is that shape with %d row entries "
|
||||||
|
.."instead of %d.", CHN2ROWS, PAL, CH2BASE, LEN, CHN2ROWS+1,
|
||||||
|
192, CHN2ROWS))
|
||||||
|
end
|
||||||
|
elseif i == 6 or i == 8 then
|
||||||
|
P(string.format(" PALETTE WRONG [%s]: %d of %d differ, first at "
|
||||||
|
.."+%d -- %d at EVEN offsets (the HIGH half of a "
|
||||||
|
.."word), %d at ODD.",
|
||||||
|
SHORT[i+1], bad, LEN, first, bad_hi, bad_lo))
|
||||||
|
elseif i == 4 then
|
||||||
|
-- THE CLAIM IS NOT "half the bytes differ". In masked 256-colour
|
||||||
|
-- mode gvram_w takes `data & 0x00ff` and ignores mem_mask, so a byte
|
||||||
|
-- written to an EVEN address is never stored and the high half keeps
|
||||||
|
-- whatever it held; some of those stale halves match the disc by
|
||||||
|
-- coincidence, and this record is full of pad, so a lot of them do.
|
||||||
|
-- The mechanism's signature is WHERE the damage is, not how much:
|
||||||
|
-- every ODD byte must survive and only EVEN ones may be lost.
|
||||||
|
P(string.format(" BYTES LOST [masked]: %d of %d differ (first at "
|
||||||
|
.."+%d) -- %d at EVEN offsets, %d at ODD.",
|
||||||
|
bad, LEN, first, bad_hi, bad_lo))
|
||||||
|
if bad_lo == 0 and bad_hi > 0 then
|
||||||
|
P(string.format(" EXACTLY THE MECHANISM: all %d survivors of the "
|
||||||
|
.."high half are stale GVRAM that happens to match "
|
||||||
|
.."(this record is mostly pad); not one of the %d "
|
||||||
|
.."ODD bytes was harmed. Bit 11 is what carried the "
|
||||||
|
.."even ones in the run above.", LEN//2 - bad_hi, LEN//2))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
P(string.format(" BYTES WRONG [%s]: %d of %d differ, first at +%d",
|
||||||
|
SHORT[i+1], bad, LEN, first))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if i == 7 then
|
||||||
|
-- THE ATTRIBUTION CONTROL'S SECOND CLAIM, and the one that makes run
|
||||||
|
-- 7 mean something: the same transfer aimed 20 KB away leaves the
|
||||||
|
-- palette exactly as the 68000 poisoned it. If this comes back with
|
||||||
|
-- the disc's bytes in it, something other than the channel's MAR
|
||||||
|
-- decides what reaches $E82000 and run 7 measured that instead.
|
||||||
|
local stale, first_s = 0, nil
|
||||||
|
for j = 0, PALN-1 do
|
||||||
|
if SP:read_u16(PS8 + 2*j) ~= ((POIS | j) & 0xffff) then
|
||||||
|
stale = stale + 1; first_s = first_s or j
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if stale == 0 then
|
||||||
|
P(string.format(" PALETTE UNTOUCHED BY THE CONTROL: %d of %d words "
|
||||||
|
.."still read the poison the 68000 wrote, so the "
|
||||||
|
.."bytes in run 7 got there because the channel's "
|
||||||
|
.."MAR pointed at $%06X.", PALN, PALN, PAL))
|
||||||
|
else
|
||||||
|
P(string.format(" CONTROL DID NOT FAIL [palctl]: %d of %d palette "
|
||||||
|
.."words are no longer poison (first at entry %d) "
|
||||||
|
.."-- the palette changed without a channel aimed "
|
||||||
|
.."at it.", stale, PALN, first_s))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if i > 0 then
|
||||||
|
-- THE DISCRIMINATOR. MTC as the instruction after START saw it, and
|
||||||
|
-- the number of times the CPU went round its own wait loop.
|
||||||
|
P(string.format(" MTC one instruction after START: %d of %d -> the "
|
||||||
|
.."CPU %s while the transfer ran [%s]",
|
||||||
|
mtc0, LEN,
|
||||||
|
(mtc0 == 0) and "NEVER EXECUTED" or "kept executing",
|
||||||
|
SHORT[i+1]))
|
||||||
|
P(string.format(" CPU trips round the wait loop: %d [%s]", spin, SHORT[i+1]))
|
||||||
|
P(string.format(" channel: CSR=$%02X (%s%s%s) CER=$%02X MTC=%d "
|
||||||
|
.."MAR=$%06X (+%d) [%s]",
|
||||||
|
csr,
|
||||||
|
((csr & 0x80) ~= 0) and "COC " or "",
|
||||||
|
((csr & 0x10) ~= 0) and "ERR " or "",
|
||||||
|
((csr & 0x08) ~= 0) and "ACT" or "idle",
|
||||||
|
cer, mtcf, marf, marf - DST[i+1], SHORT[i+1]))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- The refusal. Expected to fail, and the run is only green if it did.
|
||||||
|
local w, we = SP:read_u32(DGWIN), SP:read_u32(DGWERR)
|
||||||
|
if w == 0xFFFFFFFF and we == 5 then
|
||||||
|
P("WINDOWED DMA READ REFUSED, as it must be: a channel writes a "
|
||||||
|
.."contiguous run and cannot drop the 300 B in front of the record "
|
||||||
|
.."(58.3). P4a's precondition is a SECTOR-ALIGNED container.")
|
||||||
|
else
|
||||||
|
P(string.format("WINDOW NOT REFUSED: rc=%d err=%d -- the transport would "
|
||||||
|
.."have written the neighbours' bytes into the ring.", w, we))
|
||||||
|
end
|
||||||
|
P("done"); M:exit(); return
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if not ok then P("LUA ERROR: "..tostring(err)); P("done"); M:exit() end
|
||||||
|
end)
|
||||||
Executable
+154
@@ -0,0 +1,154 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# One HD63450 data-phase run: does the DMAC drive the SCSI data phase, and does
|
||||||
|
# it HOLD THE BUS? (ROADMAP P4a, the last item before M2.)
|
||||||
|
#
|
||||||
|
# tools/bench/dma_run.sh [container.dlx]
|
||||||
|
#
|
||||||
|
# The apparatus is tools/bench/scsi_run.sh's -- `x68000 -exp1 cz6bs1` and a
|
||||||
|
# zero-filled scsiexrom.bin on a private rompath -- and the volume is
|
||||||
|
# tools/bench/mkvol.sh's, the same bytes the host-file ring rig reads.
|
||||||
|
#
|
||||||
|
# WHAT A GREEN RUN MEANS: the same 2,048 B came off the disc three ways -- PIO,
|
||||||
|
# the channel with the bus held, the channel stealing cycles -- all three
|
||||||
|
# byte-exact against the host's copy; and in the held configuration THE WHOLE
|
||||||
|
# TRANSFER HAPPENED BETWEEN TWO INSTRUCTIONS, which is what holding the bus
|
||||||
|
# means and is not a claim about $EA0015 (57.3).
|
||||||
|
#
|
||||||
|
# WHAT IT DOES NOT MEAN: anything about `W`. MAME's DMAC runs on wall-clock
|
||||||
|
# attotimes (42.5) and models a held bus by HALTING the CPU rather than by
|
||||||
|
# charging it cycles per operand. This settles which configuration works.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
DLX=${1:-tmp/rc_fr_singe_scsi_span.dlx}
|
||||||
|
|
||||||
|
bash tools/bench/mkvol.sh "$DLX"
|
||||||
|
|
||||||
|
tools/vasm/vasmm68k_mot -Fbin -o tmp/dmagate.bin src/player/dmagate.s > /dev/null
|
||||||
|
|
||||||
|
# What the player will program, decoded out of the same constants it programs.
|
||||||
|
python3 tools/analysis/27_dmac_config.py
|
||||||
|
|
||||||
|
# stdbuf -oL: without it a long MAME run is unobservable until it exits, and a
|
||||||
|
# run that is merely finishing looks exactly like one that is wedged (34.1).
|
||||||
|
( cd tmp && SDL_VIDEODRIVER=dummy stdbuf -oL timeout -k 5 300 \
|
||||||
|
mame x68000 -bios ipl10 -exp1 cz6bs1 \
|
||||||
|
-rompath "$HOME/mame/roms;./p4roms" -hard dlxdisk.chd \
|
||||||
|
-ramsize 2M -video soft -window -sound none -nothrottle -plugins \
|
||||||
|
-autoboot_script ../tools/bench/dma.lua \
|
||||||
|
-seconds_to_run 90 > dma_run.log 2>&1 )
|
||||||
|
grep -aq "^\[DMA\] done" tmp/dma_run.log || {
|
||||||
|
echo "FAIL: the DMA gate did not finish -- no completion marker."
|
||||||
|
tail -8 tmp/dma_run.log; exit 1; }
|
||||||
|
grep -a "^\[DMA\]" tmp/dma_run.log | sed 's/^\[DMA\] / /'
|
||||||
|
|
||||||
|
# THE ASSERTIONS. Printing a result and gating on it are different things.
|
||||||
|
fail() { echo "FAIL: $1"; exit 1; }
|
||||||
|
grep -aq "BYTES OK: 2048 B from LBA 1000 .*\[pio\]" tmp/dma_run.log || \
|
||||||
|
fail "the PIO reference read did not match -- nothing below is about the DMAC."
|
||||||
|
grep -aq "BYTES OK: 2048 B from LBA 1000 .*\[held\]" tmp/dma_run.log || \
|
||||||
|
fail "the bus-held DMA read did not deliver the disc's bytes."
|
||||||
|
grep -aq "BYTES OK: 2048 B from LBA 1000 .*\[steal\]" tmp/dma_run.log || \
|
||||||
|
fail "the cycle-stealing DMA read did not deliver the disc's bytes."
|
||||||
|
grep -aq "MTC one instruction after START: 0 of 2048 .*NEVER EXECUTED .*\[held\]" \
|
||||||
|
tmp/dma_run.log || \
|
||||||
|
fail "the bus was NOT held: the CPU executed while the channel ran, so this is
|
||||||
|
not the configuration ROADMAP P4a asks for. That MTC is the whole of the
|
||||||
|
evidence that does not come from watching \$EA0015 (57.3)."
|
||||||
|
grep -aq "CPU trips round the wait loop: 1 \[held\]" tmp/dma_run.log || \
|
||||||
|
fail "the held configuration's CPU went round its wait loop more than once --
|
||||||
|
it was running, so the bus was not held for the whole transfer."
|
||||||
|
# A NEGATIVE ASSERTION IS WRITTEN AS AN `if`, not as `grep ... && fail`: under
|
||||||
|
# `set -e` a failing grep in an AND-list takes the whole script's exit status
|
||||||
|
# with it, so the run would report the failure it was looking for as a pass.
|
||||||
|
SPIN=$(sed -n 's/.*CPU trips round the wait loop: \([0-9]*\) \[steal\].*/\1/p' \
|
||||||
|
tmp/dma_run.log)
|
||||||
|
[ -n "$SPIN" ] && [ "$SPIN" -ge 100 ] || \
|
||||||
|
fail "the cycle-stealing configuration did not leave the CPU running (spin
|
||||||
|
= ${SPIN:-none}) -- the two configurations are meant to DIFFER in exactly
|
||||||
|
that, and a contrast of one against one is not a contrast."
|
||||||
|
if grep -aq "MTC one instruction after START: 0 of 2048 .*\[steal\]" tmp/dma_run.log
|
||||||
|
then
|
||||||
|
fail "the cycle-stealing configuration also finished between two instructions,
|
||||||
|
so the comparison has no contrast in it and the discriminator is measuring
|
||||||
|
something other than bus ownership."
|
||||||
|
fi
|
||||||
|
grep -aq "COC .*CER=\$00 MTC=0 .*(+2048) \[held\]" tmp/dma_run.log || \
|
||||||
|
fail "the held channel did not report a clean completion of every byte."
|
||||||
|
grep -aq "COC .*CER=\$00 MTC=0 .*(+2048) \[steal\]" tmp/dma_run.log || \
|
||||||
|
fail "the stealing channel did not report a clean completion of every byte."
|
||||||
|
# ---- the GVRAM run and its control (47.6.2). A channel that writes GVRAM in
|
||||||
|
# buffer mode is the decoder-free packed player's entire per-frame path, and a
|
||||||
|
# run with no control is 58.3's vacuous "UNDERRUNS: 0/120" again -- the IPL
|
||||||
|
# leaves R20 = $0B16, bit 11 ALREADY SET, so the first cut of this test could
|
||||||
|
# not have failed.
|
||||||
|
grep -aq "BYTES OK: 2048 B from LBA 1000 .*\[gvram\]" tmp/dma_run.log || \
|
||||||
|
fail "the channel did not fill GVRAM in buffer mode -- a device->GVRAM
|
||||||
|
transfer is the whole of the decoder-free packed player's frame."
|
||||||
|
grep -aq "R20 during the run = \$0916 (bit 11 SET)" tmp/dma_run.log || \
|
||||||
|
fail "the GVRAM run did not run in buffer mode with a KNOWN R20."
|
||||||
|
grep -aq "R20 during the run = \$0116 (bit 11 CLEAR)" tmp/dma_run.log || \
|
||||||
|
fail "the negative control did not run with bit 11 clear."
|
||||||
|
if grep -aq "BYTES OK: 2048 B from LBA 1000 .*\[masked\]" tmp/dma_run.log
|
||||||
|
then
|
||||||
|
fail "the MASKED control delivered every byte, so the run above is not a
|
||||||
|
measurement of R20 bit 11 -- it is a measurement of nothing."
|
||||||
|
fi
|
||||||
|
grep -aq "EXACTLY THE MECHANISM" tmp/dma_run.log || \
|
||||||
|
fail "the masked control lost bytes at ODD offsets too, or lost none at all.
|
||||||
|
The claim is not a COUNT -- stale GVRAM matches the disc by coincidence
|
||||||
|
wherever the record is pad -- it is a PLACE: gvram_w's 256-colour arm
|
||||||
|
drops what the channel wrote to EVEN addresses and stores what it wrote
|
||||||
|
to odd ones. Damage anywhere else is a different mechanism."
|
||||||
|
|
||||||
|
grep -aq "BYTES OK: 2048 B from LBA 1000 .*\[chain\]" tmp/dma_run.log || \
|
||||||
|
fail "the array-chained run did not put the bytes at the row bases its array
|
||||||
|
named. A picture row is 256 B of a 1024 B line stride, so a frame is 192
|
||||||
|
destinations; if the channel cannot walk them the CPU has to restart it
|
||||||
|
per row and the decoder-free path costs a per-row front end."
|
||||||
|
grep -aq "THE CHANNEL WALKED THE ARRAY ITSELF" tmp/dma_run.log || \
|
||||||
|
fail "the chained run did not report walking its own array."
|
||||||
|
|
||||||
|
# ---- THE PALETTE (ROADMAP K1, FINDINGS 61.9). If the registers at $E82000 take
|
||||||
|
# a byte-wide DMA the way GVRAM does in buffer mode, a per-frame palette is a
|
||||||
|
# 193rd array-chain entry and ONE channel start paints a whole frame; if they do
|
||||||
|
# not, the CPU writes 256 words a frame and the architecture still stands. The
|
||||||
|
# run is poisoned first and controlled twice -- once by aiming the same transfer
|
||||||
|
# elsewhere, once by counting how many of the 512 positions the poison and the
|
||||||
|
# disc actually differ in.
|
||||||
|
grep -aq "BYTES OK: 512 B from LBA 1000 .*\[pal\]" tmp/dma_run.log || \
|
||||||
|
fail "the channel did not write the palette registers at \$E82000 -- so a
|
||||||
|
per-frame palette costs the CPU 256 word writes and cannot ride the
|
||||||
|
frame's array chain (61.9). That is a RESULT, not a broken run: check the
|
||||||
|
PALETTE WRONG line above for whether the bytes were dropped or misplaced."
|
||||||
|
DIFF=$(sed -n 's/.*PALETTE POISON IS A DISCRIMINATOR: \([0-9]*\) of 512.*/\1/p' \
|
||||||
|
tmp/dma_run.log)
|
||||||
|
[ -n "$DIFF" ] && [ "$DIFF" -ge 500 ] || \
|
||||||
|
fail "the poison and the disc's bytes agree in ${DIFF:-?} of 512 positions, so
|
||||||
|
the palette run could have passed without a channel writing anything --
|
||||||
|
this is run 4's could-not-fail trap in a new place. Change DGPOIS."
|
||||||
|
grep -aq "BYTES OK: 512 B from LBA 1000 .*\[palctl\]" tmp/dma_run.log || \
|
||||||
|
fail "the ATTRIBUTION control's read did not land in RAM, so its palette claim
|
||||||
|
is about a transfer that did not happen."
|
||||||
|
grep -aq "PALETTE UNTOUCHED BY THE CONTROL: 256 of 256 words" tmp/dma_run.log || \
|
||||||
|
fail "the palette changed during a transfer aimed 20 KB away from it. Then
|
||||||
|
what reached \$E82000 in the run above was not decided by the channel's
|
||||||
|
MAR, and that run measured something else."
|
||||||
|
if grep -aq "CONTROL DID NOT FAIL \[palctl\]" tmp/dma_run.log
|
||||||
|
then
|
||||||
|
fail "the control reported its own failure -- see the line above it."
|
||||||
|
fi
|
||||||
|
grep -aq "BYTES OK: 2048 B from LBA 1000 .*\[palchain\]" tmp/dma_run.log || \
|
||||||
|
fail "ONE array-chained start could not cross from the palette registers into
|
||||||
|
GVRAM. A frame is one palette entry and 192 row entries; if the two kinds
|
||||||
|
of destination cannot share a chain, the CPU is back in the video path
|
||||||
|
once a frame to start the second half of it."
|
||||||
|
grep -aq "ONE START PAINTED THE PALETTE AND 6 ROWS" tmp/dma_run.log || \
|
||||||
|
fail "the palette+rows run did not report the crossing it exists to show."
|
||||||
|
|
||||||
|
grep -aq "WINDOWED DMA READ REFUSED" tmp/dma_run.log || \
|
||||||
|
fail "a WINDOWED read through the channel was not refused. 117 of 120 records
|
||||||
|
start part way into a sector (58.3), and a channel cannot drop the bytes
|
||||||
|
in front of one -- so it would write the neighbouring records into the
|
||||||
|
ring, over data the decoder has not finished with, with no bounds check
|
||||||
|
to catch it (49.2)."
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
PX68K ?= $(HOME)/src/px68k
|
||||||
|
CFLAGS = -O2 -fno-strict-aliasing -Wall -Wno-unused-result \
|
||||||
|
-I$(PX68K)/m68000 -I$(PX68K)/x11 -I$(PX68K)/win32api -I$(PX68K)/x68k
|
||||||
|
|
||||||
|
gvpack: harness.c $(PX68K)/x68k/gvram.c
|
||||||
|
$(CC) $(CFLAGS) -o $@ harness.c $(PX68K)/x68k/gvram.c
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f gvpack
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,178 @@
|
|||||||
|
/* Headless harness for px68k's GVRAM write and display model.
|
||||||
|
*
|
||||||
|
* Tests FINDINGS 46.6 -- the packed 1.0 byte/pixel layout -- on a SECOND
|
||||||
|
* emulator, the way tools/bench/c68k does for the CPU core. It links px68k's
|
||||||
|
* real x68k/gvram.c: the address decode, the CRTC R20 bit-11 buffer-mode write
|
||||||
|
* path, the page-byte selection, the scroll wrap and the index-0 transparency
|
||||||
|
* test are all px68k's own code, not a reimplementation.
|
||||||
|
*
|
||||||
|
* What IS glue here, and is declared as such: the ~12 lines of page-ordering
|
||||||
|
* from x11/windraw.c's 256-colour case (which page is drawn opaque and which
|
||||||
|
* transparent, as a function of the video controller's priority register).
|
||||||
|
* windraw.c is SDL-bound and cannot be linked headless, so that dispatch is
|
||||||
|
* mirrored. It is quoted verbatim in pick_order() so the mirroring is
|
||||||
|
* auditable.
|
||||||
|
*
|
||||||
|
* GrphPal is set to the IDENTITY, so what lands in Grp_LineBuf is the 8-bit
|
||||||
|
* palette INDEX rather than a host pixel. That keeps the harness out of
|
||||||
|
* px68k's host-format colour conversion, and it is faithful: px68k's
|
||||||
|
* transparency test is on the index (`if (v != 0x00)`), before the lookup.
|
||||||
|
*/
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "common.h"
|
||||||
|
#include "gvram.h"
|
||||||
|
|
||||||
|
/* --- the globals gvram.c expects from the rest of px68k -------------------- */
|
||||||
|
BYTE CRTC_Regs[48];
|
||||||
|
WORD CRTC_FastClrMask;
|
||||||
|
DWORD GrphScrollX[4], GrphScrollY[4];
|
||||||
|
WORD GrphPal[256];
|
||||||
|
BYTE TextDirtyLine[1024];
|
||||||
|
DWORD TextDotX, TextDotY;
|
||||||
|
DWORD VLINE;
|
||||||
|
BYTE Pal_Regs[1024];
|
||||||
|
WORD Pal16[65536];
|
||||||
|
WORD Ibit, Pal_HalfMask, Pal_Ix2;
|
||||||
|
|
||||||
|
extern BYTE GVRAM[0x80000];
|
||||||
|
extern WORD Grp_LineBuf[1024];
|
||||||
|
|
||||||
|
#define W 256
|
||||||
|
#define H 256
|
||||||
|
|
||||||
|
/* 68000 word write: two byte writes, high byte first, as the bus does. */
|
||||||
|
static void wr16(DWORD adr, WORD v)
|
||||||
|
{
|
||||||
|
GVRAM_Write(adr, (BYTE)(v >> 8));
|
||||||
|
GVRAM_Write(adr + 1, (BYTE)(v & 0xff));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set_r20(WORD r20) /* CRTC R20 = byte pair 0x28/0x29 */
|
||||||
|
{
|
||||||
|
CRTC_Regs[0x28] = (BYTE)(r20 >> 8);
|
||||||
|
CRTC_Regs[0x29] = (BYTE)(r20 & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mirrors x11/windraw.c, 256-colour case:
|
||||||
|
*
|
||||||
|
* if ( (VCReg1[1]&3) <= ((VCReg1[1]>>4)&3) ) {
|
||||||
|
* ... Grp_DrawLine8(1, 1); opaq = 0;
|
||||||
|
* ... Grp_DrawLine8(0, opaq);
|
||||||
|
* } else {
|
||||||
|
* ... Grp_DrawLine8(0, 1); opaq = 0;
|
||||||
|
* ... Grp_DrawLine8(1, opaq);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* i.e. the first page drawn is OPAQUE (the bottom) and the second is drawn
|
||||||
|
* with opaq=0 (the transparent top).
|
||||||
|
*/
|
||||||
|
static void draw_line(BYTE vcreg1_lo)
|
||||||
|
{
|
||||||
|
int bottom = ((vcreg1_lo & 3) <= ((vcreg1_lo >> 4) & 3)) ? 1 : 0;
|
||||||
|
Grp_DrawLine8(bottom, 1);
|
||||||
|
Grp_DrawLine8(bottom ^ 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
const char *blob = argc > 1 ? argv[1] : "tmp/frame256p.bin";
|
||||||
|
const char *out = argc > 2 ? argv[2] : "tmp/gvpack_px68k.raw";
|
||||||
|
int packed = !(argc > 3 && !strcmp(argv[3], "--unpacked"));
|
||||||
|
BYTE vc1 = (BYTE)(argc > 4 ? strtol(argv[4], NULL, 0) : 0x02);
|
||||||
|
/* --nobuffer: run the packed layout WITHOUT CRTC R20 bit 11, to show the
|
||||||
|
* bit is load-bearing here and not decoration. */
|
||||||
|
int buffer = !(argc > 5 && !strcmp(argv[5], "--nobuffer"));
|
||||||
|
int scroll = !(argc > 5 && !strcmp(argv[5], "--noscroll"));
|
||||||
|
/* --keepbuffer: leave R20 bit 11 SET while drawing. MAME blanks the
|
||||||
|
* graphics layer in that state; does px68k? */
|
||||||
|
int keepbuf = (argc > 5 && !strcmp(argv[5], "--keepbuffer"));
|
||||||
|
|
||||||
|
FILE *f = fopen(blob, "rb");
|
||||||
|
if (!f) { perror(blob); return 2; }
|
||||||
|
static BYTE d[8 + 768 + 256 * 256];
|
||||||
|
size_t n = fread(d, 1, sizeof d, f);
|
||||||
|
fclose(f);
|
||||||
|
int iw = (d[4] << 8) | d[5], ih = (d[6] << 8) | d[7];
|
||||||
|
if (n < (size_t)(8 + 768 + iw * ih)) { fprintf(stderr, "short blob\n"); return 2; }
|
||||||
|
const BYTE *pix = d + 8 + 768;
|
||||||
|
/* 'DLXQ' -- the blob is PRE-INTERLEAVED: `pix` is already the bytes a DLXP1
|
||||||
|
* record carries, in GVRAM order. The ordinary 'DLXR' path computes the
|
||||||
|
* interleave here, which tests the LAYOUT; this path tests the CONTAINER,
|
||||||
|
* by writing its bytes verbatim and asking px68k's own gvram.c what they
|
||||||
|
* display as. The two agreeing is the claim ROADMAP K2 has to make: the
|
||||||
|
* encoder's byte order is the one 47.2 verified as a picture. */
|
||||||
|
int prepacked = (d[3] == 'Q');
|
||||||
|
if (prepacked && !packed) {
|
||||||
|
fprintf(stderr, "a pre-interleaved blob has no unpacked form\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
int yoff = (H - ih) / 2;
|
||||||
|
const BYTE BLACK = 255;
|
||||||
|
|
||||||
|
#define PIX(y, x) ((y) < yoff || (y) >= yoff + ih ? BLACK : pix[((y) - yoff) * iw + (x)])
|
||||||
|
|
||||||
|
memset(GVRAM, 0, sizeof GVRAM);
|
||||||
|
for (int i = 0; i < 256; i++) GrphPal[i] = (WORD)i; /* identity */
|
||||||
|
TextDotX = W; TextDotY = H;
|
||||||
|
|
||||||
|
/* 256x256, 256 colours -- the same R20 tools/bench/crtc_mode.lua applies */
|
||||||
|
const WORD R20_DISPLAY = 0x0110;
|
||||||
|
set_r20(R20_DISPLAY);
|
||||||
|
|
||||||
|
/* page 0 -> scroll sets 0,1; page 1 -> scroll sets 2,3 */
|
||||||
|
GrphScrollX[0] = GrphScrollX[1] = 0;
|
||||||
|
GrphScrollY[0] = GrphScrollY[1] = 0;
|
||||||
|
GrphScrollX[2] = GrphScrollX[3] = (packed && scroll) ? 384 : 0;
|
||||||
|
GrphScrollY[2] = GrphScrollY[3] = 0;
|
||||||
|
|
||||||
|
if (packed) {
|
||||||
|
if (buffer) set_r20(R20_DISPLAY | 0x0800); /* buffer mode: unmasked */
|
||||||
|
for (int y = 0; y < H; y++) {
|
||||||
|
DWORD base = 0xC00000 + y * 1024;
|
||||||
|
for (int i = 128; i < 512; i++) wr16(base + i * 2, 0);
|
||||||
|
for (int i = 0; i < 128; i++) {
|
||||||
|
WORD w;
|
||||||
|
if (prepacked) {
|
||||||
|
/* The letterbox rows are STATIC SETUP and are not in a
|
||||||
|
* record (dlxp.py), so they are supplied here, the way a
|
||||||
|
* player's scene setup supplies them: both halves BLACK. */
|
||||||
|
if (y < yoff || y >= yoff + ih)
|
||||||
|
w = (WORD)((BLACK << 8) | BLACK);
|
||||||
|
else {
|
||||||
|
const BYTE *row = pix + (y - yoff) * iw;
|
||||||
|
w = (WORD)((row[i * 2] << 8) | row[i * 2 + 1]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w = (WORD)((PIX(y, i + 128) << 8) | PIX(y, i));
|
||||||
|
}
|
||||||
|
wr16(base + i * 2, w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!keepbuf) set_r20(R20_DISPLAY); /* back to display */
|
||||||
|
} else {
|
||||||
|
/* the ordinary 2.0 B/pixel path, for a control */
|
||||||
|
for (int y = 0; y < H; y++) {
|
||||||
|
DWORD base = 0xC00000 + y * 1024;
|
||||||
|
for (int x = 0; x < W; x++) wr16(base + x * 2, PIX(y, x));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FILE *o = fopen(out, "wb");
|
||||||
|
if (!o) { perror(out); return 2; }
|
||||||
|
for (int y = 0; y < H; y++) {
|
||||||
|
VLINE = (DWORD)y;
|
||||||
|
memset(Grp_LineBuf, 0, sizeof Grp_LineBuf);
|
||||||
|
draw_line(vc1);
|
||||||
|
static BYTE row[W];
|
||||||
|
for (int x = 0; x < W; x++) row[x] = (BYTE)(Grp_LineBuf[x] & 0xff);
|
||||||
|
fwrite(row, 1, W, o);
|
||||||
|
}
|
||||||
|
fclose(o);
|
||||||
|
fprintf(stderr, "[GVPACK] px68k model: %s, vcreg1=%02X, bottom page=%d -> %s\n",
|
||||||
|
packed ? (buffer ? "PACKED 1.0 B/px" : "PACKED but bit11 OFF")
|
||||||
|
: "unpacked 2.0 B/px", vc1,
|
||||||
|
((vc1 & 3) <= ((vc1 >> 4) & 3)) ? 1 : 0, out);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""The PACKED CONTAINER's own bytes, through px68k's real GVRAM code.
|
||||||
|
|
||||||
|
python3 tools/bench/gvpack/verify_dlxp.py [packed.dlxp] [frame] [--controls]
|
||||||
|
|
||||||
|
`verify_gvpack.py` checks the LAYOUT: it hands the harness a picture and lets
|
||||||
|
the harness compute the interleave, so what it proves is that FINDINGS 47.2's
|
||||||
|
scheme renders. This checks the CONTAINER: it writes a DLXP1 record's bytes
|
||||||
|
into GVRAM VERBATIM -- no interleave computed anywhere in the harness -- and
|
||||||
|
asks px68k what they display as. That is the only way to test a format whose
|
||||||
|
whole design is that nothing parses it (dlxp.py): if the encoder's byte order
|
||||||
|
were wrong, every check upstream of the display would still pass, because the
|
||||||
|
container round-trips against its own inverse.
|
||||||
|
|
||||||
|
It is the same second-emulator argument tools/bench/c68k makes for cycles: the
|
||||||
|
address decode, the R20 bit-11 write path, the page-byte selection, the scroll
|
||||||
|
wrap and the index-0 transparency test are px68k's own `x68k/gvram.c`.
|
||||||
|
|
||||||
|
Two negative controls, because a test that cannot fail proves nothing, and both
|
||||||
|
are mechanisms this container depends on rather than decoration:
|
||||||
|
--nobuffer R20 bit 11 CLEAR -- the high byte of every word is masked away,
|
||||||
|
so page 1 (columns 128..255) never gets written
|
||||||
|
--noscroll page 1 unscrolled -- its storage sits under the wrong columns
|
||||||
|
"""
|
||||||
|
import os, struct, subprocess, sys
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
import numpy as np
|
||||||
|
from dlxp import DLXP
|
||||||
|
|
||||||
|
args = [x for x in sys.argv[1:] if not x.startswith("--")]
|
||||||
|
path = args[0] if args else "tmp/packed_singe.dlxp"
|
||||||
|
frame = int(args[1]) if len(args) > 1 else 0
|
||||||
|
controls = "--controls" in sys.argv
|
||||||
|
|
||||||
|
d = DLXP(path)
|
||||||
|
_, pic = d._split(frame)
|
||||||
|
blob = b"DLXQ" + struct.pack(">HH", d.W, d.H) + b"\0" * 768 + pic
|
||||||
|
open("tmp/dlxp_gvpack.bin", "wb").write(blob)
|
||||||
|
|
||||||
|
BIN = "tools/bench/gvpack/gvpack"
|
||||||
|
if not os.path.exists(BIN):
|
||||||
|
sys.exit(f"{BIN} not built -- make -C tools/bench/gvpack PX68K=...")
|
||||||
|
|
||||||
|
|
||||||
|
def run(extra=None):
|
||||||
|
cmd = [BIN, "tmp/dlxp_gvpack.bin", "tmp/dlxp_gvpack.raw", "--packed", "0x02"]
|
||||||
|
if extra:
|
||||||
|
cmd.append(extra)
|
||||||
|
subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL)
|
||||||
|
g = np.frombuffer(open("tmp/dlxp_gvpack.raw", "rb").read(), np.uint8)
|
||||||
|
return g.reshape(256, 256)
|
||||||
|
|
||||||
|
|
||||||
|
want = d.indices(frame)
|
||||||
|
yoff = (256 - d.H) // 2
|
||||||
|
g = run()
|
||||||
|
act = g[yoff:yoff + d.H]
|
||||||
|
|
||||||
|
fail = []
|
||||||
|
if not np.array_equal(act, want):
|
||||||
|
bad = act != want
|
||||||
|
fail.append(f"{bad.sum()} px differ (left half {bad[:, :128].sum()}, "
|
||||||
|
f"right half {bad[:, 128:].sum()})")
|
||||||
|
bars = np.concatenate([g[:yoff], g[yoff + d.H:]])
|
||||||
|
if bars.size and (bars != 255).any():
|
||||||
|
fail.append(f"letterbox is not index 255: {(bars != 255).sum()} px")
|
||||||
|
if (act == 0).any():
|
||||||
|
fail.append(f"index 0 reached the screen: {(act == 0).sum()} px")
|
||||||
|
|
||||||
|
if controls and not fail:
|
||||||
|
for flag, why in (("--nobuffer", "R20 bit 11 clear"),
|
||||||
|
("--noscroll", "page 1 unscrolled")):
|
||||||
|
c = run(flag)[yoff:yoff + d.H]
|
||||||
|
n = int((c != want).sum())
|
||||||
|
print(f" control {flag:<11s} ({why}): {n:,} px differ"
|
||||||
|
+ ("" if n else " <-- IT DID NOT FAIL"))
|
||||||
|
if not n:
|
||||||
|
fail.append(f"control {flag} passed -- the test cannot fail on it")
|
||||||
|
|
||||||
|
for x in fail:
|
||||||
|
print("FAIL " + x)
|
||||||
|
if fail:
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"OK {os.path.basename(path)} frame {frame}: px68k's own gvram.c renders "
|
||||||
|
f"the container's {d.pic_bytes:,} bytes index-exact over {d.W}x{d.H},")
|
||||||
|
print(f" letterbox on the reserved black, and the transparency key never "
|
||||||
|
f"reaches the screen. The harness computed no interleave.")
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check px68k's render of the packed layout against the same reference MAME is
|
||||||
|
judged on (tools/bench/verify_frame256.py, criterion 3 and 4).
|
||||||
|
|
||||||
|
python3 tools/bench/gvpack/verify_gvpack.py <raw> [blob]
|
||||||
|
|
||||||
|
<raw> is 256x256 palette INDICES straight out of px68k's Grp_DrawLine8.
|
||||||
|
"""
|
||||||
|
import struct, sys
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
raw = sys.argv[1] if len(sys.argv) > 1 else "tmp/gvpack_px68k.raw"
|
||||||
|
blob = sys.argv[2] if len(sys.argv) > 2 else "tmp/frame256p.bin"
|
||||||
|
|
||||||
|
g = np.frombuffer(open(raw, "rb").read(), np.uint8).reshape(256, 256)
|
||||||
|
d = open(blob, "rb").read()
|
||||||
|
W, H = struct.unpack(">HH", d[4:8])
|
||||||
|
pal = np.frombuffer(d[8:8+768], np.uint8).reshape(256, 3).astype(int)
|
||||||
|
idx = np.frombuffer(d[8+768:8+768+W*H], np.uint8).reshape(H, W)
|
||||||
|
|
||||||
|
yoff = (256 - H) // 2
|
||||||
|
act = g[yoff:yoff+H]
|
||||||
|
|
||||||
|
fail = []
|
||||||
|
if not np.array_equal(act, idx):
|
||||||
|
bad = act != idx
|
||||||
|
fail.append(f"active area index-exact: {bad.sum()} px differ "
|
||||||
|
f"(left half {bad[:, :128].sum()}, right half {bad[:, 128:].sum()})")
|
||||||
|
|
||||||
|
bars = np.concatenate([g[:yoff], g[yoff+H:]])
|
||||||
|
if bars.size and (bars != 255).any():
|
||||||
|
fail.append(f"letterbox not the reserved black index 255: "
|
||||||
|
f"{(bars != 255).sum()} px")
|
||||||
|
|
||||||
|
if (act == 0).any():
|
||||||
|
fail.append(f"index 0 appeared in the picture: {(act == 0).sum()} px "
|
||||||
|
f"-- it is the transparency key and must stay unused")
|
||||||
|
|
||||||
|
for x in fail:
|
||||||
|
print("FAIL " + x)
|
||||||
|
if fail:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF
|
||||||
|
f = pal >> 3
|
||||||
|
render = lambda I: p6((f << 1) | I[:, None])
|
||||||
|
I = (((render(np.ones(256, int)) - pal) ** 2).sum(1)
|
||||||
|
< ((render(np.zeros(256, int)) - pal) ** 2).sum(1)).astype(int)
|
||||||
|
mse = ((render(I)[act].astype(int) - pal[idx]) ** 2).mean()
|
||||||
|
print(f"OK {raw}: px68k renders the packed layout index-exact over {W}x{H}, "
|
||||||
|
f"letterbox on the reserved black")
|
||||||
|
print(f" palette ceiling vs 24-bit palettised source: "
|
||||||
|
f"{10*np.log10(255**2/mse):.2f} dB")
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
-- Time and verify src/player/load.i on the emulated 68000 (ROADMAP P1+P2).
|
||||||
|
--
|
||||||
|
-- Two questions, one run, exactly as decode.lua asks them of the decoder:
|
||||||
|
-- 1. CORRECTNESS. Does the 68000 produce, out of the RAW container header,
|
||||||
|
-- byte for byte what tools/bench/dlxload.py produces host-side? The
|
||||||
|
-- expanded codebooks are read back out of RAM and the palette out of the
|
||||||
|
-- PALETTE REGISTERS -- not out of a RAM shadow, because "the words reached
|
||||||
|
-- $E82000" is the claim being tested. tools/bench/verify_load.py does the
|
||||||
|
-- comparison against dlxload.py, so the ground truth stays in one place.
|
||||||
|
-- 2. COST. How long does it take, split into the codebook expansion and the
|
||||||
|
-- palette pack, and what is that as a fraction of a 12 fps frame -- the
|
||||||
|
-- only unit this project prices anything in.
|
||||||
|
--
|
||||||
|
-- Nothing here is pre-chewed: the blob pushed into RAM is the first 5,920 bytes
|
||||||
|
-- of the container as they come off the disc. That is the whole point of the
|
||||||
|
-- exercise, and it is also, not incidentally, exactly the read a player has to
|
||||||
|
-- complete at a scene change before it can draw a single frame.
|
||||||
|
--
|
||||||
|
-- MEASUREMENT SCOPE, unchanged from decode.lua: MAME's memory carries no wait
|
||||||
|
-- states, so these are pure 68000 instruction cycles -- a LOWER BOUND on real
|
||||||
|
-- hardware. Interrupts are masked (SR=$2700). The host clock has 1/56.69 s
|
||||||
|
-- granularity and the job takes milliseconds, so each configuration is repeated
|
||||||
|
-- LITER times and divided; repeating is honest because do_load is not
|
||||||
|
-- temporally recursive -- every pass rewrites what the last one wrote, from the
|
||||||
|
-- same source bytes.
|
||||||
|
|
||||||
|
M = manager.machine
|
||||||
|
SP = M.devices[":maincpu"].spaces["program"]
|
||||||
|
|
||||||
|
local function findfile(n)
|
||||||
|
for _,p in ipairs{"../tools/bench/"..n, "tools/bench/"..n, n} do
|
||||||
|
local f = io.open(p,"rb"); if f then f:close(); return p end
|
||||||
|
end
|
||||||
|
error(n.." not found")
|
||||||
|
end
|
||||||
|
local MODE = loadfile(findfile("crtc_mode.lua"))()
|
||||||
|
local META = loadfile("load_meta.lua")()
|
||||||
|
|
||||||
|
local LFLAG, LHDR, LDARK = 0x18040, 0x18044, 0x18048
|
||||||
|
local LK1, LK4, LMODE, LITER = 0x1804C, 0x18050, 0x18054, 0x18058
|
||||||
|
local CB1, CB4, RAW = 0x20000, 0x22000, 0x30000
|
||||||
|
local GPAL = 0xE82000
|
||||||
|
local CPUHZ = 10000000 -- x68k.cpp:1133, 40_MHz_XTAL/4
|
||||||
|
local FPS = 12
|
||||||
|
local FRAME12 = CPUHZ / FPS
|
||||||
|
local ITER = tonumber(os.getenv("DLX_LOAD_ITER") or "40")
|
||||||
|
|
||||||
|
local code do local f=io.open("loadgate.bin","rb"); code=f:read("a"); f:close() end
|
||||||
|
local data do local f=io.open("load_data.bin","rb"); data=f:read("a"); f:close() end
|
||||||
|
|
||||||
|
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
|
||||||
|
local function P(s) print("[LOD] "..s) end
|
||||||
|
|
||||||
|
local function push(addr, s, from, len)
|
||||||
|
local i, n = from, len
|
||||||
|
while n >= 4 do
|
||||||
|
SP:write_u32(addr, (string.unpack(">I4", s, i)))
|
||||||
|
addr, i, n = addr+4, i+4, n-4
|
||||||
|
end
|
||||||
|
while n > 0 do
|
||||||
|
SP:write_u8(addr, string.byte(s,i)); addr, i, n = addr+1, i+1, n-1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Poison every destination before each run. Without this a stage that wrote
|
||||||
|
-- NOTHING would still compare equal to the previous stage's output, and the
|
||||||
|
-- palette-only run would "pass" the codebook check for free.
|
||||||
|
--
|
||||||
|
-- The three scratch tables are poisoned only before a run that CLAIMS to build
|
||||||
|
-- them (mode bit 2). They are scene-independent, so the palette-entry stage is
|
||||||
|
-- entitled to find them already there -- that is the whole point of measuring
|
||||||
|
-- it separately -- but a stage that says it builds them must be shown to.
|
||||||
|
local P6TAB, TABEND = 0x19000, 0x19340
|
||||||
|
local function poison(mode)
|
||||||
|
for a = CB1, CB1 + META.cb1_len - 2, 2 do SP:write_u16(a, 0xDEAD) end
|
||||||
|
for a = CB4, CB4 + META.cb4_len - 2, 2 do SP:write_u16(a, 0xDEAD) end
|
||||||
|
for c = 0, 255 do SP:write_u16(GPAL + c*2, 0xDEAD) end
|
||||||
|
SP:write_u32(LDARK, 0xFFFFFFFF)
|
||||||
|
if mode & 4 ~= 0 then
|
||||||
|
for a = P6TAB, TABEND - 2, 2 do SP:write_u16(a, 0xDEAD) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setup()
|
||||||
|
MODE.apply(SP)
|
||||||
|
push(RAW, data, 1, META.raw_len)
|
||||||
|
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
|
||||||
|
P(string.format("loaded loadgate.bin=%d B, raw container header %d B at 0x%X",
|
||||||
|
#code, META.raw_len, RAW))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function launch(mode, iter)
|
||||||
|
poison(mode)
|
||||||
|
SP:write_u32(LFLAG, 0)
|
||||||
|
SP:write_u32(LHDR, RAW)
|
||||||
|
SP:write_u32(LMODE, mode)
|
||||||
|
SP:write_u32(LITER, iter)
|
||||||
|
local cpu = M.devices[":maincpu"]
|
||||||
|
cpu.state["SR"].value = 0x2700 -- supervisor, ALL interrupts masked
|
||||||
|
cpu.state["SP"].value = 0x8000
|
||||||
|
cpu.state["PC"].value = 0x10000
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Written after the mode-3 run, and only after it: it is the output of ONE
|
||||||
|
-- do_load call over the whole header, which is what the player does.
|
||||||
|
local function dump()
|
||||||
|
local out = io.open("load_out.bin", "wb")
|
||||||
|
for a = CB1, CB1 + META.cb1_len - 1 do out:write(string.char(SP:read_u8(a))) end
|
||||||
|
for a = CB4, CB4 + META.cb4_len - 1 do out:write(string.char(SP:read_u8(a))) end
|
||||||
|
for c = 0, 255 do out:write(string.pack(">I2", SP:read_u16(GPAL + c*2) & 0xFFFF)) end
|
||||||
|
out:close()
|
||||||
|
P(string.format("dumped %d B of 68000 output to tmp/load_out.bin",
|
||||||
|
META.cb1_len + META.cb4_len + 512))
|
||||||
|
P(string.format("DARK=%d (host-side dlxload.py says %d), K1=%d K4=%d",
|
||||||
|
SP:read_u32(LDARK), META.dark, SP:read_u32(LK1), SP:read_u32(LK4)))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Order matters: the scratch tables are built by the first stage and the
|
||||||
|
-- palette-entry stage runs on them, which is exactly how a player would be
|
||||||
|
-- arranged. The two stages that stand for real player events -- boot, and a
|
||||||
|
-- scene change -- come last, and the dump the verifier checks is taken from the
|
||||||
|
-- BOOT one, so the path that is proved correct is the one that builds
|
||||||
|
-- everything from nothing.
|
||||||
|
local PLAN = {
|
||||||
|
{name="scratch tables only (boot, once)", mode=4, iter=ITER},
|
||||||
|
{name="codebook expansion only (P1)", mode=1, iter=ITER},
|
||||||
|
{name="palette entries only (P2)", mode=2, iter=ITER},
|
||||||
|
{name="BOOT: tables + codebooks + palette", mode=7, iter=ITER, dump=true},
|
||||||
|
{name="SCENE CHANGE: codebooks + palette", mode=3, iter=ITER},
|
||||||
|
}
|
||||||
|
|
||||||
|
local step, st, t0 = 0, "boot", nil
|
||||||
|
local results = {}
|
||||||
|
|
||||||
|
SUB = emu.add_machine_frame_notifier(function()
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
local t = T()
|
||||||
|
if st == "boot" then
|
||||||
|
if t < 3.0 then return end
|
||||||
|
setup(); step = 1; launch(PLAN[1].mode, PLAN[1].iter)
|
||||||
|
st, t0 = "running", nil; return
|
||||||
|
end
|
||||||
|
if st == "running" then
|
||||||
|
local fl = SP:read_u32(LFLAG)
|
||||||
|
if fl == 1 and not t0 then t0 = t; return end
|
||||||
|
if fl == 0xEE then
|
||||||
|
P("BAD HEADER -- load.i did not find the 'DLX3' magic at LHDR")
|
||||||
|
M:exit(); return
|
||||||
|
end
|
||||||
|
if fl == 0xFF then
|
||||||
|
local p = PLAN[step]
|
||||||
|
local dt = t - (t0 or t)
|
||||||
|
local cyc = dt * CPUHZ / p.iter
|
||||||
|
results[#results+1] = {name=p.name, cyc=cyc}
|
||||||
|
P(string.format("%s: %d passes in %.4f s -> %.0f cycles = %.1f%% of a "
|
||||||
|
.."%dfps frame (%.2f ms)", p.name, p.iter, dt, cyc,
|
||||||
|
100*cyc/FRAME12, FPS, 1000*cyc/CPUHZ))
|
||||||
|
if p.dump then dump() end
|
||||||
|
step = step + 1
|
||||||
|
if PLAN[step] then launch(PLAN[step].mode, PLAN[step].iter); st, t0 = "running", nil
|
||||||
|
else st = "finish" end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if t > 400 then P("TIMEOUT flag="..string.format("%08X",fl)); M:exit() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if st == "finish" then
|
||||||
|
P("---- summary (instruction cycles only; real RAM adds wait states) ----")
|
||||||
|
for _,r in ipairs(results) do
|
||||||
|
P(string.format(" %-44s %8.0f cyc %5.1f%% of a frame %6.2f ms",
|
||||||
|
r.name, r.cyc, 100*r.cyc/FRAME12, 1000*r.cyc/CPUHZ))
|
||||||
|
end
|
||||||
|
P("done")
|
||||||
|
M:exit()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if not ok then print("[LOD] LUA ERROR: "..tostring(err)); M:exit() end
|
||||||
|
end)
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# One load-time transform run: the 68000 builds its own codebooks and palette
|
||||||
|
# out of the RAW container header, on both CPU cores (ROADMAP P1+P2, FINDINGS
|
||||||
|
# 53).
|
||||||
|
#
|
||||||
|
# tools/bench/load_run.sh [container]
|
||||||
|
#
|
||||||
|
# Both instruments run the same loadgate.bin over the same header bytes:
|
||||||
|
# * MAME, which is the only one of the two with real PALETTE REGISTERS -- the
|
||||||
|
# packed words are read back out of $E82000, not out of a RAM shadow, so
|
||||||
|
# "the words reached the hardware" is part of what passes.
|
||||||
|
# * px68k's C68K, which is exact to the cycle and counts BUS cycles, and is a
|
||||||
|
# second opinion on the cost from a separately written cycle table.
|
||||||
|
# Both outputs are compared byte-for-byte against tools/bench/dlxload.py, which
|
||||||
|
# stays the reference: this code replaces where those transforms RUN, not what
|
||||||
|
# they produce.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
DLX=${1:-tmp/rc_fr_singe_scsi_span.dlx}
|
||||||
|
PX68K=${PX68K:-$HOME/src/px68k}
|
||||||
|
ITER=${DLX_LOAD_ITER:-40}
|
||||||
|
|
||||||
|
tools/vasm/vasmm68k_mot -Fbin -o tmp/loadgate.bin src/player/loadgate.s > /dev/null
|
||||||
|
python3 tools/bench/prep_load.py "$DLX" > tmp/prep_load.log
|
||||||
|
cat tmp/prep_load.log
|
||||||
|
|
||||||
|
# stdbuf -oL: without it a long MAME run is unobservable until it exits, and a
|
||||||
|
# run that is merely finishing looks exactly like one that is wedged (34.1).
|
||||||
|
( cd tmp && DLX_LOAD_ITER=$ITER SDL_VIDEODRIVER=dummy stdbuf -oL timeout -k 5 180 \
|
||||||
|
mame x68000 -bios ipl10 -ramsize 2M -video soft -window -sound none \
|
||||||
|
-nothrottle -plugins -autoboot_script ../tools/bench/load.lua \
|
||||||
|
-seconds_to_run 30 > load_check.log 2>&1 )
|
||||||
|
# A run that never reached the dump must fail as that, not as a byte mismatch.
|
||||||
|
grep -q "^\[LOD\] done" tmp/load_check.log || {
|
||||||
|
echo "FAIL: the load rig did not finish -- no completion marker."
|
||||||
|
tail -6 tmp/load_check.log; exit 1; }
|
||||||
|
grep -a "^\[LOD\]" tmp/load_check.log | sed -n '/summary/,$p' | sed 's/\[LOD\] / /'
|
||||||
|
python3 tools/bench/verify_load.py "$DLX"
|
||||||
|
|
||||||
|
if [ -f "$PX68K/m68000/c68k.c" ]; then
|
||||||
|
make -s -C tools/bench/c68k PX68K="$PX68K" 2>/dev/null
|
||||||
|
for M in 4 1 2 7 3; do
|
||||||
|
tools/bench/c68k/c68k_bench --code tmp/loadgate.bin --loadraw tmp/load_data.bin \
|
||||||
|
--loadmode $M --loaditer 1 --cb1 8192 --cb4 2048 \
|
||||||
|
$([ $M = 3 ] && echo "--loaddump tmp/load_c68k.bin") 2>&1 >/dev/null \
|
||||||
|
| grep -av arena | sed 's/\[C68K\] / /'
|
||||||
|
done
|
||||||
|
# The second core's bytes are held to the same standard as the first's.
|
||||||
|
cmp -s tmp/load_c68k.bin tmp/load_out.bin || {
|
||||||
|
echo "FAIL: the two CPU cores produced DIFFERENT load-time output."; exit 1; }
|
||||||
|
echo " OK both CPU cores produced the same $(stat -c%s tmp/load_out.bin) B"
|
||||||
|
else
|
||||||
|
echo " SKIPPED: no px68k at $PX68K (set PX68K= to point at a checkout)"
|
||||||
|
fi
|
||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build the SCSI VOLUME the P4 rigs read, and the blank card ROM MAME needs to
|
||||||
|
# instantiate the card. Sourced-by-calling from tools/bench/scsi_run.sh and
|
||||||
|
# tools/bench/pace_run.sh so there is ONE copy of the layout.
|
||||||
|
#
|
||||||
|
# tools/bench/mkvol.sh [container.dlx | container.dlxp]
|
||||||
|
#
|
||||||
|
# TWO CONTAINERS, ONE VOLUME BUILDER. A DLX volume is tools/bench/prep_stream.py's
|
||||||
|
# disk image -- the codec's records, laid down from sector 0. A DLXP volume
|
||||||
|
# (ROADMAP K2/K3) needs no preparation at all: the container is ALREADY a
|
||||||
|
# sector-aligned image of itself -- a 512 B header, then fixed 97-sector records
|
||||||
|
# -- so the file IS the volume and copying it is the whole build. That is not a
|
||||||
|
# convenience, it is the format's central claim (tools/encoder/dlxp.py) arriving
|
||||||
|
# at the disc, and a builder that transformed it on the way would be hiding the
|
||||||
|
# claim rather than testing it.
|
||||||
|
#
|
||||||
|
# The two get DIFFERENT CHD NAMES. Alternating between the packed rig and the
|
||||||
|
# codec rig would otherwise rebuild the volume on every run, and -- much worse --
|
||||||
|
# a stale CHD under the name the other rig expected would serve one container's
|
||||||
|
# bytes to the other's gate, which reads as a decode failure and is not one.
|
||||||
|
#
|
||||||
|
# ONE COPY, ON PURPOSE. The volume is tmp/stream_disk.bin -- byte for byte the
|
||||||
|
# file the host-file ring rig reads -- laid out as 512 B sectors. If two scripts
|
||||||
|
# each built it, a difference between the SCSI rig and the modelled-transport rig
|
||||||
|
# could be a difference in what they were reading, and the whole value of running
|
||||||
|
# both is that it cannot be. This tree has already paid twice for a transform
|
||||||
|
# with two copies of itself (FINDINGS 49.7.5, and check.sh's dlxload note).
|
||||||
|
#
|
||||||
|
# THE BLANK BOOT ROM is the substitution session 25 argued for and it is
|
||||||
|
# unchanged: MAME refuses to instantiate the CZ-6BS1 without an 8 KB
|
||||||
|
# `scsiexrom.bin` (CRC 7be488de) that the player never executes, so a zero-filled
|
||||||
|
# placeholder goes on a SEPARATE rompath and the user's romset is untouched.
|
||||||
|
# MAME prints WRONG CHECKSUMS, as it should. DO NOT reuse this rompath for
|
||||||
|
# anything that boots from the card or calls SCSI IOCS -- those DO execute it.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
DLX=${1:-tmp/rc_fr_singe_scsi_span.dlx}
|
||||||
|
|
||||||
|
case "$DLX" in
|
||||||
|
*.dlxp) SRC="$DLX"
|
||||||
|
IMG=tmp/dlxpdisk.img; CHD=tmp/dlxpdisk.chd ;;
|
||||||
|
*) SRC=tmp/stream_disk.bin
|
||||||
|
IMG=tmp/dlxdisk.img; CHD=tmp/dlxdisk.chd
|
||||||
|
[ -f "$SRC" ] || python3 tools/bench/prep_stream.py "$DLX" > /dev/null ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -f "$CHD" ] || [ "$SRC" -nt "$CHD" ]; then
|
||||||
|
SRC="$SRC" IMG="$IMG" python3 - <<'PY'
|
||||||
|
import os
|
||||||
|
src, img = os.environ["SRC"], os.environ["IMG"]
|
||||||
|
d = open(src, "rb").read()
|
||||||
|
n = (len(d) + 511) // 512
|
||||||
|
open(img, "wb").write(d + b"\0" * (n * 512 - len(d)))
|
||||||
|
print(f" disc image: {len(d)} B of records -> {n} sectors")
|
||||||
|
PY
|
||||||
|
rm -f "$CHD"
|
||||||
|
# -c none IS LOAD-BEARING, and it was found by a gate rather than by taste.
|
||||||
|
# Session 28, on the DLX5 volume: with the default (lzma/zlib/huff/flac) MAME
|
||||||
|
# 0.277 served the CHD FILE'S OWN BYTES as sector data -- the destination
|
||||||
|
# buffer after READ(10) at LBA 0 was byte-for-byte the first 4,096 bytes of
|
||||||
|
# dlxdisk.chd, starting "MComprHD" -- while `chdman verify` reported both SHA1s
|
||||||
|
# correct. Uncompressed, the identical image reads byte-exact. The trigger is
|
||||||
|
# the image's CONTENT: the same 8,768-sector length that works for the DLX4
|
||||||
|
# volume fails for the DLX5 one, a conventional 16x63 geometry fails too, and
|
||||||
|
# `-c zlib` alone fails as well. The MAME-side cause is NOT diagnosed; what is
|
||||||
|
# measured is that compression decides it and uncompressed is sound.
|
||||||
|
# Costs 4.5 MB in tmp/ against 1.6 MB. DO NOT restore compression to save the
|
||||||
|
# disc space: the failure is SILENT at the transport layer -- every READ(10)
|
||||||
|
# reports success and returns the wrong bytes -- and only the byte comparison
|
||||||
|
# in tools/bench/scsi.lua catches it.
|
||||||
|
chdman createhd -i "$IMG" -o "$CHD" -ss 512 -c none > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p tmp/p4roms/x68k_cz6bs1
|
||||||
|
[ -f tmp/p4roms/x68k_cz6bs1/scsiexrom.bin ] || \
|
||||||
|
head -c 8192 /dev/zero > tmp/p4roms/x68k_cz6bs1/scsiexrom.bin
|
||||||
Executable
+120
@@ -0,0 +1,120 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# One paced ring-buffer run (STATUS item 4, FINDINGS 49.7.2).
|
||||||
|
#
|
||||||
|
# tools/bench/check.sh runs the ring pass FREE-RUNNING, which is right for what
|
||||||
|
# it gates -- wrap correctness at a fixed ring size, delivery removed as a
|
||||||
|
# variable by an unlimited pipe. It cannot answer the buffering question,
|
||||||
|
# because a free-running decoder never lets the ring back up.
|
||||||
|
#
|
||||||
|
# This runs the same rig with the decoder held to the container's frame rate,
|
||||||
|
# so the ring fills and FR_HEAD-FR_TAIL means "frames the decoder could still
|
||||||
|
# draw with the pipe dead". Every run is verified PIXEL-EXACT: a paced decode
|
||||||
|
# that drops a pixel is not a slack measurement, it is a bug.
|
||||||
|
#
|
||||||
|
# tools/bench/pace_run.sh <ring_kb> <kbps> [cut_at_tick] [cut_frames]
|
||||||
|
#
|
||||||
|
# kbps 0 = unlimited pipe. There is no default rate anywhere in this tree
|
||||||
|
# (FINDINGS 50) and there is none here either.
|
||||||
|
#
|
||||||
|
# DLX_RINGOWN=1 hands the RING to the 68000 as well (ROADMAP P5,
|
||||||
|
# src/player/ring.i): this script's Lua stops placing records and becomes a
|
||||||
|
# transport that answers one request at a time. DLX_PREFILL_FR is then the
|
||||||
|
# prefill policy, in whole records. It needs a DLX4 container, because the
|
||||||
|
# machine cannot learn a record's length by walking a stream it has not fetched.
|
||||||
|
#
|
||||||
|
# DLX_ITER=2 runs the scene TWICE, which under DLX_RINGOWN means a real seek
|
||||||
|
# between the passes: the channel goes quiet, the ring is declared empty and the
|
||||||
|
# whole accumulated lookahead is thrown away and rebuilt from the prefill. It
|
||||||
|
# needs DLX_PACE=2, because rebasing the frame clock across a pass is the
|
||||||
|
# machine's to do and a host-written tick would carry on counting.
|
||||||
|
#
|
||||||
|
# DLX_XFER=scsi replaces the MODELLED transport with a real one (ROADMAP P4b,
|
||||||
|
# src/player/xfer.i): the machine gets a CZ-6BS1 and the same volume the SCSI
|
||||||
|
# gate reads, this script stops moving bytes altogether, and every record is
|
||||||
|
# fetched by the 68000 with READ(10). It needs DLX_RINGOWN=1 -- the mailbox it
|
||||||
|
# answers is ring.i's -- and it FORBIDS a modelled rate, because there is no
|
||||||
|
# longer anything for one to model.
|
||||||
|
#
|
||||||
|
# DLX_PACE selects WHO KEEPS THE TIME: 1 (default) is the host writing the tick,
|
||||||
|
# 2 is the 68000 writing it off the CRTC's V-DISP (ROADMAP P3, FINDINGS 54).
|
||||||
|
# Everything else about the run is identical, which is the whole point -- the
|
||||||
|
# pace gate in src/player/stream.s cannot tell them apart, so a difference in
|
||||||
|
# the result is a difference in the CLOCK and not in the rig.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
RING=${1:?ring KB}; KBPS=${2:?pipe KB/s, or 0 for unlimited}
|
||||||
|
CUT_AT=$3; CUT_FR=${4:-1}
|
||||||
|
DLX=${DLX:-tmp/rc_fr_singe_scsi_span.dlx}
|
||||||
|
PACE=${DLX_PACE:-1}
|
||||||
|
OWN=${DLX_RINGOWN:-0}
|
||||||
|
ITERS=${DLX_ITER:-1}
|
||||||
|
XFER=${DLX_XFER:-model}
|
||||||
|
# EMULATED seconds the run is allowed. A pass that is cut short compares a
|
||||||
|
# half-drawn screen and reads as a wrap bug, so this is raised deliberately
|
||||||
|
# rather than left to a timeout: a DLX_XFER=scsi pass costs ~46 s of emulated
|
||||||
|
# time against the modelled transport's ~7, because the CPU moves every byte
|
||||||
|
# itself (FINDINGS 58.2), and two of them do not fit in 90.
|
||||||
|
SECS=${DLX_SECONDS:-90}
|
||||||
|
if [ "$XFER" = scsi ]; then
|
||||||
|
[ "$OWN" = 1 ] || { echo "DLX_XFER=scsi needs DLX_RINGOWN=1: the transport in"
|
||||||
|
echo "src/player/xfer.i answers src/player/ring.i's mailbox, and with the"
|
||||||
|
echo "host owning the ring there is no mailbox to answer."; exit 2; }
|
||||||
|
# A rate is not merely ignored here, it is REFUSED. The bytes now arrive on
|
||||||
|
# the emulated machine's own time, and a run labelled "488 KB/s" that did not
|
||||||
|
# deliver at 488 KB/s is exactly the kind of number this project has twice
|
||||||
|
# paid for. There is no rate in a DLX_XFER=scsi run, and the log says so.
|
||||||
|
[ "$KBPS" = 0 ] || { echo "DLX_XFER=scsi takes kbps 0. The transport is real,"
|
||||||
|
echo "so nothing here delivers at a modelled rate -- and MAME's device"
|
||||||
|
echo "models are functional, not transfer-timing accurate, so the rate it"
|
||||||
|
echo "DOES deliver at is not a measurement either (docs/BENCHMARK.md)."
|
||||||
|
exit 2; }
|
||||||
|
command -v chdman > /dev/null || { echo "DLX_XFER=scsi needs chdman (ships"
|
||||||
|
echo "with mame-tools) to build the volume."; exit 2; }
|
||||||
|
bash tools/bench/mkvol.sh "$DLX"
|
||||||
|
fi
|
||||||
|
if [ "$OWN" = 1 ] && [ "$ITERS" != 1 ] && [ "$PACE" != 2 ]; then
|
||||||
|
echo "DLX_ITER>1 needs DLX_PACE=2: the frame clock is rebased per pass by"
|
||||||
|
echo "src/player/stream.s, and a host-written tick would go on counting"
|
||||||
|
echo "through the seek and open every slot of the second pass at once."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
TAG="r${RING}_k${KBPS}${CUT_AT:+_cut${CUT_AT}x${CUT_FR}}"
|
||||||
|
# The default tag is left ALONE when the host keeps the time: tools/bench/
|
||||||
|
# pace_sweep.sh reads tmp/pace_r<ring>_k<kbps>.log by name, and renaming the
|
||||||
|
# host-paced logs would break a sweep that has nothing to do with this option.
|
||||||
|
if [ "$PACE" != 1 ]; then TAG="${TAG}_p$PACE"; fi
|
||||||
|
if [ "$OWN" = 1 ]; then TAG="${TAG}_own"; fi
|
||||||
|
if [ "$ITERS" != 1 ]; then TAG="${TAG}_x$ITERS"; fi
|
||||||
|
if [ "$XFER" != model ]; then TAG="${TAG}_$XFER"; fi
|
||||||
|
MAMEX=()
|
||||||
|
if [ "$XFER" = scsi ]; then
|
||||||
|
MAMEX=(-exp1 cz6bs1 -rompath "$HOME/mame/roms;./p4roms" -hard dlxdisk.chd)
|
||||||
|
fi
|
||||||
|
|
||||||
|
tools/vasm/vasmm68k_mot -Fbin -o tmp/stream.bin src/player/stream.s > /dev/null
|
||||||
|
[ -f tmp/stream_disk.bin ] || python3 tools/bench/prep_stream.py "$DLX" > tmp/prep_stream.log
|
||||||
|
mkdir -p "tmp/snap_pace_$TAG"; rm -f "tmp/snap_pace_$TAG/x68000"/*.png
|
||||||
|
# `env` rather than an assignment prefix: an empty ${CUT_AT:+...} in the middle
|
||||||
|
# of a prefix is not an assignment token, so bash takes the next word as the
|
||||||
|
# command and the run dies with "SDL_VIDEODRIVER=dummy: command not found".
|
||||||
|
CUTENV=(); [ -n "$CUT_AT" ] && CUTENV=(DLX_CUT_AT="$CUT_AT" DLX_CUT_FR="$CUT_FR")
|
||||||
|
( cd tmp && env DLX_PACE=$PACE DLX_RING_KB=$RING DLX_STREAM_KBPS=$KBPS \
|
||||||
|
DLX_RINGOWN=$OWN DLX_ITER=$ITERS DLX_XFER=$XFER \
|
||||||
|
${DLX_PREFILL_FR:+DLX_PREFILL_FR=$DLX_PREFILL_FR} \
|
||||||
|
"${CUTENV[@]}" DLX_SLACK_CSV="slack_$TAG.csv" \
|
||||||
|
SDL_VIDEODRIVER=dummy stdbuf -oL timeout -k 5 900 \
|
||||||
|
mame x68000 -bios ipl10 "${MAMEX[@]}" -ramsize 2M -video soft -window \
|
||||||
|
-sound none \
|
||||||
|
-nothrottle -plugins -autoboot_script ../tools/bench/stream.lua \
|
||||||
|
-snapshot_directory "./snap_pace_$TAG" -snapview native \
|
||||||
|
-seconds_to_run $SECS \
|
||||||
|
> "pace_$TAG.log" 2>&1 )
|
||||||
|
# The completion marker is not optional: a run killed mid-decode compares a
|
||||||
|
# half-drawn screen and reads as a wrap bug rather than as a truncated run.
|
||||||
|
grep -q "snapshot taken" "tmp/pace_$TAG.log" || {
|
||||||
|
echo "FAIL($TAG): no snapshot marker -- the pass did not complete."
|
||||||
|
tail -6 "tmp/pace_$TAG.log"; exit 1; }
|
||||||
|
echo "=== $TAG"
|
||||||
|
grep -aE "decoder (SELF-PACED|PACED|FREE)|FRAME CLOCK|ring: |UNDERRUNS|NO IDLE|SEEK SLACK|RING-BOUND|RATE-BOUND|BUILD TIME|PIPE CUT|DEADLINE|REQUIRED|MACHINE-OWNED|PREFILL:|CHANNEL IDLE|MISPLACED|SEEK PASS|REAL TRANSPORT|SECTOR OVERHEAD|TRANSPORT FAILED|IS VACUOUS" \
|
||||||
|
"tmp/pace_$TAG.log" | sed "s/\[STR\] / /"
|
||||||
|
python3 tools/bench/verify_decode.py "$DLX" --snap "tmp/snap_pace_$TAG" | tail -2
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Ring x pipe grid for the PACED rig (STATUS item 4, FINDINGS 51).
|
||||||
|
#
|
||||||
|
# tools/bench/pace_sweep.sh "<ring KB list>" "<KB/s list>"
|
||||||
|
#
|
||||||
|
# Every cell is a full 120-frame decode on the emulated 68000, pixel-verified.
|
||||||
|
# There is no default rate list: FINDINGS 50 removed the delivery constant from
|
||||||
|
# this tree and a sweep that invented one back would be the same mistake with
|
||||||
|
# more rows. `0` means an unlimited pipe, which measures the RING's ceiling with
|
||||||
|
# delivery removed as a variable -- an upper bound, not a prediction.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
RINGS=${1:?ring KB list, quoted}
|
||||||
|
RATES=${2:?pipe KB/s list, quoted, 0 = unlimited}
|
||||||
|
printf "%6s %9s %9s %9s %8s %9s %s\n" ring kbps ceiling build_s mean underruns bound
|
||||||
|
for R in $RINGS; do for K in $RATES; do
|
||||||
|
L=tmp/pace_r${R}_k${K}.log
|
||||||
|
bash tools/bench/pace_run.sh "$R" "$K" > /dev/null 2>&1 || { \
|
||||||
|
printf "%6s %9s FAILED (see %s)\n" "$R" "$K" "$L"; continue; }
|
||||||
|
python3 - "$L" "$R" "$K" <<'PY'
|
||||||
|
import re, sys
|
||||||
|
log, ring, kbps = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
t = open(log, errors="replace").read()
|
||||||
|
def g(p, d="?"):
|
||||||
|
m = re.search(p, t)
|
||||||
|
return m.group(1) if m else d
|
||||||
|
ceil_ = g(r"SEEK SLACK: ceiling (\d+) frames")
|
||||||
|
build = g(r"BUILD TIME: (\d+) ticks")
|
||||||
|
mean = g(r"mean ([\d.]+) over the window")
|
||||||
|
under = g(r"UNDERRUNS: (\d+)/")
|
||||||
|
bound = "ring" if "RING-BOUND" in t else ("rate" if "RATE-BOUND" in t else "?")
|
||||||
|
fps = 12.0
|
||||||
|
print("%6s %9s %9s %9.2f %8s %9s %s" % (
|
||||||
|
ring, kbps, ceil_, (int(build)/fps if build.isdigit() else -1), mean, under, bound))
|
||||||
|
PY
|
||||||
|
done; done
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
-- Drive src/player/packed.s: THE DECODER-FREE PACKED PLAYER, END TO END,
|
||||||
|
-- OFF A REAL VOLUME. ROADMAP K3.
|
||||||
|
--
|
||||||
|
-- WHAT THIS SCRIPT DOES NOT DO IS THE POINT OF IT. tools/bench/stream.lua
|
||||||
|
-- pushes expanded codebooks and a packed palette into RAM, plays a transport at
|
||||||
|
-- a modelled byte rate, and writes the frame tick. This one pushes 2,898 bytes
|
||||||
|
-- of 68000 code and eleven mailbox words, and then READS. It moves no picture
|
||||||
|
-- byte, models no rate, sets no CRTC register and writes no palette entry: the
|
||||||
|
-- machine brings up its own display, builds its own chain, keeps its own clock
|
||||||
|
-- off V-DISP and fetches every record itself with READ(10) off a CZ-6BS1.
|
||||||
|
--
|
||||||
|
-- SO THE GATE IS NOT THE ONE THE CODEC USES, AND IT HAD TO CHANGE.
|
||||||
|
-- tools/bench/verify_decode.py checks ONE frame -- the last -- and that audits
|
||||||
|
-- the whole run because the codec is temporally recursive: a SKIP block is a
|
||||||
|
-- claim about the previous frame still being on screen, so the final frame is
|
||||||
|
-- only right if all 120 were. A packed frame is a LITERAL. Frame 119 being
|
||||||
|
-- pixel-exact says nothing whatever about frame 60. This script therefore
|
||||||
|
-- snapshots EVERY frame and tools/bench/verify_packed.py compares all of them;
|
||||||
|
-- the simplification that deleted the ring also deleted the gate's free lunch.
|
||||||
|
--
|
||||||
|
-- WHEN A SNAPSHOT IS TAKEN, and why not on the frame it changed. PG_SHOWN is
|
||||||
|
-- bumped by the 68000 after it clears R20 bit 11, so a change means "a complete
|
||||||
|
-- frame is now displayable". But MAME's screen bitmap for the host frame in
|
||||||
|
-- progress was drawn partly before that instant, so snapshotting immediately
|
||||||
|
-- would sample the write window -- which BLANKS the graphics layer -- for part
|
||||||
|
-- of the picture. A 12 fps frame lasts 4 or 5 host refreshes at 56.69 Hz, so
|
||||||
|
-- waiting SNAP_DELAY whole host frames is safely inside the slot and safely
|
||||||
|
-- after the window closed.
|
||||||
|
--
|
||||||
|
-- Env:
|
||||||
|
-- DLX_PK_HELD 1 = the channel HOLDS THE BUS (burst, max rate), 0 = it
|
||||||
|
-- steals cycles. Not two speeds of one thing: 59.3 showed an
|
||||||
|
-- auto-requested channel is charged by TIME, so held is the
|
||||||
|
-- 68000 stopped for as long as the record takes to arrive.
|
||||||
|
-- Default 1.
|
||||||
|
-- DLX_PK_PACE 1 = the machine holds itself to the container's fps off
|
||||||
|
-- V-DISP (default). 0 free-runs, which tests the CHAIN with
|
||||||
|
-- the clock out of the way.
|
||||||
|
-- DLX_PK_ITER passes over the scene (default 1). >1 exercises the SEEK.
|
||||||
|
-- DLX_PK_SEEK the frame passes after the first start at (default 0, i.e.
|
||||||
|
-- a replay). On the video path the seek IS arithmetic and
|
||||||
|
-- nothing else; on the audio path it is a second read at a
|
||||||
|
-- separate LBA, because a DLXP2 group puts lump k in FRONT of
|
||||||
|
-- its records and a branch lands `f mod F` frames into it
|
||||||
|
-- (FINDINGS 70.3). Setting this to a frame that is NOT a
|
||||||
|
-- multiple of the cadence is the point: 36 of the arcade's
|
||||||
|
-- 409 within-container seek targets land on a boundary and
|
||||||
|
-- 373 do not.
|
||||||
|
-- DLX_PK_NFR play only the first N frames (default: all of them)
|
||||||
|
-- DLX_PK_FPS pace at this rate instead of the container's. NOT a
|
||||||
|
-- cosmetic knob and not a way to make a number look better:
|
||||||
|
-- under MAME the emulated transport takes about a whole 12 fps
|
||||||
|
-- slot to deliver a 49,664 B record, and the write window has
|
||||||
|
-- to be OPEN for all of it -- so at 12 fps there is no instant
|
||||||
|
-- at which a complete frame is displayable and nothing can be
|
||||||
|
-- snapshotted. Pacing slower opens a display interval without
|
||||||
|
-- changing one byte of the transfer, which is what lets the
|
||||||
|
-- PIXEL-EXACTNESS of all 120 frames be gated separately from
|
||||||
|
-- the RATE the emulated transport happens to run at. The two
|
||||||
|
-- are different questions and this is the knob that separates
|
||||||
|
-- them.
|
||||||
|
-- DLX_PK_CSV write the per-frame arrival series here
|
||||||
|
|
||||||
|
M = manager.machine
|
||||||
|
SP = M.devices[":maincpu"].spaces["program"]
|
||||||
|
|
||||||
|
local function findfile(n)
|
||||||
|
for _,p in ipairs{"../tools/bench/"..n, "tools/bench/"..n, n} do
|
||||||
|
local f = io.open(p,"rb"); if f then f:close(); return p end
|
||||||
|
end
|
||||||
|
error(n.." not found")
|
||||||
|
end
|
||||||
|
local META = loadfile("packed_meta.lua")()
|
||||||
|
|
||||||
|
-- src/player/packed.s. Inputs first, then outputs; the split is the file's.
|
||||||
|
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, 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_SEEKF, PG_ARST = 0x18978, 0x1897C
|
||||||
|
local PG_APOS, PG_ASKIP = 0x189C8, 0x189CC
|
||||||
|
local PG_ASKN, PG_ASKB, PG_TSEQ = 0x189D0, 0x189D4, 0x189DC
|
||||||
|
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
|
||||||
|
local SC_ERR = 0x18200
|
||||||
|
local CRTC_R20 = 0xE80028
|
||||||
|
|
||||||
|
local HELD = (os.getenv("DLX_PK_HELD") or "1") == "1"
|
||||||
|
local PACED = (os.getenv("DLX_PK_PACE") or "1") == "1"
|
||||||
|
local ITERS = tonumber(os.getenv("DLX_PK_ITER") or "") or 1
|
||||||
|
local SEEKF = tonumber(os.getenv("DLX_PK_SEEK") or "") or 0
|
||||||
|
-- ROADMAP P6d. 1 = STOP and re-PLAY the chip at a branch. Not a tidiness
|
||||||
|
-- knob: the chip's accumulator has no leak, so the two settings are a large
|
||||||
|
-- decaying error against a small permanent one (FINDINGS 71.3).
|
||||||
|
local ARST = (os.getenv("DLX_PK_ARST") or "0") == "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",
|
||||||
|
"UNEXPECTED PHASE", "POLL TIMEOUT -- a phase never arrived",
|
||||||
|
"NON-ZERO SCSI STATUS",
|
||||||
|
"WINDOWED READ REFUSED -- a channel cannot drop bytes"}
|
||||||
|
|
||||||
|
local code do local f=assert(io.open("packed.bin","rb")); code=f:read("a"); f:close() end
|
||||||
|
|
||||||
|
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
|
||||||
|
local function P(s) print("[PK] "..s) end
|
||||||
|
|
||||||
|
local function setup()
|
||||||
|
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
|
||||||
|
SP:write_u32(PG_FLAG, 0)
|
||||||
|
SP:write_u32(PG_NFR, NFR)
|
||||||
|
SP:write_u32(PG_FPS, FPS)
|
||||||
|
SP:write_u32(PG_LBA0, META.lba0)
|
||||||
|
SP:write_u32(PG_RECS, META.rec_sectors)
|
||||||
|
SP:write_u32(PG_PALL, META.palette_last)
|
||||||
|
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)
|
||||||
|
SP:write_u32(PG_SEEKF, SEEKF)
|
||||||
|
SP:write_u32(PG_ARST, ARST and 1 or 0)
|
||||||
|
-- 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)
|
||||||
|
-- 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)
|
||||||
|
if ITERS > 1 then
|
||||||
|
local grp = (META.cad_f or 0) > 0 and (SEEKF % META.cad_f) or 0
|
||||||
|
P(string.format("SEEK: %d passes, and passes 2..%d start at FRAME %d%s",
|
||||||
|
ITERS, ITERS, SEEKF,
|
||||||
|
(META.cad_f or 0) == 0 and " (silent container)"
|
||||||
|
or string.format(" -- lump %d, %d frame(s) into its group "
|
||||||
|
.."of %d, so the audio needs a second read and a byte "
|
||||||
|
.."offset (FINDINGS 70.3)%s",
|
||||||
|
SEEKF // META.cad_f, grp, META.cad_f,
|
||||||
|
grp == 0 and " -- ON a group boundary, the free case"
|
||||||
|
or "")))
|
||||||
|
P(ARST and " the chip is STOPPED and re-PLAYED at the branch: its "
|
||||||
|
.."accumulator goes to the container's own init and its step "
|
||||||
|
.."index to 0"
|
||||||
|
or " the chip PLAYS THROUGH the branch: it keeps the predictor "
|
||||||
|
.."state the previous scene's audio left it in")
|
||||||
|
end
|
||||||
|
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 = ""
|
||||||
|
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, 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
|
||||||
|
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"
|
||||||
|
..(FPS ~= META.fps and (" -- NOT the container's "
|
||||||
|
..META.fps..", see DLX_PK_FPS") or ""))
|
||||||
|
or "FREE-RUNNING (tests the chain, not the clock)"))
|
||||||
|
P("this script writes NO picture byte, NO palette entry and NO CRTC register: "
|
||||||
|
.."the machine brings up its own display and fetches its own records.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function launch()
|
||||||
|
local cpu = M.devices[":maincpu"]
|
||||||
|
cpu.state["SR"].value = 0x2700 -- supervisor, all interrupts masked;
|
||||||
|
cpu.state["SP"].value = 0x8000 -- clk_init lowers it to $2500 itself
|
||||||
|
cpu.state["PC"].value = 0x10000
|
||||||
|
end
|
||||||
|
|
||||||
|
local st, t0 = "boot", nil
|
||||||
|
local shown, pending, snaps = 0, nil, 0
|
||||||
|
local arrive, hostfr, missed = {}, 0, 0
|
||||||
|
-- WHICH FRAME EACH SNAPSHOT IS. MAME numbers snapshots 0000, 0001, ... in the
|
||||||
|
-- order they were taken, and a frame that could not be sampled leaves no gap in
|
||||||
|
-- that sequence -- so the file name is NOT the frame index and a verifier that
|
||||||
|
-- assumed it was would compare frame 61 against record 60 and report a codec
|
||||||
|
-- bug that is really a bookkeeping one. This is the map, written out for it.
|
||||||
|
local snapfr = {}
|
||||||
|
local r20seen = {}
|
||||||
|
|
||||||
|
SUB = emu.add_machine_frame_notifier(function()
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
local t = T()
|
||||||
|
if st == "boot" then
|
||||||
|
if t < 3.0 then return end
|
||||||
|
setup(); launch(); t0 = t; st = "running"; return
|
||||||
|
end
|
||||||
|
if st ~= "running" then return end
|
||||||
|
hostfr = hostfr + 1
|
||||||
|
|
||||||
|
-- WHAT THE SCREEN MODE WAS, sampled every host frame. R20 bit 11 blanks
|
||||||
|
-- the graphics layer, so this is the only way to see the shutter the player
|
||||||
|
-- is running: the fraction of host frames that found the window OPEN is the
|
||||||
|
-- fraction of the scene the display spent dark, and it is a MEASUREMENT of
|
||||||
|
-- 47.4's cost under MAME rather than a restatement of the prior.
|
||||||
|
local r20 = SP:read_u16(CRTC_R20)
|
||||||
|
r20seen[#r20seen+1] = ((r20 >> 11) & 1)
|
||||||
|
|
||||||
|
local s = SP:read_u32(PG_SHOWN)
|
||||||
|
if s > shown then
|
||||||
|
-- Only the LAST change matters if several landed in one host frame; that
|
||||||
|
-- cannot happen at 12 fps on a 56.69 Hz raster, and if it ever does the
|
||||||
|
-- gate below catches it as a missing snapshot rather than a wrong one.
|
||||||
|
arrive[#arrive+1] = {n = s, t = t - t0}
|
||||||
|
shown = s
|
||||||
|
pending = SNAP_DELAY
|
||||||
|
end
|
||||||
|
if pending then
|
||||||
|
pending = pending - 1
|
||||||
|
if pending <= 0 then
|
||||||
|
pending = nil
|
||||||
|
if (SP:read_u16(CRTC_R20) >> 11) & 1 == 0 then
|
||||||
|
M.video:snapshot(); snaps = snaps + 1
|
||||||
|
snapfr[#snapfr+1] = shown - 1
|
||||||
|
else
|
||||||
|
-- The window was open again when the delay expired: the frame we
|
||||||
|
-- meant to sample is being overwritten. COUNTED, NOT PRINTED -- when
|
||||||
|
-- the transfer is longer than the slot EVERY frame misses, and 119
|
||||||
|
-- identical lines bury the four numbers the run exists to report.
|
||||||
|
-- The count is reported once at the end and the gate reads it there.
|
||||||
|
missed = missed + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local flag = SP:read_u32(PG_FLAG)
|
||||||
|
if flag ~= 1 and pending then
|
||||||
|
-- THE LAST FRAME IS STILL PENDING. packed.s spins in pg_hold with the
|
||||||
|
-- window CLOSED once the scene is over, so the delay can simply run out;
|
||||||
|
-- returning here rather than reporting is what stops the final frame
|
||||||
|
-- being the one frame the gate never sees.
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if flag ~= 1 then
|
||||||
|
st = "done"
|
||||||
|
local wall = t - t0
|
||||||
|
P(string.format("FLAG=$%02X after %.3f s, %d frames shown, %d snapshots, "
|
||||||
|
.."%d frames NOT SAMPLED (the write window had reopened "
|
||||||
|
.."-- the transfer is longer than the display interval)",
|
||||||
|
flag, wall, shown, snaps, missed))
|
||||||
|
local err = SP:read_u32(PG_ERR)
|
||||||
|
if err ~= 0 then
|
||||||
|
P(string.format("TRANSPORT FAILED on frame %d: %s",
|
||||||
|
SP:read_u32(PG_ERRAT), SCERRNAME[err] or ("code "..err)))
|
||||||
|
end
|
||||||
|
P(string.format("array: the 68000 built %d entries (the container wants "
|
||||||
|
.."%d)", SP:read_u32(PG_ARRN), META.entries))
|
||||||
|
-- The first entry, read back out of the machine's own RAM. It is the one
|
||||||
|
-- place palette-first and palette-last are visible as a FACT rather than
|
||||||
|
-- as a flag the rig passed in and the rig read back.
|
||||||
|
P(string.format("chain[0] = MAR $%06X MTC %d ; chain[1] = MAR $%06X MTC %d",
|
||||||
|
SP:read_u32(PG_ARR), SP:read_u16(PG_ARR+4),
|
||||||
|
SP:read_u32(PG_ARR+6), SP:read_u16(PG_ARR+10)))
|
||||||
|
if PACED then
|
||||||
|
local vd = SP:read_u32(PG_VDISP) - SP:read_u32(PG_VD0)
|
||||||
|
-- THE CLOCK, AGAINST THE RASTER THAT DROVE IT. CLK_VDISP counts the
|
||||||
|
-- edges the 68000's ISR SAW. hostfr counts the frames MAME actually
|
||||||
|
-- drew. A held channel halts the CPU, and the MFP's pending bit is one
|
||||||
|
-- bit, so an edge that falls inside a transfer long enough to span two
|
||||||
|
-- of them is an edge the machine can never count. Nothing in this
|
||||||
|
-- project has ever run a transfer and a clock at once, so nothing could
|
||||||
|
-- have seen this before.
|
||||||
|
P(string.format("frame clock: PACE=%d ticks, V-DISP edges SEEN=%d, "
|
||||||
|
.."host frames drawn=%d -> %d edges LOST (%.1f%%)",
|
||||||
|
SP:read_u32(CLK_PACE), vd, hostfr, hostfr - vd,
|
||||||
|
hostfr > 0 and (hostfr-vd)*100/hostfr or 0))
|
||||||
|
local nlate = SP:read_u32(PG_LATE)
|
||||||
|
P(string.format("late frames (tick already past at the gate): %d%s",
|
||||||
|
nlate, nlate > 0 and string.format(", first %d, worst "
|
||||||
|
.."%d ticks", SP:read_u32(PG_LATE1),
|
||||||
|
SP:read_u32(PG_LATEM)) or ""))
|
||||||
|
-- AND WHY `late = 0` IS NOT `on time`. The gate compares the frame
|
||||||
|
-- index against PACE, and PACE is advanced by the ISR that the held
|
||||||
|
-- channel stops the CPU from running. A clock that loses edges loses
|
||||||
|
-- them from BOTH sides of the comparison, so a player whose own clock
|
||||||
|
-- has halved still reports every frame early. The LOST figure above is
|
||||||
|
-- the only thing in this run that can contradict it, and it comes from
|
||||||
|
-- the host's raster count rather than from the machine.
|
||||||
|
if hostfr - vd > 0 then
|
||||||
|
P(string.format(" ...and %d of those ticks were never "
|
||||||
|
.."issued, so `late=%d` is measured against a clock "
|
||||||
|
.."running at %.1f%% of the raster. The player "
|
||||||
|
.."believes it is at %d fps and the screen is at "
|
||||||
|
.."%.2f.", hostfr - vd, nlate, vd*100/hostfr, FPS,
|
||||||
|
FPS * vd / hostfr))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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))
|
||||||
|
-- THE SEEK'S OWN ACCOUNT. PG_ASKN is the second reads and PG_ASKB the
|
||||||
|
-- bytes they skipped at the head of a lump: a seek path with no offset
|
||||||
|
-- term would report the first and zero for the second, and would be
|
||||||
|
-- indistinguishable from a correct one on any target that happened to
|
||||||
|
-- land on a group boundary.
|
||||||
|
local skn, skb = SP:read_u32(PG_ASKN), SP:read_u32(PG_ASKB)
|
||||||
|
if skn > 0 then
|
||||||
|
P(string.format(" SEEK: %d audio seek(s), %d B skipped into "
|
||||||
|
.."the head of a lump. Stream position ended at %d B "
|
||||||
|
.."and the chip was handed %d -- they differ BY the "
|
||||||
|
.."skip, which is the whole reason they are two cells "
|
||||||
|
.."(FINDINGS 71).", skn, skb, SP:read_u32(PG_APOS),
|
||||||
|
byt))
|
||||||
|
end
|
||||||
|
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,'
|
||||||
|
..'"seekn":%d,"seekb":%d,"pos":%d,"seekf":%d,"iters":%d,'
|
||||||
|
..'"arst":%s}\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,
|
||||||
|
SP:read_u32(PG_ASKN), SP:read_u32(PG_ASKB),
|
||||||
|
SP:read_u32(PG_APOS), SEEKF, ITERS,
|
||||||
|
ARST and "true" or "false"))
|
||||||
|
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%%) -- "
|
||||||
|
.."buffer mode blanks the graphics layer, so that is the "
|
||||||
|
.."share of the scene the display spent DARK under MAME",
|
||||||
|
open, #r20seen, #r20seen > 0 and open*100/#r20seen or 0))
|
||||||
|
if #arrive >= 2 then
|
||||||
|
local dts, first, last = {}, arrive[1].t, arrive[#arrive].t
|
||||||
|
for i = 2, #arrive do dts[#dts+1] = arrive[i].t - arrive[i-1].t end
|
||||||
|
table.sort(dts)
|
||||||
|
-- CADENCE, AND THE GRANULARITY IT IS MEASURED AT. PG_SHOWN is sampled
|
||||||
|
-- once per host frame, so a single inter-frame figure is quantised to
|
||||||
|
-- 1/56.69 s = 17.6 ms and the min/median/max below are multiples of it.
|
||||||
|
-- The MEAN over the whole run is not: the quantisation error is bounded
|
||||||
|
-- by one host frame at each END, so over n-1 intervals it is 35 ms/(n-1)
|
||||||
|
-- -- 0.30 ms a frame over 120. Read the mean; the spread is the
|
||||||
|
-- sampler's, not the player's.
|
||||||
|
local mean = (last-first)/(#arrive-1)
|
||||||
|
P(string.format("cadence: %d frames in %.3f s = %.3f fps, mean "
|
||||||
|
.."%.2f ms/frame (+/- %.2f ms, the sampler's); "
|
||||||
|
.."inter-frame min %.1f median %.1f max %.1f ms",
|
||||||
|
#arrive, last-first, (#arrive-1)/(last-first),
|
||||||
|
mean*1000, 35.3/(#arrive-1),
|
||||||
|
dts[1]*1000, dts[math.ceil(#dts/2)]*1000,
|
||||||
|
dts[#dts]*1000))
|
||||||
|
-- THE TRANSPORT'S TIME IS ONLY READABLE OFF A FREE-RUNNING RUN.
|
||||||
|
-- Paced, the mean inter-frame IS THE PACE PERIOD: the player waits for
|
||||||
|
-- its tick and the record's delivery hides inside the slot, so dividing
|
||||||
|
-- the record by that mean reports the clock rather than the transport
|
||||||
|
-- and reports it as a rate. The first cut of this script printed
|
||||||
|
-- "297.4 KB/s" off a 6 fps gate run, which is the pace and not the
|
||||||
|
-- disc. Free-running there is no gate and the loop is transfer-bound,
|
||||||
|
-- so the mean is the transfer.
|
||||||
|
if not PACED then
|
||||||
|
P(string.format(" -> FREE-RUNNING, so the mean IS the "
|
||||||
|
.."transport: a %d B record lands in %.2f ms, i.e. "
|
||||||
|
.."%.1f KB/s and %.1f%% of a %d fps slot. MAME's "
|
||||||
|
.."device models carry no transfer timing "
|
||||||
|
.."(docs/BENCHMARK.md, 42.5), so this is a property "
|
||||||
|
.."of the APPARATUS -- it is not W and it is not a "
|
||||||
|
.."measurement of any medium.",
|
||||||
|
META.rec_bytes, mean*1000,
|
||||||
|
META.rec_bytes/mean/1024,
|
||||||
|
mean*META.fps*100, META.fps))
|
||||||
|
else
|
||||||
|
-- What a paced run CAN say about the transfer, and it says it from
|
||||||
|
-- the display rather than from the clock: the window is open for
|
||||||
|
-- exactly as long as the record takes, so the open fraction times the
|
||||||
|
-- slot is the transfer time, sampled at the host's frame rate.
|
||||||
|
P(string.format(" -> PACED, so this mean is the PACE and "
|
||||||
|
.."NOT the transport. What the run does bound is the "
|
||||||
|
.."transfer: the window was open %.1f%% of a "
|
||||||
|
.."%.2f ms slot = %.1f ms a record.",
|
||||||
|
open*100/#r20seen, mean*1000,
|
||||||
|
open/#r20seen*mean*1000))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if CSV then
|
||||||
|
local f = io.open(CSV, "w")
|
||||||
|
f:write("frame,t_s\n")
|
||||||
|
for _,a in ipairs(arrive) do f:write(string.format("%d,%.6f\n", a.n-1, a.t)) end
|
||||||
|
f:close()
|
||||||
|
P("arrivals -> "..CSV)
|
||||||
|
end
|
||||||
|
do
|
||||||
|
local f = assert(io.open("packed_snaps.csv", "w"))
|
||||||
|
f:write("snapshot,frame\n")
|
||||||
|
for i, fr in ipairs(snapfr) do
|
||||||
|
f:write(string.format("%04d,%d\n", i-1, fr))
|
||||||
|
end
|
||||||
|
f:close()
|
||||||
|
P(string.format("%d snapshots -> tmp/packed_snaps.csv", #snapfr))
|
||||||
|
end
|
||||||
|
P("done")
|
||||||
|
M:exit()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if not ok then print("[PK] LUA ERROR: "..tostring(err)); M:exit() end
|
||||||
|
end)
|
||||||
Executable
+290
@@ -0,0 +1,290 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# THE PACKED PLAYER, END TO END, OFF A REAL VOLUME. ROADMAP K3.
|
||||||
|
#
|
||||||
|
# tools/bench/packed_run.sh [container.dlxp]
|
||||||
|
#
|
||||||
|
# Four runs of src/player/packed.s, and each answers a different question. They
|
||||||
|
# are separate runs because the questions interfere: the write window has to be
|
||||||
|
# OPEN for the whole transfer and buffer mode blanks the graphics layer, so at
|
||||||
|
# the container's own 12 fps there is no instant at which a complete frame is
|
||||||
|
# displayable and the pixel gate has nothing to sample. Pacing slower opens a
|
||||||
|
# display interval without changing one byte of the transfer. Reporting the
|
||||||
|
# rate off the gate run instead would have been the flattering shortcut, and it
|
||||||
|
# reports the PACE rather than the disc -- the first cut of packed.lua did
|
||||||
|
# exactly that and printed 297 KB/s off a 6 fps run.
|
||||||
|
#
|
||||||
|
# 1. GATE, stealing, paced at half rate: 120 records, 120 snapshots, every one
|
||||||
|
# compared. A packed frame is a LITERAL, so unlike the codec's gate the last
|
||||||
|
# frame audits nothing and all 120 have to be checked (verify_packed.py).
|
||||||
|
# 2. RATE, stealing, FREE-RUNNING: the loop is transfer-bound, so the mean
|
||||||
|
# inter-frame IS the emulated transport's time for a record.
|
||||||
|
# 3. RATE, held, FREE-RUNNING: the same, with the bus held.
|
||||||
|
# 4. CLOCK, held, paced at the container's fps: what holding the bus does to a
|
||||||
|
# frame clock built on counting V-DISP interrupts.
|
||||||
|
#
|
||||||
|
# DLX_PK_GATE_ONLY=1 runs 1 alone. That is what tools/bench/check.sh takes: the
|
||||||
|
# green light's job is to catch a regression in the PLAYER, and runs 2-4 measure
|
||||||
|
# the apparatus rather than gate it -- three more MAME jobs for numbers that
|
||||||
|
# cannot change unless MAME does.
|
||||||
|
#
|
||||||
|
# THE APPARATUS is tools/bench/dma_run.sh's -- `x68000 -exp1 cz6bs1` and a
|
||||||
|
# zero-filled scsiexrom.bin on a private rompath -- and the volume is
|
||||||
|
# tools/bench/mkvol.sh's, which for a DLXP container is the container itself.
|
||||||
|
#
|
||||||
|
# WHAT NO RUN HERE MEASURES: `W`, and any rate a real medium would deliver.
|
||||||
|
# MAME's device models carry no transfer timing (docs/BENCHMARK.md, 42.5). What
|
||||||
|
# is measured is the SHAPE -- one channel start, 193 destinations, 120 times,
|
||||||
|
# on a clock the machine keeps itself, with every frame pixel-exact.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
DLXP=${1:-tmp/packed_singe.dlxp}
|
||||||
|
NFR=${DLX_PK_NFR:-120}
|
||||||
|
GATE_FPS=${DLX_PK_GATE_FPS:-6}
|
||||||
|
|
||||||
|
bash tools/bench/mkvol.sh "$DLXP"
|
||||||
|
python3 tools/bench/prep_packed.py "$DLXP"
|
||||||
|
# WHICH ORDER THIS CONTAINER USES, read out of the container rather than
|
||||||
|
# assumed. FINDINGS 62.5/63.4 priced palette-first and palette-last at -12.8 dB
|
||||||
|
# for one paint apiece and could not choose between them, so the format records
|
||||||
|
# it (dlxp.py flags bit 1) and BOTH have to pass this gate. The chain assertion
|
||||||
|
# below is the only place the difference is visible from outside the machine,
|
||||||
|
# and hard-coding either order there would turn "K3 ran both" into "K3 ran one
|
||||||
|
# and the other could not have failed".
|
||||||
|
PALLAST=$(sed -n 's/.*palette_last = \([01]\),.*/\1/p' tmp/packed_meta.lua)
|
||||||
|
if [ "$PALLAST" = "1" ]; then
|
||||||
|
CHAIN0='chain\[0\] = MAR \$C08000 MTC 256'
|
||||||
|
ORDER="palette LAST -- the 193rd entry"
|
||||||
|
else
|
||||||
|
CHAIN0='chain\[0\] = MAR \$E82000 MTC 512 ; chain\[1\] = MAR \$C08000 MTC 256'
|
||||||
|
ORDER="palette FIRST -- entry 0, then 192 rows"
|
||||||
|
fi
|
||||||
|
echo " container order: $ORDER"
|
||||||
|
tools/vasm/vasmm68k_mot -Fbin -o tmp/packed.bin src/player/packed.s > /dev/null
|
||||||
|
|
||||||
|
# One run. $1 names the log, the rest are environment.
|
||||||
|
run() {
|
||||||
|
local tag=$1; shift
|
||||||
|
rm -rf "tmp/snap_packed_$tag"; mkdir -p "tmp/snap_packed_$tag"
|
||||||
|
# stdbuf -oL: without it a long MAME run is unobservable until it exits, and a
|
||||||
|
# run that is merely finishing looks exactly like one that is wedged (34.1).
|
||||||
|
( 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 $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 )
|
||||||
|
grep -aq "^\[PK\] done" "tmp/packed_$tag.log" || {
|
||||||
|
echo "FAIL: the $tag run did not finish -- no completion marker."
|
||||||
|
tail -12 "tmp/packed_$tag.log"; exit 1; }
|
||||||
|
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 ))
|
||||||
|
run gate DLX_PK_HELD=0 DLX_PK_PACE=1 DLX_PK_FPS=$GATE_FPS DLX_PK_NFR=$NFR
|
||||||
|
cp tmp/packed_snaps.csv tmp/packed_snaps_gate.csv
|
||||||
|
|
||||||
|
# THE ASSERTIONS. Printing a result and gating on it are different things.
|
||||||
|
grep -aq "^\[PK\] FLAG=\$FF" tmp/packed_gate.log || \
|
||||||
|
fail "the player did not reach the end of the scene. FLAG=\$E1 is a CRTC mode
|
||||||
|
the frame clock cannot divide, \$E2 is a transport failure -- and the
|
||||||
|
TRANSPORT FAILED line above names which."
|
||||||
|
grep -aq "array: the 68000 built 193 entries (the container wants 193)" \
|
||||||
|
tmp/packed_gate.log || \
|
||||||
|
fail "the 68000 built a chain of the wrong length. One entry short delivers a
|
||||||
|
picture with its last row missing, which looks like a decode bug and is a
|
||||||
|
layout bug; the container's geometry and the player's arithmetic are two
|
||||||
|
independent statements of one number and they have to agree."
|
||||||
|
grep -aq "$CHAIN0" tmp/packed_gate.log || \
|
||||||
|
fail "the chain does not have the shape this container asks for ($ORDER).
|
||||||
|
The crossing from the palette registers into GVRAM IS the packed frame
|
||||||
|
(FINDINGS 62) -- a palette entry and 192 row entries, one start, the CPU
|
||||||
|
halted throughout -- and an array built the other way round from the
|
||||||
|
record feeding it does not fail: it paints 192 rows of picture into the
|
||||||
|
palette registers and 512 B of palette across the top of the screen."
|
||||||
|
grep -aq "late frames (tick already past at the gate): 0$" tmp/packed_gate.log || \
|
||||||
|
fail "a frame missed its slot in the GATE run, which is paced at half rate on
|
||||||
|
purpose. That is not a rate result -- it means the transfer did not fit in
|
||||||
|
a slot twice as long as the container's, and the pixel comparison below is
|
||||||
|
then sampling frames the player was still overwriting."
|
||||||
|
|
||||||
|
grep -aq "0 frames NOT SAMPLED" tmp/packed_gate.log || \
|
||||||
|
fail "the gate run could not sample every frame: the write window reopened
|
||||||
|
before the snapshot on at least one. At half the container's rate the
|
||||||
|
transfer must fit inside the display interval with room to spare, and if
|
||||||
|
it does not the comparison below is checking frames the player was still
|
||||||
|
overwriting."
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. THE SEEK, WITH SOUND ON IT. FINDINGS 70.3 asked for this and named what
|
||||||
|
# was missing: "src/player/packed.s starts PG_AK/PG_AKF at lump 0 and has no
|
||||||
|
# audio seek path at all". This is that path, run.
|
||||||
|
#
|
||||||
|
# THE FRAME IS CHOSEN NOT TO BE A MULTIPLE OF THE CADENCE, and that is the whole
|
||||||
|
# design of the run. A DLXP2 group is `lump k, then F records`, so a branch
|
||||||
|
# that lands on a group boundary needs no offset and no second read -- and 36 of
|
||||||
|
# the arcade's 409 within-container seek targets do land on one. A run that
|
||||||
|
# picked one of those would exercise the arithmetic that was already there and
|
||||||
|
# report success. DLX_PK_SEEK defaults below to a frame `f mod F != 0`, so the
|
||||||
|
# byte offset inside the lump is load-bearing: get it wrong and the chip is fed
|
||||||
|
# a stream that starts up to F frames early, which is not an error, it is a
|
||||||
|
# rate, and only verify_packed_audio.py's spliced walk can see it.
|
||||||
|
#
|
||||||
|
# AND THE SECOND RESULT IS ONE NO COUNTER CAN REACH: the chip's predictor does
|
||||||
|
# not seek. Every byte can arrive, in order, exactly -- and the samples still
|
||||||
|
# be wrong, because the encoder chose them for a state a continuous play would
|
||||||
|
# have been in. The verifier measures that against its own control.
|
||||||
|
if [ "$AUDIO_ON" = "1" ] && [ "${DLX_PK_NOSEEK:-0}" != "1" ]; then
|
||||||
|
SEEKF=${DLX_PK_SEEK:-37}
|
||||||
|
CADF=$(sed -n 's/^ cad_f = \([0-9]*\),.*/\1/p' tmp/packed_meta.lua)
|
||||||
|
[ $((SEEKF % CADF)) -ne 0 ] || \
|
||||||
|
fail "the seek frame $SEEKF is a multiple of the cadence $CADF, so it lands
|
||||||
|
ON a group boundary -- the one case that needs no byte offset and would
|
||||||
|
pass with the offset arithmetic deleted (FINDINGS 70.3)."
|
||||||
|
echo
|
||||||
|
echo "--- 7. THE SEEK: two passes, the second starting at frame $SEEKF --"
|
||||||
|
echo " lump $((SEEKF / CADF)), $((SEEKF % CADF)) frame(s) into its group"
|
||||||
|
echo " of $CADF (FINDINGS 70.3/71) ---"
|
||||||
|
TOTFR=$(( NFR + NFR - SEEKF ))
|
||||||
|
SECS=$(( TOTFR / 8 + 35 ))
|
||||||
|
# BOTH CONFIGURATIONS, and they are not two speeds of one thing. The chip's
|
||||||
|
# accumulator is an integrator with no leak, so what a branch costs is set by
|
||||||
|
# the state it lands in: play THROUGH and the chip keeps whatever the previous
|
||||||
|
# scene left it in; STOP and re-PLAY and it goes to the container's own `init`
|
||||||
|
# with the step index at 0. Neither is zero and they are 5.5x apart, so the
|
||||||
|
# run measures both and FINDINGS 71.3 chooses.
|
||||||
|
for M in 0 1; do
|
||||||
|
TAG=seek; [ "$M" = 1 ] && TAG=seek_rst
|
||||||
|
echo " -- the chip $([ "$M" = 1 ] && echo 'STOPPED and re-PLAYED' \
|
||||||
|
|| echo 'PLAYING THROUGH') the branch"
|
||||||
|
SOUNDARGS="-samplerate 15625 -wavwrite packed_$TAG.wav"
|
||||||
|
run $TAG DLX_PK_HELD=0 DLX_PK_PACE=1 DLX_PK_FPS="$CFPS" DLX_PK_NFR=$NFR \
|
||||||
|
DLX_PK_AUD=1 DLX_PK_AJSON=packed_$TAG.json \
|
||||||
|
DLX_PK_ITER=2 DLX_PK_SEEK=$SEEKF DLX_PK_ARST=$M
|
||||||
|
SOUNDARGS="-sound none"
|
||||||
|
grep -aq "^\[PK\] FLAG=\$FF" "tmp/packed_$TAG.log" || \
|
||||||
|
fail "the $TAG run did not reach the end of the second pass."
|
||||||
|
python3 tools/bench/verify_packed_audio.py "$DLXP" "tmp/packed_$TAG.wav" \
|
||||||
|
"tmp/packed_$TAG.json" --seek "$SEEKF" --iters 2 || \
|
||||||
|
fail "the player did not play the container ACROSS A BRANCH ($TAG).
|
||||||
|
Nothing here parses anything (FINDINGS 67.4): a seek that fetched the
|
||||||
|
wrong lump plays 7,168 B of the wrong part of the scene, and one that
|
||||||
|
dropped the byte offset plays the right lump from up to $((CADF-1))
|
||||||
|
frames too early -- neither is an error and both are a sound."
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${DLX_PK_GATE_ONLY:-0}" = "1" ]; then exit 0; fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "--- 2/3. THE RATE: free-running, both channel configurations ---"
|
||||||
|
SECS=$(( NFR / 8 + 25 ))
|
||||||
|
run free_steal DLX_PK_HELD=0 DLX_PK_PACE=0 DLX_PK_NFR=$NFR
|
||||||
|
run free_held DLX_PK_HELD=1 DLX_PK_PACE=0 DLX_PK_NFR=$NFR
|
||||||
|
for t in free_steal free_held; do
|
||||||
|
grep -aq "FREE-RUNNING, so the mean IS the transport" "tmp/packed_$t.log" || \
|
||||||
|
fail "the $t run did not report a transport time, so there is no rate here
|
||||||
|
to read -- and a rate read off a PACED run is the pace."
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "--- 4. THE CLOCK: held, paced at the container's own rate ---"
|
||||||
|
SECS=$(( NFR / 12 + 25 ))
|
||||||
|
run held_paced DLX_PK_HELD=1 DLX_PK_PACE=1 DLX_PK_NFR=$NFR
|
||||||
|
# THE FINDING THIS RUN EXISTS FOR, asserted rather than admired. A held channel
|
||||||
|
# halts the 68000, and the frame clock is an INTERRUPT off V-DISP whose pending
|
||||||
|
# bit is ONE BIT -- so every edge that falls inside a transfer spanning two of
|
||||||
|
# them is an edge the machine can never count. If this ever comes back at zero,
|
||||||
|
# either the transfer got short enough to fit between two rasters or the held
|
||||||
|
# configuration stopped halting the CPU, and both change what the run means.
|
||||||
|
LOST=$(sed -n 's/.*-> \([0-9]*\) edges LOST.*/\1/p' tmp/packed_held_paced.log | head -1)
|
||||||
|
[ -n "$LOST" ] && [ "$LOST" -gt 0 ] || \
|
||||||
|
fail "the held run lost no V-DISP edges (${LOST:-none}). Either the bus is no
|
||||||
|
longer being held for the transfer, or the transfer now fits between two
|
||||||
|
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"
|
||||||
|
echo " configurations can do any of that is a DESIGN question, and it is the"
|
||||||
|
echo " one this run answers; neither figure is W."
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the nibble stream that asks the MSM6258 which decoder it is.
|
||||||
|
ROADMAP P6a.
|
||||||
|
|
||||||
|
WHAT HAS TO BE DISCRIMINATED, and it is four things rather than the one
|
||||||
|
FINDINGS 65 named:
|
||||||
|
|
||||||
|
1. DELTA FORMULA -- 'shift' (ffmpeg's adpcm_ima_oki) against 'terms' (the
|
||||||
|
datasheet's per-term truncation). Worth 25 dB (65.2).
|
||||||
|
2. NIBBLE ORDER -- which half of a byte handed to the data register is played
|
||||||
|
FIRST. 65.1 measured 'high' AGAINST FFMPEG, which is a fact about the VOX
|
||||||
|
file convention and not about a chip's data register.
|
||||||
|
3. THE CLAMP -- the accumulator saturates somewhere, and where is inside
|
||||||
|
the recursion, so it is not an output scaling that can be undone.
|
||||||
|
4. THE INITIAL ACCUMULATOR at the instant of PLAY.
|
||||||
|
|
||||||
|
The stream is in three parts and each part exists for a reason:
|
||||||
|
|
||||||
|
PROLOGUE, 16 zero nibbles. Nibble 0 moves the step index DOWN, so it stays
|
||||||
|
pinned at 0 and the delta is a constant +2 under every candidate. That makes
|
||||||
|
the prologue a RAMP that both formulas agree on, which is what absorbs the one
|
||||||
|
thing this rig cannot control: how many times the chip consumes byte 0 before
|
||||||
|
the channel delivers byte 1. The verifier reads that count off the capture
|
||||||
|
instead of assuming it.
|
||||||
|
|
||||||
|
SEGMENT A, a quiet sine, encoded by tools/encoder/adpcm.py itself. Amplitude
|
||||||
|
300 keeps it clear of even the 10-bit clamp, so A discriminates the FORMULA
|
||||||
|
and the ORDER without the clamp confounding either. Using the shipping
|
||||||
|
encoder rather than a hand-written pattern is deliberate: the nibbles the chip
|
||||||
|
is asked about are the kind of nibbles it will be sent.
|
||||||
|
|
||||||
|
SEGMENT B, loud bursts. It exists ONLY to cross the 10-bit clamp, which
|
||||||
|
segment A is built never to reach, and it is last because a clamp is
|
||||||
|
irreversible state and everything after it would be measuring segment B.
|
||||||
|
"""
|
||||||
|
import json, math, os, sys
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "encoder"))
|
||||||
|
import adpcm
|
||||||
|
|
||||||
|
PRO_NIB = 16 # prologue nibbles (byte 0 = $00, so a repeat costs nothing)
|
||||||
|
A_SAMPLES = 1500 # segment A, one nibble each
|
||||||
|
A_AMP = 300 # clear of the 10-bit clamp at 511 with room for the ramp
|
||||||
|
A_HZ = 61.0 # ~256 samples a cycle at 15,625 Hz: many step indices
|
||||||
|
RATE = 15625.0
|
||||||
|
BUF = 0x30000 # where the harness pushes the bytes
|
||||||
|
OUT_BIN = "tmp/adpcm_data.bin"
|
||||||
|
OUT_META = "tmp/adpcm_meta.lua"
|
||||||
|
OUT_SEQ = "tmp/adpcm_seq.json"
|
||||||
|
|
||||||
|
|
||||||
|
# THE TRIGGER, and it is here because the first cut of this file did not have
|
||||||
|
# one and measured ONE differing sample in 1,676. The two formulas are
|
||||||
|
# IDENTICAL whenever the step value is a multiple of 8:
|
||||||
|
#
|
||||||
|
# terms - shift = b2*floor(r/2) + b3*floor(r/4) - floor((4*b2+2*b3+1)*r/8)
|
||||||
|
#
|
||||||
|
# with r = step mod 8 and (b1,b2,b3) the nibble's low three bits. It is zero
|
||||||
|
# for r = 0, and the step table STARTS at 16. A quiet signal never moves the
|
||||||
|
# step index off its floor, so a probe made of quiet nibbles asks the chip a
|
||||||
|
# question that has the same answer either way.
|
||||||
|
#
|
||||||
|
# nibble 4 at step 16: delta 18 under both, and it moves the index to 2
|
||||||
|
# nibble 3 at step 19: shift 16, terms 15 <- the two states part company
|
||||||
|
#
|
||||||
|
# After that they never rejoin, because the delta is added to a running
|
||||||
|
# predictor -- so ONE two-nibble trigger converts the rest of the stream into
|
||||||
|
# discriminating evidence. That is the same recursion 65.2 priced at 25 dB,
|
||||||
|
# used deliberately instead of suffered.
|
||||||
|
TRIGGER = [4, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def segment_a():
|
||||||
|
"""The trigger, then a sine encoded by the shipping encoder. The model the
|
||||||
|
sine is encoded under does not matter for discrimination -- once the trigger
|
||||||
|
has parted the two states, any nibble stream keeps them apart -- so the
|
||||||
|
defaults are used and the choice is recorded rather than tuned. Using the
|
||||||
|
shipping encoder rather than a hand-written pattern is the point: the
|
||||||
|
nibbles the chip is asked about are the kind of nibbles it will be sent."""
|
||||||
|
sig = [int(round(A_AMP * math.sin(2 * math.pi * A_HZ * i / RATE)))
|
||||||
|
for i in range(A_SAMPLES)]
|
||||||
|
return TRIGGER + list(adpcm.encode(sig, "shift"))
|
||||||
|
|
||||||
|
|
||||||
|
def segment_b():
|
||||||
|
"""Loud, and alternating in sign so the step index does not simply pin: 40
|
||||||
|
up, 40 down, twice. Under a 10-bit accumulator this saturates; under a
|
||||||
|
12-bit one it does not, and that difference is the whole point of it."""
|
||||||
|
return ([7] * 40 + [15] * 40) * 2
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
core = segment_a() + segment_b()
|
||||||
|
nibs = [0] * PRO_NIB + core
|
||||||
|
data = adpcm.pack(nibs, "high") # HIGH first: the encoder's convention,
|
||||||
|
# which is one of the things on trial
|
||||||
|
os.makedirs("tmp", exist_ok=True)
|
||||||
|
open(OUT_BIN, "wb").write(data)
|
||||||
|
|
||||||
|
# HOW MUCH DISCRIMINATING POWER IS IN IT, counted rather than asserted. A
|
||||||
|
# probe that cannot separate two candidates reports a match against both and
|
||||||
|
# a gate that did not count this would call that a result.
|
||||||
|
ref = adpcm.decode(nibs, "shift", init=-2, bits=10)
|
||||||
|
axes = {}
|
||||||
|
for name, kw in (("formula", dict(variant="terms")),
|
||||||
|
("order", dict(order="low")),
|
||||||
|
("clamp", dict(bits=12)),
|
||||||
|
("init", dict(init=0))):
|
||||||
|
order = kw.pop("order", "high")
|
||||||
|
n2 = ([0] * PRO_NIB
|
||||||
|
+ list(adpcm.unpack(data, len(nibs), order))[PRO_NIB:]) \
|
||||||
|
if order != "high" else nibs
|
||||||
|
n2 = list(adpcm.unpack(data, len(nibs), order))
|
||||||
|
alt = adpcm.decode(n2, kw.get("variant", "shift"),
|
||||||
|
init=kw.get("init", -2), bits=kw.get("bits", 10))
|
||||||
|
d = sum(1 for a, b in zip(ref, alt) if a != b)
|
||||||
|
axes[name] = d
|
||||||
|
|
||||||
|
seq = {"nibbles": nibs, "core": core, "pro": PRO_NIB,
|
||||||
|
"bytes": len(data), "buf": BUF, "axes": axes,
|
||||||
|
"a_samples": A_SAMPLES, "a_amp": A_AMP, "a_hz": A_HZ}
|
||||||
|
json.dump(seq, open(OUT_SEQ, "w"))
|
||||||
|
|
||||||
|
with open(OUT_META, "w") as f:
|
||||||
|
f.write("return {\n")
|
||||||
|
f.write(f" buf = 0x{BUF:X},\n")
|
||||||
|
f.write(f" nbytes = {len(data)},\n")
|
||||||
|
f.write(f" nnibs = {len(nibs)},\n")
|
||||||
|
f.write("}\n")
|
||||||
|
|
||||||
|
print(f" probe stream: {len(nibs)} nibbles = {len(data)} B "
|
||||||
|
f"= {len(nibs)/RATE*1000:.1f} ms at 15,625 Hz")
|
||||||
|
print(f" prologue {PRO_NIB} zero nibbles, segment A {len(segment_a())} "
|
||||||
|
f"(sine {A_AMP} @ {A_HZ} Hz), segment B {len(segment_b())} (loud)")
|
||||||
|
print(" DISCRIMINATING POWER -- samples that change when ONE axis is "
|
||||||
|
"flipped away from MAME's own model:")
|
||||||
|
for k, v in axes.items():
|
||||||
|
print(f" {k:8s} {v:5d} of {len(ref)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+152
-30
@@ -28,32 +28,112 @@ import sys, os, argparse
|
|||||||
sys.path.insert(0, "tools/encoder")
|
sys.path.insert(0, "tools/encoder")
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from dlx import DLX
|
from dlx import DLX
|
||||||
|
sys.path.insert(0, "tools/bench")
|
||||||
|
import dlxload as DL
|
||||||
|
import spans as SP
|
||||||
|
|
||||||
|
# The harness loads the WHOLE container into emulated RAM at STREAM=0x30000 and
|
||||||
|
# the target is a stock 2 MB machine, so there is a hard ceiling on how much of
|
||||||
|
# a stream can be verified in one pass. The shipping player streams from disk
|
||||||
|
# into a ring buffer and has no such limit; this is a property of the test rig.
|
||||||
|
# A `scsi` window overruns it -- 2.84 MB of stream ends at 0x2E591C, 940 KB past
|
||||||
|
# the 0x200000 top of RAM -- so the frame list is truncated to what fits and the
|
||||||
|
# truncation is announced. Verifying a prefix is still a real test: SKIP blocks
|
||||||
|
# make every frame a claim about the one before it.
|
||||||
|
STREAM_BASE = 0x30000
|
||||||
|
RAM_TOP = 0x200000
|
||||||
|
MARGIN = 0x8000 # stack, flags, codebooks live below STREAM_BASE
|
||||||
|
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("container")
|
ap.add_argument("container")
|
||||||
ap.add_argument("--out", default="tmp/decode")
|
ap.add_argument("--out", default="tmp/decode")
|
||||||
|
ap.add_argument("--ram", type=lambda v: int(v, 0), default=RAM_TOP,
|
||||||
|
help="top of emulated RAM (default 0x200000, a stock 2 MB machine)")
|
||||||
|
ap.add_argument("--all-frames", action="store_true",
|
||||||
|
help="do NOT truncate to what fits in RAM (the loader will write "
|
||||||
|
"past the top of memory and the decoder will read garbage)")
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
|
|
||||||
d = DLX(a.container)
|
d = DLX(a.container)
|
||||||
if d.idx_bytes != 1:
|
if d.idx_bytes != 1:
|
||||||
sys.exit("2-byte codebook indices: decode.s assumes 1 (k<=256)")
|
sys.exit("2-byte codebook indices: decode.s assumes 1 (k<=256)")
|
||||||
|
# decode.s reads a u16 span count out of every frame record (FINDINGS 41), so a
|
||||||
|
# DLX2 container is not merely span-less to it -- the first two bytes of the
|
||||||
|
# block payload would be read as a count and the frame would decode as garbage.
|
||||||
|
# Fail here rather than there.
|
||||||
|
if not d.has_spans:
|
||||||
|
sys.exit(f"{a.container} is DLX{d.version}: src/player/decode.s expects the "
|
||||||
|
f"DLX3 span section. Re-encode (tools/encoder/encode.py emits DLX3 "
|
||||||
|
f"by default) or pass --spans off and use an older decoder.")
|
||||||
|
|
||||||
# --- codebooks, expanded to one WORD per pixel (high byte is discarded by
|
# --- codebooks and palette. Both transforms live in tools/bench/dlxload.py so
|
||||||
# gvram_w, so it is left zero and never has to be cleared)
|
# that prep_stream.py's ring-buffer rig shares one copy of them rather than
|
||||||
cb1 = np.zeros((d.k1, 16, 2), np.uint8); cb1[:, :, 1] = d.cb1.reshape(d.k1, 16)
|
# keeping a second that could drift silently (FINDINGS 49).
|
||||||
cb4 = np.zeros((d.k4, 4, 2), np.uint8); cb4[:, :, 1] = d.cb4.reshape(d.k4, 4)
|
cb1, cb4 = DL.expand_codebooks(d)
|
||||||
|
palb, dark, rendered = DL.pack_palette(d)
|
||||||
|
|
||||||
|
def build_synth(d):
|
||||||
|
"""The synthetic timing frames, as record bodies.
|
||||||
|
|
||||||
|
No real frame is all one mode, but the mix is exactly what the "76.6% x
|
||||||
|
non-SKIP fraction" model of FINDINGS 24.5 assumes away: it prices every
|
||||||
|
non-SKIP block as one V1-style burst. These price the modes separately,
|
||||||
|
which is the only way to see which one is expensive.
|
||||||
|
|
||||||
|
Every record carries the DLX3 span section, empty or not -- decode.s reads a
|
||||||
|
u16 count out of all of them, and a synthetic frame that omitted it would
|
||||||
|
desync the bitstream exactly where the harness is least likely to look.
|
||||||
|
|
||||||
|
The last two are the mode the block loop cannot express: a frame that is ALL
|
||||||
|
SPAN, its mode header entirely SKIP. Two run lengths, because a span costs
|
||||||
|
per-span plus per-pixel and one length cannot separate them --
|
||||||
|
all-SPAN-64 full-row runs, the floor of the mode (154 clocks/block,
|
||||||
|
FINDINGS 30.4)
|
||||||
|
all-SPAN-4 4-block runs, the break-even against V1 (FINDINGS 40.1)
|
||||||
|
They price v7 INSIDE decode.s against the constants tools/bench/span.sh
|
||||||
|
fitted in blit.s. Agreement cross-checks both; disagreement means the
|
||||||
|
decoder's span pass is not the sequence that was measured.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
empty = SP.serialise([])
|
||||||
|
for name, mo, per in (("all-SKIP", 0, 0), ("all-V1", 1, 1),
|
||||||
|
("all-V4", 2, 4), ("all-RAW", 3, 16)):
|
||||||
|
out[name] = (bytes([mo * 0x55] * d.mode_bytes) + empty
|
||||||
|
+ bytes(d.nb * per))
|
||||||
|
# MIXED-SKIP frames. Every other synthetic frame here is a pure population,
|
||||||
|
# which is exactly why none of them prices the commonest block in a real
|
||||||
|
# container: a SKIP that shares its header byte with a coded block, and so
|
||||||
|
# cannot take the all-SKIP fast path. vq_hybrid's C_SKIP_MIXED has never
|
||||||
|
# been measured -- it was derived -- and a spanned container is made mostly
|
||||||
|
# of them, because a spanned block reads SKIP. FINDINGS 41.5.
|
||||||
|
#
|
||||||
|
# Two mixes per coded mode, because one equation cannot separate the SKIP
|
||||||
|
# cost from the cost of the block it shares a group with.
|
||||||
|
#
|
||||||
|
# THE HEADER BYTES ROTATE, and that is not decoration. decode.s reaches a
|
||||||
|
# block's 2 mode bits with `lsr.b #6/#4/#2` and no shift at all for the last
|
||||||
|
# one, so a block costs 52/48/44/34 clocks of dispatch depending on WHERE in
|
||||||
|
# its header byte it sits. A fixed byte like 0x01 puts every SKIP at the
|
||||||
|
# three expensive positions and every V1 at the free one, and solving two
|
||||||
|
# such equations returns a number that describes no real frame. Cycling the
|
||||||
|
# byte through the four rotations puts each mode at each position equally,
|
||||||
|
# which is what a real mode map does.
|
||||||
|
for nm, bys, per in (("mix-3SKIP-V1", (0x01, 0x04, 0x10, 0x40), 1),
|
||||||
|
("mix-1SKIP-3V1", (0x54, 0x51, 0x45, 0x15), 1),
|
||||||
|
("mix-3SKIP-RAW", (0x03, 0x0C, 0x30, 0xC0), 16),
|
||||||
|
("mix-1SKIP-3RAW", (0xFC, 0xF3, 0xCF, 0x3F), 16)):
|
||||||
|
hdr = bytes(bys[i % 4] for i in range(d.mode_bytes))
|
||||||
|
ncoded = sum(bin(b).count("1") and
|
||||||
|
sum(1 for k in range(4) if (b >> (2 * k)) & 3) for b in hdr[:1])
|
||||||
|
ncoded = sum(sum(1 for k in range(4) if (b >> (2 * k)) & 3) for b in hdr)
|
||||||
|
out[nm] = hdr + empty + bytes(ncoded * per)
|
||||||
|
pat = np.tile(np.arange(d.W, dtype=np.uint8), (d.H, 1))
|
||||||
|
for name, blocks in (("all-SPAN-64", d.W // 4), ("all-SPAN-4", 4)):
|
||||||
|
sp = [(y, x, pat[y, x:x + blocks * 4])
|
||||||
|
for y in range(d.H) for x in range(0, d.W, blocks * 4)]
|
||||||
|
out[name] = bytes(d.mode_bytes) + SP.serialise(sp)
|
||||||
|
return out
|
||||||
|
|
||||||
# --- palette words, I chosen per entry (identical maths to verify_frame256.py)
|
|
||||||
pal = d.pal.astype(int)
|
|
||||||
p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF
|
|
||||||
f = pal >> 3
|
|
||||||
render = lambda I: p6((f << 1) | I[:, None])
|
|
||||||
I = (((render(np.ones(256, int)) - pal) ** 2).sum(1)
|
|
||||||
< ((render(np.zeros(256, int)) - pal) ** 2).sum(1)).astype(int)
|
|
||||||
words = (f[:, 1] << 11) | (f[:, 0] << 6) | (f[:, 2] << 1) | I
|
|
||||||
palb = np.zeros((256, 2), np.uint8)
|
|
||||||
palb[:, 0], palb[:, 1] = words >> 8, words & 0xFF
|
|
||||||
dark = int(((render(I).astype(int)) ** 2).sum(1).argmin())
|
|
||||||
|
|
||||||
# --- frame stream: [u32 len][modes][payload] per frame, each record start
|
# --- frame stream: [u32 len][modes][payload] per frame, each record start
|
||||||
# rounded up to a 4-byte boundary.
|
# rounded up to a 4-byte boundary.
|
||||||
@@ -65,28 +145,56 @@ dark = int(((render(I).astype(int)) ** 2).sum(1).argmin())
|
|||||||
# so this loader realigns it; the encoder should carry the padding itself
|
# so this loader realigns it; the encoder should carry the padding itself
|
||||||
# (FINDINGS 28.3). It costs at most 3 bytes per frame -- 36 B/s at 12fps,
|
# (FINDINGS 28.3). It costs at most 3 bytes per frame -- 36 B/s at 12fps,
|
||||||
# against a 110 KB/s budget.
|
# against a 110 KB/s budget.
|
||||||
|
budget = a.ram - STREAM_BASE - MARGIN
|
||||||
stream, rec_off, pad = bytearray(), [], 0
|
stream, rec_off, pad = bytearray(), [], 0
|
||||||
|
dropped = 0
|
||||||
|
|
||||||
|
# The synthetic timing frames are built FIRST, so their size comes out of the
|
||||||
|
# RAM budget rather than being appended past it. It used to be appended: the
|
||||||
|
# stream ran 26 KB beyond the top of a 2 MB machine, which was survivable only
|
||||||
|
# because the modes it overran are data-independent -- their cost is in the
|
||||||
|
# mode header, and reading junk payload costs the same as reading pixels. A
|
||||||
|
# span is not: its two jump DISPLACEMENTS come out of the stream, so an
|
||||||
|
# out-of-RAM span record jumps into open bus. FINDINGS 41.4.
|
||||||
|
SYNTH = build_synth(d)
|
||||||
|
budget -= sum(4 + len(b) + 3 for b in SYNTH.values())
|
||||||
|
|
||||||
for (o, n) in d.frames:
|
for (o, n) in d.frames:
|
||||||
while len(stream) % 4:
|
while len(stream) % 4:
|
||||||
stream += b"\0"; pad += 1
|
stream += b"\0"; pad += 1
|
||||||
|
if not a.all_frames and len(stream) + 4 + n > budget:
|
||||||
|
dropped = d.nframes - len(rec_off)
|
||||||
|
break
|
||||||
rec_off.append(len(stream))
|
rec_off.append(len(stream))
|
||||||
stream += n.to_bytes(4, "big") + d.raw[o:o + n]
|
stream += n.to_bytes(4, "big") + d.raw[o:o + n]
|
||||||
|
NFRAMES = len(rec_off)
|
||||||
|
if dropped:
|
||||||
|
print(f" TRUNCATED: {NFRAMES}/{d.nframes} frames fit in RAM "
|
||||||
|
f"(stream budget {budget:,} B at 0x{STREAM_BASE:X} under a "
|
||||||
|
f"{a.ram/1024/1024:.0f} MB machine); {dropped} frames dropped.\n"
|
||||||
|
f" This is the TEST RIG's limit, not the player's -- the player "
|
||||||
|
f"streams into a ring buffer.")
|
||||||
|
|
||||||
# Synthetic single-mode frames. No real frame is all one mode, but the mix is
|
# Append the synthetic frames the budget above already reserved.
|
||||||
# exactly what the "76.6% x non-SKIP fraction" model of FINDINGS 24.5 assumes
|
|
||||||
# away: it prices every non-SKIP block as one V1-style burst. These four price
|
|
||||||
# the modes separately, which is the only way to see which one is expensive.
|
|
||||||
synth = {}
|
synth = {}
|
||||||
for name, mo, per in (("all-SKIP", 0, 0), ("all-V1", 1, 1),
|
for name, body in SYNTH.items():
|
||||||
("all-V4", 2, 4), ("all-RAW", 3, 16)):
|
|
||||||
while len(stream) % 4:
|
while len(stream) % 4:
|
||||||
stream += b"\0"; pad += 1
|
stream += b"\0"; pad += 1
|
||||||
synth[name] = len(stream)
|
synth[name] = len(stream)
|
||||||
hdr = bytes([mo * 0x55] * d.mode_bytes)
|
stream += len(body).to_bytes(4, "big") + body
|
||||||
stream += (d.mode_bytes + d.nb * per).to_bytes(4, "big") + hdr + bytes(d.nb * per)
|
assert STREAM_BASE + len(stream) <= a.ram, (
|
||||||
|
f"stream ends at 0x{STREAM_BASE+len(stream):X}, past the 0x{a.ram:X} top "
|
||||||
|
f"of RAM -- the budget arithmetic above is wrong")
|
||||||
|
|
||||||
# --- timing anchors: the distribution, not its mean (FINDINGS 25.6's lesson)
|
# --- timing anchors: the distribution, not its mean (FINDINGS 25.6's lesson)
|
||||||
ns = np.array([100 * (d.modes(i) != 0).mean() for i in range(d.nframes)])
|
#
|
||||||
|
# A spanned block reads SKIP here, so this fraction is the BLOCK-LOOP workload
|
||||||
|
# and no longer the frame's whole cost: the span section is the rest of it. The
|
||||||
|
# anchors still pick out the extremes of the block loop, which is what they are
|
||||||
|
# for, but a frame's total decode time now has two terms.
|
||||||
|
ns = np.array([100 * (d.modes(i) != 0).mean() for i in range(NFRAMES)])
|
||||||
|
nsp = np.array([len(d.spans(i)[0]) for i in range(NFRAMES)])
|
||||||
|
spx = np.array([sum(len(p) for _, _, p in d.spans(i)[0]) for i in range(NFRAMES)])
|
||||||
order = np.argsort(ns)
|
order = np.argsort(ns)
|
||||||
pick = {
|
pick = {
|
||||||
"min non-SKIP %.1f%%" % ns[order[0]]: int(order[0]),
|
"min non-SKIP %.1f%%" % ns[order[0]]: int(order[0]),
|
||||||
@@ -95,16 +203,18 @@ pick = {
|
|||||||
"max non-SKIP %.1f%%" % ns[order[-1]]: int(order[-1]),
|
"max non-SKIP %.1f%%" % ns[order[-1]]: int(order[-1]),
|
||||||
}
|
}
|
||||||
anchors = [(n, rec_off[i], float(ns[i])) for n, i in pick.items()]
|
anchors = [(n, rec_off[i], float(ns[i])) for n, i in pick.items()]
|
||||||
for name in ("all-SKIP", "all-V1", "all-V4", "all-RAW"):
|
for name in ("all-SKIP", "all-V1", "all-V4", "all-RAW",
|
||||||
|
"all-SPAN-64", "all-SPAN-4", "mix-3SKIP-V1", "mix-1SKIP-3V1",
|
||||||
|
"mix-3SKIP-RAW", "mix-1SKIP-3RAW"):
|
||||||
anchors.append((f"synthetic {name}", synth[name],
|
anchors.append((f"synthetic {name}", synth[name],
|
||||||
0.0 if name == "all-SKIP" else 100.0))
|
0.0 if name.startswith(("all-SKIP", "all-SPAN")) else 100.0))
|
||||||
|
|
||||||
blob = cb1.tobytes() + cb4.tobytes() + palb.tobytes() + bytes(stream)
|
blob = cb1.tobytes() + cb4.tobytes() + palb.tobytes() + bytes(stream)
|
||||||
open(a.out + "_data.bin", "wb").write(blob)
|
open(a.out + "_data.bin", "wb").write(blob)
|
||||||
|
|
||||||
with open(a.out + "_meta.lua", "w") as fh:
|
with open(a.out + "_meta.lua", "w") as fh:
|
||||||
fh.write("-- generated by tools/bench/prep_dlx.py -- do not edit\nreturn {\n")
|
fh.write("-- generated by tools/bench/prep_dlx.py -- do not edit\nreturn {\n")
|
||||||
fh.write(f" W={d.W}, H={d.H}, fps={d.fps}, nframes={d.nframes},\n")
|
fh.write(f" W={d.W}, H={d.H}, fps={d.fps}, nframes={NFRAMES},\n")
|
||||||
fh.write(f" k1={d.k1}, k4={d.k4}, dark={dark},\n")
|
fh.write(f" k1={d.k1}, k4={d.k4}, dark={dark},\n")
|
||||||
fh.write(f" cb1_len={cb1.nbytes}, cb4_len={cb4.nbytes}, pal_len={palb.nbytes},\n")
|
fh.write(f" cb1_len={cb1.nbytes}, cb4_len={cb4.nbytes}, pal_len={palb.nbytes},\n")
|
||||||
fh.write(f" stream_len={len(stream)},\n")
|
fh.write(f" stream_len={len(stream)},\n")
|
||||||
@@ -118,6 +228,18 @@ print(f" cb1 {cb1.nbytes} B + cb4 {cb4.nbytes} B expanded, palette {palb.nbytes
|
|||||||
f"stream {len(stream)} B -> {a.out}_data.bin ({len(blob)} B)")
|
f"stream {len(stream)} B -> {a.out}_data.bin ({len(blob)} B)")
|
||||||
print(f" non-SKIP blocks/frame: median {np.median(ns):.1f}% "
|
print(f" non-SKIP blocks/frame: median {np.median(ns):.1f}% "
|
||||||
f"p90 {np.percentile(ns,90):.1f}% max {ns.max():.1f}%")
|
f"p90 {np.percentile(ns,90):.1f}% max {ns.max():.1f}%")
|
||||||
print(f" darkest palette entry: index {dark} -> {tuple(render(I)[dark])}")
|
print(f" v7 spans/frame: median {np.median(nsp):.0f} max {nsp.max()} "
|
||||||
print(f" 4-byte record alignment cost {pad} B over {d.nframes} frames "
|
f"({int((nsp>0).sum())}/{NFRAMES} frames); pixels painted by one: "
|
||||||
f"({pad / d.nframes:.2f} B/frame = {pad / d.nframes * d.fps:.0f} B/s)")
|
f"median {100*np.median(spx)/(d.W*d.H):.1f}% "
|
||||||
|
f"max {100*spx.max()/(d.W*d.H):.1f}% of the picture")
|
||||||
|
print(f" darkest palette entry: index {dark} -> {tuple(rendered[dark])}")
|
||||||
|
# A DLX2 container already carries this padding (FINDINGS 28.3 closed, session
|
||||||
|
# 9), so the realignment above re-derives bytes that were already there and the
|
||||||
|
# loader is doing no work. On a DLX1 container it is load-bearing: 94 of 120
|
||||||
|
# record starts land on odd addresses, and each one is an address error.
|
||||||
|
src_bad = sum(1 for (o, _) in d.frames[:NFRAMES] if (o - 4) % 4)
|
||||||
|
print(f" 4-byte record alignment cost {pad} B over {NFRAMES} frames "
|
||||||
|
f"({pad / NFRAMES:.2f} B/frame = {pad / NFRAMES * d.fps:.0f} B/s)")
|
||||||
|
print(f" source container is DLX{d.version}: {src_bad}/{NFRAMES} record starts "
|
||||||
|
f"unaligned" + (" -- this loader is what makes it decodable"
|
||||||
|
if src_bad else " -- the container carries its own padding"))
|
||||||
|
|||||||
@@ -15,16 +15,27 @@ argv = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|||||||
# to 0 displays palette entry 0, and a free mediancut palette puts a real image
|
# to 0 displays palette entry 0, and a free mediancut palette puts a real image
|
||||||
# colour there. Costs one of 256 entries; measured quality cost is negligible.
|
# colour there. Costs one of 256 entries; measured quality cost is negligible.
|
||||||
RESERVE = "--reserve-black" in sys.argv
|
RESERVE = "--reserve-black" in sys.argv
|
||||||
|
# --pack-transparent: the layout FINDINGS 46.6 needs. The packed scheme puts
|
||||||
|
# the TOP graphics page's index 0 to work as a transparency key, so index 0 must
|
||||||
|
# never appear in the picture -- and black therefore cannot live there. So:
|
||||||
|
# quantise to 254, place them at 1..254, put black at 255, leave 0 UNUSED.
|
||||||
|
# Costs two of 256 entries against --reserve-black's one.
|
||||||
|
PACKT = "--pack-transparent" in sys.argv
|
||||||
src, out = argv[0], argv[1]
|
src, out = argv[0], argv[1]
|
||||||
f = sorted(glob.glob(f"{src}/*.png"))[int(argv[2]) if len(argv) > 2 else 0]
|
f = sorted(glob.glob(f"{src}/*.png"))[int(argv[2]) if len(argv) > 2 else 0]
|
||||||
im = Image.open(f).convert("RGB")
|
im = Image.open(f).convert("RGB")
|
||||||
W, H = im.size
|
W, H = im.size
|
||||||
|
|
||||||
n = 255 if RESERVE else 256
|
n = 254 if PACKT else (255 if RESERVE else 256)
|
||||||
q = im.quantize(colors=n, method=Image.MEDIANCUT, dither=Image.NONE)
|
q = im.quantize(colors=n, method=Image.MEDIANCUT, dither=Image.NONE)
|
||||||
pal = np.array(q.getpalette()[:n*3], dtype=np.uint8).reshape(n, 3)
|
pal = np.array(q.getpalette()[:n*3], dtype=np.uint8).reshape(n, 3)
|
||||||
idx = np.asarray(q, dtype=np.uint8)
|
idx = np.asarray(q, dtype=np.uint8)
|
||||||
if RESERVE:
|
if PACKT:
|
||||||
|
# 0 unused (transparency key), 1..254 picture, 255 black
|
||||||
|
pal = np.vstack([np.zeros((1, 3), np.uint8), pal, np.zeros((1, 3), np.uint8)])
|
||||||
|
idx = idx + 1
|
||||||
|
assert idx.min() >= 1 and idx.max() <= 254, "index 0/255 must stay free"
|
||||||
|
elif RESERVE:
|
||||||
pal = np.vstack([np.zeros((1, 3), np.uint8), pal]) # index 0 = black
|
pal = np.vstack([np.zeros((1, 3), np.uint8), pal]) # index 0 = black
|
||||||
idx = idx + 1
|
idx = idx + 1
|
||||||
|
|
||||||
@@ -37,4 +48,4 @@ with open(out, "wb") as fh:
|
|||||||
# reference PNG of exactly what the X68000 should display
|
# reference PNG of exactly what the X68000 should display
|
||||||
Image.fromarray(pal[idx]).save(out.replace(".bin", "_ref.png"))
|
Image.fromarray(pal[idx]).save(out.replace(".bin", "_ref.png"))
|
||||||
print(f"src={f} {W}x{H} colors={len(np.unique(idx))}"
|
print(f"src={f} {W}x{H} colors={len(np.unique(idx))}"
|
||||||
f"{' (idx 0 reserved black)' if RESERVE else ''} -> {out}")
|
f"{' (idx 0 unused/transparent, 255 black)' if PACKT else (' (idx 0 reserved black)' if RESERVE else '')} -> {out}")
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Lay out the LOAD-TIME test: raw container header in, expected results out.
|
||||||
|
|
||||||
|
python3 tools/bench/prep_load.py <in.dlx> [--out tmp/load]
|
||||||
|
|
||||||
|
src/player/load.i does on the 68000 what tools/bench/dlxload.py has been doing
|
||||||
|
host-side since session 1: expand the two codebooks to word-per-pixel form and
|
||||||
|
pack the 24-bit palette into GGGGGRRRRRBBBBBI with the shared LSB chosen per
|
||||||
|
entry (ROADMAP P1 and P2). This writes both halves of that comparison.
|
||||||
|
|
||||||
|
<out>_data.bin the container's HEADER REGION, byte for byte as it comes
|
||||||
|
off the disc: magic, geometry, the three section offsets,
|
||||||
|
the 768-byte palette, CB1 and CB4. Nothing is pre-chewed --
|
||||||
|
that is the entire point. It ends where the frame stream
|
||||||
|
begins, so it is also exactly what a player would have to
|
||||||
|
read before it could draw anything.
|
||||||
|
<out>_expect.bin what dlxload.py says the 68000 must produce: expanded CB1,
|
||||||
|
expanded CB4, then 256 big-endian palette words.
|
||||||
|
<out>_meta.lua sizes, k1/k4, and the expected darkest-entry index.
|
||||||
|
|
||||||
|
The expectation is generated by the SAME module the two decode rigs load
|
||||||
|
through, so this cannot pass by agreeing with a second copy of the maths.
|
||||||
|
"""
|
||||||
|
import sys, argparse
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
sys.path.insert(0, "tools/bench")
|
||||||
|
from dlx import DLX
|
||||||
|
import dlxload as DL
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("container")
|
||||||
|
ap.add_argument("--out", default="tmp/load")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
d = DLX(a.container)
|
||||||
|
if d.version < 3:
|
||||||
|
sys.exit(f"{a.container} is DLX{d.version}: load.i wants DLX3 or DLX4")
|
||||||
|
if d.idx_bytes != 1:
|
||||||
|
sys.exit("2-byte codebook indices: load.i expands one source byte per pixel")
|
||||||
|
|
||||||
|
off_frm = int.from_bytes(d.raw[28:32], "big")
|
||||||
|
raw = d.raw[:off_frm]
|
||||||
|
|
||||||
|
cb1, cb4 = DL.expand_codebooks(d)
|
||||||
|
palb, dark, _ = DL.pack_palette(d)
|
||||||
|
|
||||||
|
open(a.out + "_data.bin", "wb").write(raw)
|
||||||
|
open(a.out + "_expect.bin", "wb").write(cb1.tobytes() + cb4.tobytes() + palb.tobytes())
|
||||||
|
|
||||||
|
with open(a.out + "_meta.lua", "w") as fh:
|
||||||
|
fh.write("-- generated by tools/bench/prep_load.py -- do not edit\nreturn {\n")
|
||||||
|
fh.write(f" k1={d.k1}, k4={d.k4}, dark={dark},\n")
|
||||||
|
fh.write(f" raw_len={len(raw)}, cb1_len={cb1.nbytes}, cb4_len={cb4.nbytes},\n")
|
||||||
|
fh.write(f" pal_len={palb.nbytes},\n}}\n")
|
||||||
|
|
||||||
|
print(f"{a.container}: k1={d.k1} k4={d.k4}, header region {len(raw)} B "
|
||||||
|
f"(pal 768 + cb1 {d.k1*16} + cb4 {d.k4*4} + 32)")
|
||||||
|
print(f" the 68000 must produce {cb1.nbytes} + {cb4.nbytes} B of expanded "
|
||||||
|
f"codebook and {palb.nbytes} B of palette, darkest entry {dark}")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Everything tools/bench/packed.lua needs to know about a DLXP container.
|
||||||
|
|
||||||
|
python3 tools/bench/prep_packed.py <in.dlxp> -> tmp/packed_meta.lua
|
||||||
|
|
||||||
|
THERE IS NO BLOB TO PREPARE, and that is the whole difference from
|
||||||
|
`prep_stream.py`. The codec's rig has to hand the machine expanded codebooks, a
|
||||||
|
packed palette and a record index, because a DLX record cannot be found or drawn
|
||||||
|
without them; `prep_dlx.py` and `prep_stream.py` exist for that and FINDINGS
|
||||||
|
49.7.5 records what it cost to have two copies of one of those transforms. A
|
||||||
|
packed container carries no such thing: record `i` is at sector 1 + i*97 by
|
||||||
|
geometry and its bytes are already in the order GVRAM wants them (dlxp.py). So
|
||||||
|
this file emits METADATA ONLY -- six numbers the rig would otherwise have to
|
||||||
|
hard-code, every one of them read out of the container's own header.
|
||||||
|
|
||||||
|
The volume is the container itself; tools/bench/mkvol.sh copies it.
|
||||||
|
"""
|
||||||
|
import os, sys
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"..", "encoder"))
|
||||||
|
from dlxp import DLXP, SECTOR
|
||||||
|
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
d = DLXP(sys.argv[1])
|
||||||
|
|
||||||
|
# The record's sector count, and the array chain's entry count, DERIVED here and
|
||||||
|
# asserted by the 68000 (PG_ARRN). Two independent statements of one geometry
|
||||||
|
# is the only way a container and a player can be caught disagreeing about it --
|
||||||
|
# a chain one entry short delivers a picture with its last row missing, which
|
||||||
|
# looks like a decode bug and is a layout bug.
|
||||||
|
recs = d.rec_bytes // SECTOR
|
||||||
|
rows = d.H
|
||||||
|
entries = rows + (1 if d.has_palette else 0)
|
||||||
|
|
||||||
|
out = "tmp/packed_meta.lua"
|
||||||
|
with open(out, "w") as fh:
|
||||||
|
fh.write("-- generated by tools/bench/prep_packed.py; do not edit\n")
|
||||||
|
fh.write("return {\n")
|
||||||
|
for k, v in [("W", d.W), ("H", d.H), ("fps", d.fps), ("nframes", d.nframes),
|
||||||
|
("rec_bytes", d.rec_bytes), ("rec_sectors", recs),
|
||||||
|
("pal_bytes", d.pal_bytes), ("pic_bytes", d.pic_bytes),
|
||||||
|
("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),
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
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")
|
||||||
|
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.video_kbps():.1f}"
|
||||||
|
+ (f" + {d.audio_kbps():.2f} = {d.kbps():.1f}" if d.has_audio else "")
|
||||||
|
+ f" KB/s, FIXED by geometry -> {out}")
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate V5 span streams for tools/bench/span.lua (FINDINGS 29.5 item 1).
|
||||||
|
|
||||||
|
FINDINGS 29 prices a new decoder mode -- a row-linear run of word-expanded
|
||||||
|
literal pixels, movem.l'd straight from the stream buffer into GVRAM -- at
|
||||||
|
`4 * (50 + 4L * 9.08)` cycles for a run of L blocks. Both halves of that are
|
||||||
|
extrapolations: the 50-cycle per-span overhead is hand-derived, and the 9.08
|
||||||
|
cycles/pixel was measured (FINDINGS 24 V1) at FULL ROW WIDTH with 12-register
|
||||||
|
bursts, which a short span cannot match. This script builds the stimulus that
|
||||||
|
replaces both numbers with measured ones.
|
||||||
|
|
||||||
|
One stream per span length. Every stream covers the SAME 192x256 picture
|
||||||
|
completely, so all of them draw an identical, verifiable frame and differ only
|
||||||
|
in how many spans it is cut into -- which is what lets span.lua regress
|
||||||
|
cycles = A * spans + B * pixels
|
||||||
|
across the set and read the per-span overhead off directly.
|
||||||
|
|
||||||
|
Two stream formats, both big-endian, both drawing the same frame.
|
||||||
|
|
||||||
|
v5 -- a decoder handed (x, npix) that works out the copy itself:
|
||||||
|
per row, 192 rows in order:
|
||||||
|
u16 nspans
|
||||||
|
nspans * { u16 x, u16 npix, npix * u16 pixel }
|
||||||
|
|
||||||
|
v6 -- the same spans with that arithmetic moved here, where it is free:
|
||||||
|
u16 nspans (whole frame; there is no row structure)
|
||||||
|
nspans * { u32 absolute GVRAM address, u16 jump displacement,
|
||||||
|
units * 48 bytes of pixels }
|
||||||
|
Span lengths are multiples of 24 pixels (one chain unit) and the last span
|
||||||
|
in a row may overrun the visible 256 by up to 23 pixels, which is free: the
|
||||||
|
line stride is 1024 bytes and only the first 512 are displayed. The jump
|
||||||
|
displacement selects an entry point into the decoder's unrolled copy chain.
|
||||||
|
|
||||||
|
v7 -- v6 plus a second, FINER chain for the tail (FINDINGS 39.4). Padding to
|
||||||
|
v6's 24-pixel quantum wastes ~11 pixels on an average span, and FINDINGS
|
||||||
|
39.3 attributes 86% of the DMAC array-chain's advantage over v6 to it. A
|
||||||
|
v7 span is 24*c + 2*f pixels, so the quantum is 2 and a run of 4x4 blocks
|
||||||
|
(always a multiple of 4 pixels) pads to NOTHING:
|
||||||
|
u16 nspans
|
||||||
|
nspans * { u32 absolute GVRAM address, u16 coarse displacement,
|
||||||
|
c * 48 bytes of pixels,
|
||||||
|
u16 fine displacement, f * 4 bytes of pixels }
|
||||||
|
The fine displacement is in the STREAM rather than the record because that
|
||||||
|
is what lets the decoder keep all 12 payload registers: the coarse chain
|
||||||
|
falls out into a `move.w (a0)+,d0 / jmp` with d0 dead and a0 pointing at
|
||||||
|
it. Costed here as an 8-byte record, since it is 2 more bytes a span.
|
||||||
|
|
||||||
|
Pixels are word-expanded with the palette index in the low byte; the high byte
|
||||||
|
is whatever we put there because gvram_w masks it off (x68k_crtc.cpp:501).
|
||||||
|
"""
|
||||||
|
import struct, sys
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
SRC = sys.argv[1] if len(sys.argv) > 1 else "tmp/frame256.bin"
|
||||||
|
OUT = sys.argv[2] if len(sys.argv) > 2 else "tmp/spans.bin"
|
||||||
|
META = OUT.replace(".bin", "_meta.lua")
|
||||||
|
|
||||||
|
d = open(SRC, "rb").read()
|
||||||
|
assert d[:4] == b"DLXR", SRC
|
||||||
|
W, H = struct.unpack(">HH", d[4:8])
|
||||||
|
idx = np.frombuffer(d[8+768:8+768+W*H], np.uint8).reshape(H, W)
|
||||||
|
assert (W, H) == (256, 192), f"{W}x{H}: span bench assumes the 256x192 picture"
|
||||||
|
|
||||||
|
# (span length in pixels, x of the first span). 4 px = one 4x4 block wide, the
|
||||||
|
# case the whole FINDINGS 29 argument turns on; 256 = one span per row, the
|
||||||
|
# case closest to the V1 measurement it extrapolates from. 16u starts at an
|
||||||
|
# odd x so its bursts run at addr mod 4 == 2: a claim about the 68000's 16-bit
|
||||||
|
# bus that costs nothing to test and would be embarrassing to assume.
|
||||||
|
CONFIGS = [(4, 0), (8, 0), (12, 0), (16, 0), (16, 1), (20, 0), (24, 0),
|
||||||
|
(32, 0), (48, 0), (64, 0), (128, 0), (256, 0)]
|
||||||
|
|
||||||
|
# v6 geometry, and it must match blit.s: 12 registers per movem = 48 bytes =
|
||||||
|
# 24 pixels per chain unit, 11 units in the chain.
|
||||||
|
UNITPX, UNITSZ, UNITS = 24, 12, 11
|
||||||
|
# v7 geometry, and it must match blit.s: coarse unit as v6, fine unit is one
|
||||||
|
# `move.l (a0)+,(a2)+` = 2 bytes of code = 2 pixels, 11 of them (22 px > 24).
|
||||||
|
FINEPX, FINESZ, FINES = 2, 2, 11
|
||||||
|
GVRAM, YOFF, STRIDE = 0xC00000, 32, 1024
|
||||||
|
|
||||||
|
# v7 span lengths, in pixels. Multiples of 4 (a real span is a run of 4x4
|
||||||
|
# blocks), chosen so the fine remainder P mod 24 takes every value a real span
|
||||||
|
# can: 0, 4, 8, 12, 16, 20. 4/8/12/16/20 are pure-fine, 24/48/72/120/240 are
|
||||||
|
# pure-coarse, the rest mix -- which is what makes the three-term fit
|
||||||
|
# cycles = A*spans + Bc*coarse_px + Bf*fine_px identifiable.
|
||||||
|
V7CONFIGS = [4, 8, 12, 16, 20, 24, 28, 44, 48, 72, 100, 120, 256]
|
||||||
|
|
||||||
|
blob, metas = bytearray(), []
|
||||||
|
for P, x0 in CONFIGS:
|
||||||
|
off = len(blob)
|
||||||
|
nspans = npix = 0
|
||||||
|
for y in range(H):
|
||||||
|
cuts = []
|
||||||
|
x = 0
|
||||||
|
if x0: # a short leading span to shift the phase
|
||||||
|
cuts.append((0, x0)); x = x0
|
||||||
|
while x < W:
|
||||||
|
n = min(P, W - x)
|
||||||
|
cuts.append((x, n)); x += n
|
||||||
|
blob += struct.pack(">H", len(cuts))
|
||||||
|
for x, n in cuts:
|
||||||
|
blob += struct.pack(">HH", x, n)
|
||||||
|
blob += idx[y, x:x+n].astype(">u2").tobytes()
|
||||||
|
nspans += 1; npix += n
|
||||||
|
metas.append(dict(name=f"{P}{'u' if x0 else ''}", p=P, x0=x0, off=off,
|
||||||
|
len=len(blob)-off, nspans=nspans, npix=npix,
|
||||||
|
cpx=npix, fpx=0, var=5))
|
||||||
|
|
||||||
|
# v6: one config per chain depth, so the fit sees spans from 24 to 264 pixels.
|
||||||
|
for units in range(1, UNITS+1):
|
||||||
|
P = units * UNITPX
|
||||||
|
off = len(blob)
|
||||||
|
nspans = npix = 0
|
||||||
|
rows = []
|
||||||
|
for y in range(H):
|
||||||
|
x = 0
|
||||||
|
while x < W:
|
||||||
|
rows.append((y, x)); x += P
|
||||||
|
blob += struct.pack(">H", len(rows))
|
||||||
|
for y, x in rows:
|
||||||
|
blob += struct.pack(">IH", GVRAM + (YOFF+y)*STRIDE + x*2,
|
||||||
|
(UNITS-units)*UNITSZ)
|
||||||
|
# Pad the last span of a row past the visible width; the overrun lands
|
||||||
|
# in the undisplayed half of the line.
|
||||||
|
px = np.concatenate([idx[y, x:x+P], np.zeros(max(0, x+P-W), np.uint8)])
|
||||||
|
blob += px.astype(">u2").tobytes()
|
||||||
|
nspans += 1; npix += P
|
||||||
|
metas.append(dict(name=f"{P}", p=P, x0=0, off=off, len=len(blob)-off,
|
||||||
|
nspans=nspans, npix=npix, cpx=npix, fpx=0, var=6))
|
||||||
|
|
||||||
|
# v7: same tiling, but the span is cut at a 2-pixel quantum instead of 24.
|
||||||
|
for P in V7CONFIGS:
|
||||||
|
units, fine = divmod(P, UNITPX)
|
||||||
|
assert fine % FINEPX == 0 and fine // FINEPX <= FINES, P
|
||||||
|
assert units <= UNITS, P
|
||||||
|
off = len(blob)
|
||||||
|
nspans = npix = 0
|
||||||
|
rows = []
|
||||||
|
for y in range(H):
|
||||||
|
x = 0
|
||||||
|
while x < W:
|
||||||
|
rows.append((y, x)); x += P
|
||||||
|
blob += struct.pack(">H", len(rows))
|
||||||
|
for y, x in rows:
|
||||||
|
assert x + P <= STRIDE // 2, (P, x) # the overrun must stay on the line
|
||||||
|
blob += struct.pack(">IH", GVRAM + (YOFF+y)*STRIDE + x*2,
|
||||||
|
(UNITS-units)*UNITSZ)
|
||||||
|
px = np.concatenate([idx[y, x:x+P], np.zeros(max(0, x+P-W), np.uint8)])
|
||||||
|
blob += px[:units*UNITPX].astype(">u2").tobytes()
|
||||||
|
blob += struct.pack(">H", (FINES - fine//FINEPX)*FINESZ)
|
||||||
|
blob += px[units*UNITPX:].astype(">u2").tobytes()
|
||||||
|
nspans += 1; npix += P
|
||||||
|
metas.append(dict(name=f"{P}", p=P, x0=0, off=off, len=len(blob)-off,
|
||||||
|
nspans=nspans, npix=npix,
|
||||||
|
cpx=nspans*units*UNITPX, fpx=nspans*fine, var=7))
|
||||||
|
|
||||||
|
open(OUT, "wb").write(blob)
|
||||||
|
with open(META, "w") as f:
|
||||||
|
f.write("-- generated by tools/bench/prep_spans.py -- do not edit\nreturn {\n")
|
||||||
|
f.write(f" W={W}, H={H}, total={len(blob)},\n configs = {{\n")
|
||||||
|
for m in metas:
|
||||||
|
f.write(" {{var={var}, name=\"{name}\", p={p}, x0={x0}, off={off},"
|
||||||
|
" len={len}, nspans={nspans}, npix={npix}, cpx={cpx},"
|
||||||
|
" fpx={fpx}}},\n".format(**m))
|
||||||
|
f.write(" },\n}\n")
|
||||||
|
|
||||||
|
print(f"{SRC} {W}x{H} -> {OUT} {len(blob)} B, {len(metas)} configs")
|
||||||
|
for m in metas:
|
||||||
|
print(f" v{m['var']} span {m['name']:>4} px: {m['nspans']:6d} spans, "
|
||||||
|
f"{m['npix']:6d} px, {m['len']:7d} B "
|
||||||
|
f"(+{100*m['len']/(2*W*H)-100:.1f}% over bare pixels)")
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Lay a DLX3 container out as a DISK for the ring-buffer rig (FINDINGS 49).
|
||||||
|
|
||||||
|
python3 tools/bench/prep_stream.py <in.dlx> [--out tmp/stream]
|
||||||
|
|
||||||
|
prep_dlx.py's output is one blob that tools/bench/decode.lua pushes into
|
||||||
|
emulated RAM in its entirety. That is what makes its rig RAM-bound -- a `scsi`
|
||||||
|
window is 5,261,814 B of stream and needs a 6 MB machine to hold it (FINDINGS
|
||||||
|
45) -- and, much more importantly, it is nothing like the shipping player, which
|
||||||
|
never holds a window at once.
|
||||||
|
|
||||||
|
This writes three files instead:
|
||||||
|
|
||||||
|
<out>_cb.bin codebooks + palette. ~10 KB, loaded into RAM once, exactly as
|
||||||
|
before: these are LOAD-TIME costs and not per-frame ones.
|
||||||
|
<out>_disk.bin the frame records, `[u32 len][body]` each padded up to 4, laid
|
||||||
|
end to end. tools/bench/stream.lua reads this from the HOST
|
||||||
|
filesystem and feeds it into a bounded ring, so the emulated
|
||||||
|
machine's RAM stops bounding how much of a window can be
|
||||||
|
tested. A stock 2 MB machine can now run all 120 frames.
|
||||||
|
<out>_idx.bin the DLX4 RECORD INDEX, nframes u16 big-endian, straight out
|
||||||
|
of the container's scene header. This is what the 68000
|
||||||
|
producer reads (src/player/ring.i); it is not derived here,
|
||||||
|
because deriving it is precisely what a player cannot do.
|
||||||
|
<out>_meta.lua geometry, and the record index.
|
||||||
|
|
||||||
|
THE RECORD INDEX IS NOT A CONVENIENCE. src/player/stream.s takes each frame's
|
||||||
|
base address from a descriptor the producer wrote, rather than deriving it from
|
||||||
|
where the last frame ended, because under the `aligned` wrap policy the next
|
||||||
|
record may be at the ring's base instead of just after its predecessor. The
|
||||||
|
producer therefore has to know record boundaries before it places them -- which
|
||||||
|
is what an index is. A branching laserdisc game needs one anyway to seek to a
|
||||||
|
branch point, so the policy that costs no clocks (tools/analysis/19_ring_stream.py)
|
||||||
|
reuses a structure the player cannot avoid.
|
||||||
|
|
||||||
|
The 4-byte record padding is the same one decode.s needs and DLX3 already
|
||||||
|
carries: `move.l (a0)+,d0` on an odd address is an ADDRESS ERROR on a 68000, not
|
||||||
|
a slow read. FINDINGS 28.3.
|
||||||
|
|
||||||
|
NO SYNTHETIC TIMING FRAMES. prep_dlx.py appends ten of them to price the block
|
||||||
|
modes separately; this rig measures delivery, not decode, and its per-frame cost
|
||||||
|
anchors are prep_dlx.py's job. Mixing them in would put frames on the wire that
|
||||||
|
no encoder emits and no rate controller sized.
|
||||||
|
"""
|
||||||
|
import sys, os, argparse
|
||||||
|
sys.path.insert(0, "tools/encoder")
|
||||||
|
sys.path.insert(0, "tools/bench")
|
||||||
|
import numpy as np
|
||||||
|
from dlx import DLX
|
||||||
|
import dlxload as DL
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("container")
|
||||||
|
ap.add_argument("--out", default="tmp/stream")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
d = DLX(a.container)
|
||||||
|
if d.idx_bytes != 1:
|
||||||
|
sys.exit("2-byte codebook indices: src/player/ assumes 1 (k<=256)")
|
||||||
|
if not d.has_spans:
|
||||||
|
sys.exit(f"{a.container} is DLX{d.version}: src/player/stream.s expects the "
|
||||||
|
f"DLX3 span section (see prep_dlx.py for why a DLX2 container "
|
||||||
|
f"decodes as garbage rather than merely losing its spans).")
|
||||||
|
|
||||||
|
cb1, cb4 = DL.expand_codebooks(d)
|
||||||
|
palb, dark, rendered = DL.pack_palette(d)
|
||||||
|
open(a.out + "_cb.bin", "wb").write(cb1.tobytes() + cb4.tobytes() + palb.tobytes())
|
||||||
|
|
||||||
|
disk, index = bytearray(), []
|
||||||
|
for (o, n) in d.frames:
|
||||||
|
start = len(disk)
|
||||||
|
disk += n.to_bytes(4, "big") + d.raw[o:o + n]
|
||||||
|
# The container's own alignment rule, not this script's copy of it: DLX5
|
||||||
|
# pads to 512 so a DMA channel can read whole sectors into the ring, DLX4
|
||||||
|
# to 4 so `move.l (a0)+` does not take an address error (28.3).
|
||||||
|
while len(disk) % d.rec_align:
|
||||||
|
disk += b"\0"
|
||||||
|
index.append((start, len(disk) - start))
|
||||||
|
open(a.out + "_disk.bin", "wb").write(bytes(disk))
|
||||||
|
|
||||||
|
# THE CONTAINER'S OWN INDEX, and it is checked against this layout rather than
|
||||||
|
# regenerated from it. src/player/ring.i walks the disk with a running sum of
|
||||||
|
# these lengths and never reads a record's length word before fetching it, so a
|
||||||
|
# container index that disagreed with the disk image by one byte would place
|
||||||
|
# every later record at the wrong address -- and the block loop reads without a
|
||||||
|
# bounds check (49.2), so the symptom would be wrong pixels, not a fault.
|
||||||
|
if d.has_index:
|
||||||
|
want = [ln // 4 for _, ln in index]
|
||||||
|
if d.index != want:
|
||||||
|
bad = next(i for i in range(len(want)) if d.index[i] != want[i])
|
||||||
|
sys.exit(f"{a.container}: the DLX4 index disagrees with this disk "
|
||||||
|
f"layout at record {bad}: {d.index[bad]} vs {want[bad]} "
|
||||||
|
f"longwords")
|
||||||
|
open(a.out + "_idx.bin", "wb").write(
|
||||||
|
b"".join(q.to_bytes(2, "big") for q in d.index))
|
||||||
|
else:
|
||||||
|
if os.path.exists(a.out + "_idx.bin"):
|
||||||
|
os.remove(a.out + "_idx.bin") # a stale index is worse than none
|
||||||
|
|
||||||
|
rec = np.array([n for _, n in index])
|
||||||
|
with open(a.out + "_meta.lua", "w") as fh:
|
||||||
|
fh.write("-- generated by tools/bench/prep_stream.py -- do not edit\nreturn {\n")
|
||||||
|
fh.write(f" W={d.W}, H={d.H}, fps={d.fps}, nframes={d.nframes}, dark={dark},\n")
|
||||||
|
fh.write(f" cb1_len={cb1.nbytes}, cb4_len={cb4.nbytes}, pal_len={palb.nbytes},\n")
|
||||||
|
fh.write(f" disk_len={len(disk)}, maxrec={int(rec.max())},\n")
|
||||||
|
fh.write(f" dlx_version={d.version}, "
|
||||||
|
f"has_index={'true' if d.has_index else 'false'},\n")
|
||||||
|
fh.write(f" padrec={{{','.join(str(ln) for _, ln in index)}}},\n")
|
||||||
|
fh.write(" index={\n")
|
||||||
|
for off, ln in index:
|
||||||
|
fh.write(f" {{off={off}, len={ln}}},\n")
|
||||||
|
fh.write(" },\n}\n")
|
||||||
|
|
||||||
|
print(f"{a.container}: {d.nframes} frames, {d.W}x{d.H}")
|
||||||
|
print(f" codebooks+palette {cb1.nbytes + cb4.nbytes + palb.nbytes:,} B -> "
|
||||||
|
f"{a.out}_cb.bin")
|
||||||
|
print(f" disk image {len(disk):,} B -> {a.out}_disk.bin "
|
||||||
|
f"(records: min {rec.min():,} median {int(np.median(rec)):,} "
|
||||||
|
f"max {rec.max():,})")
|
||||||
|
print(f" wire rate {rec.mean()*d.fps/1024:.1f} KB/s video at {d.fps} fps")
|
||||||
|
if d.has_index:
|
||||||
|
print(f" DLX4 record index: {2*d.nframes:,} B of scene header, and it "
|
||||||
|
f"agrees with the disk image on all {d.nframes} records")
|
||||||
|
print(f" A ring must hold one whole record contiguously: >= {rec.max():,} B "
|
||||||
|
f"({rec.max()/1024:.1f} KB) before any policy or prefill.")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user