romdevtools 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/AGENTS.md +56 -44
  2. package/CHANGELOG.md +355 -0
  3. package/README.md +4 -4
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1227 -325
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +909 -257
  8. package/examples/atari2600/templates/shmup.asm +1035 -218
  9. package/examples/atari2600/templates/sports.asm +1143 -229
  10. package/examples/atari7800/templates/hello_sprite.c +8 -4
  11. package/examples/atari7800/templates/platformer.c +991 -152
  12. package/examples/atari7800/templates/puzzle.c +1091 -145
  13. package/examples/atari7800/templates/racing.c +949 -118
  14. package/examples/atari7800/templates/shmup.c +812 -130
  15. package/examples/atari7800/templates/sports.c +820 -181
  16. package/examples/c64/templates/platformer.c +876 -157
  17. package/examples/c64/templates/puzzle.c +881 -143
  18. package/examples/c64/templates/racing.c +873 -88
  19. package/examples/c64/templates/shmup.c +762 -154
  20. package/examples/c64/templates/sports.c +755 -95
  21. package/examples/gb/templates/platformer.c +841 -175
  22. package/examples/gb/templates/puzzle.c +1094 -176
  23. package/examples/gb/templates/racing.c +761 -169
  24. package/examples/gb/templates/shmup.c +679 -169
  25. package/examples/gb/templates/sports.c +790 -153
  26. package/examples/gba/templates/platformer.c +624 -169
  27. package/examples/gba/templates/puzzle.c +535 -207
  28. package/examples/gba/templates/racing.c +513 -196
  29. package/examples/gba/templates/shmup.c +565 -168
  30. package/examples/gba/templates/sports.c +454 -162
  31. package/examples/gbc/templates/platformer.c +944 -176
  32. package/examples/gbc/templates/puzzle.c +1131 -177
  33. package/examples/gbc/templates/racing.c +891 -175
  34. package/examples/gbc/templates/shmup.c +827 -179
  35. package/examples/gbc/templates/sports.c +870 -156
  36. package/examples/genesis/templates/platformer.c +747 -129
  37. package/examples/genesis/templates/puzzle.c +702 -208
  38. package/examples/genesis/templates/racing.c +728 -193
  39. package/examples/genesis/templates/shmup.c +535 -142
  40. package/examples/genesis/templates/shmup_2p.c +13 -1
  41. package/examples/genesis/templates/sports.c +495 -158
  42. package/examples/gg/templates/platformer.c +883 -214
  43. package/examples/gg/templates/puzzle.c +906 -181
  44. package/examples/gg/templates/racing.c +919 -160
  45. package/examples/gg/templates/shmup.c +716 -177
  46. package/examples/gg/templates/sports.c +735 -128
  47. package/examples/lynx/templates/platformer.c +604 -50
  48. package/examples/lynx/templates/puzzle.c +533 -130
  49. package/examples/lynx/templates/racing.c +538 -102
  50. package/examples/lynx/templates/shmup.c +461 -122
  51. package/examples/lynx/templates/sports.c +496 -69
  52. package/examples/msx/platformer/main.c +648 -159
  53. package/examples/msx/puzzle/main.c +750 -185
  54. package/examples/msx/racing/main.c +669 -177
  55. package/examples/msx/shmup/main.c +460 -177
  56. package/examples/msx/sports/main.c +591 -124
  57. package/examples/nes/templates/platformer.c +586 -160
  58. package/examples/nes/templates/puzzle.c +603 -222
  59. package/examples/nes/templates/racing.c +505 -197
  60. package/examples/nes/templates/shmup.c +339 -144
  61. package/examples/nes/templates/sports.c +341 -182
  62. package/examples/pce/platformer/main.c +875 -204
  63. package/examples/pce/puzzle/main.c +797 -216
  64. package/examples/pce/racing/main.c +782 -206
  65. package/examples/pce/shmup/main.c +638 -211
  66. package/examples/pce/sports/main.c +585 -167
  67. package/examples/porting-across-platforms/README.md +1 -1
  68. package/examples/sms/templates/platformer.c +765 -176
  69. package/examples/sms/templates/puzzle.c +783 -177
  70. package/examples/sms/templates/racing.c +812 -133
  71. package/examples/sms/templates/shmup.c +601 -148
  72. package/examples/sms/templates/shmup_2p.c +17 -1
  73. package/examples/sms/templates/sports.c +633 -121
  74. package/examples/snes/templates/music_demo.c +7 -0
  75. package/examples/snes/templates/platformer-data.asm +123 -24
  76. package/examples/snes/templates/platformer-hdr.asm +57 -0
  77. package/examples/snes/templates/platformer.c +587 -149
  78. package/examples/snes/templates/puzzle-data.asm +116 -21
  79. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  80. package/examples/snes/templates/puzzle.c +632 -185
  81. package/examples/snes/templates/racing-data.asm +390 -32
  82. package/examples/snes/templates/racing-hdr.asm +57 -0
  83. package/examples/snes/templates/racing.c +807 -177
  84. package/examples/snes/templates/shmup-data.asm +87 -29
  85. package/examples/snes/templates/shmup-hdr.asm +57 -0
  86. package/examples/snes/templates/shmup.c +459 -180
  87. package/examples/snes/templates/sports-data.asm +48 -2
  88. package/examples/snes/templates/sports-hdr.asm +57 -0
  89. package/examples/snes/templates/sports.c +414 -156
  90. package/package.json +12 -12
  91. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  92. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  93. package/src/cores/wasm/fceumm_libretro.js +1 -1
  94. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  95. package/src/cores/wasm/gambatte_libretro.js +1 -1
  96. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  97. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  98. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  99. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  100. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  101. package/src/cores/wasm/handy_libretro.js +1 -1
  102. package/src/cores/wasm/handy_libretro.wasm +0 -0
  103. package/src/cores/wasm/mgba_libretro.js +1 -1
  104. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  105. package/src/cores/wasm/prosystem_libretro.js +1 -1
  106. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  107. package/src/cores/wasm/snes9x_libretro.js +1 -1
  108. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  109. package/src/cores/wasm/stella2014_libretro.js +1 -1
  110. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  111. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  112. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  113. package/src/host/LibretroHost.js +304 -11
  114. package/src/http/tool-registry.js +11 -11
  115. package/src/mcp/server.js +6 -0
  116. package/src/mcp/tools/cheats.js +2 -1
  117. package/src/mcp/tools/disasm-rebuild.js +315 -65
  118. package/src/mcp/tools/disasm.js +149 -28
  119. package/src/mcp/tools/find-references.js +216 -51
  120. package/src/mcp/tools/frame.js +14 -6
  121. package/src/mcp/tools/index.js +18 -4
  122. package/src/mcp/tools/input.js +31 -7
  123. package/src/mcp/tools/lifecycle.js +6 -4
  124. package/src/mcp/tools/memory.js +208 -39
  125. package/src/mcp/tools/platform-docs.js +1 -1
  126. package/src/mcp/tools/playtest.js +56 -4
  127. package/src/mcp/tools/preview-tile.js +6 -2
  128. package/src/mcp/tools/project.js +1114 -120
  129. package/src/mcp/tools/rom-id.js +5 -1
  130. package/src/mcp/tools/run-until.js +4 -2
  131. package/src/mcp/tools/snippets.js +6 -6
  132. package/src/mcp/tools/sprite-pipeline.js +14 -2
  133. package/src/mcp/tools/state.js +2 -1
  134. package/src/mcp/tools/tile-inspect.js +8 -1
  135. package/src/mcp/tools/toolchain.js +55 -11
  136. package/src/mcp/tools/watch-memory.js +145 -27
  137. package/src/observer/bus.js +73 -0
  138. package/src/observer/livestream.html +4 -2
  139. package/src/observer/tool-wrap.js +17 -14
  140. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  141. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  142. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  143. package/src/platforms/atari7800/MENTAL_MODEL.md +32 -11
  144. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  145. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  146. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  147. package/src/platforms/gb/MENTAL_MODEL.md +19 -4
  148. package/src/platforms/gb/TROUBLESHOOTING.md +101 -6
  149. package/src/platforms/gb/lib/c/README.md +10 -11
  150. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  151. package/src/platforms/gb/lib/c/patch-header.js +19 -6
  152. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  153. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  154. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  155. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  156. package/src/platforms/gbc/MENTAL_MODEL.md +16 -4
  157. package/src/platforms/gbc/TROUBLESHOOTING.md +24 -3
  158. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  159. package/src/platforms/gbc/lib/c/README.md +10 -11
  160. package/src/platforms/gbc/lib/c/font.h +43 -0
  161. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  162. package/src/platforms/gbc/lib/c/patch-header.js +19 -6
  163. package/src/platforms/genesis/MENTAL_MODEL.md +43 -9
  164. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  165. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  166. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  167. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  168. package/src/platforms/gg/TROUBLESHOOTING.md +14 -18
  169. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  170. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  171. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  172. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  173. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  174. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  175. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  176. package/src/platforms/msx/MENTAL_MODEL.md +11 -5
  177. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  178. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  179. package/src/platforms/msx/lib/c/msx_hw.h +3 -0
  180. package/src/platforms/msx/lib/c/msx_vdp.c +70 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +12 -5
  182. package/src/platforms/nes/lib/c/nes_runtime.c +190 -34
  183. package/src/platforms/nes/lib/c/nes_runtime.h +35 -0
  184. package/src/platforms/pce/MENTAL_MODEL.md +14 -5
  185. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  186. package/src/platforms/pce/lib/c/pce_hw.h +13 -1
  187. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  188. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  189. package/src/platforms/sms/MENTAL_MODEL.md +11 -6
  190. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  191. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  192. package/src/platforms/snes/MENTAL_MODEL.md +7 -2
  193. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  194. package/src/playtest/playtest.js +73 -3
  195. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  197. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  198. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  199. package/src/toolchains/index.js +64 -19
@@ -1,192 +1,799 @@
1
- /* ── sports.c — Game Gear single-player Pong (vs AI) scaffold ───────
1
+ /* ── sports.c — Game Gear head-to-head court sports game (complete example) ──
2
2
  *
3
- * The Game Gear is a handheld only one controller. Right paddle is
4
- * always AI, tracking the ball. Use UP/DOWN to move the left paddle.
3
+ * BAFFLE BOUNCE a COMPLETE, working game: a 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 a
6
+ * persistent record (longest 1P win streak vs the CPU) in Sega-mapper cart
7
+ * RAM. The court renders under the GG/SMS signature LINE-INTERRUPT split: a
8
+ * fixed HUD strip over the playfield, timed by the VDP's programmable line
9
+ * counter — but EVERYTHING is fit to the GG's 160×144 visible window.
10
+ *
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 — a "baffle" is literally a deflecting plate,
15
+ * which is exactly what the paddle is: centre = flat, edges = steep. A steep
16
+ * edge hit is how a human out-angles the 1px/frame CPU.
17
+ *
18
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
19
+ * very different one. The markers tell you what's what:
20
+ * HARDWARE IDIOM (load-bearing) — dodges a documented GG footgun; reshape
21
+ * your gameplay around it (see TROUBLESHOOTING before changing).
22
+ * GAME LOGIC (clay) — court art, ball physics, CPU skill, scoring rules:
23
+ * reshape freely.
24
+ *
25
+ * THE #1 GG FOOTGUN — THE VISIBLE WINDOW: the GG VDP is the SMS VDP. It
26
+ * renders a full 256×192 frame; the LCD shows only the CENTERED 160×144 of
27
+ * it. Hardware coordinates (sprite OAM, tilemap rows/cols, scanline counts)
28
+ * are all in the FULL 256×192 frame — content placed outside the centered
29
+ * window renders "correctly" and is simply never shown. Every SMS habit ports
30
+ * over EXCEPT placement: this whole file is written against the VIS_* block
31
+ * below. The DEUCE DASH (SMS) court was 32 cols wide on fixed paddle X 16/232;
32
+ * here the court is the 20-col window and the paddles sit at the window edges.
33
+ *
34
+ * 2P IS LEGIT HERE (vs the GG shmup's honest 1P note): a real Game Gear has
35
+ * one controller port on the unit, but its I/O chip is the SMS's and gpgx
36
+ * wires the SMS's full split-across-$DC/$DD second-controller layout for GG
37
+ * too — so a SECOND PAD drives PORT B in the emulator (and on an SMS-pad
38
+ * adapter). Two paddles facing each other is the textbook simultaneous-versus
39
+ * shape, so this example keeps the SMS sports' 2P mode (gg_joypad_read_p2).
40
+ *
41
+ * What depends on what:
42
+ * gg_hw.h / vdp_init.c / load_palette.c / load_tiles.c / sprite_table.c /
43
+ * joypad_read.c — the bundled VDP + input runtime (this file's externs).
44
+ * gg_sfx.{h,c} + gg_music.{h,c} — SN76489 PSG sound layers.
45
+ * gg_crt0.s — boot + vector table. Its $0038 IM-1 handler is the OTHER
46
+ * HALF of the line-interrupt idiom below: it acks the VDP (one status
47
+ * read clears BOTH the frame and line IRQ flags) and returns with
48
+ * ei/reti. Load-bearing; edit with TROUBLESHOOTING open.
49
+ *
50
+ * THE VERSUS LESSON (shared with the SMS/NES/Genesis sports examples): the GG
51
+ * is fully deterministic. Two fixed strategies — say, an idle player and a
52
+ * ball-chasing CPU — lock into an INFINITE rally loop (the exact same cycle,
53
+ * forever; the match never ends). A versus game NEEDS a noise source: a ±1
54
+ * random "spin" on every paddle return, ticked once per play frame, so two
55
+ * identical game states a few seconds apart diverge and SOMEONE eventually
56
+ * reaches 5. The verify harness proves this: an idle 1P-vs-CPU match must
57
+ * provably END. (See deflect() + the random8() tick in the play loop.)
58
+ *
59
+ * SDCC FOOTGUN (bites every fork): uint8 loop bounds silently wrap —
60
+ * `for (uint8_t i = 0; i < 24 * 32; i++)` is an INFINITE loop (768 > 255;
61
+ * SDCC even warns "comparison is always true"). Treat that warning as an
62
+ * error: widen the counter to uint16_t or keep loops nested per-row like the
63
+ * painters below.
5
64
  */
6
65
  #include "gg_hw.h"
7
66
  #include "gg_sfx.h"
67
+ #include "gg_music.h"
8
68
  #include <stdint.h>
9
69
 
70
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
71
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
72
+ #define GAME_TITLE "BAFFLE BOUNCE"
73
+
10
74
  extern void gg_vdp_init(void);
75
+ extern void gg_vdp_write_reg(uint8_t reg, uint8_t value);
11
76
  extern void gg_vdp_display_on(void);
77
+ extern void gg_vdp_display_off(void);
78
+ extern void gg_vdp_set_addr(uint16_t addr, uint8_t prefix);
12
79
  extern void gg_load_palette(const uint8_t *palette);
13
80
  extern void gg_load_tiles(uint16_t vram_dest, const uint8_t *src, uint16_t byte_count);
14
81
  extern void gg_set_tilemap_cell(uint8_t row, uint8_t col, uint8_t tile_idx, uint8_t attr);
15
- extern void gg_vblank_wait(void);
16
82
  extern uint8_t gg_joypad_read(void);
83
+ extern uint8_t gg_joypad_read_p2(void);
17
84
  extern void gg_sprite_init(void);
18
85
  extern void gg_sprite_set(uint8_t slot, uint8_t x, uint8_t y, uint8_t tile);
19
86
  extern void gg_sat_upload(void);
20
87
 
21
- /* ── Game Gear visible viewport ──────────────────────────────────────
22
- * Only the centered 160x144 of the 256x192 frame shows. Keep the whole
23
- * court inside [VIS_X0..VIS_X1] x [VIS_Y0..VIS_Y1] or it's off-screen. */
24
- #define VIS_X0 48
25
- #define VIS_Y0 24
26
- #define VIS_X1 207 /* 48 + 160 - 1 */
27
- #define VIS_Y1 167 /* 24 + 144 - 1 */
28
-
29
- #define COURT_TOP VIS_Y0
30
- #define COURT_BOT VIS_Y1
31
- #define PADDLE_H 24
32
- #define BALL_SIZE 8
33
- /* Explicit (uint8_t) casts: these fit a byte but SDCC warns (158) on the
34
- * implicit int->uint8_t narrowing when the computed macro is passed to the
35
- * uint8_t x/y args of gg_sprite_set. */
36
- #define PADDLE_X1 ((uint8_t)(VIS_X0 + 8)) /* near the visible left edge */
37
- #define PADDLE_X2 ((uint8_t)(VIS_X1 - 16)) /* near the visible right edge */
38
-
39
- /* GG palette = 32 entries × 2 bytes (4-4-4 BGR LE): low=(g<<4)|r, high=b.
40
- * gg_load_palette reads 64 bytes; a 32-byte array leaves the sprite palette
41
- * (entries 16-31) reading garbage = invisible sprites. Sprite colour 1 = entry
42
- * 17 (white). */
88
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
89
+ * THE GG VISIBLE WINDOW. The VDP frame is 256×192; the LCD shows the
90
+ * centered 160×144. In FULL-FRAME hardware units the window is:
91
+ *
92
+ * pixels: x ∈ [48..207] y ∈ [24..167] (sprite coords, scanlines)
93
+ * tilemap: col [6..25] row [3..20] (20×18 visible cells)
94
+ *
95
+ * EVERYTHING the hardware takes is full-frame: gg_sprite_set x/y, tilemap
96
+ * row/col, and — easy to forget — the LINE COUNTER (VDP R10) counts
97
+ * full-frame scanlines from the top of the 192-line active area, NOT from
98
+ * the top of the LCD. The window's first visible scanline is 24.
99
+ *
100
+ * Requires: nothing these are constants of the machine. Everything below
101
+ * (HUD placement, split line, court geometry, paddle X, text columns) is
102
+ * derived from them; if you reshape the layout, derive from VIS_*, never
103
+ * hardcode SMS-frame numbers. */
104
+ #define VIS_X0 48 /* left edge of the LCD window (hardware X) */
105
+ #define VIS_Y0 24 /* top edge (hardware Y / scanline) */
106
+ #define VIS_X1 207 /* right edge: 48 + 160 - 1 */
107
+ #define VIS_Y1 167 /* bottom edge: 24 + 144 - 1 */
108
+ #define VIS_W 160
109
+ #define VIS_H 144
110
+ #define VIS_COL0 6 /* first visible tilemap column (48 / 8) */
111
+ #define VIS_ROW0 3 /* first visible tilemap row (24 / 8) */
112
+ #define VIS_COLS 20 /* 160 / 8 */
113
+ #define VIS_ROWS 18 /* 144 / 8 */
114
+ /* Think in window space (0..19 cols, 0..17 rows), convert at the call: */
115
+ #define VROW(r) ((uint8_t)((r) + VIS_ROW0))
116
+ #define VCOL(c) ((uint8_t)((c) + VIS_COL0))
117
+
118
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
119
+ * Palette. THE GG's HEADLINE UPGRADE over the SMS: CRAM holds 12-bit 4-4-4
120
+ * BGR colour (4096 colours) instead of the SMS's 6-bit 2-2-2 (64). The WRITE
121
+ * FORMAT differs too — that's the #2 GG footgun:
122
+ *
123
+ * SMS: 32 entries × 1 byte --BBGGRR
124
+ * GG: 32 entries × 2 bytes little-endian: low byte = GGGGRRRR
125
+ * high byte = ----BBBB
126
+ *
127
+ * So a GG palette array is 64 bytes (entries 0-15 BG, 16-31 sprite). Feeding
128
+ * gg_load_palette a 32-byte SMS-style table reads past the array — the sprite
129
+ * palette loads garbage and every sprite renders invisible (this exact bug
130
+ * shipped in an earlier GG scaffold round). Pack an entry with:
131
+ * low = (g << 4) | r, high = b, each channel 0..15. The court greens, the
132
+ * steel HUD bar, and the cyan/red paddles below all use the 4096-colour panel
133
+ * (the DEUCE DASH SMS palette only had 64 to choose from). */
43
134
  static const uint8_t palette[64] = {
44
- /* BG 0-15: 0 = dark navy backdrop, 1 = court green, 2 = court line white */
45
- 0x20,0x02, 0x60,0x00, 0xFF,0x0F, 0,0, 0,0, 0,0, 0,0, 0,0,
46
- 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
47
- /* SPRITE 16-31: 16=transparent, 17=white */
48
- 0,0, 0xFF,0x0F, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
49
- 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
135
+ /* BG 0-15: 0 = court deep-navy (backdrop/border), 1 = court grass-green,
136
+ * 2 = white (lines + net + text), 3 = HUD-bar steel-blue */
137
+ 0x20,0x02, 0x82,0x01, 0xFF,0x0F, 0x86,0x06,
138
+ 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
139
+ /* SPRITE 16-31: 16 = transparent, 17 = P1 cyan, 18 = P2/CPU red,
140
+ * 19 = white ball. One shared sprite palette on GG/SMS: per-"sprite" colour
141
+ * means per-TILE colour indices, not per-sprite palettes. */
142
+ 0,0, 0xF8,0x0E, 0x21,0x0D, 0xFF,0x0F,
143
+ 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
144
+ };
145
+
146
+ /* ── GAME LOGIC (clay) — BG tile inventory (BG bank $0000) ───────────────────
147
+ * tile 0 = blank court (colour 0)
148
+ * tiles 1..37 = font: digits 0-9, A-Z, '-' (uploaded 1bpp→4bpp below)
149
+ * tile 38 = court field (solid colour 1 green)
150
+ * tile 39 = court line / sideline (solid colour 2 white)
151
+ * tile 40 = dashed net (colour 2 stripe on green)
152
+ * tile 41 = solid HUD bar (colour 3) — the split seam hides in it */
153
+ #define FONT_BASE 1
154
+ #define BG_FIELD 38
155
+ #define BG_LINE 39
156
+ #define BG_NET 40
157
+ #define BG_HUDBAR 41
158
+
159
+ /* 1bpp font (same glyph set as the SMS/NES/GB examples — 0-9, A-Z, '-').
160
+ * Stored 8 bytes/glyph; expanded to the VDP's 32-byte 4bpp tiles at upload
161
+ * (see load_font below), so the ROM carries 296 bytes instead of 1184. */
162
+ static const uint8_t font8[37][8] = {
163
+ /* 0-9 */
164
+ {0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00}, {0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00},
165
+ {0x3C,0x66,0x06,0x0C,0x18,0x30,0x7E,0x00}, {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00},
166
+ {0x0C,0x1C,0x3C,0x6C,0x7E,0x0C,0x0C,0x00}, {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00},
167
+ {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x30,0x30,0x00},
168
+ {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00}, {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00},
169
+ /* A-Z */
170
+ {0x18,0x3C,0x66,0x66,0x7E,0x66,0x66,0x00}, {0x7C,0x66,0x66,0x7C,0x66,0x66,0x7C,0x00},
171
+ {0x3C,0x66,0x60,0x60,0x60,0x66,0x3C,0x00}, {0x78,0x6C,0x66,0x66,0x66,0x6C,0x78,0x00},
172
+ {0x7E,0x60,0x60,0x7C,0x60,0x60,0x7E,0x00}, {0x7E,0x60,0x60,0x7C,0x60,0x60,0x60,0x00},
173
+ {0x3C,0x66,0x60,0x6E,0x66,0x66,0x3E,0x00}, {0x66,0x66,0x66,0x7E,0x66,0x66,0x66,0x00},
174
+ {0x7E,0x18,0x18,0x18,0x18,0x18,0x7E,0x00}, {0x06,0x06,0x06,0x06,0x66,0x66,0x3C,0x00},
175
+ {0x66,0x6C,0x78,0x70,0x78,0x6C,0x66,0x00}, {0x60,0x60,0x60,0x60,0x60,0x60,0x7E,0x00},
176
+ {0x63,0x77,0x7F,0x6B,0x63,0x63,0x63,0x00}, {0x66,0x76,0x7E,0x7E,0x6E,0x66,0x66,0x00},
177
+ {0x3C,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x7C,0x66,0x66,0x7C,0x60,0x60,0x60,0x00},
178
+ {0x3C,0x66,0x66,0x66,0x6A,0x6C,0x36,0x00}, {0x7C,0x66,0x66,0x7C,0x6C,0x66,0x66,0x00},
179
+ {0x3C,0x66,0x60,0x3C,0x06,0x66,0x3C,0x00}, {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00},
180
+ {0x66,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x66,0x66,0x66,0x66,0x66,0x3C,0x18,0x00},
181
+ {0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00}, {0x66,0x66,0x3C,0x18,0x3C,0x66,0x66,0x00},
182
+ {0x66,0x66,0x66,0x3C,0x18,0x18,0x18,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x60,0x7E,0x00},
183
+ /* '-' */
184
+ {0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00},
50
185
  };
51
186
 
52
- static const uint8_t tile_solid[32] = {
53
- 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00,
54
- 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00,
55
- 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00,
56
- 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00,
187
+ /* Expand 1bpp glyphs into 4bpp tiles as colour 2 (plane 1 set → white).
188
+ * GG/SMS tile rows are 4 bytes: plane0, plane1, plane2, plane3. */
189
+ static void load_font(void) {
190
+ uint8_t g, r, bits;
191
+ gg_vdp_set_addr((uint16_t)(FONT_BASE * 32), VDP_VRAM_WRITE);
192
+ for (g = 0; g < 37; g++) {
193
+ for (r = 0; r < 8; r++) {
194
+ bits = font8[g][r];
195
+ PORT_VDP_DATA = 0; /* plane 0 */
196
+ PORT_VDP_DATA = bits; /* plane 1 → colour index 2 (white) */
197
+ PORT_VDP_DATA = 0; /* plane 2 */
198
+ PORT_VDP_DATA = 0; /* plane 3 */
199
+ }
200
+ }
201
+ }
202
+
203
+ /* ── GAME LOGIC (clay) — court furniture tiles (4bpp, 32 bytes each). The
204
+ * paddles + ball are SPRITES (they move every frame); the court is BG tiles.
205
+ * BG_FIELD = solid green (colour 1 = plane 0)
206
+ * BG_LINE = solid white (colour 2 = plane 1): rails + sidelines
207
+ * BG_NET = a centred white stripe over green (the net column)
208
+ * BG_HUDBAR= solid steel (colour 3 = planes 0+1): the split seam hides here */
209
+ static const uint8_t court_tiles[32 * 4] = {
210
+ /* BG_FIELD — solid colour 1 (plane 0) */
211
+ 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
212
+ 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
213
+ /* BG_LINE — solid colour 2 (plane 1 = white) */
214
+ 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
215
+ 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
216
+ /* BG_NET — centre column white (colour 2), rest green (colour 1) */
217
+ 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
218
+ 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
219
+ /* BG_HUDBAR — solid colour 3 (planes 0+1 = steel); seam hides here */
220
+ 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
221
+ 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
57
222
  };
58
223
 
59
- /* Three BG tiles for the court, loaded into the BG tile bank at $0000:
60
- * tile 0 = court green (colour 1), tile 1 = court line / border
61
- * (colour 2 = white), tile 2 = dashed net (colour 2 stripe on green). */
62
- static const uint8_t bg_tiles[96] = {
63
- /* tile 0 = court green (colour 1 -> plane 0 set) */
64
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
65
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
66
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
67
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
68
- /* tile 1 = court line / border (colour 2 -> plane 1 set) */
69
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
70
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
71
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
72
- 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
73
- /* tile 2 = net: centre column colour 2, rest colour 1 (green) */
74
- 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
75
- 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
76
- 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
77
- 0xFF,0x18,0x00,0x00, 0xFF,0x18,0x00,0x00,
224
+ /* Sprite tiles (sprite bank $2000 vdp_init's R6=0xFF baseline reads sprite
225
+ * patterns from $2000, so upload there, not $0000). The paddle is a solid 4px
226
+ * column (players stack 3 of these = 24px tall); the ball is a small disc.
227
+ * Each on its own sprite colour so P1/P2/ball read distinctly. */
228
+ static const uint8_t sprite_tiles[32 * 3] = {
229
+ /* T_PADDLE+0 — paddle column, colour 1 (P1 cyan; recoloured per player by
230
+ * choosing the tile index, see stage_sprites). Plane 0 = colour 1. */
231
+ 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00,
232
+ 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00,
233
+ /* T_PADDLE+1 paddle column, colour 2 (P2/CPU red). Plane 1 = colour 2. */
234
+ 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00,
235
+ 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00, 0x00,0x3C,0x00,0x00,
236
+ /* T_BALL — disc, colour 3 (white = planes 0+1) */
237
+ 0x00,0x00,0x00,0x00, 0x3C,0x3C,0x00,0x00, 0x7E,0x7E,0x00,0x00, 0x7E,0x7E,0x00,0x00,
238
+ 0x7E,0x7E,0x00,0x00, 0x7E,0x7E,0x00,0x00, 0x3C,0x3C,0x00,0x00, 0x00,0x00,0x00,0x00,
78
239
  };
240
+ #define T_PADDLE 0 /* +0 = P1 (cyan), +1 = P2/CPU (red) */
241
+ #define T_BALL 2
242
+
243
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
244
+ * Court geometry + match rules, ALL in full-frame hardware units derived from
245
+ * VIS_*. The playfield sits below the 3-row HUD strip; COURT_TOP/BOT (pixels)
246
+ * keep the ball between the top/bottom rails. The two paddles sit at fixed X
247
+ * just inside the window's left/right edges; the ball is one 8x8 sprite. */
248
+ #define HUD_ROWS 3
249
+ #define HUD_PX (HUD_ROWS * 8)
250
+ #define PADDLE_H 24 /* 3 stacked 8px sprites */
251
+ #define BALL_SIZE 8
252
+ #define PADDLE_X1 (VIS_X0 + 6) /* P1 — just inside the left edge */
253
+ #define PADDLE_X2 (VIS_X1 - 13) /* P2/CPU — just inside the right edge */
254
+ #define COURT_TOP (VIS_Y0 + HUD_PX + 8) /* first ball pixel below top rail */
255
+ #define COURT_BOT (VIS_Y1 - 9) /* first pixel row of the bottom rail */
256
+ #define WIN_SCORE 5 /* first to 5 takes the match */
257
+
258
+ /* ── GAME LOGIC (clay) — game state.
259
+ * The hot ones are deliberately NON-static: they then appear in the sdld map
260
+ * (build symbols) at $Cxxx in work RAM, so a headless agent can resolve them
261
+ * by name and read/poke live state (parse the map → system_ram offset =
262
+ * addr-0xC000). The GG has 8KB of work RAM ($C000-$DFFF), so these plain
263
+ * variables cost nothing. */
264
+ int16_t p1y, p2y; /* paddle top Y (int16: collision math)*/
265
+ int16_t bx, by; /* ball position */
266
+ int8_t bdx, bdy; /* ball velocity (px/frame) */
267
+ uint8_t score_p1, score_p2;
268
+ uint8_t two_player; /* title pick: 0 = vs CPU, 1 = 2P */
269
+ uint8_t state; /* ST_TITLE / ST_PLAY / ST_OVER */
270
+ uint16_t best_streak; /* persistent record — see end_match */
271
+
272
+ static uint8_t serve_timer; /* freeze frames between points */
273
+ static uint8_t streak; /* current 1P-vs-CPU win streak (RAM) */
274
+ static uint8_t new_record; /* result screen shows NEW RECORD */
275
+ static uint8_t prev_pad; /* edge-triggered title/result input */
276
+ static uint8_t hud_dirty; /* score changed → redraw next vblank */
277
+ static uint8_t over_step; /* results text, one piece per vblank */
278
+ static uint16_t rng = 0xC0A7;
279
+
280
+ #define ST_TITLE 0
281
+ #define ST_PLAY 1
282
+ #define ST_OVER 2
283
+
284
+ /* ── GAME LOGIC (clay) — xorshift16 PRNG (~tens of cycles per call).
285
+ * A versus game NEEDS this: see THE VERSUS LESSON in the file header. Ticked
286
+ * once per play frame so identical states a few seconds apart still diverge,
287
+ * and used for the ±1 deflection spin so rallies never repeat. */
288
+ static uint8_t random8(void) {
289
+ uint16_t r = rng;
290
+ r ^= r << 7;
291
+ r ^= r >> 9;
292
+ r ^= r << 8;
293
+ rng = r;
294
+ return (uint8_t)r;
295
+ }
296
+
297
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
298
+ * LINE-INTERRUPT SPLIT — the GG/SMS VDP's signature trick (fixed status bar
299
+ * over the playfield, palette splits, water effects). The VDP has ONE scroll
300
+ * register pair for the whole frame; to keep the HUD strip pinned at the top
301
+ * while the court renders below it we DON'T scroll here (a court doesn't
302
+ * scroll) — but we still take the line IRQ at the bar so the idiom is wired
303
+ * and ready, and so the per-frame timing (vblank → line IRQ → game logic)
304
+ * matches the GG platformer/shmup exactly. Where the NES needs the
305
+ * sprite-0-hit HACK (park a sprite, busy-poll a status bit, burn scanlines),
306
+ * this VDP has a real, PROGRAMMABLE line interrupt:
307
+ *
308
+ * R10 = N line counter: a down-counter reloaded with N every line
309
+ * outside the active area; underflow → IRQ at scanline N.
310
+ * R0 bit 4 (IE1) line-IRQ enable (already set in vdp_init's 0x36 baseline).
311
+ * R1 bit 5 (IE0) frame(vblank)-IRQ enable (set by gg_vdp_display_on's 0xE0).
312
+ *
313
+ * GG WINDOW CONTRAST (the part SMS habits get wrong): R10 counts FULL-FRAME
314
+ * scanlines — line 0 is the top of the 192-line active area, which is 24
315
+ * lines ABOVE the LCD. The HUD strip starts at the window top (scanline
316
+ * VIS_Y0 = 24) and its last line is VIS_Y0 + HUD_PX - 1 = 47, so SPLIT_LINE
317
+ * is 47 — NOT 23 as it would be on an SMS with the same 3-row HUD. Lines
318
+ * 0..23 are rendered and never shown; they ride along with the HUD's region.
319
+ *
320
+ * Both IRQs land on the Z80's IM-1 vector at $0038. The crt0's handler does
321
+ * the canonical minimal handshake: push af / in a,($BF) / pop af / ei / reti
322
+ * — reading the status port ACKS the VDP (clears BOTH pending flags; skip the
323
+ * read and the IRQ line stays asserted = interrupt storm), and EI must
324
+ * precede RETI or interrupts stay off forever after the first one.
325
+ *
326
+ * Because the handler does no work, the MAIN loop synchronizes with HALT: the
327
+ * Z80 sleeps until the next interrupt, then reads the V-counter (port $7E) to
328
+ * learn WHICH one woke us — line IRQs fire during the active area (V < 0xC0),
329
+ * the frame IRQ fires at vblank (V ≥ 0xC0).
330
+ *
331
+ * wait_vblank(): sleep until the frame IRQ → do per-frame VRAM work.
332
+ * wait_split(): sleep until the line IRQ at scanline 47 (the last bar
333
+ * line) → past it, the court renders. (If you ADD a
334
+ * scrolling background, this is where you'd write R8 — see
335
+ * the GG platformer/shmup templates.)
336
+ *
337
+ * FOOTGUN — you cannot poll once IRQs are on: a status-port poll spins on the
338
+ * same port the ISR reads. The ISR always wins the race, eats the flag, and
339
+ * the poll loop hangs forever. HALT + V-counter is the IRQ-era replacement.
340
+ *
341
+ * Requires: R10 programmed, IE1 + IE0 enabled, EI executed once after
342
+ * display-on, the crt0's ack-only ISR, and wait_vblank/wait_split called
343
+ * EVERY frame in this order. R10 reloads after each underflow, so the line
344
+ * IRQ re-fires every HUD_PX+VIS_Y0 lines down the frame (47, 95, 143, 191) —
345
+ * the later wakes harmlessly interrupt game logic (the ISR acks them) and we
346
+ * re-halt inside the NEXT wait_vblank(). */
347
+ #define SPLIT_LINE (VIS_Y0 + HUD_PX - 1)
348
+
349
+ static void wait_vblank(void) {
350
+ /* check-first: if game logic overran into vblank, don't sleep a frame */
351
+ while (PORT_V_COUNTER < 0xC0) { __asm__("halt"); }
352
+ }
353
+
354
+ static void wait_split(void) {
355
+ /* halt-first: vblank work always ends inside vblank (V ≥ 0xC0), and the
356
+ * first wake at V < 0xC0 is the line IRQ at SPLIT_LINE */
357
+ do { __asm__("halt"); } while (PORT_V_COUNTER >= 0xC0);
358
+ }
359
+
360
+ /* ── HARDWARE IDIOM (load-bearing) — record in Sega-mapper cart RAM ──────────
361
+ * Same cartridge mapper as the SMS. The control register at $FFFC: bit 3 maps
362
+ * the cart's 8KB battery RAM into $8000-$BFFF (bank slot 2). Map → copy →
363
+ * unmap; keep the window short so stray pointer bugs can't shred the save.
364
+ * The block is magic + value + checksum so a never-written cart (all $FF)
365
+ * reads back as "no save" instead of a garbage record.
366
+ *
367
+ * NOTE the $FFFC address: it's IN the WRAM mirror ($C000-$DFFF mirrors at
368
+ * $E000-$FFFF), so this write also lands in WRAM at $DFFC — the mapper just
369
+ * snoops the bus. That's why the crt0 parks SP at $DFF0: the bytes above it
370
+ * ($DFFC-$FFFF) belong to the mapper registers' shadow.
371
+ *
372
+ * HONESTY (verified 2026-06-10 against the bundled gpgx core, same finding as
373
+ * the GG shmup/platformer): gpgx only instantiates the Sega mapper for ROMs
374
+ * LARGER than 48KB, and this build pipeline emits 32KB ROMs — so in-emulator
375
+ * the $8000 window stays open-bus (reads $FF), the magic check fails, and the
376
+ * game falls back to the WRAM record (in-session only). The code below is
377
+ * still the correct real-hardware idiom and lights up unchanged on a >48KB
378
+ * build or a cart with battery RAM: the load path is self-falsifying, never
379
+ * wrong. (The verify harness pads this ROM to 64KB to exercise the cart-RAM
380
+ * path for real, including across power-cycle.) */
381
+ #define MAPPER_CTRL (*(volatile uint8_t *)0xFFFC)
382
+ #define CART_RAM ((volatile uint8_t *)0x8000)
383
+
384
+ static void record_save(uint16_t v) {
385
+ uint8_t lo = (uint8_t)(v & 0xFF), hi = (uint8_t)(v >> 8);
386
+ MAPPER_CTRL = 0x08; /* map cart RAM at $8000 */
387
+ CART_RAM[0] = 0x48; /* 'H' */
388
+ CART_RAM[1] = 0x53; /* 'S' */
389
+ CART_RAM[2] = lo;
390
+ CART_RAM[3] = hi;
391
+ CART_RAM[4] = (uint8_t)(lo ^ hi ^ 0xA5);
392
+ MAPPER_CTRL = 0x00; /* back to ROM in slot 2 */
393
+ }
394
+
395
+ static uint16_t record_load(void) {
396
+ uint16_t v = 0;
397
+ MAPPER_CTRL = 0x08;
398
+ if (CART_RAM[0] == 0x48 && CART_RAM[1] == 0x53 &&
399
+ CART_RAM[4] == (uint8_t)(CART_RAM[2] ^ CART_RAM[3] ^ 0xA5)) {
400
+ v = (uint16_t)(CART_RAM[2] | ((uint16_t)CART_RAM[3] << 8));
401
+ }
402
+ MAPPER_CTRL = 0x00;
403
+ return v;
404
+ }
405
+
406
+ /* ── GAME LOGIC (clay) — text via the font tiles ─────────────────────────────
407
+ * These write the name table directly, so call them only during vblank (or
408
+ * with the display off): VRAM access during active display races the VDP's
409
+ * own fetches and drops/garbles bytes on real hardware. Rows/cols here are
410
+ * WINDOW coordinates (0..17 / 0..19) — VROW/VCOL add the border offset, so
411
+ * text can never accidentally land in the unseen 256×192 margin. */
412
+ static uint8_t font_tile(char ch) {
413
+ if (ch >= '0' && ch <= '9') return (uint8_t)(FONT_BASE + (ch - '0'));
414
+ if (ch >= 'A' && ch <= 'Z') return (uint8_t)(FONT_BASE + 10 + (ch - 'A'));
415
+ if (ch == '-') return (uint8_t)(FONT_BASE + 36);
416
+ return 0; /* space → blank tile */
417
+ }
79
418
 
80
- /* Paint the court inside the GG visible region (cols 6..25, rows 3..20):
81
- * green field, white border lines around the perimeter, dashed net down
82
- * the centre column. BG tile bank is $0000. */
83
- static void draw_court(void) {
84
- uint8_t row, col;
85
- for (row = 3; row <= 20; row++) {
86
- for (col = 6; col <= 25; col++) {
87
- uint8_t t = 0; /* green field */
88
- if (row == 3 || row == 20 || col == 6 || col == 25) t = 1; /* border */
89
- else if (col == 15 && (row & 1)) t = 2; /* dashed centre net */
90
- gg_set_tilemap_cell(row, col, t, 0);
419
+ static void text_draw(uint8_t vrow, uint8_t vcol, const char *s) {
420
+ uint8_t col = VCOL(vcol);
421
+ while (*s) gg_set_tilemap_cell(VROW(vrow), col++, font_tile(*s++), 0);
422
+ }
423
+
424
+ static void draw_u16(uint8_t vrow, uint8_t vcol, uint16_t v) {
425
+ uint8_t d[5], i;
426
+ for (i = 0; i < 5; i++) { d[i] = (uint8_t)(v % 10); v /= 10; }
427
+ for (i = 0; i < 5; i++)
428
+ gg_set_tilemap_cell(VROW(vrow), (uint8_t)(VCOL(vcol) + i),
429
+ (uint8_t)(FONT_BASE + d[4 - i]), 0);
430
+ }
431
+
432
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
433
+ * Split the HUD into STATIC labels (drawn ONCE, display off) and DYNAMIC
434
+ * scores (a handful of cells, gated behind hud_dirty during play). WHY: the
435
+ * vblank window has a finite VRAM budget — the SAT upload alone is 192 OUTs.
436
+ * A clear-the-whole-row + re-letter-everything redraw EVERY frame overruns
437
+ * that budget; the writes that land after the line IRQ get dropped, and a
438
+ * glyph silently vanishes from the bar (it bit the SMS template: the 'S' of
439
+ * BEST disappeared). So the labels go in with the display off and only the
440
+ * few digit cells change in-band. Same discipline the GG platformer/shmup
441
+ * templates use. Layout uses 18 of the 20 visible columns. ── */
442
+ static void draw_hud_labels(void) {
443
+ text_draw(0, 0, "P1");
444
+ text_draw(0, 7, "BEST");
445
+ if (two_player) text_draw(0, 17, "P2");
446
+ else text_draw(0, 16, "CPU");
447
+ }
448
+
449
+ static void draw_hud_scores(void) {
450
+ gg_set_tilemap_cell(VROW(0), VCOL(3), (uint8_t)(FONT_BASE + (score_p1 > 9 ? 9 : score_p1)), 0);
451
+ draw_u16(0, 11, best_streak);
452
+ gg_set_tilemap_cell(VROW(0), VCOL(19), (uint8_t)(FONT_BASE + (score_p2 > 9 ? 9 : score_p2)), 0);
453
+ }
454
+
455
+ /* ── GAME LOGIC (clay) — screen painters (DISPLAY OFF: free VRAM access, clean
456
+ * cut). While the display is off the frame IRQ doesn't fire — so no halt-based
457
+ * waits in here, or you hang forever.
458
+ *
459
+ * IRQ-RACE FOOTGUN (cost the GG shmup a letter of its own title): repaints
460
+ * also run with INTERRUPTS OFF — the di/ei bracket. Display-off stops the
461
+ * FRAME IRQ but NOT the LINE IRQ (R0's IE1 stays set; the line counter runs
462
+ * every scanline regardless of blanking). The crt0's ISR acks by READING the
463
+ * control port ($BF) — and that read also resets the VDP's two-byte
464
+ * address-latch state machine. If the line IRQ fires between the two bytes of
465
+ * a gg_vdp_set_addr() control-port pair, the second byte is taken as a new
466
+ * first byte, the VRAM address de-syncs, and one cell of your repaint lands
467
+ * somewhere else. Per-frame writes inside wait_vblank don't need the bracket:
468
+ * vblank has no line IRQs and the frame IRQ was already consumed by the halt
469
+ * that woke us.
470
+ *
471
+ * PERF FOOTGUN (inherited from the GG shmup/SMS sports, found the slow way):
472
+ * per-cell gg_set_tilemap_cell redoes the 4-OUT address setup for every cell —
473
+ * over a full screen that's seconds of black. Set the VRAM address ONCE per
474
+ * row (the data port autoincrements through the row's 64 bytes) and stream. */
475
+ static uint8_t court_tile(uint8_t r, uint8_t c) {
476
+ /* r,c are FULL-FRAME hardware rows/cols. The court fills the visible window
477
+ * (rows VIS_ROW0..VIS_ROW0+VIS_ROWS-1, cols VIS_COL0..VIS_COL0+VIS_COLS-1);
478
+ * the unseen margin around it stays blank (colour 0). */
479
+ uint8_t wr, wc;
480
+ if (r < VIS_ROW0 || r >= (uint8_t)(VIS_ROW0 + VIS_ROWS)) return 0;
481
+ if (c < VIS_COL0 || c >= (uint8_t)(VIS_COL0 + VIS_COLS)) return 0;
482
+ wr = (uint8_t)(r - VIS_ROW0); /* 0..17 window row */
483
+ wc = (uint8_t)(c - VIS_COL0); /* 0..19 window col */
484
+ if (wr == 2) return BG_HUDBAR; /* HUD bar (split seam) */
485
+ if (wr < 3) return 0; /* HUD text + breather */
486
+ if (wr == 3 || wr == 17) return BG_LINE; /* top + bottom rails */
487
+ if (wc == 0 || wc == 19) return BG_LINE; /* side lines */
488
+ if (wc == 10) return BG_NET; /* centre net */
489
+ return BG_FIELD; /* green field */
490
+ }
491
+
492
+ static void paint_court_field(void) {
493
+ uint8_t r, c;
494
+ for (r = 0; r < 24; r++) {
495
+ gg_vdp_set_addr((uint16_t)(0x3800 + (uint16_t)r * 64), VDP_VRAM_WRITE);
496
+ for (c = 0; c < 32; c++) {
497
+ PORT_VDP_DATA = court_tile(r, c); /* name-table entry low byte */
498
+ PORT_VDP_DATA = 0; /* high byte: flips/pal/priority */
91
499
  }
92
500
  }
93
501
  }
94
502
 
95
- static int16_t p1y, p2y, bx, by;
96
- static int8_t bdx, bdy;
97
- static uint8_t score_p1, score_p2;
98
- static uint8_t serve_timer;
503
+ static void paint_blank_field(void) {
504
+ uint8_t r, c;
505
+ for (r = 0; r < 24; r++) {
506
+ gg_vdp_set_addr((uint16_t)(0x3800 + (uint16_t)r * 64), VDP_VRAM_WRITE);
507
+ for (c = 0; c < 32; c++) {
508
+ /* window row 2 (HW row VIS_ROW0+2) gets the HUD bar; rest blank */
509
+ uint8_t t = (r == (uint8_t)(VIS_ROW0 + 2)) ? BG_HUDBAR : 0;
510
+ PORT_VDP_DATA = t; /* name-table entry low byte */
511
+ PORT_VDP_DATA = 0; /* high byte: flips/pal/priority */
512
+ }
513
+ }
514
+ }
515
+
516
+ static void paint_title(void) {
517
+ __asm__("di"); /* see IRQ-RACE FOOTGUN above */
518
+ gg_vdp_display_off();
519
+ paint_blank_field();
520
+ text_draw(5, (uint8_t)((VIS_COLS - (sizeof(GAME_TITLE) - 1)) / 2), GAME_TITLE);
521
+ text_draw(9, 3, "1P VS CPU - 1");
522
+ text_draw(11, 3, "2P VERSUS - 2");
523
+ text_draw(15, 4, "BEST STREAK");
524
+ draw_u16(15, 15, best_streak);
525
+ gg_sprite_init(); /* park every sprite off-screen */
526
+ gg_sat_upload();
527
+ gg_vdp_display_on(); /* re-enables the frame IRQ too */
528
+ __asm__("ei"); /* interrupts back on LAST — regs are set */
529
+ }
530
+
531
+ static void paint_play(void) {
532
+ __asm__("di"); /* see IRQ-RACE FOOTGUN above */
533
+ gg_vdp_display_off();
534
+ paint_court_field();
535
+ draw_hud_labels(); /* static — drawn once with the display off */
536
+ draw_hud_scores();
537
+ gg_sprite_init();
538
+ gg_sat_upload();
539
+ gg_vdp_display_on();
540
+ __asm__("ei");
541
+ }
99
542
 
543
+ /* ── GAME LOGIC (clay) — serve: ball to centre, toward the chosen side ── */
100
544
  static void serve_ball(uint8_t to_left) {
101
- bx = (VIS_X0 + VIS_X1) / 2;
102
- by = (VIS_Y0 + VIS_Y1) / 2;
545
+ bx = (VIS_X0 + VIS_X1) / 2 - BALL_SIZE / 2;
546
+ by = (COURT_TOP + COURT_BOT) / 2;
103
547
  bdx = to_left ? -2 : 2;
104
- bdy = ((score_p1 + score_p2) & 1) ? -1 : 1;
105
- serve_timer = 30;
548
+ bdy = ((score_p1 + score_p2) & 1) ? -1 : 1; /* alternate the angle */
549
+ serve_timer = 30; /* half-second breather */
106
550
  }
107
551
 
108
- static void reset_match(void) {
109
- p1y = (VIS_Y0 + VIS_Y1) / 2 - PADDLE_H / 2;
552
+ /* ── GAME LOGIC (clay) — start a match ── */
553
+ static void start_match(uint8_t players) {
554
+ two_player = players;
555
+ p1y = (COURT_TOP + COURT_BOT) / 2 - PADDLE_H / 2;
110
556
  p2y = p1y;
111
557
  score_p1 = 0; score_p2 = 0;
558
+ new_record = 0;
559
+ /* Stir the PRNG with time-spent-on-title so matches differ. */
560
+ rng ^= (uint16_t)((uint16_t)PORT_V_COUNTER << 3);
561
+ if (rng == 0) rng = 0xC0A7;
112
562
  serve_ball(0);
563
+ state = ST_PLAY;
564
+ paint_play();
565
+ prev_pad = 0xFF; /* the button that started shouldn't move */
566
+ sfx_tone(0, 200, 10); /* start jingle */
113
567
  }
114
568
 
115
- void main(void) {
569
+ /* ── GAME LOGIC (clay) — match over: result + record bookkeeping.
570
+ * Persistence choice (shared with the SMS/NES/Genesis sports examples): for a
571
+ * VERSUS game a raw hi-score is meaningless (every match ends 5-x), so we
572
+ * persist the longest 1P win streak against the CPU — the stat a returning
573
+ * player actually chases. 2P matches never touch it (humans beating each
574
+ * other isn't a record). One piece of result text per vblank (over_step)
575
+ * because each draw_u16 is 5 software divisions — see the BUDGET FOOTGUN. ── */
576
+ static void end_match(void) {
577
+ uint8_t p1_won = (uint8_t)(score_p1 >= WIN_SCORE);
578
+ if (p1_won && !two_player) {
579
+ ++streak;
580
+ if (streak > best_streak) {
581
+ best_streak = streak;
582
+ new_record = 1;
583
+ record_save(best_streak); /* cart RAM (real hardware); WRAM copy live */
584
+ }
585
+ } else if (!p1_won && !two_player) {
586
+ streak = 0; /* the streak dies with the loss */
587
+ }
588
+ /* End-of-match whistle: two quick descending tones. */
589
+ sfx_tone(0, 220, 10);
590
+ sfx_tone(1, 320, 12);
591
+ state = ST_OVER;
592
+ prev_pad = 0xFF; /* require a fresh press to leave */
593
+ over_step = 4; /* deferred result draws, one per vblank */
594
+ }
595
+
596
+ /* ── GAME LOGIC (clay) — one point scored ── */
597
+ static void score_point(uint8_t for_p1) {
598
+ if (for_p1) { if (score_p1 < 99) ++score_p1; }
599
+ else { if (score_p2 < 99) ++score_p2; }
600
+ sfx_noise(8);
601
+ hud_dirty = 1;
602
+ if (score_p1 >= WIN_SCORE || score_p2 >= WIN_SCORE) end_match();
603
+ else serve_ball(for_p1); /* loser of the point serves toward winner */
604
+ }
605
+
606
+ /* ── GAME LOGIC (clay) — paddle hit: deflect by where the ball struck.
607
+ * Centre = flat-ish, edges = steep — the "baffle" of the title. Max |bdy| is
608
+ * 2 — the CPU moves at 1, so an edge hit is exactly how a human beats it. A
609
+ * ±1 random "spin" on every return keeps rallies from repeating (see THE
610
+ * VERSUS LESSON). ── */
611
+ static void deflect(int16_t paddle_y) {
612
+ int16_t rel = (by + BALL_SIZE / 2) - (paddle_y + PADDLE_H / 2);
613
+ bdy = (int8_t)(rel >> 3);
614
+ bdy += (int8_t)((random8() & 2) - 1); /* spin: -1 or +1 */
615
+ if (bdy > 2) bdy = 2;
616
+ if (bdy < -2) bdy = -2;
617
+ if (bdy == 0) bdy = (rel < 0) ? -1 : 1; /* never return a flat ball */
618
+ sfx_tone(0, 250, 4);
619
+ }
620
+
621
+ /* ── GAME LOGIC (clay) — stage this frame's sprites. Paddles + ball are the
622
+ * only sprites: slots 0-2 = P1 paddle, 3-5 = P2/CPU paddle, 6 = ball. Inactive
623
+ * (title) slots park at Y=$E0 (below the 192-line area AND below the LCD
624
+ * window). NEVER park at Y=$D0 — that's the SAT terminator: the VDP stops
625
+ * scanning at the first $D0 and every later slot vanishes. ── */
626
+ static void stage_sprites(void) {
116
627
  uint8_t i;
117
- gg_vdp_init();
628
+ if (state == ST_TITLE) { gg_sprite_init(); return; }
629
+ for (i = 0; i < PADDLE_H / 8; i++)
630
+ gg_sprite_set((uint8_t)(0 + i), PADDLE_X1,
631
+ (uint8_t)((int16_t)p1y + (int16_t)i * 8), T_PADDLE + 0);
632
+ for (i = 0; i < PADDLE_H / 8; i++)
633
+ gg_sprite_set((uint8_t)(3 + i), PADDLE_X2,
634
+ (uint8_t)((int16_t)p2y + (int16_t)i * 8), T_PADDLE + 1);
635
+ gg_sprite_set(6, (uint8_t)bx, (uint8_t)by, T_BALL);
636
+ }
637
+
638
+ void main(void) {
639
+ uint8_t pad, pad2, fresh;
640
+ int16_t target;
641
+
642
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
643
+ * Init order: VDP regs (display off) → palette → tiles → name table → SAT →
644
+ * R10 → display on (which also enables the frame IRQ) → EI. The one hard
645
+ * rule: EI comes LAST, after every register is in place — the crt0 boots
646
+ * with DI and the FIRST halt would hang forever if interrupts were never
647
+ * enabled. (paint_title's trailing __asm__("ei") IS that final step here.) */
648
+ gg_vdp_init(); /* R0=0x36 already has IE1 (line IRQ) set */
118
649
  gg_load_palette(palette);
119
- gg_load_tiles(0x0000, bg_tiles, 96); /* BG court tiles -> BG bank $0000 */
120
- gg_load_tiles(0x2000, tile_solid, 32); /* paddle/ball sprite tile -> $2000 */
121
- {
122
- uint8_t r, c;
123
- for (r = 0; r < 28; r++) for (c = 0; c < 32; c++) gg_set_tilemap_cell(r, c, 0, 0);
124
- }
125
- draw_court();
650
+ load_font();
651
+ gg_load_tiles((uint16_t)(BG_FIELD * 32), court_tiles, 32 * 4);
652
+ gg_load_tiles(0x2000, sprite_tiles, 32 * 3);
126
653
  gg_sprite_init();
127
654
  sfx_init();
128
- gg_vdp_display_on();
655
+ music_init();
656
+ music_play(0);
129
657
 
130
- reset_match();
658
+ /* R10 = SPLIT_LINE arms the line counter: IRQ at the last bar line —
659
+ * scanline 47 in FULL-FRAME terms (window top 24 + HUD 24 - 1). Set
660
+ * once — it reloads itself every underflow. */
661
+ gg_vdp_write_reg(10, SPLIT_LINE);
131
662
 
132
- do {
133
- uint8_t p1;
134
- uint8_t slot;
135
- int16_t target;
136
- gg_vblank_wait();
137
- sfx_update();
663
+ best_streak = record_load(); /* cart RAM if present — else 0 */
664
+ streak = 0;
665
+ state = ST_TITLE;
666
+ prev_pad = 0xFF;
667
+ paint_title(); /* …ends with EI: interrupts live now */
138
668
 
139
- /* Stage SAT first — uploaded at vblank. */
140
- slot = 0;
141
- /* Left paddle = 3 stacked 8×8 sprites */
142
- for (i = 0; i < PADDLE_H / 8; i++)
143
- gg_sprite_set(slot++, PADDLE_X1, (uint8_t)(p1y + i * 8), 0);
144
- /* Right paddle */
145
- for (i = 0; i < PADDLE_H / 8; i++)
146
- gg_sprite_set(slot++, PADDLE_X2, (uint8_t)(p2y + i * 8), 0);
147
- /* Ball */
148
- gg_sprite_set(slot++, (uint8_t)bx, (uint8_t)by, 0);
149
- gg_sat_upload();
669
+ for (;;) {
670
+ if (state == ST_TITLE) {
671
+ /* ── GAME LOGIC (clay) — title: button 1 = 1P vs CPU, 2 = 2P versus ── */
672
+ wait_vblank();
673
+ sfx_update();
674
+ music_update();
675
+ wait_split();
676
+ pad = gg_joypad_read();
677
+ fresh = (uint8_t)(pad & ~prev_pad);
678
+ prev_pad = pad;
679
+ if (fresh & JOY_B1) start_match(0);
680
+ else if (fresh & JOY_B2) start_match(1);
681
+ continue;
682
+ }
683
+
684
+ if (state == ST_OVER) {
685
+ /* Freeze the court; button 1 or 2 returns to the title. */
686
+ wait_vblank();
687
+ if (over_step) { /* deferred result draws — one/vblank */
688
+ if (over_step == 4) {
689
+ for (pad = 0; pad < VIS_COLS; pad++) gg_set_tilemap_cell(VROW(6), VCOL(pad), 0, 0);
690
+ text_draw(6, 6, two_player ? (score_p1 >= WIN_SCORE ? "P1 WINS" : "P2 WINS")
691
+ : (score_p1 >= WIN_SCORE ? "P1 WINS" : "CPU WINS"));
692
+ } else if (over_step == 3) {
693
+ text_draw(9, 7, "SCORE");
694
+ gg_set_tilemap_cell(VROW(9), VCOL(13), (uint8_t)(FONT_BASE + (score_p1 > 9 ? 9 : score_p1)), 0);
695
+ gg_set_tilemap_cell(VROW(9), VCOL(14), FONT_BASE + 36, 0); /* '-' */
696
+ gg_set_tilemap_cell(VROW(9), VCOL(15), (uint8_t)(FONT_BASE + (score_p2 > 9 ? 9 : score_p2)), 0);
697
+ } else if (over_step == 2) {
698
+ if (new_record) text_draw(11, 5, "NEW RECORD");
699
+ } else {
700
+ text_draw(14, 5, "START - 1");
701
+ }
702
+ over_step--;
703
+ }
704
+ wait_split();
705
+ sfx_update();
706
+ music_update();
707
+ pad = gg_joypad_read();
708
+ fresh = (uint8_t)(pad & ~prev_pad);
709
+ prev_pad = pad;
710
+ if (fresh & (JOY_B1 | JOY_B2)) {
711
+ state = ST_TITLE;
712
+ prev_pad = 0xFF;
713
+ paint_title();
714
+ }
715
+ stage_sprites(); /* keep the frozen paddles staged */
716
+ continue;
717
+ }
718
+
719
+ /* ── ST_PLAY ─────────────────────────────────────────────────────────
720
+ * Frame shape: [vblank: SAT upload + gated HUD writes] → [line IRQ at the
721
+ * bar] → [rest of frame: game logic]. VRAM traffic stays inside vblank;
722
+ * logic runs while the VDP draws the court.
723
+ *
724
+ * BUDGET FOOTGUN (inherited from the GG shmup, which found it the hard
725
+ * way): everything between wait_vblank() and wait_split() must finish
726
+ * before the line IRQ at scanline 47 — vblank (70 lines) + the 47 lines
727
+ * above the split ≈ 27k cycles (BIGGER than the SMS's: the 24 never-shown
728
+ * border lines are free). The SAT upload eats ~7k of that. An
729
+ * unconditional HUD redraw (a draw_u16 = 5 software 16-bit divisions) is
730
+ * fine alone, but we still GATE it behind hud_dirty so it only fires on a
731
+ * scored point, never every frame — the discipline all the GG/SMS
732
+ * templates share. */
733
+ wait_vblank();
734
+ gg_sat_upload(); /* shadow SAT staged at end of last frame */
735
+ if (hud_dirty) { hud_dirty = 0; draw_hud_scores(); }
736
+ sfx_update();
737
+ music_update();
738
+ wait_split(); /* the line-interrupt split — every frame */
150
739
 
151
- p1 = gg_joypad_read();
740
+ /* ── GAME LOGIC (clay — reshape freely) from here down ── */
741
+ random8(); /* tick the noise source every play frame */
152
742
 
153
- if ((p1 & JOY_UP) && p1y > COURT_TOP) p1y -= 2;
154
- if ((p1 & JOY_DOWN) && p1y < COURT_BOT - PADDLE_H) p1y += 2;
743
+ /* P1 port 0, up/down, 2px/frame. */
744
+ pad = gg_joypad_read();
745
+ if ((pad & JOY_UP) && p1y > COURT_TOP) p1y -= 2;
746
+ if ((pad & JOY_DOWN) && p1y < COURT_BOT - PADDLE_H) p1y += 2;
155
747
 
156
- /* GG has only one controller — the right paddle is always AI. */
157
- target = by - PADDLE_H / 2;
158
- if (p2y < target && p2y < COURT_BOT - PADDLE_H) p2y += 1;
159
- else if (p2y > target && p2y > COURT_TOP) p2y -= 1;
748
+ if (two_player) {
749
+ /* P2 PORT B (gg_joypad_read_p2 reassembles the split $DC/$DD bits),
750
+ * same speed: a fair simultaneous-versus match. gpgx wires the SMS
751
+ * second-controller layout for GG too (see the file header). */
752
+ pad2 = gg_joypad_read_p2();
753
+ if ((pad2 & JOY_UP) && p2y > COURT_TOP) p2y -= 2;
754
+ if ((pad2 & JOY_DOWN) && p2y < COURT_BOT - PADDLE_H) p2y += 2;
755
+ } else {
756
+ /* CPU — chases the ball centre at 1px/frame (half player speed) with a
757
+ * small dead zone. Beatable by design: steep deflections outrun it. */
758
+ target = by + BALL_SIZE / 2 - PADDLE_H / 2;
759
+ if (p2y + 2 < target && p2y < COURT_BOT - PADDLE_H) p2y += 1;
760
+ else if (p2y > target + 2 && p2y > COURT_TOP) p2y -= 1;
761
+ }
160
762
 
763
+ /* Ball update (frozen during the post-point serve pause). */
161
764
  if (serve_timer > 0) {
162
- serve_timer--;
765
+ --serve_timer;
163
766
  } else {
164
767
  bx = (int16_t)(bx + bdx);
165
768
  by = (int16_t)(by + bdy);
166
- if (by < COURT_TOP) { by = COURT_TOP; bdy = (int8_t)(-bdy); sfx_tone(1, 300, 2); }
167
- if (by + BALL_SIZE > COURT_BOT) { by = COURT_BOT - BALL_SIZE; bdy = (int8_t)(-bdy); sfx_tone(1, 300, 2); }
168
769
 
770
+ /* Rail bounce. */
771
+ if (by < COURT_TOP) { by = COURT_TOP; bdy = (int8_t)(-bdy); sfx_tone(1, 300, 2); }
772
+ if (by + BALL_SIZE > COURT_BOT) { by = COURT_BOT - BALL_SIZE; bdy = (int8_t)(-bdy); sfx_tone(1, 300, 2); }
773
+
774
+ /* Paddle collisions (direction-gated so the ball can't double-hit). */
169
775
  if (bdx < 0
170
- && bx <= PADDLE_X1 + 8
171
- && bx + BALL_SIZE >= PADDLE_X1
172
- && by + BALL_SIZE > p1y
173
- && by < p1y + PADDLE_H) {
776
+ && bx <= PADDLE_X1 + 8 && bx + BALL_SIZE >= PADDLE_X1
777
+ && by + BALL_SIZE > p1y && by < p1y + PADDLE_H) {
174
778
  bdx = (int8_t)(-bdx);
175
779
  bx = PADDLE_X1 + 8;
176
- sfx_tone(0, 250, 3);
780
+ deflect(p1y);
177
781
  }
178
782
  if (bdx > 0
179
- && bx + BALL_SIZE >= PADDLE_X2
180
- && bx <= PADDLE_X2 + 8
181
- && by + BALL_SIZE > p2y
182
- && by < p2y + PADDLE_H) {
783
+ && bx + BALL_SIZE >= PADDLE_X2 && bx <= PADDLE_X2 + 8
784
+ && by + BALL_SIZE > p2y && by < p2y + PADDLE_H) {
183
785
  bdx = (int8_t)(-bdx);
184
786
  bx = PADDLE_X2 - BALL_SIZE;
185
- sfx_tone(0, 250, 3);
787
+ deflect(p2y);
186
788
  }
187
789
 
188
- if (bx < VIS_X0) { if (score_p2 < 9) score_p2++; sfx_noise(20); serve_ball(0); }
189
- if (bx > VIS_X1 - BALL_SIZE) { if (score_p1 < 9) score_p1++; sfx_tone(0, 180, 16); serve_ball(1); }
790
+ /* Off either side of the window point. */
791
+ if (bx < VIS_X0 - 4) score_point(0); /* past P1 P2/CPU scores */
792
+ if (bx > VIS_X1 - 3) score_point(1); /* past P2 → P1 scores */
190
793
  }
191
- } while (1);
794
+
795
+ /* Stage the SAT shadow NOW (RAM only — cheap, any time); the actual VRAM
796
+ * upload waits for the next vblank at the top of the loop. */
797
+ stage_sprites();
798
+ }
192
799
  }