romdevtools 0.27.0 → 0.29.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 (199) hide show
  1. package/AGENTS.md +56 -44
  2. package/CHANGELOG.md +355 -0
  3. package/README.md +4 -4
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1227 -325
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +909 -257
  8. package/examples/atari2600/templates/shmup.asm +1035 -218
  9. package/examples/atari2600/templates/sports.asm +1143 -229
  10. package/examples/atari7800/templates/hello_sprite.c +8 -4
  11. package/examples/atari7800/templates/platformer.c +991 -152
  12. package/examples/atari7800/templates/puzzle.c +1091 -145
  13. package/examples/atari7800/templates/racing.c +949 -118
  14. package/examples/atari7800/templates/shmup.c +812 -130
  15. package/examples/atari7800/templates/sports.c +820 -181
  16. package/examples/c64/templates/platformer.c +876 -157
  17. package/examples/c64/templates/puzzle.c +881 -143
  18. package/examples/c64/templates/racing.c +873 -88
  19. package/examples/c64/templates/shmup.c +762 -154
  20. package/examples/c64/templates/sports.c +755 -95
  21. package/examples/gb/templates/platformer.c +841 -175
  22. package/examples/gb/templates/puzzle.c +1094 -176
  23. package/examples/gb/templates/racing.c +761 -169
  24. package/examples/gb/templates/shmup.c +679 -169
  25. package/examples/gb/templates/sports.c +790 -153
  26. package/examples/gba/templates/platformer.c +624 -169
  27. package/examples/gba/templates/puzzle.c +535 -207
  28. package/examples/gba/templates/racing.c +513 -196
  29. package/examples/gba/templates/shmup.c +565 -168
  30. package/examples/gba/templates/sports.c +454 -162
  31. package/examples/gbc/templates/platformer.c +944 -176
  32. package/examples/gbc/templates/puzzle.c +1131 -177
  33. package/examples/gbc/templates/racing.c +891 -175
  34. package/examples/gbc/templates/shmup.c +827 -179
  35. package/examples/gbc/templates/sports.c +870 -156
  36. package/examples/genesis/templates/platformer.c +747 -129
  37. package/examples/genesis/templates/puzzle.c +702 -208
  38. package/examples/genesis/templates/racing.c +728 -193
  39. package/examples/genesis/templates/shmup.c +535 -142
  40. package/examples/genesis/templates/shmup_2p.c +13 -1
  41. package/examples/genesis/templates/sports.c +495 -158
  42. package/examples/gg/templates/platformer.c +883 -214
  43. package/examples/gg/templates/puzzle.c +906 -181
  44. package/examples/gg/templates/racing.c +919 -160
  45. package/examples/gg/templates/shmup.c +716 -177
  46. package/examples/gg/templates/sports.c +735 -128
  47. package/examples/lynx/templates/platformer.c +604 -50
  48. package/examples/lynx/templates/puzzle.c +533 -130
  49. package/examples/lynx/templates/racing.c +538 -102
  50. package/examples/lynx/templates/shmup.c +461 -122
  51. package/examples/lynx/templates/sports.c +496 -69
  52. package/examples/msx/platformer/main.c +648 -159
  53. package/examples/msx/puzzle/main.c +750 -185
  54. package/examples/msx/racing/main.c +669 -177
  55. package/examples/msx/shmup/main.c +460 -177
  56. package/examples/msx/sports/main.c +591 -124
  57. package/examples/nes/templates/platformer.c +586 -160
  58. package/examples/nes/templates/puzzle.c +603 -222
  59. package/examples/nes/templates/racing.c +505 -197
  60. package/examples/nes/templates/shmup.c +339 -144
  61. package/examples/nes/templates/sports.c +341 -182
  62. package/examples/pce/platformer/main.c +875 -204
  63. package/examples/pce/puzzle/main.c +797 -216
  64. package/examples/pce/racing/main.c +782 -206
  65. package/examples/pce/shmup/main.c +638 -211
  66. package/examples/pce/sports/main.c +585 -167
  67. package/examples/porting-across-platforms/README.md +1 -1
  68. package/examples/sms/templates/platformer.c +765 -176
  69. package/examples/sms/templates/puzzle.c +783 -177
  70. package/examples/sms/templates/racing.c +812 -133
  71. package/examples/sms/templates/shmup.c +601 -148
  72. package/examples/sms/templates/shmup_2p.c +17 -1
  73. package/examples/sms/templates/sports.c +633 -121
  74. package/examples/snes/templates/music_demo.c +7 -0
  75. package/examples/snes/templates/platformer-data.asm +123 -24
  76. package/examples/snes/templates/platformer-hdr.asm +57 -0
  77. package/examples/snes/templates/platformer.c +587 -149
  78. package/examples/snes/templates/puzzle-data.asm +116 -21
  79. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  80. package/examples/snes/templates/puzzle.c +632 -185
  81. package/examples/snes/templates/racing-data.asm +390 -32
  82. package/examples/snes/templates/racing-hdr.asm +57 -0
  83. package/examples/snes/templates/racing.c +807 -177
  84. package/examples/snes/templates/shmup-data.asm +87 -29
  85. package/examples/snes/templates/shmup-hdr.asm +57 -0
  86. package/examples/snes/templates/shmup.c +459 -180
  87. package/examples/snes/templates/sports-data.asm +48 -2
  88. package/examples/snes/templates/sports-hdr.asm +57 -0
  89. package/examples/snes/templates/sports.c +414 -156
  90. package/package.json +12 -12
  91. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  92. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  93. package/src/cores/wasm/fceumm_libretro.js +1 -1
  94. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  95. package/src/cores/wasm/gambatte_libretro.js +1 -1
  96. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  97. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  98. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  99. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  100. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  101. package/src/cores/wasm/handy_libretro.js +1 -1
  102. package/src/cores/wasm/handy_libretro.wasm +0 -0
  103. package/src/cores/wasm/mgba_libretro.js +1 -1
  104. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  105. package/src/cores/wasm/prosystem_libretro.js +1 -1
  106. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  107. package/src/cores/wasm/snes9x_libretro.js +1 -1
  108. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  109. package/src/cores/wasm/stella2014_libretro.js +1 -1
  110. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  111. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  112. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  113. package/src/host/LibretroHost.js +304 -11
  114. package/src/http/tool-registry.js +11 -11
  115. package/src/mcp/server.js +6 -0
  116. package/src/mcp/tools/cheats.js +2 -1
  117. package/src/mcp/tools/disasm-rebuild.js +315 -65
  118. package/src/mcp/tools/disasm.js +149 -28
  119. package/src/mcp/tools/find-references.js +216 -51
  120. package/src/mcp/tools/frame.js +14 -6
  121. package/src/mcp/tools/index.js +18 -4
  122. package/src/mcp/tools/input.js +31 -7
  123. package/src/mcp/tools/lifecycle.js +6 -4
  124. package/src/mcp/tools/memory.js +208 -39
  125. package/src/mcp/tools/platform-docs.js +1 -1
  126. package/src/mcp/tools/playtest.js +56 -4
  127. package/src/mcp/tools/preview-tile.js +6 -2
  128. package/src/mcp/tools/project.js +1114 -120
  129. package/src/mcp/tools/rom-id.js +5 -1
  130. package/src/mcp/tools/run-until.js +4 -2
  131. package/src/mcp/tools/snippets.js +6 -6
  132. package/src/mcp/tools/sprite-pipeline.js +14 -2
  133. package/src/mcp/tools/state.js +2 -1
  134. package/src/mcp/tools/tile-inspect.js +8 -1
  135. package/src/mcp/tools/toolchain.js +55 -11
  136. package/src/mcp/tools/watch-memory.js +145 -27
  137. package/src/observer/bus.js +73 -0
  138. package/src/observer/livestream.html +4 -2
  139. package/src/observer/tool-wrap.js +17 -14
  140. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  141. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  142. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  143. package/src/platforms/atari7800/MENTAL_MODEL.md +32 -11
  144. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  145. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  146. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  147. package/src/platforms/gb/MENTAL_MODEL.md +19 -4
  148. package/src/platforms/gb/TROUBLESHOOTING.md +101 -6
  149. package/src/platforms/gb/lib/c/README.md +10 -11
  150. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  151. package/src/platforms/gb/lib/c/patch-header.js +19 -6
  152. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  153. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  154. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  155. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  156. package/src/platforms/gbc/MENTAL_MODEL.md +16 -4
  157. package/src/platforms/gbc/TROUBLESHOOTING.md +24 -3
  158. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  159. package/src/platforms/gbc/lib/c/README.md +10 -11
  160. package/src/platforms/gbc/lib/c/font.h +43 -0
  161. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  162. package/src/platforms/gbc/lib/c/patch-header.js +19 -6
  163. package/src/platforms/genesis/MENTAL_MODEL.md +43 -9
  164. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  165. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  166. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  167. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  168. package/src/platforms/gg/TROUBLESHOOTING.md +14 -18
  169. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  170. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  171. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  172. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  173. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  174. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  175. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  176. package/src/platforms/msx/MENTAL_MODEL.md +11 -5
  177. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  178. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  179. package/src/platforms/msx/lib/c/msx_hw.h +3 -0
  180. package/src/platforms/msx/lib/c/msx_vdp.c +70 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +12 -5
  182. package/src/platforms/nes/lib/c/nes_runtime.c +190 -34
  183. package/src/platforms/nes/lib/c/nes_runtime.h +35 -0
  184. package/src/platforms/pce/MENTAL_MODEL.md +14 -5
  185. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  186. package/src/platforms/pce/lib/c/pce_hw.h +13 -1
  187. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  188. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  189. package/src/platforms/sms/MENTAL_MODEL.md +11 -6
  190. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  191. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  192. package/src/platforms/snes/MENTAL_MODEL.md +7 -2
  193. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  194. package/src/playtest/playtest.js +73 -3
  195. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  197. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  198. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  199. package/src/toolchains/index.js +64 -19
@@ -1,210 +1,720 @@
1
- /* ── shmup.c — Game Boy vertical-shooter scaffold ────────────────────
1
+ /* ── shmup.c — Game Boy vertical shooter (complete example game) ─────────────
2
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.
3
+ * A COMPLETE, working game title screen, lives, score + persistent
4
+ * hi-score (battery cart RAM), music + SFX, and the Game Boy's signature
5
+ * WINDOW-LAYER HUD: a fixed score/lives strip that the scrolling starfield
6
+ * slides beneath, with zero mid-frame raster tricks.
12
7
  *
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).
8
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
9
+ * very different one. The markers tell you what's what:
10
+ * HARDWARE IDIOM (load-bearing) — dodges a documented GB footgun; reshape
11
+ * your gameplay around it (see TROUBLESHOOTING before changing).
12
+ * GAME LOGIC (clay) — enemy patterns, scoring, tuning, art: reshape freely.
13
+ *
14
+ * SINGLE-PLAYER BY DESIGN (the honest handheld story): the Game Boy has ONE
15
+ * controller. Multiplayer on real hardware means the link cable, and a
16
+ * single emulator instance cannot emulate a second Game Boy on the other
17
+ * end of that cable — so this game ships 1P only instead of faking a 2P
18
+ * mode the platform can't deliver. (Consoles' examples have real 2P.)
19
+ *
20
+ * What depends on what:
21
+ * gb_hardware.h — register names (LCDC/WX/WY/NRxx/...) + LCDC bit masks.
22
+ * gb_runtime.{h,c} — vblank wait (HALT-driven), joypad, shadow OAM +
23
+ * the OAM-DMA-from-HRAM routine, VRAM-safe memcpy, APU helpers.
24
+ * gb_crt0.s — boot + interrupt vectors + the cartridge header window.
25
+ * It DECLARES the cart as MBC1+RAM+BATTERY ($0147=$03, $0149=$02) —
26
+ * that declaration is what makes hiscore_save() below persist (the
27
+ * emulator sizes battery SAVE_RAM from those two header bytes).
28
+ * Load-bearing; edit with TROUBLESHOOTING open.
29
+ *
30
+ * Frame budget (59.7 fps, ~17 556 machine cycles/frame, vblank = 10 of 154
31
+ * lines ≈ 1 140 cycles): everything VRAM/OAM-touching below happens in the
32
+ * vblank slice (OAM DMA ~165 cycles + ≤ 16 HUD map bytes + one SCY write);
33
+ * game logic (1 ship × 6 bullets × 6 enemies AABB ≈ 36 checks + staging
34
+ * 13 OAM slots) runs in the other 144 lines. Comfortable.
15
35
  */
16
36
 
17
37
  #include "gb_hardware.h"
18
38
  #include "gb_runtime.h"
19
39
 
20
- #define MAX_BULLETS 4
21
- #define MAX_ENEMIES 4
40
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
41
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
42
+ #define GAME_TITLE "METEOR MILITIA"
22
43
 
44
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
45
+ * Tile inventory. GB tiles are 16 bytes: 8 rows × [low-plane byte,
46
+ * high-plane byte]. Pixel colour index = (hi_bit << 1) | lo_bit.
47
+ * lo only = colour 1 hi only = colour 2 both = colour 3
48
+ * With the BGP/OBP palettes set below: 0 = black (backdrop), 1 = dark
49
+ * grey, 2 = light grey, 3 = white. */
23
50
  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] = {
51
+ static const uint8_t tile_ship[16] = { /* colour 3 (white) */
25
52
  0x18,0x18, 0x3C,0x3C, 0x7E,0x7E, 0xFF,0xFF,
26
53
  0xFF,0xFF, 0x7E,0x7E, 0x3C,0x3C, 0x18,0x18,
27
54
  };
28
- /* ── BG tiles (starfield) ─────────────────────────────────────────────
29
- * The background is a real scrolling-looking starfield so the screen is
30
- * never one flat colour (LCDC_BG_ON below — drop it and the screen reads
31
- * as blank, the #1 GB "why is it blank" footgun).
32
- * tile_space — a 50/50 dither of colour 0 (dark) + colour 1, so even an
33
- * empty patch of space mixes two palette shades and never
34
- * lets one colour dominate the frame.
35
- * tile_star — a bright colour-3 "+" 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,
39
- };
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] = {
55
+ static const uint8_t tile_bullet[16] = { /* colour 3 (white) */
47
56
  0x00,0x00, 0x18,0x18, 0x3C,0x3C, 0x3C,0x3C,
48
57
  0x3C,0x3C, 0x3C,0x3C, 0x18,0x18, 0x00,0x00,
49
58
  };
50
- static const uint8_t tile_enemy[16] = {
59
+ static const uint8_t tile_enemy[16] = { /* colour 3 via OBP1 → light */
51
60
  0x81,0x81, 0x42,0x42, 0x24,0x24, 0xFF,0xFF,
52
61
  0xFF,0xFF, 0x24,0x24, 0x42,0x42, 0x81,0x81,
53
62
  };
63
+ /* Starfield BG tiles. tile_space is a 50/50 dither of colours 0+1 so even
64
+ * "empty" sky mixes two shades — the screen can never read as one flat
65
+ * colour (the render-health floor every example keeps). */
66
+ static const uint8_t tile_space[16] = { /* colours 0+1 dither */
67
+ 0x55,0x00, 0xAA,0x00, 0x55,0x00, 0xAA,0x00,
68
+ 0x55,0x00, 0xAA,0x00, 0x55,0x00, 0xAA,0x00,
69
+ };
70
+ static const uint8_t tile_star[16] = { /* one colour-2 dot */
71
+ 0x00,0x00, 0x00,0x00, 0x00,0x10, 0x00,0x38,
72
+ 0x00,0x10, 0x00,0x00, 0x00,0x00, 0x00,0x00,
73
+ };
74
+ static const uint8_t tile_brite[16] = { /* colour-3 "+" twinkle */
75
+ 0x00,0x00, 0x10,0x10, 0x10,0x10, 0x7C,0x7C,
76
+ 0x10,0x10, 0x10,0x10, 0x00,0x00, 0x00,0x00,
77
+ };
78
+ static const uint8_t tile_hudbar[16] = { /* solid colour 2 */
79
+ 0x00,0xFF, 0x00,0xFF, 0x00,0xFF, 0x00,0xFF,
80
+ 0x00,0xFF, 0x00,0xFF, 0x00,0xFF, 0x00,0xFF,
81
+ };
82
+
83
+ /* Tile indices ($8000 unsigned addressing — LCDC bit 4 set below). Sprites
84
+ * and BG share the $8000 table in this layout, so one upload serves both. */
85
+ #define T_SHIP 1
86
+ #define T_BULLET 2
87
+ #define T_ENEMY 3
88
+ #define T_SPACE 4
89
+ #define T_STAR 5
90
+ #define T_BRITE 6
91
+ #define T_HUDBAR 7
92
+ /* Font: '0'-'9' → 16..25, 'A'-'Z' → 26..51, '-' → 52 (see char_tile). */
93
+ #define T_DIGIT0 16
94
+ #define T_ALPHA 26
95
+ #define T_DASH 52
54
96
 
55
- static const uint16_t obj_palette[4] = {
56
- 0x7FFF, /* 0 transparent */
57
- 0x7FFF, /* 1 white */
58
- 0x03E0, /* 2 green */
59
- 0x001F, /* 3 red */
97
+ /* 1bpp font (same glyph set as the NES/SMS examples — 0-9, A-Z, '-').
98
+ * Stored 8 bytes/glyph and expanded to 2bpp colour 3 at upload time, so
99
+ * the ROM carries 296 bytes of font instead of 592. */
100
+ static const uint8_t font8[37][8] = {
101
+ /* 0-9 */
102
+ {0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00}, {0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00},
103
+ {0x3C,0x66,0x06,0x0C,0x18,0x30,0x7E,0x00}, {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00},
104
+ {0x0C,0x1C,0x3C,0x6C,0x7E,0x0C,0x0C,0x00}, {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00},
105
+ {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x30,0x30,0x00},
106
+ {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00}, {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00},
107
+ /* A-Z */
108
+ {0x18,0x3C,0x66,0x66,0x7E,0x66,0x66,0x00}, {0x7C,0x66,0x66,0x7C,0x66,0x66,0x7C,0x00},
109
+ {0x3C,0x66,0x60,0x60,0x60,0x66,0x3C,0x00}, {0x78,0x6C,0x66,0x66,0x66,0x6C,0x78,0x00},
110
+ {0x7E,0x60,0x60,0x7C,0x60,0x60,0x7E,0x00}, {0x7E,0x60,0x60,0x7C,0x60,0x60,0x60,0x00},
111
+ {0x3C,0x66,0x60,0x6E,0x66,0x66,0x3E,0x00}, {0x66,0x66,0x66,0x7E,0x66,0x66,0x66,0x00},
112
+ {0x7E,0x18,0x18,0x18,0x18,0x18,0x7E,0x00}, {0x06,0x06,0x06,0x06,0x66,0x66,0x3C,0x00},
113
+ {0x66,0x6C,0x78,0x70,0x78,0x6C,0x66,0x00}, {0x60,0x60,0x60,0x60,0x60,0x60,0x7E,0x00},
114
+ {0x63,0x77,0x7F,0x6B,0x63,0x63,0x63,0x00}, {0x66,0x76,0x7E,0x7E,0x6E,0x66,0x66,0x00},
115
+ {0x3C,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x7C,0x66,0x66,0x7C,0x60,0x60,0x60,0x00},
116
+ {0x3C,0x66,0x66,0x66,0x6A,0x6C,0x36,0x00}, {0x7C,0x66,0x66,0x7C,0x6C,0x66,0x66,0x00},
117
+ {0x3C,0x66,0x60,0x3C,0x06,0x66,0x3C,0x00}, {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00},
118
+ {0x66,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x66,0x66,0x66,0x66,0x66,0x3C,0x18,0x00},
119
+ {0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00}, {0x66,0x66,0x3C,0x18,0x3C,0x66,0x66,0x00},
120
+ {0x66,0x66,0x66,0x3C,0x18,0x18,0x18,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x60,0x7E,0x00},
121
+ /* '-' */
122
+ {0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00},
60
123
  };
61
124
 
62
- typedef struct { uint8_t x, y, alive; } Obj;
125
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
126
+ * THE WINDOW-LAYER HUD — the Game Boy's signature "fixed HUD over a
127
+ * scrolling world" technique. The window is a second BG plane with its own
128
+ * 32×32 tile map and NO scroll registers: it always draws its map from
129
+ * (0,0), pinned to the screen, on top of the BG. So the HUD lives in the
130
+ * window and the playfield lives in the BG — SCY/SCX scroll the world all
131
+ * they like and the HUD never moves. No raster splits, no IRQ timing (the
132
+ * NES needs a sprite-0 polling dance for this exact effect; on GB it's
133
+ * three register writes).
134
+ *
135
+ * The three registers, and their two famous footguns:
136
+ * WY ($FF4A) — first screen LINE the window covers. We use 128: lines
137
+ * 0-127 are playfield, 128-143 (two tile rows) are HUD.
138
+ * WX ($FF4B) — screen column PLUS SEVEN. WX=7 means "left edge". The
139
+ * -7 offset is hardware fact, not a library quirk: WX=0..6 glitches
140
+ * (real DMG pixel pipeline artifacts), WX≥167 pushes it off-screen.
141
+ * LCDC bit 5 — window enable; bit 6 — which map it reads ($9800/$9C00).
142
+ *
143
+ * FOOTGUN 1 — "the window ate the bottom of my screen": once the window
144
+ * starts on a line it covers EVERY line from there DOWN, full width from
145
+ * WX to the right edge. There is no window height register. That is why
146
+ * GB HUDs sit at the BOTTOM of the screen (this game, and most of the
147
+ * classic library). A TOP HUD needs a mid-frame trick — STAT-interrupt on
148
+ * LYC, flip LCDC bit 5 off after the HUD rows — which is a different,
149
+ * fragile idiom; don't drift into it by accident by setting WY=0.
150
+ *
151
+ * FOOTGUN 2 — sprites are NOT clipped by the window. OBJs draw on top of
152
+ * it (priority bits notwithstanding), so a sprite that wanders below
153
+ * line 128 sits ON the HUD. Gameplay despawns everything before PLAY_H.
154
+ *
155
+ * Requires: window map at $9C00 (LCDC bit 6 set — keeps it separate from
156
+ * the BG's $9800 map), tile data at $8000 (LCDC bit 4), WX=7, WY=PLAY_H,
157
+ * LCDC bit 5 set during play (title turns the window off — LCDC bit
158
+ * discipline lives in the two LCDC_* values below, poke those, not LCDC). */
159
+ #define PLAY_H 128 /* first HUD line = window top */
160
+ #define WIN_MAP ((uint8_t *)0x9C00) /* window's 32×32 tile map */
161
+ #define LCDC_TITLE (LCDC_LCD_ON | LCDC_BG_ON | LCDC_OBJ_ON | LCDC_TILE_DATA_LO)
162
+ #define LCDC_PLAY (LCDC_TITLE | LCDC_WINDOW_ON | LCDC_WINDOW_MAP_HI)
163
+
164
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
165
+ * BATTERY SRAM — persistent hi-score. MBC1 cart RAM is 8KB at $A000-$BFFF,
166
+ * but it boots DISABLED and writes to a disabled bank are silently
167
+ * discarded (reads float). The gate is the MBC's RAM-enable register: any
168
+ * WRITE to ROM space $0000-$1FFF with $0A in the low nibble enables the
169
+ * RAM; writing $00 disables it again. (Writing "into ROM" feels wrong the
170
+ * first time — ROM-area writes never touch ROM, they're how you talk to
171
+ * the mapper chip.) Leaving RAM enabled all the time "works" in emulators
172
+ * but on real hardware risks corruption at power-off — battery carts since
173
+ * forever do enable → touch → disable, so we do too.
174
+ *
175
+ * The record is magic 'H','S' + score lo,hi + a checksum byte, so a
176
+ * first-boot cart full of $FF garbage reads as "no record" instead of a
177
+ * 65535 hi-score.
178
+ *
179
+ * Requires: gb_crt0.s declaring $0147=$03 (MBC1+RAM+BATTERY) + $0149=$02
180
+ * (8KB) — those header bytes are how the emulator knows to allocate and
181
+ * persist SAVE_RAM. Verify headlessly: play, game over, then
182
+ * memory({op:'read', region:'save_ram'}) shows the block, and the
183
+ * hi-score survives host.hardReset(). */
184
+ #define MBC_RAM_ENABLE (*(volatile uint8_t *)0x0000)
185
+ #define SRAM ((volatile uint8_t *)0xA000)
186
+
187
+ static uint16_t hiscore_load(void) {
188
+ uint16_t v = 0;
189
+ MBC_RAM_ENABLE = 0x0A; /* unlock cart RAM */
190
+ if (SRAM[0] == 'H' && SRAM[1] == 'S' &&
191
+ SRAM[4] == (uint8_t)(SRAM[2] ^ SRAM[3] ^ 0xA5)) {
192
+ v = (uint16_t)(SRAM[2] | ((uint16_t)SRAM[3] << 8));
193
+ }
194
+ MBC_RAM_ENABLE = 0x00; /* re-lock (battery hygiene) */
195
+ return v;
196
+ }
63
197
 
64
- static Obj player;
198
+ static void hiscore_save(uint16_t v) {
199
+ uint8_t lo = (uint8_t)(v & 0xFF), hi = (uint8_t)(v >> 8);
200
+ MBC_RAM_ENABLE = 0x0A;
201
+ SRAM[0] = 'H'; SRAM[1] = 'S';
202
+ SRAM[2] = lo; SRAM[3] = hi;
203
+ SRAM[4] = (uint8_t)(lo ^ hi ^ 0xA5);
204
+ MBC_RAM_ENABLE = 0x00;
205
+ }
206
+
207
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
208
+ * Object pools — fixed slots, no allocation. OAM slot plan (40 hardware
209
+ * slots, we use 13): 0 = ship, 1-6 bullets, 7-12 enemies. Sub-10 sprites
210
+ * on any one scanline keeps us clear of the 10-OBJ/line hardware drop. */
211
+ #define MAX_BULLETS 6
212
+ #define MAX_ENEMIES 6
213
+ #define START_LIVES 3
214
+
215
+ typedef struct { uint8_t x, y, alive; } Obj; /* screen coords (not OAM) */
216
+
217
+ static Obj ship;
65
218
  static Obj bullets[MAX_BULLETS];
66
219
  static Obj enemies[MAX_ENEMIES];
220
+ static uint8_t lives;
67
221
  static uint16_t score;
222
+ static uint16_t hiscore; /* live HUD readout: max(score, record) */
223
+ static uint16_t record; /* what the battery SRAM actually holds */
224
+ static uint8_t fire_cd;
68
225
  static uint8_t spawn_timer;
226
+ static uint8_t scroll_y; /* starfield drift, committed to SCY */
227
+ static uint8_t prev_pad;
228
+ static uint8_t hud_dirty; /* queue VRAM writes; vblank commits them */
229
+ static uint8_t msg_stage; /* game-over text: 2 = line 1 pending, 1 = line 2 */
230
+ static uint8_t msg_row; /* BG map row for GAME OVER (scroll-aware) */
231
+
232
+ /* Game states — the shell every example shares: title → play → game over.
233
+ * (Handheld adaptation: title is press-start; consoles add a 1P/2P pick.) */
234
+ #define ST_TITLE 0
235
+ #define ST_PLAY 1
236
+ #define ST_OVER 2
237
+ static uint8_t state;
69
238
 
70
- static uint8_t aabb(Obj *a, Obj *b) {
71
- /* Coord conversion: sprites are stored in screen coords here
72
- * (px..px+8) NOT hardware coords. We translate to hardware in OAM. */
73
- return a->x < b->x + 8 && a->x + 8 > b->x
74
- && a->y < b->y + 8 && a->y + 8 > b->y;
75
- }
76
-
77
- static void fire(void) {
78
- uint8_t i;
79
- for (i = 0; i < MAX_BULLETS; i++) {
80
- if (!bullets[i].alive) {
81
- bullets[i].x = player.x;
82
- bullets[i].y = player.y - 8;
83
- bullets[i].alive = 1;
84
- return;
85
- }
239
+ /* ── GAME LOGIC (clay) Galois LFSR (taps $B8), period 255 ── */
240
+ static uint8_t rng_state = 0xA5;
241
+ static uint8_t rand8(void) {
242
+ uint8_t lsb = (uint8_t)(rng_state & 1);
243
+ rng_state >>= 1;
244
+ if (lsb) rng_state ^= 0xB8;
245
+ return rng_state;
246
+ }
247
+
248
+ /* ── GAME LOGIC (clay) VRAM upload + text helpers ──────────────────────────
249
+ * All of these write VRAM, so they run with the LCD OFF (boot/repaints) or
250
+ * inside vblank (the HUD digit commits). Note every loop walks a pointer
251
+ * (*dst++ = v) instead of indexing dst[i] SDCC's sm83 port miscompiles
252
+ * indexed stores through VRAM-pointing pointers (the documented
253
+ * memcpy_vram footgun; see gb_runtime.c). */
254
+ static void upload_tile(uint8_t slot, const uint8_t *src) {
255
+ memcpy_vram((uint8_t *)(0x8000 + (uint16_t)slot * 16), src, 16);
256
+ }
257
+
258
+ static void upload_font(void) {
259
+ uint8_t *dst = (uint8_t *)(0x8000 + (uint16_t)T_DIGIT0 * 16);
260
+ uint8_t g, r, bits;
261
+ for (g = 0; g < 37; g++) {
262
+ for (r = 0; r < 8; r++) {
263
+ bits = font8[g][r];
264
+ *dst++ = bits; /* low plane ─┐ both set → colour 3 (white) */
265
+ *dst++ = bits; /* high plane ─┘ */
86
266
  }
267
+ }
268
+ }
269
+
270
+ static uint8_t char_tile(char ch) {
271
+ if (ch >= '0' && ch <= '9') return (uint8_t)(T_DIGIT0 + (ch - '0'));
272
+ if (ch >= 'A' && ch <= 'Z') return (uint8_t)(T_ALPHA + (ch - 'A'));
273
+ if (ch == '-') return T_DASH;
274
+ return 0; /* space → blank tile */
275
+ }
276
+
277
+ /* Both 32×32 maps (BG $9800, window $9C00) take the same row/col math. */
278
+ static void draw_text(uint8_t *map, uint8_t row, uint8_t col, const char *s) {
279
+ uint8_t *p = map + (uint16_t)row * 32 + col;
280
+ while (*s) *p++ = char_tile(*s++);
87
281
  }
88
282
 
89
- static void spawn(void) {
90
- uint8_t i;
91
- for (i = 0; i < MAX_ENEMIES; i++) {
92
- if (!enemies[i].alive) {
93
- enemies[i].x = ((spawn_timer * 37) & 0x7F) % (160 - 16) + 8;
94
- enemies[i].y = 0;
95
- enemies[i].alive = 1;
96
- return;
97
- }
283
+ /* Decimal digits WITHOUT divide/modulo (the sm83 has neither — SDCC's
284
+ * software % costs ~700 cycles a call; see paint_starfield). Repeated
285
+ * power-of-ten subtraction caps at 36 SUBs for any u16. */
286
+ static void u16_to_tiles(uint16_t v, uint8_t *out5) {
287
+ static const uint16_t pow10[4] = { 10000, 1000, 100, 10 };
288
+ uint8_t i, d;
289
+ for (i = 0; i < 4; i++) {
290
+ d = 0;
291
+ while (v >= pow10[i]) { v -= pow10[i]; ++d; }
292
+ *out5++ = (uint8_t)(T_DIGIT0 + d);
293
+ }
294
+ *out5 = (uint8_t)(T_DIGIT0 + (uint8_t)v);
295
+ }
296
+
297
+ static void draw_u16(uint8_t *map, uint8_t row, uint8_t col, uint16_t v) {
298
+ uint8_t d[5];
299
+ uint8_t i, *p = map + (uint16_t)row * 32 + col;
300
+ u16_to_tiles(v, d);
301
+ for (i = 0; i < 5; i++) *p++ = d[i];
302
+ }
303
+
304
+ /* Pre-convert a string to tile indices (full-frame time) so the vblank
305
+ * commit is a dumb byte copy. char_tile's compare chain per character is
306
+ * exactly the kind of work that blows the ~1140-cycle vblank budget —
307
+ * the first cut of this file called draw_text from the vblank slice and
308
+ * gambatte faithfully dropped the writes that slid into mode 3 (half the
309
+ * GAME OVER text simply missing — see the commit_vram budget note). */
310
+ static uint8_t msg_q[20]; /* 9 "GAME OVER" + 11 "PRESS START" */
311
+ static void stage_text(const char *s, uint8_t *out) {
312
+ while (*s) *out++ = char_tile(*s++);
313
+ }
314
+
315
+ /* ── GAME LOGIC (clay) — screen painters (LCD off = free VRAM access) ────────
316
+ * Starfield fills the FULL 32-row map (not just the visible 18) because
317
+ * SCY scrolling wraps through all 32 — a part-filled map scrolls garbage
318
+ * into view. The star pattern has no 8px vertical symmetry, so scroll
319
+ * motion is visible everywhere.
320
+ *
321
+ * PERF FOOTGUN (measured, not theoretical): the obvious pattern formula
322
+ * `(r*7 + c*5) % 11` calls SDCC's software modulo (~700 cycles) — 2048
323
+ * times over a 32×32 map ≈ 1.5 MILLION cycles ≈ a 1.5-second frozen boot.
324
+ * The fix is the classic 8-bit move: keep running counters and subtract
325
+ * on overflow (a is (r*7+c*5) mod 11, b is (r*3+c*13) mod 29, maintained
326
+ * incrementally — zero divisions). The sm83 has no divide instruction;
327
+ * treat every / and % in a loop as a red flag. */
328
+ static void paint_starfield(void) {
329
+ uint8_t *p = BG_MAP_0;
330
+ uint8_t r, c, t;
331
+ uint8_t ar = 0, br = 0; /* row seeds: (r*7) mod 11, (r*3) mod 29 */
332
+ uint8_t a, b;
333
+ for (r = 0; r < 32; r++) {
334
+ a = ar; b = br;
335
+ for (c = 0; c < 32; c++) {
336
+ t = T_SPACE;
337
+ if (a == 0) t = T_STAR;
338
+ if (b == 0) t = T_BRITE;
339
+ *p++ = t;
340
+ a += 5; if (a >= 11) a -= 11; /* +5 ≡ c step, mod 11 */
341
+ b += 13; if (b >= 29) b -= 29; /* +13 ≡ c step, mod 29 */
98
342
  }
343
+ ar += 7; if (ar >= 11) ar -= 11; /* +7 ≡ r step, mod 11 */
344
+ br += 3; if (br >= 29) br -= 29; /* +3 ≡ r step, mod 29 */
345
+ }
99
346
  }
100
347
 
101
- static void upload_tile(uint8_t slot, const uint8_t *src) {
102
- uint8_t *dst = (uint8_t *)(0x8000 + slot * 16);
103
- /* memcpy_vram (pointer-walk) NOT an indexed dst[i]=src[i] loop, which
104
- * SDCC sm83 miscompiles when dst points into VRAM ($8000-$9FFF). */
105
- memcpy_vram(dst, src, 16);
348
+ static void paint_title(void) {
349
+ paint_starfield();
350
+ draw_text(BG_MAP_0, 3, (uint8_t)((20 - (sizeof(GAME_TITLE) - 1)) / 2), GAME_TITLE);
351
+ draw_text(BG_MAP_0, 8, 4, "PRESS START");
352
+ draw_text(BG_MAP_0, 11, 6, "HI");
353
+ draw_u16(BG_MAP_0, 11, 9, hiscore);
354
+ draw_text(BG_MAP_0, 14, 6, "1P ONLY"); /* see header: no link 2P */
355
+ SCY = 0; SCX = 0;
356
+ scroll_y = 0;
106
357
  }
107
358
 
108
- /* Paint a starfield into BG map 0 ($9800): fill the visible 20×18 with the
109
- * dithered space tile, then scatter bright stars on a fixed pseudo-pattern
110
- * so the field reads as deep space rather than a flat colour. */
111
- static void draw_starfield(void) {
112
- uint8_t *bg = BG_MAP_0;
113
- uint8_t r, c;
114
- for (r = 0; r < 18; r++)
115
- for (c = 0; c < 20; c++)
116
- bg[r * 32 + c] = ((r * 7 + c * 5) % 11 == 0) ? T_STAR : T_SPACE;
359
+ /* HUD strip = window rows 0-1: a solid divider bar, then the text row.
360
+ * Columns 0-19 are the visible 20 (WX=7 pins map col 0 to screen x 0). */
361
+ static void paint_hud(void) {
362
+ uint8_t *p = WIN_MAP;
363
+ uint8_t c;
364
+ for (c = 0; c < 20; c++) *p++ = T_HUDBAR;
365
+ draw_text(WIN_MAP, 1, 0, "SC");
366
+ draw_u16(WIN_MAP, 1, 3, score);
367
+ draw_text(WIN_MAP, 1, 9, "HI");
368
+ draw_u16(WIN_MAP, 1, 12, hiscore);
369
+ draw_text(WIN_MAP, 1, 18, "L");
370
+ *(WIN_MAP + 32 + 19) = (uint8_t)(T_DIGIT0 + lives);
117
371
  }
118
372
 
119
- void main(void) {
120
- uint8_t pad, prev = 0;
121
- uint8_t i, j;
122
-
123
- lcd_init_default();
124
- LCDC = 0;
125
-
126
- upload_tile(0, tile_blank);
127
- upload_tile(1, tile_ship);
128
- upload_tile(2, tile_bullet);
129
- upload_tile(3, tile_enemy);
130
- upload_tile(T_SPACE, tile_space);
131
- upload_tile(T_STAR, tile_star);
132
-
133
- /* DMG BG palette: 0 dark, 1 mid-dark, 2 light, 3 white — the dithered
134
- * space tile mixes shades 0+1 (deep space), stars use shade 3 (white). */
135
- BGP = 0xE4;
136
-
137
- OCPS = 0x80;
138
- for (i = 0; i < 4; i++) {
139
- OCPD = (uint8_t)(obj_palette[i] & 0xFF);
140
- OCPD = (uint8_t)((obj_palette[i] >> 8) & 0xFF);
373
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
374
+ * LCD-off repaints. Bulk VRAM rewrites (full title/field repaints) happen
375
+ * with the LCD OFF — free access, no per-byte timing worries. The rule:
376
+ * only flip LCDC bit 7 to 0 DURING VBLANK. Killing the LCD mid-scanline
377
+ * is the classic "damages real DMG hardware" move; emulators shrug, real
378
+ * units can be permanently marked. wait_vblank() first, always.
379
+ * Requires: enable_vblank_irq() already called (wait_vblank HALT path);
380
+ * lcd_off-safe runtime (gb_runtime's wait_vblank bails if LCD is off). */
381
+ static void repaint_with_lcd_off(uint8_t to_title) {
382
+ msg_stage = 0; /* a queued game-over line must not land on
383
+ * the freshly painted screen a frame later */
384
+ wait_vblank(); /* never cut the LCD outside vblank */
385
+ LCDC = 0;
386
+ if (to_title) {
387
+ paint_title();
388
+ oam_clear(); /* hide every sprite slot before re-enable */
389
+ LCDC = LCDC_TITLE; /* window OFF on the title */
390
+ } else {
391
+ paint_starfield();
392
+ paint_hud();
393
+ LCDC = LCDC_PLAY; /* window ON below WY — the HUD appears */
394
+ }
395
+ }
396
+
397
+ /* ── GAME LOGIC (clay) — sound: frame-ticked tune + fire/boom SFX ────────────
398
+ * Channel plan keeps SFX from cutting the music: ch2 = music (one
399
+ * sound_play_tone trigger per note, the APU sustains it), ch1 = fire blip,
400
+ * ch4 = noise explosions. music_tick() runs once per frame from the main
401
+ * loop; the APU needs no other upkeep. Periods are the 11-bit GB frequency
402
+ * code: 2048 - (131072 / Hz). 0 = rest. */
403
+ static const uint16_t tune[16] = {
404
+ 1547, 0, 1650, 0, 1714, 0, 1798, 0, /* C4 E4 G4 C5 */
405
+ 1714, 0, 1650, 0, 1602, 0, 1650, 0, /* G4 E4 D4 E4 */
406
+ };
407
+ static uint8_t music_pos, music_timer;
408
+ static void music_tick(void) {
409
+ uint16_t n;
410
+ if (++music_timer < 14) return;
411
+ music_timer = 0;
412
+ n = tune[music_pos];
413
+ music_pos = (uint8_t)((music_pos + 1) & 15);
414
+ if (n) sound_play_tone(2, n, 12);
415
+ }
416
+
417
+ /* ── GAME LOGIC (clay) — spawning, firing, collision ── */
418
+ static void fire_bullet(void) {
419
+ uint8_t i;
420
+ for (i = 0; i < MAX_BULLETS; i++) {
421
+ if (!bullets[i].alive) {
422
+ bullets[i].x = ship.x;
423
+ bullets[i].y = (uint8_t)(ship.y - 8);
424
+ bullets[i].alive = 1;
425
+ sound_play_tone(1, 1900, 4); /* ch1 blip — music keeps ch2 */
426
+ return;
427
+ }
428
+ }
429
+ }
430
+
431
+ static void spawn_enemy(void) {
432
+ uint8_t i;
433
+ for (i = 0; i < MAX_ENEMIES; i++) {
434
+ if (!enemies[i].alive) {
435
+ /* One software-% per spawn (every ~32 frames) is fine — the
436
+ * divide-free rule (see paint_starfield) is about per-cell/per-
437
+ * frame loops, not superstition. */
438
+ enemies[i].x = (uint8_t)(rand8() % 145 + 4);
439
+ enemies[i].y = 0;
440
+ enemies[i].alive = 1;
441
+ return;
442
+ }
443
+ }
444
+ }
445
+
446
+ static uint8_t hits(Obj *a, Obj *b) { /* AABB, both 8×8 */
447
+ uint8_t dx = (uint8_t)((a->x > b->x) ? (a->x - b->x) : (b->x - a->x));
448
+ uint8_t dy = (uint8_t)((a->y > b->y) ? (a->y - b->y) : (b->y - a->y));
449
+ return (uint8_t)((dx < 8) && (dy < 8));
450
+ }
451
+
452
+ /* ── GAME LOGIC (clay) — state transitions ── */
453
+ static void start_game(void) {
454
+ uint8_t i;
455
+ ship.x = 76; ship.y = 104; ship.alive = 1;
456
+ for (i = 0; i < MAX_BULLETS; i++) bullets[i].alive = 0;
457
+ for (i = 0; i < MAX_ENEMIES; i++) enemies[i].alive = 0;
458
+ lives = START_LIVES;
459
+ score = 0;
460
+ fire_cd = 0;
461
+ spawn_timer = 0;
462
+ hud_dirty = 1; /* restage hud_q — a stale game-over stage queued
463
+ * before the repaint would overwrite the fresh
464
+ * zeros next vblank otherwise */
465
+ state = ST_PLAY;
466
+ repaint_with_lcd_off(0);
467
+ }
468
+
469
+ static void game_over(void) {
470
+ /* Compare against the SAVED record, not the live `hiscore` readout —
471
+ * the kill handler already raised `hiscore` to track the run, so
472
+ * testing `score > hiscore` here would never fire (a bug this file
473
+ * shipped with for about an hour; verified-by-harness is the cure). */
474
+ if (score > record) {
475
+ record = score;
476
+ hiscore_save(record); /* battery write — survives power-off */
477
+ }
478
+ state = ST_OVER;
479
+ /* The BG has scrolled: map row 0 is no longer screen row 0. Anchor the
480
+ * text relative to the CURRENT scroll so it lands mid-playfield
481
+ * on-screen ((SCY/8 + screen_row) & 31 = the map row under that screen
482
+ * row). Convert the strings to tile indices HERE (full-frame time) and
483
+ * queue them — commit_vram() copies one line per vblank. */
484
+ msg_row = (uint8_t)(((scroll_y >> 3) + 6) & 31);
485
+ stage_text("GAME OVER", msg_q);
486
+ stage_text("PRESS START", msg_q + 9);
487
+ msg_stage = 2;
488
+ }
489
+
490
+ /* ── GAME LOGIC (clay) — per-state update (runs OUTSIDE vblank) ── */
491
+ static void update_play(uint8_t pad) {
492
+ uint8_t i, j;
493
+
494
+ if ((pad & PAD_LEFT) && ship.x > 0) ship.x -= 2;
495
+ if ((pad & PAD_RIGHT) && ship.x < 160 - 8) ship.x += 2;
496
+ if ((pad & PAD_UP) && ship.y > 8) ship.y -= 2;
497
+ if ((pad & PAD_DOWN) && ship.y < PLAY_H - 24) ship.y += 2;
498
+ if ((pad & PAD_A) && fire_cd == 0) { fire_bullet(); fire_cd = 8; }
499
+ if (fire_cd) --fire_cd;
500
+
501
+ /* Starfield drift — the window HUD makes this free (no split timing). */
502
+ if ((spawn_timer & 1) == 0) --scroll_y;
503
+
504
+ for (i = 0; i < MAX_BULLETS; i++) {
505
+ if (!bullets[i].alive) continue;
506
+ if (bullets[i].y < 4) { bullets[i].alive = 0; continue; }
507
+ bullets[i].y -= 4;
508
+ }
509
+
510
+ /* Enemies despawn BEFORE the HUD line — sprites draw OVER the window
511
+ * (footgun 2 above), so nothing may drift past PLAY_H. */
512
+ for (i = 0; i < MAX_ENEMIES; i++) {
513
+ if (!enemies[i].alive) continue;
514
+ enemies[i].y += 1;
515
+ if (enemies[i].y >= PLAY_H - 12) enemies[i].alive = 0;
516
+ }
517
+
518
+ if (++spawn_timer >= 32) { spawn_timer = 0; spawn_enemy(); }
519
+
520
+ /* Bullets ↔ enemies. */
521
+ for (i = 0; i < MAX_BULLETS; i++) {
522
+ if (!bullets[i].alive) continue;
523
+ for (j = 0; j < MAX_ENEMIES; j++) {
524
+ if (!enemies[j].alive) continue;
525
+ if (hits(&bullets[i], &enemies[j])) {
526
+ bullets[i].alive = 0;
527
+ enemies[j].alive = 0;
528
+ if (score <= 65525u) score += 10;
529
+ if (score > hiscore) hiscore = score; /* live HI readout; SRAM
530
+ * write waits for game over */
531
+ sound_play_noise(8);
532
+ hud_dirty = 1;
533
+ break;
534
+ }
535
+ }
536
+ }
537
+
538
+ /* Enemies ↔ ship. */
539
+ for (j = 0; j < MAX_ENEMIES; j++) {
540
+ if (!enemies[j].alive) continue;
541
+ if (hits(&enemies[j], &ship)) {
542
+ enemies[j].alive = 0;
543
+ sound_play_noise(24);
544
+ if (lives) --lives;
545
+ hud_dirty = 1;
546
+ if (lives == 0) { game_over(); return; }
547
+ ship.x = 76; ship.y = 104; /* respawn knockback */
141
548
  }
549
+ }
550
+ }
551
+
552
+ /* ── GAME LOGIC (clay) — stage the shadow OAM for THIS frame ─────────────────
553
+ * Pure WRAM writes (shadow_oam at $C100) — safe any time; only the DMA
554
+ * flush is vblank-sensitive. OAM coords are hardware coords: +16 on Y,
555
+ * +8 on X (Y=0/X=0 park a sprite off-screen, which is what oam_clear's
556
+ * zero-fill does for every unused slot). */
557
+ static void stage_sprites(void) {
558
+ uint8_t i;
559
+ oam_clear();
560
+ if (state == ST_TITLE) {
561
+ /* Guaranteed-visible sprite from the first title frame — proof the
562
+ * whole OAM pipeline (shadow → HRAM DMA stub → OAM) is alive before
563
+ * any gameplay complicates the picture. */
564
+ oam_set(0, 96 + 16, 76 + 8, T_SHIP, 0);
565
+ return;
566
+ }
567
+ if (ship.alive)
568
+ oam_set(0, (uint8_t)(ship.y + 16), (uint8_t)(ship.x + 8), T_SHIP, 0);
569
+ for (i = 0; i < MAX_BULLETS; i++)
570
+ if (bullets[i].alive)
571
+ oam_set((uint8_t)(1 + i), (uint8_t)(bullets[i].y + 16),
572
+ (uint8_t)(bullets[i].x + 8), T_BULLET, 0);
573
+ for (i = 0; i < MAX_ENEMIES; i++)
574
+ if (enemies[i].alive)
575
+ oam_set((uint8_t)(7 + i), (uint8_t)(enemies[i].y + 16),
576
+ (uint8_t)(enemies[i].x + 8), T_ENEMY, 0x10); /* attr $10 → OBP1 */
577
+ }
578
+
579
+ /* ── GAME LOGIC (clay) — queued VRAM commits ─────────────────────────────────
580
+ * Two-phase update, mirroring the shadow-OAM discipline: game logic only
581
+ * sets hud_dirty / msg_stage. stage_hud() (full-frame time) does the digit
582
+ * math into hud_q; commit_vram() (vblank time) copies bytes — and commits
583
+ * AT MOST ONE queued item per vblank. The budget after the OAM DMA
584
+ * (~165 cycles of the ~1140) fits one item comfortably; committing
585
+ * everything at once on a busy frame (game over = lives digit + two text
586
+ * lines) overruns into mode 3, where the PPU locks VRAM and the writes
587
+ * are silently discarded — the harness caught exactly that as
588
+ * half-missing GAME OVER text. One item per frame = zero dropped bytes,
589
+ * and a frame of HUD latency nobody can see. */
590
+ static uint8_t hud_q[11]; /* 5 score digits, 5 hi digits, lives tile */
591
+ static uint8_t hud_ready;
592
+
593
+ static void stage_hud(void) {
594
+ if (!hud_dirty) return;
595
+ hud_dirty = 0;
596
+ u16_to_tiles(score, hud_q);
597
+ u16_to_tiles(hiscore, hud_q + 5);
598
+ hud_q[10] = (uint8_t)(T_DIGIT0 + lives);
599
+ hud_ready = 1;
600
+ }
601
+
602
+ static void commit_vram(void) {
603
+ uint8_t i;
604
+ uint8_t *p;
605
+ const uint8_t *q;
606
+ if (hud_ready) { /* item 1: HUD digits */
607
+ hud_ready = 0;
608
+ p = WIN_MAP + 32 + 3; q = hud_q; for (i = 0; i < 5; i++) *p++ = *q++;
609
+ p = WIN_MAP + 32 + 12; q = hud_q + 5; for (i = 0; i < 5; i++) *p++ = *q++;
610
+ *(WIN_MAP + 32 + 19) = hud_q[10];
611
+ return;
612
+ }
613
+ if (msg_stage == 2) { /* item 2: GAME OVER line */
614
+ msg_stage = 1;
615
+ p = BG_MAP_0 + (uint16_t)msg_row * 32 + 5;
616
+ q = msg_q;
617
+ for (i = 0; i < 9; i++) *p++ = *q++;
618
+ return;
619
+ }
620
+ if (msg_stage == 1) { /* item 3: PRESS START line */
621
+ msg_stage = 0;
622
+ p = BG_MAP_0 + (uint16_t)((msg_row + 2) & 31) * 32 + 4;
623
+ q = msg_q + 9;
624
+ for (i = 0; i < 11; i++) *p++ = *q++;
625
+ }
626
+ }
627
+
628
+ void main(void) {
629
+ uint8_t pad;
630
+
631
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
632
+ * Boot order. Three load-bearing calls, in this order:
633
+ * 1. lcd_init_default() — sane LCD state AND it installs the OAM-DMA
634
+ * stub into HRAM ($FF80). During OAM DMA the CPU can only fetch
635
+ * from HRAM; the broken alternative (spinning in ROM) fetches $FF
636
+ * = rst $38 and corrupts the stack — the classic "sprites never
637
+ * show / game dies after a while" GB death. Every oam_dma_flush()
638
+ * below depends on this stub existing.
639
+ * 2. enable_vblank_irq() — flips wait_vblank() from LY-polling to
640
+ * HALT-until-vblank-IRQ. The polling fallback runs at ~1/30 speed
641
+ * on the WASM emulator; the HALT path is full speed everywhere.
642
+ * 3. LCD off (inside vblank) for the bulk VRAM uploads — tiles, font,
643
+ * first screen — then back on. VRAM is only freely writable with
644
+ * the LCD off or during vblank/hblank windows. */
645
+ lcd_init_default();
646
+ enable_vblank_irq();
647
+ sound_init();
648
+
649
+ wait_vblank();
650
+ LCDC = 0; /* LCD off — free VRAM access from here */
651
+
652
+ upload_tile(0, tile_blank);
653
+ upload_tile(T_SHIP, tile_ship);
654
+ upload_tile(T_BULLET, tile_bullet);
655
+ upload_tile(T_ENEMY, tile_enemy);
656
+ upload_tile(T_SPACE, tile_space);
657
+ upload_tile(T_STAR, tile_star);
658
+ upload_tile(T_BRITE, tile_brite);
659
+ upload_tile(T_HUDBAR, tile_hudbar);
660
+ upload_font();
142
661
 
143
- draw_starfield();
144
-
145
- player.x = 76; player.y = 130; player.alive = 1;
146
- for (i = 0; i < MAX_BULLETS; i++) bullets[i].alive = 0;
147
- for (i = 0; i < MAX_ENEMIES; i++) enemies[i].alive = 0;
148
- score = 0;
149
- spawn_timer = 0;
150
-
151
- oam_clear();
152
- LCDC = LCDC_LCD_ON | LCDC_BG_ON | LCDC_OBJ_ON | LCDC_TILE_DATA_LO;
153
- sound_init();
154
-
155
- while (1) {
156
- wait_vblank();
157
-
158
- /* Stage OAM for this frame BEFORE we update game state — the
159
- * shadow OAM gets DMA'd next vblank. */
160
- for (i = 0; i < 40; i++) oam_set(i, 0, 0, 0, 0);
161
- oam_set(0, player.y + 16, player.x + 8, 1, 0);
162
- for (i = 0; i < MAX_BULLETS; i++) {
163
- if (bullets[i].alive)
164
- oam_set(1 + i, bullets[i].y + 16, bullets[i].x + 8, 2, 0);
165
- }
166
- for (i = 0; i < MAX_ENEMIES; i++) {
167
- if (enemies[i].alive)
168
- oam_set(5 + i, enemies[i].y + 16, enemies[i].x + 8, 3, 0);
169
- }
170
- oam_dma_flush();
171
-
172
- pad = joypad_read();
173
-
174
- if (pad & PAD_LEFT && player.x > 0) player.x -= 2;
175
- if (pad & PAD_RIGHT && player.x < 160 - 8) player.x += 2;
176
- if (pad & PAD_UP && player.y > 0) player.y -= 2;
177
- if (pad & PAD_DOWN && player.y < 144 - 8) player.y += 2;
178
- if ((pad & PAD_A) && !(prev & PAD_A)) {
179
- fire();
180
- sound_play_tone(2, 1900, 4);
181
- }
182
- prev = pad;
183
-
184
- for (i = 0; i < MAX_BULLETS; i++) {
185
- if (!bullets[i].alive) continue;
186
- if (bullets[i].y < 4) { bullets[i].alive = 0; continue; }
187
- bullets[i].y -= 4;
188
- }
189
- for (i = 0; i < MAX_ENEMIES; i++) {
190
- if (!enemies[i].alive) continue;
191
- enemies[i].y += 1;
192
- if (enemies[i].y >= 144) enemies[i].alive = 0;
193
- }
194
- if (++spawn_timer >= 28) { spawn_timer = 0; spawn(); }
195
-
196
- for (i = 0; i < MAX_BULLETS; i++) {
197
- if (!bullets[i].alive) continue;
198
- for (j = 0; j < MAX_ENEMIES; j++) {
199
- if (!enemies[j].alive) continue;
200
- if (aabb(&bullets[i], &enemies[j])) {
201
- bullets[i].alive = 0;
202
- enemies[j].alive = 0;
203
- if (score < 65500u) score += 10;
204
- sound_play_noise(6);
205
- break;
206
- }
207
- }
208
- }
662
+ /* DMG palettes (2 bits per colour index, low bits = index 0):
663
+ * BGP $1B → 0=black 1=dark 2=light 3=white (dark sky, white text).
664
+ * OBP0 $1B → ship/bullets white. OBP1 $5B enemies light grey. */
665
+ BGP = 0x1B;
666
+ OBP0 = 0x1B;
667
+ OBP1 = 0x5B;
668
+
669
+ /* Window position — set once; LCDC bit 5 decides if it shows. */
670
+ WX = 7; /* the +7 quirk: 7 = screen left edge */
671
+ WY = PLAY_H; /* HUD owns lines 128-143 */
672
+
673
+ record = hiscore_load(); /* battery SRAM — 0 on first boot */
674
+ hiscore = record;
675
+ state = ST_TITLE;
676
+ paint_title();
677
+ oam_clear();
678
+ LCDC = LCDC_TITLE;
679
+
680
+ for (;;) {
681
+ /* ── full-frame work: input, game state, shadow-OAM staging ── */
682
+ pad = joypad_read();
683
+
684
+ if (state == ST_TITLE) {
685
+ if ((pad & PAD_START) && !(prev_pad & PAD_START)) start_game();
686
+ else if ((pad & PAD_A) && !(prev_pad & PAD_A)) start_game();
687
+ } else if (state == ST_PLAY) {
688
+ update_play(pad);
689
+ } else { /* ST_OVER — freeze the field; START/A returns to title */
690
+ if ((pad & (PAD_START | PAD_A)) && !(prev_pad & (PAD_START | PAD_A))) {
691
+ state = ST_TITLE;
692
+ repaint_with_lcd_off(1);
693
+ }
209
694
  }
695
+ prev_pad = pad;
696
+ stage_sprites();
697
+ stage_hud(); /* digit math out here, not in vblank */
698
+
699
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
700
+ * The vblank slice. wait_vblank() wakes at the START of vblank
701
+ * (~1140 cycles of safe OAM/VRAM access). Order is everything:
702
+ * oam_dma_flush() FIRST — the DMA takes ~165 cycles and MUST finish
703
+ * inside vblank; pushing it later (after VRAM writes that grow
704
+ * over time) slides it into active display, where the PPU is
705
+ * reading OAM = one frame of torn/invisible sprites, intermittent
706
+ * and miserable to debug.
707
+ * commit_vram() second — the few queued HUD/map bytes.
708
+ * SCY last — scroll latches per-scanline, so writing it during
709
+ * vblank (before line 0 renders) moves the WHOLE next frame
710
+ * consistently; the window ignores it by design (the HUD idiom).
711
+ * Game logic above NEVER touches VRAM directly — it sets the dirty
712
+ * flags and shadow OAM, and this slice commits them. Keep that split
713
+ * when you reshape the game. */
714
+ wait_vblank();
715
+ oam_dma_flush();
716
+ commit_vram();
717
+ SCY = scroll_y; /* title resets scroll_y to 0; over freezes it */
718
+ music_tick();
719
+ }
210
720
  }