romdevtools 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/AGENTS.md +53 -43
  2. package/CHANGELOG.md +91 -0
  3. package/README.md +3 -3
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1225 -332
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +906 -275
  8. package/examples/atari2600/templates/shmup.asm +1031 -239
  9. package/examples/atari2600/templates/sports.asm +1135 -253
  10. package/examples/atari7800/templates/platformer.c +991 -156
  11. package/examples/atari7800/templates/puzzle.c +1091 -148
  12. package/examples/atari7800/templates/racing.c +952 -124
  13. package/examples/atari7800/templates/shmup.c +812 -134
  14. package/examples/atari7800/templates/sports.c +820 -184
  15. package/examples/c64/templates/platformer.c +879 -164
  16. package/examples/c64/templates/puzzle.c +855 -178
  17. package/examples/c64/templates/racing.c +873 -97
  18. package/examples/c64/templates/shmup.c +757 -161
  19. package/examples/c64/templates/sports.c +755 -100
  20. package/examples/gb/templates/platformer.c +841 -179
  21. package/examples/gb/templates/puzzle.c +986 -246
  22. package/examples/gb/templates/racing.c +754 -174
  23. package/examples/gb/templates/shmup.c +673 -175
  24. package/examples/gb/templates/sports.c +790 -159
  25. package/examples/gba/templates/platformer.c +626 -165
  26. package/examples/gba/templates/puzzle.c +519 -269
  27. package/examples/gba/templates/racing.c +511 -206
  28. package/examples/gba/templates/shmup.c +564 -179
  29. package/examples/gba/templates/sports.c +454 -174
  30. package/examples/gbc/templates/platformer.c +944 -180
  31. package/examples/gbc/templates/puzzle.c +363 -109
  32. package/examples/gbc/templates/racing.c +884 -180
  33. package/examples/gbc/templates/shmup.c +821 -185
  34. package/examples/gbc/templates/sports.c +870 -162
  35. package/examples/genesis/templates/platformer.c +747 -129
  36. package/examples/genesis/templates/puzzle.c +694 -261
  37. package/examples/genesis/templates/racing.c +726 -203
  38. package/examples/genesis/templates/shmup.c +535 -142
  39. package/examples/genesis/templates/sports.c +495 -158
  40. package/examples/gg/templates/platformer.c +880 -215
  41. package/examples/gg/templates/puzzle.c +875 -216
  42. package/examples/gg/templates/racing.c +915 -172
  43. package/examples/gg/templates/shmup.c +714 -191
  44. package/examples/gg/templates/sports.c +732 -129
  45. package/examples/lynx/templates/platformer.c +604 -69
  46. package/examples/lynx/templates/puzzle.c +498 -158
  47. package/examples/lynx/templates/racing.c +538 -102
  48. package/examples/lynx/templates/shmup.c +458 -131
  49. package/examples/lynx/templates/sports.c +496 -72
  50. package/examples/msx/platformer/main.c +649 -162
  51. package/examples/msx/puzzle/main.c +742 -240
  52. package/examples/msx/racing/main.c +669 -178
  53. package/examples/msx/shmup/main.c +460 -178
  54. package/examples/msx/sports/main.c +592 -126
  55. package/examples/nes/templates/platformer.c +589 -171
  56. package/examples/nes/templates/puzzle.c +563 -242
  57. package/examples/nes/templates/racing.c +502 -208
  58. package/examples/nes/templates/shmup.c +339 -145
  59. package/examples/nes/templates/sports.c +341 -183
  60. package/examples/pce/platformer/main.c +874 -205
  61. package/examples/pce/puzzle/main.c +802 -287
  62. package/examples/pce/racing/main.c +783 -208
  63. package/examples/pce/shmup/main.c +638 -212
  64. package/examples/pce/sports/main.c +586 -169
  65. package/examples/porting-across-platforms/README.md +1 -1
  66. package/examples/sms/templates/platformer.c +762 -177
  67. package/examples/sms/templates/puzzle.c +752 -212
  68. package/examples/sms/templates/racing.c +808 -145
  69. package/examples/sms/templates/shmup.c +599 -162
  70. package/examples/sms/templates/sports.c +630 -122
  71. package/examples/snes/templates/music_demo.c +7 -0
  72. package/examples/snes/templates/platformer-data.asm +123 -24
  73. package/examples/snes/templates/platformer-hdr.asm +57 -0
  74. package/examples/snes/templates/platformer.c +586 -165
  75. package/examples/snes/templates/puzzle-data.asm +116 -21
  76. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  77. package/examples/snes/templates/puzzle.c +614 -235
  78. package/examples/snes/templates/racing-data.asm +390 -32
  79. package/examples/snes/templates/racing-hdr.asm +57 -0
  80. package/examples/snes/templates/racing.c +807 -196
  81. package/examples/snes/templates/shmup-data.asm +87 -29
  82. package/examples/snes/templates/shmup-hdr.asm +57 -0
  83. package/examples/snes/templates/shmup.c +459 -198
  84. package/examples/snes/templates/sports-data.asm +48 -2
  85. package/examples/snes/templates/sports-hdr.asm +57 -0
  86. package/examples/snes/templates/sports.c +414 -163
  87. package/package.json +12 -12
  88. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  89. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  90. package/src/cores/wasm/fceumm_libretro.js +1 -1
  91. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  92. package/src/cores/wasm/gambatte_libretro.js +1 -1
  93. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  94. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  95. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  96. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  97. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  98. package/src/cores/wasm/handy_libretro.js +1 -1
  99. package/src/cores/wasm/handy_libretro.wasm +0 -0
  100. package/src/cores/wasm/mgba_libretro.js +1 -1
  101. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  102. package/src/cores/wasm/prosystem_libretro.js +1 -1
  103. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  104. package/src/cores/wasm/snes9x_libretro.js +1 -1
  105. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  106. package/src/cores/wasm/stella2014_libretro.js +1 -1
  107. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  108. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  109. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  110. package/src/host/LibretroHost.js +84 -8
  111. package/src/http/tool-registry.js +11 -11
  112. package/src/mcp/tools/cheats.js +2 -1
  113. package/src/mcp/tools/frame.js +3 -2
  114. package/src/mcp/tools/index.js +3 -3
  115. package/src/mcp/tools/input.js +5 -4
  116. package/src/mcp/tools/lifecycle.js +6 -4
  117. package/src/mcp/tools/memory.js +131 -24
  118. package/src/mcp/tools/platform-docs.js +1 -1
  119. package/src/mcp/tools/preview-tile.js +6 -2
  120. package/src/mcp/tools/project.js +1098 -130
  121. package/src/mcp/tools/record.js +6 -7
  122. package/src/mcp/tools/rom-id.js +5 -1
  123. package/src/mcp/tools/run-until.js +12 -4
  124. package/src/mcp/tools/snippets.js +6 -6
  125. package/src/mcp/tools/sprite-pipeline.js +14 -2
  126. package/src/mcp/tools/state.js +2 -1
  127. package/src/mcp/tools/tile-inspect.js +8 -1
  128. package/src/mcp/tools/toolchain.js +12 -1
  129. package/src/mcp/tools/watch-memory.js +53 -10
  130. package/src/observer/bus.js +73 -0
  131. package/src/observer/livestream.html +4 -2
  132. package/src/observer/tool-wrap.js +17 -14
  133. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +32 -3
  134. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  135. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  136. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  137. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  138. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  139. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  140. package/src/platforms/gb/lib/c/README.md +10 -11
  141. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  142. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  143. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  144. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  145. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  146. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  147. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  148. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  149. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  150. package/src/platforms/gbc/lib/c/README.md +10 -11
  151. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  152. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  153. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  154. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  155. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  156. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  157. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  158. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  159. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  160. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  161. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  162. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  163. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  164. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  165. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  166. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  167. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  168. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  169. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  170. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  171. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  172. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  173. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  174. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  175. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  176. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  177. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  178. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  179. package/src/toolchains/index.js +27 -11
@@ -1,109 +1,129 @@
1
- /* ── shmup.c — NES vertical-scrolling shooter scaffold ───────────────
1
+ /* ── shmup.c — NES vertical shooter (complete example game) ──────────────────
2
2
  *
3
- * A complete, runnable vertical shmup baseline. Includes:
4
- * - Player ship (8x8 sprite) d-pad moves, A fires
5
- * - 4 bullet slots + 4 enemy slots (object pools, no malloc)
6
- * - Enemy wave spawner: one enemy per ~32 frames from the top
7
- * - Linear bullet/enemy movement + AABB collision (8x8 vs 8x8)
8
- * - Score (16-bit, packed BCD-ish) in zero page; not yet rendered
3
+ * A COMPLETE, working game title screen, 1P and 2P co-op modes, lives,
4
+ * score + persistent hi-score (battery SRAM), music + SFX, and the NES's
5
+ * signature sprite-0-hit split (fixed HUD bar over a drifting starfield).
9
6
  *
10
- * The scaffold is GENRE-shapedgameplay-specific tuning (enemy
11
- * patterns, scoring rules, art) is yours to fill in. Tile data here
12
- * is intentionally minimal (3 outlined boxes for ship/bullet/enemy);
13
- * replace with your real sprites.
7
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game even a
8
+ * very different one. The markers tell you what's what:
9
+ * HARDWARE IDIOM (load-bearing) dodges a documented NES footgun; reshape
10
+ * your gameplay around it (see TROUBLESHOOTING before changing).
11
+ * GAME LOGIC (clay) — enemy patterns, scoring, tuning, art: reshape freely.
14
12
  *
15
- * Frame budget (NTSC): 60 fps × ~3 collision checks per bullet =
16
- * 4 bullets × 4 enemies = 16 checks/frame. Well under the cycle budget.
13
+ * What depends on what:
14
+ * nes_runtime.{h,c} rendering/input/sound/text/hi-score library.
15
+ * chr-ram-runtime.crt0.s — boot + NMI + iNES header (BATTERY bit feeds
16
+ * hiscore_load/save). Load-bearing; edit with TROUBLESHOOTING open.
17
17
  *
18
- * Read TROUBLESHOOTING.md before editing the two-vblank PPU warm-up,
19
- * shadow_oam staging-before-NMI-fires, and palette-at-$3F00 are the
20
- * NES gotchas you'll trip on first.
18
+ * Frame budget (NTSC, 60fps): the whole update (2 ships × 6 bullets × 6
19
+ * enemies AABB 72 checks worst case) is comfortably inside one frame.
21
20
  */
22
21
 
23
22
  #include "nes_runtime.h"
24
23
 
25
- /* ── Tile data: ship (1), bullet (2), enemy (3) ──────────────────── */
26
- /* Each 8x8 tile is 16 bytes: 8 plane-0 bytes then 8 plane-1 bytes.
27
- * Plane-0 only sets give color index 1 (palette entry 1). */
24
+ /* The title screen renders this examples({op:'fork'}) stamps your game's
25
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
26
+ #define GAME_TITLE "NOVA SENTRY"
27
+
28
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
29
+ * Tile art. Each 8x8 tile = 16 bytes: 8 plane-0 rows then 8 plane-1 rows
30
+ * (2bpp — plane0-only pixels use colour 1, both planes = colour 3). */
28
31
  static const uint8_t tile_blank[16] = { 0 };
29
32
  static const uint8_t tile_ship[16] = {
30
- 0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, /* plane 0 */
33
+ 0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x3C, 0x18,
31
34
  0, 0, 0, 0, 0, 0, 0, 0,
32
35
  };
33
36
  static const uint8_t tile_bullet[16] = {
34
- 0x00, 0x18, 0x3C, 0x3C, 0x3C, 0x3C, 0x18, 0x00, /* plane 0 */
37
+ 0x00, 0x18, 0x3C, 0x3C, 0x3C, 0x3C, 0x18, 0x00,
35
38
  0, 0, 0, 0, 0, 0, 0, 0,
36
39
  };
37
40
  static const uint8_t tile_enemy[16] = {
38
- 0x81, 0x42, 0x24, 0xFF, 0xFF, 0x24, 0x42, 0x81, /* plane 0 — spider-ish */
41
+ 0x81, 0x42, 0x24, 0xFF, 0xFF, 0x24, 0x42, 0x81,
39
42
  0, 0, 0, 0, 0, 0, 0, 0,
40
43
  };
41
- /* BG starfield tiles painted across the whole nametable so the screen
42
- * reads as a real "space" scene on boot instead of flat black. They live in
43
- * the BACKGROUND pattern table ($1000), separate from the sprite tiles above
44
- * (the runtime puts BG at $1000, sprites at $0000).
45
- *
46
- * BG_DUST — a faint checkerboard "space dust" that tiles seamlessly; the
47
- * base layer that covers the whole field so it never reads blank.
48
- * BG_STAR — three small stars (colour 1) sprinkled over the dust.
49
- * BG_BRITE — a single bright + star (colour 2) for the rare close star. */
44
+ /* Starfield BG tiles (BACKGROUND pattern table $1000 separate from the
45
+ * sprite table at $0000; the runtime's PPUCTRL setup makes that split). */
50
46
  static const uint8_t tile_dust[16] = {
51
- 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, /* plane 0: checker (idx 1) */
47
+ 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA,
52
48
  0, 0, 0, 0, 0, 0, 0, 0,
53
49
  };
54
50
  static const uint8_t tile_star[16] = {
55
- 0x00, 0x08, 0x00, 0x42, 0x00, 0x00, 0x20, 0x01, /* plane 0: four small stars */
51
+ 0x00, 0x08, 0x00, 0x42, 0x00, 0x00, 0x20, 0x01,
56
52
  0, 0, 0, 0, 0, 0, 0, 0,
57
53
  };
58
54
  static const uint8_t tile_brite[16] = {
59
- 0x00, 0x00, 0x10, 0x38, 0x10, 0x00, 0x00, 0x00, /* plane 0: + arms (idx ... ) */
60
- 0x00, 0x00, 0x10, 0x38, 0x10, 0x00, 0x00, 0x00, /* plane 1 set too → colour 3 */
55
+ 0x00, 0x00, 0x10, 0x38, 0x10, 0x00, 0x00, 0x00,
56
+ 0x00, 0x00, 0x10, 0x38, 0x10, 0x00, 0x00, 0x00,
61
57
  };
62
- #define BG_DUST 1 /* BG tile index 1 uploaded to $1010 */
63
- #define BG_STAR 2 /* BG tile index 2 uploaded to $1020 */
64
- #define BG_BRITE 3 /* BG tile index 3 → uploaded to $1030 */
58
+ /* A solid tile for the HUD bar sprite 0 must overlap an OPAQUE BG pixel
59
+ * for the sprite-0 hit to fire (see the split idiom below). */
60
+ static const uint8_t tile_hudbar[16] = {
61
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
62
+ 0, 0, 0, 0, 0, 0, 0, 0,
63
+ };
64
+ #define BG_DUST 1
65
+ #define BG_STAR 2
66
+ #define BG_BRITE 3
67
+ #define BG_HUDBAR 4
65
68
 
66
- /* ── Palette ─────────────────────────────────────────────────────── */
67
69
  static const uint8_t palette[32] = {
68
- /* BG0: backdrop near-black, star colour = dim white */
69
- 0x0F, 0x10, 0x20, 0x30,
70
+ /* BG: near-black backdrop, dim white stars; pal 1 = HUD (dark bar) */
70
71
  0x0F, 0x10, 0x20, 0x30,
72
+ 0x0F, 0x00, 0x10, 0x30,
71
73
  0x0F, 0x10, 0x20, 0x30,
72
74
  0x0F, 0x10, 0x20, 0x30,
73
- /* Sprite palettes 0..3 */
74
- 0x0F, 0x21, 0x32, 0x30, /* sp0: ship — blue/white */
75
- 0x0F, 0x37, 0x27, 0x16, /* sp1: bullet — yellow */
76
- 0x0F, 0x16, 0x06, 0x36, /* sp2: enemy — red */
75
+ /* Sprites: ship1 blue/white, bullets yellow, enemies red, ship2 green */
76
+ 0x0F, 0x21, 0x32, 0x30,
77
+ 0x0F, 0x37, 0x27, 0x16,
78
+ 0x0F, 0x16, 0x06, 0x36,
77
79
  0x0F, 0x2A, 0x1A, 0x0A,
78
80
  };
79
81
 
80
- /* ── Object pools ────────────────────────────────────────────────── */
81
- #define MAX_BULLETS 4
82
- #define MAX_ENEMIES 4
82
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
83
+ * Object pools — fixed slots, no allocation (there is no heap worth having
84
+ * on a 1.79MHz CPU with 2KB of work RAM). */
85
+ #define MAX_BULLETS 6
86
+ #define MAX_ENEMIES 6
83
87
  #define TILE_SHIP 1
84
88
  #define TILE_BULLET 2
85
89
  #define TILE_ENEMY 3
86
- #define SHIP_PAL 0
90
+ #define SHIP1_PAL 0
91
+ #define SHIP2_PAL 3
87
92
  #define BULLET_PAL 1
88
93
  #define ENEMY_PAL 2
94
+ #define START_LIVES 3
95
+ /* HUD layout (mind the OVERSCAN: most NTSC displays/cores crop the top 8
96
+ * scanlines, so nametable row 0 is invisible — never put text there):
97
+ * row 0 — blank (cropped by overscan)
98
+ * row 1 — HUD text (LV / SC / HI)
99
+ * row 2 — solid bar: the visual divider AND sprite 0's opaque anchor
100
+ * row 3+ — the scrolling playfield */
101
+ #define HUD_ROWS 3
89
102
 
90
103
  static uint8_t bullet_active[MAX_BULLETS];
91
104
  static uint8_t bullet_x[MAX_BULLETS];
92
105
  static uint8_t bullet_y[MAX_BULLETS];
93
-
94
106
  static uint8_t enemy_active[MAX_ENEMIES];
95
107
  static uint8_t enemy_x[MAX_ENEMIES];
96
108
  static uint8_t enemy_y[MAX_ENEMIES];
97
109
 
98
- /* ── Game state ──────────────────────────────────────────────────── */
99
- static uint8_t ship_x = 120;
100
- static uint8_t ship_y = 200;
101
- static uint8_t fire_cooldown = 0;
102
- static uint8_t spawn_timer = 0;
103
- static uint16_t score = 0;
104
- static uint16_t rng = 0xACE1; /* simple xorshift seed */
110
+ /* Players: index 0 = P1, 1 = P2 (only in 2P co-op mode). */
111
+ static uint8_t ship_x[2], ship_y[2], ship_alive[2], fire_cd[2];
112
+ static uint8_t two_player; /* mode chosen on the title screen */
113
+ static uint8_t lives; /* shared pool in co-op (arcade style) */
114
+ static uint16_t score;
115
+ static uint16_t hiscore;
116
+ static uint8_t spawn_timer;
117
+ static uint8_t scroll_x; /* starfield drift (split-scrolled below HUD) */
118
+ static uint16_t rng = 0xACE1;
119
+
120
+ /* Game states — the shell every example shares: title → play → game over. */
121
+ #define ST_TITLE 0
122
+ #define ST_PLAY 1
123
+ #define ST_OVER 2
124
+ static uint8_t state;
105
125
 
106
- /* xorshift16cheap LFSR-quality randomness, ~$10 cycles per call. */
126
+ /* ── GAME LOGIC (clay) xorshift16 PRNG (~tens of cycles per call) ── */
107
127
  static uint8_t random8(void) {
108
128
  uint16_t r = rng;
109
129
  r ^= r << 7;
@@ -113,13 +133,14 @@ static uint8_t random8(void) {
113
133
  return (uint8_t)r;
114
134
  }
115
135
 
116
- static void fire_bullet(void) {
136
+ static void fire_bullet(uint8_t p) {
117
137
  uint8_t i;
118
138
  for (i = 0; i < MAX_BULLETS; i++) {
119
139
  if (!bullet_active[i]) {
120
140
  bullet_active[i] = 1;
121
- bullet_x[i] = ship_x;
122
- bullet_y[i] = ship_y - 4;
141
+ bullet_x[i] = ship_x[p];
142
+ bullet_y[i] = ship_y[p] - 4;
143
+ sound_play_tone(0, 0x100, 6, 4);
123
144
  return;
124
145
  }
125
146
  }
@@ -130,127 +151,276 @@ static void spawn_enemy(void) {
130
151
  for (i = 0; i < MAX_ENEMIES; i++) {
131
152
  if (!enemy_active[i]) {
132
153
  enemy_active[i] = 1;
133
- enemy_x[i] = 16 + (random8() & 0x7F); /* 16..143 */
134
- enemy_y[i] = 0;
154
+ enemy_x[i] = 16 + (random8() & 0x7F);
155
+ enemy_y[i] = HUD_ROWS * 8 + 8; /* spawn below the HUD bar */
135
156
  return;
136
157
  }
137
158
  }
138
159
  }
139
160
 
140
- /* AABB 8x8 vs 8x8 — both objects assumed 8-pixel boxes. */
161
+ /* AABB, both boxes 8x8. */
141
162
  static uint8_t hits(uint8_t ax, uint8_t ay, uint8_t bx, uint8_t by) {
142
- uint8_t dx, dy;
143
- dx = (ax > bx) ? (ax - bx) : (bx - ax);
144
- dy = (ay > by) ? (ay - by) : (by - ay);
163
+ uint8_t dx = (ax > bx) ? (ax - bx) : (bx - ax);
164
+ uint8_t dy = (ay > by) ? (ay - by) : (by - ay);
145
165
  return (dx < 8) && (dy < 8);
146
166
  }
147
167
 
168
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
169
+ * Sprite-0-hit split scroll — THE classic NES technique (the fixed
170
+ * status bar over a scrolling field in countless NES classics). The PPU has ONE scroll for the whole
171
+ * frame; to keep the HUD fixed while the playfield scrolls, you change the
172
+ * scroll MID-FRAME, and sprite 0 is your timing signal:
173
+ *
174
+ * 1. Sprite 0 (the FIRST sprite staged each frame) sits inside the HUD,
175
+ * overlapping an OPAQUE background pixel (our solid HUD bar tile).
176
+ * 2. The NMI commits scroll (0,0) at vblank — the HUD renders unscrolled.
177
+ * 3. After ppu_wait_nmi(), spin on PPUSTATUS bit 6: it sets at the exact
178
+ * pixel where sprite 0's opaque pixel overlaps opaque background.
179
+ * 4. THEN write the playfield scroll to PPUSCROLL — everything below the
180
+ * HUD renders with the new scroll.
181
+ *
182
+ * Requires: sprite 0 staged FIRST (oam_spr call order = OAM order), an
183
+ * opaque BG pixel under it, ppu_scroll(0,0) left as the frame scroll, and
184
+ * this poll running EVERY frame (miss a frame and the field jumps).
185
+ * Mid-frame X-scroll needs only the two PPUSCROLL writes below. (Mid-frame
186
+ * Y needs the 4-write $2006/$2005 dance — see TROUBLESHOOTING before
187
+ * attempting; X covers the HUD-over-scrolling-field pattern.)
188
+ * The spin costs a few scanlines of CPU each frame — budget for it. */
189
+ #define PPUSTATUS_REG (*(volatile uint8_t *)0x2002)
190
+ #define PPUSCROLL_REG (*(volatile uint8_t *)0x2005)
191
+ static void split_after_hud(void) {
192
+ uint8_t timeout = 240;
193
+ /* FOOTGUN: the hit flag from the frame JUST RENDERED stays set all the
194
+ * way through vblank — it only clears at the next pre-render line. We're
195
+ * called right after ppu_wait_nmi() (i.e. inside vblank), so polling for
196
+ * "set" alone can exit INSTANTLY on the stale flag and the PPUSCROLL
197
+ * write lands during vblank — scrolling the WHOLE next frame, HUD
198
+ * included (a subtle shear that looks like HUD drift). The classic fix
199
+ * is the two-phase poll: wait for the stale flag to CLEAR (pre-render),
200
+ * then wait for THIS frame's hit to SET. */
201
+ while (PPUSTATUS_REG & 0x40) {
202
+ if (--timeout == 0) return; /* flag stuck: bail, keep scroll (0,0) */
203
+ }
204
+ timeout = 240;
205
+ while (!(PPUSTATUS_REG & 0x40)) {
206
+ if (--timeout == 0) return; /* rendering off / sprite-0 missing: bail */
207
+ }
208
+ PPUSCROLL_REG = scroll_x; /* playfield X scroll (below the HUD) */
209
+ PPUSCROLL_REG = 0;
210
+ }
211
+
212
+ /* Stage sprite 0 = an 8x8 opaque block over the HUD BAR row (OAM y is
213
+ * scanline-1, so y=16 renders scanlines 17-24 = nametable row 2 = the bar —
214
+ * opaque-on-opaque, so the hit fires INSIDE the bar and the scroll change
215
+ * lands below it, never shearing the text row). Must be the FIRST oam_spr
216
+ * call of the frame (OAM order = call order; the split needs index 0). */
217
+ static void stage_sprite0(void) {
218
+ oam_spr(4, (HUD_ROWS - 1) * 8, TILE_BULLET, 1);
219
+ }
220
+
221
+ /* ── GAME LOGIC (clay) — HUD text (queued writes; NMI commits next vblank) ── */
222
+ static void draw_hud(void) {
223
+ text_draw_u16(0, 9, 1, score);
224
+ text_draw_u16(0, 22, 1, hiscore);
225
+ tile_set(0, 3, 1, 0x40 + lives); /* lives as a digit */
226
+ }
227
+
228
+ static void draw_hud_labels(void) {
229
+ text_draw(0, 0, 1, "LV");
230
+ text_draw(0, 6, 1, "SC");
231
+ text_draw(0, 16, 1, "HI");
232
+ }
233
+
234
+ /* ── GAME LOGIC (clay) — the title screen ──────────────────────────────────
235
+ * Painted with the PPU OFF (text_draw_unsafe = raw VRAM writes; the queued
236
+ * variant would deadlock with rendering disabled — see TROUBLESHOOTING). */
237
+ static void paint_title(void) {
238
+ uint8_t r, c;
239
+ ppu_off();
240
+ /* Clear both HUD + field area to the dust backdrop. */
241
+ for (r = 0; r < 30; r++)
242
+ for (c = 0; c < 32; c++)
243
+ vram_unsafe_set((uint16_t)(0x2000 + r * 32 + c), (r == 0 || r == 1) ? 0 : BG_DUST);
244
+ text_draw_unsafe(0x2000 + 8 * 32 + ((32 - sizeof(GAME_TITLE) + 1) / 2), GAME_TITLE);
245
+ text_draw_unsafe(0x2000 + 13 * 32 + 8, "1P START - A");
246
+ text_draw_unsafe(0x2000 + 15 * 32 + 8, "2P CO-OP - B");
247
+ text_draw_unsafe(0x2000 + 20 * 32 + 10, "HI");
248
+ /* hiscore digits painted by hand (queued text needs rendering on) */
249
+ {
250
+ uint16_t v = hiscore;
251
+ uint8_t d[5], i;
252
+ for (i = 0; i < 5; i++) { d[i] = v % 10; v /= 10; }
253
+ for (i = 0; i < 5; i++) vram_unsafe_set((uint16_t)(0x2000 + 20 * 32 + 13 + i), (uint8_t)(0x40 + d[4 - i]));
254
+ }
255
+ ppu_scroll(0, 0);
256
+ oam_clear();
257
+ ppu_on_all();
258
+ }
259
+
260
+ /* ── GAME LOGIC (clay) — start a run ── */
261
+ static void paint_field(void) {
262
+ uint8_t r, c, tile;
263
+ ppu_off();
264
+ for (c = 0; c < 32; c++) {
265
+ vram_unsafe_set((uint16_t)(0x2000 + 0 * 32 + c), 0); /* row 0: overscan-cropped */
266
+ vram_unsafe_set((uint16_t)(0x2000 + 1 * 32 + c), 0); /* row 1: HUD text (queued draws fill it) */
267
+ vram_unsafe_set((uint16_t)(0x2000 + 2 * 32 + c), BG_HUDBAR); /* row 2: bar = divider + sprite-0 anchor */
268
+ }
269
+ for (r = HUD_ROWS; r < 30; r++) {
270
+ for (c = 0; c < 32; c++) {
271
+ tile = BG_DUST;
272
+ if (((r * 5 + c * 3) % 7) == 0) tile = BG_STAR;
273
+ if (((r * 3 + c * 7) % 23) == 0) tile = BG_BRITE;
274
+ vram_unsafe_set((uint16_t)(0x2000 + r * 32 + c), tile);
275
+ }
276
+ }
277
+ ppu_scroll(0, 0);
278
+ oam_clear();
279
+ ppu_on_all();
280
+ /* Labels go through the queued path once rendering is on. */
281
+ draw_hud_labels();
282
+ draw_hud();
283
+ }
284
+
285
+ static void start_game(uint8_t players) {
286
+ uint8_t i;
287
+ two_player = players;
288
+ for (i = 0; i < MAX_BULLETS; i++) bullet_active[i] = 0;
289
+ for (i = 0; i < MAX_ENEMIES; i++) enemy_active[i] = 0;
290
+ ship_x[0] = two_player ? 96 : 120; ship_y[0] = 200; ship_alive[0] = 1; fire_cd[0] = 0;
291
+ ship_x[1] = 144; ship_y[1] = 200; ship_alive[1] = two_player; fire_cd[1] = 0;
292
+ lives = START_LIVES;
293
+ score = 0;
294
+ spawn_timer = 0;
295
+ scroll_x = 0;
296
+ paint_field();
297
+ state = ST_PLAY;
298
+ }
299
+
300
+ static void game_over(void) {
301
+ if (score > hiscore) {
302
+ hiscore = score;
303
+ /* ── HARDWARE IDIOM (load-bearing) — persists via battery PRG-RAM at
304
+ * $6000; works because the crt0's iNES header sets the BATTERY bit.
305
+ * See nes_runtime.c for the magic+checksum layout. ── */
306
+ hiscore_save(hiscore);
307
+ }
308
+ state = ST_OVER;
309
+ text_draw(0, 11, 14, "GAME OVER");
310
+ }
311
+
312
+ /* ── GAME LOGIC (clay) — per-player update ── */
313
+ static void update_ship(uint8_t p) {
314
+ uint8_t pad = pad_poll(p);
315
+ if (!ship_alive[p]) return;
316
+ if ((pad & PAD_LEFT) && ship_x[p] > 8) --ship_x[p];
317
+ if ((pad & PAD_RIGHT) && ship_x[p] < 240) ++ship_x[p];
318
+ if ((pad & PAD_UP) && ship_y[p] > (HUD_ROWS * 8 + 8)) --ship_y[p];
319
+ if ((pad & PAD_DOWN) && ship_y[p] < 216) ++ship_y[p];
320
+ if ((pad & PAD_A) && fire_cd[p] == 0) {
321
+ fire_bullet(p);
322
+ fire_cd[p] = 8;
323
+ }
324
+ if (fire_cd[p] > 0) --fire_cd[p];
325
+ }
326
+
148
327
  void main(void) {
149
328
  uint8_t i, pad, prev_pad = 0;
150
329
 
330
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
331
+ * Init order: PPU off → CHR upload → palette → nametable (raw writes) →
332
+ * OAM clear → rendering on. CHR/palette/nametable writes REQUIRE the PPU
333
+ * off (raw $2007 traffic during rendering corrupts the address latch
334
+ * mid-frame). The runtime's ppu_off/ppu_on_all pair owns the PPUCTRL/
335
+ * PPUMASK bits — don't poke those registers directly alongside it. */
151
336
  ppu_off();
152
-
153
- /* Upload tile data — blank in slot 0 + 3 sprite tiles in slots 1..3,
154
- * all in the SPRITE pattern table ($0000). */
155
337
  chr_ram_upload(0x0000, tile_blank, 16);
156
338
  chr_ram_upload(0x0010, tile_ship, 16);
157
339
  chr_ram_upload(0x0020, tile_bullet, 16);
158
340
  chr_ram_upload(0x0030, tile_enemy, 16);
159
- /* Upload the starfield tiles to the BACKGROUND pattern table
160
- * ($1010/$1020/$1030 = BG slots 1/2/3). */
161
341
  chr_ram_upload(0x1010, tile_dust, 16);
162
342
  chr_ram_upload(0x1020, tile_star, 16);
163
343
  chr_ram_upload(0x1030, tile_brite, 16);
164
-
344
+ chr_ram_upload(0x1040, tile_hudbar, 16);
345
+ font_upload();
165
346
  palette_load(palette);
166
-
167
- /* Paint a full starfield directly into the nametable while the PPU is off
168
- * (vram_unsafe_set = raw write; tile_set's NMI queue would deadlock
169
- * before ppu_on). Every one of the 32×30 cells gets the faint "dust" base,
170
- * with small stars sprinkled every few cells and the odd bright star — a
171
- * deterministic scatter so the backdrop is unambiguously "space", densely
172
- * filled rather than flat black. */
173
- {
174
- uint16_t r, cc;
175
- uint8_t tile;
176
- for (r = 0; r < 30; r++) {
177
- for (cc = 0; cc < 32; cc++) {
178
- tile = BG_DUST; /* base dust everywhere */
179
- if (((r * 5 + cc * 3) % 7) == 0) tile = BG_STAR; /* sprinkle stars */
180
- if (((r * 3 + cc * 7) % 23) == 0) tile = BG_BRITE; /* rare bright one */
181
- vram_unsafe_set((uint16_t)(0x2000 + r * 32 + cc), tile);
182
- }
183
- }
184
- }
185
-
186
- oam_clear();
187
- ppu_on_all();
188
347
  sound_init();
189
348
 
190
- /* Initialize pools to inactive. */
191
- for (i = 0; i < MAX_BULLETS; i++) bullet_active[i] = 0;
192
- for (i = 0; i < MAX_ENEMIES; i++) enemy_active[i] = 0;
349
+ hiscore = hiscore_load(); /* battery SRAM 0 on first boot */
350
+ state = ST_TITLE;
351
+ paint_title();
193
352
 
194
353
  for (;;) {
195
- /* ── Stage sprites BEFORE ppu_wait_nmi() ─────────────────────
196
- * ORDER IS LOAD-BEARING. The NMI handler DMAs shadow OAM
197
- * real OAM at the START of vblank, copying whatever shadow OAM
198
- * holds AT THAT MOMENT. So stage (oam_clear + oam_spr) FIRST,
199
- * THEN ppu_wait_nmi(). If you flip it (wait, then stage), the
200
- * frame the player sees lags one frame behind / shows stale or
201
- * empty sprites. Keep oam_* above the wait. */
202
- oam_clear();
203
- oam_spr(ship_x, ship_y, TILE_SHIP, SHIP_PAL);
204
- for (i = 0; i < MAX_BULLETS; i++) {
205
- if (bullet_active[i]) {
206
- oam_spr(bullet_x[i], bullet_y[i], TILE_BULLET, BULLET_PAL);
207
- }
354
+ if (state == ST_TITLE) {
355
+ /* ── GAME LOGIC (clay) title: A = 1P, B = 2P co-op ── */
356
+ oam_clear();
357
+ ppu_wait_nmi();
358
+ sound_music_tick();
359
+ pad = pad_poll(0);
360
+ if ((pad & PAD_A) && !(prev_pad & PAD_A)) start_game(0);
361
+ else if ((pad & PAD_B) && !(prev_pad & PAD_B)) start_game(1);
362
+ else if ((pad & PAD_START) && !(prev_pad & PAD_START)) start_game(0);
363
+ prev_pad = pad;
364
+ continue;
208
365
  }
209
- for (i = 0; i < MAX_ENEMIES; i++) {
210
- if (enemy_active[i]) {
211
- oam_spr(enemy_x[i], enemy_y[i], TILE_ENEMY, ENEMY_PAL);
366
+
367
+ if (state == ST_OVER) {
368
+ /* Freeze the final frame; START or A returns to the title. */
369
+ oam_clear();
370
+ stage_sprite0();
371
+ ppu_wait_nmi();
372
+ split_after_hud();
373
+ sound_music_tick();
374
+ pad = pad_poll(0);
375
+ if ((pad & (PAD_START | PAD_A)) && !(prev_pad & (PAD_START | PAD_A))) {
376
+ state = ST_TITLE;
377
+ paint_title();
212
378
  }
379
+ prev_pad = pad;
380
+ continue;
213
381
  }
214
382
 
215
- ppu_wait_nmi();
383
+ /* ── ST_PLAY ─────────────────────────────────────────────────────── */
384
+
385
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
386
+ * Stage ALL sprites BEFORE ppu_wait_nmi(). The NMI DMAs shadow OAM →
387
+ * real OAM at the START of vblank, copying whatever shadow OAM holds AT
388
+ * THAT MOMENT. Stage-then-wait; flipping it shows stale/empty sprites.
389
+ * Sprite 0 (the split marker) must be staged FIRST — OAM order is
390
+ * oam_spr call order, and the split idiom needs it at index 0. */
391
+ oam_clear();
392
+ stage_sprite0();
393
+ for (i = 0; i < 2; i++)
394
+ if (ship_alive[i]) oam_spr(ship_x[i], ship_y[i], TILE_SHIP, i ? SHIP2_PAL : SHIP1_PAL);
395
+ for (i = 0; i < MAX_BULLETS; i++)
396
+ if (bullet_active[i]) oam_spr(bullet_x[i], bullet_y[i], TILE_BULLET, BULLET_PAL);
397
+ for (i = 0; i < MAX_ENEMIES; i++)
398
+ if (enemy_active[i]) oam_spr(enemy_x[i], enemy_y[i], TILE_ENEMY, ENEMY_PAL);
216
399
 
217
- /* ── Input ───────────────────────────────────────────────── */
400
+ ppu_wait_nmi();
401
+ split_after_hud(); /* the sprite-0 split — every frame */
218
402
  sound_music_tick();
219
- pad = pad_poll(0);
220
- if ((pad & PAD_LEFT) && ship_x > 8) --ship_x;
221
- if ((pad & PAD_RIGHT) && ship_x < 240) ++ship_x;
222
- if ((pad & PAD_UP) && ship_y > 16) --ship_y;
223
- if ((pad & PAD_DOWN) && ship_y < 216) ++ship_y;
224
-
225
- if ((pad & PAD_A) && fire_cooldown == 0) {
226
- fire_bullet();
227
- fire_cooldown = 8; /* 8-frame cooldown */
228
- sound_play_tone(0, 0x100, 6, 4); /* short pew */
229
- }
230
- if (fire_cooldown > 0) --fire_cooldown;
231
- prev_pad = pad;
232
403
 
233
- /* ── Update bullets (move up, despawn off-screen) ────────── */
404
+ /* ── GAME LOGIC (clay) from here down ── */
405
+ update_ship(0);
406
+ if (two_player) update_ship(1);
407
+
408
+ /* Starfield drift (the split makes this not move the HUD). */
409
+ if ((spawn_timer & 3) == 0) ++scroll_x;
410
+
234
411
  for (i = 0; i < MAX_BULLETS; i++) {
235
412
  if (!bullet_active[i]) continue;
236
- if (bullet_y[i] < 4) {
237
- bullet_active[i] = 0;
238
- } else {
239
- bullet_y[i] -= 4;
240
- }
413
+ if (bullet_y[i] < HUD_ROWS * 8 + 4) bullet_active[i] = 0;
414
+ else bullet_y[i] -= 4;
241
415
  }
242
416
 
243
- /* ── Update enemies (move down, despawn off-screen) ──────── */
244
417
  for (i = 0; i < MAX_ENEMIES; i++) {
245
418
  if (!enemy_active[i]) continue;
246
- if (enemy_y[i] >= 232) {
247
- enemy_active[i] = 0;
248
- } else {
249
- ++enemy_y[i];
250
- }
419
+ if (enemy_y[i] >= 224) enemy_active[i] = 0;
420
+ else ++enemy_y[i];
251
421
  }
252
422
 
253
- /* ── Collisions: bullets ↔ enemies ───────────────────────── */
423
+ /* Bullets ↔ enemies. */
254
424
  {
255
425
  uint8_t b, e;
256
426
  for (b = 0; b < MAX_BULLETS; b++) {
@@ -260,15 +430,39 @@ void main(void) {
260
430
  if (hits(bullet_x[b], bullet_y[b], enemy_x[e], enemy_y[e])) {
261
431
  bullet_active[b] = 0;
262
432
  enemy_active[e] = 0;
263
- ++score; /* TODO: render score */
264
- sound_play_noise(8, 8, 6); /* period 8, vol 8, 6 frames */
433
+ ++score;
434
+ sound_play_noise(8, 8, 6);
435
+ draw_hud();
265
436
  break;
266
437
  }
267
438
  }
268
439
  }
269
440
  }
270
441
 
271
- /* ── Spawner: one enemy every ~32 frames ─────────────────── */
442
+ /* Enemies ↔ ships: shared life pool (arcade co-op). */
443
+ {
444
+ uint8_t e, p;
445
+ for (e = 0; e < MAX_ENEMIES; e++) {
446
+ if (!enemy_active[e]) continue;
447
+ for (p = 0; p < 2; p++) {
448
+ if (!ship_alive[p]) continue;
449
+ if (hits(enemy_x[e], enemy_y[e], ship_x[p], ship_y[p])) {
450
+ enemy_active[e] = 0;
451
+ sound_play_noise(12, 12, 12);
452
+ if (lives > 0) --lives;
453
+ draw_hud();
454
+ if (lives == 0) {
455
+ game_over();
456
+ } else {
457
+ /* respawn knockback */
458
+ ship_y[p] = 200;
459
+ ship_x[p] = p ? 144 : (two_player ? 96 : 120);
460
+ }
461
+ }
462
+ }
463
+ }
464
+ }
465
+
272
466
  ++spawn_timer;
273
467
  if (spawn_timer >= 32) {
274
468
  spawn_timer = 0;