romdevtools 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/AGENTS.md +53 -43
  2. package/CHANGELOG.md +91 -0
  3. package/README.md +3 -3
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1225 -332
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +906 -275
  8. package/examples/atari2600/templates/shmup.asm +1031 -239
  9. package/examples/atari2600/templates/sports.asm +1135 -253
  10. package/examples/atari7800/templates/platformer.c +991 -156
  11. package/examples/atari7800/templates/puzzle.c +1091 -148
  12. package/examples/atari7800/templates/racing.c +952 -124
  13. package/examples/atari7800/templates/shmup.c +812 -134
  14. package/examples/atari7800/templates/sports.c +820 -184
  15. package/examples/c64/templates/platformer.c +879 -164
  16. package/examples/c64/templates/puzzle.c +855 -178
  17. package/examples/c64/templates/racing.c +873 -97
  18. package/examples/c64/templates/shmup.c +757 -161
  19. package/examples/c64/templates/sports.c +755 -100
  20. package/examples/gb/templates/platformer.c +841 -179
  21. package/examples/gb/templates/puzzle.c +986 -246
  22. package/examples/gb/templates/racing.c +754 -174
  23. package/examples/gb/templates/shmup.c +673 -175
  24. package/examples/gb/templates/sports.c +790 -159
  25. package/examples/gba/templates/platformer.c +626 -165
  26. package/examples/gba/templates/puzzle.c +519 -269
  27. package/examples/gba/templates/racing.c +511 -206
  28. package/examples/gba/templates/shmup.c +564 -179
  29. package/examples/gba/templates/sports.c +454 -174
  30. package/examples/gbc/templates/platformer.c +944 -180
  31. package/examples/gbc/templates/puzzle.c +363 -109
  32. package/examples/gbc/templates/racing.c +884 -180
  33. package/examples/gbc/templates/shmup.c +821 -185
  34. package/examples/gbc/templates/sports.c +870 -162
  35. package/examples/genesis/templates/platformer.c +747 -129
  36. package/examples/genesis/templates/puzzle.c +694 -261
  37. package/examples/genesis/templates/racing.c +726 -203
  38. package/examples/genesis/templates/shmup.c +535 -142
  39. package/examples/genesis/templates/sports.c +495 -158
  40. package/examples/gg/templates/platformer.c +880 -215
  41. package/examples/gg/templates/puzzle.c +875 -216
  42. package/examples/gg/templates/racing.c +915 -172
  43. package/examples/gg/templates/shmup.c +714 -191
  44. package/examples/gg/templates/sports.c +732 -129
  45. package/examples/lynx/templates/platformer.c +604 -69
  46. package/examples/lynx/templates/puzzle.c +498 -158
  47. package/examples/lynx/templates/racing.c +538 -102
  48. package/examples/lynx/templates/shmup.c +458 -131
  49. package/examples/lynx/templates/sports.c +496 -72
  50. package/examples/msx/platformer/main.c +649 -162
  51. package/examples/msx/puzzle/main.c +742 -240
  52. package/examples/msx/racing/main.c +669 -178
  53. package/examples/msx/shmup/main.c +460 -178
  54. package/examples/msx/sports/main.c +592 -126
  55. package/examples/nes/templates/platformer.c +589 -171
  56. package/examples/nes/templates/puzzle.c +563 -242
  57. package/examples/nes/templates/racing.c +502 -208
  58. package/examples/nes/templates/shmup.c +339 -145
  59. package/examples/nes/templates/sports.c +341 -183
  60. package/examples/pce/platformer/main.c +874 -205
  61. package/examples/pce/puzzle/main.c +802 -287
  62. package/examples/pce/racing/main.c +783 -208
  63. package/examples/pce/shmup/main.c +638 -212
  64. package/examples/pce/sports/main.c +586 -169
  65. package/examples/porting-across-platforms/README.md +1 -1
  66. package/examples/sms/templates/platformer.c +762 -177
  67. package/examples/sms/templates/puzzle.c +752 -212
  68. package/examples/sms/templates/racing.c +808 -145
  69. package/examples/sms/templates/shmup.c +599 -162
  70. package/examples/sms/templates/sports.c +630 -122
  71. package/examples/snes/templates/music_demo.c +7 -0
  72. package/examples/snes/templates/platformer-data.asm +123 -24
  73. package/examples/snes/templates/platformer-hdr.asm +57 -0
  74. package/examples/snes/templates/platformer.c +586 -165
  75. package/examples/snes/templates/puzzle-data.asm +116 -21
  76. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  77. package/examples/snes/templates/puzzle.c +614 -235
  78. package/examples/snes/templates/racing-data.asm +390 -32
  79. package/examples/snes/templates/racing-hdr.asm +57 -0
  80. package/examples/snes/templates/racing.c +807 -196
  81. package/examples/snes/templates/shmup-data.asm +87 -29
  82. package/examples/snes/templates/shmup-hdr.asm +57 -0
  83. package/examples/snes/templates/shmup.c +459 -198
  84. package/examples/snes/templates/sports-data.asm +48 -2
  85. package/examples/snes/templates/sports-hdr.asm +57 -0
  86. package/examples/snes/templates/sports.c +414 -163
  87. package/package.json +12 -12
  88. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  89. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  90. package/src/cores/wasm/fceumm_libretro.js +1 -1
  91. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  92. package/src/cores/wasm/gambatte_libretro.js +1 -1
  93. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  94. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  95. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  96. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  97. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  98. package/src/cores/wasm/handy_libretro.js +1 -1
  99. package/src/cores/wasm/handy_libretro.wasm +0 -0
  100. package/src/cores/wasm/mgba_libretro.js +1 -1
  101. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  102. package/src/cores/wasm/prosystem_libretro.js +1 -1
  103. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  104. package/src/cores/wasm/snes9x_libretro.js +1 -1
  105. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  106. package/src/cores/wasm/stella2014_libretro.js +1 -1
  107. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  108. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  109. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  110. package/src/host/LibretroHost.js +84 -8
  111. package/src/http/tool-registry.js +11 -11
  112. package/src/mcp/tools/cheats.js +2 -1
  113. package/src/mcp/tools/frame.js +3 -2
  114. package/src/mcp/tools/index.js +3 -3
  115. package/src/mcp/tools/input.js +5 -4
  116. package/src/mcp/tools/lifecycle.js +6 -4
  117. package/src/mcp/tools/memory.js +131 -24
  118. package/src/mcp/tools/platform-docs.js +1 -1
  119. package/src/mcp/tools/preview-tile.js +6 -2
  120. package/src/mcp/tools/project.js +1098 -130
  121. package/src/mcp/tools/record.js +6 -7
  122. package/src/mcp/tools/rom-id.js +5 -1
  123. package/src/mcp/tools/run-until.js +12 -4
  124. package/src/mcp/tools/snippets.js +6 -6
  125. package/src/mcp/tools/sprite-pipeline.js +14 -2
  126. package/src/mcp/tools/state.js +2 -1
  127. package/src/mcp/tools/tile-inspect.js +8 -1
  128. package/src/mcp/tools/toolchain.js +12 -1
  129. package/src/mcp/tools/watch-memory.js +53 -10
  130. package/src/observer/bus.js +73 -0
  131. package/src/observer/livestream.html +4 -2
  132. package/src/observer/tool-wrap.js +17 -14
  133. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +32 -3
  134. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  135. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  136. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  137. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  138. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  139. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  140. package/src/platforms/gb/lib/c/README.md +10 -11
  141. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  142. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  143. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  144. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  145. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  146. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  147. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  148. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  149. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  150. package/src/platforms/gbc/lib/c/README.md +10 -11
  151. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  152. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  153. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  154. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  155. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  156. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  157. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  158. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  159. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  160. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  161. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  162. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  163. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  164. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  165. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  166. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  167. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  168. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  169. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  170. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  171. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  172. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  173. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  174. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  175. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  176. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  177. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  178. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  179. package/src/toolchains/index.js +27 -11
@@ -1,175 +1,622 @@
1
- /* ── puzzle.c — SMS match-3 falling-block scaffold ─────────────────
1
+ /* ── puzzle.c — Game Gear falling-gem versus puzzle (complete example game) ───
2
2
  *
3
- * Mirrors the NES/Genesis/SNES/GB puzzle scaffolds. 6-wide × 12-tall
4
- * grid drawn via the BG tilemap (three distinct BG tile shapes for
5
- * R/G/B cells). 1×3 active piece, rotate via B1, soft-drop via DOWN,
6
- * START hard-drops, horizontal-triple clear.
3
+ * SLUICE STACK a COMPLETE, working game: title screen, 1P MARATHON mode
4
+ * (levels speed the fall as you clear) and 2P SIMULTANEOUS VERSUS mode — two
5
+ * narrow wells side by side, P1 on PORT A, P2 on PORT B, both falling at once,
6
+ * where every cascade chain you score lays SIEGE to the other well: garbage
7
+ * rows rise from the bottom of your rival's board. Score + persistent
8
+ * hi-score (Sega-mapper cart RAM — see the honesty note at hiscore_save),
9
+ * PSG music + SFX, and the GG/SMS signature LINE-INTERRUPT split: a fixed HUD
10
+ * strip over the wells, timed by the VDP's programmable line counter — but
11
+ * EVERYTHING is fit to the GG's 160×144 visible window.
12
+ *
13
+ * The game: a falling-trio match-3. A vertical trio of gems "sluices" down a
14
+ * well; LEFT/RIGHT move it, button 1 cycles its three colours, DOWN
15
+ * soft-drops, button 2 hard-drops. When it lands, any straight run of 3+
16
+ * same-coloured gems (horizontal, vertical, or diagonal) clears; survivors
17
+ * fall and cascades chain for multiplied score. First stack to reach the rim
18
+ * loses.
19
+ *
20
+ * THIS FILE IS THE GG TWIN of the SMS puzzle (GEODE GAMBIT). The GG VDP IS the
21
+ * SMS VDP — same Mode-4 hardware, same SN76489 PSG, same I/O. There is exactly
22
+ * ONE thing that changes everything about placement:
23
+ *
24
+ * THE GG VISIBLE WINDOW — the VDP renders a full 256×192 frame; the LCD
25
+ * shows only the CENTERED 160×144 of it. Every hardware coordinate (sprite
26
+ * OAM x/y, tilemap rows/cols, AND the line counter's scanline number) is in
27
+ * the FULL 256×192 frame; content placed outside the centered window is
28
+ * rendered "correctly" and simply never shown. So the HUD, the title, and
29
+ * BOTH wells must sit INSIDE the window — derive every coordinate from the
30
+ * VIS_* block below, never hardcode an SMS-frame number. (The emulator
31
+ * screenshot is the 160×144 visible crop — "my well is at col 3 but
32
+ * off-screen" means it's parked in the unseen border, not a render bug.)
33
+ *
34
+ * THE WELL-WIDTH ADAPTATION (load-bearing for the layout): the SMS GEODE
35
+ * GAMBIT had 256 px = 32 cols to spend, so its wells were 6 cells wide and
36
+ * the 2P split put P1 at cols 3-8 and P2 at cols 23-28. The GG window is only
37
+ * 20 cols (160 px) wide — two 6-wide wells (each frame+6+frame = 8 cols, so
38
+ * 16 cols + a centre gutter) do NOT fit. So the GG wells are GRID_W = 5 cells
39
+ * wide: each well is 7 cols (frame+5+frame), two = 14 cols, leaving a 5-col
40
+ * centre gutter for the VS marker, all inside the 20 visible columns. A
41
+ * 5-wide well is still a real match-3 board (3-run clears, cascades, garbage
42
+ * all work unchanged) — only the geometry constants moved. If you reshape the
43
+ * wells, derive their columns from VIS_* and keep 2*(GRID_W+2) under VIS_COLS.
44
+ *
45
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
46
+ * very different one. The markers tell you what's what:
47
+ * HARDWARE IDIOM (load-bearing) — dodges a documented GG footgun; reshape
48
+ * your gameplay around it (see TROUBLESHOOTING before changing).
49
+ * GAME LOGIC (clay) — match rules, garbage, tuning, art: reshape freely.
50
+ *
51
+ * What depends on what:
52
+ * gg_hw.h / vdp_init.c / load_palette.c / load_tiles.c / sprite_table.c /
53
+ * joypad_read.c — the bundled VDP + input runtime (this file's externs).
54
+ * gg_sfx.{h,c} + gg_music.{h,c} — SN76489 PSG sound layers.
55
+ * gg_crt0.s — boot + vector table. Its $0038 IM-1 handler is the OTHER
56
+ * HALF of the line-interrupt idiom below: it acks the VDP (one status
57
+ * read clears BOTH the frame and line IRQ flags) and returns with
58
+ * ei/reti. Load-bearing; edit with TROUBLESHOOTING open.
59
+ *
60
+ * Frame budget (NTSC, 60fps) — and a TEACHING POINT vs the NES version of
61
+ * this game (examples/nes/templates/puzzle.c): on the NES, board repaints
62
+ * squeeze through a ~16-entry vblank queue, so a full-board repaint is
63
+ * BUDGETED across 12 frames of dirty-row bitmask tricks. The GG has no such
64
+ * famine: the BOARD IS A BG TILEMAP, and a whole well (12 rows × 5 cells) is
65
+ * 60 gg_set_tilemap_cell writes — well under a single vblank's VRAM
66
+ * bandwidth. So when a lock dirties a board we just repaint the WHOLE well in
67
+ * the next vblank (board_dirty flag) — no per-row drip, no queue. The only
68
+ * thing we DO budget is the HUD's software 16-bit divisions (see the BUDGET
69
+ * FOOTGUN at the main loop), exactly as the platformer/shmup do. Same genre,
70
+ * two bandwidth worlds — fork accordingly.
71
+ *
72
+ * SDCC FOOTGUN (bites every fork): uint8 loop bounds silently wrap —
73
+ * `for (uint8_t i = 0; i < 12 * 5; i++)` is fine (60 < 255), but a full-board
74
+ * paint `for (uint8_t i = 0; i < 24 * 32; i++)` is an INFINITE loop (768 >
75
+ * 255; SDCC even warns "comparison is always true"). Treat that warning as an
76
+ * error: widen the counter to uint16_t or keep loops nested per-row like the
77
+ * painters below.
7
78
  */
8
79
  #include "gg_hw.h"
9
80
  #include "gg_sfx.h"
10
81
  #include "gg_music.h"
11
82
  #include <stdint.h>
12
83
 
13
- extern void gg_vdp_init(void);
14
- extern void gg_vdp_display_on(void);
15
- extern void gg_vdp_set_addr(uint16_t addr, uint8_t prefix);
16
- extern void gg_load_palette(const uint8_t *palette);
17
- extern void gg_load_tiles(uint16_t vram_dest, const uint8_t *src, uint16_t byte_count);
18
- extern void gg_set_tilemap_cell(uint8_t row, uint8_t col, uint8_t tile_idx, uint8_t attr);
19
- extern void gg_vblank_wait(void);
84
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
85
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
86
+ #define GAME_TITLE "SLUICE STACK"
87
+
88
+ extern void gg_vdp_init(void);
89
+ extern void gg_vdp_write_reg(uint8_t reg, uint8_t value);
90
+ extern void gg_vdp_display_on(void);
91
+ extern void gg_vdp_display_off(void);
92
+ extern void gg_vdp_set_addr(uint16_t addr, uint8_t prefix);
93
+ extern void gg_load_palette(const uint8_t *palette);
94
+ extern void gg_load_tiles(uint16_t vram_dest, const uint8_t *src, uint16_t byte_count);
95
+ extern void gg_set_tilemap_cell(uint8_t row, uint8_t col, uint8_t tile_idx, uint8_t attr);
20
96
  extern uint8_t gg_joypad_read(void);
97
+ extern uint8_t gg_joypad_read_p2(void);
98
+ extern void gg_sprite_init(void);
99
+ extern void gg_sprite_set(uint8_t slot, uint8_t x, uint8_t y, uint8_t tile);
100
+ extern void gg_sat_upload(void);
21
101
 
22
- #define COLS 6
23
- #define ROWS 12
24
-
25
- #define T_BLANK 0
26
- #define T_R 1
27
- #define T_G 2
28
- #define T_B 3
29
- #define T_WALL 4 /* well border */
30
- #define T_FIELD 5 /* empty well interior */
31
-
32
- static const uint8_t palette[32] = {
33
- /* BG palette: 0 backdrop navy, 1 red, 2 green, 3 blue, 4 wall grey,
34
- * 5 dim field blue */
35
- 0x10,0x03,0x0C,0x30, 0x15,0x14, 0x00,0x00,
36
- 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,
37
- 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,
38
- 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,
102
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
103
+ * THE GG VISIBLE WINDOW. The VDP frame is 256×192; the LCD shows the
104
+ * centered 160×144. In FULL-FRAME hardware units the window is:
105
+ *
106
+ * pixels: x ∈ [48..207] y ∈ [24..167] (sprite coords, scanlines)
107
+ * tilemap: col ∈ [6..25] row ∈ [3..20] (20×18 visible cells)
108
+ *
109
+ * EVERYTHING the hardware takes is full-frame: gg_sprite_set x/y, tilemap
110
+ * row/col, and easy to forget — the LINE COUNTER (VDP R10) counts
111
+ * full-frame scanlines from the top of the 192-line active area, NOT from
112
+ * the top of the LCD. The window's first visible scanline is 24.
113
+ *
114
+ * Requires: nothing these are constants of the machine. Everything below
115
+ * (HUD placement, split line, well geometry, sprite Y, text columns) is
116
+ * derived from them; if you reshape the layout, derive from VIS_*, never
117
+ * hardcode SMS-frame numbers. */
118
+ #define VIS_X0 48 /* left edge of the LCD window (hardware X) */
119
+ #define VIS_Y0 24 /* top edge (hardware Y / scanline) */
120
+ #define VIS_X1 207 /* right edge: 48 + 160 - 1 */
121
+ #define VIS_Y1 167 /* bottom edge: 24 + 144 - 1 */
122
+ #define VIS_W 160
123
+ #define VIS_H 144
124
+ #define VIS_COL0 6 /* first visible tilemap column (48 / 8) */
125
+ #define VIS_ROW0 3 /* first visible tilemap row (24 / 8) */
126
+ #define VIS_COLS 20 /* 160 / 8 */
127
+ #define VIS_ROWS 18 /* 144 / 8 */
128
+ /* Think in window space (0..19 cols, 0..17 rows), convert at the call: */
129
+ #define VROW(r) ((uint8_t)((r) + VIS_ROW0))
130
+ #define VCOL(c) ((uint8_t)((c) + VIS_COL0))
131
+
132
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
133
+ * Palette. THE GG's HEADLINE UPGRADE over the SMS: CRAM holds 12-bit 4-4-4
134
+ * BGR colour (4096 colours) instead of the SMS's 6-bit 2-2-2 (64). The WRITE
135
+ * FORMAT differs too — that's the #2 GG footgun:
136
+ *
137
+ * SMS: 32 entries × 1 byte --BBGGRR
138
+ * GG: 32 entries × 2 bytes little-endian: low byte = GGGGRRRR
139
+ * high byte = ----BBBB
140
+ *
141
+ * So a GG palette array is 64 bytes (entries 0-15 BG, 16-31 sprite). Feeding
142
+ * gg_load_palette a 32-byte SMS-style table reads past the array — the sprite
143
+ * palette loads garbage and every sprite renders invisible (this exact bug
144
+ * shipped in an earlier GG scaffold round). Pack an entry with:
145
+ * low = (g << 4) | r, high = b, each channel 0..15. The ruby/emerald/sapphire
146
+ * gems and the recessed well floor below all use the 4096-colour panel (the
147
+ * GEODE GAMBIT SMS palette only had 64 to choose from). */
148
+ static const uint8_t palette[64] = {
149
+ /* BG 0-15: 0 = cabinet navy (backdrop/border), 1 = ruby, 2 = emerald,
150
+ * 3 = white (text + glint), 4 = HUD-bar steel, 5 = sapphire,
151
+ * 6 = well-frame grey, 7 = dim well floor (so the well reads as recessed) */
152
+ 0x20,0x02, 0x13,0x00, 0xA1,0x00, 0xFF,0x0F, 0x86,0x06,
153
+ 0x4C,0x0E, 0xAA,0x0A, 0x32,0x03,
154
+ 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
155
+ /* SPRITE 16-31: 16 = transparent, 17 = ruby, 18 = emerald, 19 = sapphire
156
+ * (the falling trio's three colours). One shared sprite palette on GG/SMS —
157
+ * per-"sprite" colour means per-TILE colour indices, not per-sprite palettes. */
158
+ 0,0, 0x13,0x00, 0xA1,0x00, 0x4C,0x0E,
159
+ 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
39
160
  };
40
161
 
41
- static const uint8_t bg_tiles[32 * 6] = {
42
- /* T_BLANK */
43
- 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0,
44
- 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0,
45
- /* T_R colour 1 fill */
46
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
47
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
48
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
49
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
50
- /* T_G — colour 2 fill */
51
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
52
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
53
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
54
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
55
- /* T_B colour 3 fill (planes 0+1) */
56
- 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
57
- 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
58
- 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
59
- 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
60
- /* T_WALL — colour 4 fill (plane 2 set) */
61
- 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00,
62
- 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00,
63
- 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00,
64
- 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00,
65
- /* T_FIELD — colour 5 fill (planes 0+2 set) = dim field */
66
- 0xFF,0x00,0xFF,0x00, 0xFF,0x00,0xFF,0x00,
67
- 0xFF,0x00,0xFF,0x00, 0xFF,0x00,0xFF,0x00,
68
- 0xFF,0x00,0xFF,0x00, 0xFF,0x00,0xFF,0x00,
69
- 0xFF,0x00,0xFF,0x00, 0xFF,0x00,0xFF,0x00,
162
+ /* ── GAME LOGIC (clay) BG tile inventory (BG bank $0000) ───────────────────
163
+ * tile 0 = blank cabinet (colour 0)
164
+ * tiles 1..37 = font: digits 0-9, A-Z, '-' (uploaded 1bpp→4bpp below)
165
+ * tiles 38..40 = gem colours 1/2/3 (ruby/emerald/sapphire) as BG tiles
166
+ * tile 41 = well frame (steel grey)
167
+ * tile 42 = empty well floor (dim — so the well reads as recessed)
168
+ * tile 43 = solid HUD bar (colour 4) — the split seam hides in it */
169
+ #define FONT_BASE 1
170
+ #define BG_GEM_BASE 38 /* +0/+1/+2 = gem colours 1/2/3 */
171
+ #define BG_FRAME 41
172
+ #define BG_FLOOR 42
173
+ #define BG_HUDBAR 43
174
+
175
+ /* 1bpp font (same glyph set as the NES/SMS/GB examples — 0-9, A-Z, '-').
176
+ * Stored 8 bytes/glyph; expanded to the VDP's 32-byte 4bpp tiles at upload
177
+ * (see load_font below), so the ROM carries 296 bytes instead of 1184. */
178
+ static const uint8_t font8[37][8] = {
179
+ /* 0-9 */
180
+ {0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00}, {0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00},
181
+ {0x3C,0x66,0x06,0x0C,0x18,0x30,0x7E,0x00}, {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00},
182
+ {0x0C,0x1C,0x3C,0x6C,0x7E,0x0C,0x0C,0x00}, {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00},
183
+ {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x30,0x30,0x00},
184
+ {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00}, {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00},
185
+ /* A-Z */
186
+ {0x18,0x3C,0x66,0x66,0x7E,0x66,0x66,0x00}, {0x7C,0x66,0x66,0x7C,0x66,0x66,0x7C,0x00},
187
+ {0x3C,0x66,0x60,0x60,0x60,0x66,0x3C,0x00}, {0x78,0x6C,0x66,0x66,0x66,0x6C,0x78,0x00},
188
+ {0x7E,0x60,0x60,0x7C,0x60,0x60,0x7E,0x00}, {0x7E,0x60,0x60,0x7C,0x60,0x60,0x60,0x00},
189
+ {0x3C,0x66,0x60,0x6E,0x66,0x66,0x3E,0x00}, {0x66,0x66,0x66,0x7E,0x66,0x66,0x66,0x00},
190
+ {0x7E,0x18,0x18,0x18,0x18,0x18,0x7E,0x00}, {0x06,0x06,0x06,0x06,0x66,0x66,0x3C,0x00},
191
+ {0x66,0x6C,0x78,0x70,0x78,0x6C,0x66,0x00}, {0x60,0x60,0x60,0x60,0x60,0x60,0x7E,0x00},
192
+ {0x63,0x77,0x7F,0x6B,0x63,0x63,0x63,0x00}, {0x66,0x76,0x7E,0x7E,0x6E,0x66,0x66,0x00},
193
+ {0x3C,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x7C,0x66,0x66,0x7C,0x60,0x60,0x60,0x00},
194
+ {0x3C,0x66,0x66,0x66,0x6A,0x6C,0x36,0x00}, {0x7C,0x66,0x66,0x7C,0x6C,0x66,0x66,0x00},
195
+ {0x3C,0x66,0x60,0x3C,0x06,0x66,0x3C,0x00}, {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00},
196
+ {0x66,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x66,0x66,0x66,0x66,0x66,0x3C,0x18,0x00},
197
+ {0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00}, {0x66,0x66,0x3C,0x18,0x3C,0x66,0x66,0x00},
198
+ {0x66,0x66,0x66,0x3C,0x18,0x18,0x18,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x60,0x7E,0x00},
199
+ /* '-' */
200
+ {0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00},
70
201
  };
71
202
 
72
- static uint8_t grid[ROWS][COLS];
73
- static uint8_t piece[3];
74
- static int8_t piece_x;
75
- static int8_t piece_y;
76
- static uint8_t fall_timer;
77
- static uint16_t score;
78
- static uint32_t rng = 1;
79
-
80
- static uint32_t xorshift(void) {
81
- rng ^= rng << 13;
82
- rng ^= rng >> 17;
83
- rng ^= rng << 5;
84
- return rng;
203
+ /* Expand 1bpp glyphs into 4bpp tiles as colour 3 (planes 0+1 set → white).
204
+ * GG/SMS tile rows are 4 bytes: plane0, plane1, plane2, plane3. */
205
+ static void load_font(void) {
206
+ uint8_t g, r, bits;
207
+ gg_vdp_set_addr((uint16_t)(FONT_BASE * 32), VDP_VRAM_WRITE);
208
+ for (g = 0; g < 37; g++) {
209
+ for (r = 0; r < 8; r++) {
210
+ bits = font8[g][r];
211
+ PORT_VDP_DATA = bits; /* plane 0 */
212
+ PORT_VDP_DATA = bits; /* plane 1 → colour index 3 (white) */
213
+ PORT_VDP_DATA = 0; /* plane 2 */
214
+ PORT_VDP_DATA = 0; /* plane 3 */
215
+ }
216
+ }
85
217
  }
86
218
 
87
- static uint8_t rand_color(void) { return (uint8_t)(1 + (xorshift() % 3)); }
219
+ /* ── GAME LOGIC (clay) gem + furniture tiles (4bpp, 32 bytes each).
220
+ * KEY TRICK: the three gem tiles are the SAME rounded shape on different
221
+ * colour planes — a cell changes colour by changing its TILE index, no
222
+ * re-upload. Colour 1 = plane 0 only, colour 2 = plane 1 only, colour 3 =
223
+ * planes 0+1. A bright corner pixel (colour 3 = planes 0+1) gives each gem a
224
+ * glint so they don't read as flat squares. */
225
+ static const uint8_t gem_furniture[32 * 6] = {
226
+ /* BG_GEM_BASE+0 — ruby (colour 1 fill, white glint top-left) */
227
+ 0x3C,0x3C,0x00,0x00, 0x7E,0x42,0x00,0x00, 0xFF,0x81,0x00,0x00, 0xFF,0x00,0x00,0x00,
228
+ 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0x7E,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00,
229
+ /* BG_GEM_BASE+1 — emerald (colour 2 fill = plane 1, glint colour 3) */
230
+ 0x00,0x3C,0x00,0x00, 0x00,0x7E,0x00,0x00, 0x18,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
231
+ 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0x7E,0x00,0x00, 0x00,0x3C,0x00,0x00,
232
+ /* BG_GEM_BASE+2 — sapphire (colour 3 fill = planes 0+1, plus a bright glint) */
233
+ 0x3C,0x3C,0x00,0x00, 0x42,0x7E,0x00,0x00, 0x81,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
234
+ 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0x7E,0x00,0x00, 0x00,0x3C,0x00,0x00,
235
+ /* BG_FRAME — solid colour 6 (well-frame grey = planes 1+2) */
236
+ 0x00,0xFF,0xFF,0x00, 0x00,0xFF,0xFF,0x00, 0x00,0xFF,0xFF,0x00, 0x00,0xFF,0xFF,0x00,
237
+ 0x00,0xFF,0xFF,0x00, 0x00,0xFF,0xFF,0x00, 0x00,0xFF,0xFF,0x00, 0x00,0xFF,0xFF,0x00,
238
+ /* BG_FLOOR — empty well floor: colour 7 (planes 0+1+2) with a faint speck */
239
+ 0xFF,0xFF,0xFF,0x00, 0xFF,0xFF,0xFF,0x00, 0xFF,0xFF,0xFF,0x00, 0xEF,0xFF,0xFF,0x00,
240
+ 0xFF,0xFF,0xFF,0x00, 0xFF,0xFF,0xFF,0x00, 0xFF,0xFF,0xFF,0x00, 0xFF,0xFF,0xFF,0x00,
241
+ /* BG_HUDBAR — solid colour 4 (binary 100 → plane 2 only); seam hides here */
242
+ 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00,
243
+ 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00,
244
+ };
245
+
246
+ /* Sprite tiles (sprite bank $2000 — vdp_init's R6=0xFF baseline reads
247
+ * sprite patterns from $2000, so upload there, not $0000). The falling trio's
248
+ * cells are SPRITES (they move every frame); locked gems are BG tiles. Same
249
+ * three colours as the BG gems above (sprite palette indices 1/2/3). */
250
+ static const uint8_t sprite_tiles[32 * 3] = {
251
+ /* T_SPR+0 — ruby (colour 1) */
252
+ 0x3C,0x3C,0x00,0x00, 0x7E,0x42,0x00,0x00, 0xFF,0x81,0x00,0x00, 0xFF,0x00,0x00,0x00,
253
+ 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0x7E,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00,
254
+ /* T_SPR+1 — emerald (colour 2 = plane 1) */
255
+ 0x00,0x3C,0x00,0x00, 0x00,0x7E,0x00,0x00, 0x18,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
256
+ 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0x7E,0x00,0x00, 0x00,0x3C,0x00,0x00,
257
+ /* T_SPR+2 — sapphire (colour 3 = planes 0+1; sprite palette index 3) */
258
+ 0x3C,0x3C,0x00,0x00, 0x42,0x7E,0x00,0x00, 0x81,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
259
+ 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0x7E,0x00,0x00, 0x00,0x3C,0x00,0x00,
260
+ };
261
+ #define T_SPR 0 /* sprite tile of gem colour 1 */
262
+
263
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
264
+ * Board geometry, ALL in WINDOW coordinates (0..19 cols / 0..17 rows) and
265
+ * converted to full-frame at the call via VROW/VCOL. Cells are 8×8 (one BG
266
+ * tile). GRID_W = 5 (the GG-window adaptation — see the WELL-WIDTH note in the
267
+ * file header): a single well is 7 cols (frame + 5 + frame), so TWO wells fit
268
+ * side by side inside the 20-col window with a 5-col centre gutter for the 2P
269
+ * split board. Playfield WINDOW rows 4..15 sit below the 3-row HUD strip. */
270
+ #define GRID_W 5
271
+ #define GRID_H 12
272
+ #define WELL_TY 4 /* top WINDOW row of the well interior */
273
+ #define WELL_1P_TX 7 /* 1P: single centred well interior */
274
+ #define WELL_VS_P1 1 /* 2P: P1 interior cols 1-5 ... */
275
+ #define WELL_VS_P2 13 /* P2 interior cols 13-17 (split) */
276
+ #define EMPTY 0 /* cell colours 1..3 = ruby/em/sapph */
88
277
 
89
- static uint8_t tile_for(uint8_t c) {
90
- if (c == 1) return T_R;
91
- if (c == 2) return T_G;
92
- if (c == 3) return T_B;
93
- return T_FIELD; /* empty cell shows the dim well interior, not backdrop */
278
+ /* HUD layout (WINDOW rows): row 0 = text, row 1 = blank, row 2 = solid bar.
279
+ * The bar row is both the visual divider AND where the split seam hides. */
280
+ #define HUD_ROWS 3
281
+ #define HUD_PX (HUD_ROWS * 8)
282
+
283
+ #define VS_FALL_DELAY 24 /* 2P: fixed gravity (frames per row) */
284
+ #define GARBAGE_CAP 4 /* max garbage rows per attack */
285
+
286
+ /* ── GAME LOGIC (clay) — game state.
287
+ * The hot ones are deliberately NON-static: they then appear in the sdld map
288
+ * (build symbols) at $Cxxx in work RAM, so a headless agent can resolve them
289
+ * by name and read/poke live state (parse the map → system_ram offset =
290
+ * addr-0xC000). The GG has 8KB of work RAM ($C000-$DFFF), so these plain
291
+ * arrays cost nothing — no NES scratch-page gymnastics. */
292
+ uint8_t grid[2][GRID_H][GRID_W]; /* the two wells (P2's unused in 1P) */
293
+ int8_t piece_x[2]; /* falling trio: column 0..GRID_W-1 */
294
+ int8_t piece_y[2]; /* row of its TOP cell (<0 above rim) */
295
+ uint8_t piece_col[2][3]; /* trio colours, top to bottom */
296
+ uint16_t score[2];
297
+ uint16_t hiscore;
298
+ uint8_t level; /* 1P: 1..9, speeds up the fall */
299
+ uint8_t state; /* ST_TITLE / ST_PLAY / ST_OVER */
300
+ uint8_t two_player;
301
+
302
+ static uint8_t matched[GRID_H][GRID_W];
303
+ static uint8_t well_tx[2]; /* left interior WINDOW column per well */
304
+ static uint8_t fall_t[2]; /* frames until next gravity step */
305
+ static uint8_t prev_pad[2]; /* for edge-triggered input */
306
+ static uint16_t cleared_total; /* 1P: gems cleared, drives the level */
307
+ static uint8_t board_dirty[2]; /* well needs a full repaint this frame*/
308
+ static uint8_t hud_dirty; /* score/level/layout changed → redraw */
309
+ static uint8_t loser; /* who topped out (2P: 0=P1, 1=P2) */
310
+ static uint8_t over_step; /* results text, one piece per vblank */
311
+ static uint16_t rng = 0xACE1;
312
+
313
+ #define ST_TITLE 0
314
+ #define ST_PLAY 1
315
+ #define ST_OVER 2
316
+
317
+ /* ── GAME LOGIC (clay) — xorshift16 PRNG (~tens of cycles per call) ── */
318
+ static uint8_t random8(void) {
319
+ uint16_t r = rng;
320
+ r ^= r << 7;
321
+ r ^= r >> 9;
322
+ r ^= r << 8;
323
+ rng = r;
324
+ return (uint8_t)r;
94
325
  }
95
326
 
96
- /* GG shows only the centered cols 6..25 / rows 3..20. Place the 6×12 grid
97
- * at tilemap cols 7..12, rows 4..15 so the whole well sits inside that
98
- * visible band. */
99
- static void draw_cell(int8_t col, int8_t row, uint8_t cell) {
100
- if (row < 0 || row >= ROWS) return;
101
- gg_set_tilemap_cell((uint8_t)(row + 4), (uint8_t)(col + 7), tile_for(cell), 0);
327
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
328
+ * LINE-INTERRUPT SPLIT the GG/SMS VDP's signature trick (fixed status bar
329
+ * over the playfield, palette splits, water effects). The VDP has ONE scroll
330
+ * register pair for the whole frame; to keep the HUD strip pinned at the top
331
+ * while the wells render below it, we DON'T scroll here (a puzzle board doesn't
332
+ * move) but we still take the line IRQ at the bar so the idiom is wired and
333
+ * ready, and so the per-frame timing (vblank → line IRQ → game logic) matches
334
+ * the GG platformer/shmup exactly. Where the NES needs the sprite-0-hit HACK
335
+ * (park a sprite, busy-poll a status bit, burn scanlines spinning), this VDP
336
+ * has a real, PROGRAMMABLE line interrupt:
337
+ *
338
+ * R10 = N line counter: a down-counter reloaded with N every line
339
+ * outside the active area; underflow → IRQ at scanline N.
340
+ * R0 bit 4 (IE1) line-IRQ enable (already set in vdp_init's 0x36 baseline).
341
+ * R1 bit 5 (IE0) frame(vblank)-IRQ enable (set by gg_vdp_display_on's 0xE0).
342
+ *
343
+ * GG WINDOW CONTRAST (the part SMS habits get wrong): R10 counts FULL-FRAME
344
+ * scanlines — line 0 is the top of the 192-line active area, which is 24
345
+ * lines ABOVE the LCD. The HUD strip starts at the window top (scanline
346
+ * VIS_Y0 = 24) and its last line is VIS_Y0 + HUD_PX - 1 = 47, so SPLIT_LINE
347
+ * is 47 — NOT 23 as it would be on an SMS with the same 3-row HUD. Lines
348
+ * 0..23 are rendered and never shown; they ride along with the HUD's region.
349
+ *
350
+ * Both IRQs land on the Z80's IM-1 vector at $0038. The crt0's handler does
351
+ * the canonical minimal handshake: push af / in a,($BF) / pop af / ei / reti
352
+ * — reading the status port ACKS the VDP (clears BOTH pending flags; skip the
353
+ * read and the IRQ line stays asserted = interrupt storm), and EI must
354
+ * precede RETI or interrupts stay off forever after the first one.
355
+ *
356
+ * Because the handler does no work, the MAIN loop synchronizes with HALT: the
357
+ * Z80 sleeps until the next interrupt, then reads the V-counter (port $7E) to
358
+ * learn WHICH one woke us — line IRQs fire during the active area (V < 0xC0),
359
+ * the frame IRQ fires at vblank (V ≥ 0xC0).
360
+ *
361
+ * wait_vblank(): sleep until the frame IRQ → do per-frame VRAM work.
362
+ * wait_split(): sleep until the line IRQ at scanline 47 (the last bar
363
+ * line) → past it, the wells render. (If you ADD a scrolling
364
+ * background under the wells, this is where you'd write R8 —
365
+ * see the GG platformer/shmup templates for the R8 write.)
366
+ *
367
+ * FOOTGUN — you cannot poll once IRQs are on: a status-port poll spins on the
368
+ * same port the ISR reads. The ISR always wins the race, eats the flag, and
369
+ * the poll loop hangs forever. HALT + V-counter is the IRQ-era replacement.
370
+ *
371
+ * Requires: R10 programmed, IE1 + IE0 enabled, EI executed once after
372
+ * display-on, the crt0's ack-only ISR, and wait_vblank/wait_split called
373
+ * EVERY frame in this order. R10 reloads after each underflow, so the line
374
+ * IRQ re-fires every HUD_PX+VIS_Y0 lines down the frame (47, 95, 143, 191) —
375
+ * the later wakes harmlessly interrupt game logic (the ISR acks them) and we
376
+ * re-halt inside the NEXT wait_vblank(). */
377
+ #define SPLIT_LINE (VIS_Y0 + HUD_PX - 1)
378
+
379
+ static void wait_vblank(void) {
380
+ /* check-first: if game logic overran into vblank, don't sleep a frame */
381
+ while (PORT_V_COUNTER < 0xC0) { __asm__("halt"); }
102
382
  }
103
383
 
104
- /* Draw the well: a grey border frame around the 6×12 play field with a dim
105
- * field interior, so the playfield is clearly visible even when empty. The
106
- * grid maps cell (col,row) -> tilemap (row+4, col+7) = rows 4..15 cols 7..12.
107
- * Frame the perimeter at rows 3..16, cols 6..13 — inside the GG viewport. */
108
- static void draw_well(void) {
109
- uint8_t r, c;
110
- for (r = 3; r <= 16; r++) {
111
- for (c = 6; c <= 13; c++) {
112
- uint8_t t = T_FIELD;
113
- if (r == 3 || r == 16 || c == 6 || c == 13) t = T_WALL;
114
- gg_set_tilemap_cell(r, c, t, 0);
115
- }
384
+ static void wait_split(void) {
385
+ /* halt-first: vblank work always ends inside vblank (V 0xC0), and the
386
+ * first wake at V < 0xC0 is the line IRQ at SPLIT_LINE */
387
+ do { __asm__("halt"); } while (PORT_V_COUNTER >= 0xC0);
388
+ }
389
+
390
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
391
+ * hi-score in Sega-mapper cart RAM. Same cartridge mapper as the SMS. The
392
+ * control register at $FFFC: bit 3 maps the cart's 8KB battery RAM into
393
+ * $8000-$BFFF (bank slot 2). Map copy unmap; keep the window short so
394
+ * stray pointer bugs can't shred the save. The block is magic + value +
395
+ * checksum so a never-written cart (all $FF) reads back as "no save" instead
396
+ * of a garbage hi-score.
397
+ *
398
+ * NOTE the $FFFC address: it's IN the WRAM mirror ($C000-$DFFF mirrors at
399
+ * $E000-$FFFF), so this write also lands in WRAM at $DFFC — the mapper just
400
+ * snoops the bus. That's why the crt0 parks SP at $DFF0: the bytes above it
401
+ * ($DFFC-$FFFF) belong to the mapper registers' shadow.
402
+ *
403
+ * HONESTY (verified 2026-06-10 against the bundled gpgx core, same finding as
404
+ * the GG sports/platformer): gpgx only instantiates the Sega mapper for ROMs
405
+ * LARGER than 48KB, and this build pipeline emits 32KB ROMs — so in-emulator
406
+ * the $8000 window stays open-bus (reads $FF), the magic check fails, and the
407
+ * game falls back to the WRAM hi-score (in-session only). The code below is
408
+ * still the correct real-hardware idiom and lights up unchanged on a >48KB
409
+ * build or a cart with battery RAM: the load path is self-falsifying, never
410
+ * wrong. (The verify harness pads this ROM to 64KB to exercise the cart-RAM
411
+ * path for real, including across power-cycle.) */
412
+ #define MAPPER_CTRL (*(volatile uint8_t *)0xFFFC)
413
+ #define CART_RAM ((volatile uint8_t *)0x8000)
414
+
415
+ static void hiscore_save(uint16_t v) {
416
+ uint8_t lo = (uint8_t)(v & 0xFF), hi = (uint8_t)(v >> 8);
417
+ MAPPER_CTRL = 0x08; /* map cart RAM at $8000 */
418
+ CART_RAM[0] = 0x48; /* 'H' */
419
+ CART_RAM[1] = 0x53; /* 'S' */
420
+ CART_RAM[2] = lo;
421
+ CART_RAM[3] = hi;
422
+ CART_RAM[4] = (uint8_t)(lo ^ hi ^ 0xA5);
423
+ MAPPER_CTRL = 0x00; /* back to ROM in slot 2 */
424
+ }
425
+
426
+ static uint16_t hiscore_load(void) {
427
+ uint16_t v = 0;
428
+ MAPPER_CTRL = 0x08;
429
+ if (CART_RAM[0] == 0x48 && CART_RAM[1] == 0x53 &&
430
+ CART_RAM[4] == (uint8_t)(CART_RAM[2] ^ CART_RAM[3] ^ 0xA5)) {
431
+ v = (uint16_t)(CART_RAM[2] | ((uint16_t)CART_RAM[3] << 8));
116
432
  }
433
+ MAPPER_CTRL = 0x00;
434
+ return v;
117
435
  }
118
436
 
119
- static void draw_grid(void) {
120
- int8_t r, c;
121
- for (r = 0; r < ROWS; r++) for (c = 0; c < COLS; c++) draw_cell(c, r, grid[r][c]);
437
+ /* ── GAME LOGIC (clay) — text via the font tiles ─────────────────────────────
438
+ * These write the name table directly, so call them only during vblank (or
439
+ * with the display off): VRAM access during active display races the VDP's
440
+ * own fetches and drops/garbles bytes on real hardware. Rows/cols here are
441
+ * WINDOW coordinates (0..17 / 0..19) — VROW/VCOL add the border offset, so
442
+ * text can never accidentally land in the unseen 256×192 margin. */
443
+ static uint8_t font_tile(char ch) {
444
+ if (ch >= '0' && ch <= '9') return (uint8_t)(FONT_BASE + (ch - '0'));
445
+ if (ch >= 'A' && ch <= 'Z') return (uint8_t)(FONT_BASE + 10 + (ch - 'A'));
446
+ if (ch == '-') return (uint8_t)(FONT_BASE + 36);
447
+ return 0; /* space → blank tile */
122
448
  }
123
449
 
124
- static void new_piece(void) {
125
- piece[0] = rand_color();
126
- piece[1] = rand_color();
127
- piece[2] = rand_color();
128
- piece_x = COLS / 2 - 1;
129
- piece_y = -3;
450
+ static void text_draw(uint8_t vrow, uint8_t vcol, const char *s) {
451
+ uint8_t col = VCOL(vcol);
452
+ while (*s) gg_set_tilemap_cell(VROW(vrow), col++, font_tile(*s++), 0);
130
453
  }
131
454
 
132
- static uint8_t collides(int8_t col, int8_t row) {
133
- uint8_t i;
134
- int8_t r;
135
- if (col < 0 || col >= COLS) return 1;
136
- for (i = 0; i < 3; i++) {
137
- r = (int8_t)(row + i);
138
- if (r >= ROWS) return 1;
139
- if (r >= 0 && grid[r][col] != 0) return 1;
455
+ static void draw_u16(uint8_t vrow, uint8_t vcol, uint16_t v) {
456
+ uint8_t d[5], i;
457
+ for (i = 0; i < 5; i++) { d[i] = (uint8_t)(v % 10); v /= 10; }
458
+ for (i = 0; i < 5; i++)
459
+ gg_set_tilemap_cell(VROW(vrow), (uint8_t)(VCOL(vcol) + i),
460
+ (uint8_t)(FONT_BASE + d[4 - i]), 0);
461
+ }
462
+
463
+ /* draw a 3-digit number at a window cell (caps at 999 — used for the tight
464
+ * 2P split HUD where two 5-digit scores + labels won't fit 20 cols). */
465
+ static void draw_u3(uint8_t vrow, uint8_t vcol, uint16_t v) {
466
+ if (v > 999) v = 999;
467
+ gg_set_tilemap_cell(VROW(vrow), (uint8_t)(VCOL(vcol) + 0), (uint8_t)(FONT_BASE + (v / 100) % 10), 0);
468
+ gg_set_tilemap_cell(VROW(vrow), (uint8_t)(VCOL(vcol) + 1), (uint8_t)(FONT_BASE + (v / 10) % 10), 0);
469
+ gg_set_tilemap_cell(VROW(vrow), (uint8_t)(VCOL(vcol) + 2), (uint8_t)(FONT_BASE + v % 10), 0);
470
+ }
471
+
472
+ /* ── GAME LOGIC (clay) — HUD on WINDOW row 0 (only 20 cols, so it's tight).
473
+ * Title: HI hhhhh centred. 1P: SC sssss .... LV n. 2P: P1 sss .. P2 sss
474
+ * (versus scores cap at 3 digits — plenty for a versus board; the marathon 1P
475
+ * mode keeps the full 5-digit score). The play layouts stay inside cols 0..19. */
476
+ static void draw_hud(void) {
477
+ uint8_t c;
478
+ for (c = 0; c < VIS_COLS; c++) gg_set_tilemap_cell(VROW(0), VCOL(c), 0, 0); /* clear row */
479
+ if (state == ST_TITLE) {
480
+ text_draw(0, 7, "HI");
481
+ draw_u16(0, 10, hiscore);
482
+ return;
483
+ }
484
+ if (two_player) {
485
+ text_draw(0, 0, "P1"); draw_u3(0, 2, score[0]); /* cols 0..4 */
486
+ text_draw(0, 14, "P2"); draw_u3(0, 16, score[1]); /* cols 14..18 */
487
+ } else {
488
+ text_draw(0, 0, "SC"); draw_u16(0, 3, score[0]); /* cols 0..7 */
489
+ text_draw(0, 14, "LV"); /* cols 14..17 */
490
+ gg_set_tilemap_cell(VROW(0), VCOL(17), (uint8_t)(FONT_BASE + level), 0);
140
491
  }
141
- return 0;
142
492
  }
143
493
 
144
- /* ── match / clear / gravity core (ported from the GBC reference puzzle).
145
- * The old scan was horizontal-only AND cleared cells mid-scan, so vertical
146
- * and diagonal runs never cleared, 4+ runs half-cleared, and nothing ever
147
- * fell afterwards ("rows don't shift down"). This marks every 3+ run in all
148
- * 4 directions, clears them, applies per-column gravity, and loops so
149
- * cascades chain (score scales with chain depth). */
150
- static uint8_t matched[ROWS][COLS];
494
+ /* ── GAME LOGIC (clay) cell colour → BG tile (empty shows the dim floor). */
495
+ static uint8_t bg_tile_for(uint8_t col) {
496
+ return col ? (uint8_t)(BG_GEM_BASE - 1 + col) : BG_FLOOR;
497
+ }
498
+
499
+ /* ── HARDWARE IDIOM (load-bearing) whole-well repaint. Each dirtied well is
500
+ * repainted ENTIRELY in the next vblank: 12 rows × 5 cells = 60 cell writes,
501
+ * trivially inside a vblank's VRAM budget (the per-cell PERF FOOTGUN the shmup
502
+ * found applies to FULL-SCREEN repaints, not a 60-cell well — and these only
503
+ * fire on a lock/clear, not every frame). Contrast the NES version, which
504
+ * must drip ONE board row per frame through a 16-entry queue. ── */
505
+ static void repaint_well(uint8_t p) {
506
+ uint8_t r, c, tx = well_tx[p];
507
+ for (r = 0; r < GRID_H; r++)
508
+ for (c = 0; c < GRID_W; c++)
509
+ gg_set_tilemap_cell(VROW((uint8_t)(WELL_TY + r)), VCOL((uint8_t)(tx + c)),
510
+ bg_tile_for(grid[p][r][c]), 0);
511
+ }
512
+
513
+ /* ── GAME LOGIC (clay) — screen painters (DISPLAY OFF: free VRAM access, clean
514
+ * cut). While the display is off the frame IRQ doesn't fire — so no halt-based
515
+ * waits in here, or you hang forever.
516
+ *
517
+ * IRQ-RACE FOOTGUN (cost the GG shmup a letter of its own title): repaints
518
+ * also run with INTERRUPTS OFF — the di/ei bracket in paint_title/paint_play.
519
+ * Display-off stops the FRAME IRQ but NOT the LINE IRQ (R0's IE1 stays set; the
520
+ * line counter runs every scanline regardless of blanking). The crt0's ISR acks
521
+ * by READING the control port ($BF) — and that read also resets the VDP's
522
+ * two-byte address-latch state machine. If the line IRQ fires between the two
523
+ * bytes of a gg_vdp_set_addr() control-port pair, the second byte is taken as a
524
+ * new first byte, the VRAM address de-syncs, and one cell of your repaint lands
525
+ * somewhere else. Per-frame writes inside wait_vblank don't need the bracket:
526
+ * vblank has no line IRQs and the frame IRQ was already consumed by the halt
527
+ * that woke us. */
528
+ static void paint_frame_chrome(uint8_t p) {
529
+ uint8_t r, tx = well_tx[p];
530
+ /* top + bottom frame rows */
531
+ for (r = 0; r < GRID_W + 2; r++) {
532
+ gg_set_tilemap_cell(VROW((uint8_t)(WELL_TY - 1)), VCOL((uint8_t)(tx - 1 + r)), BG_FRAME, 0);
533
+ gg_set_tilemap_cell(VROW((uint8_t)(WELL_TY + GRID_H)), VCOL((uint8_t)(tx - 1 + r)), BG_FRAME, 0);
534
+ }
535
+ /* left + right frame columns */
536
+ for (r = 0; r < GRID_H; r++) {
537
+ gg_set_tilemap_cell(VROW((uint8_t)(WELL_TY + r)), VCOL((uint8_t)(tx - 1)), BG_FRAME, 0);
538
+ gg_set_tilemap_cell(VROW((uint8_t)(WELL_TY + r)), VCOL((uint8_t)(tx + GRID_W)), BG_FRAME, 0);
539
+ }
540
+ }
541
+
542
+ /* PERF FOOTGUN (inherited from the GG shmup/SMS puzzle, found the slow way):
543
+ * per-cell gg_set_tilemap_cell redoes the 4-OUT address setup for every cell —
544
+ * over a full screen that's seconds of black. Set the VRAM address ONCE per
545
+ * row (the data port autoincrements through the row's 64 bytes) and stream.
546
+ * window row 2 (HW row VIS_ROW0+2) gets the HUD bar; the rest blank. */
547
+ static void paint_blank_field(void) {
548
+ uint8_t r, c;
549
+ for (r = 0; r < 24; r++) {
550
+ gg_vdp_set_addr((uint16_t)(0x3800 + (uint16_t)r * 64), VDP_VRAM_WRITE);
551
+ for (c = 0; c < 32; c++) {
552
+ uint8_t t = (r == (uint8_t)(VIS_ROW0 + 2)) ? BG_HUDBAR : 0;
553
+ PORT_VDP_DATA = t; /* name-table entry low byte */
554
+ PORT_VDP_DATA = 0; /* high byte: flips/pal/priority */
555
+ }
556
+ }
557
+ }
558
+
559
+ static void paint_title(void) {
560
+ __asm__("di"); /* see IRQ-RACE FOOTGUN above */
561
+ gg_vdp_display_off();
562
+ paint_blank_field();
563
+ text_draw(5, (uint8_t)((VIS_COLS - (sizeof(GAME_TITLE) - 1)) / 2), GAME_TITLE);
564
+ text_draw(9, 4, "1P START - 1");
565
+ text_draw(11, 4, "2P VERSUS - 2");
566
+ text_draw(14, 2, "1 ROTATE 2 DROP");
567
+ text_draw(16, 0, "CHAINS BESIEGE RIVAL");
568
+ draw_hud();
569
+ gg_sprite_init(); /* park every sprite off-screen */
570
+ gg_sat_upload();
571
+ gg_vdp_display_on(); /* re-enables the frame IRQ too */
572
+ __asm__("ei"); /* interrupts back on LAST — regs are set */
573
+ }
574
+
575
+ static void paint_play(void) {
576
+ __asm__("di"); /* see IRQ-RACE FOOTGUN above */
577
+ gg_vdp_display_off();
578
+ paint_blank_field();
579
+ paint_frame_chrome(0);
580
+ repaint_well(0);
581
+ if (two_player) {
582
+ paint_frame_chrome(1);
583
+ repaint_well(1);
584
+ text_draw(10, 9, "VS");
585
+ }
586
+ draw_hud();
587
+ gg_sprite_init();
588
+ gg_sat_upload();
589
+ gg_vdp_display_on();
590
+ __asm__("ei");
591
+ }
592
+
593
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
594
+ * Match scan: mark every straight run of 3+ same-coloured gems in all 4
595
+ * directions (a cell can belong to several runs — the mask de-dupes), and
596
+ * return how many cells matched. Runs flat-out on the Z80 over 60 cells — no
597
+ * need to smear it across frames like the cc65 NES version. */
151
598
  static const int8_t DIRS4[4][2] = { {0,1}, {1,0}, {1,1}, {1,-1} };
152
599
 
153
- static uint8_t mark_and_count(void) {
154
- uint8_t r, c, d, len, k, cnt;
155
- uint8_t col;
600
+ static uint8_t mark_and_count(uint8_t p) {
601
+ uint8_t r, c, d, len, k, cnt, col;
156
602
  int8_t dr, dc;
157
603
  int sr, sc;
158
604
  cnt = 0;
159
- for (r = 0; r < ROWS; r++) for (c = 0; c < COLS; c++) matched[r][c] = 0;
160
- for (r = 0; r < ROWS; r++) {
161
- for (c = 0; c < COLS; c++) {
162
- col = grid[r][c];
163
- if (col == 0) continue;
605
+ for (r = 0; r < GRID_H; r++)
606
+ for (c = 0; c < GRID_W; c++) matched[r][c] = 0;
607
+ for (r = 0; r < GRID_H; r++) {
608
+ for (c = 0; c < GRID_W; c++) {
609
+ col = grid[p][r][c];
610
+ if (col == EMPTY) continue;
164
611
  for (d = 0; d < 4; d++) {
165
612
  dr = DIRS4[d][0]; dc = DIRS4[d][1];
166
613
  sr = (int)r - dr; sc = (int)c - dc;
167
- if (sr >= 0 && sr < ROWS && sc >= 0 && sc < COLS
168
- && grid[sr][sc] == col) continue; /* not the run's start */
614
+ if (sr >= 0 && sr < GRID_H && sc >= 0 && sc < GRID_W
615
+ && grid[p][sr][sc] == col) continue; /* not the run's start */
169
616
  len = 1;
170
617
  sr = (int)r + dr; sc = (int)c + dc;
171
- while (sr >= 0 && sr < ROWS && sc >= 0 && sc < COLS
172
- && grid[sr][sc] == col) { len++; sr += dr; sc += dc; }
618
+ while (sr >= 0 && sr < GRID_H && sc >= 0 && sc < GRID_W
619
+ && grid[p][sr][sc] == col) { len++; sr += dr; sc += dc; }
173
620
  if (len >= 3) {
174
621
  sr = r; sc = c;
175
622
  for (k = 0; k < len; k++) {
@@ -183,119 +630,331 @@ static uint8_t mark_and_count(void) {
183
630
  return cnt;
184
631
  }
185
632
 
186
- /* collapse each column so survivors rest on the floor (in place: walk
187
- * from the bottom, copying gems down to a write cursor, then zero above) */
188
- static void apply_gravity(void) {
633
+ /* Collapse each column so survivors rest on the floor (walk from the bottom,
634
+ * copying gems down to a write cursor, then zero everything above it). */
635
+ static void apply_gravity(uint8_t p) {
189
636
  uint8_t c;
190
- int r, w;
191
- for (c = 0; c < COLS; c++) {
192
- w = ROWS - 1;
193
- for (r = ROWS - 1; r >= 0; r--) {
194
- if (grid[r][c] != 0) { grid[w][c] = grid[r][c]; w--; }
637
+ int8_t r, w;
638
+ for (c = 0; c < GRID_W; c++) {
639
+ w = GRID_H - 1;
640
+ for (r = GRID_H - 1; r >= 0; r--) {
641
+ if (grid[p][r][c] != EMPTY) { grid[p][w][c] = grid[p][r][c]; w--; }
195
642
  }
196
- for (; w >= 0; w--) grid[w][c] = 0;
643
+ for (; w >= 0; w--) grid[p][w][c] = EMPTY;
197
644
  }
198
645
  }
199
646
 
200
- static void resolve_board(void) {
647
+ /* Forward decls — game_over/garbage_insert/spawn_piece reference each other. */
648
+ static void game_over(void);
649
+
650
+ /* ── GAME LOGIC (clay) — clear matches, drop survivors, chain cascades.
651
+ * Returns the chain depth (0 = the lock matched nothing). */
652
+ static uint8_t resolve_board(uint8_t p) {
201
653
  uint8_t n, r, c, chain;
202
- unsigned int amt;
654
+ uint16_t amt;
203
655
  chain = 0;
204
- while (1) {
205
- n = mark_and_count();
656
+ for (;;) {
657
+ n = mark_and_count(p);
206
658
  if (n == 0) break;
207
- chain++;
208
- for (r = 0; r < ROWS; r++)
209
- for (c = 0; c < COLS; c++)
210
- if (matched[r][c]) grid[r][c] = 0;
211
- amt = (unsigned int)n * 10u;
212
- if (chain > 1) amt = amt * chain;
213
- if (score < 65500) score = (uint16_t)(score + amt);
214
- sfx_tone(0, 200, 10); /* clear chime */
215
- apply_gravity();
659
+ ++chain;
660
+ for (r = 0; r < GRID_H; r++)
661
+ for (c = 0; c < GRID_W; c++)
662
+ if (matched[r][c]) grid[p][r][c] = EMPTY;
663
+ amt = (uint16_t)n * 10;
664
+ if (chain > 1) amt *= chain; /* cascades pay multiplied */
665
+ if (score[p] < 65000) score[p] += amt;
666
+ /* clear chime — pitch rises with chain depth (smaller divider = higher
667
+ * note on the PSG). Voice 0 doubles as an sfx voice over the music. */
668
+ sfx_tone(0, (uint16_t)(360 - ((uint16_t)chain << 5)), 10);
669
+ apply_gravity(p);
670
+ board_dirty[p] = 1;
671
+ hud_dirty = 1;
672
+ if (!two_player) {
673
+ cleared_total += n;
674
+ while (level < 9 && cleared_total >= (uint16_t)level * 10) ++level;
675
+ }
216
676
  }
677
+ return chain;
217
678
  }
218
679
 
219
- static void lock_piece(void) {
220
- uint8_t i;
680
+ /* ── GAME LOGIC (clay) — VERSUS attack: garbage rows rise from the bottom of
681
+ * the victim's well (random gems with one gap — matchable, so a skilled
682
+ * victim digs out). The victim's stack rising means the falling trio shifts
683
+ * up one to stay board-aligned; if the top row is already occupied, the
684
+ * victim tops out and loses. ── */
685
+ static void garbage_insert(uint8_t v, uint8_t nrows) {
686
+ uint8_t k, c, gap;
221
687
  int8_t r;
688
+ sfx_noise(8); /* incoming-garbage thud */
689
+ for (k = 0; k < nrows; k++) {
690
+ for (c = 0; c < GRID_W; c++) {
691
+ if (grid[v][0][c] != EMPTY) { loser = v; game_over(); return; }
692
+ }
693
+ for (r = 0; r < GRID_H - 1; r++)
694
+ for (c = 0; c < GRID_W; c++)
695
+ grid[v][r][c] = grid[v][r + 1][c];
696
+ gap = random8() % GRID_W;
697
+ for (c = 0; c < GRID_W; c++)
698
+ grid[v][GRID_H - 1][c] = (c == gap) ? EMPTY : (uint8_t)(1 + random8() % 3);
699
+ if (piece_y[v] > -3) --piece_y[v]; /* keep the trio aligned */
700
+ }
701
+ board_dirty[v] = 1;
702
+ }
703
+
704
+ /* Can the trio occupy column x, rows y..y+2? Cells above the rim are fine
705
+ * (pieces enter from above); below the floor or on a gem is not. */
706
+ static uint8_t can_place(uint8_t p, int8_t x, int8_t y) {
707
+ int8_t i, cy;
708
+ if (x < 0 || x >= GRID_W) return 0;
222
709
  for (i = 0; i < 3; i++) {
223
- r = (int8_t)(piece_y + i);
224
- if (r >= 0 && r < ROWS) grid[r][piece_x] = piece[i];
710
+ cy = (int8_t)(y + i);
711
+ if (cy < 0) continue;
712
+ if (cy >= GRID_H) return 0;
713
+ if (grid[p][cy][x] != EMPTY) return 0;
225
714
  }
226
- resolve_board();
227
- draw_grid();
715
+ return 1;
228
716
  }
229
717
 
230
- static void draw_piece(uint8_t clear) {
231
- uint8_t i;
718
+ static void spawn_piece(uint8_t p) {
719
+ piece_x[p] = GRID_W / 2;
720
+ piece_y[p] = -2;
721
+ piece_col[p][0] = (uint8_t)(1 + random8() % 3);
722
+ piece_col[p][1] = (uint8_t)(1 + random8() % 3);
723
+ piece_col[p][2] = (uint8_t)(1 + random8() % 3);
724
+ if (!can_place(p, piece_x[p], piece_y[p])) { loser = p; game_over(); }
725
+ }
726
+
727
+ /* ── GAME LOGIC (clay) — land the trio, resolve, attack, respawn. ── */
728
+ static void lock_piece(uint8_t p) {
729
+ int8_t i, y;
730
+ uint8_t chain;
232
731
  for (i = 0; i < 3; i++) {
233
- int8_t r = (int8_t)(piece_y + i);
234
- uint8_t v;
235
- if (r < 0 || r >= ROWS) continue;
236
- v = clear ? grid[r][piece_x] : piece[i];
237
- draw_cell(piece_x, r, v);
732
+ y = (int8_t)(piece_y[p] + i);
733
+ if (y >= 0) grid[p][y][piece_x[p]] = piece_col[p][i];
238
734
  }
735
+ board_dirty[p] = 1;
736
+ sfx_tone(1, 200, 4); /* lock thunk (low note) */
737
+ if (piece_y[p] < 0) { loser = p; game_over(); return; } /* locked above rim */
738
+ chain = resolve_board(p);
739
+ if (state != ST_PLAY) return;
740
+ if (chain && two_player) {
741
+ garbage_insert(p ^ 1, chain > GARBAGE_CAP ? GARBAGE_CAP : chain);
742
+ if (state != ST_PLAY) return; /* garbage topped them out */
743
+ }
744
+ spawn_piece(p);
239
745
  }
240
746
 
241
- void main(void) {
242
- uint8_t prev = 0;
243
- uint8_t r, c;
747
+ /* ── GAME LOGIC (clay) — per-player input + gravity. Edge-triggered moves
748
+ * (one cell per press), held DOWN soft-drops, button 1 cycles the trio's
749
+ * colours (the classic trio "rotate"), button 2 hard-drops. P2 reads PORT B. ── */
750
+ static void update_player(uint8_t p) {
751
+ uint8_t pad, fresh, t, fd;
752
+ pad = p ? gg_joypad_read_p2() : gg_joypad_read();
753
+ fresh = (uint8_t)(pad & ~prev_pad[p]);
754
+ prev_pad[p] = pad;
755
+ if ((fresh & JOY_LEFT) && can_place(p, (int8_t)(piece_x[p] - 1), piece_y[p]))
756
+ --piece_x[p];
757
+ if ((fresh & JOY_RIGHT) && can_place(p, (int8_t)(piece_x[p] + 1), piece_y[p]))
758
+ ++piece_x[p];
759
+ if (fresh & JOY_B1) { /* cycle colours downward */
760
+ t = piece_col[p][2];
761
+ piece_col[p][2] = piece_col[p][1];
762
+ piece_col[p][1] = piece_col[p][0];
763
+ piece_col[p][0] = t;
764
+ sfx_tone(1, 320, 3);
765
+ }
766
+ if (fresh & JOY_B2) { /* hard drop */
767
+ while (can_place(p, piece_x[p], (int8_t)(piece_y[p] + 1))) ++piece_y[p];
768
+ lock_piece(p); /* may end the game */
769
+ return;
770
+ }
771
+ if (pad & JOY_DOWN) fall_t[p] += 4; /* soft drop */
772
+ ++fall_t[p];
773
+ fd = two_player ? VS_FALL_DELAY
774
+ : (uint8_t)(32 - ((level << 1) + level)); /* 29..5 */
775
+ if (fall_t[p] >= fd) {
776
+ fall_t[p] = 0;
777
+ if (can_place(p, piece_x[p], (int8_t)(piece_y[p] + 1)))
778
+ ++piece_y[p];
779
+ else
780
+ lock_piece(p); /* may end the game */
781
+ }
782
+ }
244
783
 
245
- gg_vdp_init();
246
- gg_load_palette(palette);
247
- gg_load_tiles(0x0000, bg_tiles, 32 * 6);
784
+ /* ── GAME LOGIC (clay) — stage this frame's sprites. Only the falling trios
785
+ * are sprites (locked gems are BG tiles): 3 SAT slots per player. Cells above
786
+ * the rim aren't drawn — they'd poke out from under the HUD strip. The sprite
787
+ * X/Y are FULL-FRAME hardware pixels: the window cell is converted by VCOL/VROW
788
+ * (which add the VIS_* border) and then shifted ×8 to pixels — so the trio
789
+ * lands EXACTLY on its BG-tile cell inside the LCD window.
790
+ * Slot map: 0-2 = P1 trio, 3-5 = P2 trio. Inactive slots park at Y=$E0 (below
791
+ * the 192-line area AND below the LCD window). NEVER park at Y=$D0 — that's the
792
+ * SAT terminator: the VDP stops scanning at the first $D0 and every later slot
793
+ * vanishes. ── */
794
+ static void stage_sprites(void) {
795
+ uint8_t p, i, slot;
796
+ for (p = 0; p < 2; p++) {
797
+ uint8_t active = (state == ST_PLAY) && (p == 0 || two_player);
798
+ for (i = 0; i < 3; i++) {
799
+ int8_t r = (int8_t)(piece_y[p] + (int8_t)i);
800
+ uint8_t col = piece_col[p][i] ? piece_col[p][i] : 1;
801
+ slot = (uint8_t)(p * 3 + i);
802
+ if (active && r >= 0)
803
+ gg_sprite_set(slot,
804
+ (uint8_t)(VCOL((uint8_t)(well_tx[p] + piece_x[p])) << 3),
805
+ (uint8_t)(VROW((uint8_t)(WELL_TY + r)) << 3),
806
+ (uint8_t)(T_SPR + col - 1));
807
+ else
808
+ gg_sprite_set(slot, 0, 0xE0, T_SPR); /* parked below the screen */
809
+ }
810
+ }
811
+ }
812
+
813
+ /* ── GAME LOGIC (clay) — end of game (top-out). `loser` topped out. ── */
814
+ static void game_over(void) {
815
+ uint16_t best = score[0];
816
+ if (two_player && score[1] > best) best = score[1];
817
+ if (best > hiscore) {
818
+ hiscore = best;
819
+ hiscore_save(hiscore); /* cart RAM (real hardware); WRAM copy is live */
820
+ }
821
+ sfx_noise(20); /* game-over rumble */
822
+ state = ST_OVER;
823
+ board_dirty[0] = board_dirty[1] = 0; /* play field is frozen now */
824
+ prev_pad[0] = prev_pad[1] = 0xFF; /* require a fresh press */
825
+ over_step = 4; /* results text, one per vblank
826
+ * (each draw_u16 is 5 software
827
+ * divisions — see the BUDGET
828
+ * FOOTGUN at the main loop) */
829
+ }
248
830
 
249
- for (r = 0; r < 24; r++) for (c = 0; c < 32; c++) gg_set_tilemap_cell(r, c, T_BLANK, 0);
250
- for (r = 0; r < ROWS; r++) for (c = 0; c < COLS; c++) grid[r][c] = 0;
831
+ /* ── GAME LOGIC (clay) start a run ── */
832
+ static void start_game(uint8_t versus) {
833
+ uint8_t p, r, c;
834
+ two_player = versus;
835
+ well_tx[0] = versus ? WELL_VS_P1 : WELL_1P_TX;
836
+ well_tx[1] = WELL_VS_P2;
837
+ /* Stir the PRNG with time-spent-on-title so runs differ. */
838
+ rng ^= (uint16_t)((uint16_t)PORT_V_COUNTER << 3);
839
+ if (rng == 0) rng = 0xACE1;
840
+ for (p = 0; p < 2; p++) {
841
+ for (r = 0; r < GRID_H; r++)
842
+ for (c = 0; c < GRID_W; c++) grid[p][r][c] = EMPTY;
843
+ fall_t[p] = 0;
844
+ score[p] = 0;
845
+ prev_pad[p] = 0xFF; /* the button that started the game
846
+ * shouldn't also rotate the first trio */
847
+ }
848
+ cleared_total = 0;
849
+ level = 1;
850
+ state = ST_PLAY;
851
+ paint_play();
852
+ spawn_piece(0);
853
+ if (versus) spawn_piece(1);
854
+ sfx_tone(0, 200, 10); /* start jingle */
855
+ }
251
856
 
252
- score = 0;
253
- fall_timer = 0;
254
- new_piece();
255
- draw_well();
256
- draw_grid();
857
+ void main(void) {
858
+ uint8_t pad, fresh;
257
859
 
860
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
861
+ * Init order: VDP regs (display off) → palette → tiles → name table → SAT →
862
+ * R10 → display on (which also enables the frame IRQ) → EI. The one hard
863
+ * rule: EI comes LAST, after every register is in place — the crt0 boots
864
+ * with DI and the FIRST halt would hang forever if interrupts were never
865
+ * enabled. (paint_title's trailing __asm__("ei") IS that final step here.) */
866
+ gg_vdp_init(); /* R0=0x36 already has IE1 (line IRQ) set */
867
+ gg_load_palette(palette);
868
+ load_font();
869
+ gg_load_tiles((uint16_t)(BG_GEM_BASE * 32), gem_furniture, 32 * 6);
870
+ gg_load_tiles(0x2000, sprite_tiles, 32 * 3);
871
+ gg_sprite_init();
258
872
  sfx_init();
259
873
  music_init();
260
- music_play(0); /* continuous background music ("no sound" was the playtest verdict) */
261
- gg_vdp_display_on();
874
+ music_play(0);
262
875
 
263
- do {
264
- uint8_t pad, fall_rate, t;
265
- gg_vblank_wait();
266
- sfx_update();
267
- music_update();
268
- draw_piece(1);
269
-
270
- pad = gg_joypad_read();
271
- if ((pad & JOY_LEFT) && !(prev & JOY_LEFT)
272
- && !collides((int8_t)(piece_x - 1), piece_y)) piece_x--;
273
- if ((pad & JOY_RIGHT) && !(prev & JOY_RIGHT)
274
- && !collides((int8_t)(piece_x + 1), piece_y)) piece_x++;
275
- if ((pad & JOY_B1) && !(prev & JOY_B1)) {
276
- t = piece[0]; piece[0] = piece[1]; piece[1] = piece[2]; piece[2] = t;
277
- sfx_tone(1, 350, 2);
278
- }
279
- if ((pad & JOY_B2) && !(prev & JOY_B2)) {
280
- while (!collides(piece_x, (int8_t)(piece_y + 1))) piece_y++;
281
- lock_piece();
282
- new_piece();
283
- prev = pad;
876
+ /* R10 = SPLIT_LINE arms the line counter: IRQ at the last bar line —
877
+ * scanline 47 in FULL-FRAME terms (window top 24 + HUD 24 - 1). Set
878
+ * once — it reloads itself every underflow. */
879
+ gg_vdp_write_reg(10, SPLIT_LINE);
880
+
881
+ hiscore = hiscore_load(); /* cart RAM if present — else 0 */
882
+ state = ST_TITLE;
883
+ prev_pad[0] = prev_pad[1] = 0xFF;
884
+ paint_title(); /* …ends with EI: interrupts live now */
885
+
886
+ for (;;) {
887
+ if (state == ST_TITLE) {
888
+ /* ── GAME LOGIC (clay) title: button 1 = 1P, button 2 = 2P versus ── */
889
+ wait_vblank();
890
+ sfx_update();
891
+ music_update();
892
+ wait_split();
893
+ pad = gg_joypad_read();
894
+ fresh = (uint8_t)(pad & ~prev_pad[0]);
895
+ prev_pad[0] = pad;
896
+ if (fresh & JOY_B1) start_game(0);
897
+ else if (fresh & JOY_B2) start_game(1);
284
898
  continue;
285
899
  }
286
- prev = pad;
287
-
288
- fall_rate = (pad & JOY_DOWN) ? 4 : 30;
289
- fall_timer = (uint8_t)(fall_timer + 1);
290
- if (fall_timer >= fall_rate) {
291
- fall_timer = 0;
292
- if (collides(piece_x, (int8_t)(piece_y + 1))) {
293
- lock_piece();
294
- new_piece();
295
- } else {
296
- piece_y++;
900
+
901
+ if (state == ST_OVER) {
902
+ /* Freeze the boards; button 1 or 2 returns to the title. */
903
+ wait_vblank();
904
+ if (over_step) { /* deferred draws — one per vblank */
905
+ if (over_step == 4)
906
+ text_draw(7, 6, two_player ? (loser ? "P1 WINS" : "P2 WINS") : "GAME OVER");
907
+ else if (over_step == 3) { text_draw(10, 4, "P1"); draw_u16(10, 8, score[0]); }
908
+ else if (over_step == 2) { if (two_player) { text_draw(12, 4, "P2"); draw_u16(12, 8, score[1]); } }
909
+ else { text_draw(14, 4, "HI"); draw_u16(14, 8, hiscore); }
910
+ over_step--;
911
+ }
912
+ wait_split();
913
+ sfx_update();
914
+ music_update();
915
+ pad = gg_joypad_read();
916
+ fresh = (uint8_t)(pad & ~prev_pad[0]);
917
+ prev_pad[0] = pad;
918
+ if (fresh & (JOY_B1 | JOY_B2)) {
919
+ state = ST_TITLE;
920
+ prev_pad[0] = prev_pad[1] = 0xFF;
921
+ paint_title();
297
922
  }
923
+ continue;
298
924
  }
299
- draw_piece(0);
300
- } while (1);
925
+
926
+ /* ── ST_PLAY ─────────────────────────────────────────────────────────
927
+ * Frame shape: [vblank: SAT + dirty-well repaints + HUD writes] → [line
928
+ * IRQ at the bar] → [rest of frame: game logic]. VRAM traffic stays
929
+ * inside vblank; logic runs while the VDP draws the field.
930
+ *
931
+ * BUDGET FOOTGUN (inherited from the GG shmup, which found it the hard
932
+ * way): everything between wait_vblank() and wait_split() must finish
933
+ * before the line IRQ at scanline 47 — vblank (70 lines) + the 47 lines
934
+ * above the split ≈ 27k cycles (BIGGER than the SMS's: the 24 never-shown
935
+ * border lines are free). The SAT upload eats ~7k of that. An
936
+ * unconditional HUD redraw (software 16-bit divisions for the digits)
937
+ * blows the budget when it lands the SAME frame as a 60-cell well repaint.
938
+ * So we GATE both behind dirty flags — HUD redraws only when the
939
+ * score/level changed, wells repaint only when a lock/clear dirtied them —
940
+ * and they rarely coincide. */
941
+ wait_vblank();
942
+ gg_sat_upload(); /* shadow SAT staged at end of last frame */
943
+ if (board_dirty[0]) { board_dirty[0] = 0; repaint_well(0); }
944
+ if (two_player && board_dirty[1]) { board_dirty[1] = 0; repaint_well(1); }
945
+ if (hud_dirty) { hud_dirty = 0; draw_hud(); }
946
+ sfx_update();
947
+ music_update();
948
+ wait_split(); /* the line-interrupt split — every frame */
949
+
950
+ /* ── GAME LOGIC (clay — reshape freely) — both players update EVERY frame
951
+ * (simultaneous versus, not alternating turns). Any update can end the
952
+ * game, so re-check state between them. */
953
+ update_player(0);
954
+ if (two_player && state == ST_PLAY) update_player(1);
955
+
956
+ /* Stage the SAT shadow NOW (RAM only — cheap, any time); the actual VRAM
957
+ * upload waits for the next vblank at the top of the loop. */
958
+ stage_sprites();
959
+ }
301
960
  }