romdevtools 0.28.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 (154) hide show
  1. package/AGENTS.md +51 -41
  2. package/CHANGELOG.md +46 -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 +1 -1
  88. package/src/host/LibretroHost.js +59 -1
  89. package/src/http/tool-registry.js +11 -11
  90. package/src/mcp/tools/cheats.js +2 -1
  91. package/src/mcp/tools/frame.js +3 -2
  92. package/src/mcp/tools/index.js +3 -3
  93. package/src/mcp/tools/input.js +5 -4
  94. package/src/mcp/tools/lifecycle.js +6 -4
  95. package/src/mcp/tools/platform-docs.js +1 -1
  96. package/src/mcp/tools/preview-tile.js +6 -2
  97. package/src/mcp/tools/project.js +1098 -130
  98. package/src/mcp/tools/rom-id.js +5 -1
  99. package/src/mcp/tools/run-until.js +4 -2
  100. package/src/mcp/tools/snippets.js +6 -6
  101. package/src/mcp/tools/sprite-pipeline.js +14 -2
  102. package/src/mcp/tools/state.js +2 -1
  103. package/src/mcp/tools/tile-inspect.js +8 -1
  104. package/src/mcp/tools/toolchain.js +12 -1
  105. package/src/mcp/tools/watch-memory.js +4 -3
  106. package/src/observer/bus.js +73 -0
  107. package/src/observer/livestream.html +4 -2
  108. package/src/observer/tool-wrap.js +17 -14
  109. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  110. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  111. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  112. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  113. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  114. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  115. package/src/platforms/gb/lib/c/README.md +10 -11
  116. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  117. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  118. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  119. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  120. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  121. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  122. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  123. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  124. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  125. package/src/platforms/gbc/lib/c/README.md +10 -11
  126. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  127. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  128. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  129. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  130. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  131. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  132. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  133. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  134. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  135. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  136. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  137. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  138. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  139. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  140. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  141. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  142. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  143. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  144. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  145. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  146. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  147. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  148. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  149. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  150. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  151. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  152. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  153. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  154. package/src/toolchains/index.js +27 -11
@@ -1,333 +1,766 @@
1
- /* ── puzzle.c — Genesis SGDK match-3 falling-block scaffold ───────
1
+ /* ── puzzle.c — Genesis falling-gem versus puzzle (complete example game) ─────
2
2
  *
3
- * 6-wide × 12-tall grid. A 1×3 active piece (3 colours, randomly
4
- * chosen from the cell palette) drops from the top; LEFT/RIGHT
5
- * shifts, A rotates the colour order, DOWN soft-drops, START
6
- * triggers a hard-drop. Horizontal triples clear and bump score.
3
+ * SHARD SIEGE a COMPLETE, working game: title screen, 1P MARATHON mode
4
+ * (levels speed the fall as you clear) and 2P SIMULTANEOUS VERSUS mode —
5
+ * two 6x12 wells side by side, P1 on controller 1, P2 on controller 2,
6
+ * both falling at once, where every cascade chain you score lays SIEGE to
7
+ * the other well: garbage rows rise from the bottom of your rival's board.
8
+ * Score + persistent hi-score (cartridge SRAM), music + SFX.
7
9
  *
8
- * The grid lives in plain RAM (u8 grid[12][6]) and is drawn to plane
9
- * B via VDP_setTileMapXY each frame the grid changes we don't
10
- * redraw every frame, only on landings and clears, so the budget is
11
- * comfortable.
10
+ * The game: a falling-trio match-3. A vertical trio of gems drops into a
11
+ * well; LEFT/RIGHT move it, A/B cycle its three colours, DOWN soft-drops,
12
+ * C hard-drops. When it lands, any straight run of 3+ same-coloured gems
13
+ * (horizontal, vertical, or diagonal) clears; survivors fall and cascades
14
+ * chain for multiplied score. First stack to reach the rim loses.
12
15
  *
13
- * Cells:
14
- * 0 = empty 1 = red 2 = green 3 = blue
16
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
17
+ * very different one. The markers tell you what's what:
18
+ * HARDWARE IDIOM (load-bearing) — dodges a documented Genesis footgun;
19
+ * reshape your gameplay around it (see TROUBLESHOOTING before changing).
20
+ * GAME LOGIC (clay) — match rules, garbage, tuning, art: reshape freely.
15
21
  *
16
- * Yours to extend: vertical-triple clear, T/L shape pieces, gravity
17
- * after clears (current code just deletes; no settle), a score
18
- * display + game-over screen, music via XGM2.
22
+ * What depends on what:
23
+ * genesis_sfx.{h,c} PSG sound wrapper (tones + noise + a background
24
+ * melody loop). For full FM music see the xgm2_demo template — PSG keeps
25
+ * this a single-file game.
26
+ * rom_header.c (SGDK) — the Sega header at $100. Its 'RA' block at $1B0
27
+ * DECLARES the cartridge SRAM that hiscore_load/save below depend on
28
+ * (see the SRAM idiom). The build assembles it automatically.
29
+ *
30
+ * Frame budget (NTSC, 60 fps) — and a TEACHING POINT vs the NES version of
31
+ * this game (examples/nes/templates/puzzle.c): on the NES, board repaints
32
+ * squeeze through a ~16-entry vblank queue, so a full-board repaint is
33
+ * BUDGETED across 12 frames of dirty-row bitmask tricks. The Genesis has
34
+ * no such famine: each dirty well is mirrored in a RAM buffer and queued
35
+ * as ONE DMA rect (576 bytes); the H40 vblank DMA window moves ~7 KB, so
36
+ * BOTH wells + 6 SAT entries + HUD land in a single vblank with most of
37
+ * the budget unspent. Same genre, two bandwidth worlds — fork accordingly.
19
38
  */
20
39
 
21
40
  #include <genesis.h>
22
41
  #include "genesis_sfx.h"
23
42
 
24
- #define COLS 6
25
- #define ROWS 12
26
- #define CELL_PX 16 /* draw cells at 2×2 tiles for visibility */
27
-
28
- #define T_BLANK (TILE_USER_INDEX + 0)
29
- #define T_RED (TILE_USER_INDEX + 1)
30
- #define T_GREEN (TILE_USER_INDEX + 2)
31
- #define T_BLUE (TILE_USER_INDEX + 3)
32
- #define T_BG (TILE_USER_INDEX + 4) /* full-screen backdrop (BG_A) */
33
- #define T_WELL (TILE_USER_INDEX + 5) /* play-well backdrop (BG_A) */
34
-
35
- static const u32 tile_blank[8] = { 0,0,0,0,0,0,0,0 };
36
- /* Backdrop block for the far plane: a framed cell (colour 4 border /
37
- * colour 5 fill) tiled across the whole screen so the playfield no
38
- * longer floats on a flat black backdrop. */
39
- static const u32 tile_bg[8] = {
40
- 0x44444444, 0x45555554, 0x45555554, 0x45555554,
41
- 0x45555554, 0x45555554, 0x45555554, 0x44444444,
42
- };
43
- /* A darker, recessed cell drawn behind the play column so the well reads
44
- * as an inset board rather than part of the surrounding wall. */
45
- static const u32 tile_well[8] = {
46
- 0x44444444, 0x40000004, 0x40000004, 0x40000004,
47
- 0x40000004, 0x40000004, 0x40000004, 0x44444444,
43
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
44
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
45
+ #define GAME_TITLE "SHARD SIEGE"
46
+
47
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
48
+ * CONTROLLER MAPPING — two layers, both bite:
49
+ *
50
+ * On the pad: SGDK's JOY_readJoypad(JOY_1/JOY_2) returns BUTTON_A/B/C/
51
+ * START/UP/DOWN/LEFT/RIGHT as a bitmask. Here A/B cycle the trio's
52
+ * colours, C hard-drops (thumbs rest on C give it the decisive action).
53
+ *
54
+ * Driving this game HEADLESSLY through an emulator (libretro/gpgx): the
55
+ * core maps Genesis A/B/C onto libretro Y/B/A. So setInput({y:true})
56
+ * presses GENESIS A (rotate/1P-start here), setInput({b:true}) presses
57
+ * GENESIS B (rotate/2P-select), and setInput({a:true}) presses GENESIS C
58
+ * (hard drop) NOT Genesis A. Getting this wrong looks like "the game
59
+ * ignores input". START is start.
60
+ */
61
+
62
+ /* ── GAME LOGIC (clay reshape freely) ──────────────────────────────────────
63
+ * Board geometry. Cells are 16x16 px (2x2 tiles) the Genesis 320x224
64
+ * screen has room to spare; chunky gems read better than 8-px ones.
65
+ * Tile rows 0-1 sit under the WINDOW HUD; well frames at row 2 and 27. */
66
+ #define GRID_W 6
67
+ #define GRID_H 12
68
+ #define WELL_TY 3 /* top TILE row of the well interior */
69
+ #define WELL_1P_TX 14 /* 1P: single centered well (tiles 14-25) */
70
+ #define WELL_VS_P1 3 /* 2P: P1 interior tiles 3-14 ... */
71
+ #define WELL_VS_P2 25 /* P2 interior tiles 25-36 (split board)*/
72
+ #define HUD_ROWS 2 /* window rows reserved for the HUD */
73
+
74
+ #define EMPTY 0 /* cell colours 1..3 = ruby/emerald/sapphire */
75
+
76
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
77
+ * Tile art. Genesis tiles are 4bpp: each u32 row = 8 pixels, one hex
78
+ * nibble per pixel = a colour index into the tile's palette line (0 =
79
+ * transparent). Everything game-side (board, frames, gems, trio sprites)
80
+ * lives on PAL1; the backdrop plane uses PAL2; PAL0 keeps the SGDK font.
81
+ *
82
+ * KEY TRICK: the three gem colours are the SAME 16x16 shape. The four
83
+ * quarter tiles are drawn ONCE with fill nibble 1 (rim 5 / glint 4 shared),
84
+ * and the other two colours are GENERATED at boot by remapping nibble 1 ->
85
+ * 2 / 3 into a RAM buffer before upload — one piece of art, three tiles. */
86
+ #define T_FRAME (TILE_USER_INDEX + 0) /* well border */
87
+ #define T_CELL (TILE_USER_INDEX + 1) /* empty (recessed) cell quarter */
88
+ #define T_BACK (TILE_USER_INDEX + 2) /* plane B cabinet backdrop */
89
+ #define T_BAND (TILE_USER_INDEX + 3) /* plane B flat band behind HUD */
90
+ #define T_GEM (TILE_USER_INDEX + 4) /* 12 tiles: 3 colours x 4 quarters */
91
+
92
+ static const u32 tile_frame[8] = {
93
+ 0x77777777, 0x76666667, 0x76666667, 0x76666667,
94
+ 0x76666667, 0x76666667, 0x76666667, 0x77777777,
48
95
  };
49
- static const u32 tile_red[8] = {
50
- 0x11111111, 0x11111111, 0x11111111, 0x11111111,
51
- 0x11111111, 0x11111111, 0x11111111, 0x11111111,
96
+ static const u32 tile_cell[8] = { /* near-black well + faint speck */
97
+ 0x99999999, 0x99999999, 0x99999999, 0x99989999,
98
+ 0x99999999, 0x99999999, 0x99999999, 0x99999999,
52
99
  };
53
- static const u32 tile_green[8] = {
54
- 0x22222222, 0x22222222, 0x22222222, 0x22222222,
55
- 0x22222222, 0x22222222, 0x22222222, 0x22222222,
100
+ static const u32 tile_back[8] = { /* framed cabinet block */
101
+ 0x11111111, 0x12222221, 0x12222221, 0x12222221,
102
+ 0x12222221, 0x12222221, 0x12222221, 0x11111111,
56
103
  };
57
- static const u32 tile_blue[8] = {
104
+ static const u32 tile_band[8] = {
58
105
  0x33333333, 0x33333333, 0x33333333, 0x33333333,
59
106
  0x33333333, 0x33333333, 0x33333333, 0x33333333,
60
107
  };
108
+ /* Gem quarters in SPRITE TILE ORDER — Genesis 2x2 sprites take their four
109
+ * tiles COLUMN-MAJOR: base+0 top-left, +1 bottom-left, +2 top-right,
110
+ * +3 bottom-right. The tilemap placement below indexes the same way. */
111
+ static const u32 gem_quarter[4][8] = {
112
+ { 0x00005555, 0x00551111, 0x05114411, 0x05144111, /* top-left */
113
+ 0x51141111, 0x51111111, 0x51111111, 0x51111111 },
114
+ { 0x51111111, 0x51111111, 0x51111111, 0x51111111, /* bottom-left */
115
+ 0x05111111, 0x05111111, 0x00551111, 0x00005555 },
116
+ { 0x55550000, 0x11115500, 0x11111150, 0x11111150, /* top-right */
117
+ 0x11111115, 0x11111115, 0x11111115, 0x11111115 },
118
+ { 0x11111115, 0x11111115, 0x11111115, 0x11111115, /* bottom-right */
119
+ 0x11111150, 0x11111150, 0x11115500, 0x55550000 },
120
+ };
121
+ static u32 gem_ram[3][4][8]; /* colours 1..3, built at boot */
122
+
123
+ static void build_gem_tiles(void) {
124
+ u16 k, t, r, i;
125
+ for (k = 0; k < 3; k++)
126
+ for (t = 0; t < 4; t++)
127
+ for (r = 0; r < 8; r++) {
128
+ u32 v = gem_quarter[t][r], out = 0;
129
+ for (i = 0; i < 8; i++) {
130
+ u32 nib = (v >> (i * 4)) & 0xF;
131
+ if (nib == 1) nib = 1 + k; /* fill -> this colour */
132
+ out |= nib << (i * 4);
133
+ }
134
+ gem_ram[k][t][r] = out;
135
+ }
136
+ }
137
+
138
+ /* ── GAME LOGIC (clay — reshape freely) ── game state.
139
+ * Boards are PLAIN STATIC ARRAYS — the Genesis has 64 KB of work RAM, so
140
+ * none of the NES version's absolute-address scratch-page gymnastics.
141
+ * The hot ones are deliberately NON-static: they then appear in the GNU-ld
142
+ * map (build symbols), so a headless agent can resolve them by name and
143
+ * read/poke live state (symbols -> memory) — same trick as the
144
+ * two_plane_parallax template's g_player_x. */
145
+ u8 grid[2][GRID_H][GRID_W]; /* the two wells (P2's unused in 1P) */
146
+ s16 piece_x[2]; /* falling trio: column 0..5 */
147
+ s16 piece_y[2]; /* row of its TOP cell (<0 above rim) */
148
+ u8 piece_col[2][3]; /* trio colours, top to bottom */
149
+ u16 score[2];
150
+ u16 hiscore;
151
+ u8 level; /* 1P: 1..9, speeds up the fall */
152
+ u8 state; /* ST_TITLE / ST_PLAY / ST_OVER */
153
+ u8 two_player;
154
+
155
+ static u8 matched[GRID_H][GRID_W];
156
+ static u8 well_tx[2]; /* left interior TILE column per well */
157
+ static u8 fall_t[2]; /* frames until next gravity step */
158
+ static u16 prev_pad[2]; /* for edge-triggered input */
159
+ static u16 cleared_total; /* 1P: gems cleared, drives the level */
160
+ static u8 board_dirty[2]; /* well needs a repaint this frame */
161
+ static u16 rng = 0xACE1;
162
+
163
+ #define ST_TITLE 0
164
+ #define ST_PLAY 1
165
+ #define ST_OVER 2
166
+
167
+ #define VS_FALL_DELAY 24 /* 2P: fixed gravity (frames per row) */
168
+ #define GARBAGE_CAP 4 /* max garbage rows per attack */
169
+
170
+ /* ── GAME LOGIC (clay) — xorshift16 PRNG (a few 68k instructions) ── */
171
+ static u8 random8(void) {
172
+ u16 r = rng;
173
+ r ^= r << 7;
174
+ r ^= r >> 9;
175
+ r ^= r << 8;
176
+ rng = r;
177
+ return (u8)r;
178
+ }
179
+
180
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
181
+ * CARTRIDGE SRAM — the Genesis battery-save mechanism, three parts:
182
+ *
183
+ * 1. The ROM HEADER declares it: bytes $1B0.. hold 'R','A', a type word
184
+ * ($F820 = battery-backed, byte-wide on ODD addresses — the classic
185
+ * cart wiring), then start/end addresses $200000/$20FFFF. SGDK's
186
+ * rom_header.c (assembled into every build) already declares exactly
187
+ * this — no linker work needed. Emulators allocate the save RAM by
188
+ * READING THIS HEADER; no 'RA' block = writes to $200000+ go nowhere.
189
+ * 2. The MAPPER GATE: writing 1 to $A130F1 banks SRAM into $200000+,
190
+ * 0 banks the ROM back in. SGDK's SRAM_enable()/SRAM_disable() do
191
+ * this. ALWAYS disable after access — on carts >2 MB the SRAM window
192
+ * shadows ROM, and leaving it enabled corrupts later ROM fetches.
193
+ * 3. ODD-BYTE ADDRESSING: SRAM_readByte/writeByte(offset) access 68k
194
+ * address $200001 + offset*2. Headlessly, the emulator's save_ram
195
+ * region interleaves with dead even bytes: SGDK offset k lives at
196
+ * save_ram[k*2 + 1] (the even bytes read back $FF).
197
+ *
198
+ * Hi-score record layout (SGDK offsets): 0='H' 1='S' 2=lo 3=hi
199
+ * 4=checksum(lo^hi^$A5). Fresh SRAM is all $FF — the magic+checksum
200
+ * rejects it (and any corruption) so first boot shows 0, not 65535.
201
+ *
202
+ * Emulator note (verified against gpgx): the core sizes its save_ram
203
+ * region by scanning for the last non-$FF byte, so the region reads as
204
+ * EMPTY until the first write below lands — that's why hiscore_init runs
205
+ * at the very top of main(). Real hardware and .srm-restoring frontends
206
+ * have no such wrinkle. */
207
+ static u16 hiscore_load(void) {
208
+ u8 m0, m1, lo, hi, ck;
209
+ SRAM_enableRO();
210
+ m0 = SRAM_readByte(0);
211
+ m1 = SRAM_readByte(1);
212
+ lo = SRAM_readByte(2);
213
+ hi = SRAM_readByte(3);
214
+ ck = SRAM_readByte(4);
215
+ SRAM_disable();
216
+ if (m0 == 'H' && m1 == 'S' && ck == (u8)(lo ^ hi ^ 0xA5))
217
+ return ((u16)hi << 8) | lo;
218
+ return 0;
219
+ }
61
220
 
62
- static u8 grid[ROWS][COLS];
221
+ static void hiscore_save(u16 sc) {
222
+ u8 lo = (u8)sc, hi = (u8)(sc >> 8);
223
+ SRAM_enable();
224
+ SRAM_writeByte(0, 'H');
225
+ SRAM_writeByte(1, 'S');
226
+ SRAM_writeByte(2, lo);
227
+ SRAM_writeByte(3, hi);
228
+ SRAM_writeByte(4, (u8)(lo ^ hi ^ 0xA5));
229
+ SRAM_disable();
230
+ }
63
231
 
64
- static u8 piece[3]; /* the 3 colours in the active piece */
65
- static s16 piece_x; /* column 0..COLS-1 */
66
- static s16 piece_y; /* row, can be negative while above the grid */
67
- static u16 fall_timer;
68
- static u16 score;
69
- static u32 rng_state = 1;
232
+ /* Format-on-first-boot: if the magic is absent (fresh battery), write a
233
+ * valid zero record immediately so the save file exists from frame one. */
234
+ static void hiscore_init(void) {
235
+ hiscore = hiscore_load();
236
+ if (hiscore == 0) hiscore_save(0);
237
+ }
70
238
 
71
- static u32 xorshift(void) {
72
- rng_state ^= rng_state << 13;
73
- rng_state ^= rng_state >> 17;
74
- rng_state ^= rng_state << 5;
75
- return rng_state;
239
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
240
+ * DMA-QUEUED TILEMAP WRITES — the board repaint path, and where the
241
+ * Genesis earns its keep for puzzle games. Each well keeps a full tilemap
242
+ * MIRROR in work RAM (24 tile rows x 12 tile cols = 576 bytes of attrs);
243
+ * when game logic dirties a board we rebuild the mirror and queue it with
244
+ * ONE VDP_setTileMapDataRect(..., DMA_QUEUE) call. SYS_doVBlankProcess
245
+ * flushes the queue during vblank, where VRAM bandwidth lives (~7 KB per
246
+ * H40 vblank — both wells together use 1.2 KB, so a worst-case double
247
+ * cascade repaints in ONE frame; the NES version budgets the same repaint
248
+ * across 12). Three rules make it safe:
249
+ * - the mirror buffers are STATIC: the queue reads them AT FLUSH TIME,
250
+ * so stack buffers are gone by then, shipping garbage to VRAM;
251
+ * - everything VRAM-bound in the loop goes through DMA_QUEUE (sprites
252
+ * too) so writes land in vblank, never mid-scanline;
253
+ * - the queue holds 80 entries (a rect = one entry per row, so a well
254
+ * is 24) — flush every frame and you'll never overflow it.
255
+ * Mid-frame VDP_drawTextBG / VDP_fillTileMapRect port writes (HUD numbers,
256
+ * state-change repaints) are FINE on Genesis — the VDP FIFO absorbs them.
257
+ * That freedom is exactly what the NES does not give you. */
258
+ static u16 wellmap[2][GRID_H * 2 * GRID_W * 2]; /* 576 bytes per well */
259
+
260
+ static void queue_board(u8 p) {
261
+ u16 r, c, q, base;
262
+ u16 *m = wellmap[p];
263
+ for (r = 0; r < GRID_H; r++) {
264
+ for (c = 0; c < GRID_W; c++) {
265
+ u8 v = grid[p][r][c];
266
+ base = v ? (u16)(T_GEM + (v - 1) * 4) : T_CELL;
267
+ for (q = 0; q < 4; q++) { /* column-major quarters */
268
+ u16 tile = v ? (u16)(base + q) : T_CELL;
269
+ m[(r * 2 + (q & 1)) * (GRID_W * 2) + c * 2 + (q >> 1)] =
270
+ TILE_ATTR_FULL(PAL1, 0, 0, 0, tile);
271
+ }
272
+ }
273
+ }
274
+ VDP_setTileMapDataRect(BG_A, m, well_tx[p], WELL_TY,
275
+ GRID_W * 2, GRID_H * 2, GRID_W * 2, DMA_QUEUE);
76
276
  }
77
277
 
78
- static u8 random_colour(void) { return 1 + (xorshift() % 3); }
278
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
279
+ * WINDOW-PLANE HUD — the fixed status bar. The window is a third tilemap
280
+ * that REPLACES plane A wherever it's shown and IGNORES ALL SCROLLING —
281
+ * a hardware-fixed HUD with zero per-frame cost. (The NES needs a sprite-0
282
+ * raster trick for this; on Genesis it's one register.)
283
+ * VDP_setWindowOnTop(2) shows it on the top 2 cell rows; text goes in with
284
+ * VDP_drawTextBG(WINDOW, ...). Two footguns:
285
+ * - The window only lives at screen edges (top/bottom N rows or left/
286
+ * right N columns) — it cannot float mid-screen.
287
+ * - It replaces plane A ONLY: plane B and sprites still render behind/
288
+ * over it. We paint plane B's top rows with a flat dark band so HUD
289
+ * text always reads, and the trio sprites never rise above y=24
290
+ * (rows above the rim are simply not drawn). */
291
+ static void hud_init(void) {
292
+ VDP_setWindowOnTop(HUD_ROWS);
293
+ }
79
294
 
80
- static void new_piece(void) {
81
- piece[0] = random_colour();
82
- piece[1] = random_colour();
83
- piece[2] = random_colour();
84
- piece_x = COLS / 2 - 1;
85
- piece_y = -3;
295
+ /* ── GAME LOGIC (clay) — HUD text (window plane, redrawn only on change) ── */
296
+ static void draw_u16(VDPPlane plane, u16 v, u16 x, u16 y) {
297
+ char buf[8];
298
+ uintToStr(v, buf, 5);
299
+ VDP_drawTextBG(plane, buf, x, y);
86
300
  }
87
301
 
88
- static u8 tile_for(u8 cell) {
89
- switch (cell) {
90
- case 1: return T_RED;
91
- case 2: return T_GREEN;
92
- case 3: return T_BLUE;
93
- default: return T_BLANK;
302
+ static u8 hud_dirty_layout = 1; /* set when the HUD layout changes (title<->play) */
303
+
304
+ static void draw_hud(void) {
305
+ char b[2];
306
+ if (state == ST_TITLE) {
307
+ hud_dirty_layout = 1; /* next play entry repaints its layout */
308
+ VDP_clearTextAreaBG(WINDOW, 0, 0, 40, HUD_ROWS);
309
+ VDP_drawTextBG(WINDOW, "HI", 18, 0);
310
+ draw_u16(WINDOW, hiscore, 21, 0);
311
+ return;
312
+ }
313
+ /* Entering play from the title leaves the title HUD's glyphs behind
314
+ * (different column layout) — clear the row before the play layout, or
315
+ * the leftovers merge into garbage like "HIH0000000". */
316
+ if (hud_dirty_layout) { VDP_clearTextAreaBG(WINDOW, 0, 0, 40, HUD_ROWS); hud_dirty_layout = 0; }
317
+ VDP_drawTextBG(WINDOW, two_player ? "P1" : "SC", 1, 0);
318
+ draw_u16(WINDOW, score[0], 4, 0);
319
+ VDP_drawTextBG(WINDOW, "HI", 16, 0);
320
+ draw_u16(WINDOW, hiscore, 19, 0);
321
+ if (two_player) {
322
+ VDP_drawTextBG(WINDOW, "P2", 30, 0);
323
+ draw_u16(WINDOW, score[1], 33, 0);
324
+ } else {
325
+ VDP_drawTextBG(WINDOW, "LV", 30, 0);
326
+ b[0] = '0' + level; b[1] = 0;
327
+ VDP_drawTextBG(WINDOW, b, 33, 0);
94
328
  }
95
329
  }
96
330
 
97
- static u16 pal_for(u8 cell) {
98
- /* All three colours share palette 1; we colour them via tile
99
- * index (each tile uses its own colour index). */
100
- return PAL1;
331
+ /* ── GAME LOGIC (clay) — paint the planes ───────────────────────────────────
332
+ * Plane B (backdrop) is painted ONCE at boot and never touched again.
333
+ * Plane A is repainted on state changes (title text wells results);
334
+ * inside the loop only the queued board rects and HUD numbers change. */
335
+ static void paint_backdrop(void) {
336
+ VDP_fillTileMapRect(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, T_BAND),
337
+ 0, 0, 64, HUD_ROWS);
338
+ VDP_fillTileMapRect(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, T_BACK),
339
+ 0, HUD_ROWS, 64, 32 - HUD_ROWS);
101
340
  }
102
341
 
103
- static void draw_cell(s16 col, s16 row) {
104
- if (row < 0 || row >= ROWS) return;
105
- u8 v = grid[row][col];
106
- /* Each grid cell is CELL_PX/8 = 2 tiles square. */
107
- for (u16 dy = 0; dy < 2; dy++) {
108
- for (u16 dx = 0; dx < 2; dx++) {
109
- /* Cells use the EMPTY-or-coloured tile. Empty cells stay
110
- * transparent so the BG_A well backdrop shows through; filled
111
- * cells are HIGH priority so they sit above that backdrop. */
112
- VDP_setTileMapXY(BG_B,
113
- TILE_ATTR_FULL(pal_for(v), v ? 1 : 0, 0, 0, tile_for(v)),
114
- col * 2 + dx + 6,
115
- row * 2 + dy + 1);
116
- }
342
+ static void paint_frame(u8 p) {
343
+ u16 attr = TILE_ATTR_FULL(PAL1, 0, 0, 0, T_FRAME);
344
+ u16 x0 = well_tx[p] - 1;
345
+ VDP_fillTileMapRect(BG_A, attr, x0, WELL_TY - 1, GRID_W * 2 + 2, 1);
346
+ VDP_fillTileMapRect(BG_A, attr, x0, WELL_TY + GRID_H * 2, GRID_W * 2 + 2, 1);
347
+ VDP_fillTileMapRect(BG_A, attr, x0, WELL_TY, 1, GRID_H * 2);
348
+ VDP_fillTileMapRect(BG_A, attr, x0 + GRID_W * 2 + 1, WELL_TY, 1, GRID_H * 2);
349
+ }
350
+
351
+ static void paint_play(void) {
352
+ VDP_clearPlane(BG_A, TRUE);
353
+ paint_frame(0);
354
+ if (two_player) {
355
+ paint_frame(1);
356
+ VDP_drawTextBG(BG_A, "VS", 19, 14);
117
357
  }
358
+ draw_hud();
118
359
  }
119
360
 
120
- static void draw_grid(void) {
121
- for (s16 r = 0; r < ROWS; r++)
122
- for (s16 c = 0; c < COLS; c++)
123
- draw_cell(c, r);
361
+ /* ── GAME LOGIC (clay) — the title screen (text on plane A) ── */
362
+ static void paint_title(void) {
363
+ VDP_clearPlane(BG_A, TRUE);
364
+ VDP_drawTextBG(BG_A, GAME_TITLE, (40 - (sizeof(GAME_TITLE) - 1)) / 2, 8);
365
+ VDP_drawTextBG(BG_A, "1P START - A", 14, 14);
366
+ VDP_drawTextBG(BG_A, "2P VERSUS - B", 13, 16);
367
+ VDP_drawTextBG(BG_A, "A B ROTATE - C DROP", 10, 20);
368
+ VDP_drawTextBG(BG_A, "CHAINS BESIEGE YOUR RIVAL", 7, 22);
369
+ draw_hud();
124
370
  }
125
371
 
126
- static void draw_piece(s16 col, s16 row, bool clear) {
127
- /* Draw / un-draw the 1×3 vertical piece by writing as if the grid
128
- * had it. This is a *transient* overlay, so we restore from grid
129
- * when clearing. */
130
- for (u16 i = 0; i < 3; i++) {
131
- s16 r = row + i;
132
- if (r < 0 || r >= ROWS) continue;
133
- u8 v = clear ? grid[r][col] : piece[i];
134
- for (u16 dy = 0; dy < 2; dy++)
135
- for (u16 dx = 0; dx < 2; dx++)
136
- VDP_setTileMapXY(BG_B,
137
- TILE_ATTR_FULL(pal_for(v), v ? 1 : 0, 0, 0, tile_for(v)),
138
- col * 2 + dx + 6,
139
- r * 2 + dy + 1);
372
+ /* ── GAME LOGIC (clay) the game-over / results screen ── */
373
+ static void paint_over(u8 loser) {
374
+ VDP_clearPlane(BG_A, TRUE);
375
+ if (two_player)
376
+ VDP_drawTextBG(BG_A, loser ? "P1 WINS" : "P2 WINS", 16, 8);
377
+ else
378
+ VDP_drawTextBG(BG_A, "GAME OVER", 15, 8);
379
+ VDP_drawTextBG(BG_A, "P1", 13, 12);
380
+ draw_u16(BG_A, score[0], 17, 12);
381
+ if (two_player) {
382
+ VDP_drawTextBG(BG_A, "P2", 13, 14);
383
+ draw_u16(BG_A, score[1], 17, 14);
140
384
  }
385
+ VDP_drawTextBG(BG_A, "HI", 13, 17);
386
+ draw_u16(BG_A, hiscore, 17, 17);
387
+ VDP_drawTextBG(BG_A, "START - TITLE", 13, 21);
141
388
  }
142
389
 
143
- /* ── match / clear / gravity core (ported from the GBC reference puzzle).
144
- * The old scan was horizontal-only AND cleared cells mid-scan, so vertical
145
- * and diagonal runs never cleared, 4+ runs half-cleared, and nothing ever
146
- * fell afterwards ("rows don't shift down"). This marks every 3+ run in all
147
- * 4 directions, clears them, applies per-column gravity, and loops so
148
- * cascades chain (score scales with chain depth). */
149
- static u8 matched[ROWS][COLS];
390
+ /* ── GAME LOGIC (clay) end of game (top-out). `loser` topped out. ── */
391
+ static void game_end(u8 loser) {
392
+ u16 best = score[0];
393
+ if (two_player && score[1] > best) best = score[1];
394
+ if (best > hiscore) {
395
+ hiscore = best;
396
+ hiscore_save(hiscore); /* battery SRAM — see the SRAM idiom */
397
+ }
398
+ sfx_noise(20); /* game-over rumble */
399
+ state = ST_OVER;
400
+ board_dirty[0] = board_dirty[1] = 0; /* plane A is the results
401
+ * screen now — a stale queued
402
+ * board rect would stamp gems
403
+ * over it */
404
+ prev_pad[0] = 0xFFFF; /* require a fresh press */
405
+ paint_over(loser);
406
+ }
407
+
408
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
409
+ * Match scan: mark every straight run of 3+ same-coloured gems in all 4
410
+ * directions (a cell can belong to several runs — the mask de-dupes), and
411
+ * return how many cells matched. Runs flat-out on the 68000 — no need to
412
+ * smear it across frames like the cc65 version. */
150
413
  static const s8 DIRS4[4][2] = { {0,1}, {1,0}, {1,1}, {1,-1} };
151
414
 
152
- static u8 mark_and_count(void) {
153
- u8 r, c, d, len, k, cnt;
154
- u8 col;
155
- s8 dr, dc;
156
- int sr, sc;
157
- cnt = 0;
158
- for (r = 0; r < ROWS; r++) for (c = 0; c < COLS; c++) matched[r][c] = 0;
159
- for (r = 0; r < ROWS; r++) {
160
- for (c = 0; c < COLS; c++) {
161
- col = grid[r][c];
162
- if (col == 0) continue;
163
- for (d = 0; d < 4; d++) {
164
- dr = DIRS4[d][0]; dc = DIRS4[d][1];
165
- sr = (int)r - dr; sc = (int)c - dc;
166
- if (sr >= 0 && sr < ROWS && sc >= 0 && sc < COLS
167
- && grid[sr][sc] == col) continue; /* not the run's start */
168
- len = 1;
169
- sr = (int)r + dr; sc = (int)c + dc;
170
- while (sr >= 0 && sr < ROWS && sc >= 0 && sc < COLS
171
- && grid[sr][sc] == col) { len++; sr += dr; sc += dc; }
172
- if (len >= 3) {
173
- sr = r; sc = c;
174
- for (k = 0; k < len; k++) {
175
- if (!matched[sr][sc]) { matched[sr][sc] = 1; cnt++; }
176
- sr += dr; sc += dc;
177
- }
415
+ static u8 mark_and_count(u8 p) {
416
+ u8 r, c, d, len, k, cnt, col;
417
+ s8 dr, dc;
418
+ s16 sr, sc;
419
+ cnt = 0;
420
+ for (r = 0; r < GRID_H; r++)
421
+ for (c = 0; c < GRID_W; c++) matched[r][c] = 0;
422
+ for (r = 0; r < GRID_H; r++) {
423
+ for (c = 0; c < GRID_W; c++) {
424
+ col = grid[p][r][c];
425
+ if (col == EMPTY) continue;
426
+ for (d = 0; d < 4; d++) {
427
+ dr = DIRS4[d][0]; dc = DIRS4[d][1];
428
+ sr = (s16)r - dr; sc = (s16)c - dc;
429
+ if (sr >= 0 && sr < GRID_H && sc >= 0 && sc < GRID_W
430
+ && grid[p][sr][sc] == col) continue; /* not the run's start */
431
+ len = 1;
432
+ sr = (s16)r + dr; sc = (s16)c + dc;
433
+ while (sr >= 0 && sr < GRID_H && sc >= 0 && sc < GRID_W
434
+ && grid[p][sr][sc] == col) { len++; sr += dr; sc += dc; }
435
+ if (len >= 3) {
436
+ sr = r; sc = c;
437
+ for (k = 0; k < len; k++) {
438
+ if (!matched[sr][sc]) { matched[sr][sc] = 1; cnt++; }
439
+ sr += dr; sc += dc;
440
+ }
441
+ }
442
+ }
443
+ }
444
+ }
445
+ return cnt;
446
+ }
447
+
448
+ /* Collapse each column so survivors rest on the floor (walk from the bottom,
449
+ * copying gems down to a write cursor, then zero everything above it). */
450
+ static void apply_gravity(u8 p) {
451
+ u8 c;
452
+ s16 r, w;
453
+ for (c = 0; c < GRID_W; c++) {
454
+ w = GRID_H - 1;
455
+ for (r = GRID_H - 1; r >= 0; r--) {
456
+ if (grid[p][r][c] != EMPTY) { grid[p][w][c] = grid[p][r][c]; w--; }
178
457
  }
179
- }
458
+ for (; w >= 0; w--) grid[p][w][c] = EMPTY;
180
459
  }
181
- }
182
- return cnt;
183
460
  }
184
461
 
185
- /* collapse each column so survivors rest on the floor (in place: walk
186
- * from the bottom, copying gems down to a write cursor, then zero above) */
187
- static void apply_gravity(void) {
188
- u8 c;
189
- int r, w;
190
- for (c = 0; c < COLS; c++) {
191
- w = ROWS - 1;
192
- for (r = ROWS - 1; r >= 0; r--) {
193
- if (grid[r][c] != 0) { grid[w][c] = grid[r][c]; w--; }
462
+ /* ── GAME LOGIC (clay) clear matches, drop survivors, chain cascades.
463
+ * Returns the chain depth (0 = the lock matched nothing). */
464
+ static u8 resolve_board(u8 p) {
465
+ u8 n, r, c, chain;
466
+ u16 amt;
467
+ chain = 0;
468
+ for (;;) {
469
+ n = mark_and_count(p);
470
+ if (n == 0) break;
471
+ ++chain;
472
+ for (r = 0; r < GRID_H; r++)
473
+ for (c = 0; c < GRID_W; c++)
474
+ if (matched[r][c]) grid[p][r][c] = EMPTY;
475
+ amt = (u16)n * 10;
476
+ if (chain > 1) amt *= chain; /* cascades pay multiplied */
477
+ if (score[p] < 65000) score[p] += amt;
478
+ /* clear chime — pitch rises with chain depth (smaller divider =
479
+ * higher note on the PSG) */
480
+ sfx_tone(0, (u16)(360 - ((u16)chain << 5)), 10);
481
+ apply_gravity(p);
482
+ board_dirty[p] = 1;
483
+ if (!two_player) {
484
+ cleared_total += n;
485
+ while (level < 9 && cleared_total >= (u16)level * 10) ++level;
486
+ }
487
+ draw_hud();
194
488
  }
195
- for (; w >= 0; w--) grid[w][c] = 0;
196
- }
489
+ return chain;
197
490
  }
198
491
 
199
- static void resolve_board(void) {
200
- u8 n, r, c, chain;
201
- unsigned int amt;
202
- chain = 0;
203
- while (1) {
204
- n = mark_and_count();
205
- if (n == 0) break;
206
- chain++;
207
- for (r = 0; r < ROWS; r++)
208
- for (c = 0; c < COLS; c++)
209
- if (matched[r][c]) grid[r][c] = 0;
210
- amt = (unsigned int)n * 10u;
211
- if (chain > 1) amt = amt * chain;
212
- if (score < 65500u) score += amt;
213
- sfx_tone(0, 250, 12); /* clear chime */
214
- apply_gravity();
215
- }
492
+ /* ── GAME LOGIC (clay) — VERSUS attack: garbage rows rise from the bottom of
493
+ * the victim's well (random gems with one gap — matchable, so a skilled
494
+ * victim digs out). The victim's stack rising means the falling trio shifts
495
+ * up one to stay board-aligned; if the top row is already occupied, the
496
+ * victim tops out and loses. ── */
497
+ static void garbage_insert(u8 v, u8 nrows) {
498
+ u8 k, c, gap;
499
+ s16 r;
500
+ sfx_noise(8); /* incoming-garbage thud */
501
+ for (k = 0; k < nrows; k++) {
502
+ for (c = 0; c < GRID_W; c++) {
503
+ if (grid[v][0][c] != EMPTY) { game_end(v); return; }
504
+ }
505
+ for (r = 0; r < GRID_H - 1; r++)
506
+ for (c = 0; c < GRID_W; c++)
507
+ grid[v][r][c] = grid[v][r + 1][c];
508
+ gap = random8() % GRID_W;
509
+ for (c = 0; c < GRID_W; c++)
510
+ grid[v][GRID_H - 1][c] = (c == gap) ? EMPTY : (u8)(1 + random8() % 3);
511
+ if (piece_y[v] > -3) --piece_y[v]; /* keep the trio aligned */
512
+ }
513
+ board_dirty[v] = 1;
216
514
  }
217
515
 
218
- static void lock_piece(void) {
219
- for (u16 i = 0; i < 3; i++) {
220
- s16 r = piece_y + i;
221
- if (r >= 0 && r < ROWS) grid[r][piece_x] = piece[i];
516
+ /* Can the trio occupy column x, rows y..y+2? Cells above the rim are fine
517
+ * (pieces enter from above); below the floor or on a gem is not. */
518
+ static u8 can_place(u8 p, s16 x, s16 y) {
519
+ s16 i, cy;
520
+ if (x < 0 || x >= GRID_W) return 0;
521
+ for (i = 0; i < 3; i++) {
522
+ cy = y + i;
523
+ if (cy < 0) continue;
524
+ if (cy >= GRID_H) return 0;
525
+ if (grid[p][cy][x] != EMPTY) return 0;
222
526
  }
223
- resolve_board();
224
- draw_grid();
527
+ return 1;
528
+ }
529
+
530
+ static void spawn_piece(u8 p) {
531
+ piece_x[p] = GRID_W / 2;
532
+ piece_y[p] = -2;
533
+ piece_col[p][0] = (u8)(1 + random8() % 3);
534
+ piece_col[p][1] = (u8)(1 + random8() % 3);
535
+ piece_col[p][2] = (u8)(1 + random8() % 3);
536
+ if (!can_place(p, piece_x[p], piece_y[p])) game_end(p);
225
537
  }
226
538
 
227
- static bool collides(s16 col, s16 row) {
228
- if (col < 0 || col >= COLS) return TRUE;
229
- for (u16 i = 0; i < 3; i++) {
230
- s16 r = row + i;
231
- if (r >= ROWS) return TRUE;
232
- if (r >= 0 && grid[r][col] != 0) return TRUE;
539
+ /* ── GAME LOGIC (clay) — land the trio, resolve, attack, respawn. ── */
540
+ static void lock_piece(u8 p) {
541
+ s16 i, y;
542
+ u8 chain;
543
+ for (i = 0; i < 3; i++) {
544
+ y = piece_y[p] + i;
545
+ if (y >= 0) grid[p][y][piece_x[p]] = piece_col[p][i];
546
+ }
547
+ board_dirty[p] = 1;
548
+ sfx_tone(1, 700, 4); /* lock thunk */
549
+ if (piece_y[p] < 0) { game_end(p); return; } /* locked above the rim */
550
+ chain = resolve_board(p);
551
+ if (state != ST_PLAY) return;
552
+ if (chain && two_player) {
553
+ garbage_insert(p ^ 1, chain > GARBAGE_CAP ? GARBAGE_CAP : chain);
554
+ if (state != ST_PLAY) return; /* garbage topped them out */
233
555
  }
234
- return FALSE;
556
+ spawn_piece(p);
235
557
  }
236
558
 
237
- static void render_score(void) {
238
- char buf[6];
239
- u16 v = score;
240
- for (s16 i = 4; i >= 0; i--) { buf[i] = '0' + (v % 10); v /= 10; }
241
- buf[5] = 0;
242
- VDP_drawText(buf, 24, 1);
559
+ /* ── GAME LOGIC (clay) — per-player input + gravity. Edge-triggered moves
560
+ * (one cell per press), held DOWN soft-drops, A/B cycle the trio's colours
561
+ * (the classic trio "rotate"), C hard-drops. P2 reads CONTROLLER 2. ── */
562
+ static void update_player(u8 p) {
563
+ u16 pad, fresh;
564
+ u8 t, fd;
565
+ pad = JOY_readJoypad(p ? JOY_2 : JOY_1);
566
+ fresh = pad & ~prev_pad[p];
567
+ prev_pad[p] = pad;
568
+ if ((fresh & BUTTON_LEFT) && can_place(p, piece_x[p] - 1, piece_y[p]))
569
+ --piece_x[p];
570
+ if ((fresh & BUTTON_RIGHT) && can_place(p, piece_x[p] + 1, piece_y[p]))
571
+ ++piece_x[p];
572
+ if (fresh & BUTTON_A) { /* cycle colours downward */
573
+ t = piece_col[p][2];
574
+ piece_col[p][2] = piece_col[p][1];
575
+ piece_col[p][1] = piece_col[p][0];
576
+ piece_col[p][0] = t;
577
+ sfx_tone(1, 320, 3);
578
+ }
579
+ if (fresh & BUTTON_B) { /* cycle colours upward */
580
+ t = piece_col[p][0];
581
+ piece_col[p][0] = piece_col[p][1];
582
+ piece_col[p][1] = piece_col[p][2];
583
+ piece_col[p][2] = t;
584
+ sfx_tone(1, 280, 3);
585
+ }
586
+ if (fresh & BUTTON_C) { /* hard drop */
587
+ while (can_place(p, piece_x[p], piece_y[p] + 1)) ++piece_y[p];
588
+ lock_piece(p); /* may end the game */
589
+ return;
590
+ }
591
+ if (pad & BUTTON_DOWN) fall_t[p] += 4; /* soft drop */
592
+ ++fall_t[p];
593
+ fd = two_player ? VS_FALL_DELAY
594
+ : (u8)(32 - ((level << 1) + level)); /* 29..5 */
595
+ if (fall_t[p] >= fd) {
596
+ fall_t[p] = 0;
597
+ if (can_place(p, piece_x[p], piece_y[p] + 1))
598
+ ++piece_y[p];
599
+ else
600
+ lock_piece(p); /* may end the game */
601
+ }
602
+ }
603
+
604
+ /* ── GAME LOGIC (clay) — stage this frame's sprites ─────────────────────────
605
+ * Only the falling trios are sprites (locked gems are plane-A tiles): 3
606
+ * SAT slots per player, 16x16 each. Cells above the rim aren't drawn —
607
+ * they'd poke out from under the HUD band. */
608
+ #define HIDE_Y (-32)
609
+ static void stage_sprites(void) {
610
+ u16 p, i, slot;
611
+ for (p = 0; p < 2; p++) {
612
+ u8 active = (state == ST_PLAY) && (p == 0 || two_player);
613
+ for (i = 0; i < 3; i++) {
614
+ s16 r = piece_y[p] + (s16)i;
615
+ u8 col = piece_col[p][i] ? piece_col[p][i] : 1;
616
+ slot = p * 3 + i;
617
+ if (active && r >= 0)
618
+ VDP_setSprite(slot,
619
+ (s16)((well_tx[p] + piece_x[p] * 2) << 3),
620
+ (s16)((WELL_TY + r * 2) << 3),
621
+ SPRITE_SIZE(2, 2),
622
+ TILE_ATTR_FULL(PAL1, 1, 0, 0, T_GEM + (col - 1) * 4));
623
+ else
624
+ /* Hidden sprites park at y = -32 (above the screen).
625
+ * NEVER hide with x = -128..0 — a SAT x of 0 is the VDP's
626
+ * sprite-masking trigger and silently blanks every lower-
627
+ * priority sprite on those scanlines. */
628
+ VDP_setSprite(slot, 8, HIDE_Y, SPRITE_SIZE(2, 2),
629
+ TILE_ATTR_FULL(PAL1, 1, 0, 0, T_GEM));
630
+ }
631
+ }
632
+ /* ── HARDWARE IDIOM (load-bearing) — CHAIN the sprite list before
633
+ * uploading. VDP_setSprite does NOT set the SAT link byte, and link 0
634
+ * means "end of list": skip this and the VDP draws sprite 0 only.
635
+ * VDP_linkSprites(0, 6) links slots 0..5; the queued DMA flushes the
636
+ * 6 SAT entries during vblank. ── */
637
+ VDP_linkSprites(0, 6);
638
+ VDP_updateSprites(6, DMA_QUEUE);
639
+ }
640
+
641
+ /* ── GAME LOGIC (clay) — start a run ── */
642
+ static void start_game(u8 versus) {
643
+ u8 p, r, c;
644
+ two_player = versus;
645
+ well_tx[0] = versus ? WELL_VS_P1 : WELL_1P_TX;
646
+ well_tx[1] = WELL_VS_P2;
647
+ /* Stir the PRNG with time-spent-on-title so runs differ. */
648
+ rng ^= (u16)vtimer ^ ((u16)vtimer << 7);
649
+ if (rng == 0) rng = 0xACE1;
650
+ for (p = 0; p < 2; p++) {
651
+ for (r = 0; r < GRID_H; r++)
652
+ for (c = 0; c < GRID_W; c++) grid[p][r][c] = EMPTY;
653
+ fall_t[p] = 0;
654
+ score[p] = 0;
655
+ prev_pad[p] = 0xFFFF; /* the button that started the game
656
+ * shouldn't also rotate the first trio */
657
+ }
658
+ cleared_total = 0;
659
+ level = 1;
660
+ state = ST_PLAY;
661
+ paint_play();
662
+ board_dirty[0] = 1;
663
+ board_dirty[1] = versus;
664
+ spawn_piece(0);
665
+ if (versus) spawn_piece(1);
666
+ sfx_tone(0, 200, 10); /* start jingle */
243
667
  }
244
668
 
245
669
  int main(bool hard) {
670
+ u16 pad, fresh;
246
671
  (void)hard;
247
672
 
248
- /* Palette 1: tile colours for red/green/blue cells + the backdrop. */
249
- PAL_setColor(16 + 1, 0x000E); /* red */
250
- PAL_setColor(16 + 2, 0x00E0); /* green */
251
- PAL_setColor(16 + 3, 0x0E00); /* blue */
252
- PAL_setColor(16 + 4, 0x0420); /* backdrop wall border */
253
- PAL_setColor(16 + 5, 0x0610); /* backdrop wall fill */
254
-
255
- VDP_loadTileData(tile_blank, T_BLANK, 1, DMA);
256
- VDP_loadTileData(tile_red, T_RED, 1, DMA);
257
- VDP_loadTileData(tile_green, T_GREEN, 1, DMA);
258
- VDP_loadTileData(tile_blue, T_BLUE, 1, DMA);
259
- VDP_loadTileData(tile_bg, T_BG, 1, DMA);
260
- VDP_loadTileData(tile_well, T_WELL, 1, DMA);
261
-
262
- /* Far plane (BG_A): tile the whole 40x28 screen with the wall block,
263
- * then recess the 12x24-cell play column so the grid sits in an inset
264
- * well. The grid (BG_B) draws over this with HIGH priority. */
265
- for (u16 cy = 0; cy < 28; cy++)
266
- for (u16 cx = 0; cx < 40; cx++)
267
- VDP_setTileMapXY(BG_A,
268
- TILE_ATTR_FULL(PAL1, 0, 0, 0, T_BG), cx, cy);
269
- for (u16 cy = 1; cy <= 24; cy++)
270
- for (u16 cx = 6; cx <= 17; cx++)
271
- VDP_setTileMapXY(BG_A,
272
- TILE_ATTR_FULL(PAL1, 0, 0, 0, T_WELL), cx, cy);
273
-
274
- for (s16 r = 0; r < ROWS; r++)
275
- for (s16 c = 0; c < COLS; c++)
276
- grid[r][c] = 0;
277
-
278
- score = 0;
279
- fall_timer = 0;
280
- sfx_init();
281
- new_piece();
282
- draw_grid();
283
-
284
- VDP_drawText("SCORE", 18, 1);
285
- VDP_drawText("LR MOVE A ROT START DROP", 7, 26);
286
-
287
- u16 prev = 0;
673
+ /* SRAM first before any VDP work. The save file then exists within
674
+ * the game's first frames of life, which is what lets a frontend (or
675
+ * a headless host) see a non-empty save_ram region as early as
676
+ * possible (see the SRAM idiom note on gpgx's size scan). */
677
+ hiscore_init();
678
+
679
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
680
+ * Init order: window size before window text, tiles + palettes before
681
+ * tilemaps that reference them. SGDK's boot already did the dangerous
682
+ * part (VDP regs, Z80, vblank int). No scrolling here, so the scroll
683
+ * mode stays at its boot default — if you add scrolling, set
684
+ * VDP_setScrollingMode FIRST (see the platformer template). */
685
+ hud_init();
686
+
687
+ /* Palettes: PAL1 = board + gems + trio sprites, PAL2 = plane B
688
+ * backdrop, PAL0 index 15 = the SGDK font. Colours are BGR, 3 bits
689
+ * per channel: 0x0BGR with E = full. */
690
+ PAL_setColor(15, 0x0EEE); /* font white */
691
+ PAL_setColor(16 + 1, 0x022E); /* gem 1 ruby */
692
+ PAL_setColor(16 + 2, 0x02C2); /* gem 2 emerald */
693
+ PAL_setColor(16 + 3, 0x0E62); /* gem 3 sapphire */
694
+ PAL_setColor(16 + 4, 0x0EEE); /* gem glint */
695
+ PAL_setColor(16 + 5, 0x0222); /* gem rim */
696
+ PAL_setColor(16 + 6, 0x0666); /* frame steel */
697
+ PAL_setColor(16 + 7, 0x0AAA); /* frame lip */
698
+ PAL_setColor(16 + 8, 0x0421); /* empty-cell speck */
699
+ PAL_setColor(16 + 9, 0x0200); /* well interior near-black */
700
+ PAL_setColor(32 + 1, 0x0202); /* backdrop block border */
701
+ PAL_setColor(32 + 2, 0x0413); /* backdrop block fill */
702
+ PAL_setColor(32 + 3, 0x0101); /* HUD band near-black */
703
+
704
+ VDP_loadTileData(tile_frame, T_FRAME, 1, DMA);
705
+ VDP_loadTileData(tile_cell, T_CELL, 1, DMA);
706
+ VDP_loadTileData(tile_back, T_BACK, 1, DMA);
707
+ VDP_loadTileData(tile_band, T_BAND, 1, DMA);
708
+ build_gem_tiles();
709
+ VDP_loadTileData((u32 *)gem_ram, T_GEM, 12, DMA); /* 3 colours x 4 */
710
+
711
+ paint_backdrop(); /* plane B: painted once */
712
+ sfx_init(); /* PSG: sfx channels + background melody */
713
+
714
+ state = ST_TITLE;
715
+ prev_pad[0] = 0xFFFF;
716
+ paint_title();
288
717
 
289
718
  while (TRUE) {
290
- u16 pad = JOY_readJoypad(JOY_1);
291
-
292
- /* Erase current piece visual. */
293
- draw_piece(piece_x, piece_y, TRUE);
294
-
295
- if ((pad & BUTTON_LEFT) && !(prev & BUTTON_LEFT)
296
- && !collides(piece_x - 1, piece_y)) piece_x--;
297
- if ((pad & BUTTON_RIGHT) && !(prev & BUTTON_RIGHT)
298
- && !collides(piece_x + 1, piece_y)) piece_x++;
299
- if ((pad & BUTTON_A) && !(prev & BUTTON_A)) {
300
- u8 t = piece[0]; piece[0] = piece[1]; piece[1] = piece[2]; piece[2] = t;
301
- sfx_tone(2, 450, 2); /* rotate click */
302
- }
303
- if ((pad & BUTTON_START) && !(prev & BUTTON_START)) {
304
- /* Hard-drop. */
305
- while (!collides(piece_x, piece_y + 1)) piece_y++;
306
- lock_piece();
307
- new_piece();
308
- prev = pad;
309
- render_score();
719
+ if (state == ST_TITLE) {
720
+ /* ── GAME LOGIC (clay) — title: A/C/START = 1P, B = 2P versus ── */
721
+ stage_sprites();
722
+ pad = JOY_readJoypad(JOY_1);
723
+ fresh = pad & ~prev_pad[0];
724
+ if (fresh & (BUTTON_A | BUTTON_C | BUTTON_START)) start_game(0);
725
+ else if (fresh & BUTTON_B) start_game(1);
726
+ else prev_pad[0] = pad;
310
727
  sfx_update();
311
728
  SYS_doVBlankProcess();
312
729
  continue;
313
730
  }
314
- prev = pad;
315
-
316
- u16 fall_rate = (pad & BUTTON_DOWN) ? 4 : 30;
317
- if (++fall_timer >= fall_rate) {
318
- fall_timer = 0;
319
- if (collides(piece_x, piece_y + 1)) {
320
- lock_piece();
321
- new_piece();
731
+
732
+ if (state == ST_OVER) {
733
+ /* Results screen; START or A/C returns to the title. */
734
+ stage_sprites();
735
+ pad = JOY_readJoypad(JOY_1);
736
+ fresh = pad & ~prev_pad[0];
737
+ if (fresh & (BUTTON_START | BUTTON_A | BUTTON_C)) {
738
+ state = ST_TITLE;
739
+ prev_pad[0] = 0xFFFF; /* swallow the held START */
740
+ paint_title();
322
741
  } else {
323
- piece_y++;
742
+ prev_pad[0] = pad;
324
743
  }
744
+ sfx_update();
745
+ SYS_doVBlankProcess();
746
+ continue;
325
747
  }
326
748
 
327
- /* Re-draw piece in its new position. */
328
- draw_piece(piece_x, piece_y, FALSE);
749
+ /* ── ST_PLAY ──────────────────────────────────────────────────── */
750
+
751
+ /* ── GAME LOGIC (clay — reshape freely) — both players update
752
+ * EVERY frame (simultaneous versus, not alternating turns). Any
753
+ * update can end the game, so re-check state between them. */
754
+ update_player(0);
755
+ if (two_player && state == ST_PLAY) update_player(1);
756
+
757
+ if (state == ST_PLAY) {
758
+ /* Queue dirty board repaints — see the DMA-queue idiom. */
759
+ if (board_dirty[0]) { queue_board(0); board_dirty[0] = 0; }
760
+ if (two_player && board_dirty[1]) { queue_board(1); board_dirty[1] = 0; }
761
+ }
329
762
 
330
- render_score();
763
+ stage_sprites();
331
764
  sfx_update();
332
765
  SYS_doVBlankProcess();
333
766
  }