forked from arti/vt1200
Spec 11's first task: decide from the clip, not from assumptions, whether the
raw-tile scheme (no dictionary, tag + bitmap per changed tile) fits a 60 fps
frame. measure_wire.py extracts at 1280x960/60 fps with the hard threshold the
design requires, diffs each frame on the 40x30 picture grid, and totals the
payload a TILES_DELTA would carry:
4 + 3*count + 128*m
Full clip, 13,167 frames: mean 16,049 B/frame, p50 14,755, p95 33,353, max
51,523; 22.5% of frames exceed the 21 KB one-frame budget; 87% of changed tiles
are neither solid black nor white, so they carry a 128-byte bitmap (the design
assumed a third); mean 140 changed tiles/frame, max 1,173 at a cut. Link duty
at 60 fps would be 72% of the measured 1.27 MB/s.
Two consequences for the design, recorded here rather than discovered later:
the raw-tile scheme is borderline rather than comfortable, and the tile churn at
60 fps (140) is nearly the same as at 15 fps (165) -- motion between adjacent
60 fps frames is mostly sub-tile, so sending faster does not divide the wire by
four.
218 lines
9.8 KiB
Markdown
218 lines
9.8 KiB
Markdown
# Bad Apple on a VT1200
|
|
|
|
The demo end to end: a payload is **delivered over the network** into a running
|
|
terminal, and the picture is **streamed over the network** to it, which paints
|
|
it from a 32 KB tile dictionary plus per-frame deltas. Nothing here uses a
|
|
debugger — that path (`harness/mame/vidstream.lua`) exists to *test* the payload
|
|
against MAME's debugger API and is not how anything reaches a machine.
|
|
|
|
```
|
|
harness/badapple/
|
|
├── README.md this file (setup, data preparation, run)
|
|
├── demo.sh one command: wait for the OS, load, send
|
|
├── rdebug_load.py payload image → terminal RAM over rdebug (TCP 771)
|
|
├── clip_from_video.py video → raw 1-bpp frames for make_tiles.py
|
|
├── measure_wire.py what the 60 fps raw-tile wire form would cost per frame
|
|
└── tests/ unit tests for the two Python tools
|
|
```
|
|
|
|
## How the delivery works
|
|
|
|
| step | transport | what it does |
|
|
|---|---|---|
|
|
| boot | — | `harness/mame/run.sh net` (MAME) or the real unit powering up |
|
|
| readiness | ICMP | `demo.sh` waits for the terminal's own stack to answer |
|
|
| payload | **rdebug, TCP 771** | `poke` the image in 512-byte chunks, read it back, `jump` to the entry |
|
|
| dictionary | Ethernet, ethertype `0x88B7` | 23 datagrams, one `TILES_DICT_CHUNK` each |
|
|
| pictures | same | one `TILES_FULL` or `TILES_DELTA` per clip frame |
|
|
|
|
`rdebug` is the firmware's own remote-debug service: `peek`/`poke`/`jump`, the
|
|
same protocol `dwt/exploit.py` speaks (`dwt/notes/CROSS.md`). On hardware it is
|
|
the delivery path; in MAME it works because the LANCE is attached to a host tap.
|
|
|
|
Two facts about it are load-bearing and were measured:
|
|
|
|
* **A 512-byte poke chunk, not 1024.** The firmware documents a 1024-byte clamp,
|
|
but a 1024-byte poke is mis-parsed — it reads the body's first word as the
|
|
next opcode and answers `Invalid command 0xA5A5A5A5` for an `0xA5`-filled
|
|
chunk. 512 is accepted and reads back byte for byte.
|
|
* **Nothing can be probed after the `jump`.** A non-returning payload takes the
|
|
remote-debug process with it, so `rdebug_load.py` verifies the image *before*
|
|
jumping, and the run is observed afterwards from the machine itself (the
|
|
screen, or MAME's RAM/VRAM dumps). One demo per boot.
|
|
|
|
## Setup
|
|
|
|
Two builds and one piece of data preparation. Steps 1 and 2 are C builds; step 3
|
|
is the part that needs a source video.
|
|
|
|
```sh
|
|
cd /path/to/vt1200
|
|
|
|
# 1. MAME + the driver (skip if vt1200 is already built)
|
|
harness/mame/build.sh
|
|
|
|
# 2. the payload (TI SDK under DOSBox) -> harness/udpstream/build/vidstream.bin
|
|
harness/udpstream/build-stream.sh
|
|
```
|
|
|
|
Step 3 is described below, because the numbers (time, disk, what "good" looks
|
|
like) are what make it repeatable.
|
|
|
|
## Data preparation: a video to a tile cache
|
|
|
|
Nothing ships a full clip: `harness/udpstream/tiles/` in the repo is the
|
|
**30-frame synthetic clip**, which is enough to smoke-test the transport but shows
|
|
a moving ellipse, not the animation. A real clip is three commands and about
|
|
five minutes.
|
|
|
|
### 3a. Get a source video
|
|
|
|
Any file ffmpeg can read. For Bad Apple the usable public sources are:
|
|
|
|
| source | why |
|
|
|---|---|
|
|
| `archive.org/details/hd-60-fps-bad-apple-shadow-art-pv-720-x-960-60fps` | 960x720, 60 fps, 219 s, ~35 MB — the one this demo was measured on (the item name is scrambled; the file inside is 960x720) |
|
|
| `yt-dlp <url>` from YouTube | fine, but the 4K/8K uploads are upscales of the same 2009 render and add no detail |
|
|
| the original PV's Flash file (`archive.org/details/dagobah_Bad_Apple_HQ`) | the true master, vector — needs a Flash renderer to rasterise, which nothing here does |
|
|
|
|
Resolution matters less than it looks: the terminal shows **1 bpp**, and the
|
|
pipeline scales to 1280 px wide before thresholding, so anything at 960 px or
|
|
more lands on the same picture.
|
|
|
|
### 3b. Frames
|
|
|
|
```sh
|
|
python3 harness/badapple/clip_from_video.py bad-apple.mp4 /tmp/badapple --fps 15
|
|
```
|
|
|
|
* one `*.bin` per frame, 1280x960, 1 bpp, 160-byte rows, **LSB first** — bit 0 of
|
|
a byte is the row's leftmost pixel, the order both the terminal's scanout and
|
|
the payload's encoder use. Do not substitute `ffmpeg`'s `format=monob`: it
|
|
packs MSB first and mirrors every eight pixels on the display.
|
|
* `--fps 15` on the 219 s source gives **3,292 frames**; `--height 960` fills 30
|
|
of the 32 tile bands (a 4:3 source at 1280x960, the bottom two rows black), and
|
|
`--height 1024` fills the screen by stretching 4:3.
|
|
* measured cost: **~12 s**, **489 MB** on disk (153,600 B per frame).
|
|
* the source is white paper with black silhouettes, which is already the right
|
|
polarity. If yours is inverted, add `--invert`; `--threshold` (default 127)
|
|
decides what counts as ink.
|
|
|
|
### 3c. The cache
|
|
|
|
```sh
|
|
python3 harness/udpstream/make_tiles.py --clip /tmp/badapple --out /tmp/badapple-tiles
|
|
```
|
|
|
|
Takes **~2 min 47 s** (pure-Python Lloyd's algorithm over 3,292 frames) and
|
|
writes four files, **6.1 MB** total:
|
|
|
|
| file | size | what it is |
|
|
|---|---|---|
|
|
| `dictionary.bin` | 32,768 B | 256 32x32 tiles, the vocabulary every frame is expressed in |
|
|
| `indices.bin` | 4,213,760 B | one 1,280-byte index frame per clip frame |
|
|
| `stream.bin` | 1,645,081 B | the plan: 55 keyframes + 3,141 deltas + 96 "nothing changed" |
|
|
| `manifest.json` | 199 KB | the numbers below, per frame |
|
|
|
|
It prints the same summary. What it said for this clip, and what to check:
|
|
|
|
```
|
|
3292 frames, 256 dictionary entries (256 used) <- the dictionary is saturated
|
|
55 keyframe(s), 3141 delta(s), 96 unchanged
|
|
payload 1280 B full, 496 B mean delta (5..3602), 1628615 B total
|
|
reconstruction 15823231 bit(s) over 3292 frame(s), BER 0.003667, PSNR 24.36 dB
|
|
```
|
|
|
|
Saturated entries and a 24 dB PSNR are expected, not errors: one byte per tile is
|
|
the format's ceiling, and shadow-art edges are expensive. A clip that reports
|
|
`inf` PSNR has every tile in the dictionary — that happens only for very flat
|
|
footage.
|
|
|
|
Keep the cache outside the repo: it is 6.1 MB of derived data. Pass it to the
|
|
demo with `--tiles /tmp/badapple-tiles`.
|
|
|
|
## Run it in MAME
|
|
|
|
Terminal 1 — the machine, with the LANCE on the host tap (`harness/mame/cfg-net`
|
|
selects `taptun`; the tap is `tap-mess-<uid>-0` in bridge `vt1200`,
|
|
`192.168.99.1/24`):
|
|
|
|
```sh
|
|
harness/mame/run.sh net
|
|
```
|
|
|
|
The harness now models the real unit's **VR319** monitor, so the firmware itself
|
|
uses the **1280-px / 2048-bit (256-byte) row layout** the tile payload targets —
|
|
and the Terminal Manager before it renders correctly at the same geometry. No
|
|
`VT1200_VRAM_REAL` is needed (that knob forced the pitch without changing the
|
|
firmware's layout, which is what used to shear the OS screen). To reproduce the
|
|
old derived 1024-px layout, run with `VT1200_MONITOR=VR150`; the tile picture
|
|
will then be sheared, as before. See `harness/mame/RENDER_REPORT.md` §8.
|
|
|
|
Terminal 2 — the demo:
|
|
|
|
```sh
|
|
harness/badapple/demo.sh --tiles /tmp/badapple-tiles --frames 3292 --fps 15
|
|
```
|
|
|
|
With no arguments `demo.sh` plays the committed 30-frame synthetic cache: a
|
|
smoke test that needs no clip.
|
|
|
|
## Run it on a real VT1200
|
|
|
|
The terminal must be reachable over Ethernet and answering rdebug on TCP 771 —
|
|
that is all this demo needs, no debug pod and no special build of the firmware.
|
|
|
|
```sh
|
|
# the unit's address, and the host interface that reaches it
|
|
harness/badapple/demo.sh --host <unit-ip> --iface <host-iface> \
|
|
--tiles /tmp/badapple-tiles --frames 3292 --fps 15
|
|
```
|
|
|
|
Notes that differ from the emulator:
|
|
|
|
* **Pacing is not needed** for the dictionary upload: the payload's receive ring
|
|
is 32 descriptors, one more than the 23-datagram upload, so the whole
|
|
dictionary lands before the first chunk is copied. On a slower link the wire
|
|
paces it for you.
|
|
* **`VT1200_VRAM_REAL` does not exist there** — the unit already uses its own
|
|
layout; the switch only affects MAME's renderer.
|
|
* The image is position-dependent and is linked at bit `0x018C0000` with its
|
|
entry at the address in `harness/udpstream/build/vidstream.env`. Both are
|
|
read from that file, so a rebuild moves the entry without editing anything.
|
|
|
|
## What success looks like
|
|
|
|
`demo.sh` prints the load verification and the sender's per-frame CSV. The
|
|
sender's verdict is about the *transport*:
|
|
|
|
```
|
|
PASS: 2 status frames, last frames_rx=100 (drops=0 crc_err=0)
|
|
```
|
|
|
|
The terminal's own counters, read from a MAME RAM dump (byte offsets from
|
|
`MARK_PAGE` = bit `0x01810000`), say what it actually did:
|
|
|
|
| cell | offset | good value |
|
|
|---|---|---|
|
|
| `DICT_BYTES` | `0x54` | `32768` (and `DICT_READY` `0x50` = 1) |
|
|
| `DICT_BAD` | `0x58` | `0` |
|
|
| `RX_OK` | `0x30` | climbs with painted frames |
|
|
| `TILES_DROP` | `0x5c` | `0` |
|
|
| `RX_DGRAM` | `0x48` | datagrams consumed |
|
|
|
|
`harness/mame/tile_check.py --replay` is the pixel verdict for the MAME replay
|
|
path; for a live run the screen is the evidence.
|
|
|
|
## Failure modes worth knowing
|
|
|
|
| symptom | cause |
|
|
|---|---|
|
|
| `demo.sh: ... did not answer within 180s` | the terminal is not on the tap, or `run.sh net` was started without the LANCE interface selected in its cfg |
|
|
| `demo.sh: ... has no dictionary.bin` | `--tiles` wants a **cache** directory, not the frames directory `clip_from_video.py` wrote; the message names the `make_tiles.py --clip … --out …` command that builds it |
|
|
| payload already jumped, need to send again | the script refuses a bad cache path *before* the jump for this reason, but if it happens: rerun with `--no-load` — the payload is still running and waiting, and `vidsend` brings the dictionary with it |
|
|
| poke reads back a mismatch | the image is being loaded over a stale link, or the payload was built for a different load address |
|
|
| `DICT_BYTES` stuck below 32768 | datagrams lost before the payload saw them — check the tap and the sender's interface |
|
|
| picture sheared in MAME | `VT1200_MONITOR=VR150` is selected (the old derived 1024-px layout); leave the default VR319 |
|
|
| `jump` seems to hang the service | expected: the payload never returns, so rdebug is gone until the machine is rebooted |
|