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,255 +1,672 @@
1
- /*
2
- * PC Engine "sports" — a Pong-style two-paddle scaffold.
1
+ /* ── main.c — PC Engine versus court game (complete example game) ────────────
3
2
  *
4
- * Two paddles and a bouncing ball on a netted court. The d-pad moves player 1's
5
- * (left) paddle up/down. Player 2's (right) paddle follows the ball with a
6
- * chase-AI so the game is playable solo. The ball deflects off paddles and the
7
- * top/bottom court lines; a ball past either edge scores for the other side and
8
- * re-serves. Score is shown with background digit tiles. Mirrors the
9
- * NES/Genesis/SNES/GB/SMS sports scaffolds.
3
+ * SPIKE SURGE a COMPLETE, working head-to-head court game (Pong lineage):
4
+ * title screen, 1P vs a beatable CPU and 2P SIMULTANEOUS VERSUS (P1 on the
5
+ * stock pad, P2 on the TurboTap's second pad), first-to-5 match flow with a
6
+ * result screen, PSG music + SFX, and an in-session record (your longest win
7
+ * streak vs the CPU; a bare HuCard can't save — see the record note).
10
8
  *
11
- * Paddles + ball are hardware sprites; the court (green field, white border
12
- * lines, dashed centre net) is the BG tilemap, so the screen is clearly a
13
- * sports court (clears the verify gate).
9
+ * The game: two paddles, one "pulse" bouncing between them. UP/DOWN move your
10
+ * paddle; the pulse deflects off paddles (steeper the further from centre you
11
+ * parry it) and the top/bottom court rails. A pulse past either edge scores
12
+ * for the other side and re-serves. First to 5 takes the match.
14
13
  *
15
- * PCE notes (see pce_hw.h / MENTAL_MODEL.md):
16
- * - disp_enable() turns on BG + sprites + the VBlank IRQ (waitvsync needs it).
17
- * - .bss must be non-empty (pce_video.c's _pce_keep[] covers it).
14
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
15
+ * very different one. The markers tell you what's what:
16
+ * HARDWARE IDIOM (load-bearing) dodges a documented PCE footgun; reshape
17
+ * your gameplay around it (see TROUBLESHOOTING before changing).
18
+ * GAME LOGIC (clay) — court art, pulse physics, CPU skill, scoring rules:
19
+ * reshape freely.
18
20
  *
19
- * cc65 is C89 — declare locals at the top of a block.
21
+ * What depends on what:
22
+ * pce_hw.h / pce_video.c / pce_input.c / pce_sound.c — the helper lib
23
+ * (VDC/VCE/PSG register dances + joypad). The HARDWARE IDIOM markers in
24
+ * pce_video.c say which parts are load-bearing.
25
+ * cc65's pce crt0 + pce.lib are auto-linked; the 'rom32k' linker preset
26
+ * (applied automatically to example projects) gives a 32KB HuCard.
27
+ *
28
+ * 2P, honestly: the stock PC Engine has ONE controller port; 2P needs a
29
+ * TurboTap. The geargrafx core implements the TurboTap and the romdev host
30
+ * now force-ENABLES it (PLATFORM_CORE_OPTIONS pce: geargrafx_turbotap), so a
31
+ * second pad's input reaches the game on pad slot 2 — verified by driving
32
+ * port-1 input and seeing P2's paddle move. So this game ships REAL
33
+ * simultaneous 2P versus. (On real hardware the player plugs a TurboTap and a
34
+ * second pad.) The CPU opponent only exists in 1P mode.
35
+ *
36
+ * Frame budget (NTSC, 60fps, 7.16MHz 65C02-class CPU): 2 paddles + 1 pulse +
37
+ * 2 paddle AABB tests + a 7-entry SATB copy in vblank — a tiny fraction of a
38
+ * frame. Plenty of headroom for fancier physics.
20
39
  */
21
40
  #include <pce.h>
22
- #include <stdint.h> /* int8_t/int16_t for ball velocity + positions */
41
+ #include <joystick.h> /* JOY_2 + joy_read for the 2nd pad (TurboTap port 1) */
23
42
  #include "pce_hw.h"
24
43
 
25
- /* ---- VRAM layout (word addresses) --------------------------------------- */
26
- #define BAT_VRAM 0x0000
27
- #define FONT_VRAM 0x1000 /* digit tiles */
28
- #define GREEN_VRAM 0x1400 /* court field (colour 1) */
29
- #define LINE_VRAM 0x1410 /* court line / border (colour 2) */
30
- #define NET_VRAM 0x1420 /* dashed centre net */
31
- #define PADDLE_VRAM 0x1800 /* 16x16 paddle segment */
32
- #define BALL_VRAM 0x1840 /* 16x16 ball */
44
+ /* pce_hw.h gives us u8/u16; the pulse position + deflection math need signed
45
+ * types (the pulse can sit above the rim mid-bounce). cc65's int is 16-bit. */
46
+ typedef signed char s8;
47
+ typedef int s16;
48
+
49
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
50
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
51
+ #define GAME_TITLE "SPIKE SURGE"
52
+
53
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
54
+ * VRAM map (WORD addresses — the VDC is a 16-bit-word machine; an 8x8 tile is
55
+ * 16 words, a 16x16 sprite cell is 64). Sprites and BG tiles share one 64KB
56
+ * VRAM, so lay it out ONCE and keep the SATB out of pattern space:
57
+ * $0000 BAT (32x32 background map — matches vdc_init's VDC_MWR setting)
58
+ * $1000 font glyphs (38 tiles: blank, 0-9, A-Z, dash)
59
+ * $1400 court furniture tiles (floor, rail, net, HUD band)
60
+ * $1800 16x16 sprite cells: paddle, pulse */
61
+ #define BAT_VRAM 0x0000
62
+ #define FONT_VRAM 0x1000
63
+ #define FLOOR_VRAM 0x1400 /* court field (BG colour 1) */
64
+ #define RAIL_VRAM 0x1410 /* top/bottom rails + sidelines (BG colour 2) */
65
+ #define NET_VRAM 0x1420 /* dashed centre net */
66
+ #define BAND_VRAM 0x1430 /* flat band behind the HUD text */
67
+ #define PADDLE_VRAM 0x1800 /* 16x16 paddle segment */
68
+ #define PULSE_VRAM 0x1840 /* 16x16 pulse */
33
69
 
34
70
  #define BAT_ENTRY(pal, vram) ((u16)(((pal) << 12) | ((vram) >> 4)))
35
71
 
36
- #define COURT_TOP 24
37
- #define COURT_BOT 216
38
- #define PADDLE_H 48 /* 3 stacked 16px sprite segments */
39
- #define BALL_SIZE 12
40
- #define PADDLE_X1 16
41
- #define PADDLE_X2 224
72
+ /* Sprite pattern codes = VRAM >> 6 (the 16x16 cell index). */
73
+ #define PADDLE_PAT (PADDLE_VRAM >> 6)
74
+ #define PULSE_PAT (PULSE_VRAM >> 6)
42
75
 
43
- /* ---- font (digits only) ------------------------------------------------- */
44
- #define NUM_GLYPHS 10
45
- static const u8 FONT5x7[NUM_GLYPHS][7] = {
46
- {0x0E,0x11,0x13,0x15,0x19,0x11,0x0E}, /* 0 */
47
- {0x04,0x0C,0x04,0x04,0x04,0x04,0x0E}, /* 1 */
48
- {0x0E,0x11,0x01,0x02,0x04,0x08,0x1F}, /* 2 */
49
- {0x1F,0x02,0x04,0x02,0x01,0x11,0x0E}, /* 3 */
50
- {0x02,0x06,0x0A,0x12,0x1F,0x02,0x02}, /* 4 */
51
- {0x1F,0x10,0x1E,0x01,0x01,0x11,0x0E}, /* 5 */
52
- {0x06,0x08,0x10,0x1E,0x11,0x11,0x0E}, /* 6 */
53
- {0x1F,0x01,0x02,0x04,0x08,0x08,0x08}, /* 7 */
54
- {0x0E,0x11,0x11,0x0E,0x11,0x11,0x0E}, /* 8 */
55
- {0x0E,0x11,0x11,0x0F,0x01,0x02,0x0C} /* 9 */
56
- };
76
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
77
+ * Court geometry + match rules. The 256x224 court is framed by rail tiles on
78
+ * BAT rows 2 and 27; COURT_TOP/BOT keep the pulse between them. Rows 0-1 are
79
+ * the HUD band. Paddles are 3 stacked 16px sprite segments (48px tall). */
80
+ #define COURT_TOP 24 /* first pixel row below the top rail */
81
+ #define COURT_BOT 216 /* first pixel row of the bottom rail */
82
+ #define PADDLE_H 48 /* 3 stacked 16px sprite segments */
83
+ #define PADDLE_X1 16 /* P1 — left side */
84
+ #define PADDLE_X2 224 /* P2/CPU — right side */
85
+ #define PULSE_SIZE 12
86
+ #define WIN_SCORE 5 /* first to 5 takes the match */
87
+ #define P1_SPEED 3 /* px/frame — both humans move at this */
88
+ #define CPU_SPEED 1 /* px/frame — third speed: clearly beatable */
89
+ #define BALL_VMAX 3 /* max |bdy| — exceeds CPU_SPEED so a steep *
90
+ * edge parry outruns the CPU (the win) */
91
+
92
+ /* SATB slot plan (slot order = priority): 0-2 P1 paddle, 3-5 P2 paddle, 6
93
+ * pulse. PAL plan: paddles on their own sprite sub-palettes so P1/P2 differ. */
94
+ #define SLOT_P1 0
95
+ #define SLOT_P2 3
96
+ #define SLOT_PULSE 6
97
+ #define PAL_P1 0
98
+ #define PAL_P2 1
99
+ #define PAL_PULSE 2
100
+ #define OFFSCREEN_Y 0x1F0 /* park hidden sprites below the display */
57
101
 
58
- /* ---- state -------------------------------------------------------------- */
59
- static int16_t p1y, p2y, bx, by;
60
- static int8_t bdx, bdy;
102
+ /* ── GAME LOGIC (clay — reshape freely) ── game state ── */
103
+ static s16 p1y, p2y; /* paddle top Y (signed: collision math) */
104
+ static s16 bx, by; /* pulse top-left, pixels */
105
+ static s8 bdx, bdy; /* pulse velocity (px/frame) */
61
106
  static u8 score_p1, score_p2;
62
- static u8 serve_timer;
63
- static u8 pad;
64
- static u16 tile_buf[16];
65
- static u16 spr_buf[64];
107
+ static u8 serve_timer; /* freeze frames between points */
108
+ static u8 two_player; /* title pick: 0 = vs CPU, 1 = 2P versus */
109
+ static u8 streak; /* current 1P-vs-CPU win streak (RAM) */
110
+ static u16 best_streak; /* in-session record — see end_match */
111
+ static u8 new_record; /* result screen shows NEW RECORD */
112
+ static u8 state; /* ST_TITLE / ST_PLAY / ST_OVER */
113
+ static u8 prev_pad; /* edge-triggered menu input */
66
114
  static u8 sfx_timer;
115
+ static u8 hud_dirty;
116
+
117
+ /* Game states — the shell every example shares: title → play → game over. */
118
+ #define ST_TITLE 0
119
+ #define ST_PLAY 1
120
+ #define ST_OVER 2
121
+
122
+ static u16 tile_buf[16]; /* scratch for one 8x8 tile */
123
+ static u16 spr_buf[64]; /* scratch for one 16x16 sprite cell */
124
+
125
+ /* ── GAME LOGIC (clay) — 5x7 glyph font: blank, 0-9, A-Z, dash ──────────────
126
+ * Each glyph is 7 rows of 5 bits (bit4 = leftmost). upload_font() expands
127
+ * them into 8x8 1-plane tiles; drawn with BG sub-palette 1 (white). */
128
+ #define G_BLANK 0
129
+ #define G_DIGIT 1 /* '0'..'9' -> glyphs 1..10 */
130
+ #define G_ALPHA 11 /* 'A'..'Z' -> glyphs 11..36 */
131
+ #define G_DASH 37
132
+ #define NUM_GLYPHS 38
133
+
134
+ static const u8 FONT5x7[NUM_GLYPHS][7] = {
135
+ {0,0,0,0,0,0,0},
136
+ {0x0E,0x11,0x13,0x15,0x19,0x11,0x0E}, {0x04,0x0C,0x04,0x04,0x04,0x04,0x0E},
137
+ {0x0E,0x11,0x01,0x02,0x04,0x08,0x1F}, {0x1F,0x02,0x04,0x02,0x01,0x11,0x0E},
138
+ {0x02,0x06,0x0A,0x12,0x1F,0x02,0x02}, {0x1F,0x10,0x1E,0x01,0x01,0x11,0x0E},
139
+ {0x06,0x08,0x10,0x1E,0x11,0x11,0x0E}, {0x1F,0x01,0x02,0x04,0x08,0x08,0x08},
140
+ {0x0E,0x11,0x11,0x0E,0x11,0x11,0x0E}, {0x0E,0x11,0x11,0x0F,0x01,0x02,0x0C},
141
+ {0x0E,0x11,0x11,0x1F,0x11,0x11,0x11}, {0x1E,0x11,0x11,0x1E,0x11,0x11,0x1E},
142
+ {0x0E,0x11,0x10,0x10,0x10,0x11,0x0E}, {0x1E,0x11,0x11,0x11,0x11,0x11,0x1E},
143
+ {0x1F,0x10,0x10,0x1E,0x10,0x10,0x1F}, {0x1F,0x10,0x10,0x1E,0x10,0x10,0x10},
144
+ {0x0E,0x11,0x10,0x17,0x11,0x11,0x0F}, {0x11,0x11,0x11,0x1F,0x11,0x11,0x11},
145
+ {0x0E,0x04,0x04,0x04,0x04,0x04,0x0E}, {0x07,0x02,0x02,0x02,0x02,0x12,0x0C},
146
+ {0x11,0x12,0x14,0x18,0x14,0x12,0x11}, {0x10,0x10,0x10,0x10,0x10,0x10,0x1F},
147
+ {0x11,0x1B,0x15,0x15,0x11,0x11,0x11}, {0x11,0x19,0x15,0x13,0x11,0x11,0x11},
148
+ {0x0E,0x11,0x11,0x11,0x11,0x11,0x0E}, {0x1E,0x11,0x11,0x1E,0x10,0x10,0x10},
149
+ {0x0E,0x11,0x11,0x11,0x15,0x12,0x0D}, {0x1E,0x11,0x11,0x1E,0x14,0x12,0x11},
150
+ {0x0F,0x10,0x10,0x0E,0x01,0x01,0x1E}, {0x1F,0x04,0x04,0x04,0x04,0x04,0x04},
151
+ {0x11,0x11,0x11,0x11,0x11,0x11,0x0E}, {0x11,0x11,0x11,0x11,0x11,0x0A,0x04},
152
+ {0x11,0x11,0x11,0x15,0x15,0x15,0x0A}, {0x11,0x11,0x0A,0x04,0x0A,0x11,0x11},
153
+ {0x11,0x11,0x0A,0x04,0x04,0x04,0x04}, {0x1F,0x01,0x02,0x04,0x08,0x10,0x1F},
154
+ {0x00,0x00,0x00,0x1F,0x00,0x00,0x00},
155
+ };
67
156
 
157
+ /* ── GAME LOGIC (clay) — sprite masks (16 rows × 16 bits, bit15 leftmost) ──
158
+ * The paddle is a solid 8px-wide bar centred in the 16px cell; the pulse is a
159
+ * round blip. Colour is the PALETTE, not the bits (one shape, three sub-pals). */
160
+ static const u16 paddle_mask[16] = {
161
+ 0x0FF0, 0x0FF0, 0x0FF0, 0x0FF0, 0x0FF0, 0x0FF0, 0x0FF0, 0x0FF0,
162
+ 0x0FF0, 0x0FF0, 0x0FF0, 0x0FF0, 0x0FF0, 0x0FF0, 0x0FF0, 0x0FF0
163
+ };
164
+ static const u16 pulse_mask[16] = {
165
+ 0x0000, 0x0000, 0x07E0, 0x0FF0, 0x1FF8, 0x1FF8, 0x3FFC, 0x3FFC,
166
+ 0x3FFC, 0x3FFC, 0x1FF8, 0x1FF8, 0x0FF0, 0x07E0, 0x0000, 0x0000
167
+ };
168
+
169
+ /* ── GAME LOGIC (clay) — tile/sprite builders ────────────────────────────── */
68
170
  static void make_solid_tile(u16 *t, u8 ci) {
69
171
  u8 r;
70
172
  u8 p0 = (ci & 1) ? 0xFF : 0x00;
71
173
  u8 p1 = (ci & 2) ? 0xFF : 0x00;
72
- u8 p2 = (ci & 4) ? 0xFF : 0x00;
73
- u8 p3 = (ci & 8) ? 0xFF : 0x00;
74
174
  for (r = 0; r < 8; ++r) {
75
175
  t[r] = (u16)(p0 | (p1 << 8));
76
- t[r + 8] = (u16)(p2 | (p3 << 8));
176
+ t[r + 8] = 0;
77
177
  }
78
178
  }
79
179
 
80
- /* net tile: green field (colour 1) with a colour-2 vertical dash centre column */
180
+ /* net tile: court floor (colour 1) with a colour-2 dashed centre column */
81
181
  static void make_net_tile(u16 *t) {
82
182
  u8 r;
83
183
  for (r = 0; r < 8; ++r) {
84
- u8 dash = (r < 5); /* dashed: top 5 rows of each tile are the dash */
85
- u8 p1 = dash ? 0x18 : 0x00; /* centre 2 px -> colour 2 (plane1) */
86
- t[r] = (u16)(0x00FF | (p1 << 8)); /* plane0 full (green) + dash */
184
+ u8 dash = (r < 5); /* dashed: top 5 rows of each tile */
185
+ u8 p1 = dash ? 0x18 : 0x00; /* centre 2 px -> colour 2 (plane1) */
186
+ t[r] = (u16)(0x00FF | (p1 << 8)); /* plane0 full (floor) + dash */
87
187
  t[r + 8] = 0x0000;
88
188
  }
89
189
  }
90
190
 
91
- static void make_paddle_sprite(void) {
92
- u8 r;
93
- for (r = 0; r < 64; ++r) spr_buf[r] = 0;
94
- /* a solid 8px-wide vertical bar centred in the 16px cell, colour 1 */
95
- for (r = 0; r < 16; ++r) spr_buf[r] = 0x0FF0;
96
- load_tiles(PADDLE_VRAM, spr_buf, 64);
97
- }
98
-
99
- static void make_ball_sprite(void) {
100
- static const u16 ball[16] = {
101
- 0x0000, 0x0000, 0x07E0, 0x0FF0, 0x1FF8, 0x1FF8, 0x3FFC, 0x3FFC,
102
- 0x3FFC, 0x3FFC, 0x1FF8, 0x1FF8, 0x0FF0, 0x07E0, 0x0000, 0x0000
103
- };
191
+ /* one-colour 16x16 sprite cell from a 16-row mask (colour = plane0 → index 1) */
192
+ static void make_sprite16(u16 vram, const u16 *mask) {
104
193
  u8 r;
105
194
  for (r = 0; r < 64; ++r) spr_buf[r] = 0;
106
- for (r = 0; r < 16; ++r) spr_buf[r] = ball[r]; /* colour 1 */
107
- load_tiles(BALL_VRAM, spr_buf, 64);
195
+ for (r = 0; r < 16; ++r) spr_buf[r] = mask[r]; /* plane 0 → colour 1 */
196
+ load_tiles(vram, spr_buf, 64);
108
197
  }
109
198
 
110
199
  static void upload_font(void) {
111
- u8 g, row, bits, plane0;
200
+ u8 g, row, bits, px;
112
201
  for (g = 0; g < NUM_GLYPHS; ++g) {
113
202
  for (row = 0; row < 16; ++row) tile_buf[row] = 0;
114
203
  for (row = 0; row < 7; ++row) {
115
204
  bits = FONT5x7[g][row];
116
- plane0 = 0;
117
- if (bits & 0x10) plane0 |= 0x40;
118
- if (bits & 0x08) plane0 |= 0x20;
119
- if (bits & 0x04) plane0 |= 0x10;
120
- if (bits & 0x02) plane0 |= 0x08;
121
- if (bits & 0x01) plane0 |= 0x04;
122
- tile_buf[row] = (u16)plane0;
205
+ px = 0;
206
+ if (bits & 0x10) px |= 0x40;
207
+ if (bits & 0x08) px |= 0x20;
208
+ if (bits & 0x04) px |= 0x10;
209
+ if (bits & 0x02) px |= 0x08;
210
+ if (bits & 0x01) px |= 0x04;
211
+ tile_buf[row] = (u16)px;
123
212
  }
124
213
  load_tiles((u16)(FONT_VRAM + g * 16), tile_buf, 16);
125
214
  }
126
215
  }
127
216
 
128
- static void draw_court(void) {
217
+ static void upload_art(void) {
218
+ upload_font();
219
+ make_solid_tile(tile_buf, 1); load_tiles(FLOOR_VRAM, tile_buf, 16);
220
+ make_solid_tile(tile_buf, 2); load_tiles(RAIL_VRAM, tile_buf, 16);
221
+ make_net_tile(tile_buf); load_tiles(NET_VRAM, tile_buf, 16);
222
+ make_solid_tile(tile_buf, 3); load_tiles(BAND_VRAM, tile_buf, 16);
223
+ make_sprite16(PADDLE_VRAM, paddle_mask);
224
+ make_sprite16(PULSE_VRAM, pulse_mask);
225
+ }
226
+
227
+ /* ── GAME LOGIC (clay) — BAT text + court paint ──────────────────────────── */
228
+ static void put_glyph(u8 col, u8 row, u8 glyph) {
229
+ u16 e = BAT_ENTRY(1, (u16)(FONT_VRAM + glyph * 16)); /* pal 1 = white */
230
+ vram_set_write_addr((u16)(BAT_VRAM + row * 32 + col));
231
+ VDC_DATA_LO = (u8)(e & 0xFF);
232
+ VDC_DATA_HI = (u8)(e >> 8);
233
+ }
234
+
235
+ static void put_tile(u8 col, u8 row, u16 e) {
236
+ vram_set_write_addr((u16)(BAT_VRAM + row * 32 + col));
237
+ VDC_DATA_LO = (u8)(e & 0xFF);
238
+ VDC_DATA_HI = (u8)(e >> 8);
239
+ }
240
+
241
+ static void draw_text(u8 col, u8 row, const char *s) {
242
+ u8 c;
243
+ while ((c = (u8)*s++) != 0) {
244
+ u8 g = G_BLANK;
245
+ if (c >= '0' && c <= '9') g = (u8)(G_DIGIT + c - '0');
246
+ else if (c >= 'A' && c <= 'Z') g = (u8)(G_ALPHA + c - 'A');
247
+ else if (c == '-') g = G_DASH;
248
+ put_glyph(col++, row, g);
249
+ }
250
+ }
251
+
252
+ static void draw_num5(u8 col, u8 row, u16 v) {
253
+ u8 i, d[5];
254
+ for (i = 0; i < 5; ++i) { d[i] = (u8)(v % 10); v /= 10; }
255
+ for (i = 0; i < 5; ++i) put_glyph((u8)(col + i), row, (u8)(G_DIGIT + d[4 - i]));
256
+ }
257
+
258
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
259
+ * WHOLE-SCREEN BAT PAINT — the PCE's bandwidth (the inverse of the NES vblank
260
+ * famine; the puzzle template's match-3 board exploits the same thing). The
261
+ * court is just BG tiles; when a screen changes we rewrite ALL 32x32 BAT
262
+ * entries — 1024 word writes straight at the VDC's VWR port. The whole map
263
+ * streams in well under a vblank, so this game NEVER touches the tilemap inside
264
+ * the frame loop (only on a state change: title → play → result). Two rules:
265
+ * - do the streaming with the address latch armed by vram_set_write_addr(),
266
+ * which auto-increments as we feed VDC_DATA_LO/HI;
267
+ * - keep the SATB DMA (satb_dma) after the BAT writes — both share the VDC.
268
+ *
269
+ * requires: BAT 32x32 (vdc_init's MWR). */
270
+ static void paint_court(void) {
129
271
  u8 r, c;
130
- u16 g = BAT_ENTRY(0, GREEN_VRAM);
131
- u16 ln = BAT_ENTRY(0, LINE_VRAM);
132
- u16 nt = BAT_ENTRY(0, NET_VRAM);
272
+ u16 floor = BAT_ENTRY(0, FLOOR_VRAM);
273
+ u16 rail = BAT_ENTRY(0, RAIL_VRAM);
274
+ u16 net = BAT_ENTRY(0, NET_VRAM);
275
+ u16 band = BAT_ENTRY(0, BAND_VRAM);
133
276
  u16 e;
134
- for (r = 0; r < 32; ++r) {
277
+ for (r = 0; r < 32; r++) {
135
278
  vram_set_write_addr((u16)(BAT_VRAM + r * 32));
136
- for (c = 0; c < 32; ++c) {
137
- if (r <= 2 || r >= 27) e = ln; /* top/bottom border */
138
- else if (c == 1 || c == 30) e = ln; /* sidelines */
139
- else if (c == 16) e = nt; /* centre net */
140
- else e = g; /* field */
279
+ for (c = 0; c < 32; c++) {
280
+ if (r < 2) e = band; /* HUD band */
281
+ else if (r == 2 || r == 27) e = rail; /* top/bottom rails */
282
+ else if (c == 1 || c == 30) e = rail; /* sidelines */
283
+ else if (c == 16 && r > 2 && r < 27) e = net; /* centre net */
284
+ else e = floor; /* court surface */
141
285
  VDC_DATA_LO = (u8)(e & 0xFF);
142
286
  VDC_DATA_HI = (u8)(e >> 8);
143
287
  }
144
288
  }
145
289
  }
146
290
 
147
- static void put_glyph(u8 col, u8 row, u8 digit) {
148
- u16 e = BAT_ENTRY(0, (u16)(FONT_VRAM + digit * 16));
149
- vram_set_write_addr((u16)(BAT_VRAM + row * 32 + col));
150
- VDC_DATA_LO = (u8)(e & 0xFF);
151
- VDC_DATA_HI = (u8)(e >> 8);
291
+ /* HUD (row 0): "P1 n BEST nnnnn CPU n" (or "P2" in 2P mode). */
292
+ static void draw_hud(void) {
293
+ u8 i;
294
+ /* clear the HUD text row before repainting (band tile under the glyphs) */
295
+ for (i = 0; i < 32; i++) put_tile(i, 0, BAT_ENTRY(0, BAND_VRAM));
296
+ if (state == ST_TITLE) {
297
+ draw_text(11, 0, "BEST");
298
+ draw_num5(16, 0, best_streak);
299
+ return;
300
+ }
301
+ draw_text(1, 0, "P1");
302
+ put_glyph(4, 0, (u8)(G_DIGIT + score_p1));
303
+ draw_text(11, 0, "BEST");
304
+ draw_num5(16, 0, best_streak);
305
+ draw_text(24, 0, two_player ? "P2" : "CPU");
306
+ put_glyph(28, 0, (u8)(G_DIGIT + score_p2));
152
307
  }
153
308
 
154
- static void draw_scores(void) {
155
- put_glyph(12, 1, (u8)(score_p1 % 10));
156
- put_glyph(19, 1, (u8)(score_p2 % 10));
309
+ /* ── HARDWARE TRUTH: a bare HuCard CANNOT save the win streak (in-session) ──
310
+ * This was researched and corrected: earlier versions wrote the longest 1P win
311
+ * streak to BRAM ("backup RAM", bank $F7) and claimed it persisted across power
312
+ * cycles. That is NOT honest for a HuCard game. On REAL hardware a plain HuCard
313
+ * plugged into a base PC Engine / TurboGrafx-16 has NO backup RAM at all — BRAM
314
+ * exists ONLY when a peripheral is attached: the CD-ROM² System (2KB kept by a
315
+ * supercapacitor), the Tennokoe Bank HuCard, or the Memory Base 128. No
316
+ * commercial HuCard self-saved; they used PASSWORDS. (The often-cited Populous
317
+ * "ROMRAM" SRAM was the game's own working RAM, not a battery save.) An
318
+ * emulator like geargrafx exposes BRAM unconditionally, so the old code
319
+ * "worked" in emulation in a way the real machine never would.
320
+ *
321
+ * The record we track is still the longest 1P win streak vs the CPU (a raw
322
+ * hi-score is meaningless when every match ends 5-x; 2P matches never touch
323
+ * it) — but IN-SESSION only, resetting to 0 on a cold boot like the honest
324
+ * 2600/Lynx examples. To ACTUALLY persist on real hardware you would target a
325
+ * peripheral (BRAM behind a detect, or a CD-ROM² build) — a real-hardware
326
+ * feature, not a property of the cartridge. */
327
+ static u16 record_load(void) {
328
+ return 0; /* cold boot: no persistence on a bare HuCard */
329
+ }
330
+
331
+ static void record_save(u16 v) {
332
+ (void)v; /* in-session only — nowhere to persist on real HW */
157
333
  }
158
334
 
335
+ /* ── GAME LOGIC (clay) — music: a 2-channel tune ticked once per frame ──────
336
+ * PSG channel plan: 5 = melody, 4 = bass, 2/3 = SFX (tones cut by sfx_timer).
337
+ * PCE frequency regs are DIVIDERS: pitch ≈ 3.58MHz / (32 × value), so a
338
+ * BIGGER number is a LOWER note. Note indices into NOTE_DIV below. */
339
+ enum { R = 0, A2N, C3, F3, G3, A3, B3, C4, D4, E4, F4, G4, A4, B4, C5, D5, E5 };
340
+ static const u16 NOTE_DIV[17] = {
341
+ 0, 1017, 854, 641, 571, 508, 453, 427, 381, 339, 320, 285, 254, 226, 214, 190, 170
342
+ };
343
+ /* 16 melody steps + 8 bass steps (one bass note per 2 melody steps) */
344
+ static const u8 MEL_TITLE[16] = { G4,B4,D5,G4, E4,G4,B4,E4, A4,C5,E5,A4, D5,B4,G4,D4 };
345
+ static const u8 BAS_TITLE[8] = { G3,G3, C3,C3, A2N,A2N, D4,D4 };
346
+ static const u8 MEL_PLAY[16] = { E4,G4,E4,A4, G4,E4,D4,E4, C4,E4,G4,C5, B4,G4,E4,R };
347
+ static const u8 BAS_PLAY[8] = { A2N,A2N, C3,C3, G3,G3, A2N,A2N };
348
+ static const u8 MEL_OVER[16] = { C5,R,G4,R, E4,R,C4,R, D4,R,E4,R, G4,R,R,R };
349
+
350
+ static u8 music_song; /* reuses the ST_* ids */
351
+ static u8 music_step, music_timer, music_done;
352
+
353
+ static void music_set(u8 song) {
354
+ music_song = song;
355
+ music_step = 0;
356
+ music_timer = 0;
357
+ music_done = 0;
358
+ psg_off(4);
359
+ psg_off(5);
360
+ }
361
+
362
+ static void music_tick(void) {
363
+ const u8 *mel;
364
+ u8 n;
365
+ if (music_done) return;
366
+ if (music_timer == 0) {
367
+ mel = (music_song == ST_PLAY) ? MEL_PLAY
368
+ : (music_song == ST_OVER) ? MEL_OVER : MEL_TITLE;
369
+ n = mel[music_step & 15];
370
+ if (n != R) psg_tone(5, NOTE_DIV[n], 26);
371
+ else psg_off(5);
372
+ if (music_song != ST_OVER) { /* the result jingle has no bass */
373
+ n = ((music_step & 1) == 0)
374
+ ? ((music_song == ST_PLAY) ? BAS_PLAY[(music_step >> 1) & 7]
375
+ : BAS_TITLE[(music_step >> 1) & 7])
376
+ : R;
377
+ if (n != R) psg_tone(4, NOTE_DIV[n], 20);
378
+ }
379
+ ++music_step;
380
+ if (music_song == ST_OVER && music_step >= 16) { /* play once, stop */
381
+ music_done = 1;
382
+ psg_off(4);
383
+ psg_off(5);
384
+ }
385
+ }
386
+ ++music_timer;
387
+ if (music_timer >= 9) music_timer = 0;
388
+ }
389
+
390
+ /* short SFX on channels 2/3, auto-cut by sfx_timer */
391
+ static void sfx(u8 chan, u16 freq, u8 frames) {
392
+ psg_tone(chan, freq, 31);
393
+ if (frames > sfx_timer) sfx_timer = frames;
394
+ }
395
+
396
+ /* ── GAME LOGIC (clay) — xorshift16 PRNG ─────────────────────────────────────
397
+ * A versus game NEEDS this: the PCE is fully deterministic, so without a noise
398
+ * source two fixed strategies lock into an infinite rally loop (the exact same
399
+ * cycle, forever — a match that never ends). random8() is ticked once per play
400
+ * frame so identical game states a few seconds apart still diverge, and every
401
+ * paddle return adds a ±1 "spin" (see deflect). This is what makes an idle
402
+ * 1P-vs-CPU match provably END. */
403
+ static u16 rng = 0xC0A7;
404
+ static u8 random8(void) {
405
+ u16 r = rng;
406
+ r ^= r << 7;
407
+ r ^= r >> 9;
408
+ r ^= r << 8;
409
+ rng = r;
410
+ return (u8)r;
411
+ }
412
+
413
+ /* ── GAME LOGIC (clay) — serve: pulse to centre, toward the chosen side.
414
+ * The serve angle takes a PRNG bit (not a fixed alternation) — one more place
415
+ * determinism is broken so idle matches can't settle into a cycle. */
159
416
  static void serve_ball(u8 to_left) {
160
- bx = 120; by = 110;
417
+ bx = 120;
418
+ by = (COURT_TOP + COURT_BOT) / 2;
161
419
  bdx = to_left ? -2 : 2;
162
- bdy = ((score_p1 + score_p2) & 1) ? -2 : 2; /* was ±1 — rally felt slow */
163
- serve_timer = 40;
420
+ bdy = (random8() & 1) ? -2 : 2;
421
+ serve_timer = 40; /* breather between points */
164
422
  }
165
423
 
166
- void main(void) {
424
+ /* ── GAME LOGIC (clay) — paddle hit: deflect by where the pulse struck.
425
+ * Centre = flat-ish, edges = steep. Max |bdy| is 2 — the CPU moves at 2 too,
426
+ * but the random spin + steep edge parries are exactly how a human beats it. */
427
+ static void deflect(s16 paddle_y) {
428
+ s16 rel = (by + PULSE_SIZE / 2) - (paddle_y + PADDLE_H / 2);
429
+ bdy = (s8)(rel >> 3); /* edge parry → steep (up to ±3) */
430
+ bdy += (s8)((random8() & 2) - 1); /* spin: -1 or +1 */
431
+ if (bdy > BALL_VMAX) bdy = BALL_VMAX;
432
+ if (bdy < -BALL_VMAX) bdy = -BALL_VMAX;
433
+ if (bdy == 0) bdy = (rel < 0) ? -1 : 1; /* never return a flat pulse */
434
+ sfx(2, 0x200, 4);
435
+ }
436
+
437
+ /* ── GAME LOGIC (clay) — screen painters (full BAT repaint per state change) ── */
438
+ static void paint_title(void) {
439
+ paint_court();
440
+ draw_text((u8)((32 - (sizeof(GAME_TITLE) - 1)) / 2), 8, GAME_TITLE);
441
+ draw_text(10, 13, "1P VS CPU - I");
442
+ draw_text(10, 15, "2P VERSUS - II");
443
+ draw_text(11, 19, "FIRST TO 5");
444
+ draw_text(5, 22, "UP DOWN PARRY THE PULSE");
445
+ draw_hud();
446
+ }
447
+
448
+ static void paint_play(void) {
449
+ paint_court();
450
+ draw_hud();
451
+ }
452
+
453
+ static void paint_over(void) {
454
+ paint_court();
455
+ if (score_p1 >= WIN_SCORE)
456
+ draw_text(13, 8, "P1 WINS");
457
+ else
458
+ draw_text(12, 8, two_player ? "P2 WINS" : "CPU WINS");
459
+ put_glyph(14, 11, (u8)(G_DIGIT + score_p1));
460
+ draw_text(16, 11, "-");
461
+ put_glyph(18, 11, (u8)(G_DIGIT + score_p2));
462
+ if (new_record) draw_text(11, 14, "NEW RECORD");
463
+ draw_text(8, 21, "RUN - TITLE");
464
+ draw_hud();
465
+ }
466
+
467
+ /* ── GAME LOGIC (clay) — start a match ── */
468
+ static void start_match(u8 players) {
469
+ two_player = players;
470
+ p1y = (COURT_TOP + COURT_BOT) / 2 - PADDLE_H / 2;
471
+ p2y = p1y;
472
+ score_p1 = 0;
473
+ score_p2 = 0;
474
+ new_record = 0;
475
+ serve_ball(0);
476
+ state = ST_PLAY;
477
+ paint_play();
478
+ music_set(ST_PLAY);
479
+ sfx(2, 0x180, 6); /* start blip */
480
+ }
481
+
482
+ /* ── GAME LOGIC (clay) — match over: result + record bookkeeping ── */
483
+ static void end_match(void) {
484
+ if (score_p1 >= WIN_SCORE && !two_player) {
485
+ ++streak;
486
+ if (streak > best_streak) {
487
+ best_streak = streak;
488
+ new_record = 1;
489
+ record_save(best_streak); /* in-session only (no save on a bare HuCard) */
490
+ }
491
+ } else if (!two_player) {
492
+ streak = 0; /* the streak dies with the loss */
493
+ }
494
+ state = ST_OVER;
495
+ prev_pad = 0xFF; /* require a fresh press on the result */
496
+ /* End-of-match whistle: two quick descending tones. */
497
+ sfx(2, 0x300, 8);
498
+ sfx(3, 0x500, 14);
499
+ paint_over();
500
+ music_set(ST_OVER);
501
+ }
502
+
503
+ /* ── GAME LOGIC (clay) — one point scored ── */
504
+ static void score_point(u8 for_p1) {
505
+ if (for_p1) ++score_p1; else ++score_p2;
506
+ sfx(3, 0x100, 8);
507
+ hud_dirty = 1;
508
+ if (score_p1 >= WIN_SCORE || score_p2 >= WIN_SCORE) end_match();
509
+ else serve_ball(for_p1); /* winner of the point receives */
510
+ }
511
+
512
+ /* ── GAME LOGIC (clay) — stage this frame's sprites ─────────────────────────
513
+ * Fixed SATB slots: 0-2 P1 paddle, 3-5 P2 paddle, 6 pulse. Paddles freeze on
514
+ * the result screen; the pulse only shows in play. Hidden slots park below the
515
+ * display at OFFSCREEN_Y. */
516
+ static void push_sprites(void) {
167
517
  u8 i;
518
+ u8 actors = (state != ST_TITLE); /* paddles show in play + result */
519
+ u8 pulse_on = (state == ST_PLAY);
520
+ for (i = 0; i < 3; i++) {
521
+ set_sprite((u8)(SLOT_P1 + i), PADDLE_X1,
522
+ actors ? (u16)(p1y + (s16)(i * 16)) : OFFSCREEN_Y,
523
+ PADDLE_PAT, PAL_P1);
524
+ set_sprite((u8)(SLOT_P2 + i), PADDLE_X2,
525
+ actors ? (u16)(p2y + (s16)(i * 16)) : OFFSCREEN_Y,
526
+ PADDLE_PAT, PAL_P2);
527
+ }
528
+ set_sprite(SLOT_PULSE, (u16)bx, pulse_on ? (u16)by : OFFSCREEN_Y,
529
+ PULSE_PAT, PAL_PULSE);
530
+ }
168
531
 
169
- _pce_keep[0] = 0;
532
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
533
+ * 2P INPUT via the TurboTap. pce_joy_read() reads pad 1 (slot 0). For pad 2 we
534
+ * read cc65's JOY_2 directly and translate it to the same clean PCE bitmask
535
+ * pce_input.c builds for pad 1. The host force-enables the TurboTap core
536
+ * option, so JOY_2 carries real port-1 input; without that override port 1 is
537
+ * dead and this would silently fall back to 1P. ── */
538
+ static u8 read_pad2(void) {
539
+ u8 raw = joy_read(JOY_2);
540
+ u8 m = 0;
541
+ if (JOY_UP(raw)) m |= PCE_JOY_UP;
542
+ if (JOY_DOWN(raw)) m |= PCE_JOY_DOWN;
543
+ if (JOY_LEFT(raw)) m |= PCE_JOY_LEFT;
544
+ if (JOY_RIGHT(raw)) m |= PCE_JOY_RIGHT;
545
+ if (JOY_BTN_1(raw)) m |= PCE_JOY_I;
546
+ if (JOY_BTN_2(raw)) m |= PCE_JOY_II;
547
+ if (JOY_BTN_3(raw)) m |= PCE_JOY_SELECT;
548
+ if (JOY_BTN_4(raw)) m |= PCE_JOY_RUN;
549
+ return m;
550
+ }
170
551
 
171
- /* palette */
172
- vce_set_color(0, PCE_RGB(0, 1, 0)); /* backdrop dark green */
173
- vce_set_color(1, PCE_RGB(0, 4, 1)); /* BG c1: court green */
174
- vce_set_color(2, PCE_RGB(7, 7, 7)); /* BG c2: white lines/net/digit */
175
- vce_set_color(256, PCE_RGB(0, 0, 0)); /* spr pal0 transparent */
176
- vce_set_color(257, PCE_RGB(7, 7, 7)); /* spr pal0 c1: white paddle */
177
- vce_set_color(272, PCE_RGB(0, 0, 0)); /* spr pal1 transparent */
178
- vce_set_color(273, PCE_RGB(7, 7, 0)); /* spr pal1 c1: yellow ball */
552
+ void main(void) {
553
+ u8 pad1, pad2, newpad;
179
554
 
180
- upload_font();
181
- make_solid_tile(tile_buf, 1); load_tiles(GREEN_VRAM, tile_buf, 16);
182
- make_solid_tile(tile_buf, 2); load_tiles(LINE_VRAM, tile_buf, 16);
183
- make_net_tile(tile_buf); load_tiles(NET_VRAM, tile_buf, 16);
184
- make_paddle_sprite();
185
- make_ball_sprite();
555
+ _pce_keep[0] = 0; /* see the EMPTY-BSS TRAP note in pce_hw.h */
186
556
 
187
- draw_court();
557
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
558
+ * Init order: palette → VRAM uploads → BAT paint → joypad → display ON.
559
+ * disp_enable() also sets the VBlank IRQ bit — without it waitvsync()
560
+ * never returns and the game freezes on its first frame. */
561
+ /* BG sub-pal 0: court (floor/rail/net/band). BG sub-pal 1: HUD/text white. */
562
+ vce_set_color(0, PCE_RGB(0, 1, 0)); /* backdrop: dark green */
563
+ vce_set_color(1, PCE_RGB(0, 4, 1)); /* court floor green */
564
+ vce_set_color(2, PCE_RGB(7, 7, 7)); /* rails / net: white */
565
+ vce_set_color(3, PCE_RGB(1, 2, 1)); /* HUD band: dark green-grey */
566
+ vce_set_color(17, PCE_RGB(7, 7, 7)); /* pal1 text: white */
567
+ /* sprite sub-palettes (256 + pal*16 + index) — P1 cyan, P2 red, pulse
568
+ * yellow, each on its own sub-palette so the paddles read as two sides. */
569
+ vce_set_color(256 + 0 * 16 + 1, PCE_RGB(2, 6, 7)); /* spr pal0 c1: P1 cyan */
570
+ vce_set_color(256 + 1 * 16 + 1, PCE_RGB(7, 1, 1)); /* spr pal1 c1: P2 red */
571
+ vce_set_color(256 + 2 * 16 + 1, PCE_RGB(7, 7, 0)); /* spr pal2 c1: pulse amber */
188
572
 
189
- p1y = 90; p2y = 90;
190
- score_p1 = 0; score_p2 = 0;
191
- sfx_timer = 0;
192
- serve_ball(0);
193
- draw_scores();
573
+ upload_art();
574
+
575
+ best_streak = record_load(); /* always 0 — no persistence on a bare HuCard */
576
+ streak = 0;
577
+ state = ST_TITLE;
578
+ paint_title();
579
+ music_set(ST_TITLE);
194
580
 
195
581
  pce_joy_init();
196
582
  disp_enable();
197
583
 
198
584
  for (;;) {
199
- u8 slot;
200
- int16_t target;
201
585
  waitvsync();
202
- psg_music_tick();
203
-
204
- /* stage sprites: P1 paddle (3 segs), P2 paddle (3 segs), ball */
205
- slot = 0;
206
- for (i = 0; i < 3; ++i)
207
- set_sprite(slot++, PADDLE_X1, (u16)(p1y + i * 16), PADDLE_VRAM >> 6, 0);
208
- for (i = 0; i < 3; ++i)
209
- set_sprite(slot++, PADDLE_X2, (u16)(p2y + i * 16), PADDLE_VRAM >> 6, 0);
210
- set_sprite(slot++, (u16)bx, (u16)by, BALL_VRAM >> 6, 1);
586
+
587
+ /* ── vblank work first: queued HUD repaint + sprites + SATB DMA ── */
588
+ if (hud_dirty) { draw_hud(); hud_dirty = 0; }
589
+ push_sprites();
211
590
  satb_dma();
212
591
 
213
- pad = pce_joy_read();
592
+ music_tick();
593
+ if (sfx_timer) {
594
+ --sfx_timer;
595
+ if (sfx_timer == 0) { psg_off(2); psg_off(3); }
596
+ }
597
+
598
+ /* ── 2P input via the TurboTap (see read_pad2's idiom note). In 2P
599
+ * versus BOTH play simultaneously, so we read BOTH pads every frame;
600
+ * on the menus only pad 1 matters. ── */
601
+ pad1 = pce_joy_read();
602
+ pad2 = (state == ST_PLAY && two_player) ? read_pad2() : 0;
603
+
604
+ if (state == ST_TITLE) {
605
+ newpad = (u8)(pad1 & ~prev_pad);
606
+ prev_pad = pad1;
607
+ if (newpad & (PCE_JOY_RUN | PCE_JOY_I)) start_match(0);
608
+ else if (newpad & PCE_JOY_II) start_match(1);
609
+ continue;
610
+ }
611
+ if (state == ST_OVER) {
612
+ newpad = (u8)(pad1 & ~prev_pad);
613
+ prev_pad = pad1;
614
+ if (newpad & (PCE_JOY_RUN | PCE_JOY_I)) {
615
+ state = ST_TITLE;
616
+ paint_title();
617
+ music_set(ST_TITLE);
618
+ }
619
+ continue;
620
+ }
214
621
 
215
- /* P1 control */
216
- if ((pad & PCE_JOY_UP) && p1y > COURT_TOP) p1y -= 4; /* playtest: 'slow overall' */
217
- if ((pad & PCE_JOY_DOWN) && p1y < COURT_BOT - PADDLE_H) p1y += 4;
622
+ /* ── ST_PLAY ────────────────────────────────────────────────────────
623
+ * tick the noise source every play frame so idle matches diverge. */
624
+ random8();
218
625
 
219
- /* P2 chase-AI */
220
- target = (int16_t)(by - PADDLE_H / 2 + BALL_SIZE / 2);
221
- if (p2y < target && p2y < COURT_BOT - PADDLE_H) p2y += 2;
222
- else if (p2y > target && p2y > COURT_TOP) p2y -= 2;
626
+ /* P1 pad 1 (port 0), UP/DOWN. */
627
+ if ((pad1 & PCE_JOY_UP) && p1y > COURT_TOP) p1y -= P1_SPEED;
628
+ if ((pad1 & PCE_JOY_DOWN) && p1y < COURT_BOT - PADDLE_H) p1y += P1_SPEED;
223
629
 
224
- if (serve_timer > 0) {
225
- serve_timer--;
630
+ if (two_player) {
631
+ /* P2 — TurboTap pad 2 (port 1), same speed: a fair versus match. */
632
+ if ((pad2 & PCE_JOY_UP) && p2y > COURT_TOP) p2y -= P1_SPEED;
633
+ if ((pad2 & PCE_JOY_DOWN) && p2y < COURT_BOT - PADDLE_H) p2y += P1_SPEED;
226
634
  } else {
227
- bx = (int16_t)(bx + bdx);
228
- by = (int16_t)(by + bdy);
229
-
230
- if (by < COURT_TOP) { by = COURT_TOP; bdy = (int8_t)(-bdy); psg_tone(1, 0x280, 18); sfx_timer = 4; }
231
- if (by + BALL_SIZE > COURT_BOT) { by = (int16_t)(COURT_BOT - BALL_SIZE); bdy = (int8_t)(-bdy); psg_tone(1, 0x280, 18); sfx_timer = 4; }
232
-
233
- /* left paddle */
234
- if (bdx < 0 && bx <= PADDLE_X1 + 12 && bx + BALL_SIZE >= PADDLE_X1 &&
235
- by + BALL_SIZE > p1y && by < p1y + PADDLE_H) {
236
- bdx = (int8_t)(-bdx);
237
- bx = PADDLE_X1 + 12;
238
- psg_tone(0, 0x200, 22); sfx_timer = 4;
239
- }
240
- /* right paddle */
241
- if (bdx > 0 && bx + BALL_SIZE >= PADDLE_X2 && bx <= PADDLE_X2 + 12 &&
242
- by + BALL_SIZE > p2y && by < p2y + PADDLE_H) {
243
- bdx = (int8_t)(-bdx);
244
- bx = (int16_t)(PADDLE_X2 - BALL_SIZE);
245
- psg_tone(0, 0x200, 22); sfx_timer = 4;
246
- }
635
+ /* CPU chases the pulse centre at a third of the player speed
636
+ * with a small dead zone. Beatable by design: a steep edge parry
637
+ * (|bdy| up to 3) outruns the CPU's 1px/frame tracking. */
638
+ s16 target = by + PULSE_SIZE / 2 - PADDLE_H / 2;
639
+ if (p2y + 2 < target && p2y < COURT_BOT - PADDLE_H) p2y += CPU_SPEED;
640
+ else if (p2y > target + 2 && p2y > COURT_TOP) p2y -= CPU_SPEED;
641
+ }
247
642
 
248
- /* scoring */
249
- if (bx < 2) { if (score_p2 < 9) score_p2++; draw_scores(); psg_tone(0, 0x100, 24); sfx_timer = 8; serve_ball(0); }
250
- if (bx > 246) { if (score_p1 < 9) score_p1++; draw_scores(); psg_tone(0, 0x100, 24); sfx_timer = 8; serve_ball(1); }
643
+ /* Pulse update (frozen during the post-point serve pause). */
644
+ if (serve_timer > 0) { --serve_timer; continue; }
645
+ bx = (s16)(bx + bdx);
646
+ by = (s16)(by + bdy);
647
+
648
+ /* Rail bounce. */
649
+ if (by < COURT_TOP) { by = COURT_TOP; bdy = (s8)(-bdy); sfx(3, 0x280, 4); }
650
+ if (by + PULSE_SIZE > COURT_BOT) { by = (s16)(COURT_BOT - PULSE_SIZE); bdy = (s8)(-bdy); sfx(3, 0x280, 4); }
651
+
652
+ /* Paddle collisions (direction-gated so the pulse can't double-hit). */
653
+ if (bdx < 0
654
+ && bx <= PADDLE_X1 + 12 && bx + PULSE_SIZE >= PADDLE_X1
655
+ && by + PULSE_SIZE > p1y && by < p1y + PADDLE_H) {
656
+ bdx = (s8)(-bdx);
657
+ bx = PADDLE_X1 + 12;
658
+ deflect(p1y);
659
+ }
660
+ if (bdx > 0
661
+ && bx + PULSE_SIZE >= PADDLE_X2 && bx <= PADDLE_X2 + 12
662
+ && by + PULSE_SIZE > p2y && by < p2y + PADDLE_H) {
663
+ bdx = (s8)(-bdx);
664
+ bx = (s16)(PADDLE_X2 - PULSE_SIZE);
665
+ deflect(p2y);
251
666
  }
252
667
 
253
- if (sfx_timer) { --sfx_timer; if (sfx_timer == 0) { psg_off(0); psg_off(1); } }
668
+ /* Off either side point. */
669
+ if (bx < 2) score_point(0); /* past P1 → right side scores */
670
+ if (bx > 246) score_point(1); /* past P2 → P1 scores */
254
671
  }
255
672
  }