From 2d66b68c7f2b477e550ac71a59adba06d21ca865 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 21:30:18 +0000 Subject: [PATCH] tests/emulator: byte-exact golden-image diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke test used to check a per-example `nonBlack` floor — "at least one sprite rendered," plus a per-example minimum for the multi-sprite examples. That catches gross regressions (a compiler bug that makes everything go black) but silently lets through anything that changes a handful of pixels without dropping below the sprite floor. The whole point of this harness is to catch compiler miscompiles before they land; a softer check means bugs can still sneak in. This swap makes every run diff the raw canvas framebuffer against a committed PNG golden. One mismatched byte at pixel (120, 119) is enough to fail CI — there's nowhere for a regression to hide. Workflow: # normal — fails on any pixel change node tests/emulator/run_examples.mjs # when the diff is intentional, rewrite the goldens UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs # or: node tests/emulator/run_examples.mjs --update-goldens git diff tests/emulator/goldens/ # review the change git add tests/emulator/goldens/ git commit # explain WHY in the message When a run fails without `UPDATE_GOLDENS`, the runner writes: tests/emulator/actual/.png the run's raw output tests/emulator/actual/.diff.png red-highlighted pixel diff so reviewers can eyeball what changed without rerunning locally. `actual/` is gitignored and re-created on every run. The CI job now uploads `actual/`, `goldens/`, and `report.json` together as a single `emulator-diff` artifact on failure — side by side means the "what changed" story is obvious without cloning. Implementation: - `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`. All 18 existing PNGs are preserved as the initial goldens (git detected them as pure renames). - `harness.html` gets a new `window.nesHarness.rawPixelsBase64()` that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas buffer as base64. The runner compares raw pixels, not PNG bytes, so encoder quirks (zlib level, filter heuristics) can't cause false positives across Chrome versions or platforms. - The runner uses `pngjs` (pure-JS, no native deps) to decode goldens and to write diff PNGs. `PNG.sync.write` is byte-deterministic for identical pixels, so `git diff` on a committed golden only ever shows up when the actual rendered pixels changed — not because two machines produced slightly different compression. - The committed goldens were re-encoded with pngjs in this commit so the baseline is consistent from day one. File sizes are a touch larger than Chrome's output (~1KB vs ~800B on average), but that's negligible and it eliminates one entire class of flaky-looking diffs in the future. Determinism verification: I ran each of the 18 ROMs twice through fresh `NES` instances in fresh puppeteer pages, hashed the 245760-byte framebuffers at frame 180 with SHA-256, and confirmed `run1 == run2` for every single one. Exact-pixel diffs are safe for this ROM set. Negative path verification: I corrupted one golden (flipped one pixel to pure red via pngjs) and reran the runner. It printed DIFF hello_sprite 1/61440 pixels differ; first at (120,120) expected [255,0,0] got [0,0,0] actual: tests/emulator/actual/hello_sprite.png diff: tests/emulator/actual/hello_sprite.diff.png and exited 1 as expected. The diff PNG shows a dim-grayscale silhouette of the expected frame with a bright-red dot on the one mismatched pixel — enough visual context to locate the regression at a glance. All 18 examples match their goldens in strict mode. `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings`, and `cargo test --release` (313 unit + 37 integration) are all still green. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V --- .github/workflows/ci.yml | 14 +- tests/emulator/.gitignore | 4 + .../emulator/goldens/arrays_and_functions.png | Bin 0 -> 1277 bytes tests/emulator/goldens/bitwise_ops.png | Bin 0 -> 1415 bytes tests/emulator/goldens/bouncing_ball.png | Bin 0 -> 844 bytes tests/emulator/goldens/coin_cavern.png | Bin 0 -> 846 bytes tests/emulator/goldens/comparisons.png | Bin 0 -> 1129 bytes tests/emulator/goldens/function_chain.png | Bin 0 -> 954 bytes tests/emulator/goldens/hello_sprite.png | Bin 0 -> 837 bytes tests/emulator/goldens/inline_asm_demo.png | Bin 0 -> 846 bytes tests/emulator/goldens/logic_ops.png | Bin 0 -> 846 bytes .../emulator/goldens/loop_break_continue.png | Bin 0 -> 1176 bytes tests/emulator/goldens/match_demo.png | Bin 0 -> 846 bytes tests/emulator/goldens/mmc1_banked.png | Bin 0 -> 836 bytes .../emulator/goldens/mmc3_per_state_split.png | Bin 0 -> 952 bytes tests/emulator/goldens/scanline_split.png | Bin 0 -> 952 bytes .../emulator/goldens/sprites_and_palettes.png | Bin 0 -> 823 bytes tests/emulator/goldens/state_machine.png | Bin 0 -> 846 bytes tests/emulator/goldens/structs_enums_for.png | Bin 0 -> 1204 bytes tests/emulator/goldens/two_player.png | Bin 0 -> 917 bytes tests/emulator/harness.html | 14 + tests/emulator/package-lock.json | 15 +- tests/emulator/package.json | 1 + tests/emulator/run_examples.mjs | 388 ++++++++++++------ .../screenshots/arrays_and_functions.png | Bin 998 -> 0 bytes tests/emulator/screenshots/bitwise_ops.png | Bin 908 -> 0 bytes tests/emulator/screenshots/bouncing_ball.png | Bin 817 -> 0 bytes tests/emulator/screenshots/coin_cavern.png | Bin 816 -> 0 bytes tests/emulator/screenshots/comparisons.png | Bin 930 -> 0 bytes tests/emulator/screenshots/function_chain.png | Bin 891 -> 0 bytes tests/emulator/screenshots/hello_sprite.png | Bin 817 -> 0 bytes .../emulator/screenshots/inline_asm_demo.png | Bin 811 -> 0 bytes tests/emulator/screenshots/logic_ops.png | Bin 816 -> 0 bytes .../screenshots/loop_break_continue.png | Bin 963 -> 0 bytes tests/emulator/screenshots/match_demo.png | Bin 807 -> 0 bytes tests/emulator/screenshots/mmc1_banked.png | Bin 822 -> 0 bytes .../screenshots/mmc3_per_state_split.png | Bin 884 -> 0 bytes tests/emulator/screenshots/scanline_split.png | Bin 884 -> 0 bytes .../screenshots/sprites_and_palettes.png | Bin 821 -> 0 bytes tests/emulator/screenshots/state_machine.png | Bin 816 -> 0 bytes .../screenshots/structs_enums_for.png | Bin 911 -> 0 bytes tests/emulator/screenshots/two_player.png | Bin 844 -> 0 bytes 42 files changed, 301 insertions(+), 135 deletions(-) create mode 100644 tests/emulator/goldens/arrays_and_functions.png create mode 100644 tests/emulator/goldens/bitwise_ops.png create mode 100644 tests/emulator/goldens/bouncing_ball.png create mode 100644 tests/emulator/goldens/coin_cavern.png create mode 100644 tests/emulator/goldens/comparisons.png create mode 100644 tests/emulator/goldens/function_chain.png create mode 100644 tests/emulator/goldens/hello_sprite.png create mode 100644 tests/emulator/goldens/inline_asm_demo.png create mode 100644 tests/emulator/goldens/logic_ops.png create mode 100644 tests/emulator/goldens/loop_break_continue.png create mode 100644 tests/emulator/goldens/match_demo.png create mode 100644 tests/emulator/goldens/mmc1_banked.png create mode 100644 tests/emulator/goldens/mmc3_per_state_split.png create mode 100644 tests/emulator/goldens/scanline_split.png create mode 100644 tests/emulator/goldens/sprites_and_palettes.png create mode 100644 tests/emulator/goldens/state_machine.png create mode 100644 tests/emulator/goldens/structs_enums_for.png create mode 100644 tests/emulator/goldens/two_player.png delete mode 100644 tests/emulator/screenshots/arrays_and_functions.png delete mode 100644 tests/emulator/screenshots/bitwise_ops.png delete mode 100644 tests/emulator/screenshots/bouncing_ball.png delete mode 100644 tests/emulator/screenshots/coin_cavern.png delete mode 100644 tests/emulator/screenshots/comparisons.png delete mode 100644 tests/emulator/screenshots/function_chain.png delete mode 100644 tests/emulator/screenshots/hello_sprite.png delete mode 100644 tests/emulator/screenshots/inline_asm_demo.png delete mode 100644 tests/emulator/screenshots/logic_ops.png delete mode 100644 tests/emulator/screenshots/loop_break_continue.png delete mode 100644 tests/emulator/screenshots/match_demo.png delete mode 100644 tests/emulator/screenshots/mmc1_banked.png delete mode 100644 tests/emulator/screenshots/mmc3_per_state_split.png delete mode 100644 tests/emulator/screenshots/scanline_split.png delete mode 100644 tests/emulator/screenshots/sprites_and_palettes.png delete mode 100644 tests/emulator/screenshots/state_machine.png delete mode 100644 tests/emulator/screenshots/structs_enums_for.png delete mode 100644 tests/emulator/screenshots/two_player.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02fb988..5128abe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,14 +121,20 @@ jobs: - name: Install emulator harness dependencies working-directory: tests/emulator run: npm ci - - name: Run each ROM in jsnes and verify it renders + - name: Diff each ROM's framebuffer against its committed golden working-directory: tests/emulator run: node run_examples.mjs - - name: Upload emulator screenshots on failure + - name: Upload actual + diff PNGs on failure if: failure() uses: actions/upload-artifact@v4 with: - name: emulator-screenshots + name: emulator-diff + # `actual/` holds the run's output and red-highlighted diff + # PNGs for any ROM that didn't match its golden. `report.json` + # has the per-example pass/fail/diff counts. `goldens/` is + # included too so reviewers can compare the three side by side + # without cloning the repo. path: | - tests/emulator/screenshots/ + tests/emulator/actual/ + tests/emulator/goldens/ tests/emulator/report.json diff --git a/tests/emulator/.gitignore b/tests/emulator/.gitignore index fe6a8e4..f9713fc 100644 --- a/tests/emulator/.gitignore +++ b/tests/emulator/.gitignore @@ -1,2 +1,6 @@ node_modules/ report.json +# Per-run output written when `run_examples.mjs` finds a diff — not +# tracked; the CI job uploads this as an artifact on failure so +# reviewers can eyeball the mismatched PNGs. +actual/ diff --git a/tests/emulator/goldens/arrays_and_functions.png b/tests/emulator/goldens/arrays_and_functions.png new file mode 100644 index 0000000000000000000000000000000000000000..5032868b6729250bc501994adcf498fcbaacf3ca GIT binary patch literal 1277 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQIpXQ!7*fIb_Ta``nUhSe z7f-WGJuTyz>-1dSacO|zmT0;CY&RHRyg4<&PV?;U=X)6CJywaRT;-aS#p=1WX-Sl0 z&{~C+VG}~MJywargzruK5?q~cC!PF(t7*yqE#IHTO|JHRXS(xx%KNk09FzWCy*Ins zx9{_#mlpc(Om9|y-p)O#?p566<<-7%t`oilAFupaGDm#Ajdx5Q(<+k8zQ!8!Tm>Ir!CpbAY+Ad$rPXZNZ>LgxFdH+wH;b6ryZKFj8A_M+=zOZ*{$`Q_HV ziFUxy0f*AXZ0<|^$vi-rhamsS^?gq2X^gup{h{$oKRCoLysl!ln-HRj6mdA>b5s~J zjng zpDT(U_A<|pUM`GVaoGA2|Myuy&f&dkuLMGJdk%tg63~>xg>gNG7qY9F?LbBY^Uj6W zKsNT2aLxLD&ik|J$vi5xy&rnN-Ge#7m1vyUT@Bh~Pvh^n~ z?^T<$&+kVXB*9MFch36$8*skbaoy$8b~iUP1uplXl{Ao8 a_|NFOhX2}Qd4av4(#_M=&t;ucLK6V?8C72Z literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/bitwise_ops.png b/tests/emulator/goldens/bitwise_ops.png new file mode 100644 index 0000000000000000000000000000000000000000..bee2be1b77d0a52efbe33eaf4c6c5798f3b2d130 GIT binary patch literal 1415 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ_49Ob45?szdvN1gS!)5- zgV!9`?)D}IC;SsMU_ItgZc}TM+-Q35tI7lK<*Vx$_D>L5IYDIgD58A`yjJhJaCc9y z;RLR~pLfmPES|1^E^f}=7kNvZ{%?8r?9I_qf4S?nmem*UYD6#ONY|H3*I%OFsuc76 zN#R}Lm#6(j9)gtYo3nSqK9OI`dkuHIJ2@R>$iA5GDLsvw{xZ}ptdA@IY8o zy>-<_#~!TRZCU-X?CxYkw*0$iZ+35gJ+H^G;&tA+xIMAJKs*>-Ui|IR+g<9(Joi8m zlz-h;GWo-`yL0wd?0$Drg85~+*1zWAMaA14{@6Ry6R8B)S?b?_T<;|Y}QpP z{pGa(={;vJBozUTCWCQl{s-nCNB_s*4YlU&pkTON>VHgP5B`XlP`6zEfc`(GyJv4p zG`}w~&rjyrcNvsne)xfs^#f}Ri3xSvfjU%NwN#GM2>s$Wa*69Y`d>*2RL*+3`njxg HN@xNARdB7_ literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/bouncing_ball.png b/tests/emulator/goldens/bouncing_ball.png new file mode 100644 index 0000000000000000000000000000000000000000..eaae54d5c19a8433125e57c789dd0a28b9aeb7c4 GIT binary patch literal 844 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ7WQ;;45?szdvIg!Z3YIG z1K*Wdqt7l7-SVF$%F%d3>hlAwjU|EFf9}?*{^T=Q$(Uw(U=7=hs|=f^62h3zWHm(c z8mt}_91RdyVtDP}&|e-K{;yQV{oX|e`M0~)%~_v*?z-{w*E2tVJyRN6>CRlU=Jl?k zee-tSmH%0~+aAPPr~UsjgZ%p%+t06ocpcl1itWFZ_vd|gzxP%Bz;WyN^LO6$|FMWa zzh^b0-L*m)kj}z=^DN`f|JiX@-m*{m|L3VstPi;F2AX5lr~Lkh*C*M8AhJ{ESMlPP V7R#5%_=kZqi>Irf%Q~loCIB$^S114g literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/coin_cavern.png b/tests/emulator/goldens/coin_cavern.png new file mode 100644 index 0000000000000000000000000000000000000000..fd704c42cf31d8f596ea05537bf80d329dfb41c4 GIT binary patch literal 846 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ7WH&-45?szdvIf?G$RAc zfv=Aq1YKPqcBP*6`k|`}zHBn*hlt0{+vO8CMzcN1k-u0Q^OP|%w-TB@cBzOueD0e>mU!_ev zQ2O0(zMAE2{a^N-`}29IxBh_t4_UiQzpXx3?E7^6w-vMf!iS4x zj!!)PdFp!)*pWOU-k3{l^+pR^_`R`0E&vmyy-m~L+%KP4Z*Fh@#K0kVB zq5sbGX7%U0-|pSXE}A3Gf4s7A{mFZ?@9Vudy;qI@`>YpR-5zf z*-eRNHlY4VTDSuUN4O1l;VSoEtl$)}pq~2@|Mu_C;wD%7zBAnkisOH}9`-j-qj~Xu z?o0Xw>rWoutM<#@@fRy30eq>yH?gh}oE$DgBn}>v07mU{kWoKN=7=Zrd?^QpKiGns u63l;Z10!jP7YWICxJLutyo=5Ae;IjR?zsKN>S!&fB=L0hb6Mw<&;$VHYyC?A literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/function_chain.png b/tests/emulator/goldens/function_chain.png new file mode 100644 index 0000000000000000000000000000000000000000..b9470cfa13d7f98df3a494652c5e324c189dd1ce GIT binary patch literal 954 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQZuE3<45?szdvN2#D;x|g z2NJoQ-yPeane$IjgY~FFxEw!QXhnauPkFufC*=c6*k)X1*esP0#(XBLA(Gc%HDj9T zfi>@cdY}LN#Q%69%a2>L_J6SPfA{)K?cAN~jlY*ZtDMIWAN|>W-t&{Sb1V1S|9t!P z-;;gkY|aZmc%Aj{QtjNIZ}**Jh>!m~J?_1KUG-jj#(n#M#sM_~)lL6x#avT+_P<4+ z^7`-U`|n(z`P=IA>DPZx7RoU0TbJ)y_e9_3{A7FCD7jboFyt=akR{03x2J0RR91 literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/hello_sprite.png b/tests/emulator/goldens/hello_sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..c4ce20a3ba819f2732cd0c098cb2910cf6944a1b GIT binary patch literal 837 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ=J9lK45?szd(d#=Z3YIG z1E+ntwjN91de-kC(&D&w*OUDxd<_1)eYakvp2;VxA(Gc%HDj9Tfi-M1t}<+vN(f^< zGb%_-fc%pE@yW*jU%8C?kDpS{Y|l^rZB-nf{_gde+PRhU)c@4*UY_^-B#^z={^#?r z|DNnSXLDY-ZX4UaJJ)B{zkAIiE%oD5RsE;)um5sN*Rg{{um7H$|Gl)0m-lkt;}fs{ zp4<*LaNpj0^JR_;)@_UT{MS->{_~Uij5S N;_2$=vd$@?2>>iMR|Wt8 literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/inline_asm_demo.png b/tests/emulator/goldens/inline_asm_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..80396f6854e81882fbfc5494c5ac20780a0c1b00 GIT binary patch literal 846 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ7WH&-45?szdvIs(Z3YIG z178m@247tucBP&*r_od+wXBVqIq!j7Li)4tN&O6yr4quJ&tx@3@*1pWOfx;OhHb`G zhRq{R@OAkQq3^ZN>yH<*?2A6UeD{0n_pi_Vw)(v9+@AHu-)q@Hg1f+ikAI%B?|-fh zl&-zs`2NZV)qB@x*5A7>`GZqx-w~+f2jM?_@sDcfR@%h>d|f-YasGmb!Lr9E9{)Ub zKT!4e(r4>|%*Pf?_g?~|;KR#zzt8>ed(2U@J>Juf$!EmJ1ufFyYyO{w7Ryc62m*5i O1B0ilpUXO@geCz0OI{=Z literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/logic_ops.png b/tests/emulator/goldens/logic_ops.png new file mode 100644 index 0000000000000000000000000000000000000000..d8ce7104b6fc780801cf3402b0d1c8d48cdb578b GIT binary patch literal 846 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ7WH&-45?szdvIf?G$RAc zfv=Aq1YKPqcBP*6`k|`}zHBn*S`n~HjfA3=k3ND5Seyn*q z|M=&rKzopr0N&MKM*si- literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/loop_break_continue.png b/tests/emulator/goldens/loop_break_continue.png new file mode 100644 index 0000000000000000000000000000000000000000..d162a25ddd0adf3c542ed49f42627743fc817c7c GIT binary patch literal 1176 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQiT8AI45?szdvIf}iYEih z!IVa))yao<82pRAptps0@wQ{fxwr#<+CS*rt-Rgn5$_L&prr~c!zP4id#n;wxym&u zi`8>$(~>C1ptXZixaxWPjq1-gC73k|NAVDU%Nh>?KQMG z*WF(23seeZ7_v?Jx8M9hvwXt)v+2n^Dz#^&KQu1!8hl~$S~e6C8k#SjzTLa?`jfcH zAV1u!R%YG11r#_k&yQX%jJuP)>ATzACapW!pGwwueh~6UpS-+R?b3XKFS-!tdek4X{v`%? zn7!hQ^-{?NbHss>1@r-sk<7E?{!3uet-lL#+J2BbX&68ECVug*&bO0J{=n6=yu&!4lk}_sRPcDZ`njxgN@xNA D{Mr@` literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/match_demo.png b/tests/emulator/goldens/match_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..53e9075857034db8a050affc09ad175fc4261bd7 GIT binary patch literal 846 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ7WH&-45?szdvIg!Z3YIG z1Lu`lqt7l7-SVF$rqOss>T?CxhA(A_kKexwpVZGVSt=on`Ak+rB(K40#x&CdYuIL7 zW!O9_h!!Aq?fQlH%m06II&;Pb!ws$-{y}~B`=kt#>taVNIA9@3|_dnOJ-@D%UgRn`> zsY01bpzQ0~xs~zh|Eg@_E&G%|%>C>2Nzm@?zW0CLTl5_$|E_(~y}@%dqR2?3*W|A; WTP(luUHTv>yLh_#xvXL$Zq++PbUmPfuWI1I`pp00i_>zopr E094ag9{>OV literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/mmc3_per_state_split.png b/tests/emulator/goldens/mmc3_per_state_split.png new file mode 100644 index 0000000000000000000000000000000000000000..38e1739ccaeb52ea19a7c65d0468e4150c13f4bd GIT binary patch literal 952 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQuJ?3t45?szdvIg#9S$az z17Xf=cRLen$!&9f@^7o+`1E(L&(zMXoTq+Ze&)aPwjjaJRrR0V ze*O1k-#MG}!VmUm{}cLN`s{Y?T*iMfpQl&swg368s-Ef3&iOyR?_8gG{r6=3@1<-% ziqHPv`PkwUQ0vou=YZn+>%XfXFJ$@gzE0)m)cprO+x)y+D8u;gOux#0CLdaQlNLco zp_zYqgy%g!`M&QlhsmGMKvwPCo$HN(VG0i5j33J}Lw%d6&yMxR_uswdsmYx20TLf9 z`~L6<17qv;-;>jSTQSE={mVRF_yibFr@`jM$G!Il+He~f-lG31&wqYW4h((}Cn1Qo g>59U@p|bY>%+ZVT@}C{eF$QH!Pgg&ebxsLQ0B3BZqW}N^ literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/scanline_split.png b/tests/emulator/goldens/scanline_split.png new file mode 100644 index 0000000000000000000000000000000000000000..5a9a9d639f6a2d5767e8160944f330f611aca2c7 GIT binary patch literal 952 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQuJ?3t45?szdvIg#9S$az z17Xf=cRLeiz?tZGPS@lwtgLre9@0lMm^RC*GSQ zPUW@p1`i?e>TuZpedRo0;KL&JvrS`L{jJY7JJ%ZnqvL+udw*bTf3``CKHLI~=C}LK z9hlz636*(p8febGb9?U1uK_Cmu=L^lG8uQEj(uSBK0gH}2#dZAZ-FWC;r#D5{&nRt i?hmMuEC(#U{xe@<$ji6s`E(YPF+E-VT-G@yGywqm52d95 literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/sprites_and_palettes.png b/tests/emulator/goldens/sprites_and_palettes.png new file mode 100644 index 0000000000000000000000000000000000000000..1534defe99e33b341710fd411b89c005a5474f6c GIT binary patch literal 823 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ`seB57*fIb_Ta{eGVTm4 z8~*7@3EyYg^>OX@Ne9a#7BTH$`!My1^?@a9Gp;ghmP!a?K9kiD$!oBhG0pVAnvpEn zR{8$-v)cQAD(;)V|J`=}J1~^aRNXiK|9pd!X*K$HF)-D_U64H)nT;%&|g z+udGo@V0W^pB>+y9sg|e@U!WM_UDy{+Tt7U&wI`pJsMpEl4?D}A5C@kpvwnEIe|^UO{cD~-Ep7c@D)xOk|M(}@pO*U%mG`bU?tiXb52QW_OWB<& zl&Q3d|M?nh=D#YNc*{QJ4|D5Oe{#t`-}nB{dyBpU<-eys(LS(bh==5mP6jBBf6f0n W-C}uAa#sW>yLh_#xvXa7cIzu;zBb?WADBR|pQS}*;^Qu3;v(Ud5^)IVW^Wo0y^5c~ouRU7M4bgDC z@?+5)@$-9UO4jWH8Gzt_`47@*d9Hi<<2@Dc&Yt;j8J*uM|7fSV{`tK>O@KC3zU%#W zrZ_(iXmKVGf4VcR8`8}EEN4Gz} zS2GL7dU4+7`!fsueYW49U52xQYZ&+c?8oXCr~f%=KnL!AyeA_2BWGIM^Lu-&&vkDV zv%f3;VYYk%Qb4`9KU?zN+O0oSj1TYLDmMQC_mAEsUa&OxT6~oOC~Os3|9%A-0Mv&6l!8E%?us(J1BN|7?B!Ng>mU^k>>9%Nsm-4OTOznI2ffHsdP8W~qcQ<}+Ch zk)wiW0rLM)f3crk>~+zyImMq|?pmkw!|T)B!sm1L#GkL(byt4pI_*ET$h`Vk<9Bl= ze}6sm_TP!|+kY#+zi#||&WG)Di$B%b7VozI^t!D6$=|AHb0+U!_we}duV>bOe=WHU zXxN{s{qrI(es~O&zW(+fM|9qI_qx09`q%sruLH{7dDs8v{okrKU7&G5L;qI;6@Q%i z#QO0$%gX)pewOUEFNzKS@f(@>> 0).toString(16), totalPixels: 256 * 240 }; }, + // Raw canvas pixels as a base64-encoded RGBA buffer + // (256 * 240 * 4 bytes). Used by the golden-diff runner to + // compare pixel-for-pixel against a decoded PNG golden file. + // Base64 is cheap and avoids any puppeteer JSON-serialization + // pitfalls with typed arrays. + rawPixelsBase64() { + const data = ctx.getImageData(0, 0, 256, 240).data; + const chunks = []; + // Chunk to stay under String.fromCharCode's argument limit. + for (let i = 0; i < data.length; i += 4096) { + chunks.push(String.fromCharCode.apply(null, data.subarray(i, i + 4096))); + } + return btoa(chunks.join("")); + }, }; // Controller button enum constants (from jsnes Controller). diff --git a/tests/emulator/package-lock.json b/tests/emulator/package-lock.json index 88b35ab..2281062 100644 --- a/tests/emulator/package-lock.json +++ b/tests/emulator/package-lock.json @@ -1,15 +1,15 @@ { - "name": "emulator", + "name": "nescript-emulator-tests", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "emulator", + "name": "nescript-emulator-tests", "version": "1.0.0", - "license": "ISC", "dependencies": { "jsnes": "^2.1.0", + "pngjs": "^7.0.0", "puppeteer": "^24.40.0" } }, @@ -774,6 +774,15 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", diff --git a/tests/emulator/package.json b/tests/emulator/package.json index ff00548..39bc4d6 100644 --- a/tests/emulator/package.json +++ b/tests/emulator/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "jsnes": "^2.1.0", + "pngjs": "^7.0.0", "puppeteer": "^24.40.0" } } diff --git a/tests/emulator/run_examples.mjs b/tests/emulator/run_examples.mjs index 8403d18..ce81546 100644 --- a/tests/emulator/run_examples.mjs +++ b/tests/emulator/run_examples.mjs @@ -1,66 +1,194 @@ -// Drive the local jsnes harness from puppeteer to sanity-check every compiled -// example ROM. For each ROM we load it, run a couple of seconds of frames, -// capture a screenshot, and record basic "did it render" stats. This is a -// load+render smoke test, not a gameplay test. +// End-to-end smoke test: runs every compiled `.nes` in `examples/` +// through a local `jsnes` (wrapped by `harness.html` in a +// puppeteer-driven headless Chrome), lets it render ~180 frames, +// grabs the raw canvas pixels, and diffs them byte-for-byte against +// a committed golden PNG under `goldens/`. +// +// The goldens are the whole contract. Any change to the compiler +// (or any regression in jsnes, or any change to this harness that +// affects rendering) will change at least one golden, and the diff +// will fail CI loudly. That's the point — it's the only way to +// catch "silently emits wrong code" bugs without writing a +// full-fat CPU test vector per example. +// +// Updating goldens: +// +// UPDATE_GOLDENS=1 node run_examples.mjs +// # or +// node run_examples.mjs --update-goldens +// +// When a diff is legitimate, rerun with that flag. It rewrites the +// PNGs in `goldens/` from whatever the harness just produced. Then +// check the new PNGs in git — `git diff goldens/*.png` lets you eye +// each change, and the commit message is where you document why. +// +// When a diff is not legitimate, the runner writes: +// +// actual/.png the actual pixels for this run +// actual/.diff.png red-highlighted pixel diff vs. golden +// +// so you can upload them as CI artifacts or inspect locally. The +// `actual/` directory is gitignored. import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import puppeteer from "puppeteer"; +import { PNG } from "pngjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "..", ".."); const examplesDir = path.join(repoRoot, "examples"); -const screenshotsDir = path.join(__dirname, "screenshots"); +const goldensDir = path.join(__dirname, "goldens"); +const actualDir = path.join(__dirname, "actual"); const harnessUrl = pathToFileURL(path.join(__dirname, "harness.html")).toString(); -const FRAMES_TO_RUN = 180; // ~3 seconds at 60 fps, enough to get past a title/boot +const WIDTH = 256; +const HEIGHT = 240; +const BYTES_PER_PIXEL = 4; // RGBA +const PIXEL_BYTES = WIDTH * HEIGHT * BYTES_PER_PIXEL; + +const FRAMES_TO_RUN = 180; // ~3 seconds at 60 fps const SCREENSHOT_FRAME = 180; -// Per-example non-black pixel floors, used to catch silent -// render regressions. A bare smiley sprite contributes ~52 -// non-black pixels; the default floor below assumes one visible -// sprite. Examples that draw more sprites override the floor -// with a tighter value so bugs like "only one of the four -// enemies actually shows up" fail CI instead of silently -// slipping past the base `nonBlack > 0` check. -// -// Each entry is `[minNonBlack, note]`. The note is printed when -// the floor fails so it's easy to tell what the example was -// supposed to show. -const DEFAULT_MIN_NON_BLACK = 40; // one small sprite, conservative -const EXAMPLE_FLOORS = { - arrays_and_functions: [200, "player + 4 enemies drawn by a while loop"], - bitwise_ops: [150, "player + multiple flag/pip sprites across if branches and a while loop"], - loop_break_continue: [150, "player + 3 active hazards (one slot is inactive)"], - structs_enums_for: [200, "player + 4 enemies drawn by a `for` loop"], - sprites_and_palettes: [60, "custom CHR tiles visible"], - scanline_split: [80, "banner + player"], - mmc3_per_state_split: [80, "marker + player in the split-screen state"], - two_player: [100, "two player sprites drawn independently"], - function_chain: [100, "player swept by chained function return + a static marker"], - // `comparisons` has at least `value != MIDPOINT` true for 255 of - // 256 frames, plus either `<`/`<=` or `>`/`>=`, plus the player. - // That's 4+ sprites on most frames. - comparisons: [150, "player + pips for each true comparison against MIDPOINT"], -}; +const updateGoldens = + process.env.UPDATE_GOLDENS === "1" || + process.env.UPDATE_GOLDENS === "true" || + process.argv.includes("--update-goldens"); + +// ── PNG helpers ──────────────────────────────────────────────── + +// Decode a PNG file to a raw RGBA Buffer of length PIXEL_BYTES. +// Rejects if the file doesn't exist or has the wrong dimensions. +async function decodeGolden(filePath) { + const bytes = await fs.readFile(filePath); + const png = PNG.sync.read(bytes); + if (png.width !== WIDTH || png.height !== HEIGHT) { + throw new Error( + `golden ${filePath} has wrong dimensions ${png.width}x${png.height}, expected ${WIDTH}x${HEIGHT}`, + ); + } + // `png.data` is already RGBA in top-left-first row-major order. + return png.data; +} + +// Encode a raw RGBA Buffer to a PNG file. +async function writePng(filePath, rgba) { + if (rgba.length !== PIXEL_BYTES) { + throw new Error( + `writePng: expected ${PIXEL_BYTES} bytes, got ${rgba.length}`, + ); + } + const png = new PNG({ width: WIDTH, height: HEIGHT }); + rgba.copy(png.data); + const buf = PNG.sync.write(png); + await fs.writeFile(filePath, buf); +} + +// Build a diff PNG: mismatching pixels in bright red, matching +// pixels in dim grayscale so you can still see the sprite silhouettes +// for context. First differing pixel is also returned for logs. +function buildDiff(expected, actual) { + const out = Buffer.alloc(PIXEL_BYTES); + let mismatched = 0; + let firstDiff = null; + for (let i = 0; i < PIXEL_BYTES; i += 4) { + const eR = expected[i]; + const eG = expected[i + 1]; + const eB = expected[i + 2]; + const aR = actual[i]; + const aG = actual[i + 1]; + const aB = actual[i + 2]; + const same = eR === aR && eG === aG && eB === aB; + if (same) { + // Dim grayscale of the expected pixel — 25% brightness, + // preserves the silhouette without competing with red. + const gray = Math.round((eR * 0.299 + eG * 0.587 + eB * 0.114) * 0.25); + out[i] = gray; + out[i + 1] = gray; + out[i + 2] = gray; + out[i + 3] = 0xff; + } else { + mismatched++; + if (firstDiff === null) { + const px = (i / 4) | 0; + firstDiff = { + x: px % WIDTH, + y: (px / WIDTH) | 0, + expected: [eR, eG, eB], + actual: [aR, aG, aB], + }; + } + out[i] = 0xff; + out[i + 1] = 0x00; + out[i + 2] = 0x00; + out[i + 3] = 0xff; + } + } + return { mismatched, firstDiff, rgba: out }; +} + +// ── ROM discovery ────────────────────────────────────────────── async function listRoms() { const entries = await fs.readdir(examplesDir); return entries .filter((f) => f.endsWith(".nes")) .sort() - .map((f) => ({ name: f.replace(/\.nes$/, ""), file: path.join(examplesDir, f) })); + .map((f) => ({ + name: f.replace(/\.nes$/, ""), + file: path.join(examplesDir, f), + })); } -function floorFor(name) { - const entry = EXAMPLE_FLOORS[name]; - if (entry) return entry; - return [DEFAULT_MIN_NON_BLACK, "generic single-sprite floor"]; +// ── Harness driver ───────────────────────────────────────────── + +async function runRomInHarness(page, rom) { + const romBytes = await fs.readFile(rom.file); + const romB64 = romBytes.toString("base64"); + + let bootError = null; + try { + await page.evaluate((b64) => window.nesHarness.loadRomBase64(b64), romB64); + } catch (err) { + bootError = String(err); + return { bootError, rgba: null }; + } + + try { + // Use runFrames — a single round-trip is much faster than + // 180 separate `frame()` calls across puppeteer's RPC. + await page.evaluate( + (n) => window.nesHarness.runFrames(n), + FRAMES_TO_RUN, + ); + } catch (err) { + return { bootError: String(err), rgba: null }; + } + // Frame count here is a no-op marker kept for readability. + void SCREENSHOT_FRAME; + + const pixelsB64 = await page.evaluate(() => window.nesHarness.rawPixelsBase64()); + const rgba = Buffer.from(pixelsB64, "base64"); + if (rgba.length !== PIXEL_BYTES) { + return { + bootError: `harness returned ${rgba.length} pixel bytes, expected ${PIXEL_BYTES}`, + rgba: null, + }; + } + return { bootError: null, rgba }; } +// ── Main ─────────────────────────────────────────────────────── + async function main() { - await fs.mkdir(screenshotsDir, { recursive: true }); + await fs.mkdir(goldensDir, { recursive: true }); + // Wipe and recreate `actual/` so each run starts clean. This + // directory is gitignored, so it only exists to give the CI job + // something to upload when diffs fail. + await fs.rm(actualDir, { recursive: true, force: true }); + await fs.mkdir(actualDir, { recursive: true }); + const roms = await listRoms(); if (roms.length === 0) { console.error("no .nes files found in examples/ — build them first"); @@ -72,6 +200,7 @@ async function main() { args: ["--no-sandbox", "--disable-setuid-sandbox", "--allow-file-access-from-files"], }); + /** @type {Array<{name: string, status: string, reason: string | null}>} */ const results = []; let failures = 0; @@ -85,105 +214,108 @@ async function main() { }); await page.goto(harnessUrl, { waitUntil: "load" }); - // Wait until the harness reports ready. - await page.waitForFunction("window.nesHarness && document.getElementById('info').textContent === 'ready'"); - - const romBytes = await fs.readFile(rom.file); - const romB64 = romBytes.toString("base64"); - - let booted = true; - let bootError = null; - try { - await page.evaluate((b64) => window.nesHarness.loadRomBase64(b64), romB64); - } catch (err) { - booted = false; - bootError = String(err); - } - - // Collect hashes across frames so we can detect a frozen / all-black boot. - const frameHashes = []; - if (booted) { - try { - for (let i = 0; i < FRAMES_TO_RUN; i++) { - await page.evaluate(() => window.nesHarness.frame()); - if (i === 29 || i === 89 || i === 149 || i === SCREENSHOT_FRAME - 1) { - const stats = await page.evaluate(() => window.nesHarness.frameStats()); - frameHashes.push({ frame: i + 1, ...stats }); - } - } - } catch (err) { - booted = false; - bootError = String(err); - } - } - - const screenshotPath = path.join(screenshotsDir, `${rom.name}.png`); - if (booted) { - const canvas = await page.$("#screen"); - await canvas.screenshot({ path: screenshotPath }); - } - - const lastStats = frameHashes[frameHashes.length - 1]; - const uniqueHashes = new Set(frameHashes.map((h) => h.hash)).size; - const rendered = booted && lastStats && lastStats.nonBlack > 0; - const animated = uniqueHashes > 1; - - const [minNonBlack, floorNote] = floorFor(rom.name); - const meetsFloor = rendered && lastStats.nonBlack >= minNonBlack; - const pass = rendered && meetsFloor; - - const status = pass ? "OK" : "FAIL"; - if (!pass) failures++; - - let failReason = null; - if (!booted) { - failReason = `boot error: ${bootError ?? "unknown"}`; - } else if (!rendered) { - failReason = "rendered a fully black screen (nonBlack=0)"; - } else if (!meetsFloor) { - failReason = `nonBlack=${lastStats.nonBlack} below floor=${minNonBlack} (${floorNote})`; - } - - results.push({ - name: rom.name, - status, - bootError, - rendered, - animated, - meetsFloor, - minNonBlack, - floorNote, - failReason, - frames: frameHashes, - consoleErrors, - screenshot: booted ? path.relative(repoRoot, screenshotPath) : null, - }); - - console.log( - `${status.padEnd(4)} ${rom.name.padEnd(28)} ` + - (rendered - ? `nonBlack=${lastStats.nonBlack}/${lastStats.totalPixels} (floor=${minNonBlack}) uniqueHashes=${uniqueHashes} animated=${animated}` - : `boot=${booted} bootError=${bootError ?? "none"}`), + await page.waitForFunction( + "window.nesHarness && document.getElementById('info').textContent === 'ready'", ); - if (failReason && !rendered) { - console.log(` reason: ${failReason}`); - } else if (failReason) { - console.log(` reason: ${failReason}`); - } - if (consoleErrors.length > 0) { + + const { bootError, rgba } = await runRomInHarness(page, rom); + await page.close(); + + if (bootError || !rgba) { + failures++; + const reason = `boot error: ${bootError ?? "no pixels"}`; + results.push({ name: rom.name, status: "FAIL", reason }); + console.log(`FAIL ${rom.name.padEnd(28)} ${reason}`); for (const e of consoleErrors) console.log(" console:", e); + continue; } - await page.close(); + const goldenPath = path.join(goldensDir, `${rom.name}.png`); + let goldenExists = true; + try { + await fs.access(goldenPath); + } catch { + goldenExists = false; + } + + // ── Update mode ────────────────────────────────────── + if (updateGoldens) { + await writePng(goldenPath, rgba); + results.push({ name: rom.name, status: "UPDATED", reason: null }); + console.log(`UPD ${rom.name.padEnd(28)} wrote golden`); + continue; + } + + // ── Missing golden ────────────────────────────────── + if (!goldenExists) { + failures++; + // Write the actual so the user can inspect, then bail. + await writePng(path.join(actualDir, `${rom.name}.png`), rgba); + const reason = `no golden at ${path.relative(repoRoot, goldenPath)} — run with UPDATE_GOLDENS=1 to create`; + results.push({ name: rom.name, status: "MISSING", reason }); + console.log(`MISS ${rom.name.padEnd(28)} ${reason}`); + continue; + } + + // ── Byte-for-byte diff ────────────────────────────── + let golden; + try { + golden = await decodeGolden(goldenPath); + } catch (err) { + failures++; + const reason = `failed to decode golden: ${err.message}`; + results.push({ name: rom.name, status: "FAIL", reason }); + console.log(`FAIL ${rom.name.padEnd(28)} ${reason}`); + continue; + } + + // `rgba.equals(golden)` is an O(n) native memcmp — fastest + // path when they match, which is the common case. + if (rgba.equals(golden)) { + results.push({ name: rom.name, status: "OK", reason: null }); + console.log(`OK ${rom.name.padEnd(28)} exact match`); + continue; + } + + // Mismatch: write the actual and a diff PNG, record why. + const { mismatched, firstDiff, rgba: diffRgba } = buildDiff(golden, rgba); + const actualPath = path.join(actualDir, `${rom.name}.png`); + const diffPath = path.join(actualDir, `${rom.name}.diff.png`); + await writePng(actualPath, rgba); + await writePng(diffPath, diffRgba); + + failures++; + const reason = + `${mismatched}/${WIDTH * HEIGHT} pixels differ; ` + + `first at (${firstDiff.x},${firstDiff.y}) ` + + `expected [${firstDiff.expected.join(",")}] ` + + `got [${firstDiff.actual.join(",")}]`; + results.push({ name: rom.name, status: "DIFF", reason }); + console.log(`DIFF ${rom.name.padEnd(28)} ${reason}`); + console.log(` actual: ${path.relative(repoRoot, actualPath)}`); + console.log(` diff: ${path.relative(repoRoot, diffPath)}`); } } finally { await browser.close(); } const reportPath = path.join(__dirname, "report.json"); - await fs.writeFile(reportPath, JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2)); - console.log(`\nreport written to ${path.relative(repoRoot, reportPath)}`); - console.log(`${results.length - failures}/${results.length} ROMs rendered successfully`); + await fs.writeFile( + reportPath, + JSON.stringify({ generatedAt: new Date().toISOString(), updateGoldens, results }, null, 2), + ); + + console.log(""); + console.log(`report written to ${path.relative(repoRoot, reportPath)}`); + if (updateGoldens) { + console.log(`${results.length} goldens updated`); + console.log("review the changes with `git diff tests/emulator/goldens/` before committing"); + } else { + console.log(`${results.length - failures}/${results.length} ROMs match their goldens`); + if (failures > 0) { + console.log("rerun with UPDATE_GOLDENS=1 if the new output is intentional"); + } + } if (failures > 0) process.exit(1); } diff --git a/tests/emulator/screenshots/arrays_and_functions.png b/tests/emulator/screenshots/arrays_and_functions.png deleted file mode 100644 index c4f641e3ee548a2302f6e303d5e7e61980aa5f9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 998 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5#Gr$%L=Ea~K$yXM4IhhE&XXb9ZCzV+R3; zzCCou=%9RUbGw1UR1WmU_HjymO~r+mf;c_3SNwtrdJ2mT)$hFiuco zxWwWxi@`&fA(KhL7b3Xf!r!|3x_x)^j@vLLO#OWO|KG2bdu47MxX&zBQSpI~F5+_*_7Cw)?aD(&1^9Iv88gcA$_>Ikv*MdPk^O*Tk;DMF-aWBzVH_qDCt;&2E|xdc#R68B_Q$xMETD9 z@6Vs#ep|Mon<4J~pKtZ`wQ>5U8_wQuo?eBL^de5NNIV23y#`*N20>ubOHdY0Xn7tx zfpf#n`OV?KKMK@Ld!Se$&Dit6^u=n929xuc3H~(m2VwrnYk3cNmbXP$NL}$Pe->TG z{bY&tWZnDh1()WYEc0Pdzbl#jHZuT7<^3$oSnoLVj2%N}9j6*JtKjkJt$JW4{r~^a i!liLQ1{)}wGBf;Ic%P%c;?eqOQ0^c?{%wX^kX2@hx@MT!S z*qRYQJ6n|Mh2KY*xaK=dIB)%m06U`#0hp z|L;#$0uqNuV3WgMHO-=){Le{Z}a z|Ly6|qPd0BC3*jGmhAIgzy9jV@|mYMa2D*?=llP-Ua_P-V_))@+q=tl#XkGArRVcw zr=9ax*{_=&{KjbRL`EIEW}{mV7!q{;vrMLDa(ye!!0`Y7U){PXz&ynU%1q1*W{OGQ TB6sXN0m^Kiu6{1-oD!M<)sP`W diff --git a/tests/emulator/screenshots/bouncing_ball.png b/tests/emulator/screenshots/bouncing_ball.png deleted file mode 100644 index 83ba6f6817f523d9224ab90cab20c9171b716122..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 817 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5#Gr$%L=Ea~K$yo_V@BhE&XXb7SM$Lk1!a z7ysyYZ8>v*>y165wgGdlmy)8Zpi|?m?{iK~i~05HsHLw!*(?Pgh9#U0CX5r*7%s6m z%wq5mX2@hx@EsK#4G?HzNN_m4Dd%>07})(+WOth%qq#`fI*| x!Q(R~vABN(-?f?J3|6X~Unr(&x>`eKUGqL+#tF~(MqWVD sJ`wF^W?=aL|LneSIbgbH10{H7h5~N=IaX^A3xYC)r>mdKI;Vst0MM=A=Kufz diff --git a/tests/emulator/screenshots/comparisons.png b/tests/emulator/screenshots/comparisons.png deleted file mode 100644 index ade5cd3c7b79d4640472a92a2738d2d18d0b7d6b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 930 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5#Gr$%L=Ea~K$y!#!ObLn`LHxoPOPCP1Vi z(L`*^x`Vnq{)_Hly`JFx)5C+~+~FBB0t)`WU%lh)%%7IbpN$-EoMuU6Qt)M1!r5TL zI6;l!5{ttu1`lC|OiV#(m4?*FZ?7xosqfoeW%qsVe-6eqm;e9zV*+16KoJH{S3j3^P6o9}CLzp3xNx_$4 z31@=|;{-K^OY>L^&VSxEZ&~Jntu|ZkN>@vZO4q?|M{-n{{Qde&b;YIH{IoD z+0meG-?)u)gFI`2Sc(#;|E6#jaNTCP z(a*$}z%=Jj9TN{<3XuM$eS*c}M(&5K{c;DrIpq$v3m(w?!ShRtN#U$CG}=Z$7=ltK smAv^Mm~;OBPvVN%2xPE<@(we@&Kc3=o6H`Y0Yw-*UHx3vIVCg!0M$PgZU6uP diff --git a/tests/emulator/screenshots/hello_sprite.png b/tests/emulator/screenshots/hello_sprite.png deleted file mode 100644 index b2471d3a443d87b884accc01d0406e203ee5435f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 817 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5#Gr$%L=Ea~K$yo_V@BhE&XXb7Q0L5eEj= zi>$E^rbuNp{?lH-nx#Jj{o>a zD~>%=SR)SYWVo)Gz{I_wN?}Sly{VN7K_N9Vo)!>FVdQ&MBb@0MfGK$N&HU diff --git a/tests/emulator/screenshots/inline_asm_demo.png b/tests/emulator/screenshots/inline_asm_demo.png deleted file mode 100644 index af8ca562a6b6da808cc4e98ec613ac8b890f9b89..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 811 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5#Gr$%L=Ea~K$y9(cMqhE&XXb7N!RVFw1* zi_L`#rbuNp{?lH-nxzoh!PcKT*Lz2;{-vk4ep=mSYXKt81{1~!Y7CcH9A+_i2s30d zDfluh8EJwBH^2X_xwkEMgB7EVZ2tfE=f8hl(fD*P|G&HU|Mj0=|NCQwMc;MD1W&Pq zBE18pToE%^XT*pn2qZA7JrAG2+EF2GvnQshUnr(&x_U$B{RO5>3TI_U!Ki)) n!~g$_1y(8nlRO(Jtur&sW!0Y}qE`M7lovc*{an^LB{Ts5(*WWT diff --git a/tests/emulator/screenshots/logic_ops.png b/tests/emulator/screenshots/logic_ops.png deleted file mode 100644 index 497c1f551887c09df5b08988a5d98941693d2846..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 816 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5#Gr$%L=Ea~K$yo_e}ChE&XXb7N!fVFL!% zK(&H}X2vP4Z|ohzPVi)gMl~=x7Nk!5vNybQFJJtXT!$OE4l@`$gc&lK6nq($a5k7Q zPEcdG#Nse(RFH6h806mn{<-q@wu7fw44zl~{{O#jy*sbY+u{$Kj^F%&&*#~3o>NB w8;uA2X>d0)1H=FSLZ2_+2Bv#9P=aS>5b4sNlP7oS6DUJ?y85}Sb4q9e0L5+PNJu4nZ~cg1vF?&rd&{+PE=|ZNt95_V?p&FJ+T>Q#$j>w{QFFYwk^Z zeR$U$J{F0DPyP#jv)t%ol1RJ7vg4*-18duCd&a!=`#2AX=%2Q!H#?ZkX?Bpu%(&rb zdO+H28HO`Y^$!jTUp6dUcm4I>pG7-$&ZspMUfcfv`S0xAR}Pe{=XSnx@89?S^S6s? zBc|VOJRqWWAmBUa23c006egana~ZhV6PS2P|BI$D$s9R-NCWD{%bW=s^Bw!$7?#{* z#v7}0kLKSv=f&{9c-r?Pb2xstZ{G7fjmhRnp=fS?83{1OR@x BLa6`% diff --git a/tests/emulator/screenshots/match_demo.png b/tests/emulator/screenshots/match_demo.png deleted file mode 100644 index fbdb32bff98c7d836c3075a1bbff44de438bc3fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 807 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5#Gr$%L=Ea~K$y?s&R5hE&XXb8{o_Ap;SH zz-En0M$Fn9{0Kl)xf_3rPN{f}=qY`EQEz&JsT;S!6(ECvr@ zhD;^}Uxp=|4JM<4BQQXGg%0dm_x zpqj@*Gvk!jH};NUA9(UY-5MAiKQJt5{2zDp_0Pg(e@qqLWIN1Y@DOImWK!^DSi;$0 z!Z<;V;S!6(EUF8Nv2S?)yY$|++ygP%%och7KYuKjH$9LtKY8NkZ}tEG%zIpDaqPEK z1E<)3mR)8CYDEv2oHje~avvj`&-OazIpO!&5<2@go;$7lL*tDnHyMKOnjd(; zdxIf3c7Ib&gZW3p8Bd=I)=YUIxMvQ-vQLs=`Tt*P i>DjG71{)~TFf;fHMVE(9_%i`0!r zpqj@*Gvk!jH};NUA9(UY-5MAiKQJt5{2zDp_0Pg(e@qqLWIN1Y@DOImWK!^DSi;$0 z!Z<;V;S!6(EFuNP*f+fYU3za@?tvI>W{bT4pFft%n;uA+pFHvNxBCBo<~=U7IQHAA zfm7^1%PzA6wW0@1PMaNgxsQ>}XL}v37!2L&z)BOp>a@o#>p@?gBrHftw$LT zME&DbBg;X=JAFi{JP`7GU;Ojj+ia^j6XresW-nj=J&^fyE#ESW{Quw2zqgcYPu`=? zGHnCnGH^6)n80grwU(LZUIUQ+s(pg3LRh{zGEVNGJEz>i)0_!f^BwQ^GI)GuBQMnq sbkN_j2WFZ7|E=Fm2IVF;P^MvKusRxDuDQ=0D8Rtr>FVdQ&MBb@0R0RJH2?qr diff --git a/tests/emulator/screenshots/sprites_and_palettes.png b/tests/emulator/screenshots/sprites_and_palettes.png deleted file mode 100644 index 63c9c3784db6b9747eda95f3bddb4e7c4066f08f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 821 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5#Gr$%L=Ea~K$yUU|AWhE&XXbIY--*+IY| zF#W`#&y@k^pBFJT>KH{%=l#F8wW8ry$o3yM6y9ufoWbBB%#g{X;LEUtv%!RMf*Qjm z7Kd3QSFm9J>s|BC*WS0kC;4G>;p*#0{x5HoGy{du+w#X@@%>ik?2}mv_V*W>|DDXT zq2>F9+5ZyvF-?3X&3R Ui;(T9oInuo~boZWrJL2r_F{nl7+ANOz|6J`a>n`#CGyXM(NuSTSR)7A3?1N_; zf8;W*?oBs&y+8dPU%~8GR^affBk z{(3)CgkjCe=?TzSI1sY6{ry+Ygn73lmrOT!>20vp*kk=6K7-J=JePjGVFm)E+v4QdvGeeH^eSHc0 RFGoT7&C}J-Wt~$(69CHB}$EF0GU|NFIa-_-+Cm)-kc>hmt* z|J%Qhcih$gcKdVVk^Qr3|GVyEvh`c=Iq*p8v%B@b)DJwYVdB*@fB)~b4P)O+TgG`w z_x{Eo`%|~K%3+1$}B$!`>UK$*qU)z4*}Q$iB}DANI`