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