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,228 +1,836 @@
1
- // ── shmup.c — Commodore 64 vertical-shooter scaffold ─────────────────
2
- //
3
- // Cross-platform shmup shape: player + 4 bullets + 4 enemies (pool),
4
- // wave spawner, AABB collisions, SID sound effects on fire + hit.
5
- //
6
- // VIC-II hardware sprites give us up to 8 MOBs — we use 1 player +
7
- // 4 bullets + 4 enemies = 9, so we double-up: the four bullets share
8
- // sprite slots 1-4 in time-multiplexed style actually we keep it
9
- // honest by allocating only 8 slots (player + 3 bullets + 4 enemies)
10
- // to demonstrate the C64's tight sprite budget.
11
- //
12
- // Joystick port 2 drives the player (CIA1_PRA via the standard C64
13
- // idiom — port 1 conflicts with the keyboard scan matrix).
1
+ /* ── shmup.c — C64 horizontal shooter (complete example game) ─────────────────
2
+ *
3
+ * A COMPLETE, working game title screen, 1P and 2P co-op modes, lives,
4
+ * score + hi-score, SID music with the C64's signature filter sweep, SFX,
5
+ * and the C64's signature raster-IRQ split (a fixed score bar over a
6
+ * scrolling starfield).
7
+ *
8
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
9
+ * very different one. The markers tell you what's what:
10
+ * HARDWARE IDIOM (load-bearing) — dodges a documented C64 footgun; reshape
11
+ * your gameplay around it (see TROUBLESHOOTING before changing).
12
+ * GAME LOGIC (clay) enemy patterns, scoring, tuning, art: reshape freely.
13
+ *
14
+ * What depends on what:
15
+ * c64_registers.h — VIC-II / SID / CIA symbolic addresses (header only).
16
+ * c64_sfx.{h,c} — one-shot SID sound effects on voice 2.
17
+ * The BASIC stub + crt0 come from cc65's c64 target: the .prg loads at
18
+ * $0801, a tiny BASIC line does SYS into the C runtime, and the KERNAL
19
+ * stays banked in (we lean on that for the IRQ vector — see below).
20
+ *
21
+ * Memory map this file assumes (VIC bank 0 = $0000-$3FFF):
22
+ * $0400 screen RAM (40×25 chars) $D800 color RAM (static texture)
23
+ * $0801 this program (code+data grow up from here)
24
+ * $3F00 sprite images (3 × 64 bytes) — NOT $0800, which collides with
25
+ * the .prg load address, and NOT $1000-$1FFF, where the VIC sees
26
+ * the character ROM instead of RAM (a classic invisible-sprite trap).
27
+ * Keep the program under ~14 KB so it stays below $3F00.
28
+ *
29
+ * Frame budget (PAL, 50fps, ~19656 CPU cycles/frame): the coarse starfield
30
+ * shift (22 rows × 39 bytes, every 8th frame) is the big-ticket item at
31
+ * ~13k cycles; it's scheduled right after the bottom-of-frame IRQ so it
32
+ * outruns the raster beam (see scroll_field_left). Everything else fits.
33
+ */
14
34
 
15
35
  #include "c64_registers.h"
16
36
  #include "c64_sfx.h"
17
37
  #include <stdint.h>
18
38
 
39
+ /* cc65 KERNAL disk-I/O prototypes. We DON'T #include <cbm.h> — it drags in
40
+ * <c64.h>, whose VIC/SID/JOY macros collide with this project's
41
+ * c64_registers.h (cc65 errors "macro redefinition is not identical"). These
42
+ * four are the stable cc65 ABI; declaring them directly avoids the clash. */
43
+ unsigned char __fastcall__ cbm_open(unsigned char lfn, unsigned char device,
44
+ unsigned char sec_addr, const char *name);
45
+ void __fastcall__ cbm_close(unsigned char lfn);
46
+ int __fastcall__ cbm_read(unsigned char lfn, void *buffer, unsigned int size);
47
+ int __fastcall__ cbm_write(unsigned char lfn, const void *buffer, unsigned int size);
48
+
49
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
50
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
51
+ #define GAME_TITLE "ION SQUALL"
52
+
19
53
  #define POKE(addr, val) (*(volatile uint8_t*)(addr) = (val))
20
54
  #define PEEK(addr) (*(volatile uint8_t*)(addr))
21
55
 
22
56
  #define SCREEN ((volatile uint8_t*)0x0400)
23
57
  #define COLORS ((volatile uint8_t*)0xD800)
58
+ #define SPRITE_POINTERS ((volatile uint8_t*)0x07F8) /* last 8 bytes of screen RAM */
24
59
 
25
- #define SPRITE_POINTERS ((volatile uint8_t*)0x07F8)
26
- #define SPRITE_DATA_BASE 0x2000 /* sprite N data at $2000 + N*64 NOT $0800,
27
- * which collides with the $0801 .prg load (C64-1) */
28
-
29
- #define JOY_UP 0x01
30
- #define JOY_DOWN 0x02
31
- #define JOY_LEFT 0x04
32
- #define JOY_RIGHT 0x08
33
- #define JOY_FIRE 0x10
60
+ /* ── Screen layout (the raster split divides bar from field) ────────────────
61
+ * char row 0 score bar text: SC / HI / LV / mode (FIXED, never scrolls)
62
+ * char row 1 — solid divider line (FIXED)
63
+ * char row 2 — blank spacer: the split lands mid-row HERE, where a few
64
+ * raster lines of IRQ jitter are invisible (uniform color)
65
+ * char rows 3-24 — the scrolling starfield playfield
66
+ * PAL raster geometry: with YSCROLL=3 (the power-on default) text row r
67
+ * occupies raster lines 51+8r .. 58+8r. So the spacer row 2 = lines 67-74,
68
+ * and the playfield's first row 3 starts at line 75. */
69
+ #define FIELD_TOP 3
70
+ #define SPLIT_LINE 68 /* inside spacer row 2 (67-74): jitter-proof */
71
+ #define BOTTOM_LINE 251 /* first line below the 25-row text window (ends 250) */
72
+ /* $D016 values for the two halves of the frame. Bit 3 CLEAR = 38-column mode
73
+ * (masks the garbage column fine-X scrolling exposes at the edges — keep all
74
+ * text inside columns 1-38). Low 3 bits = fine X scroll 0-7. */
75
+ #define D016_BAR 0xC0 /* fine X = 0, 38 cols — the fixed bar */
34
76
 
35
- #define MAX_BULLETS 3
36
- #define MAX_ENEMIES 4
77
+ /* ── GAME LOGIC (clay — reshape freely) ── object pools, no heap ── */
78
+ #define MAX_BULLETS 2 /* one VIC sprite each — see the slot map below */
79
+ #define MAX_ENEMIES 4
80
+ #define START_LIVES 3
37
81
 
38
- /* 8 hardware sprite slots:
39
- * 0 player
40
- * 1..3 bullets
41
- * 4..7 enemies */
42
- #define SLOT_PLAYER 0
43
- #define SLOT_BULLET0 1
82
+ /* 8 VIC-II hardware sprite slots — ALL of them, the C64's full budget:
83
+ * 0 = P1 ship 1 = P2 ship 2-3 = bullets 4-7 = enemies
84
+ * (More objects than 8 needs raster-time sprite multiplexing — a deep
85
+ * rabbit hole; this game designs its gameplay inside the budget instead.) */
86
+ #define SLOT_P1 0
87
+ #define SLOT_P2 1
88
+ #define SLOT_BULLET0 2
44
89
  #define SLOT_ENEMY0 4
45
90
 
91
+ /* Sprite images live at $3F00 (top of VIC bank 0). Pointer byte = addr/64. */
92
+ #define SPR_DATA(img) (0x3F00 + (img) * 64)
93
+ #define SPR_PTR(img) (uint8_t)(SPR_DATA(img) / 64) /* $3F00/64 = $FC */
94
+ #define IMG_SHIP 0
95
+ #define IMG_BULLET 1
96
+ #define IMG_ENEMY 2
97
+
98
+ /* ── GAME LOGIC (clay) — sprite art (24×21, 3 bytes/row, 64-byte blocks) ── */
46
99
  static const uint8_t ship_sprite[64] = {
47
- 0x00,0x18,0x00, 0x00,0x3C,0x00, 0x00,0x7E,0x00, 0x00,0xFF,0x00,
48
- 0x01,0xFF,0x80, 0x03,0xFF,0xC0, 0x07,0xFF,0xE0, 0x0F,0x18,0xF0,
49
- 0x1F,0x18,0xF8, 0x3F,0xFF,0xFC, 0x7F,0xFF,0xFE, 0x7F,0xFF,0xFE,
50
- 0x7F,0xFF,0xFE, 0x3F,0xFF,0xFC, 0x1F,0xFF,0xF8, 0x0F,0xFF,0xF0,
51
- 0x07,0xFF,0xE0, 0x03,0xFF,0xC0, 0x01,0xFF,0x80, 0x00,0xFF,0x00,
52
- 0x00,0x3C,0x00, 0,
100
+ 0x60,0x00,0x00, 0x78,0x00,0x00, 0x7E,0x00,0x00, 0x1F,0x80,0x00,
101
+ 0x1F,0xE0,0x00, 0x3F,0xF8,0x00, 0x7F,0xFE,0x00, 0xFF,0xFF,0x80,
102
+ 0xFF,0xFF,0xE0, 0xFF,0xFF,0x80, 0x7F,0xFE,0x00, 0x3F,0xF8,0x00,
103
+ 0x1F,0xE0,0x00, 0x1F,0x80,0x00, 0x7E,0x00,0x00, 0x78,0x00,0x00,
104
+ 0x60,0x00,0x00, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,
53
105
  };
54
106
  static const uint8_t bullet_sprite[64] = {
55
- 0x00,0x18,0x00, 0x00,0x3C,0x00, 0x00,0x3C,0x00, 0x00,0x3C,0x00,
56
- 0x00,0x3C,0x00, 0x00,0x3C,0x00, 0x00,0x3C,0x00, 0x00,0x3C,0x00,
57
- 0x00,0x3C,0x00, 0x00,0x3C,0x00, 0x00,0x18,0x00,
107
+ 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0,
108
+ 0x3F,0xC0,0x00, 0x7F,0xE0,0x00, 0x3F,0xC0,0x00,
58
109
  0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,
59
110
  };
60
111
  static const uint8_t enemy_sprite[64] = {
61
- 0xFF,0x81,0xFF, 0x7E,0x42,0x7E, 0x3C,0x24,0x3C, 0x18,0x18,0x18,
62
- 0x3C,0x3C,0x3C, 0x7E,0x66,0x7E, 0xFF,0xC3,0xFF, 0xC3,0x18,0xC3,
63
- 0xC3,0x18,0xC3, 0xC3,0x18,0xC3, 0x18,0x18,0x18, 0x3C,0x3C,0x3C,
64
- 0x7E,0x7E,0x7E, 0xFF,0xFF,0xFF, 0x7E,0x7E,0x7E, 0x3C,0x3C,0x3C,
65
- 0x18,0x18,0x18, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,
112
+ 0x03,0xC0,0x00, 0x0F,0xF0,0x00, 0x3C,0x3C,0x00, 0x73,0xCE,0x00,
113
+ 0xE7,0xE7,0x00, 0xFF,0xFF,0x00, 0xE7,0xE7,0x00, 0x73,0xCE,0x00,
114
+ 0x3C,0x3C,0x00, 0x0F,0xF0,0x00, 0x03,0xC0,0x00,
115
+ 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,
66
116
  };
67
117
 
68
- typedef struct { uint8_t x, y, alive; } Obj;
118
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
119
+ * THE RASTER-IRQ SPLIT — the C64's classic "fixed status bar over a moving
120
+ * world" trick (and the gateway drug to all raster effects). The VIC-II has
121
+ * ONE $D016 fine-scroll for the whole frame; to scroll the playfield while
122
+ * the score bar stays put, you change $D016 MID-FRAME, at an exact raster
123
+ * line, from an interrupt. Two IRQs ping-pong per frame:
124
+ *
125
+ * line 68 (inside the blank spacer row): $D016 = playfield scroll
126
+ * → everything drawn below this line scrolls
127
+ * line 251 (just past the text window): $D016 = 0 scroll
128
+ * → next frame's bar rows render fixed; this IRQ is also the
129
+ * game's frame heartbeat (increments frame_count)
130
+ *
131
+ * The handshake, register by register:
132
+ * $D012 raster compare line (low 8 bits)
133
+ * $D011 b7 raster compare bit 8 — MUST be 0 here (both lines < 256).
134
+ * Forgetting this bit is the classic "my IRQ fires on the
135
+ * wrong line / twice" bug when lines ≥ 256 get involved.
136
+ * $D01A b0 raster IRQ enable
137
+ * $D019 IRQ latch. ACK by WRITING THE BITS BACK (write-1-to-clear).
138
+ * THE LOAD-BEARING LINE: skip the ack and the IRQ re-fires the
139
+ * instant it returns, forever — the main loop starves and the
140
+ * machine looks hung.
141
+ * $0314/15 the KERNAL's IRQ indirection. The hardware vector ($FFFE)
142
+ * points into KERNAL ROM, which saves A/X/Y and jumps through
143
+ * $0314 — so with the KERNAL banked in (cc65 default) we just
144
+ * repoint $0314. Exit via jmp $EA81 (KERNAL: restore regs +
145
+ * rti), SKIPPING $EA31's jiffy-clock/keyboard scan. If you
146
+ * ever bank the KERNAL out, you own $FFFE and the register
147
+ * save/restore yourself.
148
+ * $DC0D CIA1 interrupt control. The KERNAL leaves a 60Hz CIA timer
149
+ * IRQ running (the jiffy clock); disable it ($7F = clear all
150
+ * sources) and ack it (read $DC0D) or it shares the IRQ line
151
+ * with the raster and fires our handler at random lines.
152
+ *
153
+ * JITTER: an IRQ only starts after the current instruction finishes, so the
154
+ * handler begins 0-7 cycles late, plus the KERNAL thunk (~35 cycles) — the
155
+ * $D016 write lands one-to-two raster lines after SPLIT_LINE, at an
156
+ * unpredictable X position. We hide that by splitting inside a UNIFORM
157
+ * blank row, where shifting the (invisible) pixels mid-line changes
158
+ * nothing. Splits next to visible detail need cycle-exact stabilization
159
+ * (double-IRQ trick) — don't go there until you need to.
160
+ *
161
+ * The handler is ASSEMBLY-IN-C on purpose: cc65's generated C uses shared
162
+ * zero-page scratch registers, so a C-level IRQ body would corrupt whatever
163
+ * the main loop was computing. These asm lines touch only A + the flags
164
+ * (which the KERNAL thunk already saved). requires: KERNAL banked in,
165
+ * frame_count/field_d016 file-scope NON-static (asm %v needs the symbol). */
166
+ volatile uint8_t frame_count; /* bumped by the bottom IRQ — frame heartbeat */
167
+ volatile uint8_t field_d016; /* playfield $D016 value, precomputed by main */
168
+
169
+ void raster_irq(void) {
170
+ asm("lda $d019"); /* read VIC IRQ latch... */
171
+ asm("sta $d019"); /* ...write it back = ACK (write-1-to-clear).
172
+ * THE line you must not lose (see above). */
173
+ asm("lda $d012"); /* which raster line woke us? (self-correcting
174
+ * dispatch — no phase variable to desync) */
175
+ asm("cmp #150");
176
+ asm("bcs %g", at_bottom); /* ≥150 → we're at BOTTOM_LINE */
177
+ /* — split point (line ~68, inside the blank spacer row) — */
178
+ asm("lda %v", field_d016);
179
+ asm("sta $d016"); /* playfield fine-X from here down */
180
+ asm("lda #251"); /* = BOTTOM_LINE (cc65's asm %b only takes */
181
+ asm("sta $d012"); /* signed bytes, so these are literals — the */
182
+ asm("jmp $ea81"); /* #if below keeps them honest) */
183
+ at_bottom:
184
+ asm("lda #$C0"); /* = D016_BAR */
185
+ asm("sta $d016"); /* bar scroll for the top of the NEXT frame */
186
+ asm("inc %v", frame_count);/* frame heartbeat for the main loop */
187
+ asm("lda #%b", SPLIT_LINE);
188
+ asm("sta $d012"); /* next stop: the split line */
189
+ asm("jmp $ea81"); /* KERNAL: pla/tay/pla/tax/pla/rti */
190
+ }
191
+ #if BOTTOM_LINE != 251 || D016_BAR != 0xC0
192
+ #error raster_irq's asm immediates are out of sync with BOTTOM_LINE / D016_BAR
193
+ #endif
194
+
195
+ static void install_raster_irq(void) {
196
+ asm("sei"); /* no IRQs while we rewire them */
197
+ POKE(CIA1_PRA + 0x0D, 0x7F); /* $DC0D: disable ALL CIA1 IRQ sources
198
+ * (kills the KERNAL jiffy/keyboard IRQ
199
+ * — we read the sticks ourselves) */
200
+ (void)PEEK(CIA1_PRA + 0x0D); /* reading $DC0D acks anything pending */
201
+ POKE(0x0314, (uint8_t)((unsigned)raster_irq & 0xFF));
202
+ POKE(0x0315, (uint8_t)((unsigned)raster_irq >> 8));
203
+ POKE(VIC_CTRL1, 0x1B); /* $D011 = power-on default: screen on,
204
+ * 25 rows, YSCROLL=3, and bit 7 (raster
205
+ * compare bit 8) = 0 — both our lines
206
+ * are < 256 */
207
+ POKE(VIC_RASTER, SPLIT_LINE); /* first stop */
208
+ POKE(VIC_IRQ_ENA, 0x01); /* raster IRQ on */
209
+ POKE(VIC_IRQ, 0xFF); /* clear any stale latch bits */
210
+ asm("cli");
211
+ }
212
+
213
+ /* Wait for the bottom IRQ's heartbeat. Replaces the usual poll-$D012 loop —
214
+ * the IRQ owns the raster now, the main loop just paces itself on it. */
215
+ static void wait_frame(void) {
216
+ uint8_t f = frame_count;
217
+ while (frame_count == f) { }
218
+ }
219
+
220
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ── reading BOTH
221
+ * joystick ports. CIA1 port A ($DC00) = control port 2, port B ($DC01) =
222
+ * control port 1. Active-low: a pressed switch reads 0, so invert and mask
223
+ * to bits 0-4 (up/down/left/right/fire).
224
+ *
225
+ * THE PORT-1 GOTCHA: $DC01 is ALSO the keyboard row register — the matrix
226
+ * hangs off the same CIA lines. Writing $FF to $DC00 first deselects every
227
+ * keyboard column, so held keys can't pull $DC01 rows low and ghost into
228
+ * the port-1 stick. (The reverse ghost still exists on real hardware:
229
+ * port-2 stick presses pull $DC00 columns low and can fake keypresses /
230
+ * bleed into port 1 while keys are held. That's the real reason "port 2 is
231
+ * the C64 game port" — P1 lives there by convention, and this game puts
232
+ * the SECOND player on port 1.) requires: install_raster_irq already
233
+ * disabled the KERNAL's keyboard scan, so nothing else rewrites $DC00. */
234
+ static uint8_t read_stick_port2(void) { /* player 1 */
235
+ POKE(CIA1_PRA, 0xFF);
236
+ return (uint8_t)(~PEEK(CIA1_PRA) & 0x1F);
237
+ }
238
+ static uint8_t read_stick_port1(void) { /* player 2 */
239
+ POKE(CIA1_PRA, 0xFF);
240
+ return (uint8_t)(~PEEK(CIA1_PRB) & 0x1F);
241
+ }
242
+ #define JOY_UP 0x01
243
+ #define JOY_DOWN 0x02
244
+ #define JOY_LEFT 0x04
245
+ #define JOY_RIGHT 0x08
246
+ #define JOY_FIRE 0x10
247
+
248
+ /* ── HARDWARE IDIOM (load-bearing) — hi-score persistence: DISK SAVE ─────────
249
+ * The C64 has no battery SRAM — the honest save medium is the FLOPPY. A game
250
+ * persists by writing a file to drive 8; VICE commits it into the live 1541
251
+ * disk image (true-drive GCR write-back), so a save survives a power cycle
252
+ * exactly as it did on real hardware. (To capture it headlessly the host does
253
+ * state({op:'exportDisk', path}); re-loading that .d64 restores the save.)
254
+ *
255
+ * REQUIRES THE GAME RUN FROM A DISK: build/package it as a .d64 and load THAT
256
+ * (loadMedia autostarts it). A bare .prg injected straight into RAM has no
257
+ * mounted disk to save to, so the save is a silent no-op — still honest (the
258
+ * hi-score just stays in-session), it simply has nowhere to persist.
259
+ *
260
+ * We keep a 2-byte record in a SEQ file "HI" on drive 8. cbm_open/read/close
261
+ * for load; cbm_save (KERNAL SAVE) for the write — SAVE is the simplest path
262
+ * that VICE's true-drive emulation commits to the image. These are the STABLE
263
+ * SEAM: the game calls hiscore_load at boot and hiscore_save on a new record;
264
+ * reshape the record format freely, just keep the two function signatures. */
265
+ #define SAVE_NAME "@0:HI,S,W" /* @ = replace-if-exists; S=SEQ, W=write */
266
+ #define LOAD_NAME "0:HI,S,R"
267
+
268
+ static uint16_t hiscore_load(void) {
269
+ uint16_t v = 0;
270
+ uint8_t buf[2];
271
+ /* logical file 2, drive 8, secondary 2 (a data channel, not load-addr). */
272
+ if (cbm_open(2, 8, 2, LOAD_NAME) == 0) {
273
+ if (cbm_read(2, buf, 2) == 2) v = (uint16_t)buf[0] | ((uint16_t)buf[1] << 8);
274
+ cbm_close(2);
275
+ }
276
+ return v; /* 0 if the file isn't there yet (first ever boot) */
277
+ }
278
+
279
+ static void hiscore_save(uint16_t v) {
280
+ uint8_t buf[2];
281
+ buf[0] = (uint8_t)(v & 0xFF);
282
+ buf[1] = (uint8_t)(v >> 8);
283
+ if (cbm_open(2, 8, 2, SAVE_NAME) == 0) {
284
+ cbm_write(2, buf, 2);
285
+ cbm_close(2);
286
+ }
287
+ /* If no disk is mounted (ran as a bare .prg), cbm_open fails and this is a
288
+ * silent no-op — the hi-score simply stays in-session. */
289
+ }
290
+
291
+ /* ── GAME LOGIC (clay) — SID music: 2 voices + THE filter sweep ─────────────
292
+ * Voice 0 = melody (pulse), voice 1 = bass (sawtooth THROUGH THE FILTER),
293
+ * voice 2 is reserved for sound effects (c64_sfx). Each voice walks a
294
+ * (freq, frames) note table once per frame; end wraps → continuous loop.
295
+ *
296
+ * THE SID FILTER — the C64's sonic signature, and the part most "music
297
+ * drivers ported from other chips" miss. One analog-modeled filter, shared
298
+ * by all voices, four registers:
299
+ * $D415 cutoff low 3 bits $D416 cutoff high 8 bits (11-bit total)
300
+ * $D417 high nibble = resonance 0-15; low 3 bits ROUTE voices into the
301
+ * filter (bit0=voice0, bit1=voice1, bit2=voice2)
302
+ * $D418 bit4=lowpass bit5=bandpass bit6=highpass — AND master volume in
303
+ * bits 0-3. Volume and filter mode share a register: any "set
304
+ * volume" helper that writes plain $0F silently turns the filter
305
+ * OFF (c64_sfx's sfx_init does exactly that, so music_init runs
306
+ * AFTER it and re-asserts the mode bits).
307
+ * FOOTGUN: bit 7 of $D418 is "3OFF" — it MUTES voice 3 entirely.
308
+ * Set it by accident and all your sound effects vanish.
309
+ * The sweep: a triangle LFO walks the cutoff up and down each frame over
310
+ * the resonant lowpass — the bass goes from muffled to snarling and back,
311
+ * the "wah" that screams Commodore. Hear it change: that IS the chip. */
312
+ #define N_A2 0x0F3Cu
313
+ #define N_C3 0x1199u
314
+ #define N_D3 0x13EEu
315
+ #define N_E3 0x1666u
316
+ #define N_F3 0x1798u
317
+ #define N_G3 0x1AE6u
318
+ #define N_A3 0x1E78u
319
+ #define N_B3 0x2253u
320
+ #define N_C4 0x2333u
321
+ #define N_D4 0x27DDu
322
+ #define N_E4 0x2CCCu
323
+ #define N_F4 0x2F30u
324
+ #define N_G4 0x35CCu
325
+ #define N_A4 0x3CF1u
326
+ #define N_B4 0x44A7u
327
+ #define N_C5 0x4666u
328
+ #define N_D5 0x4FBAu
329
+ #define N_E5 0x5998u
330
+ #define N_G5 0x6B99u
331
+ #define N_REST 0u
332
+ #define STEP 9 /* frames per melodic eighth-note (~140 BPM PAL) */
333
+
334
+ typedef struct { uint16_t freq; uint8_t len; } Note;
335
+
336
+ /* The table IS the song — edit these to rescore your fork. Am F C G loop. */
337
+ static const Note melody[] = {
338
+ { N_A4, STEP*2 }, { N_C5, STEP }, { N_E5, STEP }, { N_C5, STEP*2 }, { N_E5, STEP*2 },
339
+ { N_F4, STEP*2 }, { N_A4, STEP }, { N_C5, STEP }, { N_A4, STEP*2 }, { N_REST, STEP*2 },
340
+ { N_C5, STEP*2 }, { N_E5, STEP }, { N_G5, STEP }, { N_E5, STEP*2 }, { N_C5, STEP*2 },
341
+ { N_G4, STEP*2 }, { N_B4, STEP }, { N_D5, STEP }, { N_B4, STEP*2 }, { N_REST, STEP*2 },
342
+ { N_A4, STEP }, { N_E4, STEP }, { N_A4, STEP }, { N_C5, STEP }, { N_E5, STEP*2 }, { N_D5, STEP }, { N_C5, STEP },
343
+ { N_F4, STEP }, { N_C4, STEP }, { N_F4, STEP }, { N_A4, STEP }, { N_C5, STEP*2 }, { N_B4, STEP }, { N_A4, STEP },
344
+ { N_E4, STEP }, { N_G4, STEP }, { N_C5, STEP }, { N_E5, STEP*2 }, { N_G5, STEP*2 }, { N_E5, STEP },
345
+ { N_D5, STEP }, { N_B4, STEP }, { N_G4, STEP }, { N_D4, STEP*2 }, { N_B3, STEP*2 }, { N_REST, STEP },
346
+ };
347
+ static const Note bassline[] = {
348
+ /* Octave-pumping bass — the filter sweep chews on this. */
349
+ { N_A2, STEP*3 }, { N_A3, STEP }, { N_A2, STEP*2 }, { N_A3, STEP*2 },
350
+ { N_F3, STEP*3 }, { N_C3, STEP }, { N_F3, STEP*2 }, { N_C4, STEP*2 },
351
+ { N_C3, STEP*3 }, { N_G3, STEP }, { N_C3, STEP*2 }, { N_E3, STEP*2 },
352
+ { N_G3, STEP*3 }, { N_D3, STEP }, { N_G3, STEP*2 }, { N_B3, STEP*2 },
353
+ };
354
+ #define MELODY_LEN (sizeof(melody) / sizeof(melody[0]))
355
+ #define BASS_LEN (sizeof(bassline) / sizeof(bassline[0]))
356
+
357
+ static uint8_t m_pos[2], m_left[2];
358
+ static uint16_t filter_cut; /* 11-bit cutoff, 0-2047 */
359
+ static uint8_t filter_up;
360
+
361
+ static void music_trigger(uint8_t v, uint16_t freq, uint8_t wave) {
362
+ if (freq == N_REST) {
363
+ POKE(SID_CTRL(v), wave); /* gate off: release tail plays */
364
+ return;
365
+ }
366
+ POKE(SID_FREQ_LO(v), (uint8_t)(freq & 0xFF));
367
+ POKE(SID_FREQ_HI(v), (uint8_t)(freq >> 8));
368
+ POKE(SID_CTRL(v), wave); /* gate OFF then ON — the 6581/8580 */
369
+ POKE(SID_CTRL(v), wave | SID_GATE); /* envelope only retriggers on the
370
+ * 0→1 gate edge */
371
+ }
372
+
373
+ static void music_init(void) {
374
+ /* Melody: pulse at 50% duty, snappy envelope. */
375
+ POKE(SID_PW_LO(0), 0x00); POKE(SID_PW_HI(0), 0x08);
376
+ POKE(SID_AD(0), 0x07); /* attack 0, decay 7 */
377
+ POKE(SID_SR(0), 0x84); /* sustain 8, release 4 */
378
+ /* Bass: sawtooth (harmonically rich — gives the filter teeth to chew). */
379
+ POKE(SID_AD(1), 0x06);
380
+ POKE(SID_SR(1), 0xA5);
381
+ /* Filter: route VOICE 1 ONLY into it (bit 1 of $D417), resonance 13/15. */
382
+ POKE(SID_RES_FILT, 0xD2);
383
+ /* Lowpass mode + master volume 15. NOTE bits shared with volume, and bit
384
+ * 7 (3OFF) stays 0 or voice-2 sound effects go silent — see block doc. */
385
+ POKE(SID_VOL_MODE, 0x1F);
386
+ filter_cut = 0x180; filter_up = 1;
387
+ m_pos[0] = m_pos[1] = 0;
388
+ m_left[0] = m_left[1] = 1; /* triggers both voices on the next update */
389
+ }
390
+
391
+ static void music_update(void) {
392
+ /* Note sequencing, one table per voice. */
393
+ if (--m_left[0] == 0) {
394
+ music_trigger(0, melody[m_pos[0]].freq, SID_PULSE);
395
+ m_left[0] = melody[m_pos[0]].len;
396
+ if (++m_pos[0] >= MELODY_LEN) m_pos[0] = 0;
397
+ }
398
+ if (--m_left[1] == 0) {
399
+ music_trigger(1, bassline[m_pos[1]].freq, SID_SAWTOOTH);
400
+ m_left[1] = bassline[m_pos[1]].len;
401
+ if (++m_pos[1] >= BASS_LEN) m_pos[1] = 0;
402
+ }
403
+ /* THE FILTER SWEEP — triangle LFO on the cutoff, ~10s round trip.
404
+ * 11-bit value split across two registers: low 3 bits in $D415,
405
+ * high 8 in $D416. */
406
+ if (filter_up) { filter_cut += 6; if (filter_cut >= 0x700) filter_up = 0; }
407
+ else { filter_cut -= 6; if (filter_cut <= 0x180) filter_up = 1; }
408
+ POKE(SID_FILTER_LO, (uint8_t)(filter_cut & 0x07));
409
+ POKE(SID_FILTER_HI, (uint8_t)(filter_cut >> 3));
410
+ }
411
+
412
+ /* ── GAME LOGIC (clay) — screen text. The C64 has NO VRAM port: screen RAM
413
+ * is plain memory, writable any time, mid-frame, no vblank dance (compare
414
+ * the NES's $2007 choreography). The only translation is ASCII → SCREEN
415
+ * CODES (not PETSCII!): A-Z land at 1-26; space through '?' (incl. digits)
416
+ * keep their ASCII values. ── */
417
+ static void draw_text(uint8_t row, uint8_t col, const char *s) {
418
+ uint16_t off = (uint16_t)row * 40 + col;
419
+ uint8_t ch;
420
+ while ((ch = (uint8_t)*s++) != 0) {
421
+ if (ch >= 'A' && ch <= 'Z') ch -= 64; /* A-Z → screen codes 1-26 */
422
+ SCREEN[off] = ch; /* 32-63 map straight through */
423
+ COLORS[off] = COLOR_WHITE;
424
+ ++off;
425
+ }
426
+ }
427
+ /* Blank the whole 40-col row, then draw `s` on it — a clean text BAND.
428
+ * Menu/message text sits over the starfield; drawing it raw leaves the
429
+ * surrounding star chars ('.' and reverse-space nebula) crowding the words.
430
+ * A blanked band reads cleanly on screen AND decodes cleanly from screen RAM. */
431
+ static void draw_text_band(uint8_t row, uint8_t col, const char *s) {
432
+ uint8_t c;
433
+ volatile uint8_t *p = SCREEN + (uint16_t)row * 40;
434
+ for (c = 0; c < 40; c++) p[c] = 0x20;
435
+ draw_text(row, col, s);
436
+ }
437
+
438
+ static void draw_u16(uint8_t row, uint8_t col, uint16_t v) {
439
+ uint8_t i, d[5];
440
+ uint16_t off = (uint16_t)row * 40 + col;
441
+ for (i = 0; i < 5; i++) { d[i] = v % 10; v /= 10; }
442
+ for (i = 0; i < 5; i++) {
443
+ SCREEN[off + i] = (uint8_t)('0' + d[4 - i]); /* digit screen code = ASCII */
444
+ COLORS[off + i] = COLOR_WHITE;
445
+ }
446
+ }
447
+
448
+ /* ── GAME LOGIC (clay) — xorshift-style PRNG (cheap, period 255) ── */
449
+ static uint8_t rng_state = 0xB7;
450
+ static uint8_t rand8(void) {
451
+ uint8_t lsb = (uint8_t)(rng_state & 1);
452
+ rng_state >>= 1;
453
+ if (lsb) rng_state ^= 0xB8;
454
+ return rng_state;
455
+ }
456
+
457
+ /* ── GAME LOGIC (clay) — the starfield ──────────────────────────────────────
458
+ * Two-layer trick for one-layer hardware: screen RAM holds the MOVING chars
459
+ * (stars '.' + nebula blocks), color RAM holds a STATIC color texture. The
460
+ * coarse scroll shifts ONLY screen RAM (color RAM never moves — half the
461
+ * copy cost), so drifting chars pick up each cell's resident color as they
462
+ * pass: free twinkle, deliberately cheap. */
463
+ static uint8_t field_cell(void) {
464
+ uint8_t v = (uint8_t)(rand8() & 0x0F);
465
+ if (v < 5) return 0xA0; /* reverse-space nebula block */
466
+ if (v < 7) return 0x2E; /* '.' star */
467
+ return 0x20; /* empty space */
468
+ }
469
+
470
+ /* Refill ONE field row with fresh stars + its color-texture stripe. Used by
471
+ * state transitions to erase a text band (see paint_field's budget note). */
472
+ static void repaint_field_row(uint8_t r) {
473
+ static const uint8_t tex[8] = {
474
+ COLOR_BLUE, COLOR_DARK_GRAY, COLOR_BLUE, COLOR_LIGHT_BLUE,
475
+ COLOR_BLUE, COLOR_DARK_GRAY, COLOR_WHITE, COLOR_MED_GRAY,
476
+ };
477
+ uint8_t c, t = (uint8_t)(r * 3);
478
+ volatile uint8_t *srow = SCREEN + (uint16_t)r * 40;
479
+ volatile uint8_t *crow = COLORS + (uint16_t)r * 40;
480
+ for (c = 0; c < 40; c++) {
481
+ srow[c] = field_cell();
482
+ crow[c] = tex[(uint8_t)(c + t) & 7];
483
+ }
484
+ }
485
+
486
+ /* BUDGET NOTE — this full repaint runs ONCE, at boot. 880 cells of cc65-
487
+ * generated C (function calls per cell) costs ~50 frames: a WHOLE SECOND of
488
+ * frozen music and ignored input if you call it on every state change (this
489
+ * game's original sin — the title screen ate joystick presses for ~1s).
490
+ * Transitions instead repaint only the rows they wrote text on
491
+ * (repaint_field_row), which keeps every transition inside a few frames. */
492
+ static void paint_field(void) {
493
+ uint8_t r;
494
+ for (r = FIELD_TOP; r < 25; r++) repaint_field_row(r);
495
+ }
69
496
 
70
- static Obj player;
71
- static Obj bullets[MAX_BULLETS];
72
- static Obj enemies[MAX_ENEMIES];
497
+ /* Coarse scroll: shift the playfield one char left, spawn a fresh column at
498
+ * the right edge. Runs on the frame the fine offset wraps (every 8th).
499
+ * SCHEDULING IS THE TRICK: called immediately after wait_frame(), i.e. just
500
+ * after the line-251 IRQ. The beam won't draw playfield row 3 until line 75
501
+ * of the NEXT frame (~8500 cycles away) and then takes 504 cycles per row;
502
+ * this loop spends ~600 cycles per row, so with that head start it stays
503
+ * ahead of the beam the whole way down — no tearing, no double buffer.
504
+ * (The grown-up alternative is page-flipping screen RAM via $D018.) */
505
+ static void scroll_field_left(void) {
506
+ uint8_t r, c;
507
+ volatile uint8_t *row = SCREEN + FIELD_TOP * 40;
508
+ for (r = FIELD_TOP; r < 25; r++) {
509
+ for (c = 0; c < 39; c++) row[c] = row[c + 1];
510
+ row[39] = field_cell();
511
+ row += 40;
512
+ }
513
+ }
514
+
515
+ /* ── GAME LOGIC (clay) — game state ── */
516
+ #define ST_TITLE 0
517
+ #define ST_PLAY 1
518
+ #define ST_OVER 2
519
+ static uint8_t state;
520
+ static uint8_t two_player;
521
+ static uint8_t lives;
522
+ static uint16_t score, hiscore;
523
+ static uint8_t cam; /* starfield scroll counter (low 3 bits = fine) */
524
+
525
+ static int16_t ship_x[2]; static uint8_t ship_y[2], ship_alive[2], ship_inv[2], fire_cd[2];
526
+ static int16_t bullet_x[MAX_BULLETS]; static uint8_t bullet_y[MAX_BULLETS], bullet_on[MAX_BULLETS];
527
+ static int16_t enemy_x[MAX_ENEMIES]; static uint8_t enemy_y[MAX_ENEMIES], enemy_on[MAX_ENEMIES];
73
528
  static uint8_t spawn_timer;
74
- static uint8_t prev_pad;
75
- static uint16_t score;
76
529
 
77
- static uint8_t aabb(Obj *a, Obj *b) {
78
- return a->x < b->x + 12 && a->x + 12 > b->x
79
- && a->y < b->y + 16 && a->y + 16 > b->y;
530
+ /* Sprite coordinate limits (sprite coords: visible X 24-343, Y 50-249).
531
+ * The playfield starts at raster line 75 keep ships/enemies below the bar. */
532
+ #define Y_MIN 78
533
+ #define Y_MAX 225
534
+
535
+ /* ── HARDWARE IDIOM (load-bearing) — staging sprites with the 9th X bit.
536
+ * VIC sprite X is 9 bits: low 8 in $D000+2n, bit 8 for ALL sprites packed
537
+ * into $D010. Forget $D010 and anything past X=255 wraps back to the left
538
+ * edge — the classic "my sprite teleports at two-thirds screen" bug. We
539
+ * accumulate the MSB bits while staging and commit the byte once. ── */
540
+ static uint8_t spr_msb, spr_ena;
541
+ static void stage_begin(void) { spr_msb = 0; spr_ena = 0; }
542
+ static void stage_sprite(uint8_t slot, int16_t x, uint8_t y) {
543
+ POKE(VIC_SPRITE_X(slot), (uint8_t)(x & 0xFF));
544
+ POKE(VIC_SPRITE_Y(slot), y);
545
+ if (x > 255) spr_msb |= (uint8_t)(1 << slot);
546
+ spr_ena |= (uint8_t)(1 << slot);
547
+ }
548
+ static void stage_commit(void) {
549
+ POKE(VIC_SPRITES_X8, spr_msb);
550
+ POKE(VIC_SPR_ENA, spr_ena); /* unstaged slots vanish — no stale sprites */
551
+ }
552
+
553
+ /* ── GAME LOGIC (clay) — score bar (rows 0-1) ── */
554
+ static void draw_bar_labels(void) {
555
+ uint8_t c;
556
+ for (c = 0; c < 40; c++) { /* row 1: solid divider line */
557
+ SCREEN[40 + c] = 0xA0;
558
+ COLORS[40 + c] = COLOR_DARK_GRAY;
559
+ SCREEN[80 + c] = 0x20; /* row 2: the blank spacer the
560
+ * raster split hides in */
561
+ SCREEN[c] = 0x20;
562
+ }
563
+ draw_text(0, 1, "SC");
564
+ draw_text(0, 11, "HI");
565
+ draw_text(0, 21, "LV");
566
+ draw_text(0, 27, two_player ? "2P CO-OP" : "1P ");
567
+ }
568
+ static void draw_bar_stats(void) {
569
+ draw_u16(0, 4, score);
570
+ draw_u16(0, 14, hiscore);
571
+ SCREEN[24] = (uint8_t)('0' + lives);
572
+ COLORS[24] = COLOR_WHITE;
573
+ }
574
+
575
+ /* ── GAME LOGIC (clay) — title / game start / game over ──────────────────────
576
+ * Transition rule (see paint_field's budget note): never repaint the whole
577
+ * field here. The title draws its text on blanked BANDS over whatever
578
+ * starfield is already there; start_game erases exactly those bands back to
579
+ * stars. Every transition stays a few frames — music never hiccups, and a
580
+ * fire press is acted on (visibly) by the next frame or two. */
581
+ static void paint_title(void) {
582
+ draw_bar_labels();
583
+ draw_bar_stats();
584
+ draw_text_band(7, (40 - (sizeof(GAME_TITLE) - 1)) / 2, GAME_TITLE);
585
+ draw_text_band(11, 12, "PORT 2 FIRE - 1P");
586
+ draw_text_band(13, 9, "PORT 1 FIRE - 2P CO-OP");
587
+ draw_text_band(17, 16, "HI");
588
+ draw_u16(17, 19, hiscore);
589
+ field_d016 = D016_BAR; /* title field holds still (text lives in it) */
590
+ POKE(VIC_SPR_ENA, 0);
591
+ state = ST_TITLE;
80
592
  }
81
593
 
82
- static void fire(void) {
594
+ /* The four rows paint_title wrote text bands on (game_over's two are a
595
+ * subset) — start_game turns them back into starfield. */
596
+ static void erase_text_bands(void) {
597
+ repaint_field_row(7);
598
+ repaint_field_row(11);
599
+ repaint_field_row(13);
600
+ repaint_field_row(17);
601
+ }
602
+
603
+ static void start_game(uint8_t players) {
83
604
  uint8_t i;
84
- for (i = 0; i < MAX_BULLETS; i++) {
85
- if (!bullets[i].alive) {
86
- bullets[i].x = player.x;
87
- bullets[i].y = player.y - 10;
88
- bullets[i].alive = 1;
605
+ two_player = players;
606
+ for (i = 0; i < MAX_BULLETS; i++) bullet_on[i] = 0;
607
+ for (i = 0; i < MAX_ENEMIES; i++) enemy_on[i] = 0;
608
+ ship_x[0] = 50; ship_y[0] = two_player ? 110 : 150;
609
+ ship_x[1] = 50; ship_y[1] = 190;
610
+ ship_alive[0] = 1; ship_alive[1] = players;
611
+ ship_inv[0] = ship_inv[1] = 0;
612
+ fire_cd[0] = fire_cd[1] = 0;
613
+ lives = START_LIVES;
614
+ score = 0;
615
+ spawn_timer = 0;
616
+ cam = 0;
617
+ erase_text_bands(); /* NOT paint_field — see its budget note */
618
+ draw_bar_labels();
619
+ draw_bar_stats();
620
+ state = ST_PLAY;
621
+ }
622
+
623
+ static void game_over(void) {
624
+ /* Sprites off FIRST — this runs mid-frame (right after the bottom IRQ),
625
+ * and the beam redraws the screen while we're still writing the text
626
+ * bands below. Killing $D015 before any visible change means the one
627
+ * transition frame never shows sprites parked on top of the message. */
628
+ POKE(VIC_SPR_ENA, 0);
629
+ field_d016 = D016_BAR; /* freeze the field under the message */
630
+ if (score > hiscore) {
631
+ hiscore = score;
632
+ hiscore_save(hiscore); /* the persistence seam — see its block doc */
633
+ draw_bar_stats();
634
+ }
635
+ draw_text_band(11, 15, "GAME OVER");
636
+ draw_text_band(13, 13, "FIRE - TITLE");
637
+ sfx_noise(24);
638
+ state = ST_OVER;
639
+ }
640
+
641
+ /* ── GAME LOGIC (clay) — combat ── */
642
+ static void fire_bullet(uint8_t p) {
643
+ /* 1P mode: P1 owns both bullet slots. 2P: slot per player. */
644
+ uint8_t i = two_player ? p : 0;
645
+ uint8_t end = two_player ? (uint8_t)(p + 1) : MAX_BULLETS;
646
+ for (; i < end; i++) {
647
+ if (!bullet_on[i]) {
648
+ bullet_on[i] = 1;
649
+ bullet_x[i] = ship_x[p] + 20;
650
+ bullet_y[i] = ship_y[p];
651
+ sfx_tone(2, 0x60, 0x28, 4); /* pew — voice 2 (music owns 0+1) */
89
652
  return;
90
653
  }
91
654
  }
92
655
  }
93
656
 
94
- static void spawn(void) {
657
+ static void spawn_enemy(void) {
95
658
  uint8_t i;
96
659
  for (i = 0; i < MAX_ENEMIES; i++) {
97
- if (!enemies[i].alive) {
98
- enemies[i].x = (uint8_t)(48 + ((spawn_timer * 37) & 0xFF) % 240);
99
- enemies[i].y = 30;
100
- enemies[i].alive = 1;
660
+ if (!enemy_on[i]) {
661
+ enemy_on[i] = 1;
662
+ enemy_x[i] = 348; /* just off-screen right */
663
+ enemy_y[i] = (uint8_t)(Y_MIN + (rand8() % (Y_MAX - Y_MIN)));
101
664
  return;
102
665
  }
103
666
  }
104
667
  }
105
668
 
106
- static void wait_vblank(void) {
107
- while (PEEK(VIC_RASTER) < 250) { }
108
- while (PEEK(VIC_RASTER) >= 250) { }
669
+ static uint8_t hits(int16_t ax, uint8_t ay, int16_t bx, uint8_t by) {
670
+ int16_t dx = ax - bx;
671
+ int8_t dy = (int8_t)(ay - by);
672
+ if (dx < 0) dx = -dx;
673
+ if (dy < 0) dy = -dy;
674
+ return (dx < 20) && (dy < 14);
109
675
  }
110
676
 
111
- /* Paint a deep-space backdrop into the 40x25 character matrix so the
112
- * playfield reads as space instead of a flat black void. Every cell gets
113
- * a dithered "nebula" char (reverse-space 0xA0) in one of two dark blues,
114
- * so two colours share the screen and no single colour dominates. A sparse
115
- * scatter of bright '.' stars (drawn as a normal glyph over the dither)
116
- * adds twinkle. Cosmetic only sprites still move over the top. */
117
- static void draw_starfield(void) {
118
- uint16_t i;
119
- uint8_t r, c;
120
- for (i = 0; i < 1000; i++) {
121
- SCREEN[i] = 0xA0; /* solid block fills the cell */
122
- COLORS[i] = ((i ^ (i >> 5)) & 1) ? 0x06 : 0x0B; /* blue / dark grey */
123
- }
124
- /* Scatter stars on a coarse lattice so ~1 in 12 cells twinkles. */
125
- for (r = 1; r < 25; r += 3) {
126
- for (c = (uint8_t)(r * 5u % 7u); c < 40; c += 7) {
127
- SCREEN[r * 40 + c] = 0x2E; /* '.' star glyph */
128
- COLORS[r * 40 + c] = ((r + c) & 1) ? 0x01 : 0x0F; /* white / l.grey */
129
- }
130
- }
677
+ static void update_ship(uint8_t p, uint8_t pad) {
678
+ if (!ship_alive[p]) return;
679
+ if (ship_inv[p]) --ship_inv[p];
680
+ if ((pad & JOY_LEFT) && ship_x[p] > 26) ship_x[p] -= 2;
681
+ if ((pad & JOY_RIGHT) && ship_x[p] < 300) ship_x[p] += 2;
682
+ if ((pad & JOY_UP) && ship_y[p] > Y_MIN) ship_y[p] -= 2;
683
+ if ((pad & JOY_DOWN) && ship_y[p] < Y_MAX) ship_y[p] += 2;
684
+ if ((pad & JOY_FIRE) && fire_cd[p] == 0) { fire_bullet(p); fire_cd[p] = 10; }
685
+ if (fire_cd[p]) --fire_cd[p];
131
686
  }
132
687
 
133
- static void copy_sprite(uint8_t slot, const uint8_t *data) {
688
+ static void copy_sprite_image(uint8_t img, const uint8_t *src) {
134
689
  uint8_t i;
135
- volatile uint8_t *dst = (volatile uint8_t*)(SPRITE_DATA_BASE + slot * 64);
136
- for (i = 0; i < 64; i++) dst[i] = data[i];
690
+ volatile uint8_t *dst = (volatile uint8_t*)SPR_DATA(img);
691
+ for (i = 0; i < 64; i++) dst[i] = src[i];
137
692
  }
138
693
 
139
694
  void main(void) {
140
- uint8_t i, j;
695
+ uint8_t i, p, pad0, pad1, prev0 = 0, prev1 = 0;
141
696
 
142
- POKE(VIC_SPR_ENA, 0); /* sprites off while we set up */
697
+ /* ── HARDWARE IDIOM (load-bearing) boot order. VIC + SID config before
698
+ * the IRQ goes live; sfx_init BEFORE music_init (sfx_init writes a plain
699
+ * volume to $D418, music_init re-asserts the filter-mode bits on top). ── */
700
+ POKE(VIC_SPR_ENA, 0);
701
+ POKE(VIC_BORDER, COLOR_BLACK);
702
+ POKE(VIC_BG0, COLOR_BLACK);
703
+ POKE(VIC_CTRL2, D016_BAR); /* 38-col mode from the start */
704
+ copy_sprite_image(IMG_SHIP, ship_sprite);
705
+ copy_sprite_image(IMG_BULLET, bullet_sprite);
706
+ copy_sprite_image(IMG_ENEMY, enemy_sprite);
707
+ SPRITE_POINTERS[SLOT_P1] = SPR_PTR(IMG_SHIP);
708
+ SPRITE_POINTERS[SLOT_P2] = SPR_PTR(IMG_SHIP);
709
+ for (i = 0; i < MAX_BULLETS; i++) SPRITE_POINTERS[SLOT_BULLET0 + i] = SPR_PTR(IMG_BULLET);
710
+ for (i = 0; i < MAX_ENEMIES; i++) SPRITE_POINTERS[SLOT_ENEMY0 + i] = SPR_PTR(IMG_ENEMY);
711
+ POKE(VIC_SPR_COL(SLOT_P1), COLOR_CYAN);
712
+ POKE(VIC_SPR_COL(SLOT_P2), COLOR_YELLOW);
713
+ for (i = 0; i < MAX_BULLETS; i++) POKE(VIC_SPR_COL(SLOT_BULLET0 + i), COLOR_WHITE);
714
+ for (i = 0; i < MAX_ENEMIES; i++) POKE(VIC_SPR_COL(SLOT_ENEMY0 + i), COLOR_LIGHT_RED);
715
+ POKE(CIA1_DDRA, 0xFF); /* port A drives keyboard columns */
716
+ POKE(CIA1_DDRB, 0x00); /* port B reads rows / stick 1 */
143
717
 
144
- copy_sprite(0, ship_sprite);
145
- copy_sprite(1, bullet_sprite);
146
- copy_sprite(2, enemy_sprite);
718
+ sfx_init();
719
+ music_init();
720
+ hiscore = hiscore_load(); /* 0 until the core save round lands */
147
721
 
148
- /* Sprite pointers: slot N points at /64 index into the VIC bank.
149
- * Default bank = $0000-$3FFF. $0800 / 64 = 32 = $20. */
150
- SPRITE_POINTERS[SLOT_PLAYER] = 0x80; /* $2000/64 */
151
- for (i = 0; i < MAX_BULLETS; i++) SPRITE_POINTERS[SLOT_BULLET0 + i] = 0x81;
152
- for (i = 0; i < MAX_ENEMIES; i++) SPRITE_POINTERS[SLOT_ENEMY0 + i] = 0x82;
722
+ field_d016 = D016_BAR;
723
+ paint_field(); /* the ONE full-field paint (boot) */
724
+ install_raster_irq(); /* the split + heartbeat go live */
725
+ paint_title();
153
726
 
154
- POKE(VIC_SPR_COL(SLOT_PLAYER), 0x07); /* yellow */
155
- for (i = 0; i < MAX_BULLETS; i++) POKE(VIC_SPR_COL(SLOT_BULLET0 + i), 0x01); /* white */
156
- for (i = 0; i < MAX_ENEMIES; i++) POKE(VIC_SPR_COL(SLOT_ENEMY0 + i), 0x02); /* red */
727
+ for (;;) {
728
+ wait_frame(); /* the line-251 IRQ paces everything */
157
729
 
158
- POKE(VIC_BORDER, 0x00); /* black border frames the starfield */
159
- POKE(VIC_BG0, 0x06); /* deep-blue space background */
160
- draw_starfield(); /* paint the textured space backdrop */
730
+ /* Scroll bookkeeping FIRST: field_d016 must be settled long before the
731
+ * beam reaches SPLIT_LINE, and the coarse shift needs its head start on
732
+ * the beam (see scroll_field_left). */
733
+ if (state == ST_PLAY) {
734
+ ++cam;
735
+ field_d016 = (uint8_t)(D016_BAR | (7 - (cam & 7)));
736
+ if ((cam & 7) == 0) scroll_field_left();
737
+ }
161
738
 
162
- player.x = 152; player.y = 200; player.alive = 1;
163
- for (i = 0; i < MAX_BULLETS; i++) bullets[i].alive = 0;
164
- for (i = 0; i < MAX_ENEMIES; i++) enemies[i].alive = 0;
165
- score = 0;
166
- spawn_timer = 0;
167
- prev_pad = 0;
168
- sfx_init();
739
+ music_update();
740
+ sfx_update();
741
+ pad0 = read_stick_port2(); /* P1 control port 2 (convention) */
742
+ pad1 = read_stick_port1(); /* P2 — control port 1 */
169
743
 
170
- /* Enable all 8 sprite slots. */
171
- POKE(VIC_SPR_ENA, 0xFF);
744
+ if (state == ST_TITLE) {
745
+ /* Mode select doubles as a controls demo: the stick that presses
746
+ * FIRE picks the mode — port 2 starts 1P, port 1 starts 2P co-op. */
747
+ if ((pad0 & JOY_FIRE) && !(prev0 & JOY_FIRE)) start_game(0);
748
+ else if ((pad1 & JOY_FIRE) && !(prev1 & JOY_FIRE)) start_game(1);
749
+ prev0 = pad0; prev1 = pad1;
750
+ continue;
751
+ }
172
752
 
173
- for (;;) {
174
- uint8_t pad = (uint8_t)(~PEEK(CIA1_PRA) & 0x1F);
175
- wait_vblank();
176
- sfx_update();
753
+ if (state == ST_OVER) {
754
+ if (((pad0 & JOY_FIRE) && !(prev0 & JOY_FIRE)) ||
755
+ ((pad1 & JOY_FIRE) && !(prev1 & JOY_FIRE))) paint_title();
756
+ prev0 = pad0; prev1 = pad1;
757
+ continue;
758
+ }
177
759
 
178
- if ((pad & JOY_LEFT) && player.x > 24) player.x -= 2;
179
- if ((pad & JOY_RIGHT) && player.x < 240) player.x += 2;
180
- if ((pad & JOY_UP) && player.y > 50) player.y -= 2;
181
- if ((pad & JOY_DOWN) && player.y < 230) player.y += 2;
182
- if ((pad & JOY_FIRE) && !(prev_pad & JOY_FIRE)) { fire(); sfx_tone(0, 0x80, 0x20, 4); }
183
- prev_pad = pad;
760
+ /* ── ST_PLAY GAME LOGIC (clay) from here down ─────────────────── */
761
+ update_ship(0, pad0);
762
+ if (two_player) update_ship(1, pad1);
763
+ prev0 = pad0; prev1 = pad1;
184
764
 
185
765
  for (i = 0; i < MAX_BULLETS; i++) {
186
- if (!bullets[i].alive) continue;
187
- if (bullets[i].y < 50) { bullets[i].alive = 0; continue; }
188
- bullets[i].y -= 4;
766
+ if (!bullet_on[i]) continue;
767
+ bullet_x[i] += 6;
768
+ if (bullet_x[i] > 344) bullet_on[i] = 0;
189
769
  }
770
+
190
771
  for (i = 0; i < MAX_ENEMIES; i++) {
191
- if (!enemies[i].alive) continue;
192
- enemies[i].y += 1;
193
- if (enemies[i].y >= 245) enemies[i].alive = 0;
772
+ uint8_t ty;
773
+ if (!enemy_on[i]) continue;
774
+ enemy_x[i] -= 1 + (score >= 300) + (score >= 800); /* speed up w/ score */
775
+ /* Seek the (alive) P1's altitude every other frame — pressure that
776
+ * also guarantees collisions actually happen. */
777
+ ty = ship_alive[0] ? ship_y[0] : ship_y[1];
778
+ if (frame_count & 1) {
779
+ if (enemy_y[i] < ty) ++enemy_y[i];
780
+ else if (enemy_y[i] > ty) --enemy_y[i];
781
+ }
782
+ if (enemy_x[i] < 4) enemy_on[i] = 0; /* slipped past */
194
783
  }
195
- spawn_timer++;
196
- if (spawn_timer >= 32) { spawn_timer = 0; spawn(); }
197
784
 
785
+ ++spawn_timer;
786
+ if (spawn_timer >= 40) { spawn_timer = 0; spawn_enemy(); }
787
+
788
+ /* Bullets ↔ enemies. */
198
789
  for (i = 0; i < MAX_BULLETS; i++) {
199
- if (!bullets[i].alive) continue;
200
- for (j = 0; j < MAX_ENEMIES; j++) {
201
- if (!enemies[j].alive) continue;
202
- if (aabb(&bullets[i], &enemies[j])) {
203
- bullets[i].alive = 0;
204
- enemies[j].alive = 0;
205
- if (score < 65500u) score += 10;
206
- sfx_noise(8);
790
+ uint8_t e;
791
+ if (!bullet_on[i]) continue;
792
+ for (e = 0; e < MAX_ENEMIES; e++) {
793
+ if (!enemy_on[e]) continue;
794
+ if (hits(bullet_x[i], bullet_y[i], enemy_x[e], enemy_y[e])) {
795
+ bullet_on[i] = 0;
796
+ enemy_on[e] = 0;
797
+ score += 10;
798
+ sfx_noise(6); /* boom */
799
+ draw_bar_stats();
207
800
  break;
208
801
  }
209
802
  }
210
803
  }
211
804
 
212
- /* Stage VIC sprite positions. */
213
- POKE(VIC_SPRITE_X(SLOT_PLAYER), player.x);
214
- POKE(VIC_SPRITE_Y(SLOT_PLAYER), player.y);
215
- for (i = 0; i < MAX_BULLETS; i++) {
216
- uint8_t sx = bullets[i].alive ? bullets[i].x : 0;
217
- uint8_t sy = bullets[i].alive ? bullets[i].y : 0;
218
- POKE(VIC_SPRITE_X(SLOT_BULLET0 + i), sx);
219
- POKE(VIC_SPRITE_Y(SLOT_BULLET0 + i), sy);
220
- }
805
+ /* Enemies ships: shared life pool (arcade co-op). */
221
806
  for (i = 0; i < MAX_ENEMIES; i++) {
222
- uint8_t ex = enemies[i].alive ? enemies[i].x : 0;
223
- uint8_t ey = enemies[i].alive ? enemies[i].y : 0;
224
- POKE(VIC_SPRITE_X(SLOT_ENEMY0 + i), ex);
225
- POKE(VIC_SPRITE_Y(SLOT_ENEMY0 + i), ey);
807
+ if (!enemy_on[i]) continue;
808
+ for (p = 0; p < 2; p++) {
809
+ if (!ship_alive[p] || ship_inv[p]) continue;
810
+ if (hits(enemy_x[i], enemy_y[i], ship_x[p], ship_y[p])) {
811
+ enemy_on[i] = 0;
812
+ sfx_noise(16);
813
+ if (lives) --lives;
814
+ draw_bar_stats();
815
+ if (lives == 0) { game_over(); break; }
816
+ ship_x[p] = 50; /* knockback respawn + mercy frames */
817
+ ship_inv[p] = 90;
818
+ }
819
+ }
820
+ if (state != ST_PLAY) break;
226
821
  }
822
+ if (state != ST_PLAY) continue;
823
+
824
+ /* Stage all 8 sprite slots, then commit enable + X-MSB in one go.
825
+ * Invulnerable ships blink by skipping their slot every few frames. */
826
+ stage_begin();
827
+ for (p = 0; p < 2; p++)
828
+ if (ship_alive[p] && !(ship_inv[p] & 4))
829
+ stage_sprite(p ? SLOT_P2 : SLOT_P1, ship_x[p], ship_y[p]);
830
+ for (i = 0; i < MAX_BULLETS; i++)
831
+ if (bullet_on[i]) stage_sprite(SLOT_BULLET0 + i, bullet_x[i], bullet_y[i]);
832
+ for (i = 0; i < MAX_ENEMIES; i++)
833
+ if (enemy_on[i]) stage_sprite(SLOT_ENEMY0 + i, enemy_x[i], enemy_y[i]);
834
+ stage_commit();
227
835
  }
228
836
  }