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,238 +1,777 @@
1
- /* ── shmup.c — SMS vertical-shooter scaffold ────────────────────────
1
+ /* ── shmup.c — Game Gear vertical shooter (complete example game) ────────────
2
2
  *
3
- * Mirrors the NES/Genesis/SNES/GB shmup scaffolds. Player ship + 4
4
- * bullet slots + 4 enemy slots, wave spawner, AABB collisions.
3
+ * A COMPLETE, working game title screen (press START), lives, score +
4
+ * hi-score (cart-RAM save code included see the honesty note at
5
+ * hiscore_save), PSG music + SFX, and the GG/SMS signature LINE INTERRUPT
6
+ * split: a fixed HUD strip over a drifting starfield, with the scroll change
7
+ * timed by the VDP's programmable line counter.
5
8
  *
6
- * SMS has 64 sprites max, 8 per scanline. Player at slot 0,
7
- * bullets 1-4, enemies 5-8. Score kept in WRAM.
9
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
10
+ * very different one. The markers tell you what's what:
11
+ * HARDWARE IDIOM (load-bearing) — dodges a documented GG footgun; reshape
12
+ * your gameplay around it (see TROUBLESHOOTING before changing).
13
+ * GAME LOGIC (clay) — enemy patterns, scoring, tuning, art: reshape freely.
14
+ *
15
+ * SINGLE-PLAYER BY DESIGN (honest note): the Game Gear has ONE controller —
16
+ * its 2P story is the Gear-to-Gear link cable on the EXT port, which needs a
17
+ * second console and can't be emulated in a single emulator instance. So
18
+ * this game is honestly 1P; the console examples (SMS/NES/Genesis/…) carry
19
+ * the 2P modes.
20
+ *
21
+ * THE #1 GG FOOTGUN — THE VISIBLE WINDOW: the GG VDP is the SMS VDP. It
22
+ * renders a full 256×192 frame; the LCD shows only the CENTERED 160×144 of
23
+ * it. Hardware coordinates (sprite OAM, tilemap rows/cols, scanline counts)
24
+ * are all in the FULL 256×192 frame — content placed outside the centered
25
+ * window is rendered "correctly" and is simply never shown. Every SMS habit
26
+ * ports over EXCEPT placement: see the VIS_* block below, which this whole
27
+ * file is written against. (The emulator screenshot is the 160×144 visible
28
+ * crop — "my sprite is at y=10 but invisible" means it's parked in the
29
+ * unseen border, not a render bug.)
30
+ *
31
+ * What depends on what:
32
+ * gg_hw.h / vdp_init.c / load_tiles.c / load_palette.c / sprite_table.c /
33
+ * joypad_read.c — the bundled VDP + input runtime (this file's externs).
34
+ * gg_sfx.{h,c} + gg_music.{h,c} — SN76489 PSG sound layers (music owns
35
+ * PSG ch 2; sfx use ch 0/1 + noise ch 3 — no arbitration needed).
36
+ * gg_crt0.s — boot + vector table. Its $0038 IM-1 handler is the OTHER
37
+ * HALF of the line-interrupt idiom below: it acks the VDP (one status
38
+ * read clears BOTH the frame and line IRQ flags) and returns with
39
+ * ei/reti. Load-bearing; edit with TROUBLESHOOTING open.
40
+ *
41
+ * Frame budget (60fps): SAT upload (192 OUTs) + HUD redraw fit easily in
42
+ * vblank (70 lines) + the 47 scanlines above the split; the whole update
43
+ * (6 bullets × 6 enemies AABB ≈ 36 checks worst case) fits in one frame
44
+ * with room to spare.
8
45
  */
9
46
  #include "gg_hw.h"
10
47
  #include "gg_sfx.h"
48
+ #include "gg_music.h"
11
49
  #include <stdint.h>
12
50
 
13
- extern void gg_vdp_init(void);
14
- extern void gg_vdp_display_on(void);
15
- extern void gg_vdp_set_addr(uint16_t addr, uint8_t prefix);
16
- extern void gg_load_palette(const uint8_t *palette);
17
- extern void gg_load_tiles(uint16_t vram_dest, const uint8_t *src, uint16_t byte_count);
18
- extern void gg_set_tilemap_cell(uint8_t row, uint8_t col, uint8_t tile_idx, uint8_t attr);
19
- extern void gg_vblank_wait(void);
51
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
52
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
53
+ #define GAME_TITLE "PRISM PATROL"
54
+
55
+ extern void gg_vdp_init(void);
56
+ extern void gg_vdp_write_reg(uint8_t reg, uint8_t value);
57
+ extern void gg_vdp_display_on(void);
58
+ extern void gg_vdp_display_off(void);
59
+ extern void gg_vdp_set_addr(uint16_t addr, uint8_t prefix);
60
+ extern void gg_load_palette(const uint8_t *palette);
61
+ extern void gg_load_tiles(uint16_t vram_dest, const uint8_t *src, uint16_t byte_count);
62
+ extern void gg_set_tilemap_cell(uint8_t row, uint8_t col, uint8_t tile_idx, uint8_t attr);
20
63
  extern uint8_t gg_joypad_read(void);
21
- extern void gg_sprite_init(void);
22
- extern void gg_sprite_set(uint8_t slot, uint8_t x, uint8_t y, uint8_t tile);
23
- extern void gg_sat_upload(void);
24
-
25
- #define MAX_BULLETS 4
26
- #define MAX_ENEMIES 4
27
-
28
- /* ── Game Gear visible viewport ──────────────────────────────────────
29
- * Sprite OAM uses SMS HARDWARE coordinates (256x192 space), but the GG
30
- * LCD only shows the CENTER 160x144. Anything outside the box below is
31
- * placed "correctly" in hardware terms yet INVISIBLE on the real screen
32
- * (and in the core's screenshot, which is the visible crop). Keep all
33
- * gameplay sprites inside [VIS_X0..VIS_X1] x [VIS_Y0..VIS_Y1]. */
34
- #define VIS_X0 48 /* left edge of the visible region (hardware X) */
35
- #define VIS_Y0 24 /* top edge (hardware Y) */
36
- #define VIS_X1 207 /* right edge: 48 + 160 - 1 */
37
- #define VIS_Y1 167 /* bottom edge: 24 + 144 - 1 */
38
- #define VIS_W 160
39
- #define VIS_H 144
40
- /* Convert a visible-space coordinate (0..159, 0..143) to the hardware
41
- * coordinate oam/gg_sprite_set wants. Use these if you'd rather think
42
- * in screen space: gg_sprite_set(slot, VIS_TO_HW_X(sx), VIS_TO_HW_Y(sy), tile). */
43
- #define VIS_TO_HW_X(sx) ((uint8_t)((sx) + VIS_X0))
44
- #define VIS_TO_HW_Y(sy) ((uint8_t)((sy) + VIS_Y0))
45
-
46
- #define T_SHIP 0
47
- #define T_BULLET 1
48
- #define T_ENEMY 2
49
-
50
- /* GG palette = 32 entries × 2 bytes (4-4-4 BGR LE): low=(g<<4)|r, high=b.
51
- * Entries 0-15 = BG, 16-31 = SPRITE. (The earlier 32-byte SMS-style array was
52
- * the GG #1 invisible-sprite bug: gg_load_palette reads 64 bytes, so a 32-byte
53
- * array left the sprite palette (entries 16-31) reading past the array = garbage
54
- * = invisible sprites.) */
64
+ extern void gg_sprite_init(void);
65
+ extern void gg_sprite_set(uint8_t slot, uint8_t x, uint8_t y, uint8_t tile);
66
+ extern void gg_sat_upload(void);
67
+
68
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
69
+ * THE GG VISIBLE WINDOW. The VDP frame is 256×192; the LCD shows the
70
+ * centered 160×144. In FULL-FRAME hardware units the window is:
71
+ *
72
+ * pixels: x [48..207] y [24..167] (sprite coords, scanlines)
73
+ * tilemap: col [6..25] row [3..20] (20×18 visible cells)
74
+ *
75
+ * EVERYTHING the hardware takes is full-frame: gg_sprite_set x/y, tilemap
76
+ * row/col, and easy to forget — the LINE COUNTER (VDP R10) counts
77
+ * full-frame scanlines from the top of the 192-line active area, NOT from
78
+ * the top of the LCD. The window's first visible scanline is 24.
79
+ *
80
+ * Requires: nothing these are constants of the machine. Everything below
81
+ * (HUD placement, split line, spawn ranges, movement clamps, text columns)
82
+ * is derived from them; if you reshape the layout, derive from VIS_*, never
83
+ * hardcode SMS-frame numbers. */
84
+ #define VIS_X0 48 /* left edge of the LCD window (hardware X) */
85
+ #define VIS_Y0 24 /* top edge (hardware Y / scanline) */
86
+ #define VIS_X1 207 /* right edge: 48 + 160 - 1 */
87
+ #define VIS_Y1 167 /* bottom edge: 24 + 144 - 1 */
88
+ #define VIS_W 160
89
+ #define VIS_H 144
90
+ #define VIS_COL0 6 /* first visible tilemap column (48 / 8) */
91
+ #define VIS_ROW0 3 /* first visible tilemap row (24 / 8) */
92
+ #define VIS_COLS 20 /* 160 / 8 */
93
+ #define VIS_ROWS 18 /* 144 / 8 */
94
+ /* Think in window space (0..19 cols, 0..17 rows), convert at the call: */
95
+ #define VROW(r) ((uint8_t)((r) + VIS_ROW0))
96
+ #define VCOL(c) ((uint8_t)((c) + VIS_COL0))
97
+
98
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
99
+ * Palette. THE GG's HEADLINE UPGRADE over the SMS: CRAM holds 12-bit
100
+ * 4-4-4 BGR colour (4096 colours) instead of the SMS's 6-bit 2-2-2 (64).
101
+ * The WRITE FORMAT differs too — that's the #2 GG footgun:
102
+ *
103
+ * SMS: 32 entries × 1 byte --BBGGRR
104
+ * GG: 32 entries × 2 bytes little-endian: low byte = GGGGRRRR
105
+ * high byte = ----BBBB
106
+ *
107
+ * So a GG palette array is 64 bytes (entries 0-15 BG, 16-31 sprite). Feeding
108
+ * gg_load_palette a 32-byte SMS-style table reads past the array — the
109
+ * sprite palette loads garbage and every sprite renders invisible (this
110
+ * exact bug shipped in an earlier GG scaffold round). Pack an entry with:
111
+ * low = (g << 4) | r, high = b, each channel 0..15. Most colours below
112
+ * (15-step mints, ambers, duskpinks) have NO 2-2-2 SMS equivalent — that's
113
+ * the 4096-colour panel earning its keep. */
55
114
  static const uint8_t palette[64] = {
56
- /* BG 0-15: 0 = backdrop, 1 = deep space blue, 2 = lighter space blue,
57
- * 3 = star white. */
58
- 0x20,0x02, 0x30,0x04, 0x80,0x08, 0xFF,0x0F, 0,0, 0,0, 0,0, 0,0,
59
- 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
60
- /* SPRITE 16-31: 16=transparent, 17=white, 18=yellow, 19=red */
61
- 0,0, 0xFF,0x0F, 0xFF,0x00, 0x0F,0x00, 0,0, 0,0, 0,0, 0,0,
62
- 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
115
+ /* BG 0-15: 0 = deep-space violet (backdrop/border), 1 = HUD-bar teal,
116
+ * 2 = dim star lavender, 3 = white (text), 4 = nebula dusk-magenta,
117
+ * 5 = nebula indigo */
118
+ 0x01,0x04, 0x50,0x08, 0x78,0x0C, 0xFF,0x0F, 0x17,0x06, 0x22,0x08,
119
+ 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
120
+ /* SPRITE 16-31: 16 = transparent, 17 = ice-mint (ship), 18 = amber
121
+ * (bullet), 19 = magenta / 20 = cyan / 21 = lime — the three enemy
122
+ * "prism" hues. One shared sprite palette on GG/SMS: per-sprite colour
123
+ * means per-TILE colour indices, not per-sprite palettes. */
124
+ 0,0, 0xFB,0x0C, 0xAF,0x01, 0x2E,0x0B, 0xC1,0x0E, 0xE8,0x02,
125
+ 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
63
126
  };
64
127
 
65
- /* Three BG tiles for the starfield, loaded into the BG tile bank at
66
- * $0000:
67
- * tile 0 = deep space (solid colour 1)
68
- * tile 1 = lighter space band (solid colour 2)
69
- * tile 2 = space with a star (mostly colour 1, one colour-3 pixel) */
70
- static const uint8_t bg_tiles[96] = {
71
- /* tile 0 = deep space (colour 1 → plane 0 set) */
72
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
73
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
74
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
75
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
76
- /* tile 1 = lighter band (colour 2 → plane 1 set) */
77
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
78
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
79
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
80
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
81
- /* tile 2 = deep space + a star: row 3 col 3 = colour 3 (planes 0+1),
82
- * everything else colour 1 (plane 0). */
83
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
84
- 0xFF,0x00,0x00,0x00, 0xFF,0x10,0x00,0x00,
85
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
86
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
128
+ /* ── GAME LOGIC (clay) BG tile inventory (BG bank $0000) ───────────────────
129
+ * tile 0 = blank space (colour 0)
130
+ * tiles 1..37 = font: digits 0-9, A-Z, '-' (uploaded 1bpp→4bpp below)
131
+ * tile 38 = dim star (one colour-2 pixel)
132
+ * tile 39 = bright star(one colour-3 pixel + glow)
133
+ * tile 40 = solid HUD bar (colour 1) — the split seam hides in it
134
+ * tile 41 = nebula band A (solid colour 4)
135
+ * tile 42 = nebula band B (solid colour 5) — two band hues keep the
136
+ * field colourful AND off the render-health blank floor */
137
+ #define FONT_BASE 1
138
+ #define BG_STAR 38
139
+ #define BG_BRITE 39
140
+ #define BG_HUDBAR 40
141
+ #define BG_BANDA 41
142
+ #define BG_BANDB 42
143
+
144
+ /* 1bpp font (same glyph set as the NES/SMS/GB examples 0-9, A-Z, '-').
145
+ * Stored 8 bytes/glyph; expanded to the VDP's 32-byte 4bpp tiles at upload
146
+ * (see load_font below), so the ROM carries 296 bytes instead of 1184. */
147
+ static const uint8_t font8[37][8] = {
148
+ /* 0-9 */
149
+ {0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00}, {0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00},
150
+ {0x3C,0x66,0x06,0x0C,0x18,0x30,0x7E,0x00}, {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00},
151
+ {0x0C,0x1C,0x3C,0x6C,0x7E,0x0C,0x0C,0x00}, {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00},
152
+ {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x30,0x30,0x00},
153
+ {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00}, {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00},
154
+ /* A-Z */
155
+ {0x18,0x3C,0x66,0x66,0x7E,0x66,0x66,0x00}, {0x7C,0x66,0x66,0x7C,0x66,0x66,0x7C,0x00},
156
+ {0x3C,0x66,0x60,0x60,0x60,0x66,0x3C,0x00}, {0x78,0x6C,0x66,0x66,0x66,0x6C,0x78,0x00},
157
+ {0x7E,0x60,0x60,0x7C,0x60,0x60,0x7E,0x00}, {0x7E,0x60,0x60,0x7C,0x60,0x60,0x60,0x00},
158
+ {0x3C,0x66,0x60,0x6E,0x66,0x66,0x3E,0x00}, {0x66,0x66,0x66,0x7E,0x66,0x66,0x66,0x00},
159
+ {0x7E,0x18,0x18,0x18,0x18,0x18,0x7E,0x00}, {0x06,0x06,0x06,0x06,0x66,0x66,0x3C,0x00},
160
+ {0x66,0x6C,0x78,0x70,0x78,0x6C,0x66,0x00}, {0x60,0x60,0x60,0x60,0x60,0x60,0x7E,0x00},
161
+ {0x63,0x77,0x7F,0x6B,0x63,0x63,0x63,0x00}, {0x66,0x76,0x7E,0x7E,0x6E,0x66,0x66,0x00},
162
+ {0x3C,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x7C,0x66,0x66,0x7C,0x60,0x60,0x60,0x00},
163
+ {0x3C,0x66,0x66,0x66,0x6A,0x6C,0x36,0x00}, {0x7C,0x66,0x66,0x7C,0x6C,0x66,0x66,0x00},
164
+ {0x3C,0x66,0x60,0x3C,0x06,0x66,0x3C,0x00}, {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00},
165
+ {0x66,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x66,0x66,0x66,0x66,0x66,0x3C,0x18,0x00},
166
+ {0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00}, {0x66,0x66,0x3C,0x18,0x3C,0x66,0x66,0x00},
167
+ {0x66,0x66,0x66,0x3C,0x18,0x18,0x18,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x60,0x7E,0x00},
168
+ /* '-' */
169
+ {0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00},
87
170
  };
88
171
 
89
- /* Paint the visible viewport with a banded starfield so the screen is
90
- * clearly space, not a flat backdrop. Visible name-table region is cols
91
- * 6..25, rows 3..20. BG tile bank is $0000. */
92
- static void draw_starfield(void) {
93
- uint8_t row, col;
94
- for (row = 0; row < 28; row++)
95
- for (col = 0; col < 32; col++) gg_set_tilemap_cell(row, col, 0, 0);
96
- for (row = 3; row <= 20; row++) {
97
- for (col = 6; col <= 25; col++) {
98
- uint8_t t = (row & 2) ? 1 : 0; /* alternating depth bands */
99
- if (((row * 7 + col * 5) & 7) == 0) t = 2; /* sparse stars */
100
- gg_set_tilemap_cell(row, col, t, 0);
172
+ /* Expand 1bpp glyphs into 4bpp tiles as colour 3 (planes 0+1 set).
173
+ * GG/SMS tile rows are 4 bytes: plane0, plane1, plane2, plane3. */
174
+ static void load_font(void) {
175
+ uint8_t g, r, bits;
176
+ gg_vdp_set_addr((uint16_t)(FONT_BASE * 32), VDP_VRAM_WRITE);
177
+ for (g = 0; g < 37; g++) {
178
+ for (r = 0; r < 8; r++) {
179
+ bits = font8[g][r];
180
+ PORT_VDP_DATA = bits; /* plane 0 */
181
+ PORT_VDP_DATA = bits; /* plane 1 colour index 3 */
182
+ PORT_VDP_DATA = 0; /* plane 2 */
183
+ PORT_VDP_DATA = 0; /* plane 3 */
101
184
  }
102
185
  }
103
186
  }
104
187
 
105
- static const uint8_t sprite_tiles[32 * 3] = {
106
- /* T_SHIP diamond using colour 1 (white) */
107
- 0x18,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00,
108
- 0x7E,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
109
- 0xFF,0x00,0x00,0x00, 0x7E,0x00,0x00,0x00,
110
- 0x3C,0x00,0x00,0x00, 0x18,0x00,0x00,0x00,
111
- /* T_BULLET small ball using colour 2 (yellow → plane 1) */
112
- 0x00,0x18,0x00,0x00, 0x00,0x3C,0x00,0x00,
113
- 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00,
114
- 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00,
115
- 0x00,0x18,0x00,0x00, 0x00,0x00,0x00,0x00,
116
- /* T_ENEMY X using colour 3 (redplanes 0+1) */
117
- 0x81,0x81,0x00,0x00, 0x42,0x42,0x00,0x00,
118
- 0x24,0x24,0x00,0x00, 0x18,0x18,0x00,0x00,
119
- 0x18,0x18,0x00,0x00, 0x24,0x24,0x00,0x00,
120
- 0x42,0x42,0x00,0x00, 0x81,0x81,0x00,0x00,
188
+ /* Star + HUD-bar + band tiles (4bpp, 32 bytes each — rows of plane0..3). */
189
+ static const uint8_t deco_tiles[160] = {
190
+ /* BG_STAR: one colour-2 pixel */
191
+ 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00, 0x00,0x10,0x00,0x00, 0x00,0x00,0x00,0x00,
192
+ 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,
193
+ /* BG_BRITE: colour-3 dot with colour-2 glow */
194
+ 0x00,0x00,0x00,0x00, 0x00,0x10,0x00,0x00, 0x10,0x28,0x10,0x00, 0x00,0x10,0x00,0x00,
195
+ 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,
196
+ /* BG_HUDBAR: solid colour 1 — the split seam lands inside this row */
197
+ 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
198
+ 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
199
+ /* BG_BANDA: solid colour 4 (binary 100 plane 2 only) */
200
+ 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00,
201
+ 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00,
202
+ /* BG_BANDB: solid colour 5 (binary 101 → planes 0+2) */
203
+ 0xFF,0x00,0xFF,0x00, 0xFF,0x00,0xFF,0x00, 0xFF,0x00,0xFF,0x00, 0xFF,0x00,0xFF,0x00,
204
+ 0xFF,0x00,0xFF,0x00, 0xFF,0x00,0xFF,0x00, 0xFF,0x00,0xFF,0x00, 0xFF,0x00,0xFF,0x00,
121
205
  };
122
206
 
123
- typedef struct { uint8_t x, y, alive; } Obj;
207
+ /* Sprite tiles (sprite bank $2000 vdp_init's R6=0xFF baseline reads
208
+ * sprite patterns from $2000, so upload there, not $0000). The three enemy
209
+ * tiles use colour indices 3/4/5 — three different prism hues from ONE
210
+ * shared sprite palette. */
211
+ static const uint8_t sprite_tiles[32 * 5] = {
212
+ /* T_SHIP — arrowhead, colour 1 (ice mint, plane 0) */
213
+ 0x18,0x00,0x00,0x00, 0x18,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00,
214
+ 0x7E,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xDB,0x00,0x00,0x00, 0x81,0x00,0x00,0x00,
215
+ /* T_BULLET — slug, colour 2 (amber, plane 1) */
216
+ 0x00,0x18,0x00,0x00, 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00,
217
+ 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00, 0x00,0x18,0x00,0x00, 0x00,0x00,0x00,0x00,
218
+ /* T_ENEMY0 — X fighter, colour 3 (magenta, planes 0+1) */
219
+ 0x81,0x81,0x00,0x00, 0x42,0x42,0x00,0x00, 0x24,0x24,0x00,0x00, 0xFF,0xFF,0x00,0x00,
220
+ 0xFF,0xFF,0x00,0x00, 0x24,0x24,0x00,0x00, 0x42,0x42,0x00,0x00, 0x81,0x81,0x00,0x00,
221
+ /* T_ENEMY1 — hollow diamond, colour 4 (cyan, plane 2) */
222
+ 0x00,0x00,0x18,0x00, 0x00,0x00,0x3C,0x00, 0x00,0x00,0x66,0x00, 0x00,0x00,0xC3,0x00,
223
+ 0x00,0x00,0xC3,0x00, 0x00,0x00,0x66,0x00, 0x00,0x00,0x3C,0x00, 0x00,0x00,0x18,0x00,
224
+ /* T_ENEMY2 — beetle, colour 5 (lime, planes 0+2) */
225
+ 0x3C,0x00,0x3C,0x00, 0x7E,0x00,0x7E,0x00, 0xDB,0x00,0xDB,0x00, 0xFF,0x00,0xFF,0x00,
226
+ 0xFF,0x00,0xFF,0x00, 0xDB,0x00,0xDB,0x00, 0x7E,0x00,0x7E,0x00, 0x3C,0x00,0x3C,0x00,
227
+ };
228
+ #define T_SHIP 0
229
+ #define T_BULLET 1
230
+ #define T_ENEMY0 2 /* + kind (0..2) selects the hue */
231
+
232
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
233
+ * Object pools — fixed slots, no allocation (3.58MHz Z80, 8KB WRAM: a heap
234
+ * buys you nothing). SAT slot map: 0 = ship, 1-6 bullets, 7-12 enemies —
235
+ * 13 of 64 slots; mind the 8-sprites-PER-SCANLINE limit when adding rows
236
+ * of objects (the 9th sprite on a line silently vanishes). */
237
+ #define MAX_BULLETS 6
238
+ #define MAX_ENEMIES 6
239
+ #define START_LIVES 3
240
+ /* HUD layout, in WINDOW rows: row 0 = text (SC / HI / L), row 1 = blank,
241
+ * row 2 = solid bar. The bar row is both the visual divider AND where the
242
+ * split seam hides. HUD_PX is the strip height in scanlines. */
243
+ #define HUD_ROWS 3
244
+ #define HUD_PX (HUD_ROWS * 8)
245
+ /* First playfield scanline / sprite Y below the HUD (full-frame units). */
246
+ #define FIELD_TOP (VIS_Y0 + HUD_PX)
247
+
248
+ static uint8_t bullet_active[MAX_BULLETS];
249
+ static uint8_t bullet_x[MAX_BULLETS];
250
+ static uint8_t bullet_y[MAX_BULLETS];
251
+ static uint8_t enemy_active[MAX_ENEMIES];
252
+ static uint8_t enemy_x[MAX_ENEMIES];
253
+ static uint8_t enemy_y[MAX_ENEMIES];
254
+ static uint8_t enemy_kind[MAX_ENEMIES]; /* 0..2 → prism hue + tile */
124
255
 
125
- static Obj player;
126
- static Obj bullets[MAX_BULLETS];
127
- static Obj enemies[MAX_ENEMIES];
256
+ static uint8_t ship_x, ship_y, ship_alive, fire_cd;
257
+ static uint8_t lives;
128
258
  static uint16_t score;
259
+ static uint16_t hiscore;
129
260
  static uint8_t spawn_timer;
261
+ static uint8_t spawn_seq; /* cycles 0,1,2 → every hue shows up */
262
+ static uint8_t scroll_x; /* starfield drift (split-scrolled below HUD) */
263
+ static uint8_t over_pending; /* defer GAME OVER text to the next vblank */
264
+ static uint8_t hud_dirty; /* score/lives changed → redraw next vblank */
265
+ static uint16_t rng = 0xACE1;
266
+
267
+ /* Game states — the shell every example shares: title → play → game over. */
268
+ #define ST_TITLE 0
269
+ #define ST_PLAY 1
270
+ #define ST_OVER 2
271
+ static uint8_t state;
272
+
273
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
274
+ * LINE-INTERRUPT SPLIT SCROLL — the GG/SMS VDP's signature trick (fixed
275
+ * status bar over a moving field, palette splits, water effects). The VDP
276
+ * has ONE scroll register pair for the whole frame; to keep the HUD fixed
277
+ * while the starfield drifts you change the scroll MID-FRAME. Where the NES
278
+ * needs the sprite-0-hit HACK (park a sprite, busy-poll a status bit, burn
279
+ * scanlines spinning), this VDP has a real, PROGRAMMABLE line interrupt:
280
+ *
281
+ * R10 = N line counter: a down-counter reloaded with N every line
282
+ * outside the active area; underflow → IRQ at scanline N.
283
+ * R0 bit 4 (IE1) line-IRQ enable (already set in vdp_init's 0x36 baseline).
284
+ * R1 bit 5 (IE0) frame(vblank)-IRQ enable (set by gg_vdp_display_on's 0xE0).
285
+ *
286
+ * GG WINDOW CONTRAST (the part SMS habits get wrong): R10 counts FULL-FRAME
287
+ * scanlines — line 0 is the top of the 192-line active area, which is 24
288
+ * lines ABOVE the LCD. The HUD strip starts at the window top (scanline
289
+ * VIS_Y0 = 24) and its last line is VIS_Y0 + HUD_PX - 1 = 47, so SPLIT_LINE
290
+ * is 47 — NOT 23 as it would be on an SMS with the same 3-row HUD. Lines
291
+ * 0..23 are rendered and never shown; they ride along with the HUD's
292
+ * unscrolled region for free.
293
+ *
294
+ * Both IRQs land on the Z80's IM-1 vector at $0038. The crt0's handler does
295
+ * the canonical minimal handshake: push af / in a,($BF) / pop af / ei / reti
296
+ * — reading the status port ACKS the VDP (clears BOTH pending flags; skip
297
+ * the read and the IRQ line stays asserted = interrupt storm), and EI must
298
+ * precede RETI or interrupts stay off forever after the first one.
299
+ *
300
+ * Because the handler does no work, the MAIN loop synchronizes with HALT:
301
+ * the Z80 sleeps until the next interrupt, then we read the V-counter (port
302
+ * $7E) to learn WHICH one woke us — line IRQs only fire during the active
303
+ * area (V < 0xC0 here), the frame IRQ fires at vblank (V ≥ 0xC0).
304
+ *
305
+ * wait_vblank(): sleep until the frame IRQ → do per-frame VRAM work,
306
+ * write R8 = 0 so the HUD strip renders unscrolled.
307
+ * wait_split(): sleep until the line IRQ at scanline 47 (the last line
308
+ * of the solid bar row — any single-line tear from the
309
+ * mid-row write hides inside solid colour) → write
310
+ * R8 = scroll_x; everything below drifts.
311
+ *
312
+ * FOOTGUN — you cannot poll once IRQs are on: gg_vblank_wait() spins on
313
+ * the same status port the ISR reads. The ISR always wins the race (the
314
+ * IRQ fires the instant the flag sets), eats the flag, and the poll loop
315
+ * hangs forever. HALT + V-counter is the IRQ-era replacement.
316
+ *
317
+ * FOOTGUN — why the field drifts HORIZONTALLY: the Y-scroll register (R9)
318
+ * is LATCHED ONCE PER FRAME by the VDP; mid-frame R9 writes do nothing
319
+ * until the next frame, so a "vertical scroll below the HUD" split is
320
+ * impossible on this chip. X-scroll (R8) is sampled per line — that's the
321
+ * one you can change mid-frame. (Vertical motion: animate the star tiles
322
+ * or stream the name table instead.)
323
+ *
324
+ * Requires: R10 programmed, IE1 + IE0 enabled, EI executed once after
325
+ * display-on, the crt0's ack-only ISR, and wait_vblank/wait_split called
326
+ * EVERY frame in this order. R10 reloads after each underflow, so the line
327
+ * IRQ re-fires every HUD_PX+VIS_Y0 lines down the frame (here: 47, 95,
328
+ * 143, 191) — the later wakes harmlessly interrupt game logic (the ISR
329
+ * acks them) and re-halt inside the NEXT wait_vblank(). */
330
+ #define SPLIT_LINE (VIS_Y0 + HUD_PX - 1)
331
+
332
+ static void wait_vblank(void) {
333
+ /* check-first: if game logic overran into vblank, don't sleep a frame */
334
+ while (PORT_V_COUNTER < 0xC0) { __asm__("halt"); }
335
+ gg_vdp_write_reg(8, 0); /* HUD strip renders with X scroll 0 */
336
+ }
337
+
338
+ static void wait_split(void) {
339
+ /* halt-first: vblank work always ends inside vblank (V ≥ 0xC0), and the
340
+ * first wake at V < 0xC0 is the line IRQ at SPLIT_LINE */
341
+ do { __asm__("halt"); } while (PORT_V_COUNTER >= 0xC0);
342
+ gg_vdp_write_reg(8, scroll_x); /* field below the bar drifts */
343
+ }
344
+
345
+ /* ── HARDWARE IDIOM (load-bearing) — hi-score in Sega-mapper cart RAM ────────
346
+ * Same cartridge mapper as the SMS. The control register at $FFFC: bit 3
347
+ * maps the cart's 8KB battery RAM into $8000-$BFFF (bank slot 2). Map →
348
+ * copy → unmap; keep the window short so stray pointer bugs can't shred
349
+ * the save. The block is magic + value + checksum so a never-written cart
350
+ * (all $FF) reads back as "no save" instead of a garbage hi-score.
351
+ *
352
+ * NOTE the $FFFC address: it's IN the WRAM mirror ($C000-$DFFF mirrors at
353
+ * $E000-$FFFF), so this write also lands in WRAM at $DFFC — the mapper
354
+ * just snoops the bus. That's why the crt0 parks SP at $DFF0: the bytes
355
+ * above it ($DFFC-$FFFF) belong to the mapper registers' shadow.
356
+ *
357
+ * HONESTY (verified 2026-06-10 against the bundled gpgx core, same finding
358
+ * as the SMS example): gpgx only instantiates the Sega mapper for ROMs
359
+ * LARGER than 48KB, and this build pipeline emits 32KB ROMs — so
360
+ * in-emulator the $8000 window stays open-bus (reads $FF), the magic check
361
+ * fails, and the game falls back to the WRAM hi-score (in-session only).
362
+ * The code below is still the correct real-hardware idiom and lights up
363
+ * unchanged on a >48KB build or a cart with battery RAM: the load path is
364
+ * self-falsifying, never wrong. (The verify harness proves it end-to-end
365
+ * by padding this exact ROM to 64KB.) */
366
+ #define MAPPER_CTRL (*(volatile uint8_t *)0xFFFC)
367
+ #define CART_RAM ((volatile uint8_t *)0x8000)
368
+
369
+ static void hiscore_save(uint16_t v) {
370
+ uint8_t lo = (uint8_t)(v & 0xFF), hi = (uint8_t)(v >> 8);
371
+ MAPPER_CTRL = 0x08; /* map cart RAM at $8000 */
372
+ CART_RAM[0] = 0x48; /* 'H' */
373
+ CART_RAM[1] = 0x53; /* 'S' */
374
+ CART_RAM[2] = lo;
375
+ CART_RAM[3] = hi;
376
+ CART_RAM[4] = (uint8_t)(lo ^ hi ^ 0xA5);
377
+ MAPPER_CTRL = 0x00; /* back to ROM in slot 2 */
378
+ }
379
+
380
+ static uint16_t hiscore_load(void) {
381
+ uint16_t v = 0;
382
+ MAPPER_CTRL = 0x08;
383
+ if (CART_RAM[0] == 0x48 && CART_RAM[1] == 0x53 &&
384
+ CART_RAM[4] == (uint8_t)(CART_RAM[2] ^ CART_RAM[3] ^ 0xA5)) {
385
+ v = (uint16_t)(CART_RAM[2] | ((uint16_t)CART_RAM[3] << 8));
386
+ }
387
+ MAPPER_CTRL = 0x00;
388
+ return v;
389
+ }
390
+
391
+ /* ── GAME LOGIC (clay) — xorshift16 PRNG (~tens of cycles per call) ── */
392
+ static uint8_t random8(void) {
393
+ uint16_t r = rng;
394
+ r ^= r << 7;
395
+ r ^= r >> 9;
396
+ r ^= r << 8;
397
+ rng = r;
398
+ return (uint8_t)r;
399
+ }
400
+
401
+ /* ── GAME LOGIC (clay) — text via the font tiles ─────────────────────────────
402
+ * These write the name table directly, so call them only during vblank (or
403
+ * with the display off): VRAM access during active display races the VDP's
404
+ * own fetches and drops/garbles bytes on real hardware. Rows/cols here are
405
+ * WINDOW coordinates (0..17 / 0..19) — VROW/VCOL add the border offset, so
406
+ * text can never accidentally land in the unseen 256×192 margin. */
407
+ static uint8_t font_tile(char ch) {
408
+ if (ch >= '0' && ch <= '9') return (uint8_t)(FONT_BASE + (ch - '0'));
409
+ if (ch >= 'A' && ch <= 'Z') return (uint8_t)(FONT_BASE + 10 + (ch - 'A'));
410
+ if (ch == '-') return (uint8_t)(FONT_BASE + 36);
411
+ return 0; /* space → blank tile */
412
+ }
413
+
414
+ static void text_draw(uint8_t vrow, uint8_t vcol, const char *s) {
415
+ uint8_t col = VCOL(vcol);
416
+ while (*s) gg_set_tilemap_cell(VROW(vrow), col++, font_tile(*s++), 0);
417
+ }
418
+
419
+ static void draw_u16(uint8_t vrow, uint8_t vcol, uint16_t v) {
420
+ uint8_t d[5], i;
421
+ for (i = 0; i < 5; i++) { d[i] = (uint8_t)(v % 10); v /= 10; }
422
+ for (i = 0; i < 5; i++)
423
+ gg_set_tilemap_cell(VROW(vrow), (uint8_t)(VCOL(vcol) + i),
424
+ (uint8_t)(FONT_BASE + d[4 - i]), 0);
425
+ }
426
+
427
+ /* ── GAME LOGIC (clay) — HUD: SC sssss HI hhhhh Ln on window row 0.
428
+ * Only 20 columns are visible — the layout below uses 18 of them. An SMS
429
+ * HUD string laid out for 32 columns gets its ends cut off by the border. */
430
+ static void draw_hud_labels(void) {
431
+ text_draw(0, 0, "SC");
432
+ text_draw(0, 8, "HI");
433
+ text_draw(0, 16, "L");
434
+ }
435
+
436
+ static void draw_hud(void) {
437
+ draw_u16(0, 2, score);
438
+ draw_u16(0, 10, hiscore);
439
+ gg_set_tilemap_cell(VROW(0), VCOL(17), (uint8_t)(FONT_BASE + (lives > 9 ? 9 : lives)), 0);
440
+ }
130
441
 
131
- static uint8_t aabb(Obj *a, Obj *b) {
132
- return a->x < b->x + 8 && a->x + 8 > b->x
133
- && a->y < b->y + 8 && a->y + 8 > b->y;
442
+ /* ── GAME LOGIC (clay) screen painters ─────────────────────────────────────
443
+ * Full-screen repaints happen with the DISPLAY OFF (free VRAM access, and a
444
+ * clean cut instead of a visible wipe). While the display is off the frame
445
+ * IRQ doesn't fire — so no halt-based waits in here, or you hang forever.
446
+ *
447
+ * IRQ-RACE FOOTGUN (cost this file a letter of its own title): repaints also
448
+ * run with INTERRUPTS OFF — the di/ei bracket below. Display-off stops the
449
+ * FRAME IRQ but NOT the LINE IRQ (R0's IE1 stays set; the line counter runs
450
+ * every scanline regardless of blanking). The crt0's ISR acks by READING the
451
+ * control port ($BF) — and that read also resets the VDP's two-byte
452
+ * address-latch state machine. If the line IRQ fires between the two bytes
453
+ * of a gg_vdp_set_addr() control-port pair, the second byte is taken as a
454
+ * new first byte, the VRAM address de-syncs, and one cell of your repaint
455
+ * lands somewhere else ("PRISM ATROL"). Per-frame writes inside wait_vblank
456
+ * don't need the bracket: vblank has no line IRQs and the frame IRQ was
457
+ * already consumed by the halt that woke us. */
458
+ /* PERF FOOTGUN (inherited from the SMS example, found the slow way): the
459
+ * obvious per-cell version of this — set_tilemap_cell(r, c, (r*7+c*5) % 11
460
+ * ? ... ) — costs ~35 FRAMES: SDCC's 16-bit `%` is a software-division call
461
+ * and set_tilemap_cell redoes the 4-OUT address setup for every cell. So:
462
+ * set the VRAM address ONCE per row (the data port autoincrements through
463
+ * the row's 64 bytes) and keep the star pattern in add/compare counters.
464
+ * Paints in ~1 frame. We paint all 32 columns (not just the visible 20):
465
+ * the off-window cells scroll INTO view as R8 drifts the field. */
466
+ static void paint_starfield(uint8_t from_row) {
467
+ uint8_t r, c, t, s, q;
468
+ for (r = from_row; r < 24; r++) {
469
+ gg_vdp_set_addr((uint16_t)(0x3800 + (uint16_t)r * 64), VDP_VRAM_WRITE);
470
+ /* s = (r*7) mod 11, q = (r*3) mod 29 — then walk +5 mod 11 / +13 mod 29
471
+ * across the columns (same field as the % expressions, no division). */
472
+ s = (uint8_t)(r * 7); while (s >= 11) s -= 11;
473
+ q = (uint8_t)(r * 3); while (q >= 29) q -= 29;
474
+ for (c = 0; c < 32; c++) {
475
+ /* nebula bands every 4th row, alternating the two hues */
476
+ t = ((r & 3) == 2) ? ((r & 4) ? BG_BANDA : BG_BANDB) : 0;
477
+ if (s == 0) t = BG_STAR;
478
+ if (q == 0) t = BG_BRITE;
479
+ PORT_VDP_DATA = t; /* name-table entry low byte: tile */
480
+ PORT_VDP_DATA = 0; /* high byte: flips/palette/priority */
481
+ s += 5; if (s >= 11) s -= 11;
482
+ q += 13; if (q >= 29) q -= 29;
483
+ }
484
+ }
134
485
  }
135
486
 
136
- static void fire(void) {
487
+ static void paint_title(void) {
488
+ __asm__("di"); /* see IRQ-RACE FOOTGUN above */
489
+ gg_vdp_display_off();
490
+ paint_starfield(0);
491
+ text_draw(4, (uint8_t)((VIS_COLS - (sizeof(GAME_TITLE) - 1)) / 2), GAME_TITLE);
492
+ text_draw(8, 4, "PRESS START");
493
+ text_draw(12, 6, "HI");
494
+ draw_u16(12, 9, hiscore);
495
+ gg_sprite_init(); /* park every sprite off-screen */
496
+ gg_sat_upload();
497
+ gg_vdp_write_reg(8, 0);
498
+ gg_vdp_display_on(); /* re-enables the frame IRQ too */
499
+ __asm__("ei"); /* interrupts back on LAST — regs are set */
500
+ }
501
+
502
+ static void paint_field(void) {
503
+ uint8_t c;
504
+ __asm__("di"); /* see IRQ-RACE FOOTGUN above */
505
+ gg_vdp_display_off();
506
+ for (c = 0; c < 32; c++) {
507
+ gg_set_tilemap_cell(VROW(0), c, 0, 0); /* HUD text row */
508
+ gg_set_tilemap_cell(VROW(1), c, 0, 0); /* breathing room */
509
+ gg_set_tilemap_cell(VROW(2), c, BG_HUDBAR, 0); /* bar = divider + seam */
510
+ }
511
+ paint_starfield(VIS_ROW0 + HUD_ROWS);
512
+ draw_hud_labels();
513
+ draw_hud();
514
+ gg_sprite_init();
515
+ gg_sat_upload();
516
+ gg_vdp_write_reg(8, 0);
517
+ gg_vdp_display_on();
518
+ __asm__("ei");
519
+ }
520
+
521
+ /* ── GAME LOGIC (clay) — pools ── */
522
+ static void fire_bullet(void) {
137
523
  uint8_t i;
138
524
  for (i = 0; i < MAX_BULLETS; i++) {
139
- if (!bullets[i].alive) {
140
- bullets[i].x = player.x;
141
- bullets[i].y = (uint8_t)(player.y - 8);
142
- bullets[i].alive = 1;
525
+ if (!bullet_active[i]) {
526
+ bullet_active[i] = 1;
527
+ bullet_x[i] = ship_x;
528
+ bullet_y[i] = (uint8_t)(ship_y - 8);
529
+ /* PSG ch 0 — gg_music owns ch 2, so sfx and music never collide. */
530
+ sfx_tone(0, 180, 3);
143
531
  return;
144
532
  }
145
533
  }
146
534
  }
147
535
 
148
- static void spawn(void) {
536
+ static void spawn_enemy(void) {
149
537
  uint8_t i;
150
538
  for (i = 0; i < MAX_ENEMIES; i++) {
151
- if (!enemies[i].alive) {
152
- /* Spawn across the VISIBLE width (hardware X in [VIS_X0..VIS_X1-8]). */
153
- enemies[i].x = (uint8_t)(VIS_X0 + ((spawn_timer * 37u) % (VIS_W - 8)));
154
- enemies[i].y = VIS_Y0; /* enter at the top of the visible region */
155
- enemies[i].alive = 1;
539
+ if (!enemy_active[i]) {
540
+ enemy_active[i] = 1;
541
+ /* Spawn across the VISIBLE width only: VIS_X0 + (0..127) + (0..15)
542
+ * lands in [48..190] inside the window, never in the unseen
543
+ * margin (an SMS-style 16..240 spawn range hides a third of the
544
+ * enemies in the border). */
545
+ enemy_x[i] = (uint8_t)(VIS_X0 + (random8() & 0x7F) + (random8() & 0x0F));
546
+ enemy_y[i] = FIELD_TOP + 8; /* just below the HUD bar */
547
+ enemy_kind[i] = spawn_seq; /* cycle the three prism hues */
548
+ spawn_seq++;
549
+ if (spawn_seq >= 3) spawn_seq = 0;
156
550
  return;
157
551
  }
158
552
  }
159
553
  }
160
554
 
161
- void main(void) {
162
- uint8_t prev = 0;
555
+ /* AABB, both boxes 8x8. */
556
+ static uint8_t hits(uint8_t ax, uint8_t ay, uint8_t bx, uint8_t by) {
557
+ uint8_t dx = (ax > bx) ? (uint8_t)(ax - bx) : (uint8_t)(bx - ax);
558
+ uint8_t dy = (ay > by) ? (uint8_t)(ay - by) : (uint8_t)(by - ay);
559
+ return (uint8_t)((dx < 8) && (dy < 8));
560
+ }
163
561
 
164
- gg_vdp_init();
165
- gg_load_palette(palette);
166
- gg_load_tiles(0x0000, bg_tiles, 96); /* BG tiles → BG bank $0000 */
167
- gg_load_tiles(0x2000, sprite_tiles, 32 * 3); /* sprite tiles $2000 */
168
- draw_starfield();
169
-
170
- /* Start the ship centered, near the bottom of the VISIBLE region. */
171
- player.x = (uint8_t)(VIS_X0 + VIS_W / 2 - 4); player.y = (uint8_t)(VIS_Y1 - 16); player.alive = 1;
172
- {
173
- uint8_t i;
174
- for (i = 0; i < MAX_BULLETS; i++) bullets[i].alive = 0;
175
- for (i = 0; i < MAX_ENEMIES; i++) enemies[i].alive = 0;
176
- }
562
+ /* ── GAME LOGIC (clay) — start a run / end a run ── */
563
+ static void start_game(void) {
564
+ uint8_t i;
565
+ for (i = 0; i < MAX_BULLETS; i++) bullet_active[i] = 0;
566
+ for (i = 0; i < MAX_ENEMIES; i++) enemy_active[i] = 0;
567
+ ship_x = VIS_X0 + VIS_W / 2 - 4;
568
+ ship_y = VIS_Y1 - 23;
569
+ ship_alive = 1;
570
+ fire_cd = 0;
571
+ lives = START_LIVES;
177
572
  score = 0;
178
573
  spawn_timer = 0;
574
+ spawn_seq = 0;
575
+ scroll_x = 0;
576
+ over_pending = 0;
577
+ paint_field();
578
+ state = ST_PLAY;
579
+ }
580
+
581
+ static void game_over(void) {
582
+ if (score > hiscore) {
583
+ hiscore = score;
584
+ hiscore_save(hiscore); /* cart RAM (real hardware); WRAM copy is live */
585
+ }
586
+ sfx_noise(20);
587
+ state = ST_OVER;
588
+ over_pending = 1; /* text is drawn next vblank — not mid-frame */
589
+ }
590
+
591
+ /* ── GAME LOGIC (clay) — ship update. Movement clamps to the VISIBLE box:
592
+ * the hardware happily renders a ship at x=10, the LCD just never shows it. */
593
+ static void update_ship(uint8_t pad) {
594
+ if (!ship_alive) return;
595
+ if ((pad & JOY_LEFT) && ship_x > VIS_X0) ship_x = (uint8_t)(ship_x - 2);
596
+ if ((pad & JOY_RIGHT) && ship_x < (VIS_X1 - 7)) ship_x = (uint8_t)(ship_x + 2);
597
+ if ((pad & JOY_UP) && ship_y > (FIELD_TOP + 8)) ship_y = (uint8_t)(ship_y - 2);
598
+ if ((pad & JOY_DOWN) && ship_y < (VIS_Y1 - 7)) ship_y = (uint8_t)(ship_y + 2);
599
+ if ((pad & JOY_B1) && fire_cd == 0) {
600
+ fire_bullet();
601
+ fire_cd = 8;
602
+ }
603
+ if (fire_cd > 0) fire_cd--;
604
+ }
605
+
606
+ /* Stage the SAT shadow for this frame. Inactive slots park at Y=$E0 (below
607
+ * the 192-line area AND below the LCD window). NEVER park at Y=$D0 — that's
608
+ * the SAT terminator: the VDP stops scanning at the first $D0 and every
609
+ * later slot vanishes. */
610
+ static void stage_sprites(void) {
611
+ uint8_t i;
612
+ gg_sprite_set(0, ship_x, ship_alive ? ship_y : 0xE0, T_SHIP);
613
+ for (i = 0; i < MAX_BULLETS; i++)
614
+ gg_sprite_set((uint8_t)(1 + i), bullet_x[i], bullet_active[i] ? bullet_y[i] : 0xE0, T_BULLET);
615
+ for (i = 0; i < MAX_ENEMIES; i++)
616
+ gg_sprite_set((uint8_t)(7 + i), enemy_x[i], enemy_active[i] ? enemy_y[i] : 0xE0,
617
+ (uint8_t)(T_ENEMY0 + enemy_kind[i]));
618
+ }
179
619
 
620
+ void main(void) {
621
+ uint8_t i, pad, prev_pad = 0;
622
+
623
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
624
+ * Init order: VDP regs (display off) → palette → tiles → name table →
625
+ * SAT → R10 → display on (which also enables the frame IRQ) → EI. The
626
+ * one hard rule: EI comes LAST, after every register is in place — the
627
+ * crt0 boots with DI and the FIRST halt would hang forever if interrupts
628
+ * were never enabled. (paint_title's trailing __asm__("ei") IS that final
629
+ * step here — every repaint ends by re-arming interrupts.) */
630
+ gg_vdp_init(); /* R0=0x36 already has IE1 (line IRQ) set */
631
+ gg_load_palette(palette);
632
+ load_font();
633
+ gg_load_tiles((uint16_t)(BG_STAR * 32), deco_tiles, 160);
634
+ gg_load_tiles(0x2000, sprite_tiles, 32 * 5);
180
635
  gg_sprite_init();
181
636
  sfx_init();
182
- gg_vdp_display_on();
637
+ music_init();
638
+ music_play(0);
183
639
 
184
- do {
185
- uint8_t pad;
186
- uint8_t i, j;
187
- gg_vblank_wait();
188
- sfx_update();
640
+ /* R10 = SPLIT_LINE arms the line counter: IRQ at the last bar line —
641
+ * scanline 47 in FULL-FRAME terms (window top 24 + HUD 24 - 1). Set
642
+ * once — it reloads itself every underflow. */
643
+ gg_vdp_write_reg(10, SPLIT_LINE);
189
644
 
190
- /* Stage SAT for the new frame. */
191
- gg_sprite_set(0, player.x, player.y, T_SHIP);
192
- for (i = 0; i < MAX_BULLETS; i++) {
193
- uint8_t by = bullets[i].alive ? bullets[i].y : 0xE0; /* off-screen */
194
- gg_sprite_set((uint8_t)(1 + i), bullets[i].x, by, T_BULLET);
645
+ hiscore = hiscore_load(); /* cart RAM if present else 0 */
646
+ state = ST_TITLE;
647
+ paint_title(); /* …ends with EI: interrupts live now */
648
+
649
+ for (;;) {
650
+ if (state == ST_TITLE) {
651
+ /* ── GAME LOGIC (clay) — title: START begins a run. START is a
652
+ * GG-only input: it lives on port $00 bit 7 (gg_joypad_read merges
653
+ * it into bit 7 of the pad byte), NOT on the SMS pad port $DC. ── */
654
+ wait_vblank();
655
+ sfx_update();
656
+ music_update();
657
+ pad = gg_joypad_read();
658
+ if ((pad & JOY_START) && !(prev_pad & JOY_START)) start_game();
659
+ prev_pad = pad;
660
+ continue;
195
661
  }
196
- for (i = 0; i < MAX_ENEMIES; i++) {
197
- uint8_t ey = enemies[i].alive ? enemies[i].y : 0xE0;
198
- gg_sprite_set((uint8_t)(5 + i), enemies[i].x, ey, T_ENEMY);
662
+
663
+ if (state == ST_OVER) {
664
+ /* Freeze the final frame; START returns to the title. */
665
+ wait_vblank();
666
+ if (over_pending) { /* deferred draw — now we're in vblank */
667
+ over_pending = 0;
668
+ text_draw(8, 5, "GAME OVER");
669
+ draw_hud(); /* show the (possibly new) hi-score */
670
+ }
671
+ wait_split(); /* keep the HUD/field split alive */
672
+ sfx_update();
673
+ music_update();
674
+ pad = gg_joypad_read();
675
+ if ((pad & JOY_START) && !(prev_pad & JOY_START)) {
676
+ state = ST_TITLE;
677
+ paint_title();
678
+ }
679
+ prev_pad = pad;
680
+ continue;
199
681
  }
200
- gg_sat_upload();
201
682
 
683
+ /* ── ST_PLAY ─────────────────────────────────────────────────────────
684
+ * Frame shape: [vblank: SAT + HUD writes, R8=0] → [line IRQ at the bar:
685
+ * R8=scroll] → [rest of frame: game logic]. VRAM traffic stays inside
686
+ * vblank; logic runs while the VDP draws the field.
687
+ *
688
+ * BUDGET FOOTGUN: everything between wait_vblank() and wait_split()
689
+ * must finish before the line IRQ at scanline 47 — vblank (70 lines) +
690
+ * the 47 lines above the split ≈ 27k cycles. (The GG split budget is
691
+ * BIGGER than the SMS's: the 24 never-shown border lines are free
692
+ * cycles.) The SAT upload eats ~7k of that. An unconditional draw_hud()
693
+ * here (10 software 16-bit divisions for the digits) is the classic
694
+ * budget-blower: miss the line and the seam slips to a later reload of
695
+ * the line counter, and the top of the starfield renders unscrolled in
696
+ * jittery stripes. Hence the dirty flag — the HUD only redraws on the
697
+ * frame after the score/lives actually changed. */
698
+ wait_vblank();
699
+ gg_sat_upload(); /* shadow SAT staged at end of last frame */
700
+ if (hud_dirty) {
701
+ hud_dirty = 0;
702
+ draw_hud();
703
+ }
704
+ sfx_update();
705
+ music_update();
706
+ wait_split(); /* the line-interrupt split — every frame */
707
+
708
+ /* ── GAME LOGIC (clay) from here down ── */
202
709
  pad = gg_joypad_read();
203
- /* Clamp movement to the VISIBLE box so the ship never slides off-screen. */
204
- if (pad & JOY_LEFT && player.x > VIS_X0) player.x = (uint8_t)(player.x - 2);
205
- if (pad & JOY_RIGHT && player.x < VIS_X1 - 8) player.x = (uint8_t)(player.x + 2);
206
- if (pad & JOY_UP && player.y > VIS_Y0) player.y = (uint8_t)(player.y - 2);
207
- if (pad & JOY_DOWN && player.y < VIS_Y1 - 8) player.y = (uint8_t)(player.y + 2);
208
- if ((pad & JOY_B1) && !(prev & JOY_B1)) { fire(); sfx_tone(0, 200, 4); }
209
- prev = pad;
710
+ update_ship(pad);
711
+
712
+ /* Starfield drift (the split keeps the HUD strip out of it). */
713
+ spawn_timer++;
714
+ if ((spawn_timer & 3) == 0) scroll_x++;
210
715
 
211
716
  for (i = 0; i < MAX_BULLETS; i++) {
212
- if (!bullets[i].alive) continue;
213
- if (bullets[i].y < VIS_Y0 + 4) { bullets[i].alive = 0; continue; }
214
- bullets[i].y = (uint8_t)(bullets[i].y - 4);
717
+ if (!bullet_active[i]) continue;
718
+ if (bullet_y[i] < FIELD_TOP + 4) bullet_active[i] = 0;
719
+ else bullet_y[i] = (uint8_t)(bullet_y[i] - 4);
215
720
  }
721
+
216
722
  for (i = 0; i < MAX_ENEMIES; i++) {
217
- if (!enemies[i].alive) continue;
218
- enemies[i].y = (uint8_t)(enemies[i].y + 1);
219
- if (enemies[i].y >= VIS_Y1) enemies[i].alive = 0; /* off the visible bottom */
723
+ if (!enemy_active[i]) continue;
724
+ if (enemy_y[i] >= VIS_Y1 - 7) enemy_active[i] = 0; /* off the window bottom */
725
+ else enemy_y[i]++;
220
726
  }
221
- spawn_timer = (uint8_t)(spawn_timer + 1);
222
- if (spawn_timer >= 28) { spawn_timer = 0; spawn(); }
223
727
 
224
- for (i = 0; i < MAX_BULLETS; i++) {
225
- if (!bullets[i].alive) continue;
226
- for (j = 0; j < MAX_ENEMIES; j++) {
227
- if (!enemies[j].alive) continue;
228
- if (aabb(&bullets[i], &enemies[j])) {
229
- bullets[i].alive = 0;
230
- enemies[j].alive = 0;
231
- if (score < 65500) score = (uint16_t)(score + 10);
232
- sfx_noise(8);
233
- break;
728
+ /* Bullets enemies. */
729
+ {
730
+ uint8_t b, e;
731
+ for (b = 0; b < MAX_BULLETS; b++) {
732
+ if (!bullet_active[b]) continue;
733
+ for (e = 0; e < MAX_ENEMIES; e++) {
734
+ if (!enemy_active[e]) continue;
735
+ if (hits(bullet_x[b], bullet_y[b], enemy_x[e], enemy_y[e])) {
736
+ bullet_active[b] = 0;
737
+ enemy_active[e] = 0;
738
+ score++;
739
+ hud_dirty = 1;
740
+ sfx_noise(6);
741
+ break;
742
+ }
743
+ }
744
+ }
745
+ }
746
+
747
+ /* Enemies ↔ ship. */
748
+ {
749
+ uint8_t e;
750
+ for (e = 0; e < MAX_ENEMIES; e++) {
751
+ if (!enemy_active[e]) continue;
752
+ if (ship_alive && hits(enemy_x[e], enemy_y[e], ship_x, ship_y)) {
753
+ enemy_active[e] = 0;
754
+ sfx_noise(14);
755
+ if (lives > 0) lives--;
756
+ hud_dirty = 1;
757
+ if (lives == 0) {
758
+ game_over();
759
+ } else {
760
+ /* respawn knockback */
761
+ ship_x = VIS_X0 + VIS_W / 2 - 4;
762
+ ship_y = VIS_Y1 - 23;
763
+ }
234
764
  }
235
765
  }
236
766
  }
237
- } while (1);
767
+
768
+ if (spawn_timer >= 32) {
769
+ spawn_timer = 0;
770
+ spawn_enemy();
771
+ }
772
+
773
+ /* Stage the SAT shadow NOW (RAM only — cheap, any time); the actual
774
+ * VRAM upload waits for the next vblank at the top of the loop. */
775
+ stage_sprites();
776
+ }
238
777
  }