pico9918-core 1.3.0
TMS9918A / F18A video display processor emulation in C99
Loading...
Searching...
No Matches
golden.c
Go to the documentation of this file.
1/**
2 * \file
3 * \brief pico9918-core - byte-exact frame dumps, and the comparison against them
4 *
5 * Copyright (c) 2026 Troy Schrapel
6 *
7 * This code is licensed under the MIT license
8 *
9 * https://github.com/visrealm/pico9918-core
10 *
11 * Drives fixed register/VRAM scenes through the public bus API and captures
12 * the indexed output of pico9918_scan_line() plus its returned status byte,
13 * one golden file per scene, followed by a dump of the 64 live palette RAM
14 * entries (read back through the public bus - see dumpPalette). Compare mode
15 * recomputes every scene and diffs byte-for-byte against the committed
16 * goldens.
17 *
18 * Each line also carries two post-palette digests, so the palette -> pixel
19 * conversion in pico9918_palette.c is observable: see "Post-palette surfaces".
20 *
21 * Usage:
22 * golden_runner compare against goldens (nonzero exit on FAIL)
23 * golden_runner --capture rewrite the goldens
24 * golden_runner --data DIR override the golden data directory
25 *
26 * All scene content is generated by a fixed-seed LCG - no rand(), no time.
27 */
28
29#include "pico9918.h"
30
31/* The post-palette surfaces need the LUT and the expansion macro, which are
32 * impl surface, not public API (see the "INTERIM PRIVILEGED SURFACE" note in
33 * pico9918_priv.h). The harness is in-tree, so it takes the same view of
34 * the library the firmware does. */
35#include "impl/pico9918_priv.h"
36
37#include <stdio.h>
38#include <stdlib.h>
39#include <stdint.h>
40#include <string.h>
41#include <stdbool.h>
42
43#ifndef GOLDEN_DATA_DIR
44#define GOLDEN_DATA_DIR "data"
45#endif
46
47#define GOLDEN_MAGIC "TMSG"
48#define GOLDEN_VERSION 3
49#define GOLDEN_NAME_LEN 32
50#define GOLDEN_BYTES_PER_LINE TMS9918_PIXELS_X /* 256 indexed pixels */
51#define GOLDEN_PAL_ENTRIES 64 /* pram entries per scene */
52#define GOLDEN_PAL_BYTES (GOLDEN_PAL_ENTRIES * 2)
53
54/* post-palette digests per line: host-native expansion, then Pico BGR16 */
55#define GOLDEN_DIGESTS_PER_LINE 2
56#define GOLDEN_DIGEST_BYTES (GOLDEN_DIGESTS_PER_LINE * 8)
57
58/* PICO9918_SR0_5S / PICO9918_SR0_COLLISION come from pico9918.h. These two are
59 * observations of what the library reported, not part of the reference model - the
60 * model restates its own bits below and must keep doing so. */
61
62/* Storage for the deterministic clock the golden build injects in place of the
63 * library's wall clock. goldenClock.h is force-included into every TU of this
64 * build (library and harness alike) and declares this extern plus the inline
65 * tick that PICO9918_HOST_TIME_US() expands to; the single definition lives here,
66 * in the harness, because the library must carry no test state. */
67uint32_t goldenClockNow = 0;
68
69/* goldenHostOps.h's recording of PICO9918_HOST_STATUS_VISIBLE(); see that header. */
70uint8_t goldenPublishedStatus = 0;
71uint32_t goldenPublishCount = 0;
72
73/* ---------------------------------------------------------------------------
74 * Deterministic pseudo-random source (numerical recipes LCG)
75 * ------------------------------------------------------------------------- */
76static uint32_t lcgState;
77
78static void lcgSeed(uint32_t seed)
79{
80 lcgState = seed;
81}
82
83static uint8_t lcgByte(void)
84{
85 lcgState = lcgState * 1664525u + 1013904223u;
86 return (uint8_t)(lcgState >> 24);
87}
88
89/* ---------------------------------------------------------------------------
90 * Bus helpers - the exact write paths the firmware uses (two-stage writes to
91 * the address port, data-port writes with auto-increment)
92 * ------------------------------------------------------------------------- */
93static void regWrite(uint8_t reg, uint8_t value)
94{
96 pico9918_write_addr(0x80 | reg);
97}
98
99static void vramSetWriteAddr(uint16_t addr)
100{
101 pico9918_write_addr(addr & 0xff);
102 pico9918_write_addr(0x40 | ((addr >> 8) & 0x3f));
103}
104
105/* set a read address: bit 6 clear in the second stage triggers the
106 * read-ahead fetch (and its address auto-increment) */
107static void vramSetReadAddr(uint16_t addr)
108{
109 pico9918_write_addr(addr & 0xff);
110 pico9918_write_addr((addr >> 8) & 0x3f);
111}
112
113static void vramFillLcg(uint16_t addr, int count)
114{
115 vramSetWriteAddr(addr);
116 while (count--) pico9918_write_data(lcgByte());
117}
118
119static void vramFillByte(uint16_t addr, uint8_t value, int count)
120{
121 vramSetWriteAddr(addr);
122 while (count--) pico9918_write_data(value);
123}
124
125static void vramWriteBytes(uint16_t addr, const uint8_t* bytes, int count)
126{
127 vramSetWriteAddr(addr);
128 while (count--) pico9918_write_data(*bytes++);
129}
130
131/* real F18A unlock sequence: VR57 = 0x1c written twice */
132static void unlockF18a(void)
133{
134 regWrite(57, 0x1c);
135 regWrite(57, 0x1c);
136}
137
138/* write count palette entries through the data-port palette mode (VR47).
139 * exercises the two-stage palette write path; palette values do not affect
140 * the indexed scanline output but ARE captured by the per-scene pram dump. */
141static void paletteWriteLcg(int count)
142{
143 regWrite(47, 0xc0); /* data port palette mode, auto-increment, index 0 */
144 while (count--)
145 {
146 pico9918_write_data(lcgByte() & 0x0f); /* 0R */
147 pico9918_write_data(lcgByte()); /* GB */
148 }
149 regWrite(47, 0x00); /* leave palette mode */
150}
151
152/* ---------------------------------------------------------------------------
153 * Scene lifecycle
154 * ------------------------------------------------------------------------- */
155static void sceneBegin(void)
156{
158
159 /* pico9918_reset() resets the cached display mode to Graphics I, but the
160 * cache is only re-read on a mode CHANGE seen by pico9918_scan_line. Nudge
161 * the mode register through Graphics II and back so it is re-resolved against
162 * the now-locked state, making every scene independent of the one before it. */
163 regWrite(0, 0x02);
165 regWrite(0, 0x00);
167}
168
169/* Read the 64 live palette RAM entries (128 bytes) out through the public
170 * bus API.
171 *
172 * The library exposes no palette read function, and every CPU-side read path
173 * (data port, pico9918_vram_value) masks addresses to the 16K window
174 * (0x3fff), so palette RAM at internal address 0x5000 cannot be read
175 * directly. It IS reachable through the bitmap layer: the BML fetch address
176 * (VR32 << 6) + row * width is not masked to 16K, so a width-64 opaque 2bpp
177 * BML with VR32 = 0xff fetches bytes 0x5000..0x503f on scanline 65 and
178 * 0x5040..0x507f on scanline 66 - the live pram - and emits each byte as
179 * four 2-bit pixels (MSBs first). With both tile layers and sprites disabled
180 * the scanline output is a byte-exact public read-out of the palette.
181 *
182 * Runs AFTER the scene's lines have been captured (it reprograms registers).
183 * Everything here is deterministic, and every scene starts from
184 * sceneBegin()'s reset, so no state leaks into the next scene. */
185static void dumpPalette(uint8_t out[GOLDEN_PAL_BYTES])
186{
187 unlockF18a();
188 regWrite(0, 0x00); /* Graphics I */
189 regWrite(1, 0x40); /* display on */
190 regWrite(7, 0x00);
191 regWrite(49, 0x00); /* T2 off, no ECM, no 30-row */
192 regWrite(50, 0x10); /* tile layer 1 off */
193 regWrite(51, 0x00); /* sprites to process: 0 */
194 regWrite(31, 0x80); /* BML: enabled, opaque, 2bpp, palette bits 0 */
195 regWrite(32, 0xff); /* BML fetch base 0x3fc0 */
196 regWrite(33, 0x00); /* BML x = 0 */
197 regWrite(34, 0x00); /* BML top = 0 */
198 regWrite(35, 0x00); /* BML width = 64 bytes (256px, full line) */
199 regWrite(36, 0xff); /* BML height = 255 */
200
201 for (int line = 0; line < 2; ++line)
202 {
203 pico9918_scan_line((uint16_t)(65 + line));
204 const uint8_t* probe = pico9918_line_source();
205 uint8_t* dst = out + line * 64;
206 for (int i = 0; i < 64; ++i)
207 {
208 const uint8_t* p = probe + i * 4;
209 dst[i] = (uint8_t)((p[0] << 6) | (p[1] << 4) | (p[2] << 2) | p[3]);
210 }
211 }
212}
213
214/* ---------------------------------------------------------------------------
215 * Post-palette surfaces (format v3)
216 *
217 * The indexed scanline output is palette-independent, so nothing above this point
218 * can observe the palette -> pixel conversion the library owns
219 * (pico9918_palette.c). Each line is therefore ALSO expanded through the library's
220 * own path and digested:
221 *
222 * surface 0 "lib" pico9918_palette_regenerate() + PICO9918_EXPAND_INDEXED,
223 * i.e. the library's own path. One pixel policy ships and it
224 * is the platform default, so this surface is the pixel
225 * stream the device emits.
226 * On its own this is self-capture: it pins the library
227 * against itself and proves little.
228 *
229 * surface 1 "ref" the SAME indexed bytes and the SAME pram, expanded by
230 * picoReferenceExpand() below - an INDEPENDENT
231 * reimplementation of the documented pack formula and LUT
232 * classing, written from the prose spec and deliberately
233 * NOT calling PICO9918_PIXEL_FROM_RGB12 or
234 * PICO9918_EXPAND_INDEXED. This is the evidence that the
235 * formula is right rather than merely stable.
236 *
237 * Both surfaces are stored as 64-bit FNV-1a digests rather than raw pixels:
238 * raw would be 1 KB per line (~3.5 MB of committed binaries) for no extra
239 * diagnostic power, since a digest mismatch is localised to the exact pixel
240 * by re-scanning the two buffers in memory (see reportPostPalette).
241 *
242 * Cross-check: the two surfaces now share a format, so they are compared
243 * value-for-value on every line, every scene - any divergence between the
244 * library's expansion and the independent reference fails the run before the
245 * digests are even consulted. Their digests are stored SEPARATELY all the
246 * same, so a change that moved both in lockstep (an edit to the reference
247 * included) still shows up as a golden diff.
248 * ------------------------------------------------------------------------- */
249
250/* 64-bit FNV-1a */
251#define FNV64_OFFSET 0xcbf29ce484222325ull
252#define FNV64_PRIME 0x00000100000001b3ull
253
254static uint64_t fnv1a(const void* data, size_t len)
255{
256 const uint8_t* p = (const uint8_t*)data;
257 uint64_t h = FNV64_OFFSET;
258 while (len--)
259 {
260 h ^= *p++;
261 h *= FNV64_PRIME;
262 }
263 return h;
264}
265
266/* Independent reference for the pixel pack.
267 *
268 * Deliberately NOT PICO9918_PIXEL_FROM_RGB12 - this is the documented formula
269 * rewritten from its prose description, so that breaking the macro diverges
270 * from this and is caught:
271 *
272 * a pram entry is byte-swapped RGB444 (0xGB0R): bits 15-12 = green,
273 * 11-8 = blue, 7-4 = alpha-or-zero, 3-0 = red. The transform clears bits 7-4
274 * (stripping the alpha the palette-reset path deposits there) and copies
275 * GREEN down into them; blue and red stay put. Output is BGR12 in the low 12
276 * bits, with a dead copy of green in 15-12.
277 *
278 * Verified exhaustively equal to PICO9918_PIXEL_FROM_RGB12 over all 65536 inputs.
279 * The channel names in the table above are load-bearing: a cross-check written from
280 * the same prose as the macro's own comment cannot catch a shared misreading of the
281 * format. See platform/pico/platform_pico.h.
282 *
283 * Written as explicit nibble extraction and reassembly rather than as the
284 * library's mask/shift pair, so the two share no algebra. */
285static uint16_t refPixel(uint16_t pram)
286{
287 const unsigned g = (pram >> 12) & 0x0f; /* green - also copied into bits 7-4 */
288 const unsigned b = (pram >> 8) & 0x0f; /* blue */
289 const unsigned r = (pram ) & 0x0f; /* red */
290 return (uint16_t)((g << 12) | (b << 8) | (g << 4) | r);
291}
292
293/* one line of each surface - the expansion emits two pixels per indexed byte */
294#define POST_PAL_PIXELS (GOLDEN_BYTES_PER_LINE * 2)
295
296static PICO9918_PIXEL_T libPixels[POST_PAL_PIXELS];
297static uint16_t refPixels[POST_PAL_PIXELS];
298
299/* The two surfaces are compared value-for-value, so the library's pixel type
300 must match the reference's. If the shipped policy drifts to a wider pixel this
301 fires here instead of surfacing as thousands of confusing digest mismatches.
302 The LUT type is asserted for the same reason - the expansion indexes it as
303 packed pairs. */
304_Static_assert(sizeof(PICO9918_PIXEL_T) == sizeof(uint16_t),
305 "golden surfaces require the 16-bit Pico pixel policy");
306_Static_assert(sizeof(PICO9918_PALETTE_LUT_T) == sizeof(uint32_t),
307 "golden expansion requires a 32-bit packed-pair LUT");
308
309/* The reference's own LUT, as a pixel PAIR per entry like the library's.
310 *
311 * Kept as persistent state rather than rebuilt per line because the library's
312 * LUT is persistent: it is global, rebuilt only when the palette is dirty, and
313 * each rebuild is PARTIAL - the doubled build writes entries 0..63 only, since
314 * doubled modes never index above 63. Entries 64..255 therefore survive from
315 * whichever paired build last ran, across scene boundaries. Modelling that
316 * faithfully is the whole point: f18a-text80-attrs really does render its
317 * first line through entries text80 left behind, and a reference that rebuilt
318 * from scratch would disagree with a perfectly correct library.
319 *
320 * Zero-initialised, matching pico9918_init() zeroing pico9918_palette_lut. */
321static uint32_t refLut[256];
322
323/* Independent reference for the LUT build - the counterpart of
324 * pico9918_palette_regenerate(), written from the documented behaviour:
325 *
326 * DOUBLED entries 0..63, each the pram entry's pixel repeated in both
327 * halves of the word.
328 * PAIRED entries 0..15 doubled (an index < 16 still means one colour),
329 * then 16..255 where the byte is two 4-bit indexes: the high
330 * nibble's colour in the low half of the word, the low nibble's in
331 * the high half. Little-endian storage puts the low half at the
332 * lower address, so the HIGH nibble is the left-hand pixel.
333 *
334 * Deliberately does not share code with the library's version. */
335static void refRegenerate(bool paired)
336{
337 const uint16_t* pram = tms9918->vram.map.pram;
338
339 if (!paired)
340 {
341 for (int i = 0; i < 64; ++i)
342 {
343 const uint32_t px = refPixel(pram[i]);
344 refLut[i] = px | (px << 16);
345 }
346 return;
347 }
348
349 uint16_t pal[16];
350 for (int i = 0; i < 16; ++i)
351 {
352 pal[i] = refPixel(pram[i]);
353 refLut[i] = (uint32_t)pal[i] | ((uint32_t)pal[i] << 16);
354 }
355 for (int j = 16; j < 256; ++j)
356 {
357 refLut[j] = ((uint32_t)pal[j & 0x0f] << 16) | pal[j >> 4];
358 }
359}
360
361/* Independent reference for the expansion - the counterpart of
362 * PICO9918_EXPAND_INDEXED. Unpacks each LUT word into its two pixels so the
363 * digest covers the per-pixel STREAM, making the pair-packing trick something
364 * the goldens verify rather than assume. */
365static void refExpand(uint16_t* dst, const uint8_t* src, int n)
366{
367 for (int i = 0; i < n; ++i)
368 {
369 const uint32_t entry = refLut[src[i]];
370 dst[i * 2 + 0] = (uint16_t)entry;
371 dst[i * 2 + 1] = (uint16_t)(entry >> 16);
372 }
373}
374
375/* Expand one captured line through both surfaces and digest them. */
376static void expandLine(const uint8_t* indexed, uint64_t digests[GOLDEN_DIGESTS_PER_LINE])
377{
378 PICO9918_EXPAND_INDEXED(libPixels, indexed, GOLDEN_BYTES_PER_LINE, pico9918_palette_lut);
379 refExpand(refPixels, indexed, GOLDEN_BYTES_PER_LINE);
380
381 digests[0] = fnv1a(libPixels, sizeof(libPixels));
382 digests[1] = fnv1a(refPixels, sizeof(refPixels));
383}
384
385/* First position where the library expansion and the independent reference
386 * disagree, or -1. Both are BGR16 under the golden pixel policy, so this is a
387 * direct value comparison - the strongest form of the cross-check. */
388static int refDivergence(void)
389{
390 for (int i = 0; i < POST_PAL_PIXELS; ++i)
391 {
392 if (libPixels[i] != refPixels[i]) return i;
393 }
394 return -1;
395}
396
397/* ---------------------------------------------------------------------------
398 * Scenes
399 * ------------------------------------------------------------------------- */
400
401/* standard Graphics I: LCG-filled tables, sprites from LCG attribute data */
402static void sceneGraphicsI(void)
403{
404 lcgSeed(0x1001);
405 vramFillLcg(0x0000, 0x4000);
406 regWrite(0, 0x00);
407 regWrite(1, 0xe0); /* 16K, display on, int enable */
408 regWrite(2, 0x0e); /* name table 0x3800 */
409 regWrite(3, 0x00); /* color table 0x0000 */
410 regWrite(4, 0x04); /* pattern table 0x2000 */
411 regWrite(5, 0x76); /* sprite attrs 0x3b00 */
412 regWrite(6, 0x03); /* sprite patts 0x1800 */
413 regWrite(7, 0xf9); /* white on light red - backdrop low nibble >= 8 pins
414 the full 4-bit backdrop colour mask */
415}
416
417/* standard Graphics II (bitmap): three pattern/color pages */
418static void sceneGraphicsII(void)
419{
420 lcgSeed(0x2002);
421 vramFillLcg(0x0000, 0x4000);
422 regWrite(0, 0x02);
423 regWrite(1, 0xe0);
424 regWrite(2, 0x0e); /* name table 0x3800 */
425 regWrite(3, 0xff); /* color table 0x2000, full name mask */
426 regWrite(4, 0x03); /* pattern table 0x0000, all pages */
427 regWrite(5, 0x76);
428 regWrite(6, 0x03);
429 regWrite(7, 0x01); /* bg black */
430}
431
432/* standard Text (40 column) */
433static void sceneText(void)
434{
435 lcgSeed(0x3003);
436 vramFillLcg(0x0000, 0x4000);
437 regWrite(0, 0x00);
438 regWrite(1, 0xd0); /* 16K, display on, text mode */
439 regWrite(2, 0x00); /* name table 0x0000 */
440 regWrite(4, 0x01); /* pattern table 0x0800 */
441 regWrite(7, 0xf4); /* white on dark blue */
442}
443
444/* standard Multicolor */
445static void sceneMulticolor(void)
446{
447 lcgSeed(0x4004);
448 vramFillLcg(0x0000, 0x4000);
449 regWrite(0, 0x00);
450 regWrite(1, 0xe8); /* 16K, display on, multicolor */
451 regWrite(2, 0x05); /* name table 0x1400 */
452 regWrite(4, 0x01); /* pattern table 0x0800 */
453 regWrite(5, 0x76);
454 regWrite(6, 0x03);
455 regWrite(7, 0x04);
456}
457
458/* TEXT80 (F18A 80-column), locked, two-tone path.
459 * output packing is two 4-bit pixels per byte - part of the contract. */
460static void sceneText80(void)
461{
462 lcgSeed(0x5005);
463 vramFillLcg(0x0000, 0x4000);
464 regWrite(0, 0x04); /* TEXT80 mode bit */
465 regWrite(1, 0xd0);
466 regWrite(2, 0x0c); /* name table 0x3000 (locked mask 0x0c) */
467 regWrite(4, 0x01); /* pattern table 0x0800 */
468 regWrite(7, 0xf4);
469}
470
471/* hand-placed standard sprites: five sprites on one row raise the
472 * 5th-sprite status flag; an overlapping pair raises coincidence. */
473static void sceneSpritesMax(void)
474{
475 static const uint8_t sat[] = {
476 /* y, x, name, color */
477 0x27, 0x00, 0x00, 0x02, /* row 40: sprites 0..4 */
478 0x27, 0x28, 0x00, 0x03,
479 0x27, 0x50, 0x00, 0x04,
480 0x27, 0x78, 0x00, 0x05,
481 0x27, 0xa0, 0x00, 0x06, /* 5th sprite on the line -> 5S flag */
482 0x63, 0x64, 0x00, 0x08, /* row 100: overlapping pair -> COL */
483 0x63, 0x68, 0x01, 0x09, /* solid 100..107 vs half 104..107 */
484 0xd0, 0x00, 0x00, 0x00 /* terminator */
485 };
486
487 vramFillByte(0x0000, 0x00, 0x4000);
488 vramFillByte(0x1800, 0xff, 8); /* sprite pattern 0: solid */
489 vramFillByte(0x1808, 0xf0, 8); /* sprite pattern 1: half */
490 vramWriteBytes(0x3b00, sat, (int)sizeof(sat));
491
492 regWrite(0, 0x00);
493 regWrite(1, 0xe0); /* 8x8 sprites, no magnification */
494 regWrite(2, 0x0e);
495 regWrite(3, 0x00);
496 regWrite(4, 0x04);
497 regWrite(5, 0x76);
498 regWrite(6, 0x03);
499 regWrite(7, 0xf1); /* white on black */
500}
501
502/* F18A unlocked: ECM1 tiles + sprites, two tile layers, scroll registers,
503 * 30-row mode (240 lines), scanline-sprite limit raised, 16px sprites */
504static void sceneF18aUnlocked(void)
505{
506 lcgSeed(0x6006);
507 vramFillLcg(0x0000, 0x4000);
508 unlockF18a();
509 paletteWriteLcg(64);
510 regWrite(0, 0x00);
511 regWrite(1, 0xe2); /* display on, 16px sprites */
512 regWrite(2, 0x0e); /* T1 name 0x3800 */
513 regWrite(3, 0x00); /* T1 color 0x0000 */
514 regWrite(4, 0x04); /* pattern 0x2000 */
515 regWrite(5, 0x76); /* SAT 0x3b00 */
516 regWrite(6, 0x03); /* SPT 0x1800 */
517 regWrite(7, 0xf4);
518 regWrite(10, 0x08); /* T2 name 0x2000 */
519 regWrite(11, 0x10); /* T2 color 0x0400 */
520 regWrite(24, 0x16); /* palette selects: sprites 1, T2 1, T1 2 */
521 regWrite(25, 0x0b); /* T2 h-scroll */
522 regWrite(26, 0x07); /* T2 v-scroll */
523 regWrite(27, 0x05); /* T1 h-scroll */
524 regWrite(28, 0x03); /* T1 v-scroll */
525 regWrite(29, 0x33); /* page-swap masks + horizontal page sizes */
526 regWrite(30, 0x08); /* scanline sprites: 8 */
527 regWrite(49, 0xd1); /* T2 on | 30-row | ECM1 tiles | ECM1 sprites */
528 regWrite(51, 0x18); /* sprites to process: 24 */
529}
530
531/* F18A unlocked: ECM3 tiles + sprites, per-position attributes, opaque
532 * fat-pixel bitmap layer */
533static void sceneF18aEcm3Bml(void)
534{
535 lcgSeed(0x7007);
536 vramFillLcg(0x0000, 0x4000);
537 unlockF18a();
538 regWrite(0, 0x00);
539 regWrite(1, 0xe0); /* display on, 8px sprites */
540 regWrite(2, 0x0e);
541 regWrite(3, 0x20); /* color table 0x0800 */
542 regWrite(4, 0x04);
543 regWrite(5, 0x76);
544 regWrite(6, 0x03);
545 regWrite(7, 0x01);
546 regWrite(31, 0x90); /* BML: enabled, opaque, fat 4bpp pixels */
547 regWrite(32, 0x10); /* BML addr 0x0400 */
548 regWrite(33, 0x18); /* BML x = 24 */
549 regWrite(34, 0x14); /* BML top = 20 */
550 regWrite(35, 0x80); /* BML width = 32 bytes (128px fat) */
551 regWrite(36, 0x64); /* BML height = 100 */
552 regWrite(49, 0x33); /* ECM3 tiles | ECM3 sprites */
553 regWrite(50, 0x02); /* per-position tile attributes */
554}
555
556/* F18A unlocked: a bitmap layer the display wraps rather than crops. A full-width
557 * opaque priority layer started mid-line puts its overrun back at column zero of the
558 * same line, which is what makes VR33 a horizontal scroll, and covers the line so the
559 * tile layers do not run. A row count reaching past the last scanline still stops. */
560static void sceneF18aBmlWrap(void)
561{
562 lcgSeed(0x9009);
563 vramFillLcg(0x0000, 0x4000);
564 unlockF18a();
565 regWrite(0, 0x02); /* Graphics II */
566 regWrite(1, 0xe0); /* display on, 8px sprites */
567 regWrite(2, 0x0e);
568 regWrite(3, 0xff); /* color table 0x2000 */
569 regWrite(4, 0x03); /* pattern table 0x0000 */
570 regWrite(5, 0x76);
571 regWrite(6, 0x03);
572 regWrite(7, 0x01);
573 regWrite(31, 0xc0); /* BML: enabled, priority, opaque, 2bpp */
574 regWrite(32, 0x10); /* BML addr 0x0400 */
575 regWrite(33, 0x28); /* BML x = 40, so the last 40 columns come back at column 0 */
576 regWrite(34, 0x50); /* BML top = 80 */
577 regWrite(35, 0x00); /* BML width = 64 bytes (256px, wider than the room left) */
578 regWrite(36, 0xff); /* BML height = 255, past the last scanline */
579 regWrite(49, 0x21); /* ECM2 tiles | ECM1 sprites */
580 regWrite(51, 0x10); /* sprites to process: 16 */
581}
582
583/* F18A unlocked: a bitmap layer whose width is not a whole number of bytes. Four
584 * pixels to a byte, so the stride rounds up and every row lands a byte further on
585 * than a truncating divide would put it - the only case where the two differ, and
586 * every other bitmap scene here is a multiple of four. */
587static void sceneF18aBmlStride(void)
588{
589 lcgSeed(0xa00a);
590 vramFillLcg(0x0000, 0x4000);
591 unlockF18a();
592 regWrite(0, 0x00); /* Graphics I */
593 regWrite(1, 0xe0); /* display on, 8px sprites */
594 regWrite(2, 0x0e);
595 regWrite(3, 0x20); /* color table 0x0800 */
596 regWrite(4, 0x04);
597 regWrite(5, 0x76);
598 regWrite(6, 0x03);
599 regWrite(7, 0x01);
600 regWrite(31, 0xc0); /* BML: enabled, priority, opaque, 2bpp */
601 regWrite(32, 0x10); /* BML addr 0x0400 */
602 regWrite(33, 0x14); /* BML x = 20 */
603 regWrite(34, 0x10); /* BML top = 16 */
604 regWrite(35, 0x66); /* BML width = 102px, so 26 bytes a row and not 25 */
605 regWrite(36, 0x80); /* BML height = 128 */
606 regWrite(49, 0x00); /* T2 off, no ECM, no 30-row */
607 regWrite(51, 0x08); /* sprites to process: 8 */
608}
609
610/* F18A unlocked TEXT80 with position-based attributes and a second tile
611 * layer - exercises the packed two-pixels-per-byte layered path */
612static void sceneF18aText80Attrs(void)
613{
614 lcgSeed(0x8008);
615 vramFillLcg(0x0000, 0x4000);
616 unlockF18a();
617 regWrite(0, 0x04); /* TEXT80 */
618 regWrite(1, 0xd0);
619 regWrite(2, 0x00); /* T1 name 0x0000 */
620 regWrite(3, 0x10); /* T1 color 0x0400 */
621 regWrite(4, 0x01); /* pattern 0x0800 */
622 regWrite(5, 0x76);
623 regWrite(6, 0x03);
624 regWrite(7, 0xf4);
625 regWrite(10, 0x08); /* T2 name 0x2000 */
626 regWrite(11, 0xa0); /* T2 color 0x2800 */
627 regWrite(26, 0x04); /* T2 v-scroll */
628 regWrite(28, 0x02); /* T1 v-scroll */
629 regWrite(49, 0x80); /* T2 on */
630 regWrite(50, 0x02); /* position-based attributes */
631}
632
633/* F18A unlocked, dense LCG VRAM fill with multiple features enabled - the
634 * stand-in for "GPU output captured as static VRAM state" */
635static void sceneF18aVramSnapshot(void)
636{
637 lcgSeed(0x9009);
638 vramFillLcg(0x0000, 0x4000);
639 unlockF18a();
640 paletteWriteLcg(64);
641 regWrite(0, 0x00);
642 regWrite(1, 0xe3); /* display on, 16px magnified sprites */
643 regWrite(2, 0x0a); /* T1 name 0x2800 */
644 regWrite(3, 0x00); /* T1 color 0x0000 */
645 regWrite(4, 0x07); /* pattern 0x3800 */
646 regWrite(5, 0x60); /* SAT 0x3000 */
647 regWrite(6, 0x05); /* SPT 0x2800 */
648 regWrite(7, 0x04);
649 regWrite(10, 0x04); /* T2 name 0x1000 */
650 regWrite(11, 0x50); /* T2 color 0x1400 */
651 regWrite(24, 0x09);
652 regWrite(25, 0x21); /* T2 h-scroll */
653 regWrite(26, 0x0d); /* T2 v-scroll */
654 regWrite(27, 0x13); /* T1 h-scroll */
655 regWrite(28, 0x09); /* T1 v-scroll */
656 regWrite(29, 0x77); /* page swaps, page sizes, ECM plane offsets 0x400 */
657 regWrite(31, 0xa5); /* BML: enabled, transparent, 2bpp, palette bits */
658 regWrite(32, 0x28); /* BML addr 0x0a00 */
659 regWrite(33, 0x64); /* BML x = 100 */
660 regWrite(34, 0x00); /* BML top = 0 */
661 regWrite(35, 0x40); /* BML width = 16 bytes (64px) */
662 regWrite(36, 0xc0); /* BML height = 192 */
663 regWrite(49, 0xa2); /* T2 on | ECM2 tiles | ECM2 sprites */
664 regWrite(50, 0x02); /* per-position tile attributes */
665}
666
667/* F18A unlocked: BML priority (VR31 bit 0x40 write-mask) with a width-64
668 * fully-opaque band - drives the fast path that pre-fills the row mask and
669 * suppresses both tile layers - over ECM2 tiles with ECM1 sprites placed to
670 * cross the band edges (sprites render below the bitmap stage's mask but
671 * still overdraw its pixels) */
672static void sceneF18aBmlPriority(void)
673{
674 static const uint8_t sat[] = {
675 /* y, x, name, color */
676 0x10, 0x20, 0x01, 0x06, /* above the band */
677 0x2c, 0x10, 0x00, 0x03, /* crosses the band top (48) */
678 0x50, 0x58, 0x04, 0x05, /* fully inside the band */
679 0x6c, 0xa0, 0x08, 0x07, /* crosses the band bottom (112) */
680 0xd0, 0x00, 0x00, 0x00 /* terminator */
681 };
682
683 lcgSeed(0xb00b);
684 vramFillLcg(0x0000, 0x4000);
685 vramWriteBytes(0x3b00, sat, (int)sizeof(sat));
686 unlockF18a();
687 regWrite(0, 0x00);
688 regWrite(1, 0xe0); /* display on, 8px sprites */
689 regWrite(2, 0x0e); /* T1 name 0x3800 */
690 regWrite(3, 0x08); /* T1 color 0x0200 */
691 regWrite(4, 0x04); /* pattern 0x2000 */
692 regWrite(5, 0x76); /* SAT 0x3b00 */
693 regWrite(6, 0x03); /* SPT 0x1800 */
694 regWrite(7, 0xf4);
695 regWrite(31, 0xc0); /* BML: enabled, priority/write-mask, opaque, 2bpp */
696 regWrite(32, 0x20); /* BML addr 0x0800 */
697 regWrite(33, 0x00); /* BML x = 0 (width-64 rows cover the full line) */
698 regWrite(34, 0x30); /* BML top = 48 */
699 regWrite(35, 0x00); /* BML width = 64 bytes (256px opaque) */
700 regWrite(36, 0x40); /* BML height = 64 */
701 regWrite(49, 0x21); /* ECM2 tiles | ECM1 sprites */
702}
703
704/* F18A unlocked ECM0: standard-format tiles through the scrolled F18A tile
705 * path (renderEcm0Tile) with h-scroll shift==2 alignment, non-ECM sprites
706 * rendered below the tile layer including a colour-0 transparent sprite
707 * (its pixels must be released from the sprite mask so tiles show through),
708 * and ReadData/read-ahead traffic feeding the sprite attribute table so the
709 * data-port read behaviour influences pixels */
710static void sceneF18aEcm0(void)
711{
712 lcgSeed(0xc00c);
713 vramFillLcg(0x0000, 0x4000);
714 unlockF18a();
715
716 /* data-port read traffic: read 12 bytes back via read-ahead auto-increment
717 * plus one ReadDataNoInc, then build sprites 0-3 from the values - any
718 * change in read semantics moves/recolours the sprites */
719 uint8_t rd[13];
720 vramSetReadAddr(0x2600);
721 for (int i = 0; i < 12; ++i) rd[i] = pico9918_read_data();
722 rd[12] = pico9918_read_data_no_inc();
723
724 uint8_t sat[8 * 4];
725 for (int i = 0; i < 4; ++i)
726 {
727 sat[i * 4 + 0] = (uint8_t)(0x08 + i * 0x28); /* y: spread down the frame */
728 sat[i * 4 + 1] = rd[i * 3]; /* x from read data */
729 sat[i * 4 + 2] = rd[i * 3 + 1]; /* name from read data */
730 sat[i * 4 + 3] = rd[i * 3 + 2] & 0x0f; /* colour from read data */
731 }
732 /* sprite 4: solid; sprite 5: colour-0 transparent overlapping sprite 4
733 * (overlap raises COL; its non-overlapped pixels enter then leave the
734 * sprite mask, so the tiles beneath must render); sprite 6: name comes
735 * from the ReadDataNoInc value */
736 static const uint8_t satTail[] = {
737 0x50, 0x40, 0x00, 0x05,
738 0x50, 0x44, 0x01, 0x00,
739 0x78, 0x80, 0x00, 0x0b,
740 0xd0, 0x00, 0x00, 0x00
741 };
742 memcpy(sat + 16, satTail, sizeof(satTail));
743 sat[26] = rd[12]; /* sprite 6 name from ReadDataNoInc */
744
745 vramFillByte(0x1800, 0xff, 16); /* sprite patterns 0 and 1: solid */
746 vramFillByte(0x0000, 0x53, 32); /* tile colours: fg 5 on bg 3 - every
747 tile pixel is drawn, so masked-off
748 pixels are always visible */
749 vramWriteBytes(0x3b00, sat, (int)sizeof(sat));
750
751 regWrite(0, 0x00);
752 regWrite(1, 0xe0); /* display on, 8px sprites */
753 regWrite(2, 0x0e); /* T1 name 0x3800 */
754 regWrite(3, 0x00); /* T1 color 0x0000 */
755 regWrite(4, 0x04); /* pattern 0x2000 */
756 regWrite(5, 0x76); /* SAT 0x3b00 */
757 regWrite(6, 0x03); /* SPT 0x1800 */
758 regWrite(7, 0xf4);
759 regWrite(27, 0x0a); /* T1 h-scroll: tile index 1, fine scroll 2 -> shift==2 */
760 regWrite(49, 0x00); /* ECM0 tiles, non-ECM sprites */
761}
762
763/* F18A unlocked Graphics II: R0 GII bit set while unlocked takes the unlocked
764 * Graphics-I path with the bitmap fetch paged (sprites, then the bitmap layer).
765 * 16px sprites plus a transparent 2bpp BML window. */
766static void sceneF18aGfx2(void)
767{
768 static const uint8_t sat[] = {
769 /* y, x, name, color */
770 0x20, 0x30, 0x00, 0x07,
771 0x50, 0x80, 0x04, 0x0b, /* inside the BML window */
772 0x60, 0x40, 0x0c, 0x04,
773 0x85, 0xc8, 0x08, 0x0d, /* clips the right edge */
774 0xd0, 0x00, 0x00, 0x00 /* terminator */
775 };
776
777 lcgSeed(0xd00d);
778 vramFillLcg(0x0000, 0x4000);
779 vramWriteBytes(0x3b00, sat, (int)sizeof(sat));
780 unlockF18a();
781 paletteWriteLcg(32); /* rewrite half the palette - the pram dump pins it */
782 regWrite(0, 0x02); /* Graphics II while unlocked */
783 regWrite(1, 0xe2); /* display on, 16px sprites */
784 regWrite(2, 0x0e); /* name table 0x3800 */
785 regWrite(3, 0xff); /* color table 0x2000, full name mask */
786 regWrite(4, 0x03); /* pattern table 0x0000, all pages */
787 regWrite(5, 0x76); /* SAT 0x3b00 */
788 regWrite(6, 0x03); /* SPT 0x1800 */
789 regWrite(7, 0xf1);
790 regWrite(24, 0x02); /* T1 palette select 2 (GII applies it to tile colours) */
791 regWrite(31, 0xa0); /* BML: enabled, transparent, 2bpp */
792 regWrite(32, 0x30); /* BML addr 0x0c00 */
793 regWrite(33, 0x40); /* BML x = 64 */
794 regWrite(34, 0x20); /* BML top = 32 */
795 regWrite(35, 0x40); /* BML width = 16 bytes (64px) */
796 regWrite(36, 0x80); /* BML height = 128 */
797}
798
799/* raw-reset: pins the current post-reset contract. The scene itself
800 * establishes a fully deterministic unlocked+configured state (VRAM, palette,
801 * registers) and commits the unlocked mode by rendering a throwaway line,
802 * then calls pico9918_reset() through the public API. The captured lines
803 * are rendered WITHOUT the mode nudge sceneBegin() applies, so whatever the
804 * reset path leaves behind (display off, backdrop 0, default palette, stale
805 * cached mode) is golden-file contract - independent of scene order. */
806static void sceneRawReset(void)
807{
808 lcgSeed(0xe00e);
809 vramFillLcg(0x0000, 0x4000);
810 unlockF18a();
811 paletteWriteLcg(64);
812 regWrite(0, 0x00);
813 regWrite(1, 0xe0);
814 regWrite(2, 0x0e);
815 regWrite(7, 0x1f);
816 regWrite(49, 0x11); /* ECM1 tiles | ECM1 sprites */
817 pico9918_scan_line(0); /* commit the unlocked Graphics-I mode */
819}
820
821typedef struct
822{
823 const char* name;
824 int lines;
825 void (*setup)(void);
826} Scene;
827
828static const Scene scenes[] = {
829 { "graphics-i", 192, sceneGraphicsI },
830 { "graphics-ii", 192, sceneGraphicsII },
831 { "text", 192, sceneText },
832 { "multicolor", 192, sceneMulticolor },
833 { "text80", 192, sceneText80 },
834 { "sprites-max", 192, sceneSpritesMax },
835 { "f18a-unlocked", 240, sceneF18aUnlocked },
836 { "f18a-ecm3-bml", 192, sceneF18aEcm3Bml },
837 { "f18a-text80-attrs", 192, sceneF18aText80Attrs },
838 { "f18a-vram-snapshot", 192, sceneF18aVramSnapshot},
839 { "f18a-bml-priority", 192, sceneF18aBmlPriority },
840 { "f18a-bml-wrap", 192, sceneF18aBmlWrap },
841 { "f18a-bml-stride", 192, sceneF18aBmlStride },
842 { "f18a-ecm0", 192, sceneF18aEcm0 },
843 { "f18a-gfx2", 192, sceneF18aGfx2 },
844 { "raw-reset", 8, sceneRawReset },
845};
846
847#define SCENE_COUNT ((int)(sizeof(scenes) / sizeof(scenes[0])))
848#define MAX_LINES 240
849
850/* Rendered frame: per line, GOLDEN_BYTES_PER_LINE pixels copied out of the line
851 * the library published, plus 1 status byte.
852 *
853 * No slack. A scrolled F18A tile layer renders in whole 32-bit quads, so the last
854 * partial tile of a fine-h-scrolled line is written PAST pixel 255 - the
855 * shifted-tile path reaches byte 263, exactly 8 - but that over-write now lands in
856 * the library's own buffer, which is sized PICO9918_SCANLINE_BUFFER_SIZE for
857 * it. Rows here are only ever the copy destination. */
858static uint8_t frame[MAX_LINES][GOLDEN_BYTES_PER_LINE + 1];
859
860/* per-scene palette RAM dump - 64 entries, 2 bytes each, little-endian */
861static uint8_t palDump[GOLDEN_PAL_BYTES];
862
863/* per-line post-palette digests (format v3) */
864static uint64_t digests[MAX_LINES][GOLDEN_DIGESTS_PER_LINE];
865
866/* first library-vs-reference disagreement: line and pixel, or -1 for none.
867 * The two pixel values are latched at detection time - the shared expansion
868 * buffers are overwritten by every later line. */
869static int refFailLine;
870static int refFailPixel;
871static uint16_t refFailLib;
872static uint16_t refFailRef;
873
874static void renderScene(const Scene* scene, int* lines5s, int* linesCol)
875{
876 *lines5s = 0;
877 *linesCol = 0;
878 refFailLine = -1;
879 refFailPixel = -1;
880
881 sceneBegin();
882 scene->setup();
883
884 for (int y = 0; y < scene->lines; ++y)
885 {
886 uint8_t* pixels = frame[y];
887 /* Rebuild the LUT exactly as the firmware's scanline path does - only when
888 * the library reports it dirty, and BEFORE the line is rendered (the class
889 * therefore comes from the mode cached by the PREVIOUS line's render).
890 *
891 * This is what makes palDirty observable: a palette write that fails to
892 * raise the flag leaves a stale LUT, and every following line expands
893 * through it. The reference LUT is rebuilt in lockstep, off the same
894 * dirty decision, so only the CONTENT of the two builds is being compared
895 * and not their scheduling. */
896 if (pico9918_palette_dirty())
897 {
898 const bool paired = pico9918_display_mode(PICO9918_INST_ONLY) == TMS_MODE_TEXT80 &&
900 pico9918_palette_regenerate();
901 refRegenerate(paired);
902 }
903
904 uint8_t status = pico9918_scan_line((uint16_t)y);
905 memcpy(pixels, pico9918_line_source(), GOLDEN_BYTES_PER_LINE);
906 pixels[GOLDEN_BYTES_PER_LINE] = status;
907 if (status & PICO9918_SR0_5S) ++*lines5s;
908 if (status & PICO9918_SR0_COLLISION) ++*linesCol;
909
910 expandLine(pixels, digests[y]);
911
912 if (refFailLine < 0)
913 {
914 const int bad = refDivergence();
915 if (bad >= 0)
916 {
917 refFailLine = y;
918 refFailPixel = bad;
919 refFailLib = (uint16_t)libPixels[bad];
920 refFailRef = refPixels[bad];
921 }
922 }
923 }
924
925 /* after the frame: read the live palette back out (reprograms registers,
926 * so it must come last) */
927 dumpPalette(palDump);
928}
929
930/* ---------------------------------------------------------------------------
931 * Golden file I/O
932 *
933 * layout (all integers little-endian uint32):
934 * char[4] magic "TMSG"
935 * u32 version (3)
936 * char[32] scene name, zero padded
937 * u32 line count
938 * u32 bytes per line (256)
939 * u32 status bytes per line (1)
940 * u32 palette entries (64)
941 * u32 post-palette digests per line (2)
942 * then per line: 256 indexed pixel bytes + 1 status byte
943 * then per line: 2 uint64 little-endian digests - [0] the library's BGR16
944 * expansion, [1] the independent reference's (see "Post-palette
945 * surfaces")
946 * then: 64 palette RAM entries, uint16 little-endian each (low byte 0R,
947 * high byte GB - raw pram memory order)
948 *
949 * A file whose header does not match is rejected rather than read.
950 * ------------------------------------------------------------------------- */
951static void putU32(FILE* f, uint32_t v)
952{
953 uint8_t b[4] = { (uint8_t)v, (uint8_t)(v >> 8), (uint8_t)(v >> 16), (uint8_t)(v >> 24) };
954 fwrite(b, 1, 4, f);
955}
956
957static void putU64(FILE* f, uint64_t v)
958{
959 putU32(f, (uint32_t)v);
960 putU32(f, (uint32_t)(v >> 32));
961}
962
963static bool getU32(FILE* f, uint32_t* v)
964{
965 uint8_t b[4];
966 if (fread(b, 1, 4, f) != 4) return false;
967 *v = (uint32_t)b[0] | ((uint32_t)b[1] << 8) | ((uint32_t)b[2] << 16) | ((uint32_t)b[3] << 24);
968 return true;
969}
970
971static bool getU64(FILE* f, uint64_t* v)
972{
973 uint32_t lo, hi;
974 if (!getU32(f, &lo)) return false;
975 if (!getU32(f, &hi)) return false;
976 *v = (uint64_t)lo | ((uint64_t)hi << 32);
977 return true;
978}
979
980static void scenePath(char* buf, size_t bufLen, const char* dataDir, const char* name)
981{
982 snprintf(buf, bufLen, "%s/%s.golden", dataDir, name);
983}
984
985static bool captureScene(const Scene* scene, const char* dataDir)
986{
987 char path[512];
988 scenePath(path, sizeof(path), dataDir, scene->name);
989
990 int lines5s, linesCol;
991 renderScene(scene, &lines5s, &linesCol);
992
993 /* never capture a surface the independent reference disagrees with - that
994 * would enshrine whichever side is wrong */
995 if (refFailLine >= 0)
996 {
997 printf("[ERROR] %-19s library expansion diverges from the reference at "
998 "line %d, pixel %d (lib 0x%04x, ref 0x%04x) - NOT captured\n",
999 scene->name, refFailLine, refFailPixel, refFailLib, refFailRef);
1000 return false;
1001 }
1002
1003 FILE* f = fopen(path, "wb");
1004 if (!f)
1005 {
1006 printf("[ERROR] %s: cannot open %s for writing\n", scene->name, path);
1007 return false;
1008 }
1009
1010 char name[GOLDEN_NAME_LEN] = { 0 };
1011 strncpy(name, scene->name, GOLDEN_NAME_LEN - 1);
1012
1013 fwrite(GOLDEN_MAGIC, 1, 4, f);
1014 putU32(f, GOLDEN_VERSION);
1015 fwrite(name, 1, GOLDEN_NAME_LEN, f);
1016 putU32(f, (uint32_t)scene->lines);
1017 putU32(f, GOLDEN_BYTES_PER_LINE);
1018 putU32(f, 1);
1019 putU32(f, GOLDEN_PAL_ENTRIES);
1020 putU32(f, GOLDEN_DIGESTS_PER_LINE);
1021 /* per line, not one bulk write - the rows carry trailing spill slack that is
1022 * not part of the format */
1023 for (int y = 0; y < scene->lines; ++y)
1024 {
1025 fwrite(frame[y], 1, GOLDEN_BYTES_PER_LINE + 1, f);
1026 }
1027 for (int y = 0; y < scene->lines; ++y)
1028 {
1029 for (int d = 0; d < GOLDEN_DIGESTS_PER_LINE; ++d) putU64(f, digests[y][d]);
1030 }
1031 fwrite(palDump, 1, GOLDEN_PAL_BYTES, f);
1032 fclose(f);
1033
1034 printf("[CAPTURED] %-19s %3d lines (5S on %d lines, COL on %d lines) -> %s\n",
1035 scene->name, scene->lines, lines5s, linesCol, path);
1036 return true;
1037}
1038
1039static bool compareScene(const Scene* scene, const char* dataDir)
1040{
1041 char path[512];
1042 scenePath(path, sizeof(path), dataDir, scene->name);
1043
1044 FILE* f = fopen(path, "rb");
1045 if (!f)
1046 {
1047 printf("[FAIL] %-19s missing golden file %s (run with --capture first)\n",
1048 scene->name, path);
1049 return false;
1050 }
1051
1052 char magic[4];
1053 uint32_t version = 0, lines = 0, bytesPerLine = 0, statusPerLine = 0, palEntries = 0;
1054 uint32_t digestsPerLine = 0;
1055 char name[GOLDEN_NAME_LEN];
1056
1057 bool headerOk =
1058 fread(magic, 1, 4, f) == 4 && memcmp(magic, GOLDEN_MAGIC, 4) == 0 &&
1059 getU32(f, &version) && version == GOLDEN_VERSION &&
1060 fread(name, 1, GOLDEN_NAME_LEN, f) == GOLDEN_NAME_LEN &&
1061 getU32(f, &lines) &&
1062 getU32(f, &bytesPerLine) && bytesPerLine == GOLDEN_BYTES_PER_LINE &&
1063 getU32(f, &statusPerLine) && statusPerLine == 1 &&
1064 getU32(f, &palEntries) && palEntries == GOLDEN_PAL_ENTRIES &&
1065 getU32(f, &digestsPerLine) && digestsPerLine == GOLDEN_DIGESTS_PER_LINE;
1066
1067 if (!headerOk || lines != (uint32_t)scene->lines)
1068 {
1069 printf("[FAIL] %-19s bad golden header in %s\n", scene->name, path);
1070 fclose(f);
1071 return false;
1072 }
1073
1074 int lines5s, linesCol;
1075 renderScene(scene, &lines5s, &linesCol);
1076
1077 static uint8_t expected[GOLDEN_BYTES_PER_LINE + 1];
1078 for (int y = 0; y < scene->lines; ++y)
1079 {
1080 if (fread(expected, 1, sizeof(expected), f) != sizeof(expected))
1081 {
1082 printf("[FAIL] %-19s truncated golden file at line %d\n", scene->name, y);
1083 fclose(f);
1084 return false;
1085 }
1086 if (memcmp(expected, frame[y], sizeof(expected)) != 0)
1087 {
1088 int i = 0;
1089 while (expected[i] == frame[y][i]) ++i;
1090 if (i == GOLDEN_BYTES_PER_LINE)
1091 {
1092 printf("[FAIL] %-19s first divergence: line %d status byte (expected 0x%02x, got 0x%02x)\n",
1093 scene->name, y, expected[i], frame[y][i]);
1094 }
1095 else
1096 {
1097 printf("[FAIL] %-19s first divergence: line %d, byte %d (expected 0x%02x, got 0x%02x)\n",
1098 scene->name, y, i, expected[i], frame[y][i]);
1099 }
1100 fclose(f);
1101 return false;
1102 }
1103 }
1104
1105 /* post-palette digests. Report the library surface before the reference
1106 * surface: if both moved, the library changing is the interesting fact. */
1107 static const char* const surfaceName[GOLDEN_DIGESTS_PER_LINE] = { "library", "reference" };
1108 for (int y = 0; y < scene->lines; ++y)
1109 {
1110 for (int d = 0; d < GOLDEN_DIGESTS_PER_LINE; ++d)
1111 {
1112 uint64_t expectedDigest;
1113 if (!getU64(f, &expectedDigest))
1114 {
1115 printf("[FAIL] %-19s truncated golden file in digest block at line %d\n",
1116 scene->name, y);
1117 fclose(f);
1118 return false;
1119 }
1120 if (expectedDigest != digests[y][d])
1121 {
1122 printf("[FAIL] %-19s first divergence: line %d %s post-palette digest "
1123 "(expected 0x%016llx, got 0x%016llx)\n",
1124 scene->name, y, surfaceName[d],
1125 (unsigned long long)expectedDigest, (unsigned long long)digests[y][d]);
1126 fclose(f);
1127 return false;
1128 }
1129 }
1130 }
1131
1132 /* the library and the reference agreeing is a stronger statement than either
1133 * matching its golden, so a disagreement is a failure even if both did */
1134 if (refFailLine >= 0)
1135 {
1136 printf("[FAIL] %-19s library expansion diverges from the reference at "
1137 "line %d, pixel %d (lib 0x%04x, ref 0x%04x)\n",
1138 scene->name, refFailLine, refFailPixel, refFailLib, refFailRef);
1139 fclose(f);
1140 return false;
1141 }
1142
1143 static uint8_t expectedPal[GOLDEN_PAL_BYTES];
1144 if (fread(expectedPal, 1, sizeof(expectedPal), f) != sizeof(expectedPal))
1145 {
1146 printf("[FAIL] %-19s truncated golden file in palette block\n", scene->name);
1147 fclose(f);
1148 return false;
1149 }
1150 if (memcmp(expectedPal, palDump, sizeof(expectedPal)) != 0)
1151 {
1152 int i = 0;
1153 while (expectedPal[i] == palDump[i]) ++i;
1154 const int entry = i >> 1;
1155 printf("[FAIL] %-19s first divergence: palette entry %d (expected 0x%04x, got 0x%04x)\n",
1156 scene->name, entry,
1157 expectedPal[entry * 2] | (expectedPal[entry * 2 + 1] << 8),
1158 palDump[entry * 2] | (palDump[entry * 2 + 1] << 8));
1159 fclose(f);
1160 return false;
1161 }
1162
1163 fclose(f);
1164 printf("[PASS] %-19s %3d lines\n", scene->name, scene->lines);
1165 return true;
1166}
1167
1168/* ---------------------------------------------------------------------------
1169 * OVERLAY SURFACE (data/overlay.golden, its own format - OVERLAY_VERSION)
1170 *
1171 * The 14 scenes above render no overlay pixels, so without this the splash, the
1172 * diag panels and the banner would have no behaviour gate at all. The defect class
1173 * that matters here is a colour-literal conversion yielding an image that renders
1174 * ALMOST right - an alpha or green bleed into blue. An eyeball misses a one-nibble
1175 * shift; a digest does not.
1176 *
1177 * Shape, deliberately the same as the post-palette surfaces above: the library's
1178 * own render path, plus an INDEPENDENT reference written from the documented
1179 * behaviour and sharing no code with the library, compared value-for-value on
1180 * every pixel of every row, and digested per row with FNV-1a.
1181 *
1182 * Separate artifact, not a new per-scene block: the existing 14 goldens and
1183 * GOLDEN_VERSION 3 stay untouched, so this lands without recapturing anything.
1184 * The overlays are also not per-scene state - they are driven by frame counts and
1185 * push setters, not by the register/VRAM scenes.
1186 *
1187 * Three groups, in the priority order the work was scoped:
1188 *
1189 * text pico9918_diag_render_text - the highest-value target, because it is the
1190 * ONE text path shared by the diag panels and the host's pending-display
1191 * banner, and the banner calls it from the hot border path.
1192 * splash pico9918_splash_render - driven over a fixed frame range so the enter,
1193 * hold, exit and reset positions are all pinned.
1194 * panels pico9918_diag_render - partial by necessity, see the panel note.
1195 * ------------------------------------------------------------------------- */
1196
1197#include "overlay/diag.h"
1198#include "overlay/splash.h"
1199
1200#define OVERLAY_MAGIC "TMSO"
1201#define OVERLAY_VERSION 1
1202
1203/* The overlay assets.
1204 *
1205 * The generated overlay/bmp_font.h and overlay/bmp_splash.h cannot simply be
1206 * included here: img2carray.py emits the dimensions as `const int splashWidth =
1207 * 176;` - file scope, external linkage, initialised - so a second TU including a
1208 * generated header is a duplicate-definition link error ("multiple definition of
1209 * splashWidth", verified with MinGW ld). Only the library TU that already
1210 * includes each header may define those objects.
1211 *
1212 * The arrays are therefore declared extern here, and the ASSET GEOMETRY the
1213 * reference needs is restated as harness constants. That is not a workaround so
1214 * much as the right dependency: the reference should take the asset BYTES, which
1215 * are the input under test, and not the library's own idea of how they are
1216 * shaped. To keep the restatement from going stale if an asset is ever resized,
1217 * the generated dimension MACROS are pulled in with the colliding const-int
1218 * definitions renamed aside, and static-asserted against. */
1219extern uint8_t font[];
1220extern uint8_t splash[];
1221extern PICO9918_PIXEL_T splash_pal[];
1222
1223#define OVERLAY_FONT_WIDTH 768
1224#define OVERLAY_FONT_HEIGHT 6
1225#define OVERLAY_SPLASH_WIDTH 176
1226#define OVERLAY_SPLASH_HEIGHT 10
1227
1228/* rename the generated const-int dimension objects aside so including the
1229 * headers here does not collide with the library's definitions, then check the
1230 * constants above against the generated macros. The renamed objects are unused
1231 * and the arrays re-declare identically to the externs above. */
1232#define fontWidth goldenUnusedFontWidth
1233#define fontHeight goldenUnusedFontHeight
1234#define splashWidth goldenUnusedSplashWidth
1235#define splashHeight goldenUnusedSplashHeight
1236#include "overlay/bmp_font.h"
1237#include "overlay/bmp_splash.h"
1238#undef fontWidth
1239#undef fontHeight
1240#undef splashWidth
1241#undef splashHeight
1242
1243_Static_assert(OVERLAY_FONT_WIDTH == FONT_WIDTH &&
1244 OVERLAY_FONT_HEIGHT == FONT_HEIGHT,
1245 "overlay font geometry has drifted from the generated asset");
1246_Static_assert(OVERLAY_SPLASH_WIDTH == SPLASH_WIDTH &&
1247 OVERLAY_SPLASH_HEIGHT == SPLASH_HEIGHT,
1248 "overlay splash geometry has drifted from the generated asset");
1249
1250/* Overlay rendering needs a PIXEL buffer, not the indexed scanline buffer.
1251 *
1252 * 642 = RGB_PIXELS_X, the firmware's real buffer width (src/display.h: 640 plus
1253 * two guard pixels for PIO autopull). Matching it exactly is required, not
1254 * cosmetic, because the widest thing rendered here is addressed against it: the
1255 * banner's centring is `(RGB_PIXELS_X - len * PICO9918_DIAG_CHAR_WIDTH) / 2`
1256 * (src/renderer.c), so a different width moves every banner pixel, and the register panel
1257 * starts at `636 - PICO9918_DIAG_CHAR_WIDTH * 13` and runs to 636. The palette
1258 * strip reaches furthest: renderPalette writes 32-bit pairs at pair index 32 and
1259 * advances 16 per swatch for 16 swatches, so its last store lands on pair 287,
1260 * i.e. pixels 574..575. All inside 642. */
1261#define OVERLAY_PIXELS_X 642
1262
1263static PICO9918_PIXEL_T ovLib[OVERLAY_PIXELS_X];
1264static PICO9918_PIXEL_T ovRef[OVERLAY_PIXELS_X];
1265
1266/* Prefill, and it is load-bearing rather than hygiene.
1267 *
1268 * renderText does not paint a background: every non-glyph pixel goes through
1269 * darken(), which READS the framebuffer pixel already there and writes back a
1270 * dimmed version. With a zero buffer every darkened pixel is 0 and a broken
1271 * darken() is invisible. A non-trivial prefill makes the dim arithmetic
1272 * observable - so the mask in `(pixels[x] >> 2) & 0x333` is covered.
1273 *
1274 * The pattern is a fixed affine walk over 16 bits, not the LCG: the LCG is scene
1275 * content and is reseeded per scene, and this must not depend on scene order. */
1276#define OVERLAY_PREFILL_SEED 0xfedc
1277
1278static void overlayPrefill(void)
1279{
1280 uint16_t v = OVERLAY_PREFILL_SEED;
1281 for (int i = 0; i < OVERLAY_PIXELS_X; ++i)
1282 {
1283 ovLib[i] = (PICO9918_PIXEL_T)v;
1284 ovRef[i] = (PICO9918_PIXEL_T)v;
1285 v = (uint16_t)(v * 2053u + 13849u);
1286 }
1287}
1288
1289/* Restore [from, to) of one surface to the prefill, so a partial reference replay
1290 * rebuilds over the same input the library saw. Used by the panel group, where
1291 * only some column spans of a row are independently modelled. */
1292static void overlayRestorePrefill(PICO9918_PIXEL_T* dst, int from, int to)
1293{
1294 uint16_t v = OVERLAY_PREFILL_SEED;
1295 for (int i = 0; i < to; ++i)
1296 {
1297 if (i >= from) dst[i] = (PICO9918_PIXEL_T)v;
1298 v = (uint16_t)(v * 2053u + 13849u);
1299 }
1300}
1301
1302/* Independent reference for darken().
1303 *
1304 * The library's darken() is documented pre-existing UB: `pixels[x++] =
1305 * (pixels[x] >> 2) & 0x333` modifies x and reads pixels[x] with no intervening
1306 * sequence point. Both compilers that matter resolve it the same way - ARM GCC
1307 * emits ldrh/strh at the SAME address (the port note in diag.c), and
1308 * MinGW GCC 15.2 at -O0 and -O2 likewise darkens in place. So the reference
1309 * models darken-IN-PLACE-then-advance, which is the behaviour that actually
1310 * ships.
1311 *
1312 * That choice also makes this surface a useful gate for the approved UB fix:
1313 * rewriting the library to `pixels[x] = (pixels[x] >> 2) & 0x333; return x + 1;`
1314 * is exactly this, so the fix must leave the overlay goldens UNCHANGED. A fix
1315 * that moved them would mean the accidental behaviour was not what was assumed.
1316 *
1317 * Written as an explicit shift-and-mask on a named temporary rather than as
1318 * PICO9918_PIXEL_DARKEN, so the policy macro is cross-checked rather than reused. */
1319static int refDarken(int x, PICO9918_PIXEL_T* pixels)
1320{
1321 const unsigned in = pixels[x];
1322 pixels[x] = (PICO9918_PIXEL_T)((in >> 2) & 0x333);
1323 return x + 1;
1324}
1325
1326/* Independent reference for the font glyph fetch.
1327 *
1328 * The library indexes with `font[fontY * FONT_CHARS - FONT_FIRST]` biased once
1329 * outside the character loop, which is a fused expression whose correctness is
1330 * not obvious. Reimplemented here from the ASSET GEOMETRY instead, so a
1331 * mis-fused index diverges:
1332 *
1333 * bmp_font.h says font.png is 768 x 6, 1bpp -> 96 bytes per image row, 6 rows.
1334 * So one byte per character, 96 characters across, one image row per glyph
1335 * row. A character maps to a column by subtracting 32 (space is column 0).
1336 * The glyph is the byte's LOW PICO9918_DIAG_CHAR_WIDTH bits, bit 5 leftmost -
1337 * which is what keeps every byte under 64 and the library's 64-entry mask
1338 * table in range.
1339 *
1340 * Deliberately computes the row-bytes and column arithmetic separately rather
1341 * than reusing the library's pre-biased pointer, so an off-by-one bias or a
1342 * wrong row stride is visible. */
1343#define OVERLAY_FONT_ROW_BYTES (OVERLAY_FONT_WIDTH / 8)
1344
1345static uint8_t refGlyphRowBits(char ch, int fy)
1346{
1347 const int col = (unsigned char)ch - 32;
1348 return font[fy * OVERLAY_FONT_ROW_BYTES + col];
1349}
1350
1351/* Independent reference for pico9918_diag_render_text, from its documented
1352 * behaviour:
1353 *
1354 * Row gate: fontY = scanline - y; outside 0..PICO9918_DIAG_CHAR_HEIGHT-1 nothing
1355 * is written and x comes back unchanged (so a chained caller's layout does not
1356 * shift on non-glyph rows).
1357 * Then, per character, PICO9918_DIAG_CHAR_WIDTH pixels: `fg` where the glyph bit
1358 * is set, darkened framebuffer where it is not. Returns x just past the last
1359 * pixel written.
1360 *
1361 * The glyph walk is written one pixel at a time, as an explicit bit test at
1362 * position 5-i, rather than the library's masked word pairs, so a wrong walk
1363 * direction, a wrong advance or a mis-built mask table diverges. */
1364static int refRenderText(uint16_t scanline, const char* text, uint16_t x, uint16_t y,
1365 PICO9918_PIXEL_T fg, PICO9918_PIXEL_T* pixels)
1366{
1367 const int fontY = (int)scanline - (int)y;
1368 if (fontY < 0 || fontY >= PICO9918_DIAG_CHAR_HEIGHT) return x;
1369
1370 int xPos = x;
1371 for (const char* p = text; *p; ++p)
1372 {
1373 const uint8_t bits = refGlyphRowBits(*p, fontY);
1374 for (int i = 0; i < PICO9918_DIAG_CHAR_WIDTH; ++i)
1375 {
1376 if (bits & (uint8_t)(1u << (PICO9918_DIAG_CHAR_WIDTH - 1 - i))) pixels[xPos++] = fg;
1377 else xPos = refDarken(xPos, pixels);
1378 }
1379 }
1380 return xPos;
1381}
1382
1383/* ---- splash reference -----------------------------------------------------
1384 *
1385 * Models the animation and the row gate from splash.c's documented
1386 * behaviour, including the DELIBERATE uint16 wraparound that IS the row gate
1387 * (see the comment there - it must be modelled, not "fixed"): logoOffset goes
1388 * negative, so for rows outside the band `y - (vBorder + vPixels + logoOffset)`
1389 * wraps to a large uint16 and the `< splashHeight` test is false.
1390 *
1391 * The unpack is written as a shift-then-mask (`(c >> (6 - px*2)) & 0x03`) rather
1392 * than the library's walking pixMask/offset pair, so a broken mask walk or a
1393 * reversed pixel order diverges.
1394 *
1395 * splash / splash_pal are the same generated asset bytes the library reads - the
1396 * asset is the input, not the thing under test. */
1397static int refLogoOffset;
1398static bool refCanHide;
1399
1400/* SPLASH_START_POS is private to splash.c; recomputed here from the
1401 * documented frame constants and the asset height. */
1402#define OVERLAY_SPLASH_ENTER_FRAMES 60
1403#define OVERLAY_SPLASH_HOLD_FRAMES 180
1404#define OVERLAY_SPLASH_START_POS \
1405 (OVERLAY_SPLASH_ENTER_FRAMES + OVERLAY_SPLASH_HEIGHT + 2)
1406
1407static void refSplashReset(void)
1408{
1409 refLogoOffset = OVERLAY_SPLASH_START_POS;
1410}
1411
1412static void refSplashRender(uint16_t y, uint32_t frameCount, uint32_t vBorder,
1413 uint32_t vPixels, uint32_t vVirtualPixels,
1414 PICO9918_PIXEL_T* pixels)
1415{
1416 if (y == 0)
1417 {
1418 if (frameCount < OVERLAY_SPLASH_ENTER_FRAMES) --refLogoOffset;
1419 else if (refCanHide &&
1420 frameCount > (OVERLAY_SPLASH_ENTER_FRAMES + OVERLAY_SPLASH_HOLD_FRAMES))
1421 ++refLogoOffset;
1422 }
1423
1424 if (y > vVirtualPixels) return;
1425
1426 /* the intentional narrowing, spelled out */
1427 const uint16_t row =
1428 (uint16_t)(y - (uint16_t)(vBorder + vPixels + (uint32_t)refLogoOffset));
1429 if (row >= OVERLAY_SPLASH_HEIGHT) return;
1430
1431 const int leftBorderPx = 4;
1432 const uint8_t* src = splash + (row * OVERLAY_SPLASH_WIDTH / 4);
1433 for (int x = 0; x < OVERLAY_SPLASH_WIDTH; x += 4)
1434 {
1435 const uint8_t c = *src++;
1436 for (int px = 0; px < 4; ++px)
1437 {
1438 const uint8_t palIndex = (uint8_t)((c >> (6 - px * 2)) & 0x03);
1439 if (palIndex) pixels[leftBorderPx + x + px] = splash_pal[palIndex];
1440 }
1441 }
1442}
1443
1444/* ---- overlay row bookkeeping ---------------------------------------------- */
1445
1446/* One digest per captured row, plus the value-for-value cross-check. Same
1447 * reasoning as the post-palette surfaces: raw pixels would be 1.2 KB per row for
1448 * no extra diagnostic power, since a mismatch is localised exactly by rescanning
1449 * the two buffers. */
1450/* Headroom over the current row count, not a snug fit. Exhausting this budget
1451 * TRUNCATES the surface, and because capture and compare truncate identically the
1452 * suite still reports PASS - a silent loss of coverage. Adding one panel case hit
1453 * exactly that. Raise this before adding cases rather than after. */
1454#define OVERLAY_MAX_ROWS 4096
1455
1456static uint64_t overlayDigests[OVERLAY_MAX_ROWS][2];
1457static int overlayRowCount;
1458
1459/* first library-vs-reference disagreement, latched (the buffers are reused) */
1460static int ovFailRow;
1461static int ovFailPixel;
1462static uint16_t ovFailLib;
1463static uint16_t ovFailRef;
1464
1465/* label for the failing row, so a divergence names the case rather than a row
1466 * index. Points at a string literal in the row-emitting code. */
1467static const char* ovRowLabel[OVERLAY_MAX_ROWS];
1468static const char* ovCurrentLabel = "";
1469
1470static void overlayEmitRow(void)
1471{
1472 if (overlayRowCount >= OVERLAY_MAX_ROWS)
1473 {
1474 /* Abort rather than return. Truncating silently drops coverage while both
1475 * capture and compare truncate the same way, so the suite would keep saying
1476 * PASS with rows missing - the one failure mode a gate must never have. */
1477 printf("[ERROR] overlay: row budget %d exhausted - raise OVERLAY_MAX_ROWS\n",
1478 OVERLAY_MAX_ROWS);
1479 exit(2);
1480 }
1481
1482 const int row = overlayRowCount++;
1483 ovRowLabel[row] = ovCurrentLabel;
1484 overlayDigests[row][0] = fnv1a(ovLib, sizeof(ovLib));
1485 overlayDigests[row][1] = fnv1a(ovRef, sizeof(ovRef));
1486
1487 if (ovFailRow < 0)
1488 {
1489 for (int i = 0; i < OVERLAY_PIXELS_X; ++i)
1490 {
1491 if (ovLib[i] != ovRef[i])
1492 {
1493 ovFailRow = row;
1494 ovFailPixel = i;
1495 ovFailLib = (uint16_t)ovLib[i];
1496 ovFailRef = (uint16_t)ovRef[i];
1497 break;
1498 }
1499 }
1500 }
1501}
1502
1503/* ---- the text group -------------------------------------------------------
1504 *
1505 * Every row of the glyph band plus one row either side of it, so the row gate's
1506 * boundaries are pinned in both directions - an off-by-one in
1507 * `fontY >= PICO9918_DIAG_CHAR_HEIGHT` shows up as a row that suddenly renders (or
1508 * stops rendering).
1509 *
1510 * The cases between them cover: the banner as the firmware really calls it
1511 * (both banner strings, centred by src/renderer.c's own formula, at its y of 8, in its
1512 * BANNER_FG); the panel colours labelColor / valueColor / unitsColor, which are
1513 * the literals the two colour-literal traps were found in; a chained run, which
1514 * is how every panel row is built and the only thing that catches a wrong x
1515 * advance; glyphs from all four rows of the font sheet, so a cell-row indexing
1516 * error cannot hide; and x positions at both ends of the buffer. */
1517
1518/* src/renderer.c's own centring formula, kept textually so a change there is visible as
1519 * a golden diff here. RGB_PIXELS_X is host-side, hence OVERLAY_PIXELS_X. */
1520#define OVERLAY_CENTRE_X(text) \
1521 ((uint16_t)((OVERLAY_PIXELS_X - (sizeof(text) - 1) * PICO9918_DIAG_CHAR_WIDTH) / 2))
1522
1523/* src/renderer.c's BANNER_FG, likewise: the masked white the banner really uses */
1524#define OVERLAY_BANNER_FG ((PICO9918_PIXEL_T)(PICO9918_PIXEL_FROM_RGB12(0xff0f) & 0x0fff))
1525
1526/* the panel colours are exported by the diag TU (labelColor / valueColor /
1527 * unitsColor) but not declared in its header - they are read here rather than
1528 * recomputed, because the POINT is to pin the values the library actually holds.
1529 * A wrong literal in diag.c must move these digests. */
1530extern const PICO9918_PIXEL_T labelColor;
1531extern const PICO9918_PIXEL_T valueColor;
1532extern const PICO9918_PIXEL_T unitsColor;
1533
1534/* one text case: render the same call into both surfaces for every row in
1535 * y-1 .. y+PICO9918_DIAG_CHAR_HEIGHT, digesting each row.
1536 *
1537 * The RETURNED x is folded into the row as well, at a pixel the call cannot
1538 * reach: a defect that shifted the whole run left by one AND changed the return
1539 * value in step would otherwise be invisible to a digest of the pixels alone.
1540 * OVERLAY_PIXELS_X-1 is past every case's rightmost write (the widest is the
1541 * palette strip at 575, the register panel ends at 636 and is not a text case). */
1542static void overlayTextCase(const char* label, const char* text, uint16_t x,
1543 uint16_t y, PICO9918_PIXEL_T fg)
1544{
1545 ovCurrentLabel = label;
1546 for (int scanline = (int)y - 1; scanline <= (int)y + PICO9918_DIAG_CHAR_HEIGHT; ++scanline)
1547 {
1548 overlayPrefill();
1549
1550 const int libX = pico9918_diag_render_text((uint16_t)scanline, text, x, y, fg, ovLib);
1551 const int refX = refRenderText((uint16_t)scanline, text, x, y, fg, ovRef);
1552
1553 ovLib[OVERLAY_PIXELS_X - 1] = (PICO9918_PIXEL_T)libX;
1554 ovRef[OVERLAY_PIXELS_X - 1] = (PICO9918_PIXEL_T)refX;
1555
1556 overlayEmitRow();
1557 }
1558}
1559
1560static void overlayTextGroup(void)
1561{
1562 /* the two real banners, exactly as src/renderer.c renders them */
1563 overlayTextCase("banner-await-pc", "POWER CYCLE TO TEST NEW CONFIGURATION",
1564 OVERLAY_CENTRE_X("POWER CYCLE TO TEST NEW CONFIGURATION"), 8, OVERLAY_BANNER_FG);
1565 overlayTextCase("banner-await-ok", "OPEN CONFIGURATOR TO CONFIRM NEW SETTINGS",
1566 OVERLAY_CENTRE_X("OPEN CONFIGURATOR TO CONFIRM NEW SETTINGS"), 8, OVERLAY_BANNER_FG);
1567
1568 /* the panel colours, on the panel labels they actually colour */
1569 overlayTextCase("label-color", "HWVER : ", 2, 0, labelColor);
1570 overlayTextCase("value-color", "1.0.2", 50, 0, valueColor);
1571 overlayTextCase("units-color", "MHZ", 80, 0, unitsColor);
1572
1573 /* the whole printable range the sheet holds, in four runs of sixteen: codes
1574 * 32..47, 48..63, 64..79, 80..95. A column-bias error cannot render all four
1575 * correctly. */
1576 overlayTextCase("font-row0", " !\"#$%&'()*+,-./", 4, 0, labelColor);
1577 overlayTextCase("font-row1", "0123456789:;<=>?", 4, 0, labelColor);
1578 overlayTextCase("font-row2", "@ABCDEFGHIJKLMNO", 4, 0, labelColor);
1579 overlayTextCase("font-row3", "PQRSTUVWXYZ[\\]^_", 4, 0, labelColor);
1580
1581 /* the register panel's own alphabet: nibbleBinStr uses '(' and ')' as the bit
1582 * glyphs and the row label is "R00:" through "R63:" */
1583 overlayTextCase("reg-glyphs", "R49:))((", 8, 0, valueColor);
1584
1585 /* a chained run, three calls with three colours - how every panel row is
1586 * really built, and the only case that catches a wrong x advance */
1587 ovCurrentLabel = "chained-run";
1588 for (int scanline = -1; scanline <= PICO9918_DIAG_CHAR_HEIGHT; ++scanline)
1589 {
1590 overlayPrefill();
1591 int lx = 2, rx = 2;
1592 lx = pico9918_diag_render_text((uint16_t)scanline, "TEMP : ", (uint16_t)lx, 0, labelColor, ovLib);
1593 lx = pico9918_diag_render_text((uint16_t)scanline, "42.75", (uint16_t)lx, 0, valueColor, ovLib);
1594 lx = pico9918_diag_render_text((uint16_t)scanline, "^C", (uint16_t)lx, 0, unitsColor, ovLib);
1595 rx = refRenderText((uint16_t)scanline, "TEMP : ", (uint16_t)rx, 0, labelColor, ovRef);
1596 rx = refRenderText((uint16_t)scanline, "42.75", (uint16_t)rx, 0, valueColor, ovRef);
1597 rx = refRenderText((uint16_t)scanline, "^C", (uint16_t)rx, 0, unitsColor, ovRef);
1598 ovLib[OVERLAY_PIXELS_X - 1] = (PICO9918_PIXEL_T)lx;
1599 ovRef[OVERLAY_PIXELS_X - 1] = (PICO9918_PIXEL_T)rx;
1600 overlayEmitRow();
1601 }
1602
1603 /* a non-zero y, so the row gate is not only ever exercised at y == 0 */
1604 overlayTextCase("y-offset", "OUTPUT: 480P @60", 2, 200, valueColor);
1605
1606 /* single glyph at x == 0: the leading darken() writes pixel 0, so nothing
1607 * before the run is touched and the first-pixel boundary is pinned */
1608 overlayTextCase("x-zero", "8", 0, 0, valueColor);
1609}
1610
1611/* ---- the splash group -----------------------------------------------------
1612 *
1613 * Driven over a fixed frame range so all three phases are pinned by position,
1614 * not by inspection: ENTER (logoOffset decrementing once per frame while
1615 * frameCount < 60), HOLD (stationary from 60 until AllowHide plus frameCount >
1616 * 240), and EXIT (incrementing again). Every frame's y == 0 call is made, since
1617 * that call IS the animation clock - skipping frames would desynchronise the
1618 * offset.
1619 *
1620 * Geometry is the shipping VGA 480p case: vVirtualPixels 240 (480 display rows
1621 * at vPixelScale 2), vPixels 192 (24 rows of 8), vBorder (240-192)/2 = 24. So
1622 * the logo band sits at 216 + logoOffset and enters the visible region as
1623 * logoOffset falls.
1624 *
1625 * Rows captured per sampled frame are the band and one row either side, which is
1626 * what pins the row gate: with the enter animation stopping at logoOffset 12 the
1627 * band is rows 228..237, and the wraparound gate means row 227 and row 238 must
1628 * render NOTHING. An off-by-one in the gate moves that boundary.
1629 * ------------------------------------------------------------------------- */
1630#define OVERLAY_SPLASH_VVIRT 240u
1631#define OVERLAY_SPLASH_VPIXELS 192u
1632#define OVERLAY_SPLASH_VBORDER ((OVERLAY_SPLASH_VVIRT - OVERLAY_SPLASH_VPIXELS) / 2u)
1633
1634static void overlaySplashFrame(uint32_t frameCount, bool sample)
1635{
1636 /* y == 0 advances the animation on both sides; it is also a real row, but
1637 * row 0 is never in the band so nothing is drawn. */
1638 overlayPrefill();
1639 pico9918_splash_render(0, frameCount, OVERLAY_SPLASH_VBORDER, OVERLAY_SPLASH_VPIXELS,
1640 OVERLAY_SPLASH_VVIRT, ovLib);
1641 refSplashRender(0, frameCount, OVERLAY_SPLASH_VBORDER, OVERLAY_SPLASH_VPIXELS,
1642 OVERLAY_SPLASH_VVIRT, ovRef);
1643 if (sample) overlayEmitRow();
1644
1645 if (!sample) return;
1646
1647 /* the whole bottom border, so wherever the band currently is it is captured
1648 * along with the blank rows around it - no need to know logoOffset here, and
1649 * a gate that shifted the band by one row moves two digests */
1650 for (uint16_t y = (uint16_t)(OVERLAY_SPLASH_VBORDER + OVERLAY_SPLASH_VPIXELS);
1651 y <= OVERLAY_SPLASH_VVIRT; ++y)
1652 {
1653 overlayPrefill();
1654 pico9918_splash_render(y, frameCount, OVERLAY_SPLASH_VBORDER, OVERLAY_SPLASH_VPIXELS,
1655 OVERLAY_SPLASH_VVIRT, ovLib);
1656 refSplashRender(y, frameCount, OVERLAY_SPLASH_VBORDER, OVERLAY_SPLASH_VPIXELS,
1657 OVERLAY_SPLASH_VVIRT, ovRef);
1658 overlayEmitRow();
1659 }
1660}
1661
1662static void overlaySplashGroup(void)
1663{
1665 refSplashReset();
1666 refCanHide = false;
1667
1668 /* ENTER: sample the first frame the band becomes visible and a few after.
1669 * The band is at 216 + logoOffset and the captured window is 216..240, so it
1670 * first intrudes when logoOffset <= 24, i.e. frameCount >= 48. */
1671 ovCurrentLabel = "splash-enter";
1672 for (uint32_t f = 0; f < OVERLAY_SPLASH_ENTER_FRAMES; ++f)
1673 {
1674 overlaySplashFrame(f, f >= 46 && f <= 50);
1675 }
1676
1677 /* HOLD, part 1: AllowHide has NOT been called, so the offset must stay put.
1678 * This pins the AllowHide half of the exit condition. */
1679 ovCurrentLabel = "splash-hold";
1680 for (uint32_t f = OVERLAY_SPLASH_ENTER_FRAMES; f <= 199; ++f)
1681 {
1682 overlaySplashFrame(f, f == 60 || f == 199);
1683 }
1684
1685 /* HOLD, part 2: allow the hide while still INSIDE the hold window, then sweep
1686 * across frame 240 (ENTER 60 + HOLD 180). The exit is gated on BOTH canHide and
1687 * frameCount > 240, and enabling hide only after the window had already elapsed
1688 * left the frame-count half completely unpinned - SPLASH_HOLD_FRAMES could be
1689 * set to 0 and the goldens still passed. Sampling
1690 * 239/240 (must be stationary) against 241/242 (must have moved) pins it. */
1692 refCanHide = true;
1693 for (uint32_t f = 200; f <= 245; ++f)
1694 {
1695 overlaySplashFrame(f, f == 239 || f == 240 || f == 241 || f == 242 || f == 245);
1696 }
1697
1698 /* EXIT: frameCount is now well past the hold window, so the offset keeps
1699 * climbing by one per frame and the band walks back down out of the visible
1700 * region. */
1701 ovCurrentLabel = "splash-exit";
1702 /* the band starts at 216 + logoOffset and logoOffset is 12 here, so the band
1703 * walks out of the captured 216..240 window once logoOffset passes 24, i.e.
1704 * around frame 259. Sample the transit and the first frame past it; later
1705 * frames are all-blank rows that pin nothing the boundary rows do not. */
1706 for (uint32_t f = 246; f <= 275; ++f)
1707 {
1708 overlaySplashFrame(f, (f >= 246 && f <= 249) || (f >= 257 && f <= 261));
1709 }
1710
1711 /* Reset mid-animation: the offset must jump back to the start position, so
1712 * the band leaves the visible region again. Pins pico9918_splash_reset(). */
1714 refSplashReset();
1715 ovCurrentLabel = "splash-reset";
1716 overlaySplashFrame(276, true);
1717}
1718
1719/* ---- the panel group ------------------------------------------------------
1720 *
1721 * Every PICO9918_CONF_DIAG_* panel: REGISTERS (both the locked 8-register form and the
1722 * unlocked extended one, which is the only path through extReg[]), ADDRESS, PALETTE and
1723 * PERFORMANCE. The performance panel is only repeatable because goldenClock.h replaces
1724 * PICO9918_HOST_TIME_US with a deterministic counter; a wall clock makes gpuPctStr and
1725 * the GPU row's glyphs change run to run.
1726 *
1727 * Two coverage limits, so nobody reads more into these rows than they pin:
1728 *
1729 * the GPU% row gpuTimeUs is structurally 0 here - only pico9918_gpu_loop feeds it and
1730 * this harness never runs it - so the row is pinned at 0.000 and its
1731 * divisor and *100 scale are NOT exercised. Placement and glyph plumbing
1732 * are gated; the arithmetic is not.
1733 * uint2Str covered only with PICO9918_DIAG_GPU_FRAME_COUNTER=ON, the GPU-frames
1734 * row being its only call site.
1735 *
1736 * The reference is deliberately not a reimplementation of the whole panel layout - that
1737 * would be a copy of pico9918_diag_render's control flow. Only the register panel and
1738 * the palette strip are replayed independently; the left panels' columns are seeded from
1739 * the library's own row and are covered by the golden digest alone. The modelled spans
1740 * are called out at the site.
1741 * ------------------------------------------------------------------------- */
1742
1743/* Independent reference for the palette-strip swatch transform, from
1744 * renderPalette's documented behaviour: take the pram entry, keep 0xFF0F (drop
1745 * the alpha nibble), copy green (bits 15-12) down into bits 7-4, then mask to 12
1746 * bits. That trailing mask is what keeps the RP2040 CRT-dim path from bleeding
1747 * green into blue's MSB - the same trap as DIAG_COLOR's & 0x0fff.
1748 *
1749 * Written as nibble extraction and reassembly, like refPixel above, so it shares
1750 * no algebra with the library's mask/shift chain. */
1751static PICO9918_PIXEL_T refSwatch(uint16_t pram)
1752{
1753 const unsigned g = (pram >> 12) & 0x0f;
1754 const unsigned b = (pram >> 8) & 0x0f;
1755 const unsigned r = pram & 0x0f;
1756 return (PICO9918_PIXEL_T)((b << 8) | (g << 4) | r);
1757}
1758
1759/* Replay of renderPalette's swatch geometry, from its documented behaviour: the
1760 * label at leftXPos on rows 0..5, and on rows 0..4 sixteen swatches starting at
1761 * pair index 32, each 15 pairs (30 pixels) wide followed by a one-pair gap. */
1762static void refPalettePanel(int y, uint32_t vVirtualPixels, PICO9918_PIXEL_T* pixels)
1763{
1764 const int row = y % 6;
1765 const uint8_t palette = (uint8_t)((y - ((int)vVirtualPixels - 24)) / 6);
1766 if (palette >= 4) return;
1767
1768 char buf[] = "PALETTE 0:";
1769 buf[8] = (char)('0' + palette);
1770 refRenderText((uint16_t)row, buf, 2, 0, labelColor, pixels);
1771
1772 if (row >= 5) return;
1773
1774 int pair = 32;
1775 for (int c = 0; c < 16; ++c)
1776 {
1777 const PICO9918_PIXEL_T sw = refSwatch(tms9918->vram.map.pram[palette * 16 + c]);
1778 for (int i = 0; i < 30; ++i) pixels[pair * 2 + i] = sw;
1779 pair += 16;
1780 }
1781}
1782
1783/* Replay of the register panel's composition, from pico9918_diag_render: label
1784 * "Rnn:" at 636 - 6*13, two darkened pad pixels, the high nibble as four bit
1785 * glyphs, two more pad pixels, the low nibble. nibbleBinStr maps a nibble to
1786 * "((((".."))))" with ')' for a set bit, MSB first. */
1787static void refRegisterPanel(int diagRow, int row, PICO9918_PIXEL_T* pixels)
1788{
1789 static const char* const bins[] = {
1790 "((((", "((()", "(()(", "(())",
1791 "()((", "()()", "())(", "()))",
1792 ")(((", ")(()", ")()(", ")())",
1793 "))((", "))()", ")))(", "))))",
1794 };
1795 const unsigned reg = (unsigned)diagRow;
1796 char buf[] = "R00:";
1797 buf[1] = (char)('0' + reg / 10u);
1798 buf[2] = (char)('0' + reg % 10u);
1799
1800 int xPos = 636 - PICO9918_DIAG_CHAR_WIDTH * 13;
1801 xPos = refRenderText((uint16_t)row, buf, (uint16_t)xPos, 0, labelColor, pixels);
1802 if (row < 0 || row >= PICO9918_DIAG_CHAR_HEIGHT) return;
1803 const uint8_t value = (uint8_t)pico9918_reg_value((uint8_t)reg);
1804 xPos = refDarken(xPos, pixels);
1805 xPos = refDarken(xPos, pixels);
1806 xPos = refRenderText((uint16_t)row, bins[value >> 4], (uint16_t)xPos, 0, valueColor, pixels);
1807 xPos = refDarken(xPos, pixels);
1808 xPos = refDarken(xPos, pixels);
1809 refRenderText((uint16_t)row, bins[value & 0x0f], (uint16_t)xPos, 0, valueColor, pixels);
1810}
1811
1812/* the config bytes the panels read. Written directly: the library exposes no
1813 * per-byte config setter. */
1814static void overlaySetConfig(uint8_t index, uint8_t value)
1815{
1816 tms9918->config[index] = value;
1817}
1818
1819/* Prime every push setter with a fixed value. Without this the panel value
1820 * strings are whatever pico9918_diag_init() left (empty), and the panels render
1821 * label-only rows - a much weaker surface. No time, no rand: fixed constants
1822 * only, so the strings are byte-stable across runs.
1823 *
1824 * The performance values DO reach captured pixels: the injected clock
1825 * (goldenClock.h) makes PICO9918_CONF_DIAG_PERFORMANCE repeatable, the panel group
1826 * enables it, and flt2Str / uint2Str are covered through the render/frame-time, FPS,
1827 * GPU% and GPU-frames rows. The priming below is therefore load-bearing for those
1828 * rows, not just a way of keeping the diag module in one fixed state.
1829 *
1830 * Also reset here: the clock sequence itself, so a case's performance rows do not
1831 * depend on how many clock reads earlier cases made. Same reason the value strings
1832 * are primed - each case must be independent of scene order.
1833 *
1834 * THE ACCUMULATORS ARE THE OTHER CARRY-OVER, and they are the reason this function
1835 * ends with a discard update rather than just a push. pico9918_diag_init() does NOT
1836 * zero accumulatedRenderTime / accumulatedFrameTime / accumulatedScanlines /
1837 * lastUpdateTime - correctly so, since on target it runs once at boot when they are
1838 * already zero. But this harness re-primes the module once per panel case, and
1839 * the performance block CONSUMES those accumulators (it divides by
1840 * accumulatedScanlines, then zeroes all three, and stores lastUpdateTime). Left
1841 * alone, each case's first four performance rows would be a function of whatever
1842 * the previous case left behind - flaky in exactly the way that is worse than the
1843 * honest exclusion this step replaces.
1844 *
1845 * They are file statics of the diag TU with no public reset, so the harness brings
1846 * them to a known state through the public path instead of reaching in: one
1847 * throwaway push followed by one frame-0 update, which consumes and zeroes all
1848 * three and stamps lastUpdateTime from the freshly reset clock. Whatever the
1849 * history, the module is in the same state after this call. The real push follows,
1850 * so the captured rows are computed from it alone. */
1851static void overlayPrimeDiag(void)
1852{
1853 goldenClockReset();
1855 pico9918_diag_set_version_info("1.5", "1.0.2");
1856 pico9918_diag_set_output_name("480P ", "@60");
1857 /* 42.756, not 42.75, and the third decimal is the whole point.
1858 flt2Str rounds with `(uint32_t)(flt * 10^prec + 0.5f)`. The TEMP row uses
1859 prec 2, so 42.75 scales to exactly 4275.0 and truncation gives the same
1860 digits as rounding - deleting the `+ 0.5f` was NOT caught. Every
1861 other value primed here is exact too (0.300, 56.25, 0.000), so the rounding
1862 step had no covering input at all. 42.756 scales to 4275.6: rounds to 42.76,
1863 truncates to 42.75, so the row's last glyph changes. */
1865 pico9918_diag_set_clock_hz(252000000.0f);
1867 /* The frame module owns the dropped-frame and GPU-frame counters and the overlay
1868 reads them directly, so priming them means writing the globals - there is no
1869 setter. These reach real pixels through the FPS and GPU-frames rows.
1870
1871 THE THREE VALUES MUST BE PAIRWISE DISTINCT, and that is the whole point of
1872 writing pico9918_frame_count here. capture-baselines.sh records two gate holes of
1873 the same shape: swapping the FPS row's read from pico9918_dropped_frames_count to
1874 pico9918_frame_count, and the GPU-frames row's read from pico9918_gpu_frame_count to
1875 pico9918_frame_count, are invisible to the asm gate because both are same-typed
1876 global reads that move only the literal-pool .word payload - which the
1877 normalizer blinds by design. The goldens close both holes ONLY if the wrong
1878 global yields a different NUMBER. pico9918_frame_count is otherwise whatever the
1879 scene's scanlines left it at, which is not a value this harness controls, and
1880 if it ever coincided with 1 the FPS swap would silently escape again - the
1881 same class of trap as the recorded "R3 = 0x00 absorbed the mutation".
1882
1883 7 is chosen to differ from the dropped count (1) and the GPU count (12345),
1884 and to keep the swapped FPS figure well away from the correct one:
1885 correct (16 - 1) * 3.75 = 56.25
1886 swapped (16 - 7) * 3.75 = 33.75
1887 - a different digit in every position, so no rounding can absorb it. */
1888 pico9918_frame_count = 7;
1889 pico9918_dropped_frames_count = 1;
1890#if PICO9918_DIAG_GPU_FRAME_COUNTER
1891 pico9918_gpu_frame_count = 12345;
1892#endif
1893
1894 /* Drain whatever a previous case accumulated, per the note above. Needs
1895 PICO9918_CONF_DIAG_PERFORMANCE already set to reach the consuming branch, which is why
1896 the caller sets the config bytes BEFORE calling this. accumulatedScanlines is
1897 made nonzero first so the drain itself cannot divide by zero.
1898
1899 The clock is NOT re-reset after this: the drain leaves lastUpdateTime at the
1900 first tick of the reset sequence, and the real frame-0 update needs a LATER
1901 reading to make totalTime nonzero (the GPU% row divides by it). The sequence is
1902 deterministic from goldenClockReset() above, so both readings are fixed - the
1903 drain takes tick 0 and the real update takes tick 1, in every case. */
1906
1908}
1909
1910/* one panel configuration: set the config bytes, rebuild the row table, update
1911 * the values, then render the covered border rows. */
1912static void overlayPanelCase(const char* label, bool registers, bool address,
1913 bool palette, bool unlocked, bool gfx2,
1914 bool performance)
1915{
1916 /* deterministic VDP state for the panels to read - registers, the sprite
1917 * attribute table the address panel reports, and a full palette */
1918 sceneBegin();
1919 lcgSeed(0x0e11a7);
1920 vramFillLcg(0x0000, 0x4000);
1921 if (unlocked)
1922 {
1923 unlockF18a();
1924 paletteWriteLcg(64);
1925 }
1926
1927 /* Set bits 7-4 of some pram low bytes, which paletteWriteLcg CANNOT do: it
1928 * writes the first byte as `lcgByte() & 0x0f`, mirroring the data port's own
1929 * `data & 0x0f` stage-0 mask, so that nibble is permanently zero through this
1930 * path. It is exactly the nibble renderPalette's `& 0xFF0F` exists to drop, so
1931 * with LCG data alone the mask is untestable - dropping it entirely left the
1932 * goldens passing.
1933 *
1934 * The nibble IS reachable on real hardware: pico9918_config.c stores
1935 * `__builtin_bswap16(rgb)` straight from two config bytes with NO mask, so a
1936 * configurator palette entry lands it in pram bits 7-4. Written here directly
1937 * for the same reason - going through the bus would re-apply the mask being
1938 * tested. Values chosen so each keeps bits in 7-4 AND differs in the low 12
1939 * bits the swatch actually shows. */
1940 if (palette)
1941 {
1942 tms9918->vram.map.pram[0] = 0x3f70; /* G=3 B=f pad=7 R=0 */
1943 tms9918->vram.map.pram[5] = 0xc59a; /* G=c B=5 pad=9 R=a */
1944 tms9918->vram.map.pram[17] = 0x08f3; /* G=0 B=8 pad=f R=3 */
1945 tms9918->vram.map.pram[33] = 0xfae1;
1946 tms9918->vram.map.pram[49] = 0x714c;
1947 tms9918->palDirty = 1;
1948 }
1949 /* Every register the address panel reports is given a value with bits set in
1950 * EVERY position that panel's mask keeps, so a wrong mask changes a glyph.
1951 * Zeros here silently absorb mask defects: with R3 = 0x00 the colour-table row
1952 * reads 0000 under any mask at all, and mutating that mask escaped the goldens
1953 * until these values were chosen deliberately. */
1954 /* R0 bit 1 selects Graphics II, which the address panel reports through
1955 * DIFFERENT masks (0x80 for the colour table, 0x04 for the pattern table). With
1956 * only Graphics I cases those arms were dead code and their mutations escaped,
1957 * while the comments below claimed the coverage.
1958 * The gfx2 case exists to take the other arm. */
1959 regWrite(0, gfx2 ? 0x02 : 0x00);
1960 regWrite(1, 0xe0);
1961 if (gfx2)
1962 {
1963 /* pico9918_display_mode() returns a CACHED mode that is only refreshed when
1964 * pico9918_scan_line observes a register change (same subtlety sceneBegin
1965 * documents). Writing R0 alone leaves the cache reading Graphics I, so the
1966 * panel would still take the locked arm and the mutation would still escape -
1967 * it did, on the first attempt at this case. One scanline commits the mode. */
1969 }
1970 regWrite(2, 0x0d); /* T1 name - mask 0x0f, all four bits meaningful */
1971 /* R3/R4 need bits set in the positions a WIDENED mask would newly admit, not
1972 * just in the positions the correct mask keeps. 0xb7 and 0x05 satisfied the
1973 * locked masks but left bit 6 and bit 1 clear, so 0x80-vs-0xc0 and 0x04-vs-0x06
1974 * produced identical output and the Graphics II mutations escaped even once the
1975 * mode was right. Same trap as the R3=0x00 one recorded below, one level in. */
1976 regWrite(3, 0xf7); /* T1 color - mask 0xff locked, 0x80 in Graphics II */
1977 regWrite(4, 0x07); /* pattern - mask 0x07 locked, 0x04 in Graphics II */
1978 regWrite(5, 0x76); /* SAT - mask 0x7f */
1979 regWrite(6, 0x03); /* SPT - mask 0x07 */
1980 regWrite(7, 0xf4);
1981 if (unlocked)
1982 {
1983 regWrite(24, 0x16);
1984 regWrite(27, 0x05);
1985 regWrite(49, 0x11);
1986 regWrite(51, 0x18);
1987 }
1988 /* latch the display mode the MODE row and the colour-table mask read - it is
1989 * a cached value refreshed inside pico9918_scan_line */
1991
1992 overlaySetConfig(PICO9918_CONF_DIAG, 1);
1993 overlaySetConfig(PICO9918_CONF_DIAG_REGISTERS, registers);
1994 overlaySetConfig(PICO9918_CONF_DIAG_PERFORMANCE, performance);
1995 overlaySetConfig(PICO9918_CONF_DIAG_PALETTE, palette);
1996 overlaySetConfig(PICO9918_CONF_DIAG_ADDRESS, address);
1997
1998 overlayPrimeDiag();
2000 /* Fixed frame counts, and the VALUES matter.
2001 *
2002 * pico9918_diag_update recomputes each panel on a 4-frame cadence, and the three
2003 * panels sit on three different phases of it: the performance rows on 0, the
2004 * table addresses on 2, the FPS row on 3. No single call reaches all of them,
2005 * so a case updates three times - once per phase. All three calls are made
2006 * unconditionally rather than only when `performance` is set, so a case's input
2007 * does not depend on which panels it enables.
2008 *
2009 * The accumulators must be primed before the phase-0 call, which CONSUMES them
2010 * (it divides by accumulatedScanlines and then zeroes all three); the later two
2011 * phases do not touch them. */
2016
2017 ovCurrentLabel = label;
2018
2019 /* The WHOLE frame, every border row, not a couple of sampled bands.
2020 *
2021 * The first version swept rows 1..48 plus the last 28, which looked like
2022 * enough - the left panel stack is 15 rows tall and the palette strip is at
2023 * the bottom. Mutation-testing found the hole: the extended-register dump runs
2024 * to panel row 227 (diagRow 37), so perturbing extReg[7] - which is diagRow 15,
2025 * i.e. rows 91..96 - changed nothing anyone could see. Sweeping the frame
2026 * closes that class of gap outright rather than one register at a time, and it
2027 * also removes the two-band special case, so the reference replay has exactly
2028 * one form. 240 rows per case is 4 KB of digests; the coverage is worth more
2029 * than the bytes. */
2030 for (uint16_t y = 1; y <= OVERLAY_SPLASH_VVIRT; ++y)
2031 {
2032 overlayPrefill();
2033 pico9918_diag_render(y, OVERLAY_SPLASH_VVIRT, ovLib);
2034
2035 /* Reference: start from the library's row, then rebuild independently
2036 * whichever parts of the row ARE modelled - the palette strip and the
2037 * register panel. The left panels' text is built from the IntString value
2038 * plumbing and replaying it would be a copy of that plumbing rather than
2039 * independent evidence, so those columns stay as the library left them and
2040 * are covered by the digest alone (see the group note).
2041 *
2042 * The two modelled parts are rebuilt IN THE LIBRARY'S ORDER - palette strip,
2043 * then register panel - because their column spans OVERLAP. The strip's
2044 * sixteenth swatch runs to pixel 575 and the register panel starts at 558, so
2045 * whichever is drawn second wins those 18 pixels. Getting the order wrong was
2046 * caught by the value-for-value check at exactly that boundary (panel-all,
2047 * pixel 558). Each part is rebuilt over the prefill, i.e. over the same input
2048 * the library saw. */
2049 memcpy(ovRef, ovLib, sizeof(ovRef));
2050
2051 const int panelY = (int)y - 1;
2052 const int diagRow = panelY / 6;
2053 const int row = panelY % 6;
2054 const int regPanelX = 636 - PICO9918_DIAG_CHAR_WIDTH * 13;
2055
2056 /* the strip's own gate, from pico9918_diag_render: entered when the
2057 * border-adjusted row is past vVirtualPixels - 27, and renderPalette is
2058 * called with that row + 2 */
2059 const bool refStrip = palette && panelY > (int)OVERLAY_SPLASH_VVIRT - 27;
2060 if (refStrip)
2061 {
2062 overlayRestorePrefill(ovRef, 0, OVERLAY_PIXELS_X);
2063 refPalettePanel(panelY + 2, OVERLAY_SPLASH_VVIRT, ovRef);
2064 }
2065
2066 if (registers)
2067 {
2068 int maxReg = 8;
2069 if (unlocked) maxReg += 30; /* sizeof(extReg)/sizeof(int) */
2070 if (diagRow < maxReg)
2071 {
2072 static const int extRegRef[] = { 10, 11, 15, 19, 24, 25, 26, 27,
2073 28, 29, 30, 31, 32, 33, 34, 35,
2074 36, 37, 38, 48, 49, 50, 51, 54, 55,
2075 56, 57, 58, 59, 63 };
2076 const int reg = (diagRow >= 8) ? extRegRef[diagRow - 8] : diagRow;
2077 /* only restore the panel's own span when the strip did not just draw
2078 into it - the register panel legitimately darkens over strip pixels */
2079 if (!refStrip) overlayRestorePrefill(ovRef, regPanelX, OVERLAY_PIXELS_X);
2080 refRegisterPanel(reg, row, ovRef);
2081 }
2082 }
2083
2084 overlayEmitRow();
2085 }
2086}
2087
2088static void overlayPanelGroup(void)
2089{
2090 overlayPanelCase("panel-registers", true, false, false, false, false, false);
2091 overlayPanelCase("panel-registers-unlocked", true, false, false, true, false, false);
2092 overlayPanelCase("panel-address", false, true, false, false, false, false);
2093 overlayPanelCase("panel-palette", false, false, true, true, false, false);
2094 overlayPanelCase("panel-all", true, true, true, true, false, false);
2095 /* Graphics II: takes the other arm of the address panel's colour-table and
2096 * pattern-table mask selection, which no other case reaches. */
2097 overlayPanelCase("panel-address-gfx2", false, true, false, false, true, false);
2098
2099 /* The PERFORMANCE panel. Reachable only because goldenClock.h supplies a
2100 * deterministic counter in place of the wall clock this block reads: that is what
2101 * makes these rows byte-stable and the value plumbing behind them - flt2Str,
2102 * uint2Str and the unitsColor call site - testable at all. Every row in
2103 * performanceDiags[] is rendered here: HWVER, FWVER, CLOCK,
2104 * OUTPUT, FRAME, FPS, GPU, GPU FR (when compiled in) and TEMP.
2105 *
2106 * Two cases, and the second is not redundant. Performance alone pins the block
2107 * in isolation; performance WITH the address panel is what pins the two groups'
2108 * differing phases (`frameCount & 3` against `++frameCount & 3`) against each
2109 * other, so a change that merged them or shifted either cadence moves a
2110 * digest. That interaction is the subtlety the update-call note describes, and
2111 * it had no covering case at all. */
2112 overlayPanelCase("panel-performance", false, false, false, false, false, true);
2113 overlayPanelCase("panel-performance-address", false, true, false, true, false, true);
2114}
2115
2116/* ---- overlay artifact I/O ------------------------------------------------- */
2117static void overlayRender(void)
2118{
2119 overlayRowCount = 0;
2120 ovFailRow = -1;
2121 ovFailPixel = -1;
2122
2123 /* builds the glyph mask table, which the text and splash groups render through
2124 before the panel group primes the module for its own reasons */
2126
2127 overlayTextGroup();
2128 overlaySplashGroup();
2129 overlayPanelGroup();
2130}
2131
2132static bool overlayCapture(const char* dataDir)
2133{
2134 overlayRender();
2135
2136 if (ovFailRow >= 0)
2137 {
2138 printf("[ERROR] overlay library diverges from the reference at "
2139 "row %d (%s), pixel %d (lib 0x%04x, ref 0x%04x) - NOT captured\n",
2140 ovFailRow, ovRowLabel[ovFailRow], ovFailPixel, ovFailLib, ovFailRef);
2141 return false;
2142 }
2143
2144 char path[512];
2145 snprintf(path, sizeof(path), "%s/overlay.golden", dataDir);
2146 FILE* f = fopen(path, "wb");
2147 if (!f)
2148 {
2149 printf("[ERROR] overlay: cannot open %s for writing\n", path);
2150 return false;
2151 }
2152
2153 fwrite(OVERLAY_MAGIC, 1, 4, f);
2154 putU32(f, OVERLAY_VERSION);
2155 putU32(f, (uint32_t)overlayRowCount);
2156 putU32(f, OVERLAY_PIXELS_X);
2157 putU32(f, 2); /* digests per row: library, reference */
2158 for (int r = 0; r < overlayRowCount; ++r)
2159 {
2160 putU64(f, overlayDigests[r][0]);
2161 putU64(f, overlayDigests[r][1]);
2162 }
2163 fclose(f);
2164
2165 printf("[CAPTURED] %-19s %3d rows -> %s\n", "overlay", overlayRowCount, path);
2166 return true;
2167}
2168
2169static bool overlayCompare(const char* dataDir)
2170{
2171 char path[512];
2172 snprintf(path, sizeof(path), "%s/overlay.golden", dataDir);
2173 FILE* f = fopen(path, "rb");
2174 if (!f)
2175 {
2176 printf("[FAIL] %-19s missing golden file %s (run with --capture first)\n",
2177 "overlay", path);
2178 return false;
2179 }
2180
2181 char magic[4];
2182 uint32_t version = 0, rows = 0, width = 0, perRow = 0;
2183 const bool headerOk =
2184 fread(magic, 1, 4, f) == 4 && memcmp(magic, OVERLAY_MAGIC, 4) == 0 &&
2185 getU32(f, &version) && version == OVERLAY_VERSION &&
2186 getU32(f, &rows) &&
2187 getU32(f, &width) && width == OVERLAY_PIXELS_X &&
2188 getU32(f, &perRow) && perRow == 2;
2189 if (!headerOk)
2190 {
2191 printf("[FAIL] %-19s bad golden header in %s\n", "overlay", path);
2192 fclose(f);
2193 return false;
2194 }
2195
2196 overlayRender();
2197
2198 if (rows != (uint32_t)overlayRowCount)
2199 {
2200 printf("[FAIL] %-19s row count changed (golden %u, got %d)\n",
2201 "overlay", rows, overlayRowCount);
2202 fclose(f);
2203 return false;
2204 }
2205
2206 static const char* const surface[2] = { "library", "reference" };
2207 for (int r = 0; r < overlayRowCount; ++r)
2208 {
2209 for (int d = 0; d < 2; ++d)
2210 {
2211 uint64_t expected;
2212 if (!getU64(f, &expected))
2213 {
2214 printf("[FAIL] %-19s truncated golden file at row %d\n", "overlay", r);
2215 fclose(f);
2216 return false;
2217 }
2218 if (expected != overlayDigests[r][d])
2219 {
2220 printf("[FAIL] %-19s first divergence: row %d (%s) %s digest "
2221 "(expected 0x%016llx, got 0x%016llx)\n",
2222 "overlay", r, ovRowLabel[r], surface[d],
2223 (unsigned long long)expected, (unsigned long long)overlayDigests[r][d]);
2224 fclose(f);
2225 return false;
2226 }
2227 }
2228 }
2229
2230 if (ovFailRow >= 0)
2231 {
2232 printf("[FAIL] %-19s library diverges from the reference at row %d (%s), "
2233 "pixel %d (lib 0x%04x, ref 0x%04x)\n",
2234 "overlay", ovFailRow, ovRowLabel[ovFailRow], ovFailPixel, ovFailLib, ovFailRef);
2235 fclose(f);
2236 return false;
2237 }
2238
2239 fclose(f);
2240 printf("[PASS] %-19s %3d rows\n", "overlay", overlayRowCount);
2241 return true;
2242}
2243
2244/* ---------------------------------------------------------------------------
2245 * FRAME SURFACE (data/frame.golden, its own format - FRAME_VERSION)
2246 *
2247 * The 14 scenes above call pico9918_scan_line INDEXED, with a line number the
2248 * harness picks. So they are structurally blind to two things that decide what a
2249 * frame actually looks like:
2250 *
2251 * 1. the INTERLACE FIELD MAPPING, which chooses WHICH VDP line a given display
2252 * line renders. The scenes pin what line N looks like; nothing pins that
2253 * display line N asks for line N.
2254 * 2. the FRAME GEOMETRY, which bounds the display region - vPixels, vBorder and
2255 * the end-of-frame trigger scanline, plus the virtual-pixel scaling.
2256 *
2257 * A defect in the mapping is a shimmer on real hardware and is invisible to every
2258 * other gate here, which is why it gets a surface of its own.
2259 *
2260 * ALL THREE GROUPS ARE DIFFERENTIAL - each drives real library code against an
2261 * independent in-harness model:
2262 *
2263 * interrupt calls pico9918_frame_update_interrupts.
2264 * geometry `frameGeometry` is an adapter over pico9918_frame_geometry, so the
2265 * library computes every digested value.
2266 * mapping `frameMapLine` is an adapter over pico9918_frame_map_line_impl.
2267 *
2268 * The rule that keeps them honest: the committed digests pin the numbers the library
2269 * has to produce, so replacing a candidate with a different route to the same code
2270 * MUST reproduce those digests byte for byte.
2271 *
2272 * The mutation table in README.md is the evidence that the digests are sensitive to
2273 * each behaviour the surface claims to pin.
2274 *
2275 * Same shape as the overlay surface: separate artifact with its own magic and
2276 * version, so GOLDEN_VERSION 3 and the 14 committed scene goldens are untouched
2277 * and nothing needs recapturing.
2278 * ------------------------------------------------------------------------- */
2279
2280/* the interrupt group calls the real library entry point */
2281#include "pico9918_frame.h"
2282
2283#define FRAME_MAGIC "TMSF"
2284/* Its own version, separate from GOLDEN_VERSION and OVERLAY_VERSION - each artifact
2285 * versions on its own. */
2286#define FRAME_VERSION 3
2287
2288/* Restated rather than reached for: the register BIT is the input to the behaviour
2289 * under test, not part of it. */
2290#define FRAME_R0_DOUBLE_ROWS 0x08
2291
2292/* VR49 bit 6 selects 30-row mode (main.c reads TMS_REGISTER(tms9918, 0x31) & 0x40) */
2293#define FRAME_R31_ROW30 0x40
2294
2295/* The three vertical display geometries the shipping builds actually reach.
2296 * displayPixels is the VERTICAL active line count of the mode:
2297 * VGA 640x480 - 480 rows, progressive, DISPLAY_YSCALE 2 (src/display.h)
2298 * SCART PAL 576i - 576/2 - SCART_V_BORDER*2 = 268, interlaced (vga-modes.c)
2299 * SCART NTSC 480i- 480/2 - SCART_V_BORDER*2 = 220, interlaced (vga-modes.c)
2300 * Interlaced builds run yScale 1, progressive builds yScale 2 - main.c. */
2301#define FRAME_DISPLAY_VGA 480
2302#define FRAME_DISPLAY_SCART_PAL 268
2303#define FRAME_DISPLAY_SCART_NTSC 220
2304
2305/* the host-owned scale under interlace. main.c only writes vPixelScale /
2306 * vVirtualPixels when yScale > 1, so under interlace these keep the values
2307 * vga-modes.c's setVgaParamsScale(&params, 1) left there. */
2308#define FRAME_INTERLACED_PIXEL_SCALE 1
2309
2310/* The mutable display parameters, i.e. the fields of VgaParams that the
2311 * end-of-frame code reads or writes. Named to match pico9918_frame_display_t, so the
2312 * adapter below is a field-for-field copy rather than a translation.
2313 *
2314 * Kept as a harness type rather than replaced by pico9918_frame_display_t outright: the
2315 * mapping group and refGeometry both take it, and refGeometry MUST NOT share a type
2316 * with the code under test any more than it shares its algebra. */
2317/* Widths match the firmware EXACTLY, and that is load-bearing rather than
2318 * pedantry: vga.h declares vVirtualPixels as uint16_t and vPixelScale as
2319 * uint8_t, and main.c declares vPixels as int but vBorder as uint32_t. The
2320 * SCART-NTSC row-30 case computes a negative border that the firmware converts to
2321 * a huge unsigned on assignment, so a model using int throughout would compute
2322 * -10 where the firmware computes 4294967286 - i.e. it would pin a value the
2323 * firmware never produces. Nothing else here would catch it: every other pinned
2324 * row is non-negative, so the narrowing shows in that case alone. */
2325typedef struct
2326{
2327 int displayPixels; /* vSyncParams.displayPixels - vertical lines (in) */
2328 bool interlaced; /* (in) */
2329 uint8_t vPixelScale; /* vga.h:84 (in, and out when yScale > 1) */
2330 uint16_t vVirtualPixels; /* vga.h:74 (in, and out when yScale > 1) */
2331} FrameParams;
2332
2333/* the geometry main.c computes at end of frame */
2334typedef struct
2335{
2336 uint8_t vPixelScale; /* vga.h:84 */
2337 uint16_t vVirtualPixels; /* vga.h:74 */
2338 int vPixels; /* main.c:80 - signed */
2339 uint32_t vBorder; /* UNSIGNED, as the firmware declares it - see the row-30 note */
2340 uint32_t triggerScanline; /* vBorder + vPixels, so it inherits the wrap */
2342
2343/* ---- candidate: the PATH UNDER TEST -----------------------------------------
2344 *
2345 * `frameGeometry` and `frameMapLine` are both ADAPTERS over the library. Do not
2346 * "tidy" the algebra in either to look like the reference below: the whole point of
2347 * that reference is that it does not share this algebra. */
2348
2349/* The interlace field mapping.
2350 *
2351 * The body below is an ADAPTER: pico9918_frame_map_line_impl in
2352 * src/impl/pico9918_priv.h is the code under test, so this is a genuine differential
2353 * gate rather than a specification one.
2354 *
2355 * `y` arrives with the field number in bit 12 and the line within the field in bits
2356 * 11-0, which is the host VGA layer's encoding (vga.h). SPLITTING IT STAYS IN THE
2357 * HARNESS, and that is not a shortcut: the library's scanline splits the raw y once at
2358 * entry, because it needs the field number on the border arm too, and passes the
2359 * already-separated pair to the mapping. So the split genuinely is the caller's job,
2360 * and the adapter does here exactly what pico9918_frame_scanline does there.
2361 *
2362 * The MASK WIDTH is therefore still harness-side algebra, and the two line-2000/3000
2363 * cases in frameMapGroup below still pin it against the reference rather than against
2364 * the library. Stated plainly because it is the one part of this group the rewire does
2365 * NOT convert: a narrowed mask inside pico9918_frame_scanline is invisible here. Its gate
2366 * is the asm surface (tmsScanline / pico9918_frame_scanline are both tracked).
2367 *
2368 * reg0 is installed in the REAL register file, because the library reads the
2369 * double-rows bit from the device rather than taking it as a parameter - written
2370 * directly, for the same reason frameGeometry does it. */
2371static uint16_t frameMapLine(uint16_t yRaw, const FrameParams* params, uint8_t reg0,
2372 uint8_t interlacedFieldOrder)
2373{
2374 TMS_REGISTER(tms9918, 0) = reg0;
2375
2376 return pico9918_frame_map_line_impl(PICO9918_INST (uint16_t)(yRaw & 0x0fff),
2377 (uint8_t)((yRaw >> 12) & 1),
2378 params->interlaced, interlacedFieldOrder);
2379}
2380
2381/* The end-of-frame geometry.
2382 *
2383 * The body below is an ADAPTER: pico9918_frame_geometry in src/pico9918_frame.c is
2384 * the code under test, so this is a genuine differential gate rather than a
2385 * specification one.
2386 *
2387 * The adapter does three things and nothing else, so that everything the digests
2388 * see comes from the library:
2389 *
2390 * - copies FrameParams into the library's own pico9918_frame_display_t. Field for
2391 * field, same widths, same meanings - FrameParams' fields are named to match
2392 * precisely so this is a copy rather than a conversion;
2393 * - installs reg0 / reg31 in the REAL register file, because the library reads
2394 * them from the device rather than taking them as parameters. Written directly
2395 * rather than through pico9918_write_reg_value_impl for the same reason the
2396 * interrupt group writes R1 directly: that path carries unlock-sequence and
2397 * locked-mask side effects which are not part of this contract, and R49 is
2398 * above the locked mask so it could not be written at all;
2399 * - copies the results back out, including the params the library may have
2400 * rewritten, so "the library must not write these under interlace" stays
2401 * observable rather than merely asserted.
2402 *
2403 * vPixelScale / vVirtualPixels are read back from `display` (the in/out struct),
2404 * NOT from the returned geometry - the library's return value deliberately carries
2405 * only the three values it derives, and the host-owned pair travel in the struct it
2406 * was handed. That is what makes a stray write under interlace visible.
2407 */
2408static FrameGeometry frameGeometry(FrameParams* params, uint8_t reg0, uint8_t reg31)
2409{
2410 pico9918_frame_display_t display = { params->displayPixels, params->interlaced,
2411 params->vPixelScale, params->vVirtualPixels };
2412
2413 TMS_REGISTER(tms9918, 0) = reg0;
2414 TMS_REGISTER(tms9918, 0x31) = reg31;
2415
2417
2418 /* the library writes these only when it owns them; mirror them back so the
2419 post-call params the row digests are the ones it actually left behind */
2420 params->vPixelScale = display.vPixelScale;
2421 params->vVirtualPixels = display.vVirtualPixels;
2422
2423 FrameGeometry g;
2424 g.vPixelScale = display.vPixelScale;
2425 g.vVirtualPixels = display.vVirtualPixels;
2426 g.vPixels = lib.vPixels;
2427 g.vBorder = lib.vBorder;
2428 g.triggerScanline = lib.triggerScanline;
2429 return g;
2430}
2431
2432/* ---- independent reference --------------------------------------------------
2433 *
2434 * Written from the DOCUMENTED behaviour, sharing no algebra with the candidate
2435 * above. Step 3's pixel-format error survived for months because its
2436 * "cross-check" was written from the same wrong prose as the thing it checked,
2437 * so independence here is the entire value of the surface: where the candidate
2438 * fuses (`y * 2 + (field ^ order)`, `<< (bool)doubleRows`, `yScale -
2439 * (bool)doubleRows`), the reference decomposes into explicit cases. */
2440
2441/* Reference for the field mapping.
2442 *
2443 * Documented behaviour: under interlace with double-rows, the two fields
2444 * INTERLEAVE the VDP's doubled line space - each display line of a field maps to
2445 * one of the two VDP lines of that display row, and interlacedFieldOrder selects
2446 * WHICH field takes the even VDP lines. Field 0 with order 0 takes even lines
2447 * (0, 2, 4, ...) and field 1 takes odd; order 1 swaps that. Outside interlace or
2448 * outside double-rows there is no mapping: the VDP line is the line within the
2449 * field, and the field number is simply discarded.
2450 *
2451 * Written as an explicit even/odd base plus a selected parity, not as a fused
2452 * multiply-add, so a dropped XOR or a dropped parity term diverges. */
2453static uint16_t refMapLine(uint16_t yRaw, const FrameParams* params, uint8_t reg0,
2454 uint8_t interlacedFieldOrder)
2455{
2456 const int field = (yRaw >> 12) & 1;
2457 const int lineInField = yRaw & 0x0fff;
2458
2459 if (!params->interlaced) return (uint16_t)lineInField;
2460 if (!(reg0 & FRAME_R0_DOUBLE_ROWS)) return (uint16_t)lineInField;
2461
2462 /* the pair of VDP lines this display row covers */
2463 const int evenVdpLine = lineInField + lineInField;
2464 const int oddVdpLine = evenVdpLine + 1;
2465
2466 /* which of the pair this field owns. order 0: field 0 -> even. order 1: swap. */
2467 /* Valid only for interlacedFieldOrder in {0,1}, which is its whole domain:
2468 * vga.h documents it as "0 or 1" and vga-modes.c makes the only two assignments.
2469 * Stated because this predicate form and the candidate's `field ^ order` diverge
2470 * outside that domain: order = 3 makes them disagree, which is what shows the two
2471 * surfaces to be independent implementations rather than one delegating to the
2472 * other. */
2473 const bool takesEven = (field != 0) == (interlacedFieldOrder != 0);
2474 return (uint16_t)(takesEven ? evenVdpLine : oddVdpLine);
2475}
2476
2477/* Reference for the end-of-frame geometry, from the documented behaviour:
2478 *
2479 * The display region is baseRows rows of 8 VDP lines - 24 rows normally, 30
2480 * when VR49 bit 6 selects row-30 mode. On a PROGRESSIVE build the panel is
2481 * line-doubled (yScale 2), so double-rows halves the doubling to fit twice as
2482 * many VDP lines on screen: vPixelScale drops to 1, the virtual line count
2483 * doubles, and the display region doubles with it. On an INTERLACED build the
2484 * two fields already supply the second set of lines, so yScale is 1, there is
2485 * nothing to halve, and the display region does NOT double - it stays at
2486 * baseRows * 8. Interlaced builds also leave vPixelScale and vVirtualPixels
2487 * entirely alone: the host set them up and owns them.
2488 * The border is the leftover virtual lines split evenly top and bottom, and
2489 * the end-of-frame trigger fires on the first line past the display region.
2490 *
2491 * Written as an explicit progressive/interlaced split with multiplication rather
2492 * than the candidate's shift-by-bool and single fused vPixels expression. */
2493static FrameGeometry refGeometry(FrameParams* params, uint8_t reg0, uint8_t reg31)
2494{
2495 const bool doubleRows = (reg0 & FRAME_R0_DOUBLE_ROWS) != 0;
2496 const int baseRows = (reg31 & FRAME_R31_ROW30) ? 30 : 24;
2497
2498 FrameGeometry g;
2499 g.vPixels = baseRows * 8;
2500
2501 if (params->interlaced)
2502 {
2503 /* host owns the scale: read it, do not write it. vPixels does not double. */
2504 g.vPixelScale = params->vPixelScale;
2505 g.vVirtualPixels = params->vVirtualPixels;
2506 }
2507 else
2508 {
2509 if (doubleRows)
2510 {
2511 g.vPixelScale = 1;
2512 g.vVirtualPixels = params->displayPixels;
2513 g.vPixels = g.vPixels * 2;
2514 }
2515 else
2516 {
2517 g.vPixelScale = 2;
2518 g.vVirtualPixels = params->displayPixels / 2;
2519 }
2520 params->vPixelScale = g.vPixelScale;
2521 params->vVirtualPixels = g.vVirtualPixels;
2522 }
2523
2524 /* The leftover virtual lines, split evenly top and bottom. Computed as a signed
2525 * count of spare lines, then converted by the assignment to uint32_t - matching
2526 * the firmware's `static uint32_t vBorder`. The count DOES go negative (SCART
2527 * NTSC in row-30 mode), which is why the intermediate must stay signed.
2528 *
2529 * `/2` vs `>>1` is deliberately NOT pinned by a dedicated row, and that is a
2530 * reasoned choice rather than an oversight. They differ only for an odd negative
2531 * numerator, and no reachable configuration produces one: vPixels is always
2532 * baseRows << 3 (a multiple of 8, and the doubling preserves that) and every
2533 * reachable vVirtualPixels - 240, 480, 268, 220 - is even, so the spare-line
2534 * count is always even. Adversarial review noted the mutation survives; adding a
2535 * fabricated odd geometry to catch it would pin an arithmetic accident, which is
2536 * the same mistake the row-30 exclusion made in the other direction. */
2537 const int spareLines = (int)g.vVirtualPixels - g.vPixels;
2538 g.vBorder = (uint32_t)(spareLines / 2);
2539 /* inherits the wrap: unsigned + int, as main.c passes it on */
2540 g.triggerScanline = g.vBorder + (uint32_t)g.vPixels;
2541 return g;
2542}
2543
2544/* ---- the observable consequence of the geometry ----------------------------
2545 *
2546 * How many display lines actually RENDER, i.e. take the active arm of the border
2547 * test at main.c: `if (y < vBorder || y >= (vBorder + vPixels))` takes the
2548 * border path, else the active path.
2549 *
2550 * This exists because the geometry values alone cannot pin the NTSC row-30 defect.
2551 * vBorder is stored into the row as int32_t, so the firmware's uint32_t
2552 * 4294967286 and a tidied-up int -10 are the SAME 32 bits and produce the same
2553 * digest - the TYPE is invisible to a value digest. But the blank screen is caused
2554 * by the comparison being UNSIGNED, not by the bit pattern. So the consequence is
2555 * digested directly: with the correct unsigned comparison this returns 0 active
2556 * lines for SCART NTSC row-30, and making the comparison signed returns 220
2557 * instead - verified caught at geom-scart-ntsc-row30.
2558 *
2559 * Note what is NOT catchable here, so nobody hunts for it: narrowing
2560 * FrameGeometry.vBorder from uint32_t to int is an EQUIVALENT MUTANT. The field
2561 * still holds -10, and this comparison converts it straight back to 4294967286
2562 * (mixed int/uint32_t comparisons promote to unsigned), so neither the stored bits
2563 * nor the active-line count change. There is nothing for a digest to see because
2564 * there is no behavioural difference. The uint32_t is kept because it documents the
2565 * firmware's actual declaration (main.c), not because a gate enforces it. */
2566static int32_t frameActiveLines(const FrameGeometry* g, int displayPixels)
2567{
2568 int32_t active = 0;
2569 for (uint32_t y = 0; y < (uint32_t)displayPixels; ++y)
2570 if (!(y < g->vBorder || y >= (g->vBorder + (uint32_t)g->vPixels)))
2571 ++active;
2572 return active;
2573}
2574
2575/* Reference: counts the complement (border lines) and subtracts, and derives the
2576 * band from an explicit inclusive end rather than reusing the firmware's
2577 * two-term test, so it does not share the candidate's comparison algebra. */
2578static int32_t refActiveLines(const FrameGeometry* g, int displayPixels)
2579{
2580 const uint32_t total = (uint32_t)displayPixels;
2581 const uint32_t firstActive = g->vBorder;
2582 const uint32_t lastActive = g->vBorder + (uint32_t)g->vPixels - 1u;
2583
2584 int32_t border = 0;
2585 for (uint32_t y = 0; y < total; ++y)
2586 if (y < firstActive || y > lastActive)
2587 ++border;
2588 return (int32_t)total - border;
2589}
2590
2591/* ---- frame row bookkeeping -------------------------------------------------
2592 *
2593 * One digest per row per surface, same as the overlay surface, plus the
2594 * value-for-value cross-check that localises a divergence to a field.
2595 *
2596 * The digest is taken over the FrameRow struct in native byte order. There is no
2597 * padding to worry about (the int32_t array is homogeneous, so the digest
2598 * reproducibility is real rather than padding luck), but the committed artifact
2599 * would not verify
2600 * on a big-endian host. That matches the pre-existing scene and overlay surfaces,
2601 * which digest structs the same way - noted rather than fixed, so it stays one
2602 * consistent property of the whole suite instead of one surface being different.
2603 * The FILE format itself is endian-safe: putU32/getU32 are explicit byte-wise. */
2604/* Headroom over the current row count, not a snug fit - see the overlay note. */
2605#define FRAME_MAX_ROWS 1024
2606
2607static uint64_t frameDigests[FRAME_MAX_ROWS][2];
2608static int frameRowCount;
2609
2610/* first candidate-vs-reference disagreement, latched */
2611static int frFailRow;
2612static const char* frFailField;
2613static long frFailCand;
2614static long frFailRef;
2615
2616static const char* frRowLabel[FRAME_MAX_ROWS];
2617static const char* frCurrentLabel = "";
2618
2619/* The row payload: every value either behaviour produces, in a fixed layout, so
2620 * one digest covers the whole row and a mismatch is localised by rescanning.
2621 *
2622 * Slots 0..8 are the mapping and geometry groups; 9..14 are the interrupt group.
2623 * Each group fills only its own slots and leaves the rest zero, so a divergence
2624 * report points at a value that group actually produced. */
2625#define FRAME_ROW_VALUES 15
2626
2627typedef struct
2628{
2629 int32_t v[FRAME_ROW_VALUES];
2630} FrameRow;
2631
2632static const char* const frameFieldName[FRAME_ROW_VALUES] = {
2633 "mappedLine", "vPixelScale", "vVirtualPixels", "vPixels", "vBorder", "triggerScanline", "paramsVPixelScale",
2634 "paramsVVirtualPixels", "activeLines",
2635 /* interrupt group */
2636 "frameStatusShadow", "sr0Register", "intPin", "sr1Register", "lockLatch", "publishedStatus"};
2637
2638static void frameEmitRow(const FrameRow* cand, const FrameRow* ref)
2639{
2640 if (frameRowCount >= FRAME_MAX_ROWS)
2641 {
2642 /* abort, never truncate - see the OVERLAY_MAX_ROWS note */
2643 printf("[ERROR] frame: row budget %d exhausted - raise FRAME_MAX_ROWS\n",
2644 FRAME_MAX_ROWS);
2645 exit(2);
2646 }
2647
2648 const int row = frameRowCount++;
2649 frRowLabel[row] = frCurrentLabel;
2650 frameDigests[row][0] = fnv1a(cand, sizeof(*cand));
2651 frameDigests[row][1] = fnv1a(ref, sizeof(*ref));
2652
2653 if (frFailRow < 0)
2654 {
2655 for (int i = 0; i < FRAME_ROW_VALUES; ++i)
2656 {
2657 if (cand->v[i] != ref->v[i])
2658 {
2659 frFailRow = row;
2660 frFailField = frameFieldName[i];
2661 frFailCand = cand->v[i];
2662 frFailRef = ref->v[i];
2663 break;
2664 }
2665 }
2666 }
2667}
2668
2669/* ---- the interlace mapping group -------------------------------------------
2670 *
2671 * Both fields and both field orders at every case, because a defect that SWAPS
2672 * the fields is the exact failure mode this exists to catch and it is invisible
2673 * to any single-field case. The mapping fields of the row are filled; the
2674 * geometry fields are left zero (the two groups digest disjoint halves of the
2675 * row, which keeps each group's divergence report pointing at its own values).
2676 *
2677 * Cases: line 0 of each field, so the origin of the mapping is pinned in all
2678 * four field/order combinations; a mid-panel line; the last line of a 24-row
2679 * interlaced panel (119 -> 239, the top of the doubled VDP line space); the
2680 * non-interlaced and non-double-rows arms, where the mapping must NOT apply and
2681 * the field bit must be DISCARDED rather than folded in. */
2682static void frameMapCase(const char* label, uint16_t yRaw, bool interlaced,
2683 uint8_t reg0, uint8_t order)
2684{
2685 frCurrentLabel = label;
2686
2687 FrameParams cp = { FRAME_DISPLAY_SCART_PAL, interlaced,
2688 FRAME_INTERLACED_PIXEL_SCALE, FRAME_DISPLAY_SCART_PAL };
2689 FrameParams rp = cp;
2690
2691 FrameRow cand = { { 0 } }, ref = { { 0 } };
2692 cand.v[0] = frameMapLine(yRaw, &cp, reg0, order);
2693 ref.v[0] = refMapLine(yRaw, &rp, reg0, order);
2694
2695 frameEmitRow(&cand, &ref);
2696}
2697
2698/* every field/order combination of one line-within-field.
2699 *
2700 * The generated label must outlive the call, since frRowLabel stores the pointer
2701 * for the divergence report - so it is composed into a per-row slot of a fixed
2702 * array rather than a local buffer. Indexed by the row it labels, which is
2703 * frameRowCount at the moment of the call. */
2704static char frMapLabels[FRAME_MAX_ROWS][32];
2705
2706static void frameMapQuad(const char* label, uint16_t lineInField, bool interlaced,
2707 uint8_t reg0)
2708{
2709 for (int field = 0; field < 2; ++field)
2710 {
2711 for (int order = 0; order < 2; ++order)
2712 {
2713 /* the label slot is indexed before frameEmitRow's budget check runs, so it
2714 needs its own bound - otherwise the overflow beats the abort to it */
2715 if (frameRowCount >= FRAME_MAX_ROWS)
2716 {
2717 printf("[ERROR] frame: row budget %d exhausted - raise FRAME_MAX_ROWS\n",
2718 FRAME_MAX_ROWS);
2719 exit(2);
2720 }
2721 char* stored = frMapLabels[frameRowCount];
2722 snprintf(stored, sizeof(frMapLabels[0]), "%s-f%d-o%d", label, field, order);
2723 frameMapCase(stored, (uint16_t)((field << 12) | lineInField),
2724 interlaced, reg0, (uint8_t)order);
2725 }
2726 }
2727}
2728
2729static void frameMapGroup(void)
2730{
2731 /* interlaced + double-rows: the mapping applies. These are the values the
2732 * brief's table pins - line 0 gives 0/1/1/0 across the four combinations. */
2733 frameMapQuad("il-dbl-line0", 0, true, FRAME_R0_DOUBLE_ROWS);
2734 frameMapQuad("il-dbl-line1", 1, true, FRAME_R0_DOUBLE_ROWS);
2735 frameMapQuad("il-dbl-line95", 95, true, FRAME_R0_DOUBLE_ROWS);
2736 /* 119 is the last line of a 24-row interlaced panel: 119*2 = 238, so the
2737 * mapping reaches VDP line 239 - the top of the doubled space */
2738 frameMapQuad("il-dbl-line119", 119, true, FRAME_R0_DOUBLE_ROWS);
2739 /* a 30-row interlaced panel runs to line 149 -> VDP 299 */
2740 frameMapQuad("il-dbl-line149", 149, true, FRAME_R0_DOUBLE_ROWS);
2741
2742 /* interlaced, NO double-rows: no mapping, and the field bit is discarded */
2743 frameMapQuad("il-nodbl-line0", 0, true, 0x00);
2744 frameMapQuad("il-nodbl-line119", 119, true, 0x00);
2745
2746 /* progressive: no mapping either way. The field bit cannot be set by the
2747 * progressive path, but pinning that it is discarded is what stops a defect
2748 * from folding it in unconditionally. */
2749 frameMapQuad("prog-dbl-line0", 0, false, FRAME_R0_DOUBLE_ROWS);
2750 frameMapQuad("prog-dbl-line191", 191, false, FRAME_R0_DOUBLE_ROWS);
2751 frameMapQuad("prog-nodbl-line0", 0, false, 0x00);
2752 frameMapQuad("prog-nodbl-line191", 191, false, 0x00);
2753
2754 /* THE MASK WIDTH. Every case above has lineInField <= 191, which fits in 8
2755 * bits, so none of them exercises how wide the y mask actually is - and
2756 * `>> 12` / `& 0x0fff` is the one piece of algebra the reference does NOT
2757 * decompose, so a narrowed mask is a correlated error both surfaces make
2758 * together. Found as an escape by adversarial review: mutating the candidate's
2759 * mask to 0x00ff (or 0x07ff) left the suite passing at 52 rows.
2760 *
2761 * 2000 sets bits 10 and 7..6 etc - above 8 bits, below 12 - so a 0x00ff mask
2762 * drops bit 10 and a 0x07ff mask still passes, hence the second case at 3000,
2763 * which sets bit 11 and kills 0x07ff too. The mask is load-bearing: it is what
2764 * separates the field bit from the line (vga.h documents bits 11:0 as the
2765 * line). These lines exceed any real panel height, which is the point - the
2766 * mask must be pinned by its WIDTH, not by reachable geometry. */
2767 frameMapQuad("il-dbl-line2000", 2000, true, FRAME_R0_DOUBLE_ROWS);
2768 frameMapQuad("il-dbl-line3000", 3000, true, FRAME_R0_DOUBLE_ROWS);
2769 frameMapQuad("il-nodbl-line3000", 3000, true, 0x00);
2770}
2771
2772/* ---- the geometry group ----------------------------------------------------
2773 *
2774 * The full reachable matrix of (build vertical geometry) x (double-rows) x
2775 * (row-30). Three subtleties this group exists to pin, each of which is easy to
2776 * "tidy" wrongly:
2777 *
2778 * - Under INTERLACE, vPixels IGNORES double-rows. The doubling is gated on
2779 * yScale > 1, and interlaced builds run yScale 1. Progressive double-rows
2780 * gives 384; interlaced double-rows stays 192.
2781 * - Row-30 PROGRESSIVE gives vBorder == 0: no vertical border at all. Code
2782 * that assumes a non-zero border breaks exactly there.
2783 * - vPixelScale and vVirtualPixels are rewritten ONLY when yScale > 1. Under
2784 * interlace the host owns them and the library must not write them. Both the
2785 * VALUES and the not-writing are pinned: the row carries the geometry's own
2786 * idea of them AND the post-call params, so a stray write shows up as a
2787 * divergence in paramsVPixelScale / paramsVVirtualPixels even when the
2788 * returned geometry happens to look right.
2789 *
2790 * ROW-30 UNDER INTERLACE IS IN THE MATRIX, and SCART-NTSC row-30 is a firmware defect
2791 * this surface PINS rather than fixes: vVirtualPixels 220 against vPixels 240 gives
2792 * vBorder -10, which the firmware's unsigned vBorder turns into 4294967286, so the
2793 * border test sends all 220 lines down the border path and renders none. Both
2794 * settings are reachable in a shipping build - SCART timing is fixed at boot, row-30
2795 * is set at runtime by any F18A program writing R49 bit 6 - so the combination is not
2796 * hypothetical. A fix must show up here as an intentional golden diff, which is why
2797 * FrameGeometry.vBorder is uint32_t and not a tidy int. */
2798static void frameGeomCase(const char* label, int displayPixels, bool interlaced,
2799 uint8_t reg0, uint8_t reg31)
2800{
2801 frCurrentLabel = label;
2802
2803 /* the host's starting params. Progressive builds are re-derived from
2804 * displayPixels by the code under test, so their incoming scale is the mode's
2805 * own setVgaParamsScale(1); interlaced builds keep it. */
2806 FrameParams cp = { displayPixels, interlaced,
2807 FRAME_INTERLACED_PIXEL_SCALE, displayPixels };
2808 FrameParams rp = cp;
2809
2810 const FrameGeometry cg = frameGeometry(&cp, reg0, reg31);
2811 const FrameGeometry rg = refGeometry(&rp, reg0, reg31);
2812
2813 FrameRow cand = { { 0 } }, ref = { { 0 } };
2814 cand.v[1] = cg.vPixelScale;
2815 cand.v[2] = cg.vVirtualPixels;
2816 cand.v[3] = cg.vPixels;
2817 cand.v[4] = cg.vBorder;
2818 cand.v[5] = cg.triggerScanline;
2819 cand.v[6] = cp.vPixelScale;
2820 cand.v[7] = cp.vVirtualPixels;
2821 cand.v[8] = frameActiveLines(&cg, displayPixels);
2822
2823 ref.v[1] = rg.vPixelScale;
2824 ref.v[2] = rg.vVirtualPixels;
2825 ref.v[3] = rg.vPixels;
2826 ref.v[4] = rg.vBorder;
2827 ref.v[5] = rg.triggerScanline;
2828 ref.v[6] = rp.vPixelScale;
2829 ref.v[7] = rp.vVirtualPixels;
2830 ref.v[8] = refActiveLines(&rg, displayPixels);
2831
2832 frameEmitRow(&cand, &ref);
2833}
2834
2835static void frameGeomGroup(void)
2836{
2837 /* progressive VGA, all four double-rows x row-30 combinations */
2838 frameGeomCase("geom-vga", FRAME_DISPLAY_VGA, false, 0x00, 0x00);
2839 frameGeomCase("geom-vga-row30", FRAME_DISPLAY_VGA, false, 0x00, FRAME_R31_ROW30);
2840 frameGeomCase("geom-vga-dbl", FRAME_DISPLAY_VGA, false, FRAME_R0_DOUBLE_ROWS, 0x00);
2841 frameGeomCase("geom-vga-dbl-row30", FRAME_DISPLAY_VGA, false,
2842 FRAME_R0_DOUBLE_ROWS, FRAME_R31_ROW30);
2843
2844 /* interlaced SCART, both timings, with and without double-rows. The
2845 * double-rows rows are the ones that pin "interlace ignores double-rows". */
2846 frameGeomCase("geom-scart-pal", FRAME_DISPLAY_SCART_PAL, true, 0x00, 0x00);
2847 frameGeomCase("geom-scart-pal-dbl", FRAME_DISPLAY_SCART_PAL, true,
2848 FRAME_R0_DOUBLE_ROWS, 0x00);
2849 frameGeomCase("geom-scart-ntsc", FRAME_DISPLAY_SCART_NTSC, true, 0x00, 0x00);
2850 frameGeomCase("geom-scart-ntsc-dbl", FRAME_DISPLAY_SCART_NTSC, true,
2851 FRAME_R0_DOUBLE_ROWS, 0x00);
2852
2853 /* Row-30 under interlace - reachable at runtime, see the note above this
2854 * group. PAL is an ordinary positive border (+14). NTSC underflows to
2855 * 4294967286 and is the blank-screen defect; pinned deliberately so a future
2856 * firmware fix shows up here as an intentional diff. */
2857 frameGeomCase("geom-scart-pal-row30", FRAME_DISPLAY_SCART_PAL, true,
2858 0x00, FRAME_R31_ROW30);
2859 frameGeomCase("geom-scart-ntsc-row30", FRAME_DISPLAY_SCART_NTSC, true,
2860 0x00, FRAME_R31_ROW30);
2861}
2862
2863/* ---- the interrupt/status latch merge group ---------------------------------
2864 *
2865 * Calls pico9918_frame_update_interrupts: merge the flags the scanline just raised
2866 * (tempStatus) into the SR0 latch, publish, then bring /INT into agreement. Three
2867 * mutually exclusive branches, keyed on the latch's own F and 5S:
2868 *
2869 * A F clear, 5S latched only tempStatus' flag bits merge (& 0xe0). The latched
2870 * sprite ID must SURVIVE - it names the fifth sprite of
2871 * the line that first set 5S
2872 * B F clear, 5S not latched tempStatus replaces the low five bits; the incumbent
2873 * flag bits are preserved
2874 * C F set COL only. Per the TMS9918A datasheet and the F18A, COL
2875 * is not gated by F, but 5S is blocked while F is set and
2876 * the ID must not move
2877 *
2878 * /INT has TWO sources, ORed: the frame source, R1's interrupt-enable bit AND SR0's F;
2879 * and the scanline source, R0 bit 4 AND SR1's HF, on an unlocked device only. R1 gates
2880 * the first and not the second, which is the whole point of the split - a program using
2881 * only the scanline interrupt runs with R1's enable off, and folding the two together
2882 * leaves it never interrupting at all. So R1 is a second input to every case, and R0,
2883 * SR1 and the lock state are three more.
2884 *
2885 * Two things would silently void the coverage:
2886 *
2887 * The pin field is tms9918->frameInt, NOT pico9918_interrupt_status(), which
2888 * RECOMPUTES the answer from live state and so reads correct even with the sync
2889 * deleted. frameInt is the latched pin, and the rows arrange for the correct
2890 * post-state to differ from the pre-state in both directions.
2891 *
2892 * SR0 is read from the register file, not through pico9918_read_status - reading SR0
2893 * clears F and 5S and releases the pin, destroying the state being digested. Both
2894 * copies are digested, so a publish that updated only one diverges here.
2895 * ------------------------------------------------------------------------- */
2896
2897/* R1 bit 5. Restated rather than reached for, exactly as the R0/R31 bits above
2898 * are: the register BIT is an INPUT to the behaviour under test, so the surface
2899 * must not inherit it from the code it is testing.
2900 * TMS_R1_INT_ENABLE in pico9918_priv.h spells it the same. */
2901#define FRAME_R1_INT_ENABLE 0x20
2902
2903/* The three SR0 flag bits, restated for the same reason. PICO9918_SR0_INT / _5S /
2904 * _COLLISION in pico9918.h spell them the same. */
2905#define FRAME_SR0_F 0x80 /* frame interrupt */
2906#define FRAME_SR0_5S 0x40 /* fifth sprite */
2907#define FRAME_SR0_COL 0x20 /* sprite collision */
2908#define FRAME_SR0_ID 0x1f /* fifth-sprite number, low five bits */
2909
2910/* The scanline source's own two bits, restated for the same reason.
2911 * TMS_R0_INT_SCANLINE and PICO9918_SR1_HF spell them the same. */
2912#define FRAME_R0_INT_SCANLINE 0x10
2913#define FRAME_SR1_HF 0x01
2914
2915/* Independent reference for the merge.
2916 *
2917 * Deliberately NOT `(cur & 0xe0) | temp` or `cur |= temp & 0xe0`. Each output part
2918 * is named and decided on its own, so a mask that lets the wrong bit through, or a
2919 * branch that keeps the wrong half, diverges here rather than being reproduced:
2920 *
2921 * flags which of F / 5S / COL end up set, decided one bit at a time.
2922 * id which five-bit sprite number survives.
2923 *
2924 * The branch selection is written as the two independent questions the datasheet
2925 * asks (is F latched? is 5S latched?) rather than as the candidate's nested if. */
2926static uint8_t refMergeStatus(uint8_t currentStatus, uint8_t tempStatus)
2927{
2928 const bool haveF = (currentStatus & FRAME_SR0_F) != 0;
2929 const bool have5S = (currentStatus & FRAME_SR0_5S) != 0;
2930
2931 /* the incumbent flags always survive: no branch clears a latched flag */
2932 bool outF = haveF;
2933 bool out5S = have5S;
2934 bool outCol = (currentStatus & FRAME_SR0_COL) != 0;
2935
2936 /* what the new scanline is allowed to raise */
2937 if (haveF)
2938 {
2939 /* F latched: COL only. 5S blocked, F already set, ID untouchable. */
2940 if (tempStatus & FRAME_SR0_COL) outCol = true;
2941 }
2942 else
2943 {
2944 /* F clear: all three flags may be raised */
2945 if (tempStatus & FRAME_SR0_F) outF = true;
2946 if (tempStatus & FRAME_SR0_5S) out5S = true;
2947 if (tempStatus & FRAME_SR0_COL) outCol = true;
2948 }
2949
2950 /* the sprite ID. It is replaced only when there was no latched 5S to protect
2951 * and F was not blocking the update - i.e. exactly the no-5S, no-F case. */
2952 uint8_t id = currentStatus & FRAME_SR0_ID;
2953 if (!haveF && !have5S) id = tempStatus & FRAME_SR0_ID;
2954
2955 uint8_t out = id;
2956 if (outF) out |= FRAME_SR0_F;
2957 if (out5S) out |= FRAME_SR0_5S;
2958 if (outCol) out |= FRAME_SR0_COL;
2959 return out;
2960}
2961
2962/* Independent reference for the pin.
2963 *
2964 * The library asks it as two fused `&&` terms over masked register reads, ORed. Here
2965 * every condition is a separate named predicate over a separate input, so a defect that
2966 * drops one of them, that reads the pre-merge status instead of the merged one, or that
2967 * puts the scanline source behind R1's enable, diverges. Written as the two sources the
2968 * F18A documents rather than as one expression, because their gating differs and that
2969 * difference is the behaviour being pinned.
2970 *
2971 * THE LOCK LATCH IS NOT AN INPUT, which is `f18a_cpu.vhd`'s own shape: the pin is
2972 * `(intr_ff and reg1ie) or (horz_ff and reg0ie1)` with no lock term, and a relock leaves
2973 * both halves of the scanline source standing. A device that has never unlocked cannot
2974 * reach these rows: its locked mask sends a write to register 19 to R3, so nothing ever
2975 * sets HF. */
2976static bool refIntPin(uint8_t mergedStatus, uint8_t reg1, uint8_t reg0, uint8_t sr1)
2977{
2978 const bool frameEnabled = (reg1 & FRAME_R1_INT_ENABLE) != 0;
2979 const bool frameFlagLatched = (mergedStatus & FRAME_SR0_F) != 0;
2980
2981 const bool scanlineEnabled = (reg0 & FRAME_R0_INT_SCANLINE) != 0;
2982 const bool scanlineFlagLatched = (sr1 & FRAME_SR1_HF) != 0;
2983
2984 return (frameEnabled && frameFlagLatched) || (scanlineEnabled && scanlineFlagLatched);
2985}
2986
2987/* Force the /INT pin (tms9918->frameInt) to `want` WITHOUT calling the function
2988 * under test.
2989 *
2990 * The only writer of frameInt other than that function is
2991 * pico9918_frame_sync_int_impl, which drives the pin to the OR of both sources. So
2992 * a temporary R1/SR0 pairing that computes to `want` is installed, the scanline source
2993 * is silenced so it cannot hold the pin up against a `want` of false, the sync is run,
2994 * and the caller then writes the row's real precondition over the top - which,
2995 * having no reconcile hook, leaves the pin where this left it. */
2996static void frameIntSetup(bool want)
2997{
2998 TMS_STATUS(tms9918, PICO9918_SR_IDENT) &= (uint8_t)~FRAME_SR1_HF;
2999 TMS_REGISTER(tms9918, TMS_REG_0) &= (uint8_t)~FRAME_R0_INT_SCANLINE;
3000 TMS_REGISTER(tms9918, TMS_REG_1) = want ? FRAME_R1_INT_ENABLE : 0x00;
3001 pico9918_set_status_impl(want ? FRAME_SR0_F : 0x00);
3003}
3004
3005/* One row: install the precondition, call the library, digest the consequences.
3006 *
3007 * The lock state is written as the field rather than run as the VR57 sequence: that
3008 * sequence goes through the bus, whose post-write reconcile re-syncs the pin and would
3009 * destroy the pre-state the row exists to test. Every other input here is installed
3010 * directly for the same reason. */
3011static void frameIntCase(const char* label, uint8_t currentStatus, uint8_t tempStatus, uint8_t reg1,
3012 bool intPinPre, uint8_t reg0, uint8_t sr1, bool unlocked)
3013{
3014 frCurrentLabel = label;
3015
3016 /* ---- precondition ---- */
3017 frameIntSetup(intPinPre); /* the pin's pre-state */
3018 TMS_REGISTER(tms9918, TMS_REG_1) = reg1; /* R1, the frame source's enable */
3019 TMS_REGISTER(tms9918, TMS_REG_0) = reg0; /* R0, the scanline source's enable */
3020 TMS_STATUS(tms9918, PICO9918_SR_IDENT) = sr1; /* SR1, the scanline source's flag */
3021 tms9918->isUnlocked = unlocked;
3022 pico9918_set_status_impl(currentStatus); /* the SR0 latch, both copies */
3023 goldenPublishReset(currentStatus); /* ...and the host was told, as on a device */
3024
3025 /* ---- the behaviour under test ---- */
3027
3028 /* ---- the observable consequences ---- */
3029 FrameRow cand = { { 0 } }, ref = { { 0 } };
3030 cand.v[9] = pico9918_frame_status_impl(); /* merged SR0, the frame shadow */
3031 cand.v[10] = TMS_STATUS(tms9918, 0); /* merged SR0, the register copy */
3032 cand.v[11] = pico9918_frame_int_impl(); /* the LATCHED /INT pin */
3033 cand.v[12] = TMS_STATUS(tms9918, PICO9918_SR_IDENT);
3035 cand.v[14] = goldenPublishedStatus; /* the last SR0 handed to the host */
3036
3037 const uint8_t expected = refMergeStatus(currentStatus, tempStatus);
3038 ref.v[9] = expected;
3039 ref.v[10] = expected;
3040 ref.v[11] = refIntPin(expected, reg1, reg0, sr1);
3041
3042 /* The host's view must not be STALE - not that it was refreshed a particular
3043 number of times. A publish the merge skips is correct exactly when the value
3044 the host already holds is the merged one, so the reference is the merged SR0
3045 either way and a wrongly skipped publish shows up as the pre-state. */
3046 ref.v[14] = expected;
3047
3048 /* the merge owns SR0 and must not touch SR1: the scanline flag is the read path's */
3049 ref.v[12] = sr1;
3050
3051 /* digested so every row shows its own lock precondition took, which is what stops a
3052 row that turns on the latch from passing vacuously */
3053 ref.v[13] = unlocked;
3054
3055 frameEmitRow(&cand, &ref);
3056}
3057
3058/* Every case in both R1 states and both pin pre-states, because R1 is a second
3059 * input and the pin must be shown to move in both directions.
3060 *
3061 * Four rows per merge case: (R1 int-enable off/on) x (pin pre-state false/true).
3062 * Not a snug matrix for its own sake - each combination pins something distinct:
3063 * R1 off the pin must end LOW no matter what F does, so an R1 mask taking
3064 * effect is pinned, which is the reason the function ends with a sync.
3065 * R1 on the pin must follow the MERGED F, so a merge that loses F is caught by
3066 * the pin as well as by the byte.
3067 * pre false / pre true make the correct post-state differ from the pre-state in
3068 * one direction or the other, which is what makes a MISSING sync visible.
3069 */
3070static char frIntLabels[FRAME_MAX_ROWS][40];
3071
3072static void frameIntQuad(const char* label, uint8_t currentStatus, uint8_t tempStatus)
3073{
3074 for (int r1 = 0; r1 < 2; ++r1)
3075 {
3076 for (int pre = 0; pre < 2; ++pre)
3077 {
3078 /* the label slot is indexed before frameEmitRow's budget check runs, so it
3079 needs its own bound - otherwise the overflow beats the abort to it */
3080 if (frameRowCount >= FRAME_MAX_ROWS)
3081 {
3082 printf("[ERROR] frame: row budget %d exhausted - raise FRAME_MAX_ROWS\n",
3083 FRAME_MAX_ROWS);
3084 exit(2);
3085 }
3086 char* stored = frIntLabels[frameRowCount];
3087 snprintf(stored, sizeof(frIntLabels[0]), "%s-r1%d-pin%d", label, r1, pre);
3088
3089 /* unlocked with the scanline source ARMED but unflagged, so every row of the
3090 merge group also pins that an armed second source contributes nothing */
3091 frameIntCase(stored, currentStatus, tempStatus, (uint8_t)(r1 ? FRAME_R1_INT_ENABLE : 0x00), pre != 0,
3092 FRAME_R0_INT_SCANLINE, 0x00, true);
3093 }
3094 }
3095}
3096
3097/* One scanline-source case in both pin pre-states.
3098 *
3099 * A pair rather than a quad: what varies across this group is the second source's own
3100 * three inputs, so R1 and the SR0 latch are given per case rather than swept. Both pin
3101 * pre-states still run, for the same reason the merge quads do - it is what makes a
3102 * missing sync visible in whichever direction the row moves the pin. */
3103static void frameHIntPair(const char* label, uint8_t currentStatus, uint8_t tempStatus, uint8_t reg1,
3104 uint8_t reg0, uint8_t sr1, bool unlocked)
3105{
3106 for (int pre = 0; pre < 2; ++pre)
3107 {
3108 if (frameRowCount >= FRAME_MAX_ROWS)
3109 {
3110 printf("[ERROR] frame: row budget %d exhausted - raise FRAME_MAX_ROWS\n", FRAME_MAX_ROWS);
3111 exit(2);
3112 }
3113
3114 char* stored = frIntLabels[frameRowCount];
3115 snprintf(stored, sizeof(frIntLabels[0]), "%s-pin%d", label, pre);
3116 frameIntCase(stored, currentStatus, tempStatus, reg1, pre != 0, reg0, sr1, unlocked);
3117 }
3118}
3119
3120/* One row for the read path, which is a different function: pico9918_read_status.
3121 *
3122 * Reading SR1 clears HF, and the pin is then RE-DERIVED rather than cleared - a frame
3123 * source that is still asserting has to keep it down. Without the clear a latched HF
3124 * would hold /INT asserted forever and an ISR that acknowledged would re-enter at once;
3125 * without the re-derive, acknowledging a scanline interrupt would drop a frame one the
3126 * host had not seen.
3127 *
3128 * The pin is forced HIGH first, through the R1/SR0 pairing frameIntSetup uses, and the
3129 * row's real inputs are written over the top - so the pre-state owes nothing to the
3130 * scanline source it is about to withdraw. */
3131static void frameHIntReadCase(const char* label, uint8_t currentStatus, uint8_t reg1)
3132{
3133 frCurrentLabel = label;
3134
3135 /* ---- precondition: armed, flagged, and the pin already down ---- */
3136 frameIntSetup(true);
3137 TMS_REGISTER(tms9918, TMS_REG_1) = reg1;
3138 TMS_REGISTER(tms9918, TMS_REG_0) = FRAME_R0_INT_SCANLINE;
3139 TMS_STATUS(tms9918, PICO9918_SR_IDENT) = FRAME_SR1_HF;
3140 TMS_REGISTER(tms9918, PICO9918_REG_STATUS_SELECT) = PICO9918_SR_IDENT;
3141 tms9918->isUnlocked = true;
3142 pico9918_set_status_impl(currentStatus);
3143
3144 /* ---- the behaviour under test ---- */
3145 const uint8_t got = pico9918_read_status();
3146
3147 /* ---- the observable consequences ---- */
3148 FrameRow cand = {{0}}, ref = {{0}};
3149 cand.v[9] = got;
3150 cand.v[10] = TMS_STATUS(tms9918, 0);
3151 cand.v[11] = pico9918_frame_int_impl();
3152 cand.v[12] = TMS_STATUS(tms9918, PICO9918_SR_IDENT);
3154
3155 /* the CPU sees the flag it is acknowledging, and SR0 is not the register read */
3156 ref.v[9] = FRAME_SR1_HF;
3157 ref.v[10] = currentStatus;
3158
3159 const uint8_t sr1After = (uint8_t)(FRAME_SR1_HF & ~FRAME_SR1_HF);
3160 ref.v[11] = refIntPin(currentStatus, reg1, FRAME_R0_INT_SCANLINE, sr1After);
3161 ref.v[12] = sr1After;
3162 ref.v[13] = true;
3163
3164 frameEmitRow(&cand, &ref);
3165}
3166
3167/* The same row for the OTHER read path: SR0's.
3168 *
3169 * Reading SR0 clears F, and the pin is RE-DERIVED rather than cleared - an unlocked
3170 * device whose scanline source is still armed and flagged has to keep it down, or a
3171 * host acknowledging the frame interrupt silently loses the scanline one. The latch is
3172 * fixed at F alone so the post-read SR0 is decided here rather than reproduced.
3173 *
3174 * R1's enable is on in every row: without it the frame source could not have raised the
3175 * pin, so the row would prove nothing about releasing it. */
3176static void frameSr0ReadCase(const char* label, uint8_t reg0, uint8_t sr1, bool unlocked)
3177{
3178 frCurrentLabel = label;
3179
3180 /* ---- precondition: F latched, armed, and the pin already down ---- */
3181 frameIntSetup(true);
3182 TMS_REGISTER(tms9918, TMS_REG_1) = FRAME_R1_INT_ENABLE;
3183 TMS_REGISTER(tms9918, TMS_REG_0) = reg0;
3184 TMS_STATUS(tms9918, PICO9918_SR_IDENT) = sr1;
3185 TMS_REGISTER(tms9918, PICO9918_REG_STATUS_SELECT) = PICO9918_SR_STATUS;
3186 tms9918->isUnlocked = unlocked;
3187 pico9918_set_status_impl(FRAME_SR0_F);
3188
3189 /* ---- the behaviour under test ---- */
3190 const uint8_t got = pico9918_read_status();
3191
3192 /* ---- the observable consequences ---- */
3193 FrameRow cand = {{0}}, ref = {{0}};
3194 cand.v[9] = got;
3195 cand.v[10] = TMS_STATUS(tms9918, 0);
3196 cand.v[11] = pico9918_frame_int_impl();
3197 cand.v[12] = TMS_STATUS(tms9918, PICO9918_SR_IDENT);
3199
3200 /* the CPU sees the flag it is acknowledging; F clears, and no 5S means no ID to
3201 restore, so the latch empties */
3202 ref.v[9] = FRAME_SR0_F;
3203 ref.v[10] = 0x00;
3204
3205 ref.v[11] = refIntPin(0x00, FRAME_R1_INT_ENABLE, reg0, sr1);
3206
3207 /* the SR0 read owns SR0 and must not touch SR1 */
3208 ref.v[12] = sr1;
3209 ref.v[13] = unlocked;
3210
3211 frameEmitRow(&cand, &ref);
3212}
3213
3214/* Two rows: SR0's read hands the pin over to the scanline source, then SR1's read is
3215 * the only thing left that can release it. Deliberately CONTINUES the state the row
3216 * before it left, because the handover is the behaviour, and a row digests one state. */
3217static void frameSr0ThenSr1(void)
3218{
3219 frameSr0ReadCase("sread-scanline-holds", FRAME_R0_INT_SCANLINE, FRAME_SR1_HF, true);
3220
3221 frCurrentLabel = "sread-then-hread";
3222 TMS_REGISTER(tms9918, PICO9918_REG_STATUS_SELECT) = PICO9918_SR_IDENT;
3223
3224 const uint8_t got = pico9918_read_status();
3225
3226 FrameRow cand = {{0}}, ref = {{0}};
3227 cand.v[9] = got;
3228 cand.v[10] = TMS_STATUS(tms9918, 0);
3229 cand.v[11] = pico9918_frame_int_impl();
3230 cand.v[12] = TMS_STATUS(tms9918, PICO9918_SR_IDENT);
3232
3233 ref.v[9] = FRAME_SR1_HF;
3234 ref.v[10] = 0x00;
3235 ref.v[11] = refIntPin(0x00, FRAME_R1_INT_ENABLE, FRAME_R0_INT_SCANLINE, 0x00);
3236 ref.v[12] = 0x00;
3237 ref.v[13] = true;
3238
3239 frameEmitRow(&cand, &ref);
3240}
3241
3242/* The relock, which is the only way a LOCKED device can have the scanline source armed.
3243 *
3244 * R19 and R0 bit 4 survive a relock on both parts - only a reset clears them - so the
3245 * F18A keeps interrupting on what it armed while unlocked (`f18a_cpu.vhd:617` has no lock
3246 * term). This row goes through the BUS rather than the field, because it is the R57 write
3247 * path that used to reconcile the pin back down here. */
3248static void frameRelockCase(void)
3249{
3250 frCurrentLabel = "hint-relock-holds";
3251
3252 /* ---- precondition: unlocked, the scanline source alone asserting ---- */
3253 frameIntSetup(false);
3254 TMS_REGISTER(tms9918, TMS_REG_1) = 0x00;
3255 TMS_REGISTER(tms9918, TMS_REG_0) = FRAME_R0_INT_SCANLINE;
3256 TMS_STATUS(tms9918, PICO9918_SR_IDENT) = FRAME_SR1_HF;
3257 tms9918->isUnlocked = true;
3260
3261 /* ---- the behaviour under test: relock, value byte then register select ---- */
3262 pico9918_write_addr(0x00);
3264
3265 /* ---- the observable consequences ---- */
3266 FrameRow cand = {{0}}, ref = {{0}};
3267 cand.v[9] = pico9918_frame_status_impl();
3268 cand.v[10] = TMS_STATUS(tms9918, 0);
3269 cand.v[11] = pico9918_frame_int_impl();
3270 cand.v[12] = TMS_STATUS(tms9918, PICO9918_SR_IDENT);
3272
3273 ref.v[9] = 0x00;
3274 ref.v[10] = 0x00;
3275 ref.v[11] = refIntPin(0x00, 0x00, FRAME_R0_INT_SCANLINE, FRAME_SR1_HF);
3276 ref.v[12] = FRAME_SR1_HF;
3277
3278 /* the row is only worth anything if the write actually relocked the device */
3279 ref.v[13] = false;
3280
3281 frameEmitRow(&cand, &ref);
3282}
3283
3284static void frameIntGroup(void)
3285{
3286 /* ---- branch A: F clear, 5S latched. The latched ID must SURVIVE. ----
3287 *
3288 * 0x45 + 0x9f -> 0xc5 is one of the two DISCRIMINATING cases of this group.
3289 * The latch holds 5S with sprite ID 5; the scanline raises F, 5S and COL and
3290 * names sprite 31. The ID 5 must still be 5 afterwards. Swap branches A and B
3291 * and this yields 0x9f, clobbering the ID with 31 - which on hardware means a
3292 * host reading SR0 is told the wrong sprite caused the overflow. */
3293 frameIntQuad("int-a-5s-id5", 0x45, 0x9f);
3294 /* ID 3 kept while F is raised and the low bits of tempStatus (0x08) are dropped */
3295 frameIntQuad("int-a-5s-id3", 0x43, 0xe8);
3296 /* nothing but COL raised, ID 0: pins that branch A raises COL at all */
3297 frameIntQuad("int-a-5s-col", 0x40, 0x20);
3298
3299 /* ---- branch B: F clear, no 5S. tempStatus replaces the low five bits. ----
3300 *
3301 * 0x1f + 0x80 -> 0x80 is the vertical blank, and the other DISCRIMINATING case of
3302 * this group: the blank runs no sprite scan, so it names no sprite, and an OR here
3303 * would leave the previous line's ID standing as though it had. */
3304 frameIntQuad("int-b-vblank", 0x1f, 0x80);
3305 /* empty latch, everything arrives at once */
3306 frameIntQuad("int-b-empty", 0x00, 0xc5);
3307 /* incumbent COL must be PRESERVED across the replacement: the `& 0xe0` on the
3308 * INCUMBENT is what keeps it, and dropping it loses a collision the host has
3309 * not read yet. 0x20 + 0x1f -> 0x3f, not 0x1f. */
3310 frameIntQuad("int-b-keep-col", 0x20, 0x1f);
3311
3312 /* ---- branch C: F set. COL only. ----
3313 *
3314 * 0x85 + 0x40 -> 0x85 is the second DISCRIMINATING case, and the escape that
3315 * motivated this whole group. The latch holds F with ID 5; the scanline raises
3316 * 5S. 5S is blocked while F is set, so the latch must not change at all. The
3317 * COL -> 5S mutation yields 0xc5 here. */
3318 frameIntQuad("int-c-block-5s", 0x85, 0x40);
3319 /* the converse: COL must get through while F is latched, and the ID must not be
3320 * touched. 0x9f + 0xe5 -> 0xbf. If COL were gated by F this stays 0x9f. */
3321 frameIntQuad("int-c-pass-col", 0x9f, 0xe5);
3322 /* COL already latched, COL raised again: idempotent, and pins that branch C
3323 * writes nothing else. */
3324 frameIntQuad("int-c-col-again", 0xa0, 0x20);
3325
3326 /* ---- cases beyond the nine, each discriminating something the nine miss ----
3327 *
3328 * A: 5S latched and tempStatus raises NOTHING (0x00). Branch A must be a no-op.
3329 * This separates `cur |= temp & 0xe0` from `cur = temp & 0xe0`, which the nine
3330 * cases above cannot: every one of them has at least one flag bit in tempStatus,
3331 * so an assignment-instead-of-OR still happens to land on the same flags. Here
3332 * assignment would drop the latched 5S and the ID together, giving 0x00. */
3333 frameIntQuad("int-a-noop", 0x47, 0x00);
3334 /* C: F latched, ID 0, and tempStatus raises only F. Nothing may change (F is
3335 * already set, and F is not COL), so this pins that branch C does not somehow
3336 * fold F's own bit into the ID or the flags. */
3337 frameIntQuad("int-c-f-again", 0x80, 0x80);
3338 /* B: no-5S with F ALREADY latched is impossible by construction (branch C owns
3339 * F), so the no-5S branch is only ever reached with F clear. What IS reachable
3340 * and untested above is B raising 5S with a MAXIMUM id while an incumbent 5S is
3341 * absent but COL and F both arrive: 0x00 + 0x7f -> 0x7f. This pins that B does
3342 * not mask the incoming ID. */
3343 frameIntQuad("int-b-full-id", 0x00, 0x7f);
3344 /* The pin, isolated from the merge: F is already latched and stays latched, so
3345 * mergedSR0 is CONSTANT across all four rows of the quad while the pin is not.
3346 * Any defect in the pin decision therefore has nowhere to hide behind a moving
3347 * status byte. */
3348 frameIntQuad("int-pin-only", 0x80, 0x00);
3349 /* The quiet scanline, which is nearly every scanline: nothing latched, nothing
3350 * raised, and in two of the quad's four rows the pin already agrees as well - so
3351 * the whole call is a no-op. The case the table had no row for, and the one the
3352 * device spends its time in. It is the row that says a merge which takes a
3353 * shortcut when there is nothing to do still leaves SR0, the pin and the host's
3354 * published view exactly where they were. */
3355 frameIntQuad("int-b-quiet", 0x00, 0x00);
3356 /* B with an incumbent ID and nothing raised: `(cur & 0xe0) | temp` must CLEAR the
3357 * low five bits, so unlike the row above this one is not a no-op - 0x1f -> 0x00.
3358 * The pair is deliberate: they differ only in the incumbent ID, so a shortcut
3359 * that keys on tempStatus alone passes the first and fails here. */
3360 frameIntQuad("int-b-drop-id", 0x1f, 0x00);
3361
3362 /* ---- the scanline source, which is not the frame source ----
3363 *
3364 * The escape this sub-group exists for: the scanline interrupt was routed through
3365 * SR0's F under R1's enable, so a program that enabled only the scanline interrupt -
3366 * R1's enable OFF, which is exactly what such a program does - never interrupted.
3367 *
3368 * THE case: armed and flagged with the frame source entirely off. The pin must
3369 * assert, and R1 being clear is what makes it discriminating. */
3370 frameHIntPair("hint-alone", 0x00, 0x00, 0x00, FRAME_R0_INT_SCANLINE, FRAME_SR1_HF, true);
3371
3372 /* the same row with one of the source's two inputs withdrawn, everything else
3373 * identical. The pin must stay low in both, so neither can be the one dropped. */
3374 frameHIntPair("hint-disarmed", 0x00, 0x00, 0x00, 0x00, FRAME_SR1_HF, true);
3375 frameHIntPair("hint-noflag", 0x00, 0x00, 0x00, FRAME_R0_INT_SCANLINE, 0x00, true);
3376
3377 /* and the lock latch is NOT a third input: armed and flagged, a locked device asserts
3378 * exactly as an unlocked one does. This is the F18A's shape, and the pin term the
3379 * library used to gate on the latch - see refIntPin. frameRelockCase below reaches the
3380 * same state the only way a guest can. */
3381 frameHIntPair("hint-locked", 0x00, 0x00, 0x00, FRAME_R0_INT_SCANLINE, FRAME_SR1_HF, false);
3382
3383 /* F latched AND R1 off: the frame source is gated off, so the pin can only be the
3384 * scanline source's. A fix that put the second source behind R1 as well reads LOW. */
3385 frameHIntPair("hint-f-latched-r1off", FRAME_SR0_F, 0x00, 0x00, FRAME_R0_INT_SCANLINE, FRAME_SR1_HF, true);
3386
3387 /* both sources asserting at once, and the merge raising more flags on top. SR1 is
3388 * digested on every row, so a merge that clears HF while writing SR0 diverges. */
3389 frameHIntPair("hint-both", FRAME_SR0_F, 0xc5, FRAME_R1_INT_ENABLE, FRAME_R0_INT_SCANLINE, FRAME_SR1_HF,
3390 true);
3391
3392 /* the converse: armed but unflagged, the frame source alone raises the pin, so
3393 * arming the second source cannot be what suppresses the first. */
3394 frameHIntPair("hint-frame-only", 0x00, FRAME_SR0_F, FRAME_R1_INT_ENABLE, FRAME_R0_INT_SCANLINE, 0x00, true);
3395
3396 /* ---- the read path releases it ----
3397 *
3398 * The discriminating pair. With no frame source the pin must FALL; with one still
3399 * asserting it must STAY, which is the difference between re-deriving the pin and
3400 * clearing it. LAST, with the SR0 rows below, because the read path is what writes
3401 * R15 and leaves a status register selected. */
3402 frameHIntReadCase("hread-releases", 0x00, 0x00);
3403 frameHIntReadCase("hread-frame-holds", FRAME_SR0_F, FRAME_R1_INT_ENABLE);
3404
3405 /* ---- and so does the SR0 read ----
3406 *
3407 * The mirror pair: with the scanline source still asserting the pin must STAY, with
3408 * it withdrawn the pin must FALL. Then the three rows that withdraw one of the second
3409 * source's inputs at a time, none of which may hold the pin on its own. */
3410 frameSr0ThenSr1();
3411 frameSr0ReadCase("sread-releases", FRAME_R0_INT_SCANLINE, 0x00, true);
3412 frameSr0ReadCase("sread-disarmed", 0x00, FRAME_SR1_HF, true);
3413 /* locked, and the scanline source still holds the line across the SR0 read */
3414 frameSr0ReadCase("sread-locked", FRAME_R0_INT_SCANLINE, FRAME_SR1_HF, false);
3415
3416 /* LAST: it is the only row that writes a register through the bus, so it leaves the
3417 * unlock counter and the lock latch where a field write cannot put them. */
3418 frameRelockCase();
3419}
3420
3421/* ---- frame artifact I/O ---------------------------------------------------- */
3422static void frameRender(void)
3423{
3424 frameRowCount = 0;
3425 frFailRow = -1;
3426
3427 frameMapGroup();
3428 /* This one TOUCHES LIBRARY STATE too: it writes R0 and R49 on every row (the
3429 * library reads them from the device), and pico9918_frame_geometry publishes the
3430 * frame module's vPixels / vBorder globals. Harmless for the same reason the
3431 * interrupt group's writes are - see below - since the whole surface runs last. */
3432 frameGeomGroup();
3433 /* LAST, and it TOUCHES LIBRARY STATE: it writes R1 and the SR0 latch on every row.
3434 * Running it last keeps the frame surface's own position free (see the call site
3435 * in main) and, more importantly, keeps the 14 scene goldens and the overlay
3436 * artifact independent of it - they all run before. The state it leaves behind is
3437 * the last row's, which nothing afterwards reads.
3438 *
3439 * It must also stay AFTER the geometry group, which now writes R0: R0 is not an
3440 * input to the merge, so the order is not load-bearing for correctness, but the
3441 * interrupt group's documented preconditions are written per row while the
3442 * geometry group's are not. */
3443 frameIntGroup();
3444}
3445
3446static bool frameCapture(const char* dataDir)
3447{
3448 frameRender();
3449
3450 if (frFailRow >= 0)
3451 {
3452 printf("[ERROR] frame candidate diverges from the reference at "
3453 "row %d (%s), field %s (cand %ld, ref %ld) - NOT captured\n",
3454 frFailRow, frRowLabel[frFailRow], frFailField, frFailCand, frFailRef);
3455 return false;
3456 }
3457
3458 char path[512];
3459 snprintf(path, sizeof(path), "%s/frame.golden", dataDir);
3460 FILE* f = fopen(path, "wb");
3461 if (!f)
3462 {
3463 printf("[ERROR] frame: cannot open %s for writing\n", path);
3464 return false;
3465 }
3466
3467 fwrite(FRAME_MAGIC, 1, 4, f);
3468 putU32(f, FRAME_VERSION);
3469 putU32(f, (uint32_t)frameRowCount);
3470 /* values per row, derived so the header cannot drift from FrameRow */
3471 putU32(f, FRAME_ROW_VALUES);
3472 putU32(f, 2); /* digests per row: candidate, reference */
3473 for (int r = 0; r < frameRowCount; ++r)
3474 {
3475 putU64(f, frameDigests[r][0]);
3476 putU64(f, frameDigests[r][1]);
3477 }
3478 fclose(f);
3479
3480 printf("[CAPTURED] %-19s %3d rows -> %s\n", "frame", frameRowCount, path);
3481 return true;
3482}
3483
3484static bool frameCompare(const char* dataDir)
3485{
3486 char path[512];
3487 snprintf(path, sizeof(path), "%s/frame.golden", dataDir);
3488 FILE* f = fopen(path, "rb");
3489 if (!f)
3490 {
3491 printf("[FAIL] %-19s missing golden file %s (run with --capture first)\n",
3492 "frame", path);
3493 return false;
3494 }
3495
3496 char magic[4];
3497 uint32_t version = 0, rows = 0, values = 0, perRow = 0;
3498 const bool headerOk =
3499 fread(magic, 1, 4, f) == 4 && memcmp(magic, FRAME_MAGIC, 4) == 0 &&
3500 getU32(f, &version) && version == FRAME_VERSION &&
3501 getU32(f, &rows) &&
3502 getU32(f, &values) && values == FRAME_ROW_VALUES &&
3503 getU32(f, &perRow) && perRow == 2;
3504 if (!headerOk)
3505 {
3506 printf("[FAIL] %-19s bad golden header in %s\n", "frame", path);
3507 fclose(f);
3508 return false;
3509 }
3510
3511 frameRender();
3512
3513 if (rows != (uint32_t)frameRowCount)
3514 {
3515 printf("[FAIL] %-19s row count changed (golden %u, got %d)\n",
3516 "frame", rows, frameRowCount);
3517 fclose(f);
3518 return false;
3519 }
3520
3521 static const char* const surface[2] = { "candidate", "reference" };
3522 for (int r = 0; r < frameRowCount; ++r)
3523 {
3524 for (int d = 0; d < 2; ++d)
3525 {
3526 uint64_t expected;
3527 if (!getU64(f, &expected))
3528 {
3529 printf("[FAIL] %-19s truncated golden file at row %d\n", "frame", r);
3530 fclose(f);
3531 return false;
3532 }
3533 if (expected != frameDigests[r][d])
3534 {
3535 printf("[FAIL] %-19s first divergence: row %d (%s) %s digest "
3536 "(expected 0x%016llx, got 0x%016llx)\n",
3537 "frame", r, frRowLabel[r], surface[d],
3538 (unsigned long long)expected, (unsigned long long)frameDigests[r][d]);
3539 fclose(f);
3540 return false;
3541 }
3542 }
3543 }
3544
3545 if (frFailRow >= 0)
3546 {
3547 printf("[FAIL] %-19s candidate diverges from the reference at row %d (%s), "
3548 "field %s (cand %ld, ref %ld)\n",
3549 "frame", frFailRow, frRowLabel[frFailRow], frFailField, frFailCand, frFailRef);
3550 fclose(f);
3551 return false;
3552 }
3553
3554 fclose(f);
3555 printf("[PASS] %-19s %3d rows\n", "frame", frameRowCount);
3556 return true;
3557}
3558
3559/* Not a digest: the splash groups above call pico9918_splash_render directly, with the
3560 frame count as a parameter, so they pin the animation and nothing pins the gate that
3561 decides whether it is drawn at all. That gate reads the frame count, and validWrites
3562 latches once per run - so a reset rewinding only the animation leaves it shut. */
3563static bool resetCheck(void)
3564{
3565 if (pico9918_frame_count_impl() == 0)
3566 {
3567 printf("[FAIL] %-19s frame count was already zero, so this proves nothing\n", "reset");
3568 return false;
3569 }
3570
3572
3573 if (pico9918_frame_count_impl() != 0)
3574 {
3575 printf("[FAIL] %-19s pico9918_reset left the frame count at %d, so the splash gate stays shut\n",
3576 "reset", pico9918_frame_count_impl());
3577 return false;
3578 }
3579
3580 printf("[PASS] %-19s splash gate reopens\n", "reset");
3581 return true;
3582}
3583
3584/* The VR57 latch, which is (the last VR57 write was 0x1c) AND (this one is): two writes to
3585 get in, a third that changes nothing, and any other value - or any register write between
3586 the pair - that puts the device straight back out. A belt-and-braces second unlock is the
3587 case worth pinning, because an implementation that reads every VR57 write as a lock turns
3588 it into one and masks every extended register write after it down to VR0-VR7. */
3589static bool unlockCheck(void)
3590{
3591 static const struct
3592 {
3593 uint8_t reg;
3594 uint8_t value;
3595 bool unlocked;
3596 const char* what;
3597 } steps[] = {
3598 {57, 0x00, false, "a lock write leaves it locked" },
3599 {57, 0x1c, false, "one 0x1c is not enough" },
3600 {57, 0x1c, true, "two consecutive 0x1c unlock" },
3601 {57, 0x1c, true, "a redundant unlock is a no-op" },
3602 {57, 0x00, false, "any other value locks on the spot" },
3603 {57, 0x1c, false, "one 0x1c is not enough" },
3604 { 1, 0x00, false, "any other register write clears the run"},
3605 {57, 0x1c, false, "so that pair is not a consecutive one" },
3606 {57, 0x00, false, "back to a known locked state" },
3607 {57, 0x1f, false, "the low two bits are ignored" },
3608 {57, 0x1f, true, "so 0x1f unlocks exactly as 0x1c does" },
3609 };
3610
3611 for (int i = 0; i < (int)(sizeof(steps) / sizeof(steps[0])); ++i)
3612 {
3613 regWrite(steps[i].reg, steps[i].value);
3614 if (PICO9918_UNLOCKED(tms9918) != steps[i].unlocked)
3615 {
3616 printf("[FAIL] %-19s step %d, R%d = %02x: %s\n", "unlock", i + 1, steps[i].reg, steps[i].value,
3617 steps[i].what);
3618 return false;
3619 }
3620 }
3621
3622 printf("[PASS] %-19s %d VR57 latch steps\n", "unlock", (int)(sizeof(steps) / sizeof(steps[0])));
3623 return true;
3624}
3625
3626/* ---------------------------------------------------------------------------
3627 * Entry point
3628 * ------------------------------------------------------------------------- */
3629int main(int argc, char* argv[])
3630{
3631 bool capture = false;
3632 const char* dataDir = GOLDEN_DATA_DIR;
3633
3634 for (int i = 1; i < argc; ++i)
3635 {
3636 if (strcmp(argv[i], "--capture") == 0)
3637 {
3638 capture = true;
3639 }
3640 else if (strcmp(argv[i], "--data") == 0 && i + 1 < argc)
3641 {
3642 dataDir = argv[++i];
3643 }
3644 else
3645 {
3646 printf("usage: golden_runner [--capture] [--data DIR]\n");
3647 return 2;
3648 }
3649 }
3650
3651 pico9918_init();
3652
3653 int failures = 0;
3654 for (int i = 0; i < SCENE_COUNT; ++i)
3655 {
3656 bool ok = capture ? captureScene(&scenes[i], dataDir)
3657 : compareScene(&scenes[i], dataDir);
3658 if (!ok) ++failures;
3659 }
3660
3661 /* the overlay surface, LAST: its panel cases reprogram registers and set the
3662 * PICO9918_CONF_DIAG* bytes, and the diag TU's IntString state and the splash offset are
3663 * module-global. Running it after the scenes keeps every scene independent of
3664 * whether the overlay ran, which is what lets the 14 committed goldens stay
3665 * byte-identical. Counted as one artifact alongside them. */
3666 const int artifacts = SCENE_COUNT + 2;
3667 if (!(capture ? overlayCapture(dataDir) : overlayCompare(dataDir))) ++failures;
3668
3669 /* the frame surface, LAST. Its mapping group is pure arithmetic over its own
3670 * parameter struct, but the geometry group (since 4.6) writes R0/R49 and the frame
3671 * module's geometry globals, and the interrupt group writes R1 and the SR0 latch on
3672 * every row. Running the whole surface last means nothing else in the suite can see
3673 * that state, which is what keeps the 14 committed scene goldens and the overlay
3674 * artifact byte-identical. */
3675 if (!(capture ? frameCapture(dataDir) : frameCompare(dataDir))) ++failures;
3676
3677 /* after the frame group, which is what leaves the counter above zero */
3678 if (!resetCheck()) ++failures;
3679
3680 /* and after that reset, so the latch starts from a locked device */
3681 if (!unlockCheck()) ++failures;
3682
3683 if (capture)
3684 {
3685 printf("%d artifact(s) captured to %s\n", artifacts - failures, dataDir);
3686 }
3687 else if (failures)
3688 {
3689 printf("%d of %d artifact(s) FAILED\n", failures, artifacts);
3690 }
3691 else
3692 {
3693 printf("all %d artifact(s) passed\n", artifacts);
3694 }
3695
3696 return failures ? 1 : 0;
3697}
void pico9918_diag_set_clock_hz(float clockHz)
system clock, Hz
Definition diag.c:265
void pico9918_diag_init(void)
one-time initialisation of the panel value strings
Definition diag.c:200
void pico9918_diag_set_temperature(float tempC)
core temperature, degrees C
Definition diag.c:260
void pico9918_diag_set_version_info(const char *hwVersion, const char *fwVersion)
Version identity for the HWVER / FWVER rows.
Definition diag.c:226
void pico9918_diag_update(pico9918_t *tms9918, uint32_t frameCount)
recompute the panel values - call once per frame
Definition diag.c:280
void pico9918_diag_config_updated(pico9918_t *tms9918)
rebuild the panel row table - call whenever the PICO9918_CONF_DIAG* bytes change
Definition diag.c:541
int pico9918_diag_render_text(uint16_t scanline, const char *text, uint16_t x, uint16_t y, PICO9918_PIXEL_T fg, PICO9918_PIXEL_T *pixels)
render text into the scanline buffer, if row scanline falls in the glyph band starting at y.
Definition diag.c:348
void pico9918_diag_render(pico9918_t *tms9918, uint16_t y, uint32_t vVirtualPixels, PICO9918_PIXEL_T *pixels)
render the diagnostics panels for border row y
Definition diag.c:596
void pico9918_diag_set_output_name(const char *name, const char *units)
Display-mode label for the OUTPUT row, e.g.
Definition diag.c:243
void pico9918_diag_set_frame_rate(float frameRateHz)
Host display timing, Hz.
Definition diag.c:274
void pico9918_diag_update_render_time(uint32_t renderTime, uint32_t frameTime)
accumulate one scanline's render and total time, in microseconds
Definition diag.c:393
pico9918-core - Diagnostics overlay
#define PICO9918_DIAG_CHAR_WIDTH
glyph cell width, pixels
Definition diag.h:46
#define PICO9918_DIAG_CHAR_HEIGHT
glyph cell height, pixels
Definition diag.h:47
bool pico9918_unlocked(pico9918_t *tms9918)
see the header.
Definition pico9918.c:124
uint8_t pico9918_read_data(pico9918_t *tms9918)
read data (mode = 0) from the tms9918
Definition pico9918.c:462
pico9918_mode_t pico9918_display_mode(pico9918_t *tms9918)
current display mode
Definition pico9918.c:3587
void pico9918_write_addr(pico9918_t *tms9918, uint8_t data)
write an address (mode = 1) to the tms9918
Definition pico9918.c:433
const uint8_t * pico9918_line_source(pico9918_t *tms9918)
where the scanline just generated actually is.
Definition pico9918.c:3617
uint8_t pico9918_read_data_no_inc(pico9918_t *tms9918)
read data (mode = 0) from the tms9918
Definition pico9918.c:468
uint32_t pico9918_line_bytes(pico9918_t *tms9918)
how many bytes of pixels[] this mode fills.
Definition pico9918.c:3606
uint8_t pico9918_scan_line(pico9918_t *tms9918, uint16_t y)
generate a scanline
Definition pico9918.c:3335
void pico9918_reset(pico9918_t *tms9918)
reset the new TMS9918
Definition pico9918.c:378
uint8_t pico9918_read_status(pico9918_t *tms9918)
read from the status register
Definition pico9918.c:439
uint8_t pico9918_reg_value(pico9918_t *tms9918, pico9918_register_t reg)
return a register value - see the header for the locked-device aliasing
Definition pico9918.c:3420
void pico9918_write_data(pico9918_t *tms9918, uint8_t data)
write data (mode = 0) to the tms9918
Definition pico9918.c:455
pico9918-core - core interface
#define PICO9918_SR0_5S
more sprites on a line than the limit allows
Definition pico9918.h:290
#define PICO9918_INST_ONLY
pass the instance as the only argument
Definition pico9918.h:86
@ PICO9918_REG_STATUS_SELECT
which status register S1 reads back, and the counter controls
Definition pico9918.h:233
@ PICO9918_REG_UNLOCK
0x1c twice unlocks the F18A personality; any other value locks
Definition pico9918.h:256
@ PICO9918_SR_STATUS
the TMS9918A status: interrupt, 5th sprite, collision, sprite number
Definition pico9918.h:270
@ PICO9918_SR_IDENT
chip identity, blanking, and the scanline interrupt flag
Definition pico9918.h:271
#define TMS9918_PIXELS_X
active display width, every mode
Definition pico9918.h:390
#define PICO9918_SR0_COLLISION
two sprites overlapped on an opaque pixel
Definition pico9918.h:291
#define PICO9918_INST
pass the instance ahead of other arguments
Definition pico9918.h:85
void pico9918_frame_update_interrupts(pico9918_t *tms9918, uint8_t tempStatus)
merge newly raised status flags into the SR0 latch, publish it, and bring the /INT pin into agreement
pico9918_frame_geometry_t pico9918_frame_geometry(pico9918_t *tms9918, pico9918_frame_display_t *display)
see the header.
pico9918-core - frame module
pico9918-core - the private instance layout
PICO9918_INLINE void pico9918_frame_sync_int_impl(pico9918_t *tms9918)
THE single implementation of "a status register was just read" - shared by the public read (pico9918_...
PICO9918_INLINE_HOT void pico9918_set_status_impl(pico9918_t *tms9918, uint8_t status)
set status flag
void pico9918_splash_allow_hide(void)
allow the splash to animate back out - the host calls this once the display has been enabled
Definition splash.c:64
void pico9918_splash_render(uint16_t y, uint32_t frameCount, uint32_t vBorder, uint32_t vPixels, uint32_t vVirtualPixels, PICO9918_PIXEL_T *pixels)
render the splash logo into the scanline buffer, if row y falls in the logo band.
Definition splash.c:74
void pico9918_splash_reset(void)
restart the splash animation (after... reset)
Definition splash.c:57
pico9918-core - Splash overlay
the host's mutable vertical display parameters, as the end-of-frame geometry sees them
uint16_t vVirtualPixels
(in, and out when yScale > 1)
uint8_t vPixelScale
(in, and out when yScale > 1)
the vertical geometry the end of frame derives
uint32_t vBorder
top border offset, in virtual lines
uint32_t triggerScanline
vBorder + vPixels
int vPixels
active VDP display lines