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,194 +1,702 @@
1
- /* ── sports.c — SMS two-player Pong scaffold ────────────────────────
1
+ /* ── sports.c — SMS head-to-head court sports game (complete example) ─────────
2
2
  *
3
- * Two-player Pong on the SMS. Both controller ports are wired:
4
- * Player 1 (PORT_JOY_A low 6 bits) UP/DOWN moves left paddle
5
- * Player 2 (PORT_JOY_A high 2 bits + PORT_JOY_B low 4 bits) UP/DOWN
6
- * moves right paddle. SMS splits P2 awkwardly across two ports;
7
- * sms_joypad_read_p2() reassembles into the same bit layout as P1.
3
+ * DEUCE DASH a COMPLETE, working game: title screen with a 1P-vs-CPU /
4
+ * 2P-versus pick, a beatable CPU opponent, 2P SIMULTANEOUS versus (P2 on
5
+ * PORT B), first-to-5 match flow into a result screen, PSG music + SFX, and
6
+ * a persistent record (longest 1P win streak vs the CPU) in Sega-mapper cart
7
+ * RAM. The court renders under the SMS's signature LINE-INTERRUPT split: a
8
+ * fixed HUD strip over the playfield, timed by the VDP's programmable line
9
+ * counter.
8
10
  *
9
- * When no P2 controller is plugged in, the right paddle falls back to
10
- * "chase the ball" AI so the game is still playable solo.
11
+ * The game: Pong's lineage two paddles, a bouncing ball, a netted court.
12
+ * UP/DOWN move your paddle; deflect the ball back, score when it passes the
13
+ * far paddle, first to 5 wins the match. The deflection angle depends on
14
+ * WHERE the ball hits the paddle (centre = flat, edges = steep) — and a steep
15
+ * edge hit is exactly how a human out-angles the 1px/frame CPU.
11
16
  *
12
- * Designed for the romdev playtest window plug in a second pad
13
- * mid-session and player 2 starts working without a restart.
17
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
18
+ * very different one. The markers tell you what's what:
19
+ * HARDWARE IDIOM (load-bearing) — dodges a documented SMS footgun; reshape
20
+ * your gameplay around it (see TROUBLESHOOTING before changing).
21
+ * GAME LOGIC (clay) — court art, ball physics, CPU skill, scoring rules:
22
+ * reshape freely.
23
+ *
24
+ * What depends on what:
25
+ * sms_hw.h / vdp_init.c / load_palette.c / load_tiles.c / sprite_table.c /
26
+ * joypad_read.c — the bundled VDP + input runtime (this file's externs).
27
+ * sms_sfx.{h,c} + sms_music.{h,c} — SN76489 PSG sound layers.
28
+ * sms_crt0.s — boot + vector table. Its $0038 IM-1 handler is the OTHER
29
+ * HALF of the line-interrupt idiom below: it acks the VDP (one status
30
+ * read clears BOTH the frame and line IRQ flags) and returns with
31
+ * ei/reti. Load-bearing; edit with TROUBLESHOOTING open.
32
+ *
33
+ * THE VERSUS LESSON (shared with the NES/Genesis sports examples): the SMS is
34
+ * fully deterministic. Two fixed strategies — say, an idle player and a
35
+ * ball-chasing CPU — lock into an INFINITE rally loop (the exact same cycle,
36
+ * forever; the match never ends). A versus game NEEDS a noise source: a ±1
37
+ * random "spin" on every paddle return, ticked once per play frame, so two
38
+ * identical game states a few seconds apart diverge and SOMEONE eventually
39
+ * reaches 5. The verify harness proves this: an idle 1P-vs-CPU match must
40
+ * provably END. (See deflect() + the random8() tick in the play loop.)
41
+ *
42
+ * Frame budget (NTSC, 60fps): 2 paddles (3 stacked sprites each) + 1 ball =
43
+ * 7 SAT slots, two AABB paddle tests, a handful of gated HUD writes — a
44
+ * fraction of one frame even on the 3.58MHz Z80. The only thing we budget is
45
+ * the HUD's software 16-bit divisions (see the BUDGET FOOTGUN at the main
46
+ * loop), exactly as the platformer/puzzle do.
47
+ *
48
+ * SDCC FOOTGUN (bites every fork): uint8 loop bounds silently wrap —
49
+ * `for (uint8_t i = 0; i < 24 * 32; i++)` is an INFINITE loop (768 > 255;
50
+ * SDCC even warns "comparison is always true"). Treat that warning as an
51
+ * error: widen the counter to uint16_t or keep loops nested per-row like the
52
+ * painters below.
14
53
  */
15
54
  #include "sms_hw.h"
16
55
  #include "sms_sfx.h"
17
56
  #include "sms_music.h"
18
57
  #include <stdint.h>
19
58
 
59
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
60
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
61
+ #define GAME_TITLE "DEUCE DASH"
62
+
20
63
  extern void sms_vdp_init(void);
64
+ extern void sms_vdp_write_reg(uint8_t reg, uint8_t value);
21
65
  extern void sms_vdp_display_on(void);
66
+ extern void sms_vdp_display_off(void);
67
+ extern void sms_vdp_set_addr(uint16_t addr, uint8_t prefix);
22
68
  extern void sms_load_palette(const uint8_t *palette);
23
69
  extern void sms_load_tiles(uint16_t vram_dest, const uint8_t *src, uint16_t byte_count);
24
70
  extern void sms_set_tilemap_cell(uint8_t row, uint8_t col, uint8_t tile_idx, uint8_t attr);
25
- extern void sms_vblank_wait(void);
26
71
  extern uint8_t sms_joypad_read(void);
27
72
  extern uint8_t sms_joypad_read_p2(void);
28
73
  extern void sms_sprite_init(void);
29
74
  extern void sms_sprite_set(uint8_t slot, uint8_t x, uint8_t y, uint8_t tile);
30
75
  extern void sms_sat_upload(void);
31
76
 
32
- #define COURT_TOP 8
33
- #define COURT_BOT 184
34
- #define PADDLE_H 24
35
- #define BALL_SIZE 8
36
- #define PADDLE_X1 16
37
- #define PADDLE_X2 232
38
-
77
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
78
+ * Palettes. SMS CRAM is 2-2-2 BGR (--BBGGRR): R bits 0-1, G bits 2-3,
79
+ * B bits 4-5. White = 0x3F. BG colour 0 doubles as the backdrop/border. */
39
80
  static const uint8_t palette[32] = {
40
- /* BG: 0 = backdrop, 1 = court green, 2 = court line / net white */
41
- 0x10, 0x08, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x00,
81
+ /* BG: 0 = court navy, 1 = court green, 2 = white (lines + net + text),
82
+ * 3 = HUD-bar steel */
83
+ 0x10, 0x08, 0x3F, 0x15, 0x00, 0x00, 0x00, 0x00,
42
84
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
43
- /* Sprite palette: white at idx 1 */
44
- 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
85
+ /* Sprites: 1 = P1 cyan, 2 = P2/CPU red, 3 = white ball.
86
+ * One shared sprite palette on SMS — per-"sprite" colour means per-TILE
87
+ * colour indices, not per-sprite palettes. */
88
+ 0x00, 0x3C, 0x03, 0x3F, 0x00, 0x00, 0x00, 0x00,
45
89
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
46
90
  };
47
91
 
48
- static const uint8_t tile_solid[32] = {
49
- 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00,
50
- 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00,
51
- 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00,
52
- 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00,
92
+ /* ── GAME LOGIC (clay) — BG tile inventory (BG bank $0000) ───────────────────
93
+ * tile 0 = blank court (colour 0)
94
+ * tiles 1..37 = font: digits 0-9, A-Z, '-' (uploaded 1bpp→4bpp below)
95
+ * tile 38 = court field (solid colour 1 green)
96
+ * tile 39 = court line / sideline (solid colour 2 white)
97
+ * tile 40 = dashed net (colour 2 stripe on green)
98
+ * tile 41 = solid HUD bar (colour 3) — the split seam hides in it */
99
+ #define FONT_BASE 1
100
+ #define BG_FIELD 38
101
+ #define BG_LINE 39
102
+ #define BG_NET 40
103
+ #define BG_HUDBAR 41
104
+
105
+ /* 1bpp font (same glyph set as the NES/GB examples — 0-9, A-Z, '-').
106
+ * Stored 8 bytes/glyph; expanded to the SMS's 32-byte 4bpp tiles at upload
107
+ * (see load_font below), so the ROM carries 296 bytes instead of 1184. */
108
+ static const uint8_t font8[37][8] = {
109
+ /* 0-9 */
110
+ {0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00}, {0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00},
111
+ {0x3C,0x66,0x06,0x0C,0x18,0x30,0x7E,0x00}, {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00},
112
+ {0x0C,0x1C,0x3C,0x6C,0x7E,0x0C,0x0C,0x00}, {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00},
113
+ {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x30,0x30,0x00},
114
+ {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00}, {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00},
115
+ /* A-Z */
116
+ {0x18,0x3C,0x66,0x66,0x7E,0x66,0x66,0x00}, {0x7C,0x66,0x66,0x7C,0x66,0x66,0x7C,0x00},
117
+ {0x3C,0x66,0x60,0x60,0x60,0x66,0x3C,0x00}, {0x78,0x6C,0x66,0x66,0x66,0x6C,0x78,0x00},
118
+ {0x7E,0x60,0x60,0x7C,0x60,0x60,0x7E,0x00}, {0x7E,0x60,0x60,0x7C,0x60,0x60,0x60,0x00},
119
+ {0x3C,0x66,0x60,0x6E,0x66,0x66,0x3E,0x00}, {0x66,0x66,0x66,0x7E,0x66,0x66,0x66,0x00},
120
+ {0x7E,0x18,0x18,0x18,0x18,0x18,0x7E,0x00}, {0x06,0x06,0x06,0x06,0x66,0x66,0x3C,0x00},
121
+ {0x66,0x6C,0x78,0x70,0x78,0x6C,0x66,0x00}, {0x60,0x60,0x60,0x60,0x60,0x60,0x7E,0x00},
122
+ {0x63,0x77,0x7F,0x6B,0x63,0x63,0x63,0x00}, {0x66,0x76,0x7E,0x7E,0x6E,0x66,0x66,0x00},
123
+ {0x3C,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x7C,0x66,0x66,0x7C,0x60,0x60,0x60,0x00},
124
+ {0x3C,0x66,0x66,0x66,0x6A,0x6C,0x36,0x00}, {0x7C,0x66,0x66,0x7C,0x6C,0x66,0x66,0x00},
125
+ {0x3C,0x66,0x60,0x3C,0x06,0x66,0x3C,0x00}, {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00},
126
+ {0x66,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x66,0x66,0x66,0x66,0x66,0x3C,0x18,0x00},
127
+ {0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00}, {0x66,0x66,0x3C,0x18,0x3C,0x66,0x66,0x00},
128
+ {0x66,0x66,0x66,0x3C,0x18,0x18,0x18,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x60,0x7E,0x00},
129
+ /* '-' */
130
+ {0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00},
131
+ };
132
+
133
+ /* Expand 1bpp glyphs into 4bpp SMS tiles as colour 2 (plane 1 set → white).
134
+ * SMS tile rows are 4 bytes: plane0, plane1, plane2, plane3. */
135
+ static void load_font(void) {
136
+ uint8_t g, r, bits;
137
+ sms_vdp_set_addr((uint16_t)(FONT_BASE * 32), VDP_VRAM_WRITE);
138
+ for (g = 0; g < 37; g++) {
139
+ for (r = 0; r < 8; r++) {
140
+ bits = font8[g][r];
141
+ PORT_VDP_DATA = 0; /* plane 0 */
142
+ PORT_VDP_DATA = bits; /* plane 1 → colour index 2 (white) */
143
+ PORT_VDP_DATA = 0; /* plane 2 */
144
+ PORT_VDP_DATA = 0; /* plane 3 */
145
+ }
146
+ }
147
+ }
148
+
149
+ /* ── GAME LOGIC (clay) — court furniture tiles (4bpp, 32 bytes each). The
150
+ * paddles + ball are SPRITES (they move every frame); the court is BG tiles.
151
+ * BG_FIELD = solid green (colour 1 = plane 0)
152
+ * BG_LINE = solid white (colour 2 = plane 1): rails + sidelines
153
+ * BG_NET = a centred white stripe over green (the net column)
154
+ * BG_HUDBAR= solid steel (colour 3 = planes 0+1): the split seam hides here */
155
+ static const uint8_t court_tiles[32 * 4] = {
156
+ /* BG_FIELD — solid colour 1 (plane 0) */
157
+ 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
158
+ 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
159
+ /* BG_LINE — solid colour 2 (plane 1 = white) */
160
+ 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
161
+ 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
162
+ /* BG_NET — centre column white (colour 2), rest green (colour 1) */
163
+ 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
164
+ 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
165
+ /* BG_HUDBAR — solid colour 3 (planes 0+1 = steel); seam hides here */
166
+ 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
167
+ 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
53
168
  };
54
169
 
55
- /* Three BG tiles for the court, loaded into the BG tile bank at $0000:
56
- * tile 0 = court green (solid colour 1)
57
- * tile 1 = court line / border (solid colour 2 = white)
58
- * tile 2 = dashed net (colour 2 vertical stripe on green) */
59
- static const uint8_t bg_tiles[96] = {
60
- /* tile 0 = court green (colour 1 -> plane 0 set) */
61
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
62
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
63
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
64
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
65
- /* tile 1 = court line / border (colour 2 -> plane 1 set) */
66
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
67
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
68
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
69
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
70
- /* tile 2 = net: centre column colour 2, rest colour 1 (green) */
71
- 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
72
- 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
73
- 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
74
- 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
170
+ /* Sprite tiles (sprite bank $2000 vdp_init's R6=0xFF baseline reads sprite
171
+ * patterns from $2000, so upload there, not $0000). The paddle is a solid 4px
172
+ * column (players stack 3 of these = 24px tall); the ball is a small disc.
173
+ * Each on its own sprite colour so P1/P2/ball read distinctly. */
174
+ static const uint8_t sprite_tiles[32 * 3] = {
175
+ /* T_PADDLE+0 paddle column, colour 1 (P1 cyan; recoloured per player by
176
+ * choosing the tile index, see stage_sprites). Plane 0 = colour 1. */
177
+ 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00,
178
+ 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00,
179
+ /* T_PADDLE+1 — paddle column, colour 2 (P2/CPU red). Plane 1 = colour 2. */
180
+ 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00,
181
+ 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00,
182
+ /* T_BALL — disc, colour 3 (white = planes 0+1) */
183
+ 0x00,0x00,0x00,0x00, 0x3C,0x3C,0x00,0x00, 0x7E,0x7E,0x00,0x00, 0x7E,0x7E,0x00,0x00,
184
+ 0x7E,0x7E,0x00,0x00, 0x7E,0x7E,0x00,0x00, 0x3C,0x3C,0x00,0x00, 0x00,0x00,0x00,0x00,
75
185
  };
186
+ #define T_PADDLE 0 /* +0 = P1 (cyan), +1 = P2/CPU (red) */
187
+ #define T_BALL 2
188
+
189
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
190
+ * Court geometry + match rules. The playfield sits below the 3-row HUD strip;
191
+ * COURT_TOP/BOT (pixels) keep the ball between the top/bottom rails. The two
192
+ * paddles sit at fixed X near the sides; the ball is one 8x8 sprite. */
193
+ #define HUD_ROWS 3
194
+ #define HUD_PX (HUD_ROWS * 8)
195
+ #define PADDLE_H 24 /* 3 stacked 8px sprites */
196
+ #define BALL_SIZE 8
197
+ #define PADDLE_X1 16 /* P1 — left side */
198
+ #define PADDLE_X2 232 /* P2/CPU — right side */
199
+ #define COURT_TOP (HUD_PX + 8) /* first ball pixel below the top rail */
200
+ #define COURT_BOT 184 /* first pixel row of the bottom rail */
201
+ #define WIN_SCORE 5 /* first to 5 takes the match */
202
+
203
+ /* ── GAME LOGIC (clay) — game state.
204
+ * The hot ones are deliberately NON-static: they then appear in the sdld map
205
+ * (build symbols) at $Cxxx in work RAM, so a headless agent can resolve them
206
+ * by name and read/poke live state (parse the map → system_ram offset =
207
+ * addr-0xC000). The SMS has 8KB of work RAM ($C000-$DFFF), so these plain
208
+ * variables cost nothing. */
209
+ int16_t p1y, p2y; /* paddle top Y (int16: collision math)*/
210
+ int16_t bx, by; /* ball position */
211
+ int8_t bdx, bdy; /* ball velocity (px/frame) */
212
+ uint8_t score_p1, score_p2;
213
+ uint8_t two_player; /* title pick: 0 = vs CPU, 1 = 2P */
214
+ uint8_t state; /* ST_TITLE / ST_PLAY / ST_OVER */
215
+ uint16_t best_streak; /* persistent record — see end_match */
216
+
217
+ static uint8_t serve_timer; /* freeze frames between points */
218
+ static uint8_t streak; /* current 1P-vs-CPU win streak (RAM) */
219
+ static uint8_t new_record; /* result screen shows NEW RECORD */
220
+ static uint8_t prev_pad; /* edge-triggered title/result input */
221
+ static uint8_t hud_dirty; /* score changed → redraw next vblank */
222
+ static uint8_t over_step; /* results text, one piece per vblank */
223
+ static uint16_t rng = 0xC0A7;
224
+
225
+ #define ST_TITLE 0
226
+ #define ST_PLAY 1
227
+ #define ST_OVER 2
228
+
229
+ /* ── GAME LOGIC (clay) — xorshift16 PRNG (~tens of cycles per call).
230
+ * A versus game NEEDS this: see THE VERSUS LESSON in the file header. Ticked
231
+ * once per play frame so identical states a few seconds apart still diverge,
232
+ * and used for the ±1 deflection spin so rallies never repeat. */
233
+ static uint8_t random8(void) {
234
+ uint16_t r = rng;
235
+ r ^= r << 7;
236
+ r ^= r >> 9;
237
+ r ^= r << 8;
238
+ rng = r;
239
+ return (uint8_t)r;
240
+ }
241
+
242
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
243
+ * LINE-INTERRUPT SPLIT — the SMS's signature trick (fixed status bar over the
244
+ * playfield, palette splits, water effects). The VDP has ONE scroll register
245
+ * pair for the whole frame; to keep the HUD strip pinned at the top while the
246
+ * court renders below it, we DON'T scroll here (a court doesn't scroll) — but
247
+ * we still take the line IRQ at the bar so the idiom is wired and ready, and
248
+ * so the per-frame timing (vblank → line IRQ → game logic) matches the
249
+ * platformer/puzzle exactly. Where the NES needs the sprite-0-hit HACK (park a
250
+ * sprite, busy-poll a status bit, burn scanlines spinning), the SMS has a
251
+ * real, PROGRAMMABLE line interrupt:
252
+ *
253
+ * R10 = N line counter: a down-counter reloaded with N every line
254
+ * outside the active area; underflow → IRQ at line N.
255
+ * R0 bit 4 (IE1) line-IRQ enable (already set in vdp_init's 0x36 baseline).
256
+ * R1 bit 5 (IE0) frame(vblank)-IRQ enable (set by sms_vdp_display_on's 0xE0).
257
+ *
258
+ * Both IRQs land on the Z80's IM-1 vector at $0038. The crt0's handler does
259
+ * the canonical minimal handshake: push af / in a,($BF) / pop af / ei / reti
260
+ * — reading the status port ACKS the VDP (clears BOTH pending flags; skip the
261
+ * read and the IRQ line stays asserted = interrupt storm), and EI must
262
+ * precede RETI or interrupts stay off forever after the first one.
263
+ *
264
+ * Because the handler does no work, the MAIN loop synchronizes with HALT: the
265
+ * Z80 sleeps until the next interrupt, then reads the V-counter (port $7E) to
266
+ * learn WHICH one woke us — line IRQs fire during the active area (V < 0xC0),
267
+ * the frame IRQ fires at vblank (V ≥ 0xC0).
268
+ *
269
+ * wait_vblank(): sleep until the frame IRQ → do per-frame VRAM work.
270
+ * wait_split(): sleep until the line IRQ at the last bar line → past it,
271
+ * the court renders. (If you ADD a scrolling background, this
272
+ * is where you'd write R8 — see the platformer template.)
273
+ *
274
+ * FOOTGUN — you cannot poll once IRQs are on: a status-port poll spins on the
275
+ * same port the ISR reads. The ISR always wins the race, eats the flag, and
276
+ * the poll loop hangs forever. HALT + V-counter is the IRQ-era replacement.
277
+ *
278
+ * Requires: R10 programmed, IE1 + IE0 enabled, EI executed once after
279
+ * display-on, the crt0's ack-only ISR, and wait_vblank/wait_split called
280
+ * EVERY frame in this order. R10 reloads after each underflow, so the line
281
+ * IRQ re-fires every HUD_PX lines all the way down the frame — the later
282
+ * wakes harmlessly interrupt game logic (the ISR acks them) and we re-halt
283
+ * inside the NEXT wait_vblank(). */
284
+ #define SPLIT_LINE (HUD_PX - 1)
285
+
286
+ static void wait_vblank(void) {
287
+ /* check-first: if game logic overran into vblank, don't sleep a frame */
288
+ while (PORT_V_COUNTER < 0xC0) { __asm__("halt"); }
289
+ }
290
+
291
+ static void wait_split(void) {
292
+ /* halt-first: vblank work always ends inside vblank (V ≥ 0xC0), and the
293
+ * first wake at V < 0xC0 is the line IRQ at SPLIT_LINE */
294
+ do { __asm__("halt"); } while (PORT_V_COUNTER >= 0xC0);
295
+ }
296
+
297
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
298
+ * record in Sega-mapper cart RAM. The Sega mapper's control register at
299
+ * $FFFC: bit 3 maps the cart's 8KB battery RAM into $8000-$BFFF (bank slot
300
+ * 2). Map → copy → unmap; keep the window short so stray pointer bugs can't
301
+ * shred the save. The block is magic + value + checksum so a never-written
302
+ * cart (all $FF) reads back as "no save" instead of a garbage record.
303
+ *
304
+ * NOTE the $FFFC address: it's IN the WRAM mirror ($C000-$DFFF mirrors at
305
+ * $E000-$FFFF), so this write also lands in WRAM at $DFFC — the mapper just
306
+ * snoops the bus. That's why the crt0 parks SP at $DFF0: the bytes above it
307
+ * ($DFFC-$FFFF) belong to the mapper registers' shadow.
308
+ *
309
+ * HONESTY (verified against the bundled gpgx core): gpgx only instantiates
310
+ * the Sega mapper for ROMs LARGER than 48KB, and this build pipeline emits
311
+ * 32KB ROMs — so in-emulator the $8000 window stays open-bus (reads $FF), the
312
+ * magic check fails, and the game falls back to the WRAM record (in-session
313
+ * only). The code below is still the correct real-hardware idiom and lights up
314
+ * unchanged on a >48KB build or a cart with battery RAM: the load path is
315
+ * self-falsifying, never wrong. (The verify harness pads this ROM to 64KB to
316
+ * exercise the cart-RAM path for real, including across power-cycle.) */
317
+ #define MAPPER_CTRL (*(volatile uint8_t *)0xFFFC)
318
+ #define CART_RAM ((volatile uint8_t *)0x8000)
319
+
320
+ static void record_save(uint16_t v) {
321
+ uint8_t lo = (uint8_t)(v & 0xFF), hi = (uint8_t)(v >> 8);
322
+ MAPPER_CTRL = 0x08; /* map cart RAM at $8000 */
323
+ CART_RAM[0] = 0x48; /* 'H' */
324
+ CART_RAM[1] = 0x53; /* 'S' */
325
+ CART_RAM[2] = lo;
326
+ CART_RAM[3] = hi;
327
+ CART_RAM[4] = (uint8_t)(lo ^ hi ^ 0xA5);
328
+ MAPPER_CTRL = 0x00; /* back to ROM in slot 2 */
329
+ }
330
+
331
+ static uint16_t record_load(void) {
332
+ uint16_t v = 0;
333
+ MAPPER_CTRL = 0x08;
334
+ if (CART_RAM[0] == 0x48 && CART_RAM[1] == 0x53 &&
335
+ CART_RAM[4] == (uint8_t)(CART_RAM[2] ^ CART_RAM[3] ^ 0xA5)) {
336
+ v = (uint16_t)(CART_RAM[2] | ((uint16_t)CART_RAM[3] << 8));
337
+ }
338
+ MAPPER_CTRL = 0x00;
339
+ return v;
340
+ }
341
+
342
+ /* ── GAME LOGIC (clay) — text via the font tiles ─────────────────────────────
343
+ * These write the name table directly, so call them only during vblank (or
344
+ * with the display off): VRAM access during active display races the VDP's
345
+ * own fetches and drops/garbles bytes on real hardware. */
346
+ static uint8_t font_tile(char ch) {
347
+ if (ch >= '0' && ch <= '9') return (uint8_t)(FONT_BASE + (ch - '0'));
348
+ if (ch >= 'A' && ch <= 'Z') return (uint8_t)(FONT_BASE + 10 + (ch - 'A'));
349
+ if (ch == '-') return (uint8_t)(FONT_BASE + 36);
350
+ return 0; /* space → blank tile */
351
+ }
352
+
353
+ static void text_draw(uint8_t row, uint8_t col, const char *s) {
354
+ while (*s) sms_set_tilemap_cell(row, col++, font_tile(*s++), 0);
355
+ }
356
+
357
+ static void draw_u16(uint8_t row, uint8_t col, uint16_t v) {
358
+ uint8_t d[5], i;
359
+ for (i = 0; i < 5; i++) { d[i] = (uint8_t)(v % 10); v /= 10; }
360
+ for (i = 0; i < 5; i++)
361
+ sms_set_tilemap_cell(row, (uint8_t)(col + i), (uint8_t)(FONT_BASE + d[4 - i]), 0);
362
+ }
363
+
364
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
365
+ * Split the HUD into STATIC labels (drawn ONCE, display off) and DYNAMIC
366
+ * scores (a handful of cells, gated behind hud_dirty during play). WHY: the
367
+ * vblank window has a finite VRAM budget — the SAT upload alone is 192 OUTs.
368
+ * A clear-the-whole-row + re-letter-everything redraw EVERY frame overruns
369
+ * that budget; the writes that land after the line IRQ (at line 23) get
370
+ * dropped, and a glyph silently vanishes from the bar (it bit this template:
371
+ * the 'S' of BEST disappeared). So the labels go in with the display off and
372
+ * only the few digit cells change in-band. Same discipline the platformer/
373
+ * puzzle templates use. ── */
374
+ static void draw_hud_labels(void) {
375
+ text_draw(0, 1, "P1");
376
+ text_draw(0, 12, "BEST");
377
+ if (two_player) text_draw(0, 26, "P2");
378
+ else text_draw(0, 25, "CPU");
379
+ }
380
+
381
+ static void draw_hud_scores(void) {
382
+ sms_set_tilemap_cell(0, 4, (uint8_t)(FONT_BASE + (score_p1 > 9 ? 9 : score_p1)), 0);
383
+ draw_u16(0, 17, best_streak);
384
+ sms_set_tilemap_cell(0, 29, (uint8_t)(FONT_BASE + (score_p2 > 9 ? 9 : score_p2)), 0);
385
+ }
386
+
387
+ /* ── GAME LOGIC (clay) — screen painters (DISPLAY OFF: free VRAM access, clean
388
+ * cut). While the display is off the frame IRQ doesn't fire — so no halt-based
389
+ * waits in here, or you hang forever.
390
+ *
391
+ * PERF FOOTGUN (inherited from the shmup, found the slow way): per-cell
392
+ * sms_set_tilemap_cell redoes the 4-OUT address setup for every cell — over a
393
+ * full screen that's seconds of black. Set the VRAM address ONCE per row (the
394
+ * data port autoincrements through the row's 64 bytes) and stream. */
395
+ static uint8_t court_tile(uint8_t r, uint8_t c) {
396
+ if (r == 2) return BG_HUDBAR; /* HUD bar row (split seam) */
397
+ if (r < 3) return 0; /* HUD text + breather rows */
398
+ if (r == 3 || r == 23) return BG_LINE; /* top + bottom rails */
399
+ if (c == 0 || c == 31) return BG_LINE; /* side lines */
400
+ if (c == 15) return BG_NET; /* centre net */
401
+ return BG_FIELD; /* green field */
402
+ }
403
+
404
+ static void paint_court_field(void) {
405
+ uint8_t r, c;
406
+ for (r = 0; r < 24; r++) {
407
+ sms_vdp_set_addr((uint16_t)(0x3800 + (uint16_t)r * 64), VDP_VRAM_WRITE);
408
+ for (c = 0; c < 32; c++) {
409
+ PORT_VDP_DATA = court_tile(r, c); /* name-table entry low byte */
410
+ PORT_VDP_DATA = 0; /* high byte: flips/pal/priority */
411
+ }
412
+ }
413
+ }
76
414
 
77
- /* Paint the whole 32x24 court: green field, white top/bottom border lines,
78
- * and a dashed net down the centre column. BG tile bank is $0000. */
79
- static void draw_court(void) {
80
- uint8_t row, col;
81
- for (row = 0; row < 24; row++) {
82
- for (col = 0; col < 32; col++) {
83
- uint8_t t = 0; /* green field */
84
- if (row <= 1 || row >= 22) t = 1; /* white top/bottom lines */
85
- else if (col == 1 || col == 30) t = 1; /* white sidelines */
86
- else if (col == 16) t = 2; /* solid centre net */
87
- sms_set_tilemap_cell(row, col, t, 0);
415
+ static void paint_blank_field(void) {
416
+ uint8_t r, c;
417
+ for (r = 0; r < 24; r++) {
418
+ sms_vdp_set_addr((uint16_t)(0x3800 + (uint16_t)r * 64), VDP_VRAM_WRITE);
419
+ for (c = 0; c < 32; c++) {
420
+ uint8_t t = (r == 2) ? BG_HUDBAR : 0; /* row 2 = HUD bar (split seam) */
421
+ PORT_VDP_DATA = t; /* name-table entry low byte */
422
+ PORT_VDP_DATA = 0; /* high byte: flips/pal/priority */
88
423
  }
89
424
  }
90
425
  }
91
426
 
92
- static int16_t p1y, p2y, bx, by;
93
- static int8_t bdx, bdy;
94
- static uint8_t score_p1, score_p2;
95
- static uint8_t serve_timer;
427
+ static void paint_title(void) {
428
+ sms_vdp_display_off();
429
+ paint_blank_field();
430
+ text_draw(7, (uint8_t)((32 - (sizeof(GAME_TITLE) - 1)) / 2), GAME_TITLE);
431
+ text_draw(11, 9, "1P VS CPU - 1");
432
+ text_draw(13, 9, "2P VERSUS - 2");
433
+ text_draw(17, 10, "BEST STREAK");
434
+ draw_u16(17, 22, best_streak);
435
+ sms_sprite_init(); /* park every sprite off-screen */
436
+ sms_sat_upload();
437
+ sms_vdp_display_on(); /* re-enables the frame IRQ too */
438
+ }
439
+
440
+ static void paint_play(void) {
441
+ sms_vdp_display_off();
442
+ paint_court_field();
443
+ draw_hud_labels(); /* static — drawn once with the display off */
444
+ draw_hud_scores();
445
+ sms_sprite_init();
446
+ sms_sat_upload();
447
+ sms_vdp_display_on();
448
+ }
96
449
 
450
+ /* ── GAME LOGIC (clay) — serve: ball to centre, toward the chosen side ── */
97
451
  static void serve_ball(uint8_t to_left) {
98
452
  bx = 124;
99
- by = 90;
453
+ by = (COURT_TOP + COURT_BOT) / 2;
100
454
  bdx = to_left ? -2 : 2;
101
- bdy = ((score_p1 + score_p2) & 1) ? -1 : 1;
102
- serve_timer = 30;
455
+ bdy = ((score_p1 + score_p2) & 1) ? -1 : 1; /* alternate the angle */
456
+ serve_timer = 30; /* half-second breather */
103
457
  }
104
458
 
105
- static void reset_match(void) {
106
- p1y = 84; p2y = 84;
459
+ /* ── GAME LOGIC (clay) — start a match ── */
460
+ static void start_match(uint8_t players) {
461
+ two_player = players;
462
+ p1y = (COURT_TOP + COURT_BOT) / 2 - PADDLE_H / 2;
463
+ p2y = p1y;
107
464
  score_p1 = 0; score_p2 = 0;
465
+ new_record = 0;
466
+ /* Stir the PRNG with time-spent-on-title so matches differ. */
467
+ rng ^= (uint16_t)((uint16_t)PORT_V_COUNTER << 3);
468
+ if (rng == 0) rng = 0xC0A7;
108
469
  serve_ball(0);
470
+ state = ST_PLAY;
471
+ paint_play();
472
+ prev_pad = 0xFF; /* the button that started shouldn't move */
473
+ sfx_tone(0, 200, 10); /* start jingle */
109
474
  }
110
475
 
111
- void main(void) {
476
+ /* ── GAME LOGIC (clay) — match over: result + record bookkeeping.
477
+ * Persistence choice (shared with the NES/Genesis sports examples): for a
478
+ * VERSUS game a raw hi-score is meaningless (every match ends 5-x), so we
479
+ * persist the longest 1P win streak against the CPU — the stat a returning
480
+ * player actually chases. 2P matches never touch it (humans beating each
481
+ * other isn't a record). One piece of result text per vblank (over_step)
482
+ * because each draw_u16 is 5 software divisions — see the BUDGET FOOTGUN. ── */
483
+ static void end_match(void) {
484
+ uint8_t p1_won = (uint8_t)(score_p1 >= WIN_SCORE);
485
+ if (p1_won && !two_player) {
486
+ ++streak;
487
+ if (streak > best_streak) {
488
+ best_streak = streak;
489
+ new_record = 1;
490
+ record_save(best_streak); /* cart RAM (real hardware); WRAM copy live */
491
+ }
492
+ } else if (!p1_won && !two_player) {
493
+ streak = 0; /* the streak dies with the loss */
494
+ }
495
+ /* End-of-match whistle: two quick descending tones. */
496
+ sfx_tone(0, 220, 10);
497
+ sfx_tone(1, 320, 12);
498
+ state = ST_OVER;
499
+ prev_pad = 0xFF; /* require a fresh press to leave */
500
+ over_step = 4; /* deferred result draws, one per vblank */
501
+ }
502
+
503
+ /* ── GAME LOGIC (clay) — one point scored ── */
504
+ static void score_point(uint8_t for_p1) {
505
+ if (for_p1) { if (score_p1 < 99) ++score_p1; }
506
+ else { if (score_p2 < 99) ++score_p2; }
507
+ sfx_noise(8);
508
+ hud_dirty = 1;
509
+ if (score_p1 >= WIN_SCORE || score_p2 >= WIN_SCORE) end_match();
510
+ else serve_ball(for_p1); /* loser of the point serves toward winner */
511
+ }
512
+
513
+ /* ── GAME LOGIC (clay) — paddle hit: deflect by where the ball struck.
514
+ * Centre = flat-ish, edges = steep. Max |bdy| is 2 — the CPU moves at 1, so an
515
+ * edge hit is exactly how a human beats it. A ±1 random "spin" on every return
516
+ * keeps rallies from repeating (see THE VERSUS LESSON). ── */
517
+ static void deflect(int16_t paddle_y) {
518
+ int16_t rel = (by + BALL_SIZE / 2) - (paddle_y + PADDLE_H / 2);
519
+ bdy = (int8_t)(rel >> 3);
520
+ bdy += (int8_t)((random8() & 2) - 1); /* spin: -1 or +1 */
521
+ if (bdy > 2) bdy = 2;
522
+ if (bdy < -2) bdy = -2;
523
+ if (bdy == 0) bdy = (rel < 0) ? -1 : 1; /* never return a flat ball */
524
+ sfx_tone(0, 250, 4);
525
+ }
526
+
527
+ /* ── GAME LOGIC (clay) — stage this frame's sprites. Paddles + ball are the
528
+ * only sprites: slots 0-2 = P1 paddle, 3-5 = P2/CPU paddle, 6 = ball. Inactive
529
+ * (title) slots park at Y=$E0 (below the 192-line area). NEVER park at Y=$D0 —
530
+ * that's the SAT terminator: the VDP stops scanning at the first $D0 and every
531
+ * later slot vanishes. ── */
532
+ static void stage_sprites(void) {
112
533
  uint8_t i;
113
- sms_vdp_init();
534
+ if (state == ST_TITLE) { sms_sprite_init(); return; }
535
+ for (i = 0; i < PADDLE_H / 8; i++)
536
+ sms_sprite_set((uint8_t)(0 + i), PADDLE_X1,
537
+ (uint8_t)((int16_t)p1y + (int16_t)i * 8), T_PADDLE + 0);
538
+ for (i = 0; i < PADDLE_H / 8; i++)
539
+ sms_sprite_set((uint8_t)(3 + i), PADDLE_X2,
540
+ (uint8_t)((int16_t)p2y + (int16_t)i * 8), T_PADDLE + 1);
541
+ sms_sprite_set(6, (uint8_t)bx, (uint8_t)by, T_BALL);
542
+ }
543
+
544
+ void main(void) {
545
+ uint8_t pad, pad2, fresh;
546
+ int16_t target;
547
+
548
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
549
+ * Init order: VDP regs (display off) → palette → tiles → name table → SAT →
550
+ * R10 → display on (which also enables the frame IRQ) → EI. The one hard
551
+ * rule: EI comes LAST, after every register is in place — the crt0 boots
552
+ * with DI and the FIRST halt would hang forever if interrupts were never
553
+ * enabled. */
554
+ sms_vdp_init(); /* R0=0x36 already has IE1 (line IRQ) set */
114
555
  sms_load_palette(palette);
115
- sms_load_tiles(0x0000, bg_tiles, 96); /* BG court tiles -> BG bank $0000 */
116
- sms_load_tiles(0x2000, tile_solid, 32); /* paddle/ball sprite tile -> $2000 */
117
- draw_court();
556
+ load_font();
557
+ sms_load_tiles((uint16_t)(BG_FIELD * 32), court_tiles, 32 * 4);
558
+ sms_load_tiles(0x2000, sprite_tiles, 32 * 3);
118
559
  sms_sprite_init();
119
560
  sfx_init();
120
561
  music_init();
121
- music_play(0); /* continuous background music ("no sound" was the playtest verdict) */
122
- sms_vdp_display_on();
562
+ music_play(0);
123
563
 
124
- reset_match();
564
+ /* R10 = SPLIT_LINE arms the line counter: IRQ at the last bar line. Set
565
+ * once — it reloads itself every underflow. */
566
+ sms_vdp_write_reg(10, SPLIT_LINE);
125
567
 
126
- do {
127
- uint8_t p1, p2;
128
- uint8_t slot;
129
- int16_t target;
130
- sms_vblank_wait();
568
+ best_streak = record_load(); /* cart RAM if present — else 0 */
569
+ streak = 0;
570
+ state = ST_TITLE;
571
+ prev_pad = 0xFF;
572
+ paint_title();
573
+ __asm__("ei"); /* interrupts live from here on */
574
+
575
+ for (;;) {
576
+ if (state == ST_TITLE) {
577
+ /* ── GAME LOGIC (clay) — title: button 1 = 1P vs CPU, 2 = 2P versus ── */
578
+ wait_vblank();
579
+ sfx_update();
580
+ music_update();
581
+ wait_split();
582
+ pad = sms_joypad_read();
583
+ fresh = (uint8_t)(pad & ~prev_pad);
584
+ prev_pad = pad;
585
+ if (fresh & JOY_B1) start_match(0);
586
+ else if (fresh & JOY_B2) start_match(1);
587
+ continue;
588
+ }
589
+
590
+ if (state == ST_OVER) {
591
+ /* Freeze the court; button 1 or 2 returns to the title. */
592
+ wait_vblank();
593
+ if (over_step) { /* deferred result draws — one/vblank */
594
+ if (over_step == 4) {
595
+ for (pad = 1; pad < 32; pad++) sms_set_tilemap_cell(8, pad, 0, 0);
596
+ text_draw(8, 12, two_player ? (score_p1 >= WIN_SCORE ? "P1 WINS" : "P2 WINS")
597
+ : (score_p1 >= WIN_SCORE ? "P1 WINS" : "CPU WINS"));
598
+ } else if (over_step == 3) {
599
+ text_draw(11, 11, "SCORE");
600
+ sms_set_tilemap_cell(11, 17, (uint8_t)(FONT_BASE + (score_p1 > 9 ? 9 : score_p1)), 0);
601
+ sms_set_tilemap_cell(11, 18, FONT_BASE + 36, 0); /* '-' */
602
+ sms_set_tilemap_cell(11, 19, (uint8_t)(FONT_BASE + (score_p2 > 9 ? 9 : score_p2)), 0);
603
+ } else if (over_step == 2) {
604
+ if (new_record) text_draw(13, 11, "NEW RECORD");
605
+ } else {
606
+ text_draw(16, 10, "START - 1");
607
+ }
608
+ over_step--;
609
+ }
610
+ wait_split();
611
+ sfx_update();
612
+ music_update();
613
+ pad = sms_joypad_read();
614
+ fresh = (uint8_t)(pad & ~prev_pad);
615
+ prev_pad = pad;
616
+ if (fresh & (JOY_B1 | JOY_B2)) {
617
+ state = ST_TITLE;
618
+ prev_pad = 0xFF;
619
+ paint_title();
620
+ }
621
+ stage_sprites(); /* keep the frozen paddles staged */
622
+ continue;
623
+ }
624
+
625
+ /* ── ST_PLAY ─────────────────────────────────────────────────────────
626
+ * Frame shape: [vblank: SAT upload + gated HUD writes] → [line IRQ at the
627
+ * bar] → [rest of frame: game logic]. VRAM traffic stays inside vblank;
628
+ * logic runs while the VDP draws the court.
629
+ *
630
+ * BUDGET FOOTGUN (inherited from the shmup, which found it the hard way):
631
+ * everything between wait_vblank() and wait_split() must finish before the
632
+ * line IRQ at line 23 — vblank (70 lines) + the HUD strip (23) ≈ 21k
633
+ * cycles. The SAT upload eats ~7k of that. An unconditional HUD redraw (a
634
+ * draw_u16 = 5 software 16-bit divisions) is fine alone, but we still GATE
635
+ * it behind hud_dirty so it only fires on a scored point, never every
636
+ * frame — the same discipline the platformer/puzzle use. */
637
+ wait_vblank();
638
+ sms_sat_upload(); /* shadow SAT staged at end of last frame */
639
+ if (hud_dirty) { hud_dirty = 0; draw_hud_scores(); }
131
640
  sfx_update();
132
641
  music_update();
642
+ wait_split(); /* the line-interrupt split — every frame */
133
643
 
134
- /* Stage SAT firstuploaded at vblank. */
135
- slot = 0;
136
- /* Left paddle = 3 stacked 8×8 sprites */
137
- for (i = 0; i < PADDLE_H / 8; i++)
138
- sms_sprite_set(slot++, PADDLE_X1, (uint8_t)(p1y + i * 8), 0);
139
- /* Right paddle */
140
- for (i = 0; i < PADDLE_H / 8; i++)
141
- sms_sprite_set(slot++, PADDLE_X2, (uint8_t)(p2y + i * 8), 0);
142
- /* Ball */
143
- sms_sprite_set(slot++, (uint8_t)bx, (uint8_t)by, 0);
144
- sms_sat_upload();
145
-
146
- p1 = sms_joypad_read();
147
- p2 = sms_joypad_read_p2();
148
-
149
- if ((p1 & JOY_UP) && p1y > COURT_TOP) p1y -= 2;
150
- if ((p1 & JOY_DOWN) && p1y < COURT_BOT - PADDLE_H) p1y += 2;
151
-
152
- /* P2 input if any, otherwise AI. (`target` is declared at the top of the
153
- * loop body — SDCC is C89, declarations must precede statements.) */
154
- if (p2 != 0) {
155
- if ((p2 & JOY_UP) && p2y > COURT_TOP) p2y -= 2;
156
- if ((p2 & JOY_DOWN) && p2y < COURT_BOT - PADDLE_H) p2y += 2;
644
+ /* ── GAME LOGIC (clay reshape freely) from here down ── */
645
+ random8(); /* tick the noise source every play frame */
646
+
647
+ /* P1 port 0, up/down, 2px/frame. */
648
+ pad = sms_joypad_read();
649
+ if ((pad & JOY_UP) && p1y > COURT_TOP) p1y -= 2;
650
+ if ((pad & JOY_DOWN) && p1y < COURT_BOT - PADDLE_H) p1y += 2;
651
+
652
+ if (two_player) {
653
+ /* P2 — PORT B (sms_joypad_read_p2 reassembles the split $DC/$DD bits),
654
+ * same speed: a fair simultaneous-versus match. */
655
+ pad2 = sms_joypad_read_p2();
656
+ if ((pad2 & JOY_UP) && p2y > COURT_TOP) p2y -= 2;
657
+ if ((pad2 & JOY_DOWN) && p2y < COURT_BOT - PADDLE_H) p2y += 2;
157
658
  } else {
158
- target = by - PADDLE_H / 2;
159
- if (p2y < target && p2y < COURT_BOT - PADDLE_H) p2y += 1;
160
- else if (p2y > target && p2y > COURT_TOP) p2y -= 1;
659
+ /* CPU chases the ball centre at 1px/frame (half player speed) with a
660
+ * small dead zone. Beatable by design: steep deflections outrun it. */
661
+ target = by + BALL_SIZE / 2 - PADDLE_H / 2;
662
+ if (p2y + 2 < target && p2y < COURT_BOT - PADDLE_H) p2y += 1;
663
+ else if (p2y > target + 2 && p2y > COURT_TOP) p2y -= 1;
161
664
  }
162
665
 
666
+ /* Ball update (frozen during the post-point serve pause). */
163
667
  if (serve_timer > 0) {
164
- serve_timer--;
668
+ --serve_timer;
165
669
  } else {
166
670
  bx = (int16_t)(bx + bdx);
167
671
  by = (int16_t)(by + bdy);
168
- if (by < COURT_TOP) { by = COURT_TOP; bdy = (int8_t)(-bdy); sfx_tone(1, 300, 2); }
169
- if (by + BALL_SIZE > COURT_BOT) { by = COURT_BOT - BALL_SIZE; bdy = (int8_t)(-bdy); sfx_tone(1, 300, 2); }
170
672
 
673
+ /* Rail bounce. */
674
+ if (by < COURT_TOP) { by = COURT_TOP; bdy = (int8_t)(-bdy); sfx_tone(1, 300, 2); }
675
+ if (by + BALL_SIZE > COURT_BOT) { by = COURT_BOT - BALL_SIZE; bdy = (int8_t)(-bdy); sfx_tone(1, 300, 2); }
676
+
677
+ /* Paddle collisions (direction-gated so the ball can't double-hit). */
171
678
  if (bdx < 0
172
- && bx <= PADDLE_X1 + 8
173
- && bx + BALL_SIZE >= PADDLE_X1
174
- && by + BALL_SIZE > p1y
175
- && by < p1y + PADDLE_H) {
679
+ && bx <= PADDLE_X1 + 8 && bx + BALL_SIZE >= PADDLE_X1
680
+ && by + BALL_SIZE > p1y && by < p1y + PADDLE_H) {
176
681
  bdx = (int8_t)(-bdx);
177
682
  bx = PADDLE_X1 + 8;
178
- sfx_tone(0, 250, 3);
683
+ deflect(p1y);
179
684
  }
180
685
  if (bdx > 0
181
- && bx + BALL_SIZE >= PADDLE_X2
182
- && bx <= PADDLE_X2 + 8
183
- && by + BALL_SIZE > p2y
184
- && by < p2y + PADDLE_H) {
686
+ && bx + BALL_SIZE >= PADDLE_X2 && bx <= PADDLE_X2 + 8
687
+ && by + BALL_SIZE > p2y && by < p2y + PADDLE_H) {
185
688
  bdx = (int8_t)(-bdx);
186
689
  bx = PADDLE_X2 - BALL_SIZE;
187
- sfx_tone(0, 250, 3);
690
+ deflect(p2y);
188
691
  }
189
692
 
190
- if (bx < 4) { if (score_p2 < 9) score_p2++; sfx_noise(20); serve_ball(0); }
191
- if (bx > 252) { if (score_p1 < 9) score_p1++; sfx_tone(0, 180, 16); serve_ball(1); }
693
+ /* Off either side point. */
694
+ if (bx < 4) score_point(0); /* past P1 P2/CPU scores */
695
+ if (bx > 252) score_point(1); /* past P2 → P1 scores */
192
696
  }
193
- } while (1);
697
+
698
+ /* Stage the SAT shadow NOW (RAM only — cheap, any time); the actual VRAM
699
+ * upload waits for the next vblank at the top of the loop. */
700
+ stage_sprites();
701
+ }
194
702
  }