mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-11-01 12:14:56 +02:00
spec: Bad Apple 30s vector-polygon demo
This commit is contained in:
1 parent
6febcf00f4
commit
bc3b20113c
1 file changed
+601
@@ -0,0 +1,601 @@
|
||||
# Bad Apple, 30 s, vector polygons — design
|
||||
|
||||
Date: 2026-10-31
|
||||
Status: **rev 1** — first draft, awaiting approval.
|
||||
Author: this session.
|
||||
|
||||
This is a **separate prototype** from
|
||||
[`2026-10-31-badapple-60p-design.md`](2026-10-31-badapple-60p-design.md)
|
||||
(the raw-tile, full-length, streamed design in `harness/badapple/`). It does
|
||||
not supersede it and does not change the existing harness. It answers a
|
||||
different brief: a **standalone, first-30-seconds, polygon-styled** demo in a
|
||||
new top-level `bad-apple/` folder.
|
||||
|
||||
Decisions marked **human:** were given in this session. Decisions marked
|
||||
**call:** are the builder's proposals, not yet agreed.
|
||||
|
||||
---
|
||||
|
||||
## 1. Intent
|
||||
|
||||
Play the first **30 seconds** of
|
||||
`/tmp/opencode/badapple-real/hd60.mp4` on a VT1200, **full screen** at
|
||||
**1280×1024**, as **flat black-and-white polygon art** in the spirit of
|
||||
*Another World* — not a dithered bitmap and not a 32×32 tile dictionary.
|
||||
|
||||
The picture is quantised to straight-edged polygons, then to a coarse logical
|
||||
grid, then to rectangles that the TMS34010's `FILL` engine can paint fast.
|
||||
After a small bootstrap is delivered over `rdebug`, the payload takes over the
|
||||
machine and receives the movie data over Ethernet; playback is then entirely
|
||||
local from the VT1200's 4 MB of main RAM. Recovery is a hardware restart.
|
||||
|
||||
## 2. What the human partner decided
|
||||
|
||||
* **Source:** `/tmp/opencode/badapple-real/hd60.mp4`, first **30 s**.
|
||||
* **Frame:** fill the whole 1280×1024 panel; no letterboxed 4:3.
|
||||
* **Look:** black-and-white **polygons**, similar to *Another World*; drop
|
||||
dithered sections and threshold/simplify them into flat shapes.
|
||||
* **Motion:** **60 fps is the target**; complex scenes may drop frames.
|
||||
* **Ownership:** after the initial load, the program may take the whole 4 MB
|
||||
of RAM; the OS may die; recovery is a power cycle.
|
||||
* **Delivery:** `rdebug` is the bootstrap path; the network loader in
|
||||
`harness/badapple/` is fair game to make the big load fast.
|
||||
* **Location:** a new **top-level `bad-apple/`** folder.
|
||||
|
||||
## 3. Success criteria
|
||||
|
||||
1. `bad-apple/` reproduces the whole pipeline from the source video with one
|
||||
command per stage, and documents the data budget it measured.
|
||||
2. In MAME, the first 30 s plays full-screen, recognisably Bad Apple, with
|
||||
clean straight-edged shapes and no corrupt or stale frames. Typical frames
|
||||
run at 60 fps; heavy frames may drop to 30 fps or hold for one refresh.
|
||||
The baseline repaints dirty tiles incrementally; a brief wipe on a large
|
||||
change is accepted for this prototype (§8.5).
|
||||
3. The movie data plus code fits in the VT1200's 4 MB of main RAM, excluding
|
||||
the 256 KB framebuffer in video RAM.
|
||||
4. On the real unit, the same payload is delivered by `rdebug` plus the
|
||||
Ethernet loader and plays without a host attached.
|
||||
5. Existing `harness/` behaviour and the MAME no-env regression baseline are
|
||||
untouched. The ROM stays byte-identical.
|
||||
|
||||
## 4. Non-goals
|
||||
|
||||
* The full 3:39 clip (the 30 s limit is what makes local RAM playback possible).
|
||||
* Audio (the VT1200 has no audio path in this harness).
|
||||
* Grey, colour, dither, or anti-aliased pixels; this is 1 bpp.
|
||||
* Pixel-exact reproduction of the source. The target is a recognisable,
|
||||
clean vector reinterpretation.
|
||||
* Coexistence with the resident OS after takeover.
|
||||
* A general-purpose video player; this is a demo with a fixed 30 s stream.
|
||||
|
||||
## 5. Constraints and measured facts
|
||||
|
||||
### 5.1 Hardware
|
||||
|
||||
| item | value | source |
|
||||
|---|---|---|
|
||||
| CPU | TMS34010FNL-50, 50 MHz, 1 state = 160 ns | `harness/mame/vt1200.cpp` |
|
||||
| panel | 1280×1024 visible, 2048-bit (256-byte) row pitch, 72 Hz | `RENDER_REPORT.md`, driver |
|
||||
| main RAM | 4 MiB at bit address 0 | `vt1200.cpp` address map |
|
||||
| video RAM | separate, at bit `0x04000000` | `vt1200.cpp` |
|
||||
| `FILL L` on the unit | **0.40 µs per destination word** (16 pixels), plus ~2.6–3.3 µs per call | `2026-10-31-badapple-60p-design.md` §5 |
|
||||
| full 1280×1024 repaint | ~82k words ≈ **33 ms** | derived from the unit rate |
|
||||
| 60 fps budget | 16.7 ms ≈ **41k words** ≈ **667k pixels** | derived |
|
||||
| 72 Hz refresh | 13.9 ms per refresh | driver |
|
||||
|
||||
The 72 Hz display and 60 fps source have no common period; the player must
|
||||
pace a 60 Hz timeline against a 72 Hz refresh (§8.6).
|
||||
|
||||
### 5.2 Source
|
||||
|
||||
| item | value |
|
||||
|---|---|
|
||||
| video | 960×720, 60 fps, h264, 219.5 s, 13,167 frames |
|
||||
| audio | AAC stereo, ignored |
|
||||
| first 30 s | 1,800 frames |
|
||||
| black coverage | mean 55 %, p75 80 %, p95 98 % (1280×1024 threshold) |
|
||||
|
||||
The source is already high-contrast black-on-white; it is the ideal input for
|
||||
a threshold-and-trace pipeline.
|
||||
|
||||
### 5.3 The measured data budget
|
||||
|
||||
A host-side pass over the first 30 s at a **640×512 logical grid** (each
|
||||
logical pixel is a 2×2 screen block), hard threshold, no vectorisation yet,
|
||||
measured the tile-delta format in §8:
|
||||
|
||||
| quantity | mean | p50 | p95 | max |
|
||||
|---|---|---|---|---|
|
||||
| dirty 32×32 logical tiles per frame | 47.4 | 49 | 79 | 295 |
|
||||
| rectangles per frame | 437 | 451 | 820 | 1,381 |
|
||||
| bytes per frame (5 B tile header + 3 B/rect) | 1,548 | 1,598 | 2,855 | 5,618 |
|
||||
|
||||
**Total for 1,800 frames: 2.79 MB.** The data area is
|
||||
`0x010000..0x317FFF` = **3.03 MB**, so this leaves ~0.24 MB of headroom. A
|
||||
scene cut touches up to 295 tiles, still only 5.6 KB and one slow repaint.
|
||||
|
||||
The vectorisation step will change these numbers; the first milestone
|
||||
re-measures them. The gate is **≤ 3.0 MB** and a p95 paint cost within the
|
||||
refresh budget. If vectorisation pushes past 3.0 MB, the fallback is stronger
|
||||
simplification or a 4×4 logical grid, not a shorter clip without asking.
|
||||
|
||||
**Why 3.03 MB and not 3.5 MB:** the movie data starts at byte `0x010000` and
|
||||
must stop below the payload's `rdebug` origin at bit `0x018C0000` = byte
|
||||
`0x318000`. `0x318000 - 0x010000 = 0x308000` = 3.03 MB. Moving the payload
|
||||
origin would cost the one free block the real loader is known to accept, so
|
||||
the design keeps the origin and caps the data instead.
|
||||
|
||||
## 6. Approach
|
||||
|
||||
### 6.1 Why not store rectangles for every frame?
|
||||
|
||||
A full-frame rectangle list is simple but forces a full repaint: at the unit's
|
||||
0.40 µs/word, 1280×1024 costs ~33 ms, i.e. two 60 fps frames. The design must
|
||||
paint **only what changed**. The measured dirty-tile format does that: typical
|
||||
frames touch ~47 tiles (≈192k screen pixels ≈ 4.8 ms of fill), and the data
|
||||
still fits in 4 MB.
|
||||
|
||||
### 6.2 Why a 640×512 logical grid?
|
||||
|
||||
At 1×1 (1280×1024) the same delta format measures ~4× the data (≈10.8 MB) and
|
||||
does not fit. At 2×2 it fits with room to spare, and 640×512 is still four
|
||||
times the resolution of *Another World*'s 320×200. The 2×2 quantisation is
|
||||
also part of the requested look: it turns pixel-level fuzz into flat blocks
|
||||
with straight polygon edges.
|
||||
|
||||
**call:** 640×512 logical, 2×2 screen blocks. If the first milestone shows
|
||||
the data is unexpectedly small, 1×1 can be re-measured as a quality mode; it
|
||||
is not the baseline.
|
||||
|
||||
### 6.3 Why polygons then rectangles?
|
||||
|
||||
The polygon step is the **look**: threshold, despeckle, trace contours, simplify
|
||||
to straight segments, and fill the result. That produces the flat, angular
|
||||
*Another World* silhouette. The runtime does not rasterise polygons; the
|
||||
host rasterises the simplified polygons back to the 640×512 grid and decomposes
|
||||
each dirty tile into rectangles. Rectangles are what `FILL L` paints fastest
|
||||
and most simply. The player is a rectangle decoder, not a polygon engine.
|
||||
|
||||
### 6.4 Why not `LINE`?
|
||||
|
||||
`LINE` costs 5 states/pixel (≈800 ns/pixel), while `FILL` costs ~25 ns/pixel
|
||||
on the unit. Filling a polygon with `LINE` outlines would be 30× slower and
|
||||
would not fill the interior. `LINE` is reserved for an optional debug view
|
||||
that draws polygon outlines; the animation uses `FILL L` only. The request
|
||||
said "FILL and/or LINE"; this is the FILL branch, for a measured reason.
|
||||
|
||||
## 7. Pipeline
|
||||
|
||||
```
|
||||
hd60.mp4
|
||||
│ ffmpeg: first 30 s, 60 fps, 640×512 gray
|
||||
▼
|
||||
gray frames ──► temporal median (3) ──► 3×3 blur ──► threshold
|
||||
│
|
||||
▼
|
||||
binary frames ──► potrace -a 0 (straight polygons) ──► RDP simplify
|
||||
│
|
||||
▼
|
||||
simplified polygons ──► rasterise to 640×512 binary
|
||||
│
|
||||
▼
|
||||
tile diff (32×32 logical) ──► per-tile background + rectangles
|
||||
│
|
||||
▼
|
||||
movie.bin + index + manifest.json
|
||||
│
|
||||
▼
|
||||
player payload (TMS34010 C + FILL asm)
|
||||
```
|
||||
|
||||
### 7.1 Extract and stylise
|
||||
|
||||
* `ffmpeg -t 30 -vf fps=60,scale=640:512:flags=lanczos,format=gray`.
|
||||
* **Temporal median over 3 frames** before threshold. This is the main defence
|
||||
against dither flicker and per-frame contour boiling; it is cheap on the
|
||||
host and does not cost the player anything.
|
||||
* **3×3 box/Gaussian blur** then threshold at 127. This removes halftone
|
||||
patterns and turns them into flat regions, as requested.
|
||||
* **Despeckle:** drop connected components below ~8 logical pixels.
|
||||
* **Trace:** `potrace -b geojson -a 0 -t 4 -u 1`, which emits straight-line
|
||||
polygons only (`-a 0` disables curves). Parse the GeoJSON rings.
|
||||
* **Simplify:** Ramer–Douglas–Peucker on each ring, epsilon 1.5 logical pixels
|
||||
(tunable), minimum segment 2 px. This is the "straight lines" step.
|
||||
* **Rasterise:** even-odd scanline fill of the simplified rings back to
|
||||
640×512. The result is the frame the player will actually show.
|
||||
|
||||
All parameters live in one `pipeline/params.json` so the look can be tuned
|
||||
without touching code. A `preview` command writes side-by-side PNGs of source,
|
||||
thresholded, traced and final frames.
|
||||
|
||||
### 7.2 Tile-delta format
|
||||
|
||||
The logical frame is divided into **32×32 logical tiles** (64×64 screen
|
||||
pixels): 20 × 16 = **320 tiles**, row-major.
|
||||
|
||||
For each frame, compare with the previous frame. For every tile with any
|
||||
changed pixel:
|
||||
|
||||
* choose the tile's **background colour** (black or white) — the majority
|
||||
colour in that tile;
|
||||
* decompose the **minority colour** into rectangles (merge identical
|
||||
horizontal runs vertically);
|
||||
* emit the tile index, the background bit, the rectangle count, and the
|
||||
rectangles.
|
||||
|
||||
The player paints a dirty tile by:
|
||||
|
||||
1. `FILL` the whole 64×64 screen tile with the background colour (one call);
|
||||
2. `FILL` each rectangle with the opposite colour.
|
||||
|
||||
This reconstructs the tile exactly, including erasing old ink. Unchanged
|
||||
tiles are not touched. The background choice keeps mostly-black tiles cheap
|
||||
(one fill, few white holes).
|
||||
|
||||
**Record layout (little-endian):**
|
||||
|
||||
```
|
||||
header:
|
||||
magic "BAPL" 4
|
||||
version u16
|
||||
logical_width u16 (640)
|
||||
logical_height u16 (512)
|
||||
scale u16 (2)
|
||||
tile_logical u16 (32)
|
||||
tiles_x, tiles_y u16 (20, 16)
|
||||
frame_count u32
|
||||
index_offset u32
|
||||
data_offset u32
|
||||
source_fps u16 (60)
|
||||
display_hz u16 (72)
|
||||
|
||||
index[frame_count]:
|
||||
offset u32 (byte offset of the record)
|
||||
ticks u8 (1 = 1/60 s, 2 = held for 2/60 s, ...)
|
||||
flags u8 (bit 0: scene cut / full repaint)
|
||||
|
||||
record:
|
||||
dirty_tile_count u16
|
||||
for each dirty tile:
|
||||
tile_index u16
|
||||
bg_and_count u16 (bit 15 = background colour, bits 0-14 = rect count)
|
||||
rects[count]:
|
||||
x0:5 y0:5 w:5 h:5 packed into 3 bytes
|
||||
(logical tile coordinates; w,h are stored as w-1,h-1 so 1..32 fits in 5 bits)
|
||||
```
|
||||
|
||||
Rectangles use the minority colour; the tile background is the majority. A
|
||||
rect count of 0 is legal (a solid tile). The count is 15 bits, so the
|
||||
theoretical maximum of 512 horizontal runs in a 32×32 tile cannot overflow;
|
||||
the decoder rejects a count larger than the remaining record bytes.
|
||||
|
||||
**Sizing:** 5 bytes of tile header + 3 bytes per rect. The measured mean is
|
||||
1.55 KB/frame, total 2.79 MB for 1,800 frames. A scene-cut record with 295
|
||||
tiles and ~1,300 rects is ~5.6 KB, still one record.
|
||||
|
||||
### 7.3 Frame-rate adaptation
|
||||
|
||||
The encoder estimates each record's cost from the tile and rectangle areas
|
||||
(at 0.40 µs per 16-pixel word), the per-`FILL` call overhead (~3 µs), and the
|
||||
C decoder's per-rect work. The calibration constants come from milestone 3;
|
||||
the initial values are 0.40 µs/word, 3 µs/call and 1 µs/rect. The gate is
|
||||
**13.9 ms** — one 72 Hz refresh — not the 16.7 ms nominal 60 fps period,
|
||||
because a record that misses 13.9 ms loses a whole refresh on the 72 Hz panel.
|
||||
|
||||
If a record exceeds the gate:
|
||||
|
||||
* first try stronger simplification for that frame (larger RDP epsilon,
|
||||
larger minimum feature);
|
||||
* if it still exceeds the gate, **hold the previous frame for an extra
|
||||
1/60 s** and compute the next delta from the held frame. The index `ticks`
|
||||
field records the hold.
|
||||
|
||||
This is build-time frame dropping, so the delta chain stays exact. The player
|
||||
never skips a record at runtime: every record is a delta from the one before,
|
||||
so skipping one would leave its tiles stale. If the real unit is slower than
|
||||
the encoder's estimate, the movie **stretches** — every frame is still shown,
|
||||
in order, with the correct picture — rather than dropping or corrupting
|
||||
frames. That is the safe failure mode; the encoder is re-tuned from the unit's
|
||||
measured cost if the stretch is noticeable.
|
||||
|
||||
### 7.4 Scene cuts
|
||||
|
||||
A large frame-to-frame difference is a cut, not motion. The encoder marks the
|
||||
record `flags & 1`, and emits every tile as dirty. The player repaints the
|
||||
screen tile by tile. This costs one slow frame (~30–40 ms) and may drop the
|
||||
next refresh; that is the documented price of a cut. Future work could split a
|
||||
cut over two frames if it looks bad.
|
||||
|
||||
## 8. Player
|
||||
|
||||
### 8.1 Memory layout
|
||||
|
||||
| region | byte address | use |
|
||||
|---|---|---|
|
||||
| `0x000000`–`0x00FFFF` | 64 KB | low staging / scratch; free after load |
|
||||
| `0x010000`–`0x317FFF` | 3.03 MB | movie data (`movie.bin`) |
|
||||
| `0x318000`–`0x3FFFFF` | 0.9 MB | payload code, LANCE block, read-only tables, stack |
|
||||
| VRAM `0x04000000` (bit) | 256 KB | single framebuffer (separate) |
|
||||
|
||||
`0x018C0000` is a **bit** address = byte `0x318000`, the low end of the high
|
||||
code block. The existing loader's origin is reused so `rdebug` needs no new
|
||||
address. The movie data starts at byte `0x010000` and is loaded over the
|
||||
network after takeover; it overwrites the OS, which is dead by then.
|
||||
|
||||
The LANCE block (init block, rings, RX buffers) lives in the high code block,
|
||||
following `harness/udpstream/vidstream.c`, which places its RX buffers near
|
||||
byte `0x3BC000` and is documented as having run on the unit. The 64 KB low
|
||||
reservation is a **fallback staging area** if the real chip turns out to
|
||||
truncate a high RX buffer address; the loader then receives low and copies
|
||||
up. Milestone 5 settles this before the full 30 s run. It is a loader change,
|
||||
not a player change.
|
||||
|
||||
### 8.2 Takeover
|
||||
|
||||
The payload is entered from `rdebug`. It:
|
||||
|
||||
1. publishes a marker word;
|
||||
2. executes `DINT` once, for good, and stops the OS's LANCE use;
|
||||
3. takes the LANCE over with the verified init/ring sequence and receives
|
||||
`movie.bin` into `0x010000` (the OS is already dead; the display controller
|
||||
keeps scanning out because it was configured before takeover);
|
||||
4. verifies the movie header and a whole-file checksum;
|
||||
5. reads the display registers the OS programmed (`DPYCTL`, `DPYSTRT`,
|
||||
`DPYINT`, pitch) and publishes them for diagnosis; it inherits the mode
|
||||
rather than hardcoding one;
|
||||
6. clears the screen to white with one full-screen `FILL`;
|
||||
7. enters the playback loop.
|
||||
|
||||
Interrupts stay disabled. The OS's timer and DUART ISRs are never entered
|
||||
again. The LANCE is stopped after the load; the player no longer needs it.
|
||||
**The OS dies before the first byte lands in low RAM** — that is what makes
|
||||
overwriting its heap and ISRs safe. The earlier wording, "receive while the
|
||||
OS is alive, then DINT", was wrong; it would crash mid-load.
|
||||
|
||||
**Recovery:** a hang after step 2 is a power cycle. The README says so in the
|
||||
first screenful.
|
||||
|
||||
### 8.3 Loader
|
||||
|
||||
Two paths, same payload:
|
||||
|
||||
* **MAME / development:** a Lua script writes `movie.bin` into MAME's RAM at
|
||||
byte `0x010000` and the payload at `0x318000`, then jumps. Slow but fine
|
||||
for 5 s slices.
|
||||
* **Real unit / full 30 s:** `rdebug` writes a small bootstrap into the high
|
||||
block. The bootstrap disables interrupts, takes the LANCE over with the
|
||||
proven register/ring sequence from `harness/udpstream` (copied, not edited),
|
||||
receives `movie.bin` in 1,450-byte datagrams into `0x010000`, ACKs every
|
||||
window, and jumps to the player entry. The host sender is a small Python
|
||||
tool using the same raw-Ethernet framing as `harness/badapple` /
|
||||
`harness/udpstream`.
|
||||
|
||||
The baseline places the RX buffers in the high code block, as
|
||||
`vidstream.c` does. If the real chip truncates a high buffer address, the
|
||||
fallback receives into a 32 KB ring below 64 KB and copies each datagram up
|
||||
with `MOVE`/word copy. That copy rate is measured in milestone 5; it is a
|
||||
loader-only fallback, not the baseline.
|
||||
|
||||
The protocol is deliberately simple: `u32 offset`, `u16 length`, `u16 seq`,
|
||||
`u16 crc`, then data. The receiver ACKs the highest contiguous offset every
|
||||
32 datagrams; the sender retransmits from the first gap. A final whole-file
|
||||
CRC-32 is checked before the jump. If the load fails, the payload paints an
|
||||
error block and halts rather than jumping into a partial movie.
|
||||
|
||||
### 8.4 Rendering
|
||||
|
||||
All drawing goes through one assembly primitive:
|
||||
|
||||
```asm
|
||||
; fill_rect(x, y, w, h, color)
|
||||
; x,y,w,h in screen pixels (already scaled by 2)
|
||||
; color 0 = black, 0xFFFF = white
|
||||
```
|
||||
|
||||
It sets `DADDR`, `DYDX`, `DPTCH=0x800`, `PSIZE=1`, `PMASK=0`, `CONTROL`
|
||||
replace, and `COLOR1`, then executes `FILL L`. Constants are programmed once
|
||||
per run; only `DADDR`, `DYDX` and `COLOR1` change per call, and `FILL` destroys
|
||||
`DADDR`/`DYDX`, so those two are reloaded each time. This is the same
|
||||
implied-operand discipline as `harness/udpstream/vram.asm`, which is already
|
||||
verified on the unit.
|
||||
|
||||
The C player converts each packed tile rect to screen coordinates:
|
||||
|
||||
```
|
||||
sx = (tile_x*32 + x0) * 2
|
||||
sy = (tile_y*32 + y0) * 2
|
||||
sw = (w + 1) * 2 // stored as w-1
|
||||
sh = (h + 1) * 2 // stored as h-1
|
||||
```
|
||||
|
||||
and calls `fill_rect`. A dirty tile is one background fill plus its rects.
|
||||
|
||||
**Hoisting:** `PSIZE`, `PMASK`, `CONTROL`, `DPTCH` are set once at startup.
|
||||
`CONVDP`/`CONVSP` are not needed for `FILL L` (linear destination), but are
|
||||
set to `0x14` for consistency with the verified primitive.
|
||||
|
||||
### 8.5 Frame buffer and presentation
|
||||
|
||||
**Baseline: one framebuffer, incremental dirty-tile updates.** Drawing a
|
||||
record takes ~2–10 ms for a typical frame, a fraction of the 13.9 ms refresh.
|
||||
Updates are visible as they land; the TMS34010's VRAM is dual-ported, so the
|
||||
display does not stall the CPU. At 60 fps the eye usually reads this as
|
||||
motion, but a large change can show a brief top-to-bottom wipe.
|
||||
|
||||
**Tear-free is a separate mode, not free.** It needs either:
|
||||
|
||||
* **two framebuffers and a vblank flip.** The real panel uses a 256-byte row
|
||||
stride, so one 1280×1024 buffer is **256 KB**, not 160 KB; two are 512 KB.
|
||||
Whether the unit has that much VRAM is not established. It also doubles the
|
||||
fill work unless each delta is applied to both buffers, which costs the
|
||||
p95 frame its refresh.
|
||||
* **beam chasing.** Draw each dirty tile only after the scanout has passed it,
|
||||
so the new content appears on the next frame. This is tear-free with one
|
||||
buffer but needs `VCOUNT` scheduling and leaves one frame of latency.
|
||||
|
||||
Neither is in the baseline. **human:** incremental painting is acceptable for
|
||||
the prototype, so the baseline is one framebuffer and dirty-tile updates.
|
||||
Milestone 2 still measures VRAM capacity for the record, but a tear-free mode
|
||||
is not built unless the incremental look fails on the unit.
|
||||
|
||||
### 8.6 Pacing: 60 fps content on a 72 Hz panel
|
||||
|
||||
The player polls the TMS34010 display interrupt pending bit (`INTPEND` DI) at
|
||||
each vertical blank; interrupts remain disabled, so this is a poll, not an
|
||||
ISR. A 16.16 fixed-point accumulator advances the 60 Hz timeline:
|
||||
|
||||
```
|
||||
each vsync: now += 60/72
|
||||
if now >= next_frame_time:
|
||||
draw next record // never skip a record
|
||||
next_frame_time += record.ticks
|
||||
if now - next_frame_time > 1.0: // fell behind
|
||||
next_frame_time = now // slow down, do not skip
|
||||
```
|
||||
|
||||
If a record's paint overruns a vsync, the movie **stretches**: the next record
|
||||
is drawn as soon as the CPU is free, and the timeline is re-based to `now`
|
||||
rather than accumulating debt. Records are never skipped, because each one is
|
||||
a delta from the previous picture. This produces the correct average rate on
|
||||
a unit that keeps up, and slow motion — not corruption — on one that does
|
||||
not. The 5:6 cadence (five source frames per six refreshes) is visible only as
|
||||
the usual 60-on-72 judder; it is a hardware property, not a decoder stall.
|
||||
|
||||
If `INTPEND` polling proves unreliable in MAME, the fallback is a DUART counter
|
||||
programmed as a 60 Hz timebase, polled with interrupts off. That is a builder
|
||||
detail; the timeline arithmetic is unchanged.
|
||||
|
||||
## 9. Verification
|
||||
|
||||
### 9.1 Host pipeline tests
|
||||
|
||||
* Frame count, dimensions and LSB-first bit order (the harness's long-standing
|
||||
trap: an MSB-first packer mirrors every eight pixels).
|
||||
* Polygon round-trip: rasterised simplified polygons differ from the
|
||||
thresholded source only in the intended simplified regions; report PSNR and
|
||||
changed-pixel count, not a pass/fail on aesthetics.
|
||||
* Tile-delta round-trip: applying every record to a blank 640×512 canvas
|
||||
reproduces the encoded frame **bit for bit**.
|
||||
* Budget: total `movie.bin` ≤ 3.0 MB (the hard cap is 3.03 MB), p95 paint cost
|
||||
≤ 13.9 ms including the C decoder and `FILL` call overhead.
|
||||
* Manifest records the actual numbers (data size, dirty-tile distribution,
|
||||
paint-cost distribution, dropped frames).
|
||||
|
||||
### 9.2 MAME pixel verdict
|
||||
|
||||
The player publishes a marker page in high RAM:
|
||||
|
||||
```
|
||||
frame_index, ticks_played, last_record_bytes, paint_loops, data_error
|
||||
```
|
||||
|
||||
`VT1200_DUMP_RAM` and `VT1200_DUMP_VRAM` at the same MAME frame give both the
|
||||
marker and the picture. A checker reads the marker, reconstructs the expected
|
||||
logical frame from the encoder's records, downsamples the 2×2 VRAM to
|
||||
640×512, and compares **exactly**. A single wrong bit is a bug, not a PSNR.
|
||||
|
||||
Sample frames: 0, 60, 300, 600, 900, 1200, 1500, 1799, plus every scene cut.
|
||||
Also run a 5 s slice first; do not debug the full 30 s before one frame is
|
||||
exact.
|
||||
|
||||
### 9.3 MAME motion verdict
|
||||
|
||||
Run with `-window`, throttle at the emulated 72 Hz, and capture with
|
||||
`x11grab` as the existing `run-gui.sh` does. Check:
|
||||
|
||||
* no half-painted or stale regions after a cut;
|
||||
* no accumulating offset (a delta applied twice or skipped);
|
||||
* the 30 s ends on the expected last frame;
|
||||
* the paint-cost histogram matches the encoder's estimate.
|
||||
|
||||
MAME cannot prove the real unit's 60 fps: its TMS34010 interpreter has a
|
||||
different instruction/cycle mix from silicon. It proves correctness and gives a
|
||||
relative cost. The unit is the performance verdict.
|
||||
|
||||
### 9.4 Real unit
|
||||
|
||||
The existing `harness/badapple/demo.sh` boot gate (wait for the OS's ICMP
|
||||
reply, then load over rdebug) is the model. The new `bad-apple/load.sh`:
|
||||
|
||||
1. waits for the unit's stack;
|
||||
2. `rdebug` writes the bootstrap;
|
||||
3. jumps;
|
||||
4. sends `movie.bin` over the tap/interface;
|
||||
5. waits for the bootstrap's final ACK;
|
||||
6. observes the screen (there is no back-channel after the jump).
|
||||
|
||||
Record a video of the 30 s run and note the actual dropped frames. Recovery is
|
||||
a power cycle; say so before running.
|
||||
|
||||
## 10. Milestones
|
||||
|
||||
1. **Measure the stylised stream (host only).** Extract, vectorise, tile-diff,
|
||||
and report the numbers. Gate: ≤3.0 MB, p95 paint cost within 13.9 ms.
|
||||
Produce a preview PNG/MP4 of the vectorised look for approval.
|
||||
2. **One exact frame in MAME.** Build the payload with a single hardcoded
|
||||
record, paint it, dump VRAM, compare bit-exact. This proves `FILL` geometry,
|
||||
the 2×2 scaling, the test harness and the marker page.
|
||||
3. **Five-second player.** Pack a 5 s slice, play it, compare every sampled
|
||||
frame, and measure the paint-cost histogram. No loader yet; use the Lua
|
||||
path.
|
||||
4. **Full 30 s.** Pack 1,800 frames, play in MAME, tune simplification and
|
||||
frame drops until the data and motion gates pass.
|
||||
5. **Delivery.** Build the rdebug bootstrap + LANCE loader, run the same 30 s
|
||||
in MAME over the tap, then on the real unit.
|
||||
6. **Report.** `bad-apple/README.md` and a short report with the measured data
|
||||
size, paint-cost distribution, dropped frames, unit behaviour and the
|
||||
remaining gaps.
|
||||
|
||||
## 11. Risks
|
||||
|
||||
| risk | why it matters | mitigation |
|
||||
|---|---|---|
|
||||
| Potrace/RDP temporal jitter ("boiling") | looks like a glitch even when every frame is exact | 3-frame temporal median, spatial blur, minimum feature size; preview at 60 fps before committing to the full 30 s |
|
||||
| Vectorised data exceeds the 3.03 MB data area | no room for the 30 s | measured 2.79 MB before vectorisation; gate at 3.0 MB; fallback is 4×4 blocks or stronger simplification, not a shorter clip without asking |
|
||||
| Dirty tiles exceed the paint budget | frames hold or stretch | build-time `ticks` holds, adaptive simplification, per-frame cost estimate; report the distribution |
|
||||
| Incremental updates show a wipe | accepted for the prototype, but may look worse than expected on the unit | baseline is incremental by human decision; if it reads as a glitch, milestone 2's VRAM measurement feeds a double-buffered/beam-chasing variant |
|
||||
| The real unit is slower than the model | the movie stretches rather than drops | never skip a record; re-base the timeline; re-encode with stronger simplification from the measured unit cost |
|
||||
| LANCE loader loses datagrams | partial movie, dead machine | offsets + CRC + ACK/retransmit; whole-file CRC before jump; error block and halt instead of jumping |
|
||||
| Real C-LANCE high buffer addresses | loader cannot receive above 64 KB | baseline reuses `vidstream.c`'s high block, documented as run on the unit; fallback is a low staging ring plus copy, measured in milestone 5 |
|
||||
| 60 fps on 72 Hz | unavoidable judder | fixed-point 5:6 timeline; document it; do not call it a decoder bug |
|
||||
| OS video mode not configured | no picture after takeover | inherit and publish the OS registers; boot path is "wait for the OS, then take over" |
|
||||
| One demo per boot | a hang costs a power cycle | verify the image/checksum before jumping; README warning; no automatic retry after takeover |
|
||||
| MAME timing is not silicon | false confidence in 60 fps | correctness in MAME, performance measured on the unit; report both |
|
||||
|
||||
## 12. Left to the builder
|
||||
|
||||
* Exact RDP epsilon, minimum feature size, and temporal-filter width: tune by
|
||||
preview, then freeze in `params.json`.
|
||||
* Whether the tile background choice is per-tile majority or a cost-minimising
|
||||
search; both are host-side and cheap.
|
||||
* The precise `CONTROL` mask and hoisting split for `FILL L`; copy the verified
|
||||
`vram.asm` discipline and measure once on the unit.
|
||||
* Whether a short slice uses the Lua/debugger load or the same bootstrap; the
|
||||
full 30 s uses raw LANCE because the OS is dead before the data lands.
|
||||
* The marker page layout and the exact MAME dump procedure.
|
||||
* Whether the first 30 s starts at t=0 (assumed) or at a musically chosen
|
||||
offset; t=0 is the default.
|
||||
* Whether a stop/pause key is worth keeping. It would require one live ISR or
|
||||
polling the keyboard DUART; the default is no input, as the OS is dead.
|
||||
|
||||
## 13. Decisions from this session
|
||||
|
||||
* **human:** full screen, 1280×1024, first 30 s.
|
||||
* **human:** as close to `hd60.mp4` as the medium allows.
|
||||
* **human:** flat black-and-white polygons in the *Another World* spirit;
|
||||
drop dither and simplify.
|
||||
* **human:** 60 fps target, drops allowed for complex scenes.
|
||||
* **human:** the program may take the whole 4 MB; the OS may die; recovery is a
|
||||
hardware restart.
|
||||
* **human:** bootstrap over `rdebug`; the existing network loader may be used
|
||||
to speed the big load.
|
||||
* **human:** new top-level `bad-apple/` folder.
|
||||
* **human:** incremental dirty-tile painting is acceptable; a brief wipe on a
|
||||
large change is not a failure for this prototype.
|
||||
* **human:** stretch the 4:3 source to the 5:4 panel; keep all content.
|
||||
* **call:** 640×512 logical grid (2×2 blocks), 32×32 logical tiles.
|
||||
* **call:** `FILL`-only runtime; `LINE` reserved for debug outlines.
|
||||
* **call:** 60 Hz timeline on a 72 Hz panel, with build-time `ticks` holds.
|
||||
* **call:** 30 s from t=0.
|
||||
* **call:** a `--fit crop` switch remains available for comparison, but stretch
|
||||
is the default.
|
||||
Reference in new issue
Block a user