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,186 +1,868 @@
1
- /* shmup.c — Atari 7800 vertical-shooter (minimal player + bullets).
1
+ /* ── shmup.c — Atari 7800 dense-field shooter (complete example game) ────────
2
2
  *
3
- * SCAFFOLD CAVEAT: the original "shmup with 4 enemies + 4 bullets"
4
- * exceeded the per-scanline DL pool budget within 7800's 2 KB RAM1
5
- * (a per-scanline pool with computed row addresses ran into a
6
- * cc65 BSS placement issue that needs further investigation).
3
+ * A COMPLETE, working game title screen, 1P and 2P co-op modes, lives,
4
+ * score + session hi-score, music + SFX, and the 7800's signature feature:
5
+ * MARIA SPRITE QUANTITY. 24 meteors + 2 ships + 4 shots = 30 independent
6
+ * moving objects on screen at once a field no 2600 (5 hardware objects)
7
+ * and no stock NES (8-sprites-per-scanline flicker) draws this comfortably.
8
+ * On the 7800 every object is just a 4-byte display-list entry that MARIA
9
+ * DMAs each scanline; quantity is the whole point of the chip.
7
10
  *
8
- * This minimal scaffold demonstrates: player ship that moves with
9
- * the joystick, fires bullets on FIRE button. Bullets travel up and
10
- * disappear at top. Extend with enemies + collision once the
11
- * per-scanline-pool approach is debugged.
11
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
12
+ * very different one. The markers tell you what's what:
13
+ * HARDWARE IDIOM (load-bearing) dodges a documented 7800/MARIA footgun;
14
+ * reshape your gameplay around it (see TROUBLESHOOTING before changing).
15
+ * GAME LOGIC (clay) — meteor patterns, scoring, tuning, art: reshape freely.
12
16
  *
13
- * Uses the same per-row DL pattern as default.c (1-scanline zones,
14
- * 7-byte DLs, 243-entry DLL) see MENTAL_MODEL.md for the format.
17
+ * What depends on what:
18
+ * atari7800_sfx.{h,c} TIA one-shot effects (we give it voice 1; the
19
+ * inline music player below owns voice 0 — TIA only HAS two voices).
20
+ * cc65's atari7800 target crt0 + atari7800.cfg — boot, BSS in RAM1
21
+ * ($1800-$203F), C parameter stack at the TOP of RAM3 growing DOWN
22
+ * ($2800 →). This game claims the BOTTOM of RAM3 ($2200-$25FD) for its
23
+ * display-list pool — see the RAM MAP below before moving anything.
24
+ *
25
+ * PERSISTENCE — honest note: the canonical 7800 save path is the High Score
26
+ * Cart (HSC): a pass-through cartridge with 2KB battery RAM at $1000-$17FF
27
+ * plus a directory ROM. The bundled prosystem core does NOT implement HSC
28
+ * (probed 2026-06: retro_get_memory(SAVE_RAM) size = 0, and the core binary
29
+ * has no HSC code at all), so this game keeps the hi-score IN-SESSION ONLY
30
+ * (it survives play → title → play, dies on power-off). Do not fake
31
+ * persistence the hardware path can't back — if a future core round adds
32
+ * HSC, wire hiscore into $1000-$17FF and it becomes real.
33
+ *
34
+ * Frame budget (NTSC): with ~125 emitted object-rows the per-tick update
35
+ * fits in one 60Hz frame, stretching to two on heavy frames (collision
36
+ * sweep + HUD redraw) — vblank_wait() paces the sim at 60Hz, dipping to
37
+ * 30 under load: the classic 8-bit pattern. MARIA does not care — it
38
+ * re-walks the same DLs every frame, so a slow CPU loop never blanks or
39
+ * tears the whole screen. That budget only holds because of the
40
+ * #pragma optimize(on) right below — read its comment before deleting it.
41
+ * The trade (object quantity vs sim rate) is THE 7800 design dial; see
42
+ * the DMA-budget comment at the display-list pool.
15
43
  */
44
+
16
45
  #include <stdint.h>
46
+ #include <string.h>
17
47
  #include "atari7800_sfx.h"
18
48
 
49
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
50
+ * cc65 SHIPS WITH ITS OPTIMIZER OFF, and this toolchain does not pass -O —
51
+ * each translation unit must opt in. Without this pragma the unoptimized
52
+ * emit pass made the main loop take ~9 frames per sim tick instead of 1-2
53
+ * (measured: 8.8 → 1.7 frames/tick on prosystem), and every TICK-DENOMINATED
54
+ * timer silently stretched 4-5x in wall-clock terms: the 60-tick spawn
55
+ * shield lasted ~9 seconds, and its 4-ticks-hidden blink kept BOTH ships
56
+ * (inv[] starts equal, so they blink in sync) off screen for ~600ms at a
57
+ * time. That presents as "ships missing / display corruption" — but the
58
+ * DLL, the zone pointers, and every pool slot were byte-perfect when read
59
+ * back from RAM. The footgun generalizes: on a 1.79MHz 6502 the C
60
+ * optimizer is not a nicety, it IS the frame budget, and a too-slow loop
61
+ * shows up as broken GAME RULES (stretched timers, missed 1-frame input
62
+ * edges), not as a slow-looking screen — MARIA keeps repainting the same
63
+ * display lists at a rock-steady 60Hz no matter how far behind the CPU
64
+ * falls. If your fork feels like molasses or "ignores" short button taps,
65
+ * check this pragma is still here before debugging the display lists. */
66
+ #pragma optimize(on)
67
+
68
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
69
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
70
+ #define GAME_TITLE "COMET FLURRY"
71
+
72
+ /* ── MARIA + TIA + RIOT registers (full list in MENTAL_MODEL.md) ── */
19
73
  #define BACKGRND (*(volatile uint8_t*)0x20)
20
74
  #define P0C1 (*(volatile uint8_t*)0x21)
21
75
  #define P0C2 (*(volatile uint8_t*)0x22)
22
76
  #define P0C3 (*(volatile uint8_t*)0x23)
23
77
  #define P1C1 (*(volatile uint8_t*)0x25)
24
- #define P2C1 (*(volatile uint8_t*)0x29)
78
+ #define P1C2 (*(volatile uint8_t*)0x26)
79
+ #define P1C3 (*(volatile uint8_t*)0x27)
25
80
  #define MSTAT (*(volatile uint8_t*)0x28)
81
+ #define P2C1 (*(volatile uint8_t*)0x29)
82
+ #define P2C2 (*(volatile uint8_t*)0x2A)
83
+ #define P2C3 (*(volatile uint8_t*)0x2B)
26
84
  #define DPPH (*(volatile uint8_t*)0x2C)
85
+ #define P3C1 (*(volatile uint8_t*)0x2D)
86
+ #define P3C2 (*(volatile uint8_t*)0x2E)
87
+ #define P3C3 (*(volatile uint8_t*)0x2F)
27
88
  #define DPPL (*(volatile uint8_t*)0x30)
89
+ #define P4C1 (*(volatile uint8_t*)0x31)
90
+ #define P4C3 (*(volatile uint8_t*)0x33)
28
91
  #define CHARBASE (*(volatile uint8_t*)0x34)
92
+ #define P5C1 (*(volatile uint8_t*)0x35)
29
93
  #define OFFSET (*(volatile uint8_t*)0x38)
94
+ #define P6C1 (*(volatile uint8_t*)0x39)
30
95
  #define CTRL (*(volatile uint8_t*)0x3C)
31
- #define SWCHA (*(volatile uint8_t*)0x280)
32
- #define INPT4 (*(volatile uint8_t*)0x0C)
33
-
34
- #define JOY_UP 0x80
35
- #define JOY_DOWN 0x40
36
- #define JOY_LEFT 0x20
37
- #define JOY_RIGHT 0x10
38
-
39
- /* Ship sprite — 16 px wide × 8 rows. */
40
- static const uint8_t ship_row0[4] = { 0x00, 0x05, 0x50, 0x00 };
41
- static const uint8_t ship_row1[4] = { 0x00, 0x5A, 0xA5, 0x00 };
42
- static const uint8_t ship_row2[4] = { 0x05, 0xAA, 0xAA, 0x50 };
43
- static const uint8_t ship_row3[4] = { 0x5A, 0xFF, 0xFF, 0xA5 };
44
- static const uint8_t ship_row4[4] = { 0xAA, 0xFF, 0xFF, 0xAA };
45
- static const uint8_t ship_row5[4] = { 0xAA, 0xAA, 0xAA, 0xAA };
46
- static const uint8_t ship_row6[4] = { 0x05, 0x05, 0x50, 0x50 };
47
- static const uint8_t ship_row7[4] = { 0x00, 0x05, 0x50, 0x00 };
48
-
49
- #define MK_DL(name) static uint8_t name[7] = { 0, 0x40, 0, 0x1C, 80, 0, 0 }
50
- MK_DL(dl_row0); MK_DL(dl_row1); MK_DL(dl_row2); MK_DL(dl_row3);
51
- MK_DL(dl_row4); MK_DL(dl_row5); MK_DL(dl_row6); MK_DL(dl_row7);
96
+ #define P7C1 (*(volatile uint8_t*)0x3D)
52
97
 
53
- static uint8_t dl_empty[2] = { 0, 0 };
98
+ /* TIA audio (shared with the music player below; atari7800_sfx.c has the
99
+ * same defines — the chip is tiny enough that duplicating 6 lines beats a
100
+ * header dependency the fork machinery would have to carry). */
101
+ #define AUDC0 (*(volatile uint8_t*)0x15)
102
+ #define AUDC1 (*(volatile uint8_t*)0x16)
103
+ #define AUDF0 (*(volatile uint8_t*)0x17)
104
+ #define AUDF1 (*(volatile uint8_t*)0x18)
105
+ #define AUDV0 (*(volatile uint8_t*)0x19)
106
+ #define AUDV1 (*(volatile uint8_t*)0x1A)
107
+
108
+ #define SWCHA (*(volatile uint8_t*)0x280)
109
+ #define INPT4 (*(volatile uint8_t*)0x0C) /* P1 fire, active low (bit 7) */
110
+ #define INPT5 (*(volatile uint8_t*)0x0D) /* P2 fire, active low (bit 7) */
111
+
112
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
113
+ * SWCHA joystick bit order — the #1 7800 input footgun. After the ~SWCHA
114
+ * invert, port 0 (left jack) lives in the HIGH nibble as
115
+ * Right($80) Left($40) Down($20) Up($10), and port 1 (right jack) in the
116
+ * LOW nibble as Right($08) Left($04) Down($02) Up($01). Writing the masks
117
+ * in "natural reading order" (UP=0x80…) is exactly REVERSED and makes the
118
+ * stick's vertical axis steer horizontally — a bug weird enough to
119
+ * misdiagnose as a core problem. Verified bit-by-bit against prosystem. */
120
+ #define J1_RIGHT 0x80
121
+ #define J1_LEFT 0x40
122
+ #define J1_DOWN 0x20
123
+ #define J1_UP 0x10
124
+ #define J2_RIGHT 0x08
125
+ #define J2_LEFT 0x04
126
+ #define J2_DOWN 0x02
127
+ #define J2_UP 0x01
54
128
 
55
- /* ── Background playfield ─────────────────────────────────────────
56
- * Without a full-screen drawable the display list emits only the
57
- * ship and ~99% of the screen stays the flat BACKGRND colour (reads
58
- * as "blank"). These full-width bands fill the non-ship zones with a
59
- * starfield-style background so the frame has real content.
129
+ /* ════════════════════════════════════════════════════════════════════════
130
+ * RAM MAP the 7800 gives you 4KB ($1800-$27FF) and the stock cc65 config
131
+ * only hands the linker the first 2112 bytes of it:
60
132
  *
61
- * A single DL drawable is at most 32 bytes = 128 px wide, so a full
62
- * 160-px line needs TWO drawables. Width = byte[3] low 5 bits (32-n);
63
- * high 3 bits = palette. */
64
- static const uint8_t band_pix[32] = {
65
- 0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,
66
- 0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55
133
+ * $1800-$203F RAM1 — cc65 DATA + BSS (everything `static` below)
134
+ * $2040-$20FF (gap the cc65 cfg skips unused here)
135
+ * $2100-$213F RAM2 — unused here
136
+ * $2200-$25FD RAM3 bottom OUR display-list pool/canvas arena (POOLB):
137
+ * raw pointer, invisible to the linker, 1022 bytes
138
+ * $25FE-$27FF RAM3 top — cc65 C parameter stack (crt0 starts it at $2800
139
+ * growing DOWN; ~510 bytes is plenty for these call depths,
140
+ * but if you add deep recursion, shrink POOLB_LINES first)
141
+ * ════════════════════════════════════════════════════════════════════════ */
142
+ #define POOLB ((uint8_t*)0x2200)
143
+
144
+ /* ── Screen layout (243 NTSC zone-lines; the visible frame is ~lines 9-232) ──
145
+ * lines 0- 15 blank (top overscan) 1 DLL entry, 16 tall
146
+ * lines 16- 23 HUD text row (RAM canvas) 8 entries, 1 tall each
147
+ * lines 24- 25 divider band 1 entry, 2 tall
148
+ * lines 26-145 THE FIELD — 120 one-line zones 120 entries (the pool)
149
+ * lines 146-147 divider band 1 entry, 2 tall
150
+ * lines 148-242 decor stripes (planet glow) 12 entries, 8/7 tall
151
+ * Total: 143 DLL entries = 429 bytes (vs 729 for the naive all-1-line DLL —
152
+ * mixed zone heights are how real 7800 games keep the DLL small). */
153
+ #define FIELD_LINES 120
154
+ #define FIELD_DLL_OFF 30 /* byte offset of field entry 0 in dll[] */
155
+
156
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
157
+ * Object art. 160A mode: 1 byte = 4 pixels of 2 bits each; pixel value
158
+ * 1/2/3 = colour 1/2/3 of the palette the DL entry names, 0 = transparent.
159
+ * Rows are stored top-down, consecutive (the 1-scanline-zone pattern below
160
+ * means NO page-alignment dance — see "offset addressing quirk" in
161
+ * MENTAL_MODEL.md for what multi-line zones would demand instead). */
162
+
163
+ /* Player ship, 12px wide (3 bytes) x 8 rows. Colours: 1 hull, 2 canopy,
164
+ * 3 highlight. Drawn with palette 1 (P1 ship) or 2 (P2 ship). */
165
+ static const uint8_t GFX_SHIP[8 * 3] = {
166
+ 0x00, 0x0C, 0x00, /* 33 */
167
+ 0x00, 0x2D, 0x80, /* 2331 */
168
+ 0x00, 0x69, 0x60, /* 12 21 1 */
169
+ 0x01, 0x69, 0x64, /* 112 21 11 */
170
+ 0x05, 0x69, 0x65, /* 1112 21 111 */
171
+ 0x16, 0xAA, 0x95, /* 11222222 2111 */
172
+ 0x55, 0x55, 0x55, /* 111111111111 */
173
+ 0x10, 0x41, 0x04, /* 1 1 1 */
67
174
  };
175
+
176
+ /* Meteor, 8px wide (2 bytes) x 4 rows. Colour 1 core / 2 rim / 3 flash. */
177
+ static const uint8_t GFX_METEOR[4 * 2] = {
178
+ 0x29, 0x60, /* 2 31 2 */
179
+ 0xA5, 0x58, /* 2211 112 */
180
+ 0x96, 0x5A, /* 2112 1122 */
181
+ 0x29, 0xA0, /* 2 1 22 */
182
+ };
183
+
184
+ /* Shot, 4px wide (1 byte) x 3 rows — a thin colour-1 streak. */
185
+ static const uint8_t GFX_SHOT[3] = { 0x14, 0x14, 0x14 };
186
+
187
+ /* DL mode bytes for the 4-byte (direct) entry form: palette in bits 5-7,
188
+ * width as (32 - width_bytes) in bits 0-4 (must be non-zero — a zero low
189
+ * 5 bits would make MARIA parse a 5-byte entry instead). */
190
+ #define MODE_SHIP1 ((1u << 5) | (32 - 3)) /* palette 1, 3 bytes wide */
191
+ #define MODE_SHIP2 ((2u << 5) | (32 - 3)) /* palette 2 */
192
+ #define MODE_METEOR ((3u << 5) | (32 - 2)) /* palette 3, 2 bytes wide */
193
+ #define MODE_SHOT ((4u << 5) | (32 - 1)) /* palette 4, 1 byte wide */
194
+
195
+ /* ── GAME LOGIC (clay) — 8x8 text font, 1 bit per pixel, 7px glyphs.
196
+ * The 7800 has NO text mode and no tilemap; text is just more objects.
197
+ * The text path here: expand glyphs into a 32-byte-wide RAM canvas
198
+ * (= 128px, 16 characters), then show the canvas with ONE wide DL entry
199
+ * per scanline. One drawable per line beats one-DL-entry-per-character
200
+ * by 16x in MARIA DMA time. Index order: 0-9 A-Z dash space. */
201
+ static const uint8_t FONT[38 * 8] = {
202
+ 0x70,0x88,0x98,0xA8,0xC8,0x88,0x70,0x00, /* 0 */
203
+ 0x20,0x60,0x20,0x20,0x20,0x20,0x70,0x00, /* 1 */
204
+ 0x70,0x88,0x08,0x30,0x40,0x80,0xF8,0x00, /* 2 */
205
+ 0x70,0x88,0x08,0x30,0x08,0x88,0x70,0x00, /* 3 */
206
+ 0x10,0x30,0x50,0x90,0xF8,0x10,0x10,0x00, /* 4 */
207
+ 0xF8,0x80,0xF0,0x08,0x08,0x88,0x70,0x00, /* 5 */
208
+ 0x30,0x40,0x80,0xF0,0x88,0x88,0x70,0x00, /* 6 */
209
+ 0xF8,0x08,0x10,0x20,0x40,0x40,0x40,0x00, /* 7 */
210
+ 0x70,0x88,0x88,0x70,0x88,0x88,0x70,0x00, /* 8 */
211
+ 0x70,0x88,0x88,0x78,0x08,0x10,0x60,0x00, /* 9 */
212
+ 0x20,0x50,0x88,0x88,0xF8,0x88,0x88,0x00, /* A */
213
+ 0xF0,0x88,0x88,0xF0,0x88,0x88,0xF0,0x00, /* B */
214
+ 0x70,0x88,0x80,0x80,0x80,0x88,0x70,0x00, /* C */
215
+ 0xF0,0x88,0x88,0x88,0x88,0x88,0xF0,0x00, /* D */
216
+ 0xF8,0x80,0x80,0xF0,0x80,0x80,0xF8,0x00, /* E */
217
+ 0xF8,0x80,0x80,0xF0,0x80,0x80,0x80,0x00, /* F */
218
+ 0x70,0x88,0x80,0xB8,0x88,0x88,0x70,0x00, /* G */
219
+ 0x88,0x88,0x88,0xF8,0x88,0x88,0x88,0x00, /* H */
220
+ 0x70,0x20,0x20,0x20,0x20,0x20,0x70,0x00, /* I */
221
+ 0x38,0x10,0x10,0x10,0x10,0x90,0x60,0x00, /* J */
222
+ 0x88,0x90,0xA0,0xC0,0xA0,0x90,0x88,0x00, /* K */
223
+ 0x80,0x80,0x80,0x80,0x80,0x80,0xF8,0x00, /* L */
224
+ 0x88,0xD8,0xA8,0xA8,0x88,0x88,0x88,0x00, /* M */
225
+ 0x88,0xC8,0xA8,0x98,0x88,0x88,0x88,0x00, /* N */
226
+ 0x70,0x88,0x88,0x88,0x88,0x88,0x70,0x00, /* O */
227
+ 0xF0,0x88,0x88,0xF0,0x80,0x80,0x80,0x00, /* P */
228
+ 0x70,0x88,0x88,0x88,0xA8,0x90,0x68,0x00, /* Q */
229
+ 0xF0,0x88,0x88,0xF0,0xA0,0x90,0x88,0x00, /* R */
230
+ 0x78,0x80,0x80,0x70,0x08,0x08,0xF0,0x00, /* S */
231
+ 0xF8,0x20,0x20,0x20,0x20,0x20,0x20,0x00, /* T */
232
+ 0x88,0x88,0x88,0x88,0x88,0x88,0x70,0x00, /* U */
233
+ 0x88,0x88,0x88,0x88,0x88,0x50,0x20,0x00, /* V */
234
+ 0x88,0x88,0x88,0xA8,0xA8,0xD8,0x88,0x00, /* W */
235
+ 0x88,0x88,0x50,0x20,0x50,0x88,0x88,0x00, /* X */
236
+ 0x88,0x88,0x50,0x20,0x20,0x20,0x20,0x00, /* Y */
237
+ 0xF8,0x08,0x10,0x20,0x40,0x80,0xF8,0x00, /* Z */
238
+ 0x00,0x00,0x00,0x78,0x00,0x00,0x00,0x00, /* - */
239
+ 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, /* space */
240
+ };
241
+ /* nibble → 2bpp expansion: each 1 bit becomes pixel value 1 (palette c1) */
242
+ static const uint8_t NIB2[16] = {
243
+ 0x00,0x01,0x04,0x05,0x10,0x11,0x14,0x15,
244
+ 0x40,0x41,0x44,0x45,0x50,0x51,0x54,0x55,
245
+ };
246
+
247
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
248
+ * Solid band drawable for multi-line zones. Inside a zone of height H,
249
+ * MARIA fetches scanline l's pixels from ADDR + (H-1-l)*256 — the "offset
250
+ * addressing quirk". A multi-line drawable therefore needs valid data at
251
+ * the SAME low-byte offset across H consecutive 256-byte pages. For solid
252
+ * colour bands we sidestep alignment entirely: a 2KB ROM run of 0x55 means
253
+ * ANY address inside the first page works for zones up to 8 tall (8 pages
254
+ * x 256). Costs 2KB of a 32KB cart — ROM is the cheap resource here.
255
+ * (Real games use this page layout for big multi-line sprites too; our
256
+ * moving objects instead live in 1-line zones where the quirk vanishes.) */
257
+ #define S16 0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55
258
+ #define S256 S16,S16,S16,S16,S16,S16,S16,S16,S16,S16,S16,S16,S16,S16,S16,S16
259
+ static const uint8_t SOLID8[2048] = { S256,S256,S256,S256,S256,S256,S256,S256 };
260
+
261
+ /* Full-width band DL: a DL drawable is at most 32 bytes (128px), so a
262
+ * 160px line takes TWO 5-byte entries + terminator = 11 bytes. 5-byte
263
+ * form: lo, $40 (extended, write-mode 0 = 160A), hi, palette|width, X.
264
+ * Width 32 encodes as 0 in the low 5 bits — legal ONLY in 5-byte form. */
68
265
  #define MK_BAND(name, pal) static uint8_t name[11] = { \
69
- 0, 0x40, 0, ((pal) << 5) | 0, 0, /* 128 px @ x0 */ \
70
- 0, 0x40, 0, ((pal) << 5) | 24, 128, /* 32 px @ x128 */ \
266
+ 0, 0x40, 0, ((pal) << 5) | 0, 0, /* 128px @ x=0 */ \
267
+ 0, 0x40, 0, ((pal) << 5) | 24, 128, /* 32px @ x=128 */ \
71
268
  0 }
72
- MK_BAND(dl_field, 1);
73
- MK_BAND(dl_ground, 2);
74
- #define GROUND_ZONE 188
269
+ MK_BAND(dl_band_a, 6);
270
+ MK_BAND(dl_band_b, 7);
271
+ static uint8_t dl_empty[2] = { 0, 0 };
75
272
 
76
- static void set_band_addr(uint8_t* dl) {
77
- uint16_t a = (uint16_t)(uintptr_t)band_pix;
78
- dl[0] = dl[5] = (uint8_t)(a & 0xFF);
79
- dl[2] = dl[7] = (uint8_t)(a >> 8);
273
+ /* ════════════════════════════════════════════════════════════════════════
274
+ * ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
275
+ * THE DISPLAY-LIST POOL — how 30 objects get drawn (the 7800's signature).
276
+ *
277
+ * MARIA hierarchy refresher: DPP → DLL (one entry per ZONE: height + DL
278
+ * pointer) → DL (one 4/5-byte entry per OBJECT crossing that zone) → pixel
279
+ * bytes. There is no sprite table; "an object" IS a DL entry.
280
+ *
281
+ * The field is 120 one-scanline zones. Each has a fixed 14-byte DL slot:
282
+ * room for THREE 4-byte object entries + the terminator byte (MARIA reads
283
+ * the NEXT entry's mode byte after each entry; a 0 there ends the line —
284
+ * forget the terminator and MARIA walks into garbage and the screen dies).
285
+ *
286
+ * Every frame, object-major (NOT line-major — a 120-line x 30-object scan
287
+ * would be ~3600 checks; emitting 30 objects' ~125 rows is 30x cheaper):
288
+ * 1. line_used[] = 0 for all 120 lines
289
+ * 2. each object writes one 4-byte entry per row it covers into the
290
+ * lines it crosses (emit_object) — full lines just drop the row
291
+ * 3. every line gets its terminator written after its last entry
292
+ *
293
+ * WHY 3 PER LINE — the MARIA DMA budget, the dial this whole game turns:
294
+ * MARIA steals the bus from the CPU to fetch each line's DL + pixels
295
+ * (~113 DMA cycles per scanline before the line visibly runs out). A
296
+ * 4-byte header costs ~8 cycles + 3/pixel-byte, so three 2-byte-wide
297
+ * objects ≈ 40 of 113 — comfortable. Eight would not be. When a 4th
298
+ * object-row lands on one line we DROP it for that frame — a one-line
299
+ * flicker on that object, exactly the artifact real dense 7800 games
300
+ * show. More objects per line ⇒ bigger slots ⇒ more RAM ⇒ fewer lines;
301
+ * quantity, width, and field height all trade against the same budget.
302
+ *
303
+ * The pool is SPLIT across two RAM regions because no single linker
304
+ * region fits 1680 bytes + the DLL + the canvases (see RAM MAP):
305
+ * lines 0-46 → pool_a[] (BSS, RAM1) 47 * 14 = 658 bytes
306
+ * lines 47-119 → POOLB ($2200, raw RAM3) 73 * 14 = 1022 bytes
307
+ * line_dl[] resolves a field line to its slot; nothing else knows the split.
308
+ *
309
+ * Rebuild-vs-patch doctrine (MENTAL_MODEL.md): the DLL is built ONCE and
310
+ * only its 3-byte field entries are repointed at state changes (with DMA
311
+ * off); per-frame work only rewrites bytes INSIDE existing 14-byte slots.
312
+ * Tearing down the DLL itself mid-game races MARIA's walker — the classic
313
+ * "works one frame then the screen falls apart" 7800 bug.
314
+ * ════════════════════════════════════════════════════════════════════════ */
315
+ #define LINE_BYTES 14
316
+ #define LINE_FULL 12 /* 3 entries * 4 bytes */
317
+ #define POOLA_LINES 47
318
+ static uint8_t pool_a[POOLA_LINES * LINE_BYTES];
319
+ static uint8_t* line_dl[FIELD_LINES];
320
+ static uint8_t line_used[FIELD_LINES];
321
+
322
+ static uint8_t dll[143 * 3];
323
+ static uint8_t hud_canvas[8 * 32]; /* 16-char text row, lives in BSS */
324
+ static uint8_t hud_dls[8 * 7]; /* one 5-byte DL + term per row */
325
+
326
+ /* Emit one object: a 4-byte direct DL entry into every field line one of
327
+ * its rows crosses. gfx rows are consecutive (stride = width in bytes).
328
+ * Callers keep y in [0, FIELD_LINES - h] so no clipping is needed — keep
329
+ * that invariant if you change movement code, or add clipping here. */
330
+ static void emit_object(uint8_t y, uint8_t h, const uint8_t* gfx,
331
+ uint8_t stride, uint8_t mode, uint8_t x) {
332
+ uint8_t r, off;
333
+ uint8_t* dl;
334
+ for (r = 0; r < h; ++r) {
335
+ off = line_used[y];
336
+ if (off < LINE_FULL) { /* line full ⇒ drop row (flicker) */
337
+ dl = line_dl[y] + off;
338
+ dl[0] = (uint8_t)((uint16_t)(uintptr_t)gfx & 0xFF);
339
+ dl[1] = mode;
340
+ dl[2] = (uint8_t)((uint16_t)(uintptr_t)gfx >> 8);
341
+ dl[3] = x;
342
+ line_used[y] = off + 4;
343
+ }
344
+ ++y;
345
+ gfx += stride;
346
+ }
347
+ }
348
+
349
+ static void field_open(void) { /* step 1: forget last frame */
350
+ memset(line_used, 0, FIELD_LINES);
351
+ }
352
+
353
+ static void field_close(void) { /* step 3: terminate every line */
354
+ uint8_t i;
355
+ for (i = 0; i < FIELD_LINES; ++i)
356
+ line_dl[i][line_used[i] + 1] = 0; /* next entry's MODE byte = 0 */
80
357
  }
81
358
 
82
- static uint16_t bg_zone_dl(int zone) {
83
- if (zone >= GROUND_ZONE) return (uint16_t)(uintptr_t)dl_ground;
84
- if (zone >= 28) return (uint16_t)(uintptr_t)dl_field;
85
- return (uint16_t)(uintptr_t)dl_empty;
359
+ /* ── HARDWARE IDIOM (load-bearing) — DLL construction + zone repointing.
360
+ * Built once at boot; dll_zone appends one 3-byte entry (offset byte =
361
+ * height-1; DLI/holey bits stay 0 — no NMI handler, no holey DMA here). */
362
+ static uint8_t* dllp;
363
+ static void dll_zone(uint8_t height, uint16_t dl) {
364
+ dllp[0] = height - 1;
365
+ dllp[1] = (uint8_t)(dl >> 8);
366
+ dllp[2] = (uint8_t)(dl & 0xFF);
367
+ dllp += 3;
86
368
  }
87
369
 
88
- #define DLL_ZONES 243
89
- static uint8_t dll[DLL_ZONES * 3];
370
+ /* Repoint ONE field line's DLL entry (title/menu/game-over text overlays
371
+ * borrow field zones; play repoints them back at the pool slots). */
372
+ static void point_field_zone(uint8_t fline, uint16_t dl) {
373
+ uint8_t* e = dll + FIELD_DLL_OFF + (uint16_t)fline * 3;
374
+ e[0] = 0;
375
+ e[1] = (uint8_t)(dl >> 8);
376
+ e[2] = (uint8_t)(dl & 0xFF);
377
+ }
90
378
 
91
- static int player_x;
92
- static int player_y;
379
+ /* ── GAME LOGIC (clay) — text rendering into a 32-byte-wide RAM canvas ── */
380
+ static uint8_t glyph_index(char c) {
381
+ if (c >= '0' && c <= '9') return (uint8_t)(c - '0');
382
+ if (c >= 'A' && c <= 'Z') return (uint8_t)(10 + c - 'A');
383
+ if (c == '-') return 36;
384
+ return 37; /* space */
385
+ }
93
386
 
94
- static void set_dl_addr(uint8_t* dl, const uint8_t* row) {
95
- uint16_t a = (uint16_t)(uintptr_t)row;
96
- dl[0] = (uint8_t)(a & 0xFF);
97
- dl[2] = (uint8_t)(a >> 8);
387
+ static void draw_text(uint8_t* canvas, uint8_t col, const char* s) {
388
+ uint8_t r, b;
389
+ const uint8_t* g;
390
+ uint8_t* dst;
391
+ while (*s && col < 16) {
392
+ g = FONT + ((uint16_t)glyph_index(*s) << 3);
393
+ dst = canvas + ((uint16_t)col << 1);
394
+ for (r = 0; r < 8; ++r) {
395
+ b = g[r];
396
+ dst[0] = NIB2[b >> 4];
397
+ dst[1] = NIB2[b & 0x0F];
398
+ dst += 32;
399
+ }
400
+ ++s;
401
+ ++col;
402
+ }
98
403
  }
99
404
 
100
- static void set_dll_entry(int idx, uint16_t dl_ptr) {
101
- dll[idx * 3 + 0] = 0;
102
- dll[idx * 3 + 1] = (uint8_t)(dl_ptr >> 8);
103
- dll[idx * 3 + 2] = (uint8_t)(dl_ptr & 0xFF);
405
+ static void digits5(char* d, uint16_t v) {
406
+ uint8_t i;
407
+ for (i = 0; i < 5; ++i) { d[4 - i] = (char)('0' + v % 10); v /= 10; }
104
408
  }
105
409
 
106
- static void set_x(uint8_t x) {
107
- dl_row0[4] = x; dl_row1[4] = x; dl_row2[4] = x; dl_row3[4] = x;
108
- dl_row4[4] = x; dl_row5[4] = x; dl_row6[4] = x; dl_row7[4] = x;
410
+ /* Build the 8 one-line DLs that display an arbitrary RAM canvas at x=16
411
+ * (centered 128px). pal picks the text colour palette. dls = 8*7 bytes. */
412
+ static void canvas_dls(uint8_t* dls, const uint8_t* canvas, uint8_t pal) {
413
+ uint8_t r;
414
+ uint16_t a;
415
+ for (r = 0; r < 8; ++r) {
416
+ a = (uint16_t)(uintptr_t)canvas + ((uint16_t)r << 5);
417
+ dls[0] = (uint8_t)(a & 0xFF);
418
+ dls[1] = 0x40; /* 5-byte form, 160A write mode */
419
+ dls[2] = (uint8_t)(a >> 8);
420
+ dls[3] = (uint8_t)((pal << 5) | 0); /* width 32 bytes encodes as 0 */
421
+ dls[4] = 16;
422
+ dls[5] = 0;
423
+ dls[6] = 0; /* terminator for the next read */
424
+ dls += 7;
425
+ }
109
426
  }
110
427
 
111
- static void build_dll(int y) {
112
- int i;
113
- for (i = 0; i < DLL_ZONES; i++) {
114
- uint16_t dl;
115
- int d = i - y;
116
- switch (d) {
117
- case 0: dl = (uint16_t)(uintptr_t)dl_row0; break;
118
- case 1: dl = (uint16_t)(uintptr_t)dl_row1; break;
119
- case 2: dl = (uint16_t)(uintptr_t)dl_row2; break;
120
- case 3: dl = (uint16_t)(uintptr_t)dl_row3; break;
121
- case 4: dl = (uint16_t)(uintptr_t)dl_row4; break;
122
- case 5: dl = (uint16_t)(uintptr_t)dl_row5; break;
123
- case 6: dl = (uint16_t)(uintptr_t)dl_row6; break;
124
- case 7: dl = (uint16_t)(uintptr_t)dl_row7; break;
125
- default: dl = bg_zone_dl(i); break;
428
+ /* ── GAME LOGIC (clay) — the music. Two-voice TIA tune loop. ─────────────────
429
+ * The TIA's frequency divider is 5 bits — ~32 pitches TOTAL, none of them
430
+ * in tune with each other. Don't fight it: write the melody IN the TIA's
431
+ * crooked scale and it reads as "gritty 7800", fight it and it reads as
432
+ * "wrong". The note tables ARE the song — edit them to recompose.
433
+ * Voice 0 = melody (AUDC 4, square-ish). Voice 1 = bass (AUDC 6, deep
434
+ * buzz) and voice 1 is SHARED with sound effects (TIA has only two
435
+ * voices): when the game fires an effect, sfx_hold mutes the bass for the
436
+ * effect's length, then the bass re-enters on its next note. That
437
+ * steal-and-return is the standard 2-voice arbitration trick. */
438
+ static const uint8_t MEL_F[16] = { 13,15,17,15, 13,13,10,255, 15,17,19,17, 20,17,15,255 };
439
+ static const uint8_t MEL_L[16] = { 8, 8, 8, 8, 8, 8,16, 8, 8, 8, 8, 8, 8, 8,16, 8 };
440
+ static const uint8_t BAS_F[8] = { 29,25,27,29, 29,25,23,27 };
441
+ static uint8_t mel_i, mel_t, bas_i, bas_t, sfx_hold;
442
+
443
+ static void music_tick(void) {
444
+ if (mel_t) --mel_t;
445
+ if (mel_t == 0) {
446
+ mel_i = (uint8_t)((mel_i + 1) & 15);
447
+ mel_t = MEL_L[mel_i];
448
+ if (MEL_F[mel_i] == 255) {
449
+ AUDV0 = 0; /* 255 = rest */
450
+ } else {
451
+ AUDC0 = 4; AUDF0 = MEL_F[mel_i]; AUDV0 = 6;
126
452
  }
127
- set_dll_entry(i, dl);
453
+ }
454
+ if (sfx_hold) { /* an effect owns voice 1 */
455
+ --sfx_hold;
456
+ if (sfx_hold == 0) bas_t = 1; /* bass re-enters next tick */
457
+ return;
458
+ }
459
+ if (bas_t) --bas_t;
460
+ if (bas_t == 0) {
461
+ bas_i = (uint8_t)((bas_i + 1) & 7);
462
+ bas_t = 16;
463
+ AUDC1 = 6; AUDF1 = BAS_F[bas_i]; AUDV1 = 5;
128
464
  }
129
465
  }
130
466
 
467
+ /* Effects (voice 1 via atari7800_sfx; sfx_hold keeps the bass out). */
468
+ static void fx_shot(void) { sfx_tone(1, 4, 4); sfx_hold = 5; }
469
+ static void fx_boom(void) { sfx_noise(8); sfx_hold = 9; }
470
+ static void fx_crash(void) { sfx_noise(22); sfx_hold = 23; }
471
+ static void fx_start(void) { sfx_tone(1, 8, 6); sfx_hold = 7; }
472
+
473
+ /* ── GAME LOGIC (clay — reshape freely) — game state ─────────────────────────
474
+ * Fixed object pools, no allocation (1.79MHz CPU, 4KB RAM — a heap is a
475
+ * cost with no payer). 24 meteors are ALWAYS active; "destroyed" just
476
+ * respawns one at the top, so the field never thins out. */
477
+ #define METEORS 24
478
+ #define SHOTS 4
479
+ #define LIVES_START 3
480
+ static uint8_t mx[METEORS], my[METEORS], macc[METEORS], mspd[METEORS];
481
+ static uint8_t sx[SHOTS], sy[SHOTS], sact[SHOTS];
482
+ static uint8_t shipx[2], shipy[2], alive[2], cool[2], inv[2];
483
+ static uint8_t two_p, lives, dirty, prev_fire, over_lock;
484
+ static uint16_t score, hiscore;
485
+ static uint16_t rng = 0xACE1;
486
+
487
+ #define ST_TITLE 0
488
+ #define ST_PLAY 1
489
+ #define ST_OVER 2
490
+ static uint8_t state;
491
+
492
+ static uint8_t random8(void) { /* xorshift16 — cheap + fine */
493
+ uint16_t r = rng;
494
+ r ^= r << 7;
495
+ r ^= r >> 9;
496
+ r ^= r << 8;
497
+ rng = r;
498
+ return (uint8_t)r;
499
+ }
500
+
501
+ static void spawn_meteor(uint8_t i, uint8_t ytop) {
502
+ uint8_t x = (uint8_t)((random8() & 0x7F) + (random8() & 0x1F));
503
+ if (x > 150) x -= 80; /* keep x+7 ≤ 159 (field width) */
504
+ mx[i] = (uint8_t)(x + 2);
505
+ my[i] = ytop;
506
+ macc[i] = 0;
507
+ /* speed = quarter-pixels per sim tick (1..2 base, +1/+2 as the score
508
+ * climbs). Tuned for the real ~45Hz tick rate the optimized loop hits:
509
+ * a meteor crosses the field in ~5-11s, which keeps 24 of them dense
510
+ * but survivable. (At 4x these speeds an IDLE ship died every ~3s —
511
+ * fine for a bullet hell, wrong for a teaching example.) */
512
+ mspd[i] = (uint8_t)(1 + (random8() & 1) + (score >= 200 ? 2 : score >= 80 ? 1 : 0));
513
+ }
514
+
515
+ /* ── GAME LOGIC (clay) — HUD: "S00000 H00000 L3" composed into the canvas ── */
516
+ static void draw_hud(void) {
517
+ static char buf[17] = "S00000 H00000 L0";
518
+ digits5(buf + 1, score);
519
+ digits5(buf + 8, hiscore);
520
+ buf[15] = (char)('0' + lives);
521
+ memset(hud_canvas, 0, sizeof(hud_canvas));
522
+ draw_text(hud_canvas, 0, buf);
523
+ dirty = 0;
524
+ }
525
+
526
+ static void draw_hud_title(void) {
527
+ static char buf[9] = "HI 00000";
528
+ digits5(buf + 3, hiscore);
529
+ memset(hud_canvas, 0, sizeof(hud_canvas));
530
+ draw_text(hud_canvas, 4, buf);
531
+ }
532
+
533
+ /* ── HARDWARE IDIOM (load-bearing) — paint functions bracket structural
534
+ * display-list changes with MARIA DMA OFF ($7F) / ON ($40), the 7800's
535
+ * version of the NES "rendering off before nametable writes" rule: MARIA
536
+ * may be mid-walk through the very lists being rewritten, and repointing
537
+ * dozens of zones under it glitches (or with bad luck hangs) the frame.
538
+ * CTRL $40 = DMA on, 160A read mode, colour burst on — forget to restore
539
+ * it and the screen stays the flat BACKGRND colour forever. ── */
540
+
541
+ /* Title screen: borrow field zones for three text overlays composed in
542
+ * POOLB (the pool isn't drawing meteors on the title, so its RAM is free —
543
+ * 4KB machines make you reuse like this). Title is double-height by
544
+ * pointing TWO consecutive 1-line zones at each canvas row — zero extra
545
+ * RAM, pure DLL trickery. */
546
+ static void paint_title(void) {
547
+ uint8_t i;
548
+ uint8_t* c0 = POOLB; /* title canvas (256 bytes) */
549
+ uint8_t* c1 = POOLB + 256; /* menu line 1 (256 bytes) */
550
+ uint8_t* c2 = POOLB + 512; /* menu line 2 (256 bytes) */
551
+ uint8_t* td = POOLB + 768; /* 3 lines * 8 row-DLs * 7 */
552
+ CTRL = 0x7F; /* DMA off */
553
+ memset(POOLB, 0, 768);
554
+ draw_text(c0, (uint8_t)((16 - (sizeof(GAME_TITLE) - 1)) / 2), GAME_TITLE);
555
+ draw_text(c1, 3, "1P - FIRE");
556
+ draw_text(c2, 0, "2P - PAD 2 FIRE");
557
+ canvas_dls(td, c0, 0); /* white */
558
+ canvas_dls(td + 56, c1, 5); /* HUD green */
559
+ canvas_dls(td + 112, c2, 5);
560
+ for (i = 0; i < FIELD_LINES; ++i)
561
+ point_field_zone(i, (uint16_t)(uintptr_t)dl_empty);
562
+ for (i = 0; i < 16; ++i) /* double-height title rows */
563
+ point_field_zone((uint8_t)(8 + i),
564
+ (uint16_t)(uintptr_t)(td + ((i >> 1) * 7)));
565
+ for (i = 0; i < 8; ++i) {
566
+ point_field_zone((uint8_t)(56 + i), (uint16_t)(uintptr_t)(td + 56 + i * 7));
567
+ point_field_zone((uint8_t)(76 + i), (uint16_t)(uintptr_t)(td + 112 + i * 7));
568
+ }
569
+ draw_hud_title();
570
+ state = ST_TITLE;
571
+ CTRL = 0x40; /* DMA back on */
572
+ }
573
+
574
+ /* Game over: freeze nothing — the pool RAM becomes the message overlay
575
+ * (same reuse trick as the title), the rest of the field goes blank. */
576
+ static void paint_gameover(void) {
577
+ uint8_t i;
578
+ uint8_t* c0 = POOLB;
579
+ uint8_t* c1 = POOLB + 256;
580
+ uint8_t* td = POOLB + 768;
581
+ static char buf[12] = "SCORE 00000";
582
+ CTRL = 0x7F;
583
+ memset(POOLB, 0, 512);
584
+ draw_text(c0, 3, "GAME OVER");
585
+ digits5(buf + 6, score);
586
+ draw_text(c1, 2, buf);
587
+ canvas_dls(td, c0, 0);
588
+ canvas_dls(td + 56, c1, 5);
589
+ for (i = 0; i < FIELD_LINES; ++i)
590
+ point_field_zone(i, (uint16_t)(uintptr_t)dl_empty);
591
+ for (i = 0; i < 8; ++i) {
592
+ point_field_zone((uint8_t)(40 + i), (uint16_t)(uintptr_t)(td + i * 7));
593
+ point_field_zone((uint8_t)(60 + i), (uint16_t)(uintptr_t)(td + 56 + i * 7));
594
+ }
595
+ over_lock = 30; /* swallow the held fire button */
596
+ state = ST_OVER;
597
+ CTRL = 0x40;
598
+ }
599
+
600
+ /* ── GAME LOGIC (clay) — start a run ── */
601
+ static void start_game(uint8_t players) {
602
+ uint8_t i;
603
+ CTRL = 0x7F;
604
+ two_p = players;
605
+ for (i = 0; i < FIELD_LINES; ++i) /* field zones → pool slots */
606
+ point_field_zone(i, (uint16_t)(uintptr_t)line_dl[i]);
607
+ field_open();
608
+ field_close(); /* all lines empty + terminated */
609
+ score = 0; /* before seeding — spawn speed */
610
+ /* scales with score */
611
+ for (i = 0; i < METEORS; ++i) /* seed the swarm SPREAD OUT — */
612
+ spawn_meteor(i, (uint8_t)(i * 4)); /* all-at-top would pile 24 */
613
+ /* rows on the same scanlines */
614
+ for (i = 0; i < SHOTS; ++i) sact[i] = 0;
615
+ shipx[0] = two_p ? 56 : 74; shipy[0] = 104; alive[0] = 1;
616
+ shipx[1] = 92; shipy[1] = 104; alive[1] = two_p;
617
+ cool[0] = cool[1] = 0;
618
+ inv[0] = inv[1] = 60; /* spawn shield vs the swarm */
619
+ /* Shared arcade life pool; co-op fields two hulls against the same
620
+ * swarm, so the team gets two extra. */
621
+ lives = (uint8_t)(LIVES_START + (two_p ? 2 : 0));
622
+ rng ^= (uint16_t)(my[0] * 251) ^ 0x1234;
623
+ draw_hud();
624
+ fx_start();
625
+ state = ST_PLAY;
626
+ CTRL = 0x40;
627
+ }
628
+
629
+ static void game_over(void) {
630
+ if (score > hiscore) {
631
+ hiscore = score;
632
+ /* HSC NOTE (see file header): on real hardware with a High Score Cart
633
+ * you would write the record into HSC RAM ($1000-$17FF) here. The
634
+ * bundled prosystem core has no HSC support and exposes no SAVE_RAM,
635
+ * so the record honestly lives only as long as the session. */
636
+ }
637
+ paint_gameover();
638
+ }
639
+
640
+ /* ── GAME LOGIC (clay) — per-player update. p=0 reads SWCHA's high nibble
641
+ * + INPT4, p=1 the low nibble + INPT5 (see the bit-order idiom up top). */
642
+ static void update_ship(uint8_t p, uint8_t pad, uint8_t fire) {
643
+ uint8_t lf, rt, up, dn;
644
+ if (!alive[p]) return;
645
+ if (p == 0) { rt = pad & J1_RIGHT; lf = pad & J1_LEFT; dn = pad & J1_DOWN; up = pad & J1_UP; }
646
+ else { rt = pad & J2_RIGHT; lf = pad & J2_LEFT; dn = pad & J2_DOWN; up = pad & J2_UP; }
647
+ if (lf && shipx[p] > 2) shipx[p] -= 2;
648
+ if (rt && shipx[p] < 146) shipx[p] += 2;
649
+ if (up && shipy[p] > 64) --shipy[p];
650
+ if (dn && shipy[p] < 111) ++shipy[p];
651
+ if (cool[p]) --cool[p];
652
+ if (fire && cool[p] == 0) {
653
+ uint8_t i;
654
+ for (i = 0; i < SHOTS; ++i) {
655
+ if (!sact[i]) {
656
+ sact[i] = 1;
657
+ sx[i] = (uint8_t)(shipx[p] + 4); /* from the nose, centered */
658
+ sy[i] = (uint8_t)(shipy[p] - 3);
659
+ cool[p] = 10;
660
+ fx_shot();
661
+ break;
662
+ }
663
+ }
664
+ }
665
+ if (inv[p]) --inv[p];
666
+ }
667
+
131
668
  static void vblank_wait(void) {
132
- while (MSTAT & 0x80) { }
133
- while (!(MSTAT & 0x80)) { }
669
+ while (MSTAT & 0x80) { } /* leave the current vblank */
670
+ while (!(MSTAT & 0x80)) { } /* catch the next one starting */
134
671
  }
135
672
 
136
673
  void main(void) {
137
- uint16_t dll_addr;
138
- uint8_t prev_fire = 0;
139
-
140
- set_dl_addr(dl_row0, ship_row0);
141
- set_dl_addr(dl_row1, ship_row1);
142
- set_dl_addr(dl_row2, ship_row2);
143
- set_dl_addr(dl_row3, ship_row3);
144
- set_dl_addr(dl_row4, ship_row4);
145
- set_dl_addr(dl_row5, ship_row5);
146
- set_dl_addr(dl_row6, ship_row6);
147
- set_dl_addr(dl_row7, ship_row7);
148
- set_band_addr(dl_field);
149
- set_band_addr(dl_ground);
150
-
151
- player_x = 80;
152
- player_y = 180;
153
- set_x((uint8_t)player_x);
154
- build_dll(player_y);
155
-
156
- BACKGRND = 0x00; /* black space */
157
- P0C1 = 0x0F;
158
- P0C2 = 0x1C;
159
- P0C3 = 0x46;
160
- P1C1 = 0x84; /* upper nebula band (deep blue) */
161
- P2C1 = 0x82; /* lower nebula band (darker blue) */
674
+ uint8_t i, fires, f1, f2;
675
+ uint16_t a;
676
+
677
+ /* ── HARDWARE IDIOM (load-bearing) — boot order: build EVERYTHING the
678
+ * DLL will reference, then point DPP at it, THEN enable DMA. Enabling
679
+ * DMA over a half-built DLL is the 7800 black-screen classic. ── */
680
+
681
+ /* Resolve the pool split: field line → 14-byte DL slot. */
682
+ for (i = 0; i < POOLA_LINES; ++i)
683
+ line_dl[i] = pool_a + (uint16_t)i * LINE_BYTES;
684
+ for (i = POOLA_LINES; i < FIELD_LINES; ++i)
685
+ line_dl[i] = POOLB + (uint16_t)(i - POOLA_LINES) * LINE_BYTES;
686
+
687
+ /* Patch the ROM band drawables' data pointers (SOLID8). */
688
+ a = (uint16_t)(uintptr_t)SOLID8;
689
+ dl_band_a[0] = dl_band_a[5] = (uint8_t)(a & 0xFF);
690
+ dl_band_a[2] = dl_band_a[7] = (uint8_t)(a >> 8);
691
+ dl_band_b[0] = dl_band_b[5] = (uint8_t)(a & 0xFF);
692
+ dl_band_b[2] = dl_band_b[7] = (uint8_t)(a >> 8);
693
+
694
+ canvas_dls(hud_dls, hud_canvas, 5);
695
+
696
+ /* The DLL — the screen layout, built once (see the layout table above).
697
+ * 143 entries, mixed zone heights; only the 120 field entries are ever
698
+ * repointed after this. */
699
+ dllp = dll;
700
+ dll_zone(16, (uint16_t)(uintptr_t)dl_empty); /* lines 0-15 */
701
+ for (i = 0; i < 8; ++i) /* HUD 16-23 */
702
+ dll_zone(1, (uint16_t)(uintptr_t)(hud_dls + i * 7));
703
+ dll_zone(2, (uint16_t)(uintptr_t)dl_band_a); /* divider */
704
+ for (i = 0; i < FIELD_LINES; ++i) /* field 26-145 */
705
+ dll_zone(1, (uint16_t)(uintptr_t)line_dl[i]);
706
+ dll_zone(2, (uint16_t)(uintptr_t)dl_band_a); /* divider */
707
+ /* Decor stripes (planet glow) — also our anti-blank-screen ballast:
708
+ * with DMA fetching only objects, everything else is the single flat
709
+ * BACKGRND colour, and a mostly-one-colour frame reads as "dead". */
710
+ dll_zone(8, (uint16_t)(uintptr_t)dl_band_a);
711
+ dll_zone(8, (uint16_t)(uintptr_t)dl_empty);
712
+ dll_zone(8, (uint16_t)(uintptr_t)dl_band_b);
713
+ dll_zone(8, (uint16_t)(uintptr_t)dl_empty);
714
+ dll_zone(8, (uint16_t)(uintptr_t)dl_band_a);
715
+ dll_zone(8, (uint16_t)(uintptr_t)dl_empty);
716
+ dll_zone(8, (uint16_t)(uintptr_t)dl_band_b);
717
+ dll_zone(8, (uint16_t)(uintptr_t)dl_empty);
718
+ dll_zone(8, (uint16_t)(uintptr_t)dl_band_a);
719
+ dll_zone(8, (uint16_t)(uintptr_t)dl_empty);
720
+ dll_zone(8, (uint16_t)(uintptr_t)dl_band_b); /* …through 235 */
721
+ dll_zone(7, (uint16_t)(uintptr_t)dl_empty); /* 236-242 */
722
+
723
+ /* Palettes (Atari colour byte = hue<<4 | luminance). */
724
+ BACKGRND = 0x00; /* space black */
725
+ P0C1 = 0x0F; /* title text white */
726
+ P1C1 = 0x95; P1C2 = 0x9C; P1C3 = 0x0F; /* P1 ship blues */
727
+ P2C1 = 0x16; P2C2 = 0x1C; P2C3 = 0x0F; /* P2 ship golds */
728
+ P3C1 = 0x43; P3C2 = 0x37; P3C3 = 0x0F; /* meteor embers */
729
+ P4C1 = 0x1E; P4C3 = 0x0F; /* shot streak */
730
+ P5C1 = 0xC9; /* HUD green */
731
+ P6C1 = 0x84; /* decor band deep blue */
732
+ P7C1 = 0x88; /* decor band brighter blue */
162
733
  CHARBASE = 0;
163
- OFFSET = 0;
734
+ OFFSET = 0; /* must stay 0 (7800 standard) */
735
+
736
+ a = (uint16_t)(uintptr_t)dll;
737
+ DPPL = (uint8_t)(a & 0xFF);
738
+ DPPH = (uint8_t)(a >> 8);
164
739
 
165
- dll_addr = (uint16_t)(uintptr_t)dll;
166
- DPPL = (uint8_t)(dll_addr & 0xFF);
167
- DPPH = (uint8_t)(dll_addr >> 8);
168
- CTRL = 0x40;
169
740
  sfx_init();
741
+ hiscore = 0; /* in-session only — see header */
742
+ paint_title(); /* …turns DMA on */
170
743
 
171
744
  for (;;) {
172
- uint8_t pad, fire_now;
745
+ uint8_t pad;
173
746
  vblank_wait();
174
747
  sfx_update();
748
+ music_tick();
749
+
750
+ pad = (uint8_t)~SWCHA;
751
+ f1 = (uint8_t)(!(INPT4 & 0x80));
752
+ f2 = (uint8_t)(!(INPT5 & 0x80));
753
+ fires = (uint8_t)(f1 | (f2 << 1));
754
+
755
+ if (state == ST_TITLE) {
756
+ /* ── GAME LOGIC (clay) — title: P1 fire = 1P, P2 fire = 2P co-op ── */
757
+ if ((fires & 1) && !(prev_fire & 1)) start_game(0);
758
+ else if ((fires & 2) && !(prev_fire & 2)) start_game(1);
759
+ prev_fire = fires;
760
+ continue;
761
+ }
175
762
 
176
- pad = ~SWCHA;
177
- if (pad & JOY_LEFT && player_x > 4) { player_x--; set_x((uint8_t)player_x); }
178
- if (pad & JOY_RIGHT && player_x < 152) { player_x++; set_x((uint8_t)player_x); }
179
- if (pad & JOY_UP && player_y > 30) { player_y--; build_dll(player_y); }
180
- if (pad & JOY_DOWN && player_y < 200) { player_y++; build_dll(player_y); }
763
+ if (state == ST_OVER) {
764
+ if (over_lock) { --over_lock; prev_fire = fires; continue; }
765
+ if (fires && !prev_fire) paint_title();
766
+ prev_fire = fires;
767
+ continue;
768
+ }
769
+
770
+ /* ── ST_PLAY ───────────────────────────────────────────────────── */
771
+ update_ship(0, pad, f1);
772
+ if (two_p) update_ship(1, pad, f2);
773
+
774
+ /* ── GAME LOGIC (clay) — the swarm. Sub-pixel fall: macc accumulates
775
+ * quarter-pixels so speeds 1..6 span 0.25-1.5 px per sim tick. */
776
+ for (i = 0; i < METEORS; ++i) {
777
+ macc[i] += mspd[i];
778
+ if (macc[i] >= 4) {
779
+ my[i] = (uint8_t)(my[i] + (macc[i] >> 2));
780
+ macc[i] &= 3;
781
+ /* recycle at FIELD_LINES-6: the fastest meteor steps 2px/tick, so
782
+ * post-check y ≤ 113 and its 4 rows stay inside the field — the
783
+ * emit invariant (no clipping) depends on this bound. */
784
+ if (my[i] > FIELD_LINES - 6) spawn_meteor(i, 0);
785
+ }
786
+ }
787
+
788
+ /* Shots rise 3px per tick. */
789
+ for (i = 0; i < SHOTS; ++i) {
790
+ if (!sact[i]) continue;
791
+ if (sy[i] >= 3) sy[i] -= 3; else sact[i] = 0;
792
+ }
793
+
794
+ /* Shots × meteors. */
795
+ {
796
+ uint8_t s, m;
797
+ for (s = 0; s < SHOTS; ++s) {
798
+ if (!sact[s]) continue;
799
+ for (m = 0; m < METEORS; ++m) {
800
+ if (sy[s] + 2 >= my[m] && sy[s] <= my[m] + 3 &&
801
+ sx[s] + 3 >= mx[m] && sx[s] <= mx[m] + 7) {
802
+ sact[s] = 0;
803
+ score += (uint16_t)(4 + mspd[m]); /* fast rocks pay more */
804
+ if (score > 99999u) score = 99999u; /* 5-digit HUD */
805
+ spawn_meteor(m, 0);
806
+ fx_boom();
807
+ dirty = 1;
808
+ break;
809
+ }
810
+ }
811
+ }
812
+ }
813
+
814
+ /* Meteors × ships (shared life pool — arcade co-op). */
815
+ {
816
+ uint8_t m, p;
817
+ for (m = 0; m < METEORS; ++m) {
818
+ for (p = 0; p < 2; ++p) {
819
+ if (!alive[p] || inv[p]) continue;
820
+ if (mx[m] + 7 >= shipx[p] && mx[m] <= shipx[p] + 11 &&
821
+ my[m] + 3 >= shipy[p] && my[m] <= shipy[p] + 7) {
822
+ spawn_meteor(m, 0);
823
+ fx_crash();
824
+ if (lives) --lives;
825
+ dirty = 1;
826
+ if (lives == 0) { game_over(); break; }
827
+ inv[p] = 90; /* respawn shield (blinks) */
828
+ shipy[p] = 104;
829
+ }
830
+ }
831
+ if (state != ST_PLAY) break;
832
+ }
833
+ }
834
+ if (state != ST_PLAY) { prev_fire = fires; continue; }
835
+
836
+ /* ── HARDWARE IDIOM (load-bearing) — the per-frame draw pass:
837
+ * open (clear counts) → emit every object → close (terminators).
838
+ * Emission order = draw order on shared scanlines, and when a line
839
+ * is full the LAST emitters get dropped — so ships go first (the
840
+ * player's own object must never be the one that flickers out). ── */
841
+ field_open();
842
+ /* Shield blink = SHIMMER, never vanish: on blink ticks draw only the
843
+ * ship's bottom half instead of skipping it. A skipped ship is gone
844
+ * from the display list for 4 ticks straight — and both inv[] timers
845
+ * start equal in co-op, so BOTH ships vanish on the same ticks; any
846
+ * single-frame look at the screen (a screenshot, a human glancing
847
+ * back at their seat after a hit) reads that as "the ships are gone",
848
+ * not as a blink. Half-drawing flickers just as loudly but keeps
849
+ * every object accounted for on every frame. */
850
+ for (i = 0; i < 2; ++i) {
851
+ if (!alive[i]) continue;
852
+ if (inv[i] && (inv[i] & 4))
853
+ emit_object((uint8_t)(shipy[i] + 4), 4, GFX_SHIP + 12, 3,
854
+ i ? MODE_SHIP2 : MODE_SHIP1, shipx[i]);
855
+ else
856
+ emit_object(shipy[i], 8, GFX_SHIP, 3,
857
+ i ? MODE_SHIP2 : MODE_SHIP1, shipx[i]);
858
+ }
859
+ for (i = 0; i < SHOTS; ++i)
860
+ if (sact[i]) emit_object(sy[i], 3, GFX_SHOT, 1, MODE_SHOT, sx[i]);
861
+ for (i = 0; i < METEORS; ++i)
862
+ emit_object(my[i], 4, GFX_METEOR, 2, MODE_METEOR, mx[i]);
863
+ field_close();
181
864
 
182
- fire_now = (INPT4 & 0x80) ? 0 : 1;
183
- if (fire_now && !prev_fire) { sfx_tone(0, 8, 4); }
184
- prev_fire = fire_now;
865
+ if (dirty) draw_hud();
866
+ prev_fire = fires;
185
867
  }
186
868
  }