Put the ring on the 68000, and find the disc stops whenever the player is not asking
ROADMAP P5. The loader moved in session 21 and the frame clock in 22; the ring producer was the last policy living outside the machine. src/player/ring.i does `aligned` placement, the descriptor ring, a prefill, 51.2's slack rule and a seek, and the host keeps only the transport. It needed a container change. `aligned` asks whether the next record fits before the end of the ring -- a length asked BEFORE the record is fetched -- and every reader in this tree answered that by walking the frame stream, which is exactly what a player streaming off a disc cannot do. DLX4 carries nframes u16 record lengths in the scene header. Frame payloads are byte-identical to the DLX3 encode, so no fitted constant moves; the scene header goes 5,920 to 6,164 B. The producer reproduces the host's tiling exactly: 18 wraps, 14.7 KB mean hole, pixel-exact, a third independent implementation of the same policy. What it exposed is bigger than the item. A channel only moves bytes while it has a request and only the CPU can issue one, so the disc stands still between records by an amount the PLAYER sets, not the medium -- and no host-filled run could see it. At 488 KB/s in a 256 KB ring a one-deep request queue gives away 6.8% of the pipe and underruns 59 of 120 frames; two-deep gives away 3.4% and underruns none. The container's whole surplus over the wire is 8.7%, so the player's own loop was spending most of the slack a branch point saves up. Prefill is the weaker lever: six records of it still leaves 24 underruns. Three silent bugs are recorded in FINDINGS 55.7 -- all produced wrong pixels or a desync rather than a fault -- plus a rig one: MAME renders a screen line by line, so snapshotting the frame the decoder finished in captures a tear that reads exactly like a decoder bug. check.sh gains the machine-owned ring and a seek with the decode after it. decode.bin is unchanged at 1,296 B and a host-filled run executes none of the new code, so every FINDINGS 49/51 figure stands. ALL GREEN before and after. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
@@ -117,6 +117,15 @@ of seek slack those bytes lengthen the refill climb by 138 ms at 488 KB/s and by
|
||||
**1.099 s at 451.4 KB/s**, because the surplus they are divided by goes to zero
|
||||
(FINDINGS 53).
|
||||
|
||||
**The 68000 fills its own ring now, and the player's request loop costs more
|
||||
than the medium does.** `src/player/ring.i` places records, prefills, keeps the
|
||||
slack rule and seeks, out of a per-record index the container carries (DLX4).
|
||||
The channel only moves bytes while it has a request and only the CPU can issue
|
||||
one, so the disc **stands still between records** by an amount the player sets:
|
||||
at 488 KB/s a one-deep request queue gives away **6.8% of the pipe and underruns
|
||||
59 of 120 frames**, a two-deep one gives away 3.4% and underruns none — on a
|
||||
container whose whole surplus over the wire is 8.7% (FINDINGS 55).
|
||||
|
||||
**Current encode:** 496.7 KB/s at 29.19 dB, 1 frame of 120 over the 12fps
|
||||
budget, and that one is frame 0, the intra frame, late on purpose.
|
||||
|
||||
@@ -317,6 +326,9 @@ tools/encoder/ hybrid VQ encoder and DLX3 container writer.
|
||||
DLX2 4-byte-aligns every frame record, because an odd move.l
|
||||
is an ADDRESS ERROR on a 68000, not a slow read.
|
||||
dlx.py is the reference DECODER, ground truth for the 68000.
|
||||
24 models the ring with the 68000 owning it: the request
|
||||
queue, the poll-only-when-not-decoding rule and 54.4's frame
|
||||
cadence, and reports the pipe the player's own loop gives away.
|
||||
src/player/ decode.s is the 68000 DLX3 decoder with a preloaded-stream
|
||||
front-end. stream.s is the same decoder behind a bounded ring.
|
||||
load.i is the LOAD-time half: codebook expansion and palette
|
||||
@@ -324,6 +336,11 @@ src/player/ decode.s is the 68000 DLX3 decoder with a preloaded-stream
|
||||
its rig front-end. Its three scratch tables describe the
|
||||
machine rather than the scene, so they are a separate entry
|
||||
point a player calls once at boot.
|
||||
ring.i is the RING PRODUCER: `aligned` placement, the
|
||||
descriptor ring, the prefill policy, 51.2's slack rule as
|
||||
arithmetic (ring_may_seek) and a seek. It reads the DLX4 record
|
||||
index because a player cannot learn a record's length by
|
||||
walking a stream it has not fetched.
|
||||
Both include frame.i (the block loop and span chain) and
|
||||
geom.i (the constants), so there is exactly ONE copy of the
|
||||
bytes every cycle constant is fitted to. The span pass is
|
||||
|
||||
@@ -4814,3 +4814,206 @@ accumulator fits 16 bits. `decode.s` and `frame.i` were checked for stack tricks
|
||||
before the mask was lowered: the only `a7` use in either is one `move.l a1,-(sp)`
|
||||
pair, so an interrupt cannot corrupt decoder state. The 120-frame self-paced
|
||||
decode being pixel-exact is the test of that, and it is gated.
|
||||
|
||||
---
|
||||
|
||||
## 55. The 68000 fills its own ring, and the player's request loop costs more than the medium does (session 23)
|
||||
|
||||
ROADMAP P5, the last M2 item this tree could build. FINDINGS 49 and 51 measured
|
||||
a ring that a HOST filled: `tools/bench/stream.lua` held the record index, chose
|
||||
where every record went, wrote the descriptor and advertised it. The 68000 only
|
||||
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 was the last one in the delivery path.
|
||||
|
||||
`src/player/ring.i` is that policy on the 68000: `aligned` placement, the
|
||||
descriptor ring, a prefill, an accumulated-slack rule and a seek. The rig keeps
|
||||
only what is genuinely not the CPU's — a transport that answers one request at a
|
||||
time at a modelled rate, which is what an SPC and one DMAC channel are.
|
||||
|
||||
### 55.1 The container had to change: DLX4 carries a record index
|
||||
|
||||
`aligned` asks whether the NEXT record fits 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 boundaries 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. A branch point needs the same table a second time, to
|
||||
seek to record *j* without reading what lies between.
|
||||
|
||||
DLX4 adds `nframes` u16 longword-counts to the scene header, ahead of the frame
|
||||
stream. Costs, measured on the gate container:
|
||||
|
||||
| | DLX3 | DLX4 |
|
||||
|---|---:|---:|
|
||||
| scene header | 5,920 B | **6,164 B** (+240 index, +4 header) |
|
||||
| frame payloads | — | **byte-identical**, all 120 |
|
||||
|
||||
The payloads being byte-identical is asserted rather than assumed: the same
|
||||
encode was written both ways and compared record for record, so **no constant
|
||||
fitted to the gate container moves**. `dlx.py` cross-checks the index against
|
||||
its own walk of the stream and refuses a container where they disagree, and
|
||||
`prep_stream.py` checks it again against the disk image it lays out. Lengths
|
||||
rather than offsets: 2 bytes a frame instead of 4, and the disc offsets are a
|
||||
running sum the player builds once at scene load (`ROFF`, 4 B/record of RAM).
|
||||
|
||||
In 53.5's currency the 244 bytes are small — 0.5 ms of pipe at 488 KB/s — but
|
||||
they are on the same side of the ledger as the 5,920 that section priced, and
|
||||
the scene header is now **6,164 B that must arrive before frame 0**.
|
||||
|
||||
### 55.2 It reproduces the host producer's tiling exactly
|
||||
|
||||
Third independent implementation of `aligned`, on the gate container in a 256 KB
|
||||
ring:
|
||||
|
||||
| producer | wraps | mean hole | pixel-exact |
|
||||
|---|---:|---:|:--:|
|
||||
| `19_ring_stream.py` (Python, from record sizes) | 18 | 14.7 KB | — |
|
||||
| `stream.lua` (host, driving the 68000) | 18 | 14.7 KB | yes |
|
||||
| **`ring.i` (the 68000 itself)** | **18** | **14.7 KB** | **yes** |
|
||||
|
||||
The host now **audits** rather than produces: every placement the machine makes
|
||||
is checked against the host's own index and its own list of records the decoder
|
||||
has not consumed, and the run is refused on the first disagreement. That is what
|
||||
makes the pixel-exact result a statement about `ring.i` and not about a new rig.
|
||||
|
||||
### 55.3 THE ONE THAT MOVES SOMETHING: the channel is idle whenever the player is not asking
|
||||
|
||||
A channel only moves bytes while it has a request, and only the CPU can give it
|
||||
one. Between the completion of record *i* and the issue of record *i+1* the disc
|
||||
**stands still**, and the length of that gap is a property of the player's loop,
|
||||
not of the medium. No host-filled run could see it — the host producer placed
|
||||
records whenever it liked — so **no rate table in this tree contains it**.
|
||||
|
||||
Measured on the machine, same container, same 256 KB ring, same 488 KB/s, the
|
||||
only difference being how many requests the player may have outstanding:
|
||||
|
||||
| queue | channel idle | gaps | underruns | slack ceiling | mean slack | bound by |
|
||||
|---:|---:|---:|---:|---:|---:|---|
|
||||
| **1 request** | **669.0 ms, 6.8%** | 119 | **59/120** | 2 | 1.0 | rate |
|
||||
| **2 requests** | **317.5 ms, 3.4%** | 9 | **0/120** | 5 | 3.5 | ring |
|
||||
|
||||
The surplus this container has over the wire at 488 KB/s is 8.7% of the pipe,
|
||||
and a one-deep request loop spends 6.8% of it on nothing. **That is most of the
|
||||
surplus 51.3's lookahead is accumulated out of**, which is why the same ring at
|
||||
the same rate goes from rate-bound with a ceiling of 2 to ring-bound with a
|
||||
ceiling of 5 on a change with no bytes in it at all.
|
||||
|
||||
A second queued slot costs the 68000 nothing per frame and is available on the
|
||||
hardware: the HD63450 has four channels and the IPL programs all of them (52.1).
|
||||
|
||||
### 55.4 Prefill is the weaker lever, and now there is a number for it
|
||||
|
||||
Prefill in whole records, at 488 KB/s in a 256 KB ring, every cell pixel-exact:
|
||||
|
||||
| prefill | 1 | 2 | 3 | 4 | 6 |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| underruns, 1-deep queue | 66 | 59 | 49 | 47 | 24 |
|
||||
| underruns, 2-deep queue | 1 | **0** | **0** | **0** | **0** |
|
||||
|
||||
**A prefill buys a one-off cushion that a rate-bound pipe spends immediately; a
|
||||
queued request buys the rate back every frame.** Six records of prefill is half
|
||||
a second of black screen at the start of every scene and still leaves 24
|
||||
underruns; a second slot leaves none for nothing. The policy `ring_prefill`
|
||||
implements is therefore small — 2 records — and the reason it is not 1 is 51.2:
|
||||
*n* resident records buy *n-1* frame times, so releasing at 1 starts a scene
|
||||
with a stall budget of zero.
|
||||
|
||||
### 55.5 The slack rule is in the player now, and so is a seek
|
||||
|
||||
`ring_may_seek` answers 51.2's rule as arithmetic the player can run — "resident
|
||||
minus one, against the frames this branch will cost" — instead of a line in a
|
||||
rig's log. `ring_seek` takes a record number, waits the channel quiet (an
|
||||
outstanding transfer is bytes already on their way to an address about to be
|
||||
declared free), takes the disc address out of the index, and empties the ring.
|
||||
|
||||
Rehearsed as a second pass over the same scene: **240 records placed, the seek
|
||||
at 12.87 s, the ring refilled from empty, 0 underruns after it, and the final
|
||||
frame of the second pass pixel-exact**. The seek's cost shows up exactly where
|
||||
55.3 says it would — as the worst channel gap of the run, **397.5 ms** — and
|
||||
that is the disc idle, not a mechanical seek, which is still unmodelled (51.7.5).
|
||||
|
||||
### 55.6 An independent model, and where it does and does not agree
|
||||
|
||||
`tools/analysis/24_ring_owner.py` is the same producer written from record
|
||||
sizes and per-frame decode costs, sharing no code with the rig — the 49.4/51.5
|
||||
arrangement. It reads the DLX4 index the machine reads, and it reproduces 54.4's
|
||||
4-or-5-refresh cadence rather than averaging it away.
|
||||
|
||||
| 488 KB/s | rig Q=1 | model Q=1 | rig Q=2 | model Q=2 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| channel idle | 6.8% | 8.8% | 3.4% | 5.3% |
|
||||
| slack ceiling | 2 | 3 | 5 | 6 |
|
||||
| mean slack | 1.0 | 1.1 | 3.5 | 4.2 |
|
||||
| underruns | 59/120 | 11/120 | 0/120 | 0/120 |
|
||||
|
||||
The model runs **one record ahead** of the rig, which is the same one-record
|
||||
bracket 51.5 recorded and reported rather than tuned away. The underrun count at
|
||||
Q=1 is the one number that disagrees badly, and it is a threshold statistic on a
|
||||
quantity sitting at 1: with a mean slack of one record, whether each individual
|
||||
frame's record lands before or after its tick is decided by details neither model
|
||||
has. **The agreement that matters is the resource statement** — a one-deep queue
|
||||
loses 7-9% of the pipe and two-thirds of the lookahead — and on that they agree.
|
||||
|
||||
The model's mean decode cost, 561,126 clk/frame, lands within 0.07% of the
|
||||
561,532 the ring pass measured under MAME (49.7.5), from the encoder's own
|
||||
constants.
|
||||
|
||||
### 55.7 Three bugs and one instrument correction, recorded because they were all silent
|
||||
|
||||
1. **The reader's wrap rule was not the writer's.** Stepping the read cursor
|
||||
past record *i* lands on the end of record *i*, which is where record *i+1*
|
||||
went only if it FITTED there. Using the wrong record's length left the cursor
|
||||
inside the hole, and one more retirement pushed it past the end of the ring
|
||||
and wrapped it to an address unrelated to any record. The live span computed
|
||||
from that is *shorter* than the truth, so the producer places over a record
|
||||
the decoder has not read. Symptom: a bitstream desync, not a fault.
|
||||
2. **The free-space test decided the wrap before it knew the shape.** When the
|
||||
LIVE span is the one that wraps, the ring base is not free and `aligned` may
|
||||
not restart there. Deciding from `WCUR + len > SZ` alone overwrote live
|
||||
records. Same symptom.
|
||||
3. **The queue was gated on completion instead of retirement.** A slot stays in
|
||||
use until the descriptor has been read out of it, which happens one poll after
|
||||
the ack at the earliest. Gating on the ack let the CPU overwrite a slot whose
|
||||
descriptor had not been published; `DESC` for that frame stayed zero and the
|
||||
decoder decoded address zero. This one only exists at a queue depth above 1,
|
||||
and it is why the two-deep result took three attempts to obtain.
|
||||
4. **The rig was capturing a torn screen.** MAME renders a screen line by line
|
||||
and the machine-frame notifier fires at the END of that frame, so a bitmap for
|
||||
a frame in which GVRAM changed holds lines from before and after the change.
|
||||
Snapshotting it captures a tear, which reads as a pixel-exactness failure in
|
||||
the bottom blocks plus a broken double-scan pairing. It only bites when the
|
||||
decoder finishes its last frame late in a screen frame, so it appeared for the
|
||||
first time in a run with underruns. The rig now waits one whole frame before
|
||||
the capture. **No previously reported result is affected** — every one of them
|
||||
finished its last frame with idle to spare — but the check.sh gates would have
|
||||
been flaky under any future run that did not.
|
||||
|
||||
An instrument note, not a bug: under the self-clock the host's "records late"
|
||||
report grades arrivals against the tick times it OBSERVED, which are up to
|
||||
17.64 ms late (54.5), so it understates lateness — 6 records late where the
|
||||
68000 itself counted 59 frames that had to wait. The decoder's own stall counter
|
||||
is the sharp instrument. Host-paced runs take their deadlines from a host model
|
||||
and are exact, so 49.6's table is unaffected.
|
||||
|
||||
### 55.8 What this does NOT establish
|
||||
|
||||
1. **The transport is still a model.** It delivers at a chosen byte rate with an
|
||||
exact clock; it is not an MB89352. No command overhead, no arbitration, no
|
||||
sector granularity, no mechanical seek. `W` — the clocks the DMAC steals per
|
||||
delivered byte — is still undecided and still unmeasurable here (P4, 52.5).
|
||||
**A zero-underrun result means the bytes were in time, not that the frames
|
||||
fit.**
|
||||
2. **The rates are chosen inputs.** FINDINGS 50 stands: every column is a
|
||||
sensitivity, not a claim about a BlueSCSI.
|
||||
3. **One container, one scene, one ring size.** The ceilings are in whole
|
||||
records and move with record size (51.7.3).
|
||||
4. **The seek is a rewind, not a branch.** It exercises the machinery — quiet
|
||||
the channel, empty the ring, address record *j* out of the index, refill —
|
||||
against a container that has one scene in it. What the worst gap between two
|
||||
real decision points is still needs the scene graph (ROADMAP G1).
|
||||
5. **`decode.s` and `frame.i` are unchanged** and `decode.bin` is still 1,296 B
|
||||
at the same MD5. `stream.s` grew to 2,814 B: the ring producer, plus a
|
||||
two-instruction test at the top of the pace wait that routes a self-filled
|
||||
run into a polling wait loop. The legacy wait loops are byte for byte the ones
|
||||
FINDINGS 51 measured and a host-filled run executes none of the new code.
|
||||
|
||||
+32
-4
@@ -157,7 +157,10 @@ period from `htotal - 8`), so the tree's "1/55.46 s granularity" was 1/56.69 s
|
||||
throughout. No 68000 cycle figure moves — the CPU clock is unrelated to the
|
||||
screen — but anything paced by the raster does. 54.5.
|
||||
|
||||
**P4. Real transport.** Drive the MB89352 instead of a host file. The `W`
|
||||
**P4. Real transport.** Drive the MB89352 instead of a host file. **Session 23
|
||||
added a second axis to it:** `W` is the clocks stolen per delivered byte, and
|
||||
55.3 measured that the player's own request loop gives away 3-7% of the pipe
|
||||
before `W` is even asked about. A transport design has to answer both. The `W`
|
||||
handshake — clocks stolen per delivered byte, bracketed 5..12 by MC68450 Fig
|
||||
4-25 — is listed in "Decisions locked" as UNDECIDED and as the thing that
|
||||
decides the project: `W<=6` fits 0/120 frames, `W=8` misses 47/120. It is a
|
||||
@@ -178,11 +181,36 @@ first job rather than its last.
|
||||
clocks per WORD and FINDINGS 43 voided it; 52.5 cited it in byte units when
|
||||
first written and strikes it.
|
||||
|
||||
**P5. Seek and branch.** Per-record index (the `aligned` producer needs one
|
||||
anyway, 49.3), prefill policy, and the accumulated-slack rule from 51.3 made
|
||||
explicit in the player rather than implied by the rig.
|
||||
~~**P5. Seek and branch.**~~ **DONE, session 23 — FINDINGS 55.**
|
||||
`src/player/ring.i` fills the ring on the 68000: `aligned` placement, the
|
||||
descriptor ring, a prefill policy, 51.2's slack rule as arithmetic the player
|
||||
can run (`ring_may_seek`), and a seek that quiets the channel and re-addresses
|
||||
the stream out of the index. It reproduces the host producer's tiling exactly —
|
||||
18 wraps, 14.7 KB mean hole, pixel-exact — and the host now AUDITS every
|
||||
placement instead of making it.
|
||||
|
||||
The index is a **container change**: DLX4 carries `nframes` u16 record lengths
|
||||
in the scene header, because `aligned` needs a record's length before it fetches
|
||||
it and walking the stream is precisely what a player cannot do. Frame payloads
|
||||
are byte-identical to the DLX3 encode; the scene header goes 5,920 to 6,164 B.
|
||||
|
||||
**What it exposed is bigger than the item.** A channel only moves bytes while it
|
||||
has a request and only the CPU can issue one, so the disc **stands still between
|
||||
records** by an amount set by the player's loop rather than by the medium — and
|
||||
no host-filled run could see it. At 488 KB/s in a 256 KB ring, a one-deep
|
||||
request queue gives away **6.8% of the pipe and underruns 59 of 120 frames**; a
|
||||
two-deep one gives away 3.4% and underruns none. The container's whole surplus
|
||||
over the wire at that rate is 8.7%, so the player's own loop was spending most
|
||||
of the slack 51.3 accumulates. **Prefill is the weaker lever** — six records of
|
||||
it still leaves 24 underruns at depth 1 — and the fix costs no clocks and no
|
||||
bytes. 55.3, 55.4.
|
||||
|
||||
**P5a (open, and it belongs with P4).** The two-deep queue is modelled as two
|
||||
mailbox slots. On the machine it is two DMAC channels or one channel with a
|
||||
chained descriptor array, and which of those is affordable is a `W` question.
|
||||
|
||||
**P7. Boot.** The player as an executable loading from the SCSI volume.
|
||||
Buildable, and empty until P4: there is nothing to boot from yet.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+106
@@ -1,3 +1,109 @@
|
||||
# Status & next-session handoff — end of session 23 (2026-08-24)
|
||||
|
||||
## Session 23: the 68000 fills its own ring, and the player's request loop turns out to cost more than the medium does
|
||||
|
||||
**Green light first and last: `./tools/bench/check.sh` was ALL GREEN before any
|
||||
of this and ALL GREEN after**, 120/120 on both cores, no `TRUNCATED`, plus two
|
||||
new ring stages.
|
||||
|
||||
**ROADMAP P5 is DONE. FINDINGS 55.** P5 was the last M2 item buildable in this
|
||||
tree, and it is the third and last policy to move off the host: the loader went
|
||||
in session 21, the frame clock in 22, and the ring producer now.
|
||||
|
||||
**1. The container had to change, and it is the first format change since
|
||||
session 12.** `aligned` asks whether the next record fits before the end of the
|
||||
ring — a question about a record's length asked **before it is fetched** — and
|
||||
every reader in this tree answered it by WALKING the frame stream, which is
|
||||
exactly what a player streaming off a disc cannot do. **DLX4** carries
|
||||
`nframes` u16 record lengths in the scene header. The frame payloads are
|
||||
**byte-identical** to the DLX3 encode, asserted record for record, so no
|
||||
constant fitted to the gate container moves; the scene header goes 5,920 →
|
||||
**6,164 B**. `dlx.py` refuses a container whose index disagrees with its own
|
||||
walk. 55.1.
|
||||
|
||||
**2. `src/player/ring.i` reproduces the host producer exactly.** Same 18 wraps,
|
||||
same 14.7 KB mean hole, pixel-exact — a third independent implementation of
|
||||
`aligned` landing on the same tiling (55.2). The host now **audits** every
|
||||
placement instead of making it.
|
||||
|
||||
**3. THE ONE THAT MOVES SOMETHING: the disc stands still whenever the player is
|
||||
not asking.** A channel only moves bytes while it has a request and only the CPU
|
||||
can issue one, so there is a gap between every pair of records that is a
|
||||
property of the **player's loop, not the medium** — and no host-filled run could
|
||||
see it, so no rate table in this tree contains it. At 488 KB/s in a 256 KB ring,
|
||||
changing nothing but how many requests the player may have outstanding:
|
||||
|
||||
| queue | channel idle | underruns | slack ceiling | bound by |
|
||||
|---:|---:|---:|---:|---|
|
||||
| 1 request | **6.8%** | **59/120** | 2 | rate |
|
||||
| 2 requests | **3.4%** | **0/120** | 5 | ring |
|
||||
|
||||
This container's surplus over the wire at 488 KB/s is 8.7% of the pipe, and a
|
||||
one-deep request loop spends 6.8% of it on nothing — **most of the surplus
|
||||
51.3's lookahead is accumulated out of**. A second slot costs no clocks and the
|
||||
hardware has four channels (52.1). 55.3.
|
||||
|
||||
**4. Prefill is the weaker lever, and now it has a number.** Six records of
|
||||
prefill — half a second of black at every scene start — still leaves 24
|
||||
underruns at a one-deep queue; a second queue slot leaves none. **A prefill buys
|
||||
a one-off cushion a rate-bound pipe spends immediately; a queued request buys
|
||||
the rate back every frame.** The shipped policy is 2 records, and it is not 1
|
||||
because 51.2 measured that *n* resident records buy *n-1* frame times. 55.4.
|
||||
|
||||
**5. The slack rule and a seek are in the player.** `ring_may_seek` is 51.2's
|
||||
rule as arithmetic the player runs; `ring_seek` quiets the channel, takes record
|
||||
*j*'s disc address out of the index and empties the ring. Rehearsed as a second
|
||||
pass: 240 records, seek at 12.87 s, refill from empty, **0 underruns after it
|
||||
and the last frame of the second pass pixel-exact**. The seek shows up as the
|
||||
run's worst channel gap, **397.5 ms** — disc idle, not mechanical seek, which is
|
||||
still unmodelled. 55.5.
|
||||
|
||||
**6. Three silent bugs and one instrument correction**, all written up in 55.7,
|
||||
because every one of them produced wrong pixels or a desync rather than a fault:
|
||||
the read cursor's wrap rule not matching the writer's; the free-space test
|
||||
deciding a wrap before it knew whether the LIVE span was the wrapping one; the
|
||||
request queue gated on completion instead of retirement (only reachable above
|
||||
depth 1). The fourth is the rig's: MAME renders a screen line by line, so
|
||||
snapshotting the frame in which the decoder finished captures a **tear** — it
|
||||
looked exactly like a decoder bug and was not. The rig now settles one frame
|
||||
before capturing. No previously reported result is affected.
|
||||
|
||||
**New in the tree:** `src/player/ring.i` (the producer, the prefill, the slack
|
||||
rule and the seek); `tools/analysis/24_ring_owner.py` (an independent model of
|
||||
all of it, sharing no code with the rig). `encode.py`/`dlx.py` gain DLX4 and its
|
||||
index cross-check; `prep_stream.py` emits the index and checks it against the
|
||||
disk image; `load.i` accepts DLX3 or DLX4. `stream.s` gains the ring hooks and a
|
||||
polling wait loop; `stream.lua` becomes a **transport** built out of memory taps
|
||||
(exact issue and completion times — see 55.7 for why nothing in a tap may touch
|
||||
the memory space); `pace_run.sh` gains `DLX_RINGOWN`, `DLX_QDEPTH`,
|
||||
`DLX_PREFILL_FR`, `DLX_ITER`. `check.sh` gains two stages: the machine-owned
|
||||
ring, and a seek with the decode after it.
|
||||
|
||||
**`decode.s` and `frame.i` are unchanged**, `decode.bin` still 1,296 B at the
|
||||
same MD5, and a host-filled run executes none of the new code — so every
|
||||
FINDINGS 49/51 figure stands.
|
||||
|
||||
**Still open in P2:** unchanged — the encoder does not reserve a black entry
|
||||
(23.4).
|
||||
|
||||
**Next:** M2 has no item left that this tree can build. **P4** (drive the
|
||||
MB89352, settle `W`) still decides the project and still needs hardware or a
|
||||
MAME that models the SPC; 55.3 sharpens what to ask of it, because the ladder
|
||||
now has a second axis — the clocks stolen per byte AND the fraction of the pipe
|
||||
the player's own loop gives away. **G1** (import the scene graph) is what would
|
||||
let this tree ask the question 55.5 rehearsed but could not pose: what is the
|
||||
worst gap between two real decision points, and does the refill climb survive
|
||||
it. **P7** (boot from the volume) is buildable but empty until P4.
|
||||
|
||||
**A question 55.3 raises and does not answer:** the encoder is fitted to a pipe
|
||||
that delivers continuously. It does not, and by 3-7% depending on the player's
|
||||
queue. Whether the rate point should be set against the *delivered* rate rather
|
||||
than the nominal one is the same class of change as the reserved black entry and
|
||||
the short-slot question from 54.4 — a re-encode plus a re-measurement, and all
|
||||
three should be decided together.
|
||||
|
||||
---
|
||||
|
||||
# Status & next-session handoff — end of session 22 (2026-08-24)
|
||||
|
||||
## Session 22: the frame clock moves onto the 68000, and the 12 fps frame turns out not to exist
|
||||
|
||||
+13
-2
@@ -63,11 +63,22 @@ GPAL = $E82000 ; graphics palette, 256 words
|
||||
; 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 container. a0-a4 clobbered, a5 = base.
|
||||
; 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
|
||||
cmpi.l #$444C5833,(a5) ; 'DLX3'
|
||||
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
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
; ---------------------------------------------------------------- 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
|
||||
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
|
||||
|
||||
; ---- 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
|
||||
+84
-2
@@ -94,6 +94,9 @@ CLKON = $1803C ; 1 = the 68000 paces ITSELF: src/player/clock.i
|
||||
; 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
|
||||
@@ -104,6 +107,18 @@ SPINMAX = 2000000 ; polls with no progress before giving up
|
||||
|
||||
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
|
||||
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
|
||||
@@ -125,7 +140,38 @@ outer:
|
||||
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
|
||||
@@ -170,9 +216,9 @@ frameloop:
|
||||
beq.s nopace
|
||||
move.l PACE.l,d0
|
||||
cmp.l FR_TAIL.l,d0 ; PACE < FR_TAIL: the slot has not come
|
||||
bcs.s pacewait ; round yet, so this frame is EARLY
|
||||
bcs.s pacesel ; round yet, so this frame is EARLY
|
||||
tst.l FR_TAIL.l
|
||||
beq.s pacewait ; frame 0 starts AT tick 0 by definition
|
||||
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
|
||||
@@ -182,6 +228,25 @@ frameloop:
|
||||
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
|
||||
@@ -191,6 +256,22 @@ nopace:
|
||||
; 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
|
||||
@@ -253,3 +334,4 @@ desync: move.l #$EE,FLAG.l
|
||||
|
||||
include "src/player/frame.i"
|
||||
include "src/player/clock.i"
|
||||
include "src/player/ring.i"
|
||||
|
||||
@@ -72,8 +72,15 @@ for span_mode in ("need", "all"):
|
||||
f"({100*px/(len(recs)*m['H']*m['W']):.1f}% of all pixels)")
|
||||
|
||||
d = DLX(path)
|
||||
if d.version != 3:
|
||||
print(f"FAIL: container is DLX{d.version}, not DLX3"); bad += 1; continue
|
||||
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" DLX4: 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
|
||||
@@ -104,5 +111,5 @@ print()
|
||||
if bad:
|
||||
print(f"FAILED: {bad} check(s)")
|
||||
sys.exit(1)
|
||||
print("OK the DLX3 span container round-trips: the reference decoder rebuilds "
|
||||
print("OK the span container round-trips: the reference decoder rebuilds "
|
||||
"the\n encoder's reconstruction exactly, from the emitted bytes.")
|
||||
|
||||
@@ -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.")
|
||||
@@ -338,4 +338,72 @@ grep -q "^OK" tmp/selfpace_check.log || {
|
||||
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 and 14.7 KB mean hole 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 "ALL GREEN"
|
||||
|
||||
+25
-1
@@ -16,6 +16,18 @@
|
||||
# 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_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
|
||||
@@ -27,11 +39,21 @@ 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}
|
||||
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
|
||||
|
||||
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
|
||||
@@ -41,6 +63,8 @@ mkdir -p "tmp/snap_pace_$TAG"; rm -f "tmp/snap_pace_$TAG/x68000"/*.png
|
||||
# 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_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 -ramsize 2M -video soft -window -sound none \
|
||||
@@ -53,6 +77,6 @@ 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" \
|
||||
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" \
|
||||
"tmp/pace_$TAG.log" | sed "s/\[STR\] / /"
|
||||
python3 tools/bench/verify_decode.py "$DLX" --snap "tmp/snap_pace_$TAG" | tail -2
|
||||
|
||||
@@ -34,7 +34,7 @@ a = ap.parse_args()
|
||||
|
||||
d = DLX(a.container)
|
||||
if d.version < 3:
|
||||
sys.exit(f"{a.container} is DLX{d.version}: load.i checks for the 'DLX3' magic")
|
||||
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")
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ This writes three files instead:
|
||||
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
|
||||
@@ -71,12 +75,34 @@ for (o, n) in d.frames:
|
||||
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")
|
||||
@@ -89,5 +115,8 @@ 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.")
|
||||
|
||||
+420
-16
@@ -52,7 +52,18 @@
|
||||
-- no provenance that was never a bus measurement at all
|
||||
-- (FINDINGS 42.1). A default is how a folklore number ends up
|
||||
-- silently underneath a table nobody restates it in.
|
||||
-- DLX_PREFILL_KB bytes to deliver before releasing the CPU (default 0)
|
||||
-- DLX_PREFILL_KB bytes to deliver before releasing the CPU (default 0).
|
||||
-- Host-owned ring only: with DLX_RINGOWN the machine does
|
||||
-- its own prefill and DLX_PREFILL_FR is the knob.
|
||||
-- DLX_RINGOWN 1 = THE 68000 OWNS THE RING (ROADMAP P5,
|
||||
-- src/player/ring.i). This script stops placing records and
|
||||
-- becomes a TRANSPORT: it answers one request at a time,
|
||||
-- delivering XF_LEN bytes from XF_OFF to XF_DST in
|
||||
-- len/rate seconds, and the machine decides which record,
|
||||
-- where in the ring, and when. It also AUDITS every
|
||||
-- placement the machine makes against its own index.
|
||||
-- DLX_PREFILL_FR whole records the machine's prefill waits for before it
|
||||
-- releases the decoder (default 2; see ring_prefill)
|
||||
-- DLX_PACE 1 = hold the decoder to META.fps, with the HOST writing
|
||||
-- the tick (default 0 = free-run)
|
||||
-- 2 = hold it to META.fps with the 68000 writing its own
|
||||
@@ -101,6 +112,26 @@ local STALLS, SPINS, DESC = 0x1802C, 0x18030, 0x18100
|
||||
local PACE, PACEON, CLKON = 0x18034, 0x18038, 0x1803C
|
||||
local CLK_VDISP, CLK_FPS, CLK_ERR = 0x18064, 0x18068, 0x1806C
|
||||
local LATEFR, LATEMAX, LATE1ST = 0x18080, 0x18084, 0x18088
|
||||
-- src/player/ring.i. The transport mailbox, then the producer's state and its
|
||||
-- instruments. Nothing below 0x18300 changed: the decoder's handshake with the
|
||||
-- ring (FR_HEAD/FR_TAIL/DESC) is the same one FINDINGS 49 and 51 measured, which
|
||||
-- is what makes a self-filled run a test of ring.i and not of a new rig.
|
||||
local XF_SLOT, XF_SLSZ = 0x18300, 16
|
||||
local XF_GO, XF_ACK, XF_QD = 0x18320, 0x18324, 0x18328
|
||||
local A_RINGOWN, A_RNG_B, A_RNG_SZ, A_IDX_B = 0x1832C, 0x18330, 0x18334, 0x18338
|
||||
local A_RQ_NEXT, A_WCUR, A_RCUR = 0x1833C, 0x18340, 0x18344
|
||||
local A_NHOLE, A_NHOLEB, A_NFULL = 0x18354, 0x18358, 0x1835C
|
||||
local A_NPOLL, A_NISSUE = 0x18360, 0x18364
|
||||
local A_SLKMIN, A_SLKAT = 0x18368, 0x1836C
|
||||
local A_PFREC, A_PFDONE = 0x18370, 0x18374
|
||||
local A_NSEEK, A_SKWAIT = 0x18378, 0x1837C
|
||||
-- The scene header's record index, pushed into RAM before the CPU is launched.
|
||||
-- It sits ABOVE the codebooks rather than in the low RAM around ring.i's own
|
||||
-- tables: setup() runs one raster frame before launch() and the IPL is still
|
||||
-- executing in that gap, so anything written below ~$20000 can be overwritten
|
||||
-- before the 68000 ever sees it. That cost an hour: the machine read a table of
|
||||
-- zeroes and asked the transport for a 0-byte record.
|
||||
local IDXRAM = 0x30000 -- the DLX4 index, in RAM
|
||||
local DESCN = 64
|
||||
local CB1, CB4 = 0x20000, 0x22000
|
||||
local RING = 0x40000
|
||||
@@ -125,6 +156,21 @@ local PACED = (os.getenv("DLX_PACE") == "1") or SELFCLK
|
||||
local CUT_AT = tonumber(os.getenv("DLX_CUT_AT") or "")
|
||||
local CUT_FR = tonumber(os.getenv("DLX_CUT_FR") or "") or 1
|
||||
local SLACK_CSV = os.getenv("DLX_SLACK_CSV")
|
||||
local RINGOWN = (os.getenv("DLX_RINGOWN") == "1")
|
||||
local PREFILL_FR = tonumber(os.getenv("DLX_PREFILL_FR") or "") or 2
|
||||
-- Passes over the scene. With DLX_RINGOWN each pass after the first begins
|
||||
-- with a REAL seek in src/player/stream.s: the channel is waited quiet, the
|
||||
-- ring is declared empty and the lookahead 51.3 says takes seconds of play to
|
||||
-- accumulate is thrown away and rebuilt from the prefill. The check that
|
||||
-- matters afterwards is the one this rig always makes -- the last frame of the
|
||||
-- last 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.
|
||||
local ITERS = tonumber(os.getenv("DLX_ITER") or "") or 1
|
||||
-- How many requests the player may have outstanding at once. 1 is the obvious
|
||||
-- loop and leaves the channel idle from every completion until the next poll;
|
||||
-- 2 keeps the next request queued so it never stops. Both are real designs and
|
||||
-- the difference between them is what this rig measures.
|
||||
local QDEPTH = tonumber(os.getenv("DLX_QDEPTH") or "") or 2
|
||||
-- One snapshot per frame tick instead of one at the end of the run. This is a
|
||||
-- DOCUMENTATION artefact -- 120 PNGs of a paced player, for a recording -- and
|
||||
-- it is deliberately not on any path tools/bench/check.sh takes. Needs
|
||||
@@ -140,6 +186,16 @@ local BPS = (KBPS > 0) and (KBPS - AUDIO_KBPS) * 1024 or math.huge
|
||||
local code do local f=io.open("stream.bin","rb"); code=f:read("a"); f:close() end
|
||||
local cb do local f=io.open("stream_cb.bin","rb"); cb=f:read("a"); f:close() end
|
||||
local DISK = assert(io.open("stream_disk.bin","rb"))
|
||||
local idxblob do
|
||||
local f = io.open("stream_idx.bin","rb")
|
||||
if f then idxblob = f:read("a"); f:close() end
|
||||
end
|
||||
if RINGOWN and not idxblob then
|
||||
print("[STR] DLX_RINGOWN needs the DLX4 record index (stream_idx.bin). "
|
||||
.."Re-run tools/bench/prep_stream.py on a DLX4 container: the machine "
|
||||
.."cannot derive record lengths by walking a stream it has not fetched.")
|
||||
manager.machine:exit(); return
|
||||
end
|
||||
|
||||
local YOFF = (MODE.height - META.H) // 2
|
||||
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
|
||||
@@ -264,6 +320,202 @@ local function produce(now)
|
||||
end
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------- transport
|
||||
-- THE OTHER HALF OF ROADMAP P5. With DLX_RINGOWN the producer above is not
|
||||
-- used at all: src/player/ring.i decides which record to fetch, where to put it
|
||||
-- and when it is safe, and this becomes the part that is genuinely not the
|
||||
-- CPU's -- an SPC and one DMAC channel moving bytes at a rate.
|
||||
--
|
||||
-- IT IS BUILT OUT OF MEMORY TAPS, not out of the machine-frame notifier, and
|
||||
-- that is a measurement decision rather than a stylistic one. A notifier sees
|
||||
-- the machine once per raster frame, 17.64 ms (54.5), and the thing being
|
||||
-- measured here is the gap between a transfer COMPLETING and the CPU issuing
|
||||
-- the next one -- which is a fraction of a frame slot. Sampling it at 17.64 ms
|
||||
-- would have quantised the very quantity in question, and in the flattering
|
||||
-- direction if the ack were early or the pessimistic one if late.
|
||||
--
|
||||
-- write tap on XF_GO fires inside the 68000's write, so the issue time is
|
||||
-- exact. The bytes are copied in there and then: the
|
||||
-- CPU cannot observe them before its ack anyway, because
|
||||
-- it does not advertise the record until it retires.
|
||||
-- read tap on XF_ACK synthesises the completion word from emulated time --
|
||||
-- `done` iff now >= t_done -- so the CPU learns of the
|
||||
-- completion on the exact cycle it happens, the way it
|
||||
-- would from a status register.
|
||||
-- A 68000 bus access is 16 bits, so a longword is two tap calls: the write is
|
||||
-- acted on at the LOW half (written second, so the whole value is there) and
|
||||
-- the read latches its answer at the HIGH half so the two halves cannot
|
||||
-- straddle a completion.
|
||||
local xf = {go = 0, ack = 0, busy = 0.0, gap = 0.0, gapmax = 0.0, ngap = 0,
|
||||
latch = 0, tfree = 0, copylate = 0.0, ncopylate = 0}
|
||||
-- q[n] = the n-th request (n counts from 1): when the CPU issued it, when the
|
||||
-- transport will have finished it, and whether its bytes have been moved yet.
|
||||
local q = {}
|
||||
local npass = 1
|
||||
local function xf_deliver(off, dst, len, now)
|
||||
DISK:seek("set", off)
|
||||
push(dst, DISK:read(len), 1, len)
|
||||
end
|
||||
|
||||
-- The host as AUDITOR rather than as producer. Every placement the machine
|
||||
-- makes is checked against this script's own record index and its own list of
|
||||
-- records the decoder has not finished with -- the same overlap test the host
|
||||
-- producer used to make its decisions with, now used only to grade them. A
|
||||
-- wrong placement corrupts pixels rather than faulting (49.2), so it has to be
|
||||
-- caught where it is made.
|
||||
local function audit(idx, off, dst, len)
|
||||
local rec = META.index[idx + 1]
|
||||
if not rec then return string.format("record %d does not exist", idx) end
|
||||
if off ~= rec.off then
|
||||
return string.format("record %d: machine asked for disc offset %d, the "
|
||||
.."index says %d", idx, off, rec.off) end
|
||||
if len ~= rec.len then
|
||||
return string.format("record %d: machine asked for %d B, the index says %d",
|
||||
idx, len, rec.len) end
|
||||
local w = dst - RING
|
||||
if w < 0 or w + len > RINGSZ then
|
||||
return string.format("record %d: placed at ring offset %d..%d, outside a "
|
||||
.."%d B ring", idx, w, w + len, RINGSZ) end
|
||||
local tail = SP:read_u32(FR_TAIL)
|
||||
local i = 1
|
||||
while i <= #live do
|
||||
if live[i].idx < tail then table.remove(live, i) else i = i + 1 end
|
||||
end
|
||||
for _, r in ipairs(live) do
|
||||
if w < r.off + r.len and r.off < w + len then
|
||||
return string.format("record %d at %d..%d overlaps record %d at %d..%d, "
|
||||
.."which the decoder has not consumed",
|
||||
idx, w, w + len, r.idx, r.off, r.off + r.len) end
|
||||
end
|
||||
live[#live+1] = {idx = idx, off = w, len = len}
|
||||
return nil
|
||||
end
|
||||
|
||||
-- THE TAP OBJECTS ARE KEPT ALIVE HERE ON PURPOSE. install_*_tap returns a
|
||||
-- handle and the tap dies with it: dropping it on the floor leaves the taps
|
||||
-- working until Lua's collector next runs, and then the mailbox silently stops
|
||||
-- answering. That looks exactly like a wedged producer -- the machine polled
|
||||
-- 2.4 million times for an ack that had already been computed.
|
||||
-- THE TAP OBJECTS ARE KEPT ALIVE HERE ON PURPOSE. install_*_tap returns a
|
||||
-- handle and the tap dies with it: dropping it on the floor leaves the taps
|
||||
-- working until Lua's collector next runs, and then the mailbox silently stops
|
||||
-- answering. That looks exactly like a wedged producer -- the machine polled
|
||||
-- 2.4 million times for an ack that had already been computed.
|
||||
local TAPS = {}
|
||||
|
||||
local function install_transport()
|
||||
-- NOTHING IN A TAP CALLBACK TOUCHES THE MEMORY SPACE. MAME 0.277 segfaults
|
||||
-- if a tap reads or writes the space it was triggered from -- a nested access
|
||||
-- part way through the CPU's own -- and it does it intermittently, which is
|
||||
-- the worst way to find out. So the taps do pure Lua: the write tap stamps
|
||||
-- the EXACT emulated time the 68000 issued a request, the read tap answers
|
||||
-- the completion count from those stamps, and every actual memory access is
|
||||
-- done from the machine-frame notifier.
|
||||
--
|
||||
-- What that buys is the thing worth having: issue and completion times are
|
||||
-- both exact, so the gap between a transfer finishing and the next one
|
||||
-- starting -- the disc standing still because nobody has asked it for
|
||||
-- anything -- is measured at cycle resolution rather than at 17.64 ms.
|
||||
TAPS[#TAPS+1] = SP:install_write_tap(XF_GO, XF_GO + 3, "dlx_xf_go",
|
||||
function(offset, data, mask)
|
||||
if offset ~= XF_GO + 2 then return data end -- low half, written second
|
||||
-- ...and it must be the INCREMENT, not any other write to the word.
|
||||
-- ring_init clears the mailbox at boot, which lands on this address with
|
||||
-- the request fields still empty; without this test the transport answered
|
||||
-- that clear as if it were a request and audited a 0-byte record 0.
|
||||
if data ~= ((xf.go + 1) & 0xFFFF) then return data end
|
||||
xf.go = xf.go + 1
|
||||
q[xf.go] = {tissue = T(), served = false}
|
||||
return data
|
||||
end)
|
||||
TAPS[#TAPS+1] = SP:install_read_tap(XF_ACK, XF_ACK + 3, "dlx_xf_ack",
|
||||
function(offset, data, mask)
|
||||
if offset == XF_ACK then
|
||||
-- Latched on the high half so the two halves of one longword read cannot
|
||||
-- straddle a completion. Requests complete IN ORDER: one channel, one
|
||||
-- transfer at a time, however many are queued.
|
||||
local now = T()
|
||||
while true do
|
||||
local r = q[xf.ack + 1]
|
||||
if not (r and r.served and now >= r.tdone) then break end
|
||||
xf.ack = xf.ack + 1
|
||||
end
|
||||
xf.latch = xf.ack
|
||||
return (xf.latch >> 16) & 0xFFFF
|
||||
end
|
||||
return xf.latch & 0xFFFF
|
||||
end)
|
||||
end
|
||||
|
||||
-- Service every request the machine has issued but this script has not yet
|
||||
-- looked at: read what was asked for, grade the placement, move the bytes, and
|
||||
-- work out when the channel will have finished it. Called from the
|
||||
-- machine-frame notifier, which is where this script may touch memory.
|
||||
local function transport_service(t)
|
||||
local n = xf.ack + 1
|
||||
while q[n] do
|
||||
local r = q[n]
|
||||
if r.served then n = n + 1; goto continue end
|
||||
local slot = XF_SLOT + ((n - 1) % 2) * XF_SLSZ
|
||||
local off, dst = SP:read_u32(slot), SP:read_u32(slot + 4)
|
||||
local len, idx = SP:read_u32(slot + 8), SP:read_u32(slot + 12)
|
||||
if idx == 0 and n > 1 then
|
||||
-- A new pass: everything the auditor knows about the ring is about the
|
||||
-- scene we just left. The decoder has not consumed the new records and
|
||||
-- the old ones are no longer anybody's.
|
||||
live, nsent, arrival = {}, 0, {}
|
||||
npass = npass + 1
|
||||
P(string.format("SEEK PASS %d: the machine seeked back to record 0 at "
|
||||
.."%.3f s and is refilling from empty", npass, r.tissue))
|
||||
end
|
||||
local err = audit(idx, off, dst, len)
|
||||
if err then
|
||||
P("MISPLACED: "..err)
|
||||
P(" The machine owns the ring in this run, so this is a policy bug in "
|
||||
.."src/player/ring.i, not a rig one -- and it would have shown up as "
|
||||
.."wrong pixels, because the block loop reads without a bounds check.")
|
||||
M:exit(); return
|
||||
end
|
||||
-- ONE CHANNEL: a transfer starts when the channel is free AND the request
|
||||
-- exists, so a queued request starts the instant its predecessor lands and
|
||||
-- a late one starts when it is issued. The difference is the gap, and the
|
||||
-- gap is the player's, not the medium's -- no host-filled run could see it,
|
||||
-- because the host producer placed records whenever it liked.
|
||||
local startt = math.max(r.tissue, xf.tfree)
|
||||
if xf.tfree > 0 then
|
||||
local g = startt - xf.tfree
|
||||
if g > 0 and nsent < META.nframes then
|
||||
xf.gap, xf.ngap = xf.gap + g, xf.ngap + 1
|
||||
if g > xf.gapmax then xf.gapmax = g end
|
||||
end
|
||||
end
|
||||
local dur = (BPS == math.huge) and 0 or (len / BPS)
|
||||
local done = startt + dur
|
||||
-- A cut is a seek: the transfer freezes for its duration rather than
|
||||
-- carrying on invisibly. Freezing is the conservative reading and the one
|
||||
-- 51.6 settled on for the host producer -- a drive that is repositioning is
|
||||
-- not banking bytes it will burst on arrival.
|
||||
if cut_t0 and done > cut_t0 and startt < cut_t1 then
|
||||
done = done + (cut_t1 - math.max(startt, cut_t0))
|
||||
end
|
||||
xf_deliver(off, dst, len, t)
|
||||
r.tdone, r.served = done, true
|
||||
xf.tfree = done
|
||||
xf.busy = xf.busy + dur
|
||||
nsent = math.max(nsent, idx + 1)
|
||||
arrival[idx + 1] = done -- resident when the last byte lands
|
||||
delivered = delivered + len
|
||||
-- The one place this rig is coarser than the machine: a record whose
|
||||
-- modelled transfer was shorter than the wait for this notifier was already
|
||||
-- "done" by the time the bytes could be moved. Counted, not absorbed.
|
||||
if t > done then
|
||||
xf.copylate, xf.ncopylate = xf.copylate + (t - done), xf.ncopylate + 1
|
||||
end
|
||||
n = n + 1
|
||||
::continue::
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ setup
|
||||
local function setup()
|
||||
MODE.apply(SP)
|
||||
@@ -280,10 +532,24 @@ local function setup()
|
||||
end
|
||||
for i = 1, #code do SP:write_u8(0x10000+i-1, string.byte(code,i)) end
|
||||
SP:write_u32(FLAG, 0); SP:write_u32(FR_HEAD, 0); SP:write_u32(FR_TAIL, 0)
|
||||
SP:write_u32(ITER, 1); SP:write_u32(NFR, META.nframes)
|
||||
SP:write_u32(ITER, ITERS); SP:write_u32(NFR, META.nframes)
|
||||
SP:write_u32(PACE, 0); SP:write_u32(PACEON, PACED and 1 or 0)
|
||||
SP:write_u32(CLKON, SELFCLK and 1 or 0)
|
||||
SP:write_u32(CLK_FPS, META.fps)
|
||||
SP:write_u32(A_RINGOWN, RINGOWN and 1 or 0)
|
||||
if RINGOWN then
|
||||
-- The scene header the machine reads: the DLX4 record index, exactly the
|
||||
-- bytes the container carries, pushed into RAM the way a loaded scene
|
||||
-- header would be. ring_init turns it into the disc-offset table a seek
|
||||
-- needs; nothing here derives a record boundary for the machine.
|
||||
push(IDXRAM, idxblob, 1, #idxblob)
|
||||
SP:write_u32(A_IDX_B, IDXRAM)
|
||||
SP:write_u32(A_RNG_B, RING)
|
||||
SP:write_u32(A_RNG_SZ, RINGSZ)
|
||||
SP:write_u32(A_PFREC, PREFILL_FR)
|
||||
SP:write_u32(XF_QD, QDEPTH)
|
||||
install_transport()
|
||||
end
|
||||
P(string.format("stream.bin=%d B, codebooks %d+%d B, disk %d B, %d frames",
|
||||
#code, META.cb1_len, META.cb4_len, META.disk_len, META.nframes))
|
||||
P(string.format("decoder %s%s", SELFCLK
|
||||
@@ -292,9 +558,15 @@ local function setup()
|
||||
or "FREE-RUNNING (tests wrap, not buffering -- 49.7.2)",
|
||||
CUT_AT and string.format(", pipe cut at tick %d for %.2f fr",
|
||||
CUT_AT, CUT_FR) or ""))
|
||||
P(string.format("ring %d KB at %06X, pipe %s, prefill %d KB, maxrec %d B",
|
||||
P(string.format("ring %d KB at %06X, pipe %s, prefill %s, maxrec %d B",
|
||||
RING_KB, RING, (KBPS > 0) and (KBPS.." KB/s") or "unlimited",
|
||||
PREFILL // 1024, META.maxrec))
|
||||
RINGOWN and (PREFILL_FR.." records (the machine's own)")
|
||||
or ((PREFILL // 1024).." KB"), META.maxrec))
|
||||
if RINGOWN then
|
||||
P(string.format("ring OWNED BY THE 68000 (src/player/ring.i): this script "
|
||||
.."is a transport with a %d-deep request queue, %d B of "
|
||||
.."DLX4 index at %06X", QDEPTH, #idxblob, IDXRAM))
|
||||
end
|
||||
if META.maxrec > RINGSZ then
|
||||
P("RING TOO SMALL: one record does not fit. stream.s needs a whole record "
|
||||
.."contiguous."); M:exit()
|
||||
@@ -322,13 +594,18 @@ local slack_series = {}
|
||||
SUB = emu.add_machine_frame_notifier(function()
|
||||
local ok, err = pcall(function()
|
||||
local t = T()
|
||||
if RINGOWN then transport_service(t) end
|
||||
if st == "boot" then
|
||||
if t < 3.0 then return end
|
||||
setup(); last_t, pf_t0 = t, t; st = "prefill"; return
|
||||
end
|
||||
if st == "prefill" then
|
||||
produce(t)
|
||||
if delivered >= PREFILL then
|
||||
if not RINGOWN then produce(t) end
|
||||
-- RINGOWN: there is nothing for the host to prefill. The machine issues
|
||||
-- its own requests, so it has to be running first; ring_prefill then
|
||||
-- holds the decoder until PF_REC records are resident, which is the same
|
||||
-- policy in the place a player would keep it.
|
||||
if RINGOWN or delivered >= PREFILL then
|
||||
P(string.format("prefill done: %.1f KB in %.3f s, releasing the CPU",
|
||||
delivered/1024, t - pf_t0))
|
||||
launch(); t_rel = t; st = "running"
|
||||
@@ -344,6 +621,14 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
-- which is the point: the same bytes are gated either way.
|
||||
local tick = SELFCLK and SP:read_u32(PACE)
|
||||
or math.floor((t - t_rel) * META.fps)
|
||||
if tick < pace then
|
||||
-- src/player/stream.s rebased the clock for a new pass: tick 0 is the
|
||||
-- instant the decoder was released after the seek's prefill, not the
|
||||
-- start of the run. Everything sampled per tick starts again with it.
|
||||
pace, tick_t = -1, {}
|
||||
min_ahead, min_at = math.huge, -1
|
||||
sum_ahead, n_ahead, slack_series = 0, 0, {}
|
||||
end
|
||||
if tick > pace then
|
||||
pace = tick
|
||||
if not SELFCLK then SP:write_u32(PACE, pace) end
|
||||
@@ -372,7 +657,12 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
if nsent < META.nframes then
|
||||
if ahead < min_ahead then min_ahead, min_at = ahead, tick end
|
||||
sum_ahead, n_ahead = sum_ahead + ahead, n_ahead + 1
|
||||
slack_series[#slack_series+1] = {tick, ahead, n_ring, n_rate}
|
||||
-- Column 3 is "has the producer been refused for space yet",
|
||||
-- which is what makes a later minimum meaningful: before the first
|
||||
-- refusal the ring is still filling. With the ring owned by the
|
||||
-- machine that counter is ring.i's, not this script's.
|
||||
slack_series[#slack_series+1] =
|
||||
{tick, ahead, RINGOWN and SP:read_u32(A_NFULL) or n_ring, n_rate}
|
||||
end
|
||||
if CUT_AT and tick >= CUT_AT and not cut_t0 then
|
||||
cut_t0, cut_t1 = t, t + CUT_FR / META.fps
|
||||
@@ -382,12 +672,38 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
end
|
||||
end
|
||||
end
|
||||
produce(t)
|
||||
if not RINGOWN then produce(t) end
|
||||
if RINGOWN and os.getenv("DLX_RINGDBG") == "1" then
|
||||
dbg_n = (dbg_n or 0) + 1
|
||||
if dbg_n % 30 == 0 then
|
||||
P(string.format("DBG t=%.2f flag=%X go=%d ack=%d tfree=%.3f queued=%s "
|
||||
.."head=%d tail=%d rq=%d poll=%d full=%d wcur=%d rcur=%d",
|
||||
t, SP:read_u32(FLAG), xf.go, xf.ack, xf.tfree,
|
||||
tostring(q[xf.ack+1] ~= nil), SP:read_u32(FR_HEAD),
|
||||
SP:read_u32(FR_TAIL), SP:read_u32(A_RQ_NEXT),
|
||||
SP:read_u32(A_NPOLL), SP:read_u32(A_NFULL),
|
||||
SP:read_u32(A_WCUR), SP:read_u32(A_RCUR)))
|
||||
end
|
||||
end
|
||||
if cut_t0 and not cut_done and t >= cut_t1 then cut_done = true end
|
||||
local fl = SP:read_u32(FLAG)
|
||||
if fl == 1 and not t0 then t0 = t; return end
|
||||
if fl == 0xEE then
|
||||
P("BITSTREAM DESYNC -- the decoder consumed the wrong number of bytes.")
|
||||
if RINGOWN then
|
||||
local tail, head = SP:read_u32(FR_TAIL), SP:read_u32(FR_HEAD)
|
||||
P(string.format(" tail=%d head=%d rq=%d wcur=%d rcur=%d go=%d ack=%d",
|
||||
tail, head, SP:read_u32(A_RQ_NEXT), SP:read_u32(A_WCUR),
|
||||
SP:read_u32(A_RCUR), xf.go, xf.ack))
|
||||
P(string.format(" DESC[%d]=%08X, the index says record %d is at ring "
|
||||
.."offset ? len %d", tail,
|
||||
SP:read_u32(DESC + (tail % 64)*4), tail,
|
||||
META.index[tail+1] and META.index[tail+1].len or -1))
|
||||
local ls = {}
|
||||
for _, r in ipairs(live) do
|
||||
ls[#ls+1] = string.format("%d@%d+%d", r.idx, r.off, r.len) end
|
||||
P(" host thinks live: "..table.concat(ls, " "))
|
||||
end
|
||||
P(" Under a ring that is the whole point: it means a record was placed "
|
||||
.."or described wrongly, not that the codec changed.")
|
||||
M:exit(); return
|
||||
@@ -400,6 +716,11 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
" (fps*VTOTAL does not fit the 16-bit accumulator)" or ""))
|
||||
M:exit(); return
|
||||
end
|
||||
if fl == 0xE3 then
|
||||
P("RING INIT REFUSED: the record index has more entries than "
|
||||
.."src/player/ring.i's disc-offset table (ROFFMAX) can hold.")
|
||||
M:exit(); return
|
||||
end
|
||||
if fl == 0xE1 then
|
||||
P("PRODUCER STALLED OUT -- stream.s spun SPINMAX times with no new "
|
||||
.."record. Delivered "..nsent.."/"..META.nframes..".")
|
||||
@@ -443,10 +764,62 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
vd, pace + 1, vd/(pace + 1), META.fps, vt,
|
||||
31500/(META.fps*vt), 181.35*vd/(pace+1)))
|
||||
end
|
||||
local rholes, rhole_b = holes, hole_bytes
|
||||
if RINGOWN then
|
||||
rholes, rhole_b = SP:read_u32(A_NHOLE), SP:read_u32(A_NHOLEB)
|
||||
end
|
||||
P(string.format("ring: %d wraps, %d B of hole (mean %.1f KB, %.1f%% of "
|
||||
.."the ring)", holes, hole_bytes,
|
||||
holes > 0 and hole_bytes/holes/1024 or 0,
|
||||
100*(holes > 0 and hole_bytes/holes or 0)/RINGSZ))
|
||||
.."the ring)", rholes, rhole_b,
|
||||
rholes > 0 and rhole_b/rholes/1024 or 0,
|
||||
100*(rholes > 0 and rhole_b/rholes or 0)/RINGSZ))
|
||||
if RINGOWN then
|
||||
-- The producer's own account of itself. These are read out of the
|
||||
-- machine's RAM, not kept here: the point of the run is that this
|
||||
-- script did not make any of these decisions.
|
||||
local pf = SP:read_u32(A_PFDONE)
|
||||
local npoll, nissue = SP:read_u32(A_NPOLL), SP:read_u32(A_NISSUE)
|
||||
local nfull = SP:read_u32(A_NFULL)
|
||||
local slk, slkat = SP:read_u32(A_SLKMIN), SP:read_u32(A_SLKAT)
|
||||
local nseek, skw = SP:read_u32(A_NSEEK), SP:read_u32(A_SKWAIT)
|
||||
P(string.format("MACHINE-OWNED RING: %d records placed by the 68000, "
|
||||
.."%d polls, %d refused for space (ring-bound), "
|
||||
.."%d seeks", nissue, npoll, nfull, nseek))
|
||||
P(string.format("PREFILL: released the decoder at %d records "
|
||||
.."(policy: %d); LEAST SLACK %d records at frame %d",
|
||||
pf, PREFILL_FR, slk, slkat))
|
||||
-- THE NUMBER THIS RIG EXISTS TO PRODUCE. The channel only moves
|
||||
-- bytes while a request is outstanding and only the CPU can issue the
|
||||
-- next one, so the disc stands still between the completion of one
|
||||
-- record and the issue of the next. That gap is a property of the
|
||||
-- PLAYER's loop, not of the medium, and no host-filled run could see
|
||||
-- it -- the host producer placed records whenever it liked.
|
||||
local span = (arrival[nissue] or t) - (t0 or t)
|
||||
P(string.format("CHANNEL IDLE: %.1f ms over %d gaps (worst %.1f ms) = "
|
||||
.."%.1f%% of the %.2f s the transport was needed for; "
|
||||
.."busy %.1f ms",
|
||||
xf.gap*1000, xf.ngap, xf.gapmax*1000,
|
||||
span > 0 and 100*xf.gap/span or 0, span,
|
||||
xf.busy*1000))
|
||||
-- THE RIG'S OWN RESOLUTION, PRINTED RATHER THAN LEFT IMPLICIT.
|
||||
-- The transport's timing is exact (memory taps), but the BYTES are
|
||||
-- moved from the machine-frame notifier, so a record whose modelled
|
||||
-- transfer is shorter than the wait for that notifier is acked later
|
||||
-- than the model says it landed. Every millisecond here is a
|
||||
-- millisecond the decoder may have waited that the medium would not
|
||||
-- have made it wait.
|
||||
if xf.ncopylate > 0 then
|
||||
P(string.format("RIG RESOLUTION: %d of %d transfers were acked late "
|
||||
.."because the bytes are moved on the notifier, by "
|
||||
.."%.1f ms in total (mean %.1f ms) -- an artefact of "
|
||||
.."this rig, not of the player",
|
||||
xf.ncopylate, nissue, xf.copylate*1000,
|
||||
xf.copylate*1000/xf.ncopylate))
|
||||
end
|
||||
if skw > 0 then
|
||||
P(string.format(" the last seek waited %d polls for the channel "
|
||||
.."to go quiet before it could start", skw))
|
||||
end
|
||||
end
|
||||
-- The decoder's own spin counter, kept for what it is: evidence that
|
||||
-- stream.s outran the pipe, NOT evidence of an underrun. See the note
|
||||
-- on `arrival` above.
|
||||
@@ -490,15 +863,22 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
for _,e in ipairs(slack_series) do
|
||||
if e[3] > 0 and e[2] < ss_min then ss_min, ss_at = e[2], e[1] end
|
||||
end
|
||||
if ss_min == math.huge then ss_min, ss_at = -1, -1 end
|
||||
P(string.format("SEEK SLACK: ceiling %d frames (%.0f ms), first "
|
||||
.."reached at tick %d; mean %.1f over the window",
|
||||
ceiling, 1000*ceiling/META.fps, ceil_at,
|
||||
sum_ahead/math.max(1,n_ahead)))
|
||||
if n_ring > 0 then
|
||||
-- WHOSE REFUSAL COUNT. With the ring owned by the machine the host
|
||||
-- producer's counters are not merely stale, they are zero -- it never
|
||||
-- placed anything -- so the classification has to come from
|
||||
-- src/player/ring.i's own N_FULL. Reading n_ring here in a RINGOWN
|
||||
-- run would have reported RATE-BOUND on every run by construction.
|
||||
local ring_ref = RINGOWN and SP:read_u32(A_NFULL) or n_ring
|
||||
if ring_ref > 0 then
|
||||
P(string.format(" RING-BOUND: the ring filled (%d refusals). Once "
|
||||
.."full it survives delivery stopped dead for %d "
|
||||
.."frames = %.0f ms; min after first fill %d "
|
||||
.."(tick %d)", n_ring, ceiling,
|
||||
.."(tick %d)", ring_ref, ceiling,
|
||||
1000*ceiling/META.fps, ss_min, ss_at))
|
||||
else
|
||||
P(string.format(" RATE-BOUND: the ring NEVER filled in %d frames. "
|
||||
@@ -530,6 +910,16 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
for i = 0, META.nframes-1 do
|
||||
local a = arrival[i+1]
|
||||
if a then
|
||||
-- INSTRUMENT NOTE, and it only bites under the self-clock.
|
||||
-- tick_t[] is when this script OBSERVED the machine's tick, which
|
||||
-- is the next machine-frame boundary after it -- up to 17.64 ms
|
||||
-- late (54.5). So these deadlines are generous by that much and
|
||||
-- this count UNDERSTATES lateness. The sharp instrument for the
|
||||
-- same question is the decoder's own stall counter, which is exact
|
||||
-- because the machine takes it: at 488 KB/s with a one-deep request
|
||||
-- queue this reported 6 records late where the 68000 counted 59
|
||||
-- frames that had to wait. Host-paced (DLX_PACE=1) the deadline is
|
||||
-- a host model and is exact, so FINDINGS 49.6's table is unaffected.
|
||||
local due = SELFCLK and tick_t[i+1] or (t_rel + i / META.fps)
|
||||
if not due then due = t_rel + i / META.fps end
|
||||
local late = a - due
|
||||
@@ -550,14 +940,28 @@ SUB = emu.add_machine_frame_notifier(function()
|
||||
.."at %s", prefill_s*1000, prefill_s*META.fps,
|
||||
(KBPS > 0) and (prefill_s * BPS / 1024) or 0.0,
|
||||
(KBPS > 0) and (KBPS.." KB/s") or "an unlimited pipe"))
|
||||
-- ONE FRAME OF SETTLE BEFORE THE CAPTURE, and it is not cosmetic.
|
||||
-- MAME renders a screen scanline by scanline and the machine-frame
|
||||
-- notifier fires at the END of that frame, so a bitmap for a frame in
|
||||
-- which GVRAM changed holds some lines drawn before the change and some
|
||||
-- after. Snapshotting it captures a TEAR -- and the symptom is a
|
||||
-- pixel-exactness failure in the bottom blocks plus a broken double-scan
|
||||
-- pairing, which reads exactly like a decoder bug and is not one. It
|
||||
-- only bites when the decoder finishes its last frame LATE in a screen
|
||||
-- frame, which is why it never showed up until a run with underruns in
|
||||
-- it. Waiting one whole frame renders the finished picture with nothing
|
||||
-- writing to GVRAM.
|
||||
st = "settle"; return
|
||||
end
|
||||
if t > 900 then P("TIMEOUT flag="..string.format("%08X",fl)); M:exit() end
|
||||
return
|
||||
end
|
||||
if st == "settle" then
|
||||
M.video:snapshot()
|
||||
P("snapshot taken after the sequential pass -- last frame, decoded "
|
||||
.."entirely out of a "..RING_KB.." KB ring")
|
||||
st = "snapped"; return
|
||||
end
|
||||
if t > 900 then P("TIMEOUT flag="..string.format("%08X",fl)); M:exit() end
|
||||
return
|
||||
end
|
||||
if st == "snapped" then M:exit(); return end
|
||||
end)
|
||||
if not ok then print("[STR] LUA ERROR: "..tostring(err)); M:exit() end
|
||||
|
||||
+30
-1
@@ -38,14 +38,16 @@ class DLX:
|
||||
# (FINDINGS 28.3), so the padding is part of the format, not a loader
|
||||
# convenience -- but DLX1 containers stay readable, because every
|
||||
# measurement in FINDINGS 28-31 was taken on one.
|
||||
if b[:4] not in (b"DLX1", b"DLX2", b"DLX3"):
|
||||
if b[:4] not in (b"DLX1", b"DLX2", b"DLX3", b"DLX4"):
|
||||
raise ValueError(f"{path}: not a DLX container")
|
||||
self.version = int(b[3:4])
|
||||
self.aligned = self.version >= 2
|
||||
self.has_spans = self.version >= 3
|
||||
self.has_index = self.version >= 4
|
||||
(self.W, self.H, self.fps, self.nframes,
|
||||
self.k1, self.k4) = struct.unpack(">HHHHHH", b[4:16])
|
||||
off_pal, off_cb1, off_cb4, off_frm = struct.unpack(">IIII", b[16:32])
|
||||
off_idx = struct.unpack(">I", b[32:36])[0] if self.has_index else None
|
||||
|
||||
self.pal = np.frombuffer(b, np.uint8, 256 * 3, off_pal).reshape(256, 3)
|
||||
self.cb1 = np.frombuffer(b, np.uint8, self.k1 * 16,
|
||||
@@ -77,6 +79,33 @@ class DLX:
|
||||
raise ValueError(f"{path}: {slack} trailing bytes after "
|
||||
f"{self.nframes} frames")
|
||||
|
||||
# DLX4's record index, and it is CHECKED rather than trusted. The
|
||||
# walk above is what every reader in this tree did before there was an
|
||||
# index -- read a record's length word to find the next one -- and it is
|
||||
# exactly what a player streaming off a disc cannot do, because the
|
||||
# length word of record i+1 is one of the bytes it has not fetched. So
|
||||
# the two are computed independently here and required to agree: the
|
||||
# index is the producer's only source of record geometry, and an index
|
||||
# that disagrees with the stream places records at wrong addresses,
|
||||
# which the block loop reads without a bounds check (49.2).
|
||||
self.index = None
|
||||
if self.has_index:
|
||||
self.index = list(struct.unpack(
|
||||
f">{self.nframes}H", b[off_idx:off_idx + 2 * self.nframes]))
|
||||
walked = [(-(4 + n) % 4 + 4 + n) // 4 for _, n in self.frames]
|
||||
if self.index != walked:
|
||||
bad = next(i for i in range(self.nframes)
|
||||
if self.index[i] != walked[i])
|
||||
raise ValueError(
|
||||
f"{path}: record index disagrees with the frame stream at "
|
||||
f"frame {bad}: index says {self.index[bad]} longwords, the "
|
||||
f"stream is {walked[bad]}")
|
||||
if off_frm + 4 * sum(self.index) != len(b):
|
||||
raise ValueError(
|
||||
f"{path}: the index accounts for "
|
||||
f"{off_frm + 4 * sum(self.index)} bytes and the file is "
|
||||
f"{len(b)} -- a producer trusting it would run off the end")
|
||||
|
||||
def modes(self, f):
|
||||
o, _ = self.frames[f]
|
||||
h = np.frombuffer(self.raw, np.uint8, self.mode_bytes, o)
|
||||
|
||||
+47
-7
@@ -15,7 +15,7 @@ lets quiet frames spend the whole allowance and lands the mean ON target.
|
||||
Container (little-endian is WRONG here -- the 68000 is big-endian, so every
|
||||
multi-byte field is big-endian and the decoder can read it with a plain move.w):
|
||||
|
||||
header, 32 bytes
|
||||
header, 32 bytes ('DLX4': 36 -- one more offset, see below)
|
||||
0 'DLX2' magic ('DLX1' = the same, unaligned; still read)
|
||||
4 u16 width, u16 height
|
||||
8 u16 fps, u16 nframes
|
||||
@@ -25,6 +25,23 @@ multi-byte field is big-endian and the decoder can read it with a plain move.w):
|
||||
20 u32 cb1 offset (k1 * 16 bytes of palette indices)
|
||||
24 u32 cb4 offset (k4 * 4 bytes)
|
||||
28 u32 frames offset
|
||||
32 u32 record index offset DLX4 ONLY. nframes * u16, each the length of
|
||||
that frame's PADDED record in LONGWORDS --
|
||||
i.e. (4 + payload + pad) / 4, the whole thing
|
||||
the ring producer must place contiguously.
|
||||
THE INDEX IS NOT A CONVENIENCE, AND IT IS NOT DERIVABLE ON THE MACHINE. The
|
||||
`aligned` wrap policy (FINDINGS 49.3) requires the producer to know how long
|
||||
the next record is BEFORE it fetches it, because that is what decides whether
|
||||
it fits before the end of the ring or leaves a hole and restarts at the base.
|
||||
Every reader in this tree up to DLX3 learned record boundaries by WALKING the
|
||||
frame stream -- reading each record's length word to find the next -- which a
|
||||
host with the whole file mapped can do and a player streaming off a disc
|
||||
cannot: the length word of record i+1 is exactly one of the bytes it has not
|
||||
fetched yet. A branching game needs the same structure a second time, to seek
|
||||
to a branch point without reading what lies between.
|
||||
Lengths are stored rather than offsets: 2 bytes a frame instead of 4, and the
|
||||
offsets are a running sum the player builds once at scene load (u16 caps a
|
||||
record at 262,140 B, asserted at write time).
|
||||
then, per frame, each record starting on a 4-BYTE BOUNDARY (0-3 zero pad
|
||||
bytes before it; a 68000 takes an address error, not a slow read, on an odd
|
||||
`move.l` -- FINDINGS 28.3):
|
||||
@@ -126,10 +143,26 @@ def write_container(path, m, frames, fps, k1, k4, span_mode):
|
||||
cb1_b = m["cb1"].astype(np.uint8).tobytes()
|
||||
cb4_b = m["cb4"].astype(np.uint8).tobytes()
|
||||
|
||||
off_pal = 32
|
||||
off_pal = 36 if span_mode else 32
|
||||
off_cb1 = off_pal + len(pal_b)
|
||||
off_cb4 = off_cb1 + len(cb1_b)
|
||||
off_frm = off_cb4 + len(cb4_b)
|
||||
# DLX4: the record index sits with the palette and the codebooks, ahead of
|
||||
# the frame stream, because it is part of what has to ARRIVE before frame 0
|
||||
# can be decoded -- FINDINGS 53.5's scene header, and this adds to it.
|
||||
idx_b = b""
|
||||
if span_mode:
|
||||
qlens = []
|
||||
for i, rec in enumerate(frames):
|
||||
n = 4 + len(rec)
|
||||
n += -n % 4
|
||||
q = n // 4
|
||||
assert q <= 0xFFFF, (f"frame {i} is {n} B: a u16 longword count "
|
||||
f"caps a record at 262,140 B")
|
||||
qlens.append(q)
|
||||
idx_b = struct.pack(f">{len(qlens)}H", *qlens)
|
||||
|
||||
off_idx = off_cb4 + len(cb4_b)
|
||||
off_frm = off_idx + len(idx_b)
|
||||
# DLX2: every frame record starts on a 4-byte boundary, including the
|
||||
# first. Payload lengths are arbitrary, so end-to-end records land on odd
|
||||
# addresses -- and `move.l (a0)+` at an odd address is an ADDRESS ERROR on
|
||||
@@ -138,18 +171,25 @@ def write_container(path, m, frames, fps, k1, k4, span_mode):
|
||||
# realigning at load time; the container now carries it.
|
||||
tbl_pad = -off_frm % 4
|
||||
off_frm += tbl_pad
|
||||
hdr = ((b"DLX3" if span_mode else b"DLX2")
|
||||
hdr = ((b"DLX4" if span_mode else b"DLX2")
|
||||
+ struct.pack(">HHHHHH", m["W"], m["H"], fps, len(frames), k1, k4)
|
||||
+ struct.pack(">IIII", off_pal, off_cb1, off_cb4, off_frm))
|
||||
assert len(hdr) == 32, len(hdr)
|
||||
if span_mode:
|
||||
hdr += struct.pack(">I", off_idx)
|
||||
assert len(hdr) == (36 if span_mode else 32), len(hdr)
|
||||
|
||||
frm_pad = 0
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(hdr); fh.write(pal_b); fh.write(cb1_b); fh.write(cb4_b)
|
||||
fh.write(b"\0" * tbl_pad)
|
||||
fh.write(idx_b); fh.write(b"\0" * tbl_pad)
|
||||
for i, rec in enumerate(frames):
|
||||
fh.write(struct.pack(">I", len(rec))); fh.write(rec)
|
||||
if i + 1 < len(frames): # nothing follows the last record
|
||||
# DLX2/3 skipped the pad after the LAST record because nothing
|
||||
# followed it. DLX4's index describes PADDED records, and a producer
|
||||
# that trusts the index fetches that many bytes -- so the last
|
||||
# record is padded too, and the file ends where the index says it
|
||||
# does rather than up to 3 bytes short of it.
|
||||
if span_mode or i + 1 < len(frames):
|
||||
n = -(4 + len(rec)) % 4
|
||||
fh.write(b"\0" * n); frm_pad += n
|
||||
total = os.path.getsize(path)
|
||||
|
||||
Reference in New Issue
Block a user