romdevtools 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/AGENTS.md +53 -43
  2. package/CHANGELOG.md +91 -0
  3. package/README.md +3 -3
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1225 -332
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +906 -275
  8. package/examples/atari2600/templates/shmup.asm +1031 -239
  9. package/examples/atari2600/templates/sports.asm +1135 -253
  10. package/examples/atari7800/templates/platformer.c +991 -156
  11. package/examples/atari7800/templates/puzzle.c +1091 -148
  12. package/examples/atari7800/templates/racing.c +952 -124
  13. package/examples/atari7800/templates/shmup.c +812 -134
  14. package/examples/atari7800/templates/sports.c +820 -184
  15. package/examples/c64/templates/platformer.c +879 -164
  16. package/examples/c64/templates/puzzle.c +855 -178
  17. package/examples/c64/templates/racing.c +873 -97
  18. package/examples/c64/templates/shmup.c +757 -161
  19. package/examples/c64/templates/sports.c +755 -100
  20. package/examples/gb/templates/platformer.c +841 -179
  21. package/examples/gb/templates/puzzle.c +986 -246
  22. package/examples/gb/templates/racing.c +754 -174
  23. package/examples/gb/templates/shmup.c +673 -175
  24. package/examples/gb/templates/sports.c +790 -159
  25. package/examples/gba/templates/platformer.c +626 -165
  26. package/examples/gba/templates/puzzle.c +519 -269
  27. package/examples/gba/templates/racing.c +511 -206
  28. package/examples/gba/templates/shmup.c +564 -179
  29. package/examples/gba/templates/sports.c +454 -174
  30. package/examples/gbc/templates/platformer.c +944 -180
  31. package/examples/gbc/templates/puzzle.c +363 -109
  32. package/examples/gbc/templates/racing.c +884 -180
  33. package/examples/gbc/templates/shmup.c +821 -185
  34. package/examples/gbc/templates/sports.c +870 -162
  35. package/examples/genesis/templates/platformer.c +747 -129
  36. package/examples/genesis/templates/puzzle.c +694 -261
  37. package/examples/genesis/templates/racing.c +726 -203
  38. package/examples/genesis/templates/shmup.c +535 -142
  39. package/examples/genesis/templates/sports.c +495 -158
  40. package/examples/gg/templates/platformer.c +880 -215
  41. package/examples/gg/templates/puzzle.c +875 -216
  42. package/examples/gg/templates/racing.c +915 -172
  43. package/examples/gg/templates/shmup.c +714 -191
  44. package/examples/gg/templates/sports.c +732 -129
  45. package/examples/lynx/templates/platformer.c +604 -69
  46. package/examples/lynx/templates/puzzle.c +498 -158
  47. package/examples/lynx/templates/racing.c +538 -102
  48. package/examples/lynx/templates/shmup.c +458 -131
  49. package/examples/lynx/templates/sports.c +496 -72
  50. package/examples/msx/platformer/main.c +649 -162
  51. package/examples/msx/puzzle/main.c +742 -240
  52. package/examples/msx/racing/main.c +669 -178
  53. package/examples/msx/shmup/main.c +460 -178
  54. package/examples/msx/sports/main.c +592 -126
  55. package/examples/nes/templates/platformer.c +589 -171
  56. package/examples/nes/templates/puzzle.c +563 -242
  57. package/examples/nes/templates/racing.c +502 -208
  58. package/examples/nes/templates/shmup.c +339 -145
  59. package/examples/nes/templates/sports.c +341 -183
  60. package/examples/pce/platformer/main.c +874 -205
  61. package/examples/pce/puzzle/main.c +802 -287
  62. package/examples/pce/racing/main.c +783 -208
  63. package/examples/pce/shmup/main.c +638 -212
  64. package/examples/pce/sports/main.c +586 -169
  65. package/examples/porting-across-platforms/README.md +1 -1
  66. package/examples/sms/templates/platformer.c +762 -177
  67. package/examples/sms/templates/puzzle.c +752 -212
  68. package/examples/sms/templates/racing.c +808 -145
  69. package/examples/sms/templates/shmup.c +599 -162
  70. package/examples/sms/templates/sports.c +630 -122
  71. package/examples/snes/templates/music_demo.c +7 -0
  72. package/examples/snes/templates/platformer-data.asm +123 -24
  73. package/examples/snes/templates/platformer-hdr.asm +57 -0
  74. package/examples/snes/templates/platformer.c +586 -165
  75. package/examples/snes/templates/puzzle-data.asm +116 -21
  76. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  77. package/examples/snes/templates/puzzle.c +614 -235
  78. package/examples/snes/templates/racing-data.asm +390 -32
  79. package/examples/snes/templates/racing-hdr.asm +57 -0
  80. package/examples/snes/templates/racing.c +807 -196
  81. package/examples/snes/templates/shmup-data.asm +87 -29
  82. package/examples/snes/templates/shmup-hdr.asm +57 -0
  83. package/examples/snes/templates/shmup.c +459 -198
  84. package/examples/snes/templates/sports-data.asm +48 -2
  85. package/examples/snes/templates/sports-hdr.asm +57 -0
  86. package/examples/snes/templates/sports.c +414 -163
  87. package/package.json +12 -12
  88. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  89. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  90. package/src/cores/wasm/fceumm_libretro.js +1 -1
  91. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  92. package/src/cores/wasm/gambatte_libretro.js +1 -1
  93. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  94. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  95. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  96. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  97. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  98. package/src/cores/wasm/handy_libretro.js +1 -1
  99. package/src/cores/wasm/handy_libretro.wasm +0 -0
  100. package/src/cores/wasm/mgba_libretro.js +1 -1
  101. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  102. package/src/cores/wasm/prosystem_libretro.js +1 -1
  103. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  104. package/src/cores/wasm/snes9x_libretro.js +1 -1
  105. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  106. package/src/cores/wasm/stella2014_libretro.js +1 -1
  107. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  108. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  109. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  110. package/src/host/LibretroHost.js +84 -8
  111. package/src/http/tool-registry.js +11 -11
  112. package/src/mcp/tools/cheats.js +2 -1
  113. package/src/mcp/tools/frame.js +3 -2
  114. package/src/mcp/tools/index.js +3 -3
  115. package/src/mcp/tools/input.js +5 -4
  116. package/src/mcp/tools/lifecycle.js +6 -4
  117. package/src/mcp/tools/memory.js +131 -24
  118. package/src/mcp/tools/platform-docs.js +1 -1
  119. package/src/mcp/tools/preview-tile.js +6 -2
  120. package/src/mcp/tools/project.js +1098 -130
  121. package/src/mcp/tools/record.js +6 -7
  122. package/src/mcp/tools/rom-id.js +5 -1
  123. package/src/mcp/tools/run-until.js +12 -4
  124. package/src/mcp/tools/snippets.js +6 -6
  125. package/src/mcp/tools/sprite-pipeline.js +14 -2
  126. package/src/mcp/tools/state.js +2 -1
  127. package/src/mcp/tools/tile-inspect.js +8 -1
  128. package/src/mcp/tools/toolchain.js +12 -1
  129. package/src/mcp/tools/watch-memory.js +53 -10
  130. package/src/observer/bus.js +73 -0
  131. package/src/observer/livestream.html +4 -2
  132. package/src/observer/tool-wrap.js +17 -14
  133. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +32 -3
  134. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  135. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  136. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  137. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  138. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  139. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  140. package/src/platforms/gb/lib/c/README.md +10 -11
  141. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  142. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  143. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  144. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  145. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  146. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  147. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  148. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  149. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  150. package/src/platforms/gbc/lib/c/README.md +10 -11
  151. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  152. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  153. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  154. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  155. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  156. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  157. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  158. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  159. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  160. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  161. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  162. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  163. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  164. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  165. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  166. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  167. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  168. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  169. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  170. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  171. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  172. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  173. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  174. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  175. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  176. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  177. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  178. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  179. package/src/toolchains/index.js +27 -11
@@ -1,103 +1,276 @@
1
- /* ── shmup.c — Game Boy vertical-shooter scaffold ────────────────────
2
- *
3
- * Mirrors the NES/Genesis/SNES shmup scaffolds for the Game Boy:
4
- * - Player ship at OAM slot 0
5
- * - 4 bullet slots, 4 enemy slots (smaller pools for the Game Boy's
6
- * 40-sprite hard cap total = 9, leaves plenty of headroom)
7
- * - Wave spawner every ~28 frames
8
- * - Linear movement, AABB collision (8×8 vs 8×8)
9
- * - Score rendered to OAM digits would need a font tile blob; we
10
- * keep a running u16 score in WRAM for now extend with BG-map
11
- * text rendering when you wire a font.
12
- *
13
- * GB OAM coords are HARDWARE coords: visible Y range is 16..160,
14
- * visible X range is 8..168. Y=0 hides the sprite (off-screen).
1
+ /* ── shmup.c — PHOTON DRIFT: Game Boy Color vertical shooter (complete example game) ──
2
+ *
3
+ * A COMPLETE, working game title screen, lives, score + persistent
4
+ * battery hi-score (MBC1+RAM+BATTERY SRAM), GB APU music + SFX, the Game
5
+ * Boy's signature WINDOW-LAYER fixed HUD over a scrolling starfield and
6
+ * the GBC's signature feature on top of all of it: TRUE per-tile color.
7
+ * The ship, its bullets, the enemies and the starfield are each REAL CGB
8
+ * palettes (15-bit BGR, loaded through BCPS/BCPD + OCPS/OCPD): the field is
9
+ * three DEPTH-BANDED blue palettes selected per BG cell through the VRAM
10
+ * bank-1 attribute map, and the ship (cyan), bullets (gold) and enemies
11
+ * (red) are their own OBJ palettes through OCPS — not a colorized
12
+ * monochrome game.
13
+ *
14
+ * THE GAME: a one-stick vertical shooter. The d-pad flies your ship around
15
+ * the lower playfield, A fires (a six-deep bullet pool), and waves of
16
+ * enemies drift down a parallax-banded starfield. Shoot them for points;
17
+ * one that reaches your ship costs a life. Three lives; the battery
18
+ * remembers your best run forever. SELECT toggles the music.
19
+ *
20
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
21
+ * very different one. The markers tell you what's what:
22
+ * HARDWARE IDIOM (load-bearing) — dodges a documented GB/GBC footgun;
23
+ * reshape your gameplay around it (see TROUBLESHOOTING before changing).
24
+ * GAME LOGIC (clay) — enemy patterns, scoring, tuning, art: reshape freely.
25
+ *
26
+ * SINGLE-PLAYER, honestly: the Game Boy's "player 2" is a LINK CABLE, which
27
+ * one emulator instance cannot provide — so handheld examples ship a
28
+ * press-start title and no 2P mode instead of faking one. (Consoles' shmup
29
+ * examples have real co-op 2P.)
30
+ *
31
+ * What depends on what:
32
+ * gb_hardware.h — register names (LCDC/WX/WY/VBK/BCPS/NRxx/...) + masks.
33
+ * gb_runtime.{h,c} — vblank wait (HALT-driven), joypad, shadow OAM + the
34
+ * OAM-DMA-from-HRAM routine, VRAM-safe memcpy, APU helpers (shared GB).
35
+ * gb_crt0.s — boot + interrupt vectors + the cartridge header window. It
36
+ * DECLARES the cart as MBC1+RAM+BATTERY ($0147=$03, $0149=$02): that
37
+ * header is what makes the SRAM hi-score persist (the GB equivalent of
38
+ * the NES BATTERY bit). Load-bearing; edit with TROUBLESHOOTING open.
39
+ * font.h — 0-9 A-Z 2bpp glyphs for all text.
40
+ *
41
+ * The starfield fills the FULL 32-row BG map (not just the visible 18)
42
+ * because the uint8 SCY scroll wraps through all 32 — a part-filled map
43
+ * scrolls garbage into view. The color travels with the tiles: each cell's
44
+ * bank-1 attribute byte scrolls along with its tile, so a "far" depth band
45
+ * stays its dim blue wherever it slides under the screen.
46
+ *
47
+ * WRAM NOTE: build with dataLoc:0xC200 so our statics sit ABOVE shadow_oam
48
+ * ($C100) — else oam_clear() would zero our state. The project recipe sets
49
+ * that automatically.
15
50
  */
16
51
 
17
52
  #include "gb_hardware.h"
18
53
  #include "gb_runtime.h"
54
+ #include "font.h"
19
55
 
20
- #define MAX_BULLETS 4
21
- #define MAX_ENEMIES 4
56
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
57
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
58
+ #define GAME_TITLE "PHOTON DRIFT"
22
59
 
60
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
61
+ * Tile inventory. GB/GBC tiles are 16 bytes: 8 rows × [low-plane byte,
62
+ * high-plane byte]. Pixel colour index = (hi_bit << 1) | lo_bit (0..3); on
63
+ * CGB that index selects a colour WITHIN whichever CGB palette the cell's
64
+ * bank-1 attribute (BG) or the sprite's OAM attr (OBJ) chose. So one ship
65
+ * tile reads cyan or any other palette purely by its attribute byte. */
23
66
  static const uint8_t tile_blank[16] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 };
24
- static const uint8_t tile_ship[16] = {
25
- 0x18,0x18, 0x3C,0x3C, 0x7E,0x7E, 0xFF,0xFF,
26
- 0xFF,0xFF, 0x7E,0x7E, 0x3C,0x3C, 0x18,0x18,
27
- };
28
- /* ── BG tiles (starfield) ─────────────────────────────────────────────
29
- * The background is a real starfield so the screen is never one flat
30
- * colour (LCDC_BG_ON below — drop it and the screen reads as blank, the
31
- * #1 GB "why is it blank" footgun).
32
- * tile_space — a 50/50 dither of palette colours 0 (deep space blue) +
33
- * 1 (mid blue), so even an empty patch of space mixes two
34
- * shades and never lets one colour dominate the frame.
35
- * tile_star — a bright colour-3 (white) "+" star on the dithered field. */
36
- static const uint8_t tile_space[16] = {
37
- 0x55,0x00, 0xAA,0x00, 0x55,0x00, 0xAA,0x00,
38
- 0x55,0x00, 0xAA,0x00, 0x55,0x00, 0xAA,0x00,
67
+ static const uint8_t tile_ship[16] = { /* arrowhead fighter */
68
+ 0x18,0x18, 0x18,0x18, 0x3C,0x24, 0x3C,0x24,
69
+ 0x7E,0x5A, 0xFF,0xDB, 0xFF,0xA5, 0x66,0x66,
39
70
  };
40
- static const uint8_t tile_star[16] = {
41
- 0x10,0x10, 0x10,0x10, 0x54,0x54, 0x38,0x38,
42
- 0x54,0x54, 0x10,0x10, 0x10,0x10, 0x00,0x00,
43
- };
44
- #define T_SPACE 4
45
- #define T_STAR 5
46
- static const uint8_t tile_bullet[16] = {
71
+ static const uint8_t tile_bullet[16] = { /* bright bolt (value 3) */
47
72
  0x00,0x00, 0x18,0x18, 0x3C,0x3C, 0x3C,0x3C,
48
73
  0x3C,0x3C, 0x3C,0x3C, 0x18,0x18, 0x00,0x00,
49
74
  };
50
- static const uint8_t tile_enemy[16] = {
51
- 0x81,0x81, 0x42,0x42, 0x24,0x24, 0xFF,0xFF,
52
- 0xFF,0xFF, 0x24,0x24, 0x42,0x42, 0x81,0x81,
75
+ static const uint8_t tile_enemy[16] = { /* spiky drone (value 3) */
76
+ 0x81,0x81, 0x42,0x5A, 0x24,0x3C, 0xFF,0xFF,
77
+ 0xFF,0xFF, 0x24,0x3C, 0x42,0x5A, 0x81,0x81,
78
+ };
79
+ /* Starfield BG tiles. tile_space carries two value-1 specks so even "empty"
80
+ * space is never one flat colour (the render-health floor every example
81
+ * keeps), and the specks make vertical scroll motion visible everywhere. */
82
+ static const uint8_t tile_space[16] = { /* faint specks (value 1) */
83
+ 0x00,0x00, 0x08,0x00, 0x00,0x00, 0x00,0x00,
84
+ 0x40,0x00, 0x00,0x00, 0x02,0x00, 0x00,0x00,
85
+ };
86
+ static const uint8_t tile_star[16] = { /* value-2 dot */
87
+ 0x00,0x00, 0x00,0x00, 0x18,0x00, 0x3C,0x00,
88
+ 0x3C,0x00, 0x18,0x00, 0x00,0x00, 0x00,0x00,
89
+ };
90
+ static const uint8_t tile_brite[16] = { /* value-3 "+" twinkle */
91
+ 0x00,0x00, 0x18,0x18, 0x18,0x18, 0x7E,0x7E,
92
+ 0x7E,0x7E, 0x18,0x18, 0x18,0x18, 0x00,0x00,
53
93
  };
94
+ static const uint8_t tile_hudbar[16] = { /* solid value-3 divider */
95
+ 0xFF,0xFF, 0xFF,0xFF, 0xFF,0xFF, 0xFF,0xFF,
96
+ 0xFF,0xFF, 0xFF,0xFF, 0xFF,0xFF, 0xFF,0xFF,
97
+ };
98
+
99
+ /* Tile indices ($8000 unsigned addressing — LCDC bit 4 set below). Sprites
100
+ * and BG share the $8000 table in this layout, so one upload serves both.
101
+ * Font glyphs follow at FONT_BASE (digits 0-9, then A-Z). */
102
+ #define T_BLANK 0
103
+ #define T_SHIP 1
104
+ #define T_BULLET 2
105
+ #define T_ENEMY 3
106
+ #define T_SPACE 4
107
+ #define T_STAR 5
108
+ #define T_BRITE 6
109
+ #define T_HUDBAR 7
110
+ #define FONT_BASE 16 /* digit d = 16+d, letter L = 16+10+idx (see font.h) */
111
+
112
+ /* ── GAME LOGIC (clay — reshape freely) ── the CGB palette TABLE (the colours
113
+ * themselves are art; the LOADER below is the hardware idiom).
114
+ * 15-bit BGR: 5 bits each, blue in the high bits — RGB() packs it. Colour 0
115
+ * of a BG palette is the cell's "background" shade; for OBJ palettes colour 0
116
+ * is transparent (the scene shows through). */
117
+ #define RGB(r,g,b) ((uint16_t)(((uint16_t)(b)<<10)|((uint16_t)(g)<<5)|(r)))
54
118
 
55
- static const uint16_t obj_palette[4] = {
56
- 0x7FFF, /* 0 transparent */
57
- 0x7FFF, /* 1 white — player ship */
58
- 0x03FF, /* 2 yellow — bullets */
59
- 0x001F, /* 3 red enemies */
119
+ /* BG palette slots (bank-1 attribute byte bits 0-2 select one of these).
120
+ * The three depth bands give the parallax starfield real distance — and they
121
+ * are genuinely DIFFERENT HUES (deep indigo far, teal-cyan mid, magenta-violet
122
+ * near), not three shades of one blue. So the field reads as a colourful
123
+ * nebula band, and a wide-area hue census sees several distinct colours the
124
+ * proof the cart is doing per-tile CGB colour, not 4-shade-green DMG. */
125
+ #define PAL_FAR 0 /* deep blue distance */
126
+ #define PAL_MID 1 /* teal mid band */
127
+ #define PAL_GRN 2 /* green inner band */
128
+ #define PAL_NEAR 4 /* magenta-violet foreground band */
129
+ #define PAL_HUD 3 /* HUD bar + all text */
130
+
131
+ static const uint16_t bg_palettes[8][4] = {
132
+ /* 0 far */ { RGB(2,3,14), RGB(5,8,24), RGB(8,12,30), RGB(18,20,31) },
133
+ /* 1 mid */ { RGB(1,8,9), RGB(3,20,22), RGB(6,30,30), RGB(20,31,31) },
134
+ /* 2 grn */ { RGB(2,9,3), RGB(6,22,7), RGB(10,31,12), RGB(22,31,20) },
135
+ /* 3 hud */ { RGB(2,2,6), RGB(8,9,16), RGB(2,2,6), RGB(31,31,31) },
136
+ /* 4 near */ { RGB(12,1,12), RGB(26,3,24), RGB(31,8,26), RGB(31,22,31) },
137
+ /* 5 spare*/ { RGB(0,0,0), RGB(10,10,10), RGB(20,20,20), RGB(31,31,31) },
138
+ /* 6 spare*/ { RGB(0,0,0), RGB(10,10,10), RGB(20,20,20), RGB(31,31,31) },
139
+ /* 7 spare*/ { RGB(0,0,0), RGB(10,10,10), RGB(20,20,20), RGB(31,31,31) },
60
140
  };
61
141
 
62
- /* CGB BG palette 0 used for the dark-blue starfield background. */
63
- static const uint16_t bg_palette[4] = {
64
- 0x2842, /* 0 deep space blue */
65
- 0x4A52, /* 1 mid blue */
66
- 0x6B5A, /* 2 light blue (stars) */
67
- 0x7FFF, /* 3 white (highlight) */
142
+ /* OBJ palette slots (OAM attr bits 0-2 select one of these). Colour 0 is
143
+ * always transparent. */
144
+ #define OPAL_SHIP 0 /* cyan hero */
145
+ #define OPAL_BULLET 1 /* gold bolt */
146
+ #define OPAL_ENEMY 2 /* danger red drone */
147
+
148
+ static const uint16_t obj_palettes[8][4] = {
149
+ /* 0 ship */ { 0, RGB(8,28,31), RGB(2,16,28), RGB(28,31,31) },
150
+ /* 1 bullet */ { 0, RGB(31,28,6), RGB(31,20,2), RGB(31,31,20) },
151
+ /* 2 enemy */ { 0, RGB(31,8,8), RGB(20,2,2), RGB(31,24,16) },
152
+ /* 3 spare */ { 0, RGB(20,20,20), RGB(31,31,31), RGB(10,10,10) },
153
+ /* 4 spare */ { 0, RGB(20,20,20), RGB(31,31,31), RGB(10,10,10) },
154
+ /* 5 spare */ { 0, RGB(20,20,20), RGB(31,31,31), RGB(10,10,10) },
155
+ /* 6 spare */ { 0, RGB(20,20,20), RGB(31,31,31), RGB(10,10,10) },
156
+ /* 7 spare */ { 0, RGB(20,20,20), RGB(31,31,31), RGB(10,10,10) },
68
157
  };
69
158
 
70
- typedef struct { uint8_t x, y, alive; } Obj;
159
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
160
+ * THE WINDOW-LAYER HUD — the Game Boy's signature "fixed HUD over a
161
+ * scrolling world" technique. The window is a second BG plane with its own
162
+ * 32×32 tile map and NO scroll registers: it always draws its map from
163
+ * (0,0), pinned to the screen, on top of the BG. So the HUD lives in the
164
+ * window and the playfield lives in the BG — SCY scrolls the starfield all
165
+ * it likes and the HUD never moves. No raster splits, no IRQ timing (the NES
166
+ * needs a sprite-0 polling dance for this exact effect; on GB it's three
167
+ * register writes). On CGB the window cells take bank-1 palette attributes
168
+ * exactly like the BG (set_wcell writes both banks).
169
+ *
170
+ * The three registers, and their two famous footguns:
171
+ * WY ($FF4A) — first screen LINE the window covers. We use 128: lines
172
+ * 0-127 are playfield, 128-143 (two tile rows) are the HUD strip.
173
+ * WX ($FF4B) — screen column PLUS SEVEN. WX=7 means "left edge". The -7
174
+ * offset is hardware fact: WX=0..6 glitches, WX≥167 is off-screen.
175
+ * LCDC bit 5 — window enable; bit 6 — which map it reads ($9800/$9C00).
176
+ *
177
+ * FOOTGUN 1 — "the window ate the bottom of my screen": once the window
178
+ * starts on a line it covers EVERY line from there DOWN, full width. There
179
+ * is no window height register. That is why GB HUDs sit at the BOTTOM of the
180
+ * screen. A TOP HUD needs a STAT-interrupt LYC trick — a different, fragile
181
+ * idiom; don't drift into it by accident by setting WY=0.
182
+ *
183
+ * FOOTGUN 2 — sprites are NOT clipped by the window. OBJs draw over it, so a
184
+ * sprite below line 128 sits ON the HUD. Gameplay despawns every enemy and
185
+ * clamps the ship above PLAY_H, so nothing overlaps the HUD strip.
186
+ *
187
+ * Requires: window map at $9C00 (LCDC bit 6), tile data at $8000 (bit 4),
188
+ * WX=7, WY=PLAY_H, LCDC bit 5 set during play (title turns the window off). */
189
+ #define PLAY_H 128 /* first HUD line = window top */
190
+ #define LCDC_TITLE (LCDC_LCD_ON | LCDC_BG_ON | LCDC_OBJ_ON | LCDC_TILE_DATA_LO)
191
+ #define LCDC_PLAY (LCDC_TITLE | LCDC_WINDOW_ON | LCDC_WINDOW_MAP_HI)
192
+
193
+ #define VRAM ((volatile uint8_t *)0x9800) /* BG map $9800 base */
194
+ #define WIN_OFF 0x400 /* window map $9C00 = $9800 + $400 */
195
+
196
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
197
+ * BATTERY SRAM — persistent hi-score. MBC1 cart RAM is 8KB at $A000-$BFFF,
198
+ * but it boots DISABLED and writes to a disabled bank are silently
199
+ * discarded (reads float). The gate is the MBC's RAM-enable register: any
200
+ * WRITE to ROM space $0000-$1FFF with $0A in the low nibble enables the RAM;
201
+ * writing $00 disables it again. (Writing "into ROM" feels wrong the first
202
+ * time — ROM-area writes never touch ROM, they talk to the mapper chip.)
203
+ * Leaving RAM enabled all the time "works" in emulators but on real hardware
204
+ * risks corruption at power-off — battery carts since forever do
205
+ * enable → touch → disable, so we do too.
206
+ *
207
+ * First boot is GARBAGE, not zeros: battery RAM holds whatever the silicon
208
+ * woke up with. The magic 'H','S' + checksum is how the load path tells "my
209
+ * save" from "factory noise" — without it a fresh cart shows a junk hi-score.
210
+ *
211
+ * Save block at $A000: 'H' 'S' lo hi (lo^hi^$A5)
212
+ *
213
+ * Requires: gb_crt0.s declaring $0147=$03 (MBC1+RAM+BATTERY) + $0149=$02
214
+ * (8KB) — those header bytes are how the emulator knows to allocate and
215
+ * persist SAVE_RAM. Verify headlessly: play, game over, then
216
+ * memory({op:'read', region:'save_ram'}) shows the block, and the hi-score
217
+ * survives host.hardReset(). */
218
+ #define MBC_RAM_ENABLE (*(volatile uint8_t *)0x0000)
219
+ #define SRAM ((volatile uint8_t *)0xA000)
220
+
221
+ static uint16_t hiscore_load(void) {
222
+ uint16_t v = 0;
223
+ MBC_RAM_ENABLE = 0x0A; /* unlock cart RAM */
224
+ if (SRAM[0] == 'H' && SRAM[1] == 'S' &&
225
+ SRAM[4] == (uint8_t)(SRAM[2] ^ SRAM[3] ^ 0xA5)) {
226
+ v = (uint16_t)(SRAM[2] | ((uint16_t)SRAM[3] << 8));
227
+ }
228
+ MBC_RAM_ENABLE = 0x00; /* re-lock (battery hygiene) */
229
+ return v;
230
+ }
231
+
232
+ static void hiscore_save(uint16_t v) {
233
+ uint8_t lo = (uint8_t)(v & 0xFF), hi = (uint8_t)(v >> 8);
234
+ MBC_RAM_ENABLE = 0x0A;
235
+ SRAM[0] = 'H'; SRAM[1] = 'S';
236
+ SRAM[2] = lo; SRAM[3] = hi;
237
+ SRAM[4] = (uint8_t)(lo ^ hi ^ 0xA5);
238
+ MBC_RAM_ENABLE = 0x00;
239
+ }
240
+
241
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
242
+ * Object pools — fixed slots, no allocation. OAM slot plan (40 hardware
243
+ * slots, we use 13): 0 = ship, 1-6 bullets, 7-12 enemies. Sub-10 sprites on
244
+ * any one scanline keeps us clear of the 10-OBJ/line hardware drop. */
245
+ #define MAX_BULLETS 6
246
+ #define MAX_ENEMIES 6
247
+ #define START_LIVES 3
71
248
 
72
- static Obj player;
249
+ typedef struct { uint8_t x, y, alive; } Obj; /* screen coords (not OAM) */
250
+
251
+ static Obj ship;
73
252
  static Obj bullets[MAX_BULLETS];
74
253
  static Obj enemies[MAX_ENEMIES];
254
+ static uint8_t lives;
75
255
  static uint16_t score;
256
+ static uint16_t hiscore; /* live HUD readout: max(score, record) */
257
+ static uint16_t record; /* what the battery SRAM actually holds */
258
+ static uint8_t fire_cd;
76
259
  static uint8_t spawn_timer;
260
+ static uint8_t scroll_y; /* starfield drift, committed to SCY */
261
+ static uint8_t prev_pad;
262
+ static uint8_t hud_dirty; /* queue VRAM writes; vblank commits them */
263
+ static uint8_t msg_stage; /* game-over text: 2 = line 1 pending, 1 = line 2 */
264
+ static uint8_t msg_row; /* BG map row for GAME OVER (scroll-aware) */
77
265
 
78
- static uint8_t aabb(Obj *a, Obj *b) {
79
- /* Coord conversion: sprites are stored in screen coords here
80
- * (px..px+8) NOT hardware coords. We translate to hardware in OAM. */
81
- return a->x < b->x + 8 && a->x + 8 > b->x
82
- && a->y < b->y + 8 && a->y + 8 > b->y;
83
- }
84
-
85
- static void fire(void) {
86
- uint8_t i;
87
- for (i = 0; i < MAX_BULLETS; i++) {
88
- if (!bullets[i].alive) {
89
- bullets[i].x = player.x;
90
- bullets[i].y = player.y - 8;
91
- bullets[i].alive = 1;
92
- return;
93
- }
94
- }
95
- }
266
+ /* Game states the shell every example shares: title → play → game over.
267
+ * (Handheld adaptation: title is press-start; consoles add a 1P/2P pick.) */
268
+ #define ST_TITLE 0
269
+ #define ST_PLAY 1
270
+ #define ST_OVER 2
271
+ static uint8_t state;
96
272
 
97
- /* Galois LFSR (taps $B8), period 255 -- real per-spawn randomness.
98
- * The old code derived the spawn column from spawn_timer, but the caller
99
- * resets spawn_timer just before calling here, so it was CONSTANT and
100
- * every enemy spawned in the same left column/lane. */
273
+ /* ── GAME LOGIC (clay) — Galois LFSR (taps $B8), period 255 ── */
101
274
  static uint8_t rng_state = 0xA5;
102
275
  static uint8_t rand8(void) {
103
276
  uint8_t lsb = (uint8_t)(rng_state & 1);
@@ -106,129 +279,592 @@ static uint8_t rand8(void) {
106
279
  return rng_state;
107
280
  }
108
281
 
109
- static void spawn(void) {
110
- uint8_t i;
111
- for (i = 0; i < MAX_ENEMIES; i++) {
112
- if (!enemies[i].alive) {
113
- enemies[i].x = rand8() % (160 - 16) + 8;
114
- enemies[i].y = 0;
115
- enemies[i].alive = 1;
116
- return;
117
- }
282
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
283
+ * CGB palette RAM — the BCPS/BCPD (BG) and OCPS/OCPD (OBJ) port pairs.
284
+ * requires: a .gbc build (CGB flag $0143 set the build pipeline does it);
285
+ * on a DMG build these registers are dead and you get 4-shade green.
286
+ *
287
+ * Palette RAM is NOT memory-mapped: it's 64 bytes (8 palettes × 4 colours ×
288
+ * 2 bytes, little-endian 15-bit BGR) behind an index/data port pair.
289
+ * BCPS = 0x80 | index — set write index; bit 7 = AUTO-INCREMENT, so a
290
+ * burst of BCPD writes walks the whole 64 bytes.
291
+ * BCPD = low byte; BCPD = high byte; ... 32 times = all 8 palettes.
292
+ *
293
+ * TIMING FOOTGUN: palette RAM belongs to the PPU. Writes during active
294
+ * display (mode 3) are IGNORED on real hardware — same constraint as VRAM.
295
+ * Load palettes with the LCD OFF (boot / transitions, as here) or inside
296
+ * vblank. A palette "fade" = a few BCPD writes per vblank, never a mid-frame
297
+ * burst. */
298
+ static void load_bg_palettes(void) {
299
+ uint8_t p, i;
300
+ BCPS = 0x80; /* index 0, auto-increment on */
301
+ for (p = 0; p < 8; p++)
302
+ for (i = 0; i < 4; i++) {
303
+ BCPD = (uint8_t)(bg_palettes[p][i] & 0xFF);
304
+ BCPD = (uint8_t)((bg_palettes[p][i] >> 8) & 0xFF);
118
305
  }
119
306
  }
120
307
 
308
+ static void load_obj_palettes(void) {
309
+ uint8_t p, i;
310
+ OCPS = 0x80;
311
+ for (p = 0; p < 8; p++)
312
+ for (i = 0; i < 4; i++) {
313
+ OCPD = (uint8_t)(obj_palettes[p][i] & 0xFF);
314
+ OCPD = (uint8_t)((obj_palettes[p][i] >> 8) & 0xFF);
315
+ }
316
+ }
317
+
318
+ /* ── GAME LOGIC (clay) — VRAM upload + text helpers ──────────────────────────
319
+ * All of these write VRAM, so they run with the LCD OFF (boot/repaints) or
320
+ * inside vblank (the HUD digit commit). memcpy_vram walks a pointer
321
+ * (*dst++ = v) — never index dst[i] through a VRAM pointer (SDCC's sm83 port
322
+ * miscompiles indexed stores through VRAM-pointing pointers). */
121
323
  static void upload_tile(uint8_t slot, const uint8_t *src) {
122
- uint8_t *dst = (uint8_t *)(0x8000 + slot * 16);
123
- /* memcpy_vram (pointer-walk) — NOT an indexed dst[i]=src[i] loop, which
124
- * SDCC sm83 miscompiles when dst points into VRAM ($8000-$9FFF). */
125
- memcpy_vram(dst, src, 16);
324
+ memcpy_vram((uint8_t *)(0x8000 + (uint16_t)slot * 16), src, 16);
126
325
  }
127
326
 
128
- /* Paint a starfield into BG map 0 ($9800): fill the visible 20×18 with the
129
- * dithered space tile, then scatter bright stars on a fixed pseudo-pattern
130
- * so the field reads as deep space rather than a flat colour. */
131
- static void draw_starfield(void) {
132
- uint8_t *bg = BG_MAP_0;
133
- uint8_t r, c;
134
- for (r = 0; r < 18; r++)
135
- for (c = 0; c < 20; c++)
136
- bg[r * 32 + c] = ((r * 7 + c * 5) % 11 == 0) ? T_STAR : T_SPACE;
327
+ static void upload_font(void) {
328
+ uint8_t g;
329
+ /* font.h glyphs are already 2bpp (16 bytes each) straight copy. */
330
+ for (g = 0; g < FONT_GLYPHS; g++)
331
+ memcpy_vram((uint8_t *)(0x8000 + (uint16_t)(FONT_BASE + g) * 16),
332
+ &font_data[g * 16], 16);
137
333
  }
138
334
 
139
- void main(void) {
140
- uint8_t pad, prev = 0;
141
- uint8_t i, j;
335
+ static uint8_t char_tile(char ch) {
336
+ if (ch >= '0' && ch <= '9') return (uint8_t)(FONT_BASE + (ch - '0'));
337
+ if (ch >= 'A' && ch <= 'Z') return (uint8_t)(FONT_BASE + 10 + (ch - 'A'));
338
+ return T_BLANK; /* space / unknown → blank */
339
+ }
142
340
 
143
- lcd_init_default();
144
- LCDC = 0;
341
+ /* Pre-convert a string to tile indices at full-frame time, so the vblank
342
+ * commit (commit_bg_text) is a dumb byte copy — see game_over(). */
343
+ static uint8_t msg_q[20]; /* 9 "GAME OVER" + 11 "PRESS START" */
344
+ static void stage_text(const char *s, uint8_t *out) {
345
+ while (*s) *out++ = char_tile(*s++);
346
+ }
145
347
 
146
- upload_tile(0, tile_blank);
147
- upload_tile(1, tile_ship);
148
- upload_tile(2, tile_bullet);
149
- upload_tile(3, tile_enemy);
150
- upload_tile(T_SPACE, tile_space);
151
- upload_tile(T_STAR, tile_star);
348
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
349
+ * Per-tile color — the VRAM bank-1 attribute map (VBK register).
350
+ * requires: CGB mode (see the palette idiom above); writes in a VRAM-safe
351
+ * window (LCD off, or a bounded vblank batch).
352
+ *
353
+ * VRAM is TWO 8KB banks behind the same $8000-$9FFF window; VBK ($FF4F)
354
+ * selects which one the CPU sees. Bank 0 holds what the DMG had: tile pixels
355
+ * + the tile-index maps. Bank 1 at the SAME map address holds one ATTRIBUTE
356
+ * byte per cell:
357
+ * bits 0-2 palette 0-7 ← this game's whole color system
358
+ * bit 3 tile VRAM bank
359
+ * bit 5/6 H/V flip
360
+ * bit 7 BG-over-OBJ priority
361
+ * So coloring a cell is a write PAIR: tile index with VBK=0, attribute with
362
+ * VBK=1, at the SAME offset.
363
+ *
364
+ * FOOTGUN: VBK is global state. Forget to restore VBK=0 and every later
365
+ * "tile" write lands in the attribute map — the screen turns into garbage
366
+ * colors while the tile data you wrote is simply gone. Always end VBK=0
367
+ * (every routine here does). */
368
+ static void set_cell(uint8_t mx, uint8_t my, uint8_t tile, uint8_t pal) {
369
+ uint16_t off = (uint16_t)my * 32 + mx;
370
+ VBK = 0;
371
+ VRAM[off] = tile;
372
+ VBK = 1;
373
+ VRAM[off] = pal;
374
+ VBK = 0;
375
+ }
152
376
 
153
- /* Sprite palette 0 uploaded to OCPS/OCPD (CGB-only registers). */
154
- OCPS = 0x80;
155
- for (i = 0; i < 4; i++) {
156
- OCPD = (uint8_t)(obj_palette[i] & 0xFF);
157
- OCPD = (uint8_t)((obj_palette[i] >> 8) & 0xFF);
377
+ /* same write-pair, into the WINDOW's map at $9C00 (window HUD idiom) */
378
+ static void set_wcell(uint8_t wx, uint8_t wy, uint8_t tile, uint8_t pal) {
379
+ uint16_t off = WIN_OFF + (uint16_t)wy * 32 + wx;
380
+ VBK = 0;
381
+ VRAM[off] = tile;
382
+ VBK = 1;
383
+ VRAM[off] = pal;
384
+ VBK = 0;
385
+ }
386
+
387
+ /* draw a NUL-terminated string into the BG map (palette PAL_HUD = readable). */
388
+ static void draw_text(uint8_t col, uint8_t row, const char *s) {
389
+ uint8_t i;
390
+ for (i = 0; s[i] != 0; i++)
391
+ set_cell((uint8_t)(col + i), row, char_tile(s[i]), PAL_HUD);
392
+ }
393
+
394
+ /* draw a NUL-terminated string into the WINDOW map. */
395
+ static void draw_wtext(uint8_t col, uint8_t row, const char *s) {
396
+ uint8_t i;
397
+ for (i = 0; s[i] != 0; i++)
398
+ set_wcell((uint8_t)(col + i), row, char_tile(s[i]), PAL_HUD);
399
+ }
400
+
401
+ /* Decimal digits WITHOUT divide/modulo (the sm83 has neither — SDCC's
402
+ * software % costs ~700 cycles a call). Repeated power-of-ten subtraction
403
+ * caps at 36 SUBs for any u16. Writes 5 tile slots into out5. */
404
+ static void u16_to_tiles(uint16_t v, uint8_t *out5) {
405
+ static const uint16_t pow10[4] = { 10000, 1000, 100, 10 };
406
+ uint8_t i, d;
407
+ for (i = 0; i < 4; i++) {
408
+ d = 0;
409
+ while (v >= pow10[i]) { v -= pow10[i]; ++d; }
410
+ *out5++ = (uint8_t)(FONT_BASE + d);
411
+ }
412
+ *out5 = (uint8_t)(FONT_BASE + (uint8_t)v);
413
+ }
414
+
415
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
416
+ * The PARALLAX-BANDED starfield — the game's CGB colour proof in the BG.
417
+ * The field fills the FULL 32-row map; SCY scrolling wraps through all 32,
418
+ * so a part-filled map would scroll garbage into view. Each cell takes a
419
+ * tile (bank 0) AND a depth-band palette attribute (bank 1) — that pairing
420
+ * is the whole CGB colour story.
421
+ *
422
+ * PERF FOOTGUN (measured, not theoretical): the obvious pattern formula
423
+ * `(r*7 + c*5) % 11` calls SDCC's software modulo (~700 cycles) — 1024 times
424
+ * over a 32×32 map ≈ a multi-frame frozen boot. The fix is the classic 8-bit
425
+ * move: keep running counters and subtract on overflow (zero divisions). The
426
+ * sm83 has no divide instruction; treat every / and % in a loop as a red flag.
427
+ *
428
+ * The DEPTH BAND is chosen by the map ROW in a repeating 4-row cycle
429
+ * (blue, teal, green, magenta) so FOUR distinct nebula hues are ALWAYS on
430
+ * screen at once — the field reads as a banded nebula and a wide hue census
431
+ * sees several distinct colours regardless of where SCY has scrolled. As the
432
+ * field scrolls the bands scroll with it (the attribute byte rides the tile).
433
+ * 32 rows IS a multiple of 4, so the band cycle wraps seamlessly at the map
434
+ * seam too. */
435
+ static const uint8_t band_cycle[4] = { PAL_FAR, PAL_MID, PAL_GRN, PAL_NEAR };
436
+ static uint8_t band_for_row(uint8_t r) {
437
+ return band_cycle[r & 3]; /* r mod 4 — divide-free */
438
+ }
439
+
440
+ static void paint_starfield(void) {
441
+ uint8_t r, c, t, pal;
442
+ uint8_t ar = 0, br = 0; /* row seeds: (r*7) mod 11, (r*3) mod 29 */
443
+ uint8_t a, b;
444
+ for (r = 0; r < 32; r++) {
445
+ a = ar; b = br;
446
+ pal = band_for_row(r);
447
+ for (c = 0; c < 32; c++) {
448
+ t = T_SPACE;
449
+ if (a == 0) t = T_STAR;
450
+ if (b == 0) t = T_BRITE;
451
+ VBK = 0; VRAM[(uint16_t)r * 32 + c] = t;
452
+ VBK = 1; VRAM[(uint16_t)r * 32 + c] = pal;
453
+ a += 5; if (a >= 11) a -= 11; /* +5 ≡ c step, mod 11 */
454
+ b += 13; if (b >= 29) b -= 29; /* +13 ≡ c step, mod 29 */
158
455
  }
456
+ ar += 7; if (ar >= 11) ar -= 11; /* +7 ≡ r step, mod 11 */
457
+ br += 3; if (br >= 29) br -= 29; /* +3 ≡ r step, mod 29 */
458
+ }
459
+ VBK = 0;
460
+ }
159
461
 
160
- /* BG palette 0 — starfield background colors. */
161
- BCPS = 0x80;
162
- for (i = 0; i < 4; i++) {
163
- BCPD = (uint8_t)(bg_palette[i] & 0xFF);
164
- BCPD = (uint8_t)((bg_palette[i] >> 8) & 0xFF);
462
+ static void paint_title(void) {
463
+ paint_starfield(); /* banded field — text owns the top */
464
+ draw_text((uint8_t)((20 - (sizeof(GAME_TITLE) - 1)) / 2), 3, GAME_TITLE);
465
+ draw_text(4, 8, "PRESS START");
466
+ draw_text(6, 11, "HI");
467
+ {
468
+ uint8_t d[5], i;
469
+ u16_to_tiles(hiscore, d);
470
+ for (i = 0; i < 5; i++) set_cell((uint8_t)(9 + i), 11, d[i], PAL_HUD);
471
+ }
472
+ draw_text(6, 14, "1P ONLY"); /* see header: no link 2P */
473
+ SCY = 0; SCX = 0;
474
+ scroll_y = 0;
475
+ }
476
+
477
+ /* HUD strip = window rows 0-1: a solid divider bar, then the text row.
478
+ * Columns 0-19 are the visible 20 (WX=7 pins map col 0 to screen x 0). */
479
+ static void paint_hud(void) {
480
+ uint8_t c, d[5], i;
481
+ for (c = 0; c < 20; c++) set_wcell(c, 0, T_HUDBAR, PAL_HUD);
482
+ for (c = 0; c < 20; c++) set_wcell(c, 1, T_BLANK, PAL_HUD);
483
+ draw_wtext(0, 1, "SC");
484
+ u16_to_tiles(score, d);
485
+ for (i = 0; i < 5; i++) set_wcell((uint8_t)(3 + i), 1, d[i], PAL_HUD);
486
+ draw_wtext(9, 1, "HI");
487
+ u16_to_tiles(hiscore, d);
488
+ for (i = 0; i < 5; i++) set_wcell((uint8_t)(12 + i), 1, d[i], PAL_HUD);
489
+ draw_wtext(18, 1, "L");
490
+ set_wcell(19, 1, (uint8_t)(FONT_BASE + lives), PAL_HUD);
491
+ }
492
+
493
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
494
+ * LCD-off repaints. Bulk VRAM rewrites (full title/field repaints) happen
495
+ * with the LCD OFF — free access, no per-byte timing worries. The rule:
496
+ * only flip LCDC bit 7 to 0 DURING VBLANK. Killing the LCD mid-scanline is
497
+ * the classic "damages real DMG hardware" move; emulators shrug, real units
498
+ * can be permanently marked. wait_vblank() first, always.
499
+ * Requires: enable_vblank_irq() already called (wait_vblank HALT path);
500
+ * lcd_off-safe runtime (gb_runtime's wait_vblank bails if LCD is off). */
501
+ static void repaint_with_lcd_off(uint8_t to_title) {
502
+ msg_stage = 0; /* a queued game-over line must not land on
503
+ * the freshly painted screen a frame later */
504
+ wait_vblank(); /* never cut the LCD outside vblank */
505
+ LCDC = 0;
506
+ if (to_title) {
507
+ paint_title();
508
+ oam_clear(); /* hide every sprite slot before re-enable */
509
+ LCDC = LCDC_TITLE; /* window OFF on the title */
510
+ } else {
511
+ paint_starfield();
512
+ paint_hud();
513
+ LCDC = LCDC_PLAY; /* window ON below WY — the HUD appears */
514
+ }
515
+ }
516
+
517
+ /* ── GAME LOGIC (clay) — sound: frame-ticked tune + fire/boom SFX ─────────────
518
+ * Channel plan keeps SFX from cutting the music: ch2 = music (one
519
+ * sound_play_tone trigger per note, the APU sustains it), ch1 = fire blips,
520
+ * ch4 = noise for explosions. music_tick() runs once per frame from the main
521
+ * loop; the APU needs no other upkeep. Periods are the 11-bit GB frequency
522
+ * code: 2048 - (131072 / Hz). 0 = rest. SELECT toggles it. */
523
+ static const uint16_t tune[16] = {
524
+ 1547, 0, 1650, 0, 1714, 0, 1798, 0, /* C4 E4 G4 C5 */
525
+ 1714, 0, 1650, 0, 1602, 0, 1650, 0, /* G4 E4 D4 E4 */
526
+ };
527
+ static uint8_t music_on = 1, music_pos, music_timer;
528
+ static void music_tick(void) {
529
+ uint16_t n;
530
+ if (!music_on) return;
531
+ if (++music_timer < 14) return;
532
+ music_timer = 0;
533
+ n = tune[music_pos];
534
+ music_pos = (uint8_t)((music_pos + 1) & 15);
535
+ if (n) sound_play_tone(2, n, 12);
536
+ }
537
+ static void music_toggle(void) {
538
+ music_on = (uint8_t)(!music_on);
539
+ if (!music_on) { NR21 = 0x00; NR22 = 0x00; NR24 = 0x80; } /* silence ch2 */
540
+ }
541
+
542
+ /* ── GAME LOGIC (clay) — spawning, firing, collision ── */
543
+ static void fire_bullet(void) {
544
+ uint8_t i;
545
+ for (i = 0; i < MAX_BULLETS; i++) {
546
+ if (!bullets[i].alive) {
547
+ bullets[i].x = ship.x;
548
+ bullets[i].y = (uint8_t)(ship.y - 8);
549
+ bullets[i].alive = 1;
550
+ sound_play_tone(1, 1900, 4); /* ch1 blip — music keeps ch2 */
551
+ return;
165
552
  }
553
+ }
554
+ }
166
555
 
167
- draw_starfield();
168
-
169
- player.x = 76; player.y = 130; player.alive = 1;
170
- for (i = 0; i < MAX_BULLETS; i++) bullets[i].alive = 0;
171
- for (i = 0; i < MAX_ENEMIES; i++) enemies[i].alive = 0;
172
- score = 0;
173
- spawn_timer = 0;
174
-
175
- oam_clear();
176
- LCDC = LCDC_LCD_ON | LCDC_BG_ON | LCDC_OBJ_ON | LCDC_TILE_DATA_LO;
177
- sound_init();
178
-
179
- while (1) {
180
- wait_vblank();
181
-
182
- /* Stage OAM for this frame BEFORE we update game state — the
183
- * shadow OAM gets DMA'd next vblank. */
184
- for (i = 0; i < 40; i++) oam_set(i, 0, 0, 0, 0);
185
- oam_set(0, player.y + 16, player.x + 8, 1, 0);
186
- for (i = 0; i < MAX_BULLETS; i++) {
187
- if (bullets[i].alive)
188
- oam_set(1 + i, bullets[i].y + 16, bullets[i].x + 8, 2, 0);
189
- }
190
- for (i = 0; i < MAX_ENEMIES; i++) {
191
- if (enemies[i].alive)
192
- oam_set(5 + i, enemies[i].y + 16, enemies[i].x + 8, 3, 0);
193
- }
194
- oam_dma_flush();
195
-
196
- pad = joypad_read();
197
-
198
- if (pad & PAD_LEFT && player.x > 0) player.x -= 2;
199
- if (pad & PAD_RIGHT && player.x < 160 - 8) player.x += 2;
200
- if (pad & PAD_UP && player.y > 0) player.y -= 2;
201
- if (pad & PAD_DOWN && player.y < 144 - 8) player.y += 2;
202
- if ((pad & PAD_A) && !(prev & PAD_A)) {
203
- fire();
204
- sound_play_tone(2, 1900, 4);
205
- }
206
- prev = pad;
207
-
208
- for (i = 0; i < MAX_BULLETS; i++) {
209
- if (!bullets[i].alive) continue;
210
- if (bullets[i].y < 4) { bullets[i].alive = 0; continue; }
211
- bullets[i].y -= 4;
212
- }
213
- for (i = 0; i < MAX_ENEMIES; i++) {
214
- if (!enemies[i].alive) continue;
215
- enemies[i].y += 1;
216
- if (enemies[i].y >= 144) enemies[i].alive = 0;
217
- }
218
- if (++spawn_timer >= 28) { spawn_timer = 0; spawn(); }
219
-
220
- for (i = 0; i < MAX_BULLETS; i++) {
221
- if (!bullets[i].alive) continue;
222
- for (j = 0; j < MAX_ENEMIES; j++) {
223
- if (!enemies[j].alive) continue;
224
- if (aabb(&bullets[i], &enemies[j])) {
225
- bullets[i].alive = 0;
226
- enemies[j].alive = 0;
227
- if (score < 65500u) score += 10;
228
- sound_play_noise(6);
229
- break;
230
- }
231
- }
232
- }
556
+ static void spawn_enemy(void) {
557
+ uint8_t i;
558
+ for (i = 0; i < MAX_ENEMIES; i++) {
559
+ if (!enemies[i].alive) {
560
+ /* One software-% per spawn (every ~32 frames) is fine — the
561
+ * divide-free rule (see paint_starfield) is about per-cell/per-frame
562
+ * loops, not superstition. */
563
+ enemies[i].x = (uint8_t)(rand8() % 145 + 4);
564
+ enemies[i].y = 0;
565
+ enemies[i].alive = 1;
566
+ return;
233
567
  }
568
+ }
569
+ }
570
+
571
+ static uint8_t hits(Obj *a, Obj *b) { /* AABB, both 8×8 */
572
+ uint8_t dx = (uint8_t)((a->x > b->x) ? (a->x - b->x) : (b->x - a->x));
573
+ uint8_t dy = (uint8_t)((a->y > b->y) ? (a->y - b->y) : (b->y - a->y));
574
+ return (uint8_t)((dx < 8) && (dy < 8));
575
+ }
576
+
577
+ /* ── GAME LOGIC (clay) — state transitions ── */
578
+ static void start_game(void) {
579
+ uint8_t i;
580
+ ship.x = 76; ship.y = 104; ship.alive = 1;
581
+ for (i = 0; i < MAX_BULLETS; i++) bullets[i].alive = 0;
582
+ for (i = 0; i < MAX_ENEMIES; i++) enemies[i].alive = 0;
583
+ lives = START_LIVES;
584
+ score = 0;
585
+ fire_cd = 0;
586
+ spawn_timer = 0;
587
+ hud_dirty = 1; /* restage hud_q — a stale game-over stage queued
588
+ * before the repaint would overwrite the fresh
589
+ * zeros next vblank otherwise */
590
+ state = ST_PLAY;
591
+ repaint_with_lcd_off(0);
592
+ }
593
+
594
+ static void game_over(void) {
595
+ /* Compare against the SAVED record, not the live `hiscore` readout — the
596
+ * kill handler already raised `hiscore` to track the run, so testing
597
+ * `score > hiscore` here would never fire. */
598
+ if (score > record) {
599
+ record = score;
600
+ hiscore_save(record); /* battery write — survives power-off */
601
+ }
602
+ state = ST_OVER;
603
+ /* The BG has scrolled: map row 0 is no longer screen row 0. Anchor the
604
+ * text relative to the CURRENT scroll so it lands mid-playfield on-screen
605
+ * ((SCY/8 + screen_row) & 31 = the map row under that screen row). Convert
606
+ * the strings to tile indices HERE (full-frame time) into msg_q — the
607
+ * vblank commit is then a DUMB byte copy. char_tile's per-char compare
608
+ * chain is exactly the work that blows the ~1140-cycle vblank budget; doing
609
+ * it inside the commit dropped the middle of the 11-char PRESS START line
610
+ * (verified on the GB original). Stage out here, copy in there. */
611
+ msg_row = (uint8_t)(((scroll_y >> 3) + 6) & 31);
612
+ stage_text("GAME OVER", msg_q);
613
+ stage_text("PRESS START", msg_q + 9);
614
+ msg_stage = 2;
615
+ }
616
+
617
+ /* ── GAME LOGIC (clay) — per-state update (runs OUTSIDE vblank) ── */
618
+ static void update_play(uint8_t pad) {
619
+ uint8_t i, j;
620
+
621
+ if ((pad & PAD_LEFT) && ship.x > 0) ship.x -= 2;
622
+ if ((pad & PAD_RIGHT) && ship.x < 160 - 8) ship.x += 2;
623
+ if ((pad & PAD_UP) && ship.y > 8) ship.y -= 2;
624
+ if ((pad & PAD_DOWN) && ship.y < PLAY_H - 24) ship.y += 2;
625
+ if ((pad & PAD_A) && fire_cd == 0) { fire_bullet(); fire_cd = 8; }
626
+ if (fire_cd) --fire_cd;
627
+
628
+ /* Starfield drift — the window HUD makes this free (no split timing). */
629
+ if ((spawn_timer & 1) == 0) --scroll_y;
630
+
631
+ for (i = 0; i < MAX_BULLETS; i++) {
632
+ if (!bullets[i].alive) continue;
633
+ if (bullets[i].y < 4) { bullets[i].alive = 0; continue; }
634
+ bullets[i].y -= 4;
635
+ }
636
+
637
+ /* Enemies despawn BEFORE the HUD line — sprites draw OVER the window
638
+ * (footgun 2 above), so nothing may drift past PLAY_H. */
639
+ for (i = 0; i < MAX_ENEMIES; i++) {
640
+ if (!enemies[i].alive) continue;
641
+ enemies[i].y += 1;
642
+ if (enemies[i].y >= PLAY_H - 12) enemies[i].alive = 0;
643
+ }
644
+
645
+ if (++spawn_timer >= 32) { spawn_timer = 0; spawn_enemy(); }
646
+
647
+ /* Bullets ↔ enemies. */
648
+ for (i = 0; i < MAX_BULLETS; i++) {
649
+ if (!bullets[i].alive) continue;
650
+ for (j = 0; j < MAX_ENEMIES; j++) {
651
+ if (!enemies[j].alive) continue;
652
+ if (hits(&bullets[i], &enemies[j])) {
653
+ bullets[i].alive = 0;
654
+ enemies[j].alive = 0;
655
+ if (score <= 65525u) score += 10;
656
+ if (score > hiscore) hiscore = score; /* live HI readout; SRAM
657
+ * write waits for game over */
658
+ sound_play_noise(8);
659
+ hud_dirty = 1;
660
+ break;
661
+ }
662
+ }
663
+ }
664
+
665
+ /* Enemies ↔ ship. */
666
+ for (j = 0; j < MAX_ENEMIES; j++) {
667
+ if (!enemies[j].alive) continue;
668
+ if (hits(&enemies[j], &ship)) {
669
+ enemies[j].alive = 0;
670
+ sound_play_noise(24);
671
+ if (lives) --lives;
672
+ hud_dirty = 1;
673
+ if (lives == 0) { game_over(); return; }
674
+ ship.x = 76; ship.y = 104; /* respawn knockback */
675
+ }
676
+ }
677
+ }
678
+
679
+ /* ── GAME LOGIC (clay) — stage the shadow OAM for THIS frame ──────────────────
680
+ * Pure WRAM writes (shadow_oam at $C100) — safe any time; only the DMA flush
681
+ * is vblank-sensitive. OAM coords are hardware coords: +16 on Y, +8 on X.
682
+ * A sprite's CGB palette = OAM attr bits 0-2 — that's the whole "color this
683
+ * sprite" story. Slot plan (40 hardware slots, we use 13): 0 = ship,
684
+ * 1-6 bullets, 7-12 enemies — well under the 10-OBJ/line hardware drop. */
685
+ static void stage_sprites(void) {
686
+ uint8_t i;
687
+ oam_clear();
688
+ if (state == ST_TITLE) {
689
+ /* Guaranteed-visible sprite from the first title frame — proof the OAM
690
+ * pipeline (shadow → HRAM DMA stub → OAM) is alive before any gameplay
691
+ * complicates the picture. */
692
+ oam_set(0, 96 + 16, 76 + 8, T_SHIP, OPAL_SHIP);
693
+ return;
694
+ }
695
+ if (ship.alive)
696
+ oam_set(0, (uint8_t)(ship.y + 16), (uint8_t)(ship.x + 8), T_SHIP, OPAL_SHIP);
697
+ for (i = 0; i < MAX_BULLETS; i++)
698
+ if (bullets[i].alive)
699
+ oam_set((uint8_t)(1 + i), (uint8_t)(bullets[i].y + 16),
700
+ (uint8_t)(bullets[i].x + 8), T_BULLET, OPAL_BULLET);
701
+ for (i = 0; i < MAX_ENEMIES; i++)
702
+ if (enemies[i].alive)
703
+ oam_set((uint8_t)(7 + i), (uint8_t)(enemies[i].y + 16),
704
+ (uint8_t)(enemies[i].x + 8), T_ENEMY, OPAL_ENEMY);
705
+ }
706
+
707
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
708
+ * Queued VRAM commits — and the bank-0-only HUD write. Two-phase update,
709
+ * mirroring the shadow-OAM discipline: game logic only sets hud_dirty /
710
+ * msg_stage. stage_hud() (full-frame time) does the digit math into hud_q;
711
+ * commit_vram() (vblank time) writes bytes — AT MOST ONE queued item/vblank.
712
+ *
713
+ * THE CGB TWIST (load-bearing): a naive set_wcell() per HUD cell toggles VBK
714
+ * twice + writes two banks PER cell — for 11 HUD cells that's ~33 VBK writes
715
+ * in one vblank, which OVERRUNS the ~1140-cycle window and silently drops the
716
+ * tail writes (the lives digit at col 19 vanished — verified on the GBC
717
+ * platformer). The fix: the window HUD cells' bank-1 ATTRIBUTE bytes are
718
+ * constant PAL_HUD (painted once by paint_hud at LCD-off and never changed),
719
+ * so the per-frame commit only needs to rewrite bank-0 TILE bytes. We set
720
+ * VBK=0 ONCE and pointer-walk the digit cells — a tight write that fits
721
+ * vblank with room to spare. (Pointer walk, not map[i] indexing — the SDCC
722
+ * VRAM footgun.)
723
+ *
724
+ * The game-over text on the BG goes the same way: pre-staged tiles, written
725
+ * one line per vblank, and we DELIBERATELY leave the cells' bank-1 attribute
726
+ * alone — the field painted them a depth-band palette whose colour-3 (the
727
+ * font ink value) is bright, so the text reads on top with ZERO attribute
728
+ * writes. That halves the vblank cost. */
729
+ static uint8_t hud_q[11]; /* 5 score digits, 5 hi digits, lives tile */
730
+ static uint8_t hud_ready;
731
+ #define WIN_TILE ((volatile uint8_t *)0x9C00) /* window map, bank 0 */
732
+
733
+ static void stage_hud(void) {
734
+ if (!hud_dirty) return;
735
+ hud_dirty = 0;
736
+ u16_to_tiles(score, hud_q);
737
+ u16_to_tiles(hiscore, hud_q + 5);
738
+ hud_q[10] = (uint8_t)(FONT_BASE + lives);
739
+ hud_ready = 1;
740
+ }
741
+
742
+ /* Write a scroll-anchored, pre-staged BG-map line (msg_q tiles) as a single
743
+ * BANK-0 tile copy — a dumb byte walk, no char_tile work and no per-cell VBK
744
+ * toggling. col wraps at the 32-col map seam (the text is scroll-anchored, so
745
+ * it can straddle the wrap). */
746
+ static void commit_bg_text(uint8_t row, uint8_t col, const uint8_t *q, uint8_t len) {
747
+ volatile uint8_t *base = VRAM + (uint16_t)row * 32;
748
+ volatile uint8_t *p = base + col;
749
+ uint8_t n = (uint8_t)(32 - col);
750
+ VBK = 0;
751
+ if (n > len) n = len; /* run 1: up to the map seam */
752
+ len -= n;
753
+ while (n--) *p++ = *q++;
754
+ p = base; /* run 2: wrapped remainder */
755
+ while (len--) *p++ = *q++;
756
+ }
757
+
758
+ static void commit_vram(void) {
759
+ uint8_t i;
760
+ if (hud_ready) { /* item 1: HUD digits (bank 0) */
761
+ hud_ready = 0;
762
+ VBK = 0; /* attributes already PAL_HUD */
763
+ for (i = 0; i < 5; i++) WIN_TILE[32 + 3 + i] = hud_q[i]; /* score */
764
+ for (i = 0; i < 5; i++) WIN_TILE[32 + 12 + i] = hud_q[5 + i]; /* hi */
765
+ WIN_TILE[32 + 19] = hud_q[10]; /* lives */
766
+ return;
767
+ }
768
+ if (msg_stage == 2) { /* item 2: GAME OVER line */
769
+ msg_stage = 1;
770
+ commit_bg_text(msg_row, 5, msg_q, 9);
771
+ return;
772
+ }
773
+ if (msg_stage == 1) { /* item 3: PRESS START line */
774
+ msg_stage = 0;
775
+ commit_bg_text((uint8_t)((msg_row + 2) & 31), 4, msg_q + 9, 11);
776
+ }
777
+ }
778
+
779
+ void main(void) {
780
+ uint8_t pad;
781
+
782
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
783
+ * Boot order. Three load-bearing calls, in this order:
784
+ * 1. lcd_init_default() — sane LCD state AND it installs the OAM-DMA stub
785
+ * into HRAM ($FF80). During OAM DMA the CPU can only fetch from HRAM;
786
+ * the broken alternative (spinning in ROM) fetches $FF = rst $38 and
787
+ * corrupts the stack — the classic "sprites never show / game dies
788
+ * after a while" GB death. Every oam_dma_flush() depends on this stub.
789
+ * 2. enable_vblank_irq() — flips wait_vblank() from LY-polling to
790
+ * HALT-until-vblank-IRQ. The polling fallback runs at ~1/30 speed on
791
+ * the WASM emulator; the HALT path is full speed everywhere.
792
+ * 3. LCD off (inside vblank) for the bulk VRAM uploads — tiles, font,
793
+ * palettes, first screen — then back on. Tile/palette/map uploads
794
+ * REQUIRE a VRAM-safe window; boot does them all at once, so LCD-off
795
+ * is the only sane choice here. */
796
+ lcd_init_default();
797
+ enable_vblank_irq();
798
+ sound_init();
799
+
800
+ wait_vblank();
801
+ LCDC = 0; /* LCD off — free VRAM access from here */
802
+
803
+ upload_tile(T_BLANK, tile_blank);
804
+ upload_tile(T_SHIP, tile_ship);
805
+ upload_tile(T_BULLET, tile_bullet);
806
+ upload_tile(T_ENEMY, tile_enemy);
807
+ upload_tile(T_SPACE, tile_space);
808
+ upload_tile(T_STAR, tile_star);
809
+ upload_tile(T_BRITE, tile_brite);
810
+ upload_tile(T_HUDBAR, tile_hudbar);
811
+ upload_font();
812
+
813
+ load_bg_palettes(); /* the CGB BG palettes — depth bands + HUD */
814
+ load_obj_palettes(); /* ship / bullet / enemy OBJ palettes */
815
+
816
+ /* Window position — set once; LCDC bit 5 decides if it shows. */
817
+ WX = 7; /* the +7 quirk: 7 = screen left edge */
818
+ WY = PLAY_H; /* HUD owns lines 128-143 */
819
+
820
+ record = hiscore_load(); /* battery SRAM — 0 on first boot */
821
+ hiscore = record;
822
+ state = ST_TITLE;
823
+ paint_title();
824
+ oam_clear();
825
+ LCDC = LCDC_TITLE;
826
+
827
+ for (;;) {
828
+ /* ── full-frame work: input, game state, shadow-OAM staging ── */
829
+ pad = joypad_read();
830
+
831
+ /* SELECT toggles the background music, in any state. */
832
+ if ((pad & PAD_SELECT) && !(prev_pad & PAD_SELECT)) music_toggle();
833
+
834
+ if (state == ST_TITLE) {
835
+ if ((pad & (PAD_START | PAD_A)) && !(prev_pad & (PAD_START | PAD_A))) start_game();
836
+ prev_pad = pad;
837
+ } else if (state == ST_PLAY) {
838
+ update_play(pad);
839
+ prev_pad = pad;
840
+ } else { /* ST_OVER — freeze the field; START/A returns to title */
841
+ if ((pad & (PAD_START | PAD_A)) && !(prev_pad & (PAD_START | PAD_A))) {
842
+ state = ST_TITLE;
843
+ repaint_with_lcd_off(1);
844
+ }
845
+ prev_pad = pad;
846
+ }
847
+ stage_sprites();
848
+ stage_hud(); /* digit math out here, not in vblank */
849
+
850
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
851
+ * The vblank slice. wait_vblank() wakes at the START of vblank
852
+ * (~1140 cycles of safe OAM/VRAM access). Order is everything:
853
+ * oam_dma_flush() FIRST — the DMA takes ~165 cycles and MUST finish
854
+ * inside vblank; pushing it later (after VRAM writes that grow over
855
+ * time) slides it into active display, where the PPU is reading OAM
856
+ * = one frame of torn/invisible sprites, intermittent and miserable
857
+ * to debug.
858
+ * commit_vram() second — the few queued HUD/map bytes (one item/frame).
859
+ * SCY last — scroll latches per-scanline, so writing it during vblank
860
+ * (before line 0 renders) moves the WHOLE next frame consistently;
861
+ * the window ignores it by design (the HUD idiom).
862
+ * Game logic above NEVER touches VRAM directly — it sets the dirty flags
863
+ * and shadow OAM, and this slice commits them. Keep that split. */
864
+ wait_vblank();
865
+ oam_dma_flush();
866
+ commit_vram();
867
+ SCY = scroll_y; /* title resets scroll_y to 0; over freezes it */
868
+ music_tick();
869
+ }
234
870
  }