|
| 1 | +# Frontend implementation brief: native CRT video v2 (zaparoo-launcher) |
| 2 | + |
| 3 | +**Audience:** the zaparoo-launcher team / an implementation agent with no prior |
| 4 | +context. This document is self-contained; `docs/native-video-plan.md` (same |
| 5 | +repo) has the full background and rationale if you want it. |
| 6 | +**Counterpart:** Menu_MiSTer fork, branch `fix/native-video-centering` — the |
| 7 | +FPGA side of everything below is implemented, simulated, and pushed. The |
| 8 | +launcher work in this brief is the only remaining piece. |
| 9 | +**Existing code this modifies:** `src/app/native_video_writer.cpp` and the |
| 10 | +`--crt` startup path in zaparoo-launcher (see also its `docs/native-core-poc.md`). |
| 11 | + |
| 12 | +--- |
| 13 | + |
| 14 | +## 1. What changed and why you're doing this |
| 15 | + |
| 16 | +The menu core no longer outputs a 320x240 picture with hand-tuned porches, and |
| 17 | +it no longer has any OSD video options. It now generates broadcast-standard |
| 18 | +15 kHz timing in three modes, and **everything the launcher used to rely on |
| 19 | +the OSD for (CRT mode on/off, H/V centering) now travels through the DDR |
| 20 | +control block you already write**. Key consequences for the app: |
| 21 | + |
| 22 | +- The framebuffer is now **352x240** (not 320x240). 352 px fills a standard |
| 23 | + NTSC/PAL active line edge-to-edge; the old 320 was ~10% too narrow on every |
| 24 | + correctly calibrated CRT. |
| 25 | +- The picture now *overscans* like broadcast TV: the outer few percent of the |
| 26 | + framebuffer is cropped on most sets. The UI must adopt safe-area rules |
| 27 | + (section 5) — this is as much a part of the fix as the FPGA work. |
| 28 | +- There is no "CRT mode" toggle anywhere. **Publishing frames IS the mode |
| 29 | + switch**: the core shows its noise pattern until your control word goes |
| 30 | + live and reverts when you zero it. |
| 31 | +- Two new modes exist when you're ready for them: **720x480i60** (mode 1) and |
| 32 | + **352x288p50 PAL** (mode 2). The core side is done; you opt in per-frame |
| 33 | + via the mode field. |
| 34 | + |
| 35 | +Backward compatibility is handled on the core side: an old launcher writing |
| 36 | +the legacy 320x240 layout still displays (centered with 16-px black side |
| 37 | +bars), and your existing fb-geometry validation already self-disables the |
| 38 | +writer against an old core. Ship order doesn't matter. |
| 39 | + |
| 40 | +## 2. DDR contract v2 (normative) |
| 41 | + |
| 42 | +Physical base `0x3A000000`, mmap **0x300000** (3 MB, up from 640 KB). |
| 43 | + |
| 44 | +| Offset | Contents | |
| 45 | +|---|---| |
| 46 | +| `+0x0` | **word0**: `(frame_counter << 2) \| active_buffer`. Bit 1 reserved, write 0. `0` means "writer stopped". | |
| 47 | +| `+0x4` | **word1**: `[31:16]` magic `0x5A50` ("ZP"); `[15:8]` h_offset, signed int8, pixels, + = right; `[7:4]` v_offset, signed 4-bit, lines, + = down; `[3:0]` mode | |
| 48 | +| `+0x1000` | buffer 0 | |
| 49 | +| `+0x180000` | buffer 1 | |
| 50 | + |
| 51 | +Modes: `0` = 352x240 @ 60p (NTSC, default), `1` = 720x480 @ 60i, |
| 52 | +`2` = 352x288 @ 50p (PAL). Stride is always tight (`width * 4` bytes). |
| 53 | +Pixel format is unchanged: memcpy linuxfb BGRX rows as-is; the core swaps |
| 54 | +bytes in RTL. |
| 55 | + |
| 56 | +Per-mode framebuffer numbers: |
| 57 | + |
| 58 | +| Mode | fb size | stride | frame bytes | |
| 59 | +|---|---|---|---| |
| 60 | +| 0 | 352x240 | 1408 | 0x52800 (337 920) | |
| 61 | +| 2 | 352x288 | 1408 | 0x63000 (405 504) | |
| 62 | +| 1 | 720x480 | 2880 | 0x151800 (1 382 400) | |
| 63 | + |
| 64 | +Protocol rules: |
| 65 | + |
| 66 | +1. **Init:** write word1 (magic + mode + saved offsets) **before** the first |
| 67 | + word0 publish. The core reads both words in one atomic 64-bit beat once |
| 68 | + per vblank, so word1-then-word0 ordering guarantees the first frame is |
| 69 | + interpreted correctly. |
| 70 | +2. **Publish:** render into the inactive buffer, then write word0 once with |
| 71 | + the incremented counter and that buffer's index (single 32-bit store — |
| 72 | + this is the atomic commit). Counter is 30 bits, start at 1. |
| 73 | +3. **Mode/offset change at runtime:** update word1 first, then bump word0. |
| 74 | + The core latches mode and offsets at the field boundary; modes 0↔1 keep |
| 75 | + the same line rate (instant re-lock), 0/1↔2 is a 50↔60 Hz retune (the CRT |
| 76 | + takes a moment, like real hardware). |
| 77 | +4. **Stop:** zero word0 (zero word1 too for tidiness). The core reverts to |
| 78 | + its noise pattern within one frame. This is also your crash-recovery |
| 79 | + story — if the launcher dies and the words go stale, the core keeps |
| 80 | + scanning the last frame; only a zeroed word0 releases it, so keep the |
| 81 | + existing stop-handler behavior. |
| 82 | +5. **Offsets:** the core honors **−8…+8 px** horizontal, **−8…+2 lines** |
| 83 | + vertical, and clamps anything outside (a garbage word1 degrades to a |
| 84 | + saturated shift, never broken sync). Don't rely on the clamp — keep the |
| 85 | + calibration UI within those ranges. |
| 86 | +6. **480i is rendered progressive:** publish one normal 720x480 frame; the |
| 87 | + core extracts fields itself (reads source line `2*line + field`). No |
| 88 | + field splitting, no half-frame timing on the ARM side. |
| 89 | + |
| 90 | +## 3. Task 1 — Phase A (required): 352x240 writer + safe-area UI |
| 91 | + |
| 92 | +This is the must-ship piece; modes 1 and 2 are follow-ups. |
| 93 | + |
| 94 | +1. `--crt` startup sets fb0 to **352x240 32bpp** (the `vmode -r 352 240 |
| 95 | + rgb32` equivalent of the current 320x240 setup). Update the fb-geometry |
| 96 | + validation to expect 352x240. |
| 97 | +2. Update writer constants: width 352, stride 1408, frame size 0x52800, |
| 98 | + buffers at `+0x1000` / `+0x180000`, mmap 0x300000. |
| 99 | +3. Write word1 on init: magic `0x5A50`, mode 0, offsets from launcher config |
| 100 | + (default 0/0). Clear both words on stop. |
| 101 | +4. UI safe-area pass (section 5). |
| 102 | +5. Calibration screen (section 6). |
| 103 | + |
| 104 | +Acceptance: on hardware with the new core, the launcher UI fills a CRT |
| 105 | +edge-to-edge; killing the launcher returns the noise pattern; a capture |
| 106 | +device reports 15.734 kHz / 240p. |
| 107 | + |
| 108 | +## 4. Tasks 2 & 3 — PAL and 480i (when ready) |
| 109 | + |
| 110 | +**PAL (mode 2):** add a "video standard: NTSC / PAL" user setting. PAL |
| 111 | +renders **352x288** and publishes mode 2. Note most PAL sets accept 60 Hz |
| 112 | +RGB over SCART ("PAL-60"), so mode 0 remains a fine default in PAL regions; |
| 113 | +mode 2 is for strict-50 Hz sets and correct-speed feel. |
| 114 | + |
| 115 | +**480i (mode 1):** add a 720x480 rendering path and (optionally) per-screen |
| 116 | +mode selection — e.g. main UI in 240p, text-heavy screens in 480i. |
| 117 | +Flicker discipline is mandatory (section 5, rule 4). |
| 118 | + |
| 119 | +## 5. UI rendering rules (apply to every mode) |
| 120 | + |
| 121 | +These are not suggestions; geometry alone doesn't fix "every CRT crops |
| 122 | +differently": |
| 123 | + |
| 124 | +1. **Render full-bleed.** Background art/color must reach all four edges. |
| 125 | + The outer few percent will be cropped on most sets and visible on a few — |
| 126 | + both must look intentional. |
| 127 | +2. **Safe areas** (SMPTE SD practice): |
| 128 | + - *Action safe* (all interactive/meaningful content): central **90%** — |
| 129 | + ~317x216 of 352x240, ~317x259 of 352x288, ~648x432 of 720x480. |
| 130 | + - *Title safe* (text that must be readable): central **80%** — |
| 131 | + ~282x192 / ~282x230 / ~576x384. |
| 132 | +3. **Pixel aspect ratio is 10:11** (pixels ~9% narrower than square) in all |
| 133 | + three modes. Ignorable for boxes-and-text; correct for logos/art that |
| 134 | + must not look squished (a true circle needs ~10% more width in pixels). |
| 135 | +4. **480i flicker discipline:** every scanline repaints 30x/second, so 1-px |
| 136 | + horizontal lines and fine text shimmer. Use ≥2 px horizontal strokes, |
| 137 | + avoid hard 1-px horizontal edges, or apply a mild vertical blur (the |
| 138 | + standard console-era 480i dashboard trick). Existing CRT typography rules |
| 139 | + in `native-core-poc.md` (integer snapping, bitmap fonts) stay in force. |
| 140 | + |
| 141 | +## 6. Calibration screen |
| 142 | + |
| 143 | +The launcher now owns centering (the OSD options are gone): |
| 144 | + |
| 145 | +- Draw a border test pattern (240p-test-suite style: 1-px frame at the |
| 146 | + extreme edge, rectangles at the 90% and 80% safe areas, cross-hatch). |
| 147 | +- Arrow keys nudge h_offset (−8…+8, 1-px steps) and v_offset (−8…+2), |
| 148 | + publishing word1 live so the user sees the picture move in real time. |
| 149 | +- Persist the values in launcher config; load them at init. Defaults are |
| 150 | + zero — the standard timing is the centering mechanism, trims only |
| 151 | + compensate for miscentered sets. |
| 152 | + |
| 153 | +## 7. Verification checklist (frontend-visible items) |
| 154 | + |
| 155 | +- Fill/centering on **2–3 different CRTs** plus a capture device (should |
| 156 | + report 15.734 kHz exactly; 480i should be detected as 480i, not 240p). |
| 157 | +- Writer-stop: kill the launcher → noise pattern returns. |
| 158 | +- Trim screen: live nudge both axes; values survive a restart; out-of-range |
| 159 | + values (if forced) shift-and-saturate without disturbing sync. |
| 160 | +- Compat matrix: old launcher + new core → centered 320x240 with side bars; |
| 161 | + new launcher + old core → writer self-disables via fb-geometry validation, |
| 162 | + core shows noise (obvious, not subtle, breakage). |
| 163 | +- 480i: fine horizontal lines should shimmer, not stack (no line pairing). |
| 164 | +- HDMI output still locks in every mode (the core's ascal path handles it; |
| 165 | + just confirm). |
| 166 | + |
| 167 | +## 8. Reference |
| 168 | + |
| 169 | +- FPGA-side spec and rationale: `docs/native-video-plan.md` (Menu_MiSTer). |
| 170 | +- RTL that consumes this contract: `rtl/native_video_reader.sv` (the word1 |
| 171 | + parse and buffer addresses are the source of truth, with simulation |
| 172 | + coverage in `tb/native_video_reader_tb.sv`). |
| 173 | +- Current writer: `src/app/native_video_writer.cpp` (zaparoo-launcher). |
| 174 | +- Why the scaler is bypassed: `docs/native-core-poc.md` (zaparoo-launcher). |
0 commit comments