mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-11-01 12:14:56 +02:00
spec: Bad Apple at 60 fps exact — raw tagged tiles, no dictionary
This commit is contained in:
1 parent
d6aa6f46c1
commit
650ec9a0cd
1 file changed
+514
@@ -0,0 +1,514 @@
|
||||
# Bad Apple at 60 fps, exact — design
|
||||
|
||||
Date: 2026-10-31
|
||||
Status: **rev 1** — first draft, awaiting approval.
|
||||
Author: (session). Decisions marked "**call:**" are the builder's proposals, not
|
||||
something the human partner has agreed to. Decisions marked "**human:**" were
|
||||
given in this session and are not open.
|
||||
|
||||
Supersedes the tile-codec half of
|
||||
[`2026-10-30-vt1200-video-streaming-design.md`](2026-10-30-vt1200-video-streaming-design.md)
|
||||
(§8.3 scheme C, §8.9 OS interference, §10 M3–M5). The transport there (§8.1), the
|
||||
back-channel (§8.4) and the delivery path (`harness/badapple/`) stand.
|
||||
|
||||
---
|
||||
|
||||
## 1. Intent
|
||||
|
||||
Play **Bad Apple!!** on a VT1200 from
|
||||
`/tmp/opencode/badapple-real/hd60.mp4` at the source's **60 fps**, upscaled to
|
||||
fill the panel, with the picture **no longer quantised through a 256-entry tile
|
||||
dictionary** — because that dictionary, not the resolution, is what makes the
|
||||
diagonals jagged.
|
||||
|
||||
The ROM stays byte-identical (`md5 b46a3e442ccb86134d2356bd70d22fd2`). The OS is
|
||||
allowed to die (§8.9 of this document).
|
||||
|
||||
## 2. Grounding: the source
|
||||
|
||||
`ffprobe /tmp/opencode/badapple-real/hd60.mp4` (human-supplied, this session):
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| video | 960x720, SAR 1:1, DAR 4:3, **60 fps**, progressive, yuv420p, h264 Main |
|
||||
| duration | 00:03:39.52 → **≈13,171 frames** at 60 fps |
|
||||
| audio | aac LC stereo (ignored — no audio path in this harness) |
|
||||
|
||||
960×720 is 4:3 exactly, and 1280×960 is 4:3 exactly, so an upscale to
|
||||
1280×960 is a clean 1.3333× with no crop and no aspect error. 960 is also a
|
||||
multiple of 32, so `clip_from_video.py`'s default `--height 960` is already
|
||||
legal (its `height % 32` check at line 98 rejects 720, not 960).
|
||||
|
||||
**human:** deliver it upscaled to fill the panel rather than 1:1 with borders.
|
||||
|
||||
**call:** the target geometry is **1280×960 at the top of the screen**, rows
|
||||
960–1023 a permanently black bar. Stretching 960 rows to 1024 (a further 6.7 %
|
||||
in Y) is a one-flag alternative if the bars look wrong on the unit; it changes
|
||||
no other number in this document except the bar tiles.
|
||||
|
||||
## 3. Grounding: what exists today
|
||||
|
||||
All paths below are in this repository.
|
||||
|
||||
| piece | where | what it does |
|
||||
|---|---|---|
|
||||
| payload | `harness/udpstream/vidstream.c` | takes the LANCE over, arms a 32-descriptor RX ring in two 48 KB slots, parses VS frames, dispatches to `paint` / `paint_tiles` / `paint_delta` / `dict_chunk`, sends a status frame every 64 accepted frames |
|
||||
| tile primitive | `harness/udpstream/vram_tile.asm` | one `PIXBLT B,XY` of a 32×32 dictionary entry; ~35 instructions of prologue per call |
|
||||
| strip primitive | `harness/udpstream/vram_b.asm` | one `PIXBLT B,XY` of a row with `SPTCH=0` vertical stretch |
|
||||
| fill primitive | `harness/udpstream/vram.asm` | `FILL L` of the whole screen (fixed geometry) |
|
||||
| wire format | `harness/udpstream/frame.py` + `frame_parse.h` | 16-byte `VS` header, header-only CRC-16, kinds 1–5 |
|
||||
| tile wire form | `harness/udpstream/tiledict.py` | `TILES_FULL` = 1,280 index bytes; `TILES_DELTA` = `u16 count` + `count×u16 pos` + `count×u8 index`; `KEYFRAME_EVERY = 60` |
|
||||
| dictionary build | `harness/udpstream/tilecost.py` | Lloyd/k-means over 32×32 tiles, Hamming distance, 256 entries × 128 B |
|
||||
| encoder | `harness/udpstream/make_tiles.py` | clip → dictionary + indices + stream plan + manifest |
|
||||
| sender | `harness/udpstream/vidsend.py` | paces at `--fps`, reads status frames, forces a keyframe when `drops` rises |
|
||||
| checker | `harness/mame/tile_check.py` | decodes `stream.bin` independently, renders it, compares against dumped VRAM |
|
||||
| demo | `harness/badapple/demo.sh` | waits for the OS's stack, loads the payload over rdebug, sends the clip |
|
||||
|
||||
Measured behaviour of today's codec, all from `harness/mame/NETWORK_REPORT.md`:
|
||||
|
||||
| quantity | value | where |
|
||||
|---|---|---|
|
||||
| synthetic clip, 30 frames | PSNR **29.47 dB**, 1.16 bits/tile mean error, 97.0 % of tiles exact | §76.4 |
|
||||
| **real clip** (3,292 frames, 15 fps, 1280×960) | **PSNR 24.4 dB**, mean delta **496 B**, 55 keyframes, 1.63 MB stream | `harness/badapple/README.md` |
|
||||
| one full tile repaint in MAME | `decode_worst` **37,612 REFCNT ticks** (1,280 tiles) | §76.5.1 |
|
||||
|
||||
## 4. The diagnosis
|
||||
|
||||
**The jaggies are the dictionary, and they can be counted.** 24.4 dB is a
|
||||
bit-error rate of 0.0036, i.e. **≈4,760 wrong pixels per frame** out of
|
||||
1,310,720. A 32×32 tile has on the order of 2³² distinct edge placements and the
|
||||
dictionary has 256 centroids, so the errors concentrate on edges — which is
|
||||
exactly what a viewer sees as ragged diagonals. At 1280 wide there is no
|
||||
resolution left to buy; the fragment is the *edge vocabulary*.
|
||||
|
||||
Three other causes look identical on screen and must be ruled out before the
|
||||
codec is blamed. All are cheap:
|
||||
|
||||
1. `clip_from_video.py` line 49/64: `scale=...:flags=lanczos` then `gray >
|
||||
threshold`. Hard-thresholding a downscaled frame makes its own staircase.
|
||||
Dump one `.bin` as an image and look.
|
||||
2. `VT1200_MONITOR=VR150` shears the picture (`harness/mame/README.md`).
|
||||
3. A source frame whose edges are already stepped (e.g. a 4:3 crop of a
|
||||
differently-scaled master).
|
||||
|
||||
**And the speed is not the constraint.** At the real clip's rate of change a
|
||||
delta is ~165 tiles at 15 fps, so ~41 tiles per frame at 60 fps (motion per
|
||||
frame divides by four). Against the budgets in §6 that is roughly **1/20th of
|
||||
one frame**. There is no speed problem to solve in the delta path; there is
|
||||
spare budget to spend on exactness.
|
||||
|
||||
## 5. What the hardware actually charges for
|
||||
|
||||
The single most important number in this document, measured on the unit and
|
||||
recorded in the 2026-10-30 design (§2, §6):
|
||||
|
||||
> `FILL L` costs **0.40 µs per destination word**; `PIXBLT L,XY` costs
|
||||
> **0.62 µs/word**; each blit/fill *call* costs **2.6–3.3 µs** on top.
|
||||
|
||||
A destination word is 16 pixels at 1 bpp. So **the pixel engine's cost is
|
||||
proportional to the number of destination words written, and essentially
|
||||
independent of which primitive writes them and of how the writes are grouped.**
|
||||
Consequences, all of which this design depends on:
|
||||
|
||||
* A 32×32 tile is 32 rows × 2 words = **64 destination words**.
|
||||
* A 48×48 tile is 48 × 3 = 144 words; a 64×64 is 256. Per *pixel* there is no
|
||||
gain — the extra call overhead saved (0.8 ms vs 3.1 ms over a full screen) is
|
||||
under 10 % of a repaint. **Tile size is a wire decision, not a CPU decision.**
|
||||
* `FILL` and `PIXBLT` cost the same per word, so collapsing a run of solid tiles
|
||||
into one `FILL` saves **call overhead and wire bytes only**, not blitter time.
|
||||
* The whole 1280×960 picture is 960 × 80 = **76,800 destination words**.
|
||||
At 0.40 µs/word that is **≈31 ms** — *more than one 60 fps frame*, whatever
|
||||
scheme drives it.
|
||||
|
||||
Note the contradiction this exposes: MAME reports a full tile repaint at
|
||||
~12 ms (37,612 REFCNT ticks, §3) while the unit's `FILL` rate says the same
|
||||
pixels cost ~31 ms. `PIXBLT B,XY` at "0.32 µs/word" is a **MAME benchmark and
|
||||
has never been measured on the unit** (the 2026-10-30 doc says so in its own
|
||||
risk table). Every per-frame CPU number in this document is therefore labelled
|
||||
either **unit** or **MAME**, and the first milestone measures the gap.
|
||||
|
||||
## 6. Budget
|
||||
|
||||
| quantity | value | source |
|
||||
|---|---|---|
|
||||
| frame period at 60 fps | **16.67 ms** | derived |
|
||||
| display refresh | 72 Hz (13.9 ms) | 2026-10-30 §2 |
|
||||
| destination words affordable per frame | **41,700** at 0.40 µs/word | derived from the unit rate |
|
||||
| full picture (1280×960) | 76,800 words ≈ **31 ms** = **1.84 frames** | derived, unit rate |
|
||||
| link | 10 Mbit half-duplex, **1.27 MB/s** measured | 2026-10-30 §2 |
|
||||
| wire per frame at 60 fps | **21 KB** | derived |
|
||||
| RX ring | 32 descriptors × 1526 B = 48,832 B (one slot) | `vidstream.c` |
|
||||
| `payload_len` | u16 → a frame's payload is ≤ 65,535 B | `frame_parse.h` |
|
||||
| heap | ~2.7 MB free at takeover | status frames |
|
||||
|
||||
Two hard limits follow: **a frame's payload cannot exceed 65,535 B**
|
||||
(`payload_len` is 16 bits) and **cannot exceed 32 datagrams** (~45.9 KB of
|
||||
payload) without deepening the RX ring. The binding one is the ring.
|
||||
|
||||
**The central finding: at 60 fps a full-screen repaint fits neither budget.**
|
||||
It costs 1.84 frames of CPU and ~7 frames of wire (154 KB at 1.27 MB/s =
|
||||
121 ms). Everything in the design below exists to make sure the full screen is
|
||||
never repainted in one frame, and that ordinary frames are exact.
|
||||
|
||||
## 7. Approaches considered
|
||||
|
||||
**A. Keep the dictionary; widen the index to 10–12 bits.** Gets PSNR up by
|
||||
increasing entries (256 → 1,024+). Wire: 2 B/tile. Cost: a 92-datagram upload
|
||||
(the current 32-descriptor ring holds 23), plus the dictionary still cannot
|
||||
represent every edge. **Rejected as the primary path**: it improves the same
|
||||
approximation that causes the complaint, and it keeps the k-means encoder and
|
||||
the 32 KB upload.
|
||||
|
||||
**B. Keep the dictionary; add an 8×8 residual patch pass.** Exactness with
|
||||
bounded cost (patches only where error > 0). **Not rejected, deferred**: it is
|
||||
the best option if raw bitmaps turn out too expensive on the wire, and it reuses
|
||||
everything. Kept as the fallback in §12.
|
||||
|
||||
**C. Raw tiles — send the changed tiles' actual bits, with a solid tag.**
|
||||
No dictionary at all. Exact by construction. Wire: 1 bit per changed pixel plus
|
||||
a 1-byte tag per changed tile; a solid tile costs 1 byte instead of 128.
|
||||
**Chosen.**
|
||||
|
||||
**D. Spans / vectors (`FILL XY` per scanline run).** Exact and the same
|
||||
per-word cost as everything else, so its only advantage is that it *writes fewer
|
||||
words* on flat content and its only cost is that it turns dither into runs of
|
||||
length 1. Bad Apple is full of dithered texture (grass, hair, checkerboard), so
|
||||
a span encoding explodes exactly where tiles are cheap. **Rejected for this
|
||||
source**; it stays the right answer for a flat-shaded stylisation, which is a
|
||||
different film.
|
||||
|
||||
**Decision (this document): C.** It is also the *simplest* option — it deletes
|
||||
more code than it adds (§8.6).
|
||||
|
||||
## 8. Design
|
||||
|
||||
### 8.1 Geometry
|
||||
|
||||
1280×960 at the top of the panel: **40 × 30 tiles of 32×32** = 1,200 picture
|
||||
tiles, plus 64 permanently black bar tiles (grid rows 30–31) that are painted
|
||||
once and never listed again. The grid, `TILES_SCREEN`, the row-major position
|
||||
encoding, `tile_check.py`'s renderer and `vram_tile.asm`'s `DYDX=(32,32)` are all
|
||||
unchanged — the smallest possible diff to the geometry.
|
||||
|
||||
32×32 rather than 64×64 because tile size affects CPU cost by under 10 % (§5)
|
||||
and affects wire cost a great deal: a solid 32×32 costs one tag, and a changed
|
||||
32×32 patch costs 128 B against 512 B for 64×64. Finer tiles waste less overdraw
|
||||
on an edge. **call:** 32×32.
|
||||
|
||||
### 8.2 Wire form
|
||||
|
||||
`KIND_TILES_FULL` and `KIND_TILES_DELTA` keep their numbers (2, 3) so the kind
|
||||
table and the existing tests stay meaningful; their **payloads change**, and
|
||||
`VERSION` goes to 2 so an old sender cannot be mis-dispatched.
|
||||
|
||||
```
|
||||
payload:
|
||||
offset size field
|
||||
0 2 count, u16 little-endian, 1..1,280
|
||||
2 2*count position[k], u16, row-major tile index, STRICTLY ASCENDING
|
||||
2+2c count tag[k], u8:
|
||||
0x00 = this tile is solid black
|
||||
0x01 = this tile is solid white
|
||||
0x02 = a 128-byte bitmap follows
|
||||
2+3c m*128 bitmaps for the tags equal to 0x02, in k order
|
||||
payload_len == 4 + 3*count + 128*m
|
||||
```
|
||||
|
||||
Grouped rather than interleaved (positions, then tags, then bitmaps) so the
|
||||
receiver can validate **the whole list without reading a single bitmap**:
|
||||
count in range, length consistent with the count, every position on the grid and
|
||||
strictly ascending, every tag in {0,1,2}, and the tail length exactly
|
||||
128 × (number of `0x02` tags). That preserves the property the harness's
|
||||
off-grid negative control exists to protect — *one bad entry paints nothing* —
|
||||
and it is `O(count)` with one cursor and no division.
|
||||
|
||||
`TILES_FULL` is the same form with `count == 1,200` (all picture tiles); the bar
|
||||
tiles are never sent.
|
||||
|
||||
**Positions refer to the 40×30 picture grid**: 0..1,199, row-major, with
|
||||
`ty = pos / 40` avoided by incrementing as today. That is a *change* to the wire
|
||||
constant `FP_TILES_SCREEN`/`TILES_PER_SCREEN` (1,280 → 1,200) and to
|
||||
`tile_check.py`'s renderer, and the bar rows are outside the wire entirely —
|
||||
which is the one place this design is not a minimal diff, so it is stated rather
|
||||
than implied.
|
||||
|
||||
**Expected wire, estimated from the one real-clip measurement** (496 B mean
|
||||
delta at 15 fps ≈ 165 changed tiles → ~41 tiles per frame at 60 fps): if a third
|
||||
of the changed tiles need a bitmap, ≈1.9 KB/frame ≈ **115 KB/s at 60 fps**, i.e.
|
||||
9 % of the link. If every one needs a bitmap, 5.3 KB/frame ≈ 320 KB/s, 25 % of
|
||||
the link. **The measurement in §11 replaces both numbers before any code is
|
||||
written.**
|
||||
|
||||
### 8.3 Decoder
|
||||
|
||||
Per frame, unchanged from today's structure (§76's `paint_delta`): two passes,
|
||||
validate then paint, inside one burst.
|
||||
|
||||
Paint per tag:
|
||||
* `0x00` / `0x01` → one `PIXBLT B,XY` from a **64-word** all-zero or all-ones
|
||||
constant in the payload's pool, `DYDX=(32,32)`. (A `FILL` is the same cost —
|
||||
§5 — and `PIXBLT` keeps one primitive and one set of implied operands.)
|
||||
* `0x02` → one `PIXBLT B,XY` from the bitmap in the frame itself (it is already
|
||||
in a slot, bit address `rx_frag_ptr + offset*8`; no copy).
|
||||
|
||||
**Optimisation carried over from the earlier analysis, and it is the only one
|
||||
that matters:** hoist `PSIZE`, `CONVSP`, `CONVDP`, `PMASK`, `CONTROL`, `B1`,
|
||||
`B3`, `B7`, `B8`, `B9` out of the per-tile primitive into a per-frame (really
|
||||
per-run) setup, and delete the eight push/pop pairs in `vram_tile.asm`. Only
|
||||
`B0` (SADDR) and `B2` (DADDR) vary per tile, and `PIXBLT` destroys both. This is
|
||||
a small constant on top of the per-word cost, so it is worth doing once and not
|
||||
worth designing around.
|
||||
|
||||
**Decoder state — the encoder's shadow screen.** Every delta must be computed
|
||||
against *what the decoder currently shows*, not against the source's previous
|
||||
frame, because a rolling refresh (§8.4) makes those two different: after a cut
|
||||
the decoder holds the old picture with a few hundred tiles replaced. So
|
||||
`vidsend.py` keeps a **shadow index buffer** of the decoder's screen, updated as
|
||||
each frame is sent and marked "everything stale" when a refresh begins, and
|
||||
computes every delta against it. Today's `plan()` diffs `previous` and assumes it
|
||||
arrived; that is already false after a drop and would be routinely false under a
|
||||
rolling refresh. Small change in the sender, a *precondition* for a correct
|
||||
picture.
|
||||
|
||||
### 8.4 Refresh
|
||||
|
||||
A full refresh is **not** one frame. Split into sub-frames of at most **354
|
||||
bitmaps**: a frame must fit 32 datagrams of 1,450-byte payloads, so
|
||||
`4 + 3c + 128m ≤ 32 × 1,450 = 46,400` with `m = c` gives `c ≤ 354` — fewer when
|
||||
the tags make it smaller. (The u16 `payload_len` would allow 500; the ring binds
|
||||
first.) The sub-frames go out across consecutive frames.
|
||||
|
||||
**Bitmaps must not straddle a datagram.** A 128-byte bitmap lands mid-datagram
|
||||
for most payload sizes, and a split bitmap would need bit-level reassembly across
|
||||
fragments — per-byte CPU work, which is an anti-goal. **call:** the sender ends
|
||||
each datagram at a tile boundary, wasting at most 127 bytes of a 1,450-byte
|
||||
payload (≤ 9 % of the wire) and leaving the decoder a single fragment arithmetic
|
||||
per bitmap.
|
||||
|
||||
**The first refresh is a boot sequence, not a repair.** Before it the picture is
|
||||
undefined, so painting it progressively is a wipe-in on a black screen. Every
|
||||
later refresh replaces a picture already on screen, which is why §12 treats the
|
||||
cut wipe as a real risk. On the wire a refresh
|
||||
is ~154 KB worst case = **121 ms**, so a refresh occupies ~7 frame periods
|
||||
whatever the decoder does; the decoder's own share (1,200 × 64 words = 76,800 =
|
||||
31 ms) is spread by the same split.
|
||||
|
||||
**call:** the fixed `KEYFRAME_EVERY = 60` cadence goes away. A refresh is forced
|
||||
(a) at the first frame, (b) when the status frame's `drops` rises, and (c) on a
|
||||
slow safety cadence of 3,600 frames (60 s at 60 fps). The reason to remove the
|
||||
fixed cadence is arithmetic: one 31 ms refresh every second is 1.9 frames of
|
||||
budget per second *and* 7 frames of wire, i.e. a dropped frame every 60 — the
|
||||
hitch a viewer would notice. `keyframe_due` keeps its `drops` trigger, which is
|
||||
the only repair the terminal can actually ask for.
|
||||
|
||||
On a hard cut the whole screen changes, so the cut is a refresh and the picture
|
||||
wipes over ~7 frame periods (117 ms). That is the price of dropping the
|
||||
dictionary and it is stated here rather than discovered later.
|
||||
|
||||
### 8.5 Pacing
|
||||
|
||||
**call:** replace the fixed `--fps` clock with **wire-capacity pacing**: send a
|
||||
frame whenever the ring has room and the previous frame's bytes have left, and
|
||||
never send faster than 60 fps. Normal frames (1.9–5.3 KB) then arrive at 60 fps
|
||||
with the link at 9–25 % duty; a refresh (or a cut) temporarily lowers the
|
||||
delivered rate rather than overflowing the ring or dropping frames. `--fps`
|
||||
stays as a ceiling so the demo can still be slowed for inspection.
|
||||
|
||||
This matters because the failure mode of the fixed clock is silent: at 60 fps a
|
||||
7-frame refresh cannot be sent, the ring fills, the chip sets `MISS`, the status
|
||||
frame reports a rise in `drops`, and `keyframe_due` responds by… scheduling
|
||||
another refresh. Capacity pacing removes the loop.
|
||||
|
||||
### 8.6 What gets deleted
|
||||
|
||||
* `tilecost.build_dictionary`, `quantise`, `_frame_tiles` and the whole k-means
|
||||
path; the dictionary, its 32 KB, its 23-datagram upload, `DICT_READY`,
|
||||
`DICT_BYTES`, `DICT_BAD`, `dict_chunk()`, `frag_copy()` (its only caller),
|
||||
and the `DICT_CHUNK` kind.
|
||||
* `OS_TOUCH` and the VRAM sentinel word (`SENT_ADDR`/`SENT_VALUE`), because the
|
||||
OS is gone (§8.9).
|
||||
* `TILES_DROP` survives, but now means "a frame failed validation" rather than
|
||||
"no dictionary yet".
|
||||
* The per-frame `vid_dint_` / `vid_eint_` pair: one `DINT` at startup, never
|
||||
re-enabled.
|
||||
* `CONVSP`-for-source (`PIXBLT B,XY` here has a constant source pitch of 32
|
||||
bits). `CONVDP`, `PSIZE` and `PMASK` still matter.
|
||||
|
||||
Net effect: `vidstream.c` and `make_tiles.py` both get *smaller*, and the
|
||||
encoder loses its only slow step.
|
||||
|
||||
### 8.7 Encoder pipeline
|
||||
|
||||
One pass, no intermediate frames: `ffmpeg` rawvideo → numpy → per-frame tile
|
||||
diff → stream records. Reasons: `tilecost`/`make_tiles` are pure Python and
|
||||
13,171 frames × 691,200 pixels is 9.1 G pixels (hours in the current code), and
|
||||
materialising 13,171 × 153,600 B of `*.bin` frames is 2.0 GB of disk.
|
||||
|
||||
Bit order stays **LSB-first within each byte** (`clip_from_video.py` and
|
||||
`vidsrc.pack_row` both spell out why: an MSB-first packer is self-consistent and
|
||||
mirrors every eight pixels on the display).
|
||||
|
||||
**No dither.** A hard threshold is what makes exact deltas cheap; ordered or
|
||||
error-diffused dither would make nearly every tile dirty every frame and the
|
||||
wire cost would leave the budget. This is a design constraint, not a preference.
|
||||
|
||||
`clip_from_video.py` stays the reference for the extraction front end
|
||||
(upscale in grey, then threshold); its `--height` check already accepts 960.
|
||||
|
||||
### 8.8 Failure behaviour
|
||||
|
||||
* Bad header / bad length / descending positions / off-grid position / unknown
|
||||
tag: refuse the frame, paint nothing, count it (`RX_BAD` / `TILES_DROP`), keep
|
||||
the picture. Unchanged from today.
|
||||
* Ring overrun: `MISS` is reported; capacity pacing (§8.5) is the prevention.
|
||||
* A refresh interrupted (drops mid-refresh): the next `drops` rise restarts it;
|
||||
the picture is mixed during the window and self-heals on completion. Say so in
|
||||
the status frame — a `refresh_in_progress` bit is cheap and makes the mixed
|
||||
window explainable instead of mysterious.
|
||||
|
||||
### 8.9 The payload owns the machine
|
||||
|
||||
**human:** the resident OS may die; it is already losing its network stack to
|
||||
the LANCE takeover.
|
||||
|
||||
What that removes: `OS_TOUCH` and the sentinel, the `dint`/`eint` burst
|
||||
discipline, and any question of interleaving.
|
||||
|
||||
What it obliges, and this is the part that must not be skipped:
|
||||
|
||||
* **One `DINT` at startup, never re-enabled.** With `IE = 0` nothing maskable is
|
||||
taken, so the OS's old ISRs are never entered against dead state. NMI remains
|
||||
maskable-only-by-the-host (it needs a `HSTCTL` write) and is not a concern.
|
||||
* **Video registers are inherited, verified and then owned.** Nothing will
|
||||
re-program `DPYCTL`/`DPYSTRT`/`DPYADR`/`DPYINT`/`DUDATE`/`HTOTAL`… once the OS
|
||||
is gone, and the TMS34010's own screen-refresh machinery runs on those values
|
||||
forever. So the payload reads them at startup, publishes them in the marker
|
||||
page for diagnosis, and re-asserts them only if a sanity check fails. It does
|
||||
**not** hardcode a mode: the values that matter are the ones the OS just
|
||||
programmed for the modelled monitor (`VT1200_MONITOR`, default VR319).
|
||||
Consequence to state plainly: **a payload loaded from a machine that never
|
||||
configured video has no picture**, and the demo's boot path is therefore
|
||||
"boot the OS, wait for it, then take over" — as `demo.sh` already does.
|
||||
* **Operational cost:** a payload that hangs with the OS dead takes the machine
|
||||
down to a power cycle — no rdebug, no console. This matches the existing
|
||||
"one demo per boot" note in `harness/badapple/README.md` and should be
|
||||
repeated in it.
|
||||
* The bar rows are painted once by the payload (2 × 1,280 × 32 pixels = 5,120
|
||||
destination words ≈ 2 ms) so the picture is not sitting on whatever the OS
|
||||
left there.
|
||||
|
||||
## 9. Budgets
|
||||
|
||||
Unit rates for the blit path, MAME-labelled where the unit has never been
|
||||
measured. `B,XY` is shown at both ends of its uncertainty.
|
||||
|
||||
| case | tiles | dest words | CPU @0.32 µs/word | CPU @0.62 µs/word | wire |
|
||||
|---|---|---|---|---|---|
|
||||
| typical 60 fps delta (≈41 tiles) | 41 | 2,624 | 0.9 ms | 1.7 ms | 1.9–5.3 KB |
|
||||
| delta, busy frame (≈150 tiles) | 150 | 9,600 | 3.2 ms | 6.1 ms | ~7–19 KB |
|
||||
| refresh sub-frame (354 bitmaps) | 354 | 22,656 | 7.3 ms | 14.0 ms | ~46.4 KB |
|
||||
| full picture (1280×960) | 1,200 | 76,800 | 24.6 ms | 47.6 ms | ~154 KB |
|
||||
|
||||
Frame budgets: **41,700 words** and **21 KB**. So a typical delta uses ~6 % of
|
||||
the CPU budget and ~10–25 % of the wire; a refresh sub-frame uses 18–35 % of the
|
||||
CPU but **>100 % of one frame's wire**, which is why refresh is paced by the wire
|
||||
and not by the clock (§8.5).
|
||||
|
||||
## 10. Verification
|
||||
|
||||
* **Pixel verdict, unchanged in spirit:** `tile_check.py` decodes the new
|
||||
payloads independently and renders them against the payload's dumped VRAM. It
|
||||
must show **exact** agreement frame for frame — with no dictionary there is no
|
||||
reconstruction error to report, so a single differing bit is a bug rather than
|
||||
a PSNR.
|
||||
* **Host tests:** `harness/udpstream/tests/` keeps its role — the kind table is
|
||||
mirrored in `frame.py` and `frame_parse.h` and `test_frame.py` compares them;
|
||||
the new payload needs its own negative controls (count 0, count > 1,200,
|
||||
descending position, off-grid position, unknown tag, tail length not
|
||||
128 × m).
|
||||
* **MAME:** the no-env regression baseline
|
||||
(`PC=3FDCE400 samples=199 (2 distinct PCs)`, `harness/mame/README.md`) must not
|
||||
move; the tile modes (`VT1200_MODE=tiles|delta|replay`) are re-pointed at the
|
||||
new payloads.
|
||||
* **Unit:** `bench-sweep.sh` methodology plus the 3.5-minute soak; the new
|
||||
`decode_worst` samples are the instrument. **The `B,XY` per-word rate must be
|
||||
measured on the unit in the first milestone** — every CPU number above is an
|
||||
extrapolation from `FILL`/`L,XY` until then.
|
||||
|
||||
## 11. First task: measure before building
|
||||
|
||||
Before any payload change, one host-side pass over `hd60.mp4` with numpy:
|
||||
|
||||
1. extract at **1280×960, 60 fps, hard threshold** (the proposed front end);
|
||||
2. per frame, diff against its predecessor and record **changed tiles of the
|
||||
40×30 picture grid**, **changed pixels**, **tags-only bytes**, **bitmap
|
||||
bytes**, and the solid/bitmap split;
|
||||
3. report mean / p50 / p95 / max wire per frame, the frames where wire exceeds
|
||||
the 21 KB budget, and where the cuts are.
|
||||
|
||||
This decides three things the design currently *assumes*:
|
||||
* whether the solid-tag split makes the wire budget comfortable or marginal;
|
||||
* whether 32×32 stays the tile size (a bitmap-heavy distribution would argue for
|
||||
a smaller tile, a tag-heavy one for a larger);
|
||||
* how many refreshes the clip actually needs, i.e. how visible the cut wipes are.
|
||||
|
||||
It also settles the extractor question (§4, cause 1) by producing the frames
|
||||
that the picture will actually contain.
|
||||
|
||||
## 12. Risks
|
||||
|
||||
| risk | why it matters | mitigation |
|
||||
|---|---|---|
|
||||
| `B,XY` rate is a MAME number | every CPU estimate here; the 12 ms MAME repaint contradicts the unit's 31 ms | measure `B,XY` on the unit first (§10); the design is wire-bound on deltas so a 2× CPU surprise costs little |
|
||||
| refresh is 7 frames of wire | cuts wipe for ~117 ms | capacity pacing; keep the dictionary as a *refresh-only* accelerator (approach B) if it looks bad |
|
||||
| a cut needs a full refresh | all 1,200 bitmaps, 154 KB | tags collapse the mostly-black ones; if not, approach B |
|
||||
| killing the OS is irreversible | hang = power cycle; no video if video was never configured | keep the boot path `demo.sh` uses; publish the video registers; never hardcode a mode |
|
||||
| `payload_len` u16 and a 32-descriptor ring cap a frame at ~355 bitmaps | a naive full-frame refresh cannot be expressed | sub-frames (§8.4); optional 64-descriptor ring if it ever binds |
|
||||
| 60 fps on a 72 Hz panel | every frame is 1.2 refresh periods, the tear line drifts | accepted (§8.7 of the 2026-10-30 doc); revisit with a start-of-frame delay |
|
||||
| encoder is pure Python today | 9.1 G pixels | numpy or C, one pass, no intermediate frames (§8.7) |
|
||||
| the jaggies are not the dictionary | rebuilding the codec would not fix them | §4's three cheap checks run first |
|
||||
|
||||
## 13. Left to the builder
|
||||
|
||||
* Ring depth: 32 descriptors with 4-way refresh split, or 64 with a wider slot.
|
||||
* Whether solid tiles use a constant bitmap and `PIXBLT`, or `FILL`.
|
||||
* Exact `CONTROL` mask and implied-operand setup; how much of it is hoisted and
|
||||
where the hoist lives (a `_tile_init_` in assembly, or C writing the I/O
|
||||
registers once).
|
||||
* The status body's new fields (a `refresh_in_progress` bit, and whether
|
||||
`decode_worst` should now be reported per kind).
|
||||
* Whether the encoder keeps `make_tiles.py`'s cache files
|
||||
(`dictionary.bin`/`indices.bin`/`stream.bin`/`manifest.json`) or moves to a
|
||||
single stream file.
|
||||
* How `tile_check.py` reports "exact" (a bit count of zero, or a hash).
|
||||
* Whether the bars are bottom-aligned (960 rows at the top, §2) or split 32/32.
|
||||
* Whether the old strip path (`KIND_STRIP`, `paint()`, `vram_b.asm`) is kept as a
|
||||
black-screen/bring-up test or deleted with the rest of the tile machinery.
|
||||
Keeping it costs nothing and it is already verified; deleting it removes a
|
||||
second decoder path.
|
||||
* `vidsend.py`'s `min_chunk` check exists to stop a frame being split across more
|
||||
than 32 descriptors; it must be recomputed from the new maximum frame size.
|
||||
|
||||
## 14. Decisions from the human partner (this session)
|
||||
|
||||
* **The output geometry is upscaled to fill the panel**, not 1:1 with borders.
|
||||
* **The resident OS may die** — it already loses its network stack to the
|
||||
LANCE takeover.
|
||||
* **60 fps is the target**, and the source in `/tmp/opencode/badapple-real/` is
|
||||
the one to use.
|
||||
* "We can change everything" — the wire format, the encoder and the payload are
|
||||
all in scope.
|
||||
|
||||
Carried over from 2026-10-30 §14 and still binding: *"30 fps is just best case if
|
||||
there is enough budget. Real fps can drop."* So a rate that falls below 60 fps in
|
||||
busy sections is acceptable as long as the picture stays legible and the drop is
|
||||
a consequence of the content, not of a stall.
|
||||
|
||||
## 15. Open questions
|
||||
|
||||
1. Is the panel's pixel grid square? If it is not, 1280×960 is a subtly wrong
|
||||
aspect and the 1024-row stretch becomes the right answer.
|
||||
2. Does the terminal's Ethernet path ever deliver at more than the measured
|
||||
1.27 MB/s — is the 10 Mbit figure the NIC or the hub?
|
||||
3. Should the demo keep any input at all (a stop key), which would argue for
|
||||
keeping one ISR alive rather than `DINT` forever?
|
||||
Reference in new issue
Block a user