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,142 +1,797 @@
1
- // ── sports.c — Commodore 64 Pong scaffold ────────────────────────────
2
- //
3
- // Two-paddle Pong using 3 hardware sprites (left paddle, right paddle,
4
- // ball). Joystick port 2 = P1 left paddle. P2 (port 1) AI tracks the ball
5
- // since port 1 conflicts with the keyboard matrix in headless mode.
1
+ /* ── sports.c — C64 head-to-head court sports (complete example game) ─────────
2
+ *
3
+ * DELTA DUEL a COMPLETE, working game (Pong lineage): a title screen with
4
+ * 1P vs a BEATABLE CPU and 2P SIMULTANEOUS VERSUS (both paddles live at once,
5
+ * P1 on CONTROL PORT 2, P2 on CONTROL PORT 1), a first-to-5 match into a
6
+ * result screen, in-session best 1P-vs-CPU win streak behind the gated
7
+ * persistence seam, 2-voice SID music with the C64's signature filter sweep +
8
+ * SFX, and the C64's signature raster-IRQ split: a fixed HUD bar over the
9
+ * court. The two paddles and the ball are VIC-II HARDWARE SPRITES.
10
+ *
11
+ * The game: two paddles guard the left and right edges of a court; a ball
12
+ * rallies between them. UP/DOWN slide your paddle; the ball deflects by where
13
+ * it strikes (centre = flat, edges = steep) plus a ±1 PRNG "spin" so no two
14
+ * rallies repeat — an idle match provably ENDS instead of looping forever.
15
+ * Miss the ball past your edge and your rival scores. First to 5 wins.
16
+ *
17
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
18
+ * very different one. The markers tell you what's what:
19
+ * HARDWARE IDIOM (load-bearing) — dodges a documented C64 footgun; reshape
20
+ * your gameplay around it (see TROUBLESHOOTING before changing).
21
+ * GAME LOGIC (clay) — court art, ball physics, CPU skill, scoring rules:
22
+ * reshape freely.
23
+ *
24
+ * What depends on what:
25
+ * c64_registers.h — VIC-II / SID / CIA symbolic addresses (header only).
26
+ * c64_sfx.{h,c} — one-shot SID sound effects on voice 2.
27
+ * The BASIC stub + crt0 come from cc65's c64 target: the .prg loads at
28
+ * $0801, a tiny BASIC line does SYS into the C runtime, and the KERNAL
29
+ * stays banked in (we lean on that for the IRQ vector — see below).
30
+ *
31
+ * Memory map this file assumes (VIC bank 0 = $0000-$3FFF):
32
+ * $0400 screen RAM (40×25 chars) $D800 color RAM (per-cell color)
33
+ * $0801 this program (code+data grow up from here)
34
+ * $3F00 sprite images (2 × 64 bytes) — NOT $0800, which collides with
35
+ * the .prg load address, and NOT $1000-$1FFF, where the VIC sees
36
+ * the character ROM instead of RAM (a classic invisible-sprite trap).
37
+ * Keep the program under ~14 KB so it stays below $3F00.
38
+ *
39
+ * Frame budget (PAL, 50fps): 3 sprites + 2 paddle AABB tests + a couple of
40
+ * HUD digits — a sliver of one frame even on the 1MHz 6510. The court is a
41
+ * STATIC field of chars painted once at match start and never touched during
42
+ * play (only the HUD digits change, and they live in the fixed bar), so the
43
+ * C64's full-repaint famine (a whole-screen 880-cell repaint freezes ~50
44
+ * frames; see the puzzle template's cell-diff note) never comes up here.
45
+ */
6
46
 
7
47
  #include "c64_registers.h"
8
48
  #include "c64_sfx.h"
9
49
  #include <stdint.h>
10
50
 
51
+ /* cc65 KERNAL disk-I/O prototypes. We DON'T #include <cbm.h> — it drags in
52
+ * <c64.h>, whose VIC/SID/JOY macros collide with this project's
53
+ * c64_registers.h (cc65 errors "macro redefinition is not identical"). These
54
+ * four are the stable cc65 ABI; declaring them directly avoids the clash. */
55
+ unsigned char __fastcall__ cbm_open(unsigned char lfn, unsigned char device,
56
+ unsigned char sec_addr, const char *name);
57
+ void __fastcall__ cbm_close(unsigned char lfn);
58
+ int __fastcall__ cbm_read(unsigned char lfn, void *buffer, unsigned int size);
59
+ int __fastcall__ cbm_write(unsigned char lfn, const void *buffer, unsigned int size);
60
+
61
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
62
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
63
+ #define GAME_TITLE "DELTA DUEL"
64
+
11
65
  #define POKE(addr, val) (*(volatile uint8_t*)(addr) = (val))
12
66
  #define PEEK(addr) (*(volatile uint8_t*)(addr))
13
67
 
14
68
  #define SCREEN ((volatile uint8_t*)0x0400)
15
69
  #define COLORS ((volatile uint8_t*)0xD800)
70
+ #define SPRITE_POINTERS ((volatile uint8_t*)0x07F8) /* last 8 bytes of screen RAM */
71
+
72
+ /* ── GAME LOGIC (clay — reshape freely) — sprite art (24×21, 3 bytes/row) ──
73
+ * Three VIC-II hardware sprites: P1 paddle, P2/CPU paddle, the ball. The
74
+ * court (rails + net + floor) is CHARACTERS in screen RAM, so it costs no
75
+ * sprite slots — leaving the other 5 VIC sprites free for your fork. */
76
+ #define SLOT_P1 0
77
+ #define SLOT_P2 1
78
+ #define SLOT_BALL 2
79
+ #define SPR_DATA(img) (0x3F00 + (img) * 64)
80
+ #define SPR_PTR(img) (uint8_t)(SPR_DATA(img) / 64) /* $3F00/64 = $FC */
81
+ #define IMG_PADDLE 0
82
+ #define IMG_BALL 1
83
+
84
+ /* A vertical bar ~6 px wide, 21 px tall — a paddle. (24×21 sprite; we light
85
+ * the middle columns so a thin paddle reads cleanly at any Y.) */
86
+ static const uint8_t paddle_sprite[64] = {
87
+ 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00,
88
+ 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00,
89
+ 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00,
90
+ 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00,
91
+ 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0,
92
+ };
93
+ static const uint8_t ball_sprite[64] = { /* a small round-ish blob */
94
+ 0,0,0, 0,0,0, 0,0,0, 0x03,0xC0,0x00, 0x0F,0xF0,0x00,
95
+ 0x1F,0xF8,0x00, 0x1F,0xF8,0x00, 0x1F,0xF8,0x00, 0x1F,0xF8,0x00,
96
+ 0x0F,0xF0,0x00, 0x03,0xC0,0x00, 0,0,0, 0,0,0,
97
+ 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,
98
+ };
99
+
100
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
101
+ * THE RASTER-IRQ SPLIT — the C64's classic "fixed status bar over a moving
102
+ * world" trick (and the gateway drug to all raster effects). Here it pins a
103
+ * HUD bar at the top while the court lives below it. The VIC-II has ONE
104
+ * $D016 fine-scroll for the whole frame; we don't scroll the court (a Pong
105
+ * arena holds still), but the split is STILL the idiomatic way to guarantee
106
+ * the HUD's first rows render in a known, fixed scroll state regardless of
107
+ * what the rest of the frame does — and it gives you the per-frame heartbeat
108
+ * the main loop paces on. Two IRQs ping-pong per frame:
109
+ *
110
+ * line 68 (inside the blank spacer row 2): assert the court's $D016
111
+ * → everything below the split renders in the court's scroll state
112
+ * line 251 (just past the text window): assert the bar's $D016
113
+ * → next frame's HUD rows render fixed; this IRQ is also the
114
+ * game's frame heartbeat (increments frame_count)
115
+ *
116
+ * The handshake, register by register:
117
+ * $D012 raster compare line (low 8 bits)
118
+ * $D011 b7 raster compare bit 8 — MUST be 0 here (both lines < 256).
119
+ * Forgetting this bit is the classic "my IRQ fires on the
120
+ * wrong line / twice" bug when lines ≥ 256 get involved.
121
+ * $D01A b0 raster IRQ enable
122
+ * $D019 IRQ latch. ACK by WRITING THE BITS BACK (write-1-to-clear).
123
+ * THE LOAD-BEARING LINE: skip the ack and the IRQ re-fires the
124
+ * instant it returns, forever — the main loop starves and the
125
+ * machine looks hung.
126
+ * $0314/15 the KERNAL's IRQ indirection. The hardware vector ($FFFE)
127
+ * points into KERNAL ROM, which saves A/X/Y and jumps through
128
+ * $0314 — so with the KERNAL banked in (cc65 default) we just
129
+ * repoint $0314. Exit via jmp $EA81 (KERNAL: restore regs +
130
+ * rti), SKIPPING $EA31's jiffy-clock/keyboard scan.
131
+ * $DC0D CIA1 interrupt control. The KERNAL leaves a 60Hz CIA timer
132
+ * IRQ running (the jiffy clock); disable it ($7F = clear all
133
+ * sources) and ack it (read $DC0D) or it shares the IRQ line
134
+ * with the raster and fires our handler at random lines.
135
+ *
136
+ * JITTER: an IRQ only starts after the current instruction finishes, so the
137
+ * handler begins 0-7 cycles late, plus the KERNAL thunk (~35 cycles) — the
138
+ * $D016 write lands one-to-two raster lines after SPLIT_LINE. We hide that
139
+ * by splitting inside a UNIFORM blank row, where shifting the (invisible)
140
+ * pixels mid-line changes nothing. Splits next to visible detail need
141
+ * cycle-exact stabilization (double-IRQ trick) — don't go there until you do.
142
+ *
143
+ * The handler is ASSEMBLY-IN-C on purpose: cc65's generated C uses shared
144
+ * zero-page scratch registers, so a C-level IRQ body would corrupt whatever
145
+ * the main loop was computing. These asm lines touch only A + the flags
146
+ * (which the KERNAL thunk already saved). requires: KERNAL banked in,
147
+ * frame_count file-scope NON-static (asm %v needs the symbol). */
148
+ #define SPLIT_LINE 68 /* inside spacer row 2 (67-74): jitter-proof */
149
+ #define BOTTOM_LINE 251 /* first line below the 25-row text window */
150
+ #define D016_BAR 0xC0 /* fine X = 0, 38-col mode for both halves */
151
+
152
+ volatile uint8_t frame_count; /* bumped by the bottom IRQ — frame heartbeat */
16
153
 
154
+ void raster_irq(void) {
155
+ asm("lda $d019"); /* read VIC IRQ latch... */
156
+ asm("sta $d019"); /* ...write it back = ACK (write-1-to-clear).
157
+ * THE line you must not lose (see above). */
158
+ asm("lda $d012"); /* which raster line woke us? (self-correcting
159
+ * dispatch — no phase variable to desync) */
160
+ asm("cmp #150");
161
+ asm("bcs %g", at_bottom); /* ≥150 → we're at BOTTOM_LINE */
162
+ /* — split point (line ~68, inside the blank spacer row) — */
163
+ asm("lda #$C0"); /* = D016_BAR — court holds still, same scroll */
164
+ asm("sta $d016");
165
+ asm("lda #251"); /* = BOTTOM_LINE (cc65's asm %b only takes */
166
+ asm("sta $d012"); /* signed bytes, so these are literals — the */
167
+ asm("jmp $ea81"); /* #if below keeps them honest) */
168
+ at_bottom:
169
+ asm("lda #$C0"); /* = D016_BAR */
170
+ asm("sta $d016"); /* bar scroll for the top of the NEXT frame */
171
+ asm("inc %v", frame_count);/* frame heartbeat for the main loop */
172
+ asm("lda #%b", SPLIT_LINE);
173
+ asm("sta $d012"); /* next stop: the split line */
174
+ asm("jmp $ea81"); /* KERNAL: pla/tay/pla/tax/pla/rti */
175
+ }
176
+ #if BOTTOM_LINE != 251 || D016_BAR != 0xC0
177
+ #error raster_irq's asm immediates are out of sync with BOTTOM_LINE / D016_BAR
178
+ #endif
179
+
180
+ static void install_raster_irq(void) {
181
+ asm("sei"); /* no IRQs while we rewire them */
182
+ POKE(CIA1_PRA + 0x0D, 0x7F); /* $DC0D: disable ALL CIA1 IRQ sources
183
+ * (kills the KERNAL jiffy/keyboard IRQ
184
+ * — we read the sticks ourselves) */
185
+ (void)PEEK(CIA1_PRA + 0x0D); /* reading $DC0D acks anything pending */
186
+ POKE(0x0314, (uint8_t)((unsigned)raster_irq & 0xFF));
187
+ POKE(0x0315, (uint8_t)((unsigned)raster_irq >> 8));
188
+ POKE(VIC_CTRL1, 0x1B); /* $D011 = power-on default: screen on,
189
+ * 25 rows, YSCROLL=3, and bit 7 (raster
190
+ * compare bit 8) = 0 — both lines < 256 */
191
+ POKE(VIC_RASTER, SPLIT_LINE); /* first stop */
192
+ POKE(VIC_IRQ_ENA, 0x01); /* raster IRQ on */
193
+ POKE(VIC_IRQ, 0xFF); /* clear any stale latch bits */
194
+ asm("cli");
195
+ }
196
+
197
+ /* Wait for the bottom IRQ's heartbeat. Replaces the usual poll-$D012 loop —
198
+ * the IRQ owns the raster now, the main loop just paces itself on it. */
199
+ static void wait_frame(void) {
200
+ uint8_t f = frame_count;
201
+ while (frame_count == f) { }
202
+ }
203
+
204
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ── reading BOTH
205
+ * joystick ports. CIA1 port A ($DC00) = control port 2, port B ($DC01) =
206
+ * control port 1. Active-low: a pressed switch reads 0, so invert and mask
207
+ * to bits 0-4 (up/down/left/right/fire).
208
+ *
209
+ * THE PORT-1 GOTCHA: $DC01 is ALSO the keyboard row register — the matrix
210
+ * hangs off the same CIA lines. Writing $FF to $DC00 first deselects every
211
+ * keyboard column, so held keys can't pull $DC01 rows low and ghost into
212
+ * the port-1 stick. That's also why "port 2 is the C64 game port": P1 lives
213
+ * there by convention, and this game puts the SECOND player on port 1.
214
+ * requires: install_raster_irq already disabled the KERNAL's keyboard scan,
215
+ * so nothing else rewrites $DC00. */
216
+ static uint8_t read_stick_port2(void) { /* player 1 */
217
+ POKE(CIA1_PRA, 0xFF);
218
+ return (uint8_t)(~PEEK(CIA1_PRA) & 0x1F);
219
+ }
220
+ static uint8_t read_stick_port1(void) { /* player 2 */
221
+ POKE(CIA1_PRA, 0xFF);
222
+ return (uint8_t)(~PEEK(CIA1_PRB) & 0x1F);
223
+ }
17
224
  #define JOY_UP 0x01
18
225
  #define JOY_DOWN 0x02
226
+ #define JOY_LEFT 0x04
227
+ #define JOY_RIGHT 0x08
228
+ #define JOY_FIRE 0x10
19
229
 
20
- #define SPRITE_POINTERS ((volatile uint8_t*)0x07F8)
230
+ /* ── HARDWARE IDIOM (load-bearing) — best-streak persistence: DISK SAVE ──────
231
+ * The C64 has no battery SRAM — the honest save medium is the FLOPPY. The game
232
+ * persists by writing a file to drive 8; VICE commits it into the live 1541
233
+ * disk image (true-drive GCR write-back), so a save survives a power cycle
234
+ * exactly as on real hardware. REQUIRES THE GAME RUN FROM A DISK (build/load a
235
+ * .d64); a bare .prg has no mounted disk, so the save is a silent no-op (still
236
+ * honest — the record just stays in-session). Implemented in the load/save
237
+ * functions below; these two are the STABLE SEAM (load at boot, save on a new
238
+ * record) — reshape the record format freely, keep the signatures.
239
+ *
240
+ * Persistence choice (same as every platform's sports template): for a VERSUS
241
+ * game a raw hi-score is meaningless (every match ends 5-x), so we persist the
242
+ * LONGEST 1P-vs-CPU WIN STREAK — the stat a returning player actually chases.
243
+ * 2P matches never touch it (humans beating each other isn't a record). */
244
+ /* ── HARDWARE IDIOM (load-bearing) — record persistence: DISK SAVE ─────────
245
+ * The C64 has no battery SRAM — the honest save medium is the FLOPPY. A game
246
+ * persists by writing a file to drive 8; VICE commits it into the live 1541
247
+ * disk image (true-drive GCR write-back), so a save survives a power cycle
248
+ * exactly as it did on real hardware. (To capture it headlessly the host does
249
+ * state({op:'exportDisk', path}); re-loading that .d64 restores the save.)
250
+ *
251
+ * REQUIRES THE GAME RUN FROM A DISK: build/package it as a .d64 and load THAT
252
+ * (loadMedia autostarts it). A bare .prg injected straight into RAM has no
253
+ * mounted disk to save to, so the save is a silent no-op — still honest (the
254
+ * value just stays in-session), it simply has nowhere to persist.
255
+ *
256
+ * We keep a 2-byte record in a SEQ file "HI" on drive 8. These are the STABLE
257
+ * SEAM: the game calls hiscore_load at boot and hiscore_save on a new record;
258
+ * reshape the record format freely, just keep the two function signatures. */
259
+ #define SAVE_NAME "@0:HI,S,W" /* @ = replace-if-exists; S=SEQ, W=write */
260
+ #define LOAD_NAME "0:HI,S,R"
21
261
 
22
- /* 8x21 tall thin paddle. */
23
- static const uint8_t paddle_sprite[64] = {
24
- 0xFF,0x00,0x00, 0xFF,0x00,0x00, 0xFF,0x00,0x00, 0xFF,0x00,0x00,
25
- 0xFF,0x00,0x00, 0xFF,0x00,0x00, 0xFF,0x00,0x00, 0xFF,0x00,0x00,
26
- 0xFF,0x00,0x00, 0xFF,0x00,0x00, 0xFF,0x00,0x00, 0xFF,0x00,0x00,
27
- 0xFF,0x00,0x00, 0xFF,0x00,0x00, 0xFF,0x00,0x00, 0xFF,0x00,0x00,
28
- 0xFF,0x00,0x00, 0xFF,0x00,0x00, 0xFF,0x00,0x00, 0xFF,0x00,0x00,
29
- 0xFF,0x00,0x00, 0,
262
+ static uint16_t hiscore_load(void) {
263
+ uint16_t v = 0;
264
+ uint8_t buf[2];
265
+ if (cbm_open(2, 8, 2, LOAD_NAME) == 0) {
266
+ if (cbm_read(2, buf, 2) == 2) v = (uint16_t)buf[0] | ((uint16_t)buf[1] << 8);
267
+ cbm_close(2);
268
+ }
269
+ return v; /* 0 if the file isn't there yet (first ever boot) */
270
+ }
271
+
272
+ static void hiscore_save(uint16_t v) {
273
+ uint8_t buf[2];
274
+ buf[0] = (uint8_t)(v & 0xFF);
275
+ buf[1] = (uint8_t)(v >> 8);
276
+ if (cbm_open(2, 8, 2, SAVE_NAME) == 0) {
277
+ cbm_write(2, buf, 2);
278
+ cbm_close(2);
279
+ }
280
+ /* No disk mounted (ran as a bare .prg) -> cbm_open fails -> silent no-op. */
281
+ }
282
+
283
+ /* ── GAME LOGIC (clay) — SID music: 2 voices + THE filter sweep ─────────────
284
+ * Voice 0 = melody (pulse), voice 1 = bass (sawtooth THROUGH THE FILTER),
285
+ * voice 2 is reserved for sound effects (c64_sfx). Each voice walks a
286
+ * (freq, frames) note table once per frame; end wraps → continuous loop.
287
+ *
288
+ * THE SID FILTER — the C64's sonic signature, and the part most "music
289
+ * drivers ported from other chips" miss. One analog-modeled filter, shared
290
+ * by all voices, four registers:
291
+ * $D415 cutoff low 3 bits $D416 cutoff high 8 bits (11-bit total)
292
+ * $D417 high nibble = resonance 0-15; low 3 bits ROUTE voices into the
293
+ * filter (bit0=voice0, bit1=voice1, bit2=voice2)
294
+ * $D418 bit4=lowpass bit5=bandpass bit6=highpass — AND master volume in
295
+ * bits 0-3. Volume and filter mode share a register: any "set
296
+ * volume" helper that writes plain $0F silently turns the filter
297
+ * OFF (c64_sfx's sfx_init does exactly that, so music_init runs
298
+ * AFTER it and re-asserts the mode bits).
299
+ * FOOTGUN: bit 7 of $D418 is "3OFF" — it MUTES voice 3 entirely.
300
+ * Set it by accident and all your sound effects vanish.
301
+ * The sweep: a triangle LFO walks the cutoff up and down each frame over
302
+ * the resonant lowpass — the bass goes from muffled to snarling and back,
303
+ * the "wah" that screams Commodore. Hear it change: that IS the chip. */
304
+ #define N_C3 0x1199u
305
+ #define N_D3 0x13EEu
306
+ #define N_E3 0x1666u
307
+ #define N_F3 0x1798u
308
+ #define N_G3 0x1AE6u
309
+ #define N_A3 0x1E78u
310
+ #define N_B3 0x2253u
311
+ #define N_C4 0x2333u
312
+ #define N_D4 0x27DDu
313
+ #define N_E4 0x2CCCu
314
+ #define N_F4 0x2F30u
315
+ #define N_G4 0x35CCu
316
+ #define N_A4 0x3CF1u
317
+ #define N_B4 0x44A7u
318
+ #define N_C5 0x4666u
319
+ #define N_D5 0x4FBAu
320
+ #define N_E5 0x5998u
321
+ #define N_REST 0u
322
+ #define STEP 9 /* frames per melodic eighth-note (~140 BPM PAL) */
323
+
324
+ typedef struct { uint16_t freq; uint8_t len; } Note;
325
+
326
+ /* The table IS the song — edit these to rescore your fork. A driving, bright
327
+ * sporting march to keep a rally tense. */
328
+ static const Note melody[] = {
329
+ { N_E4, STEP }, { N_G4, STEP }, { N_C5, STEP*2 }, { N_G4, STEP }, { N_E4, STEP*2 }, { N_G4, STEP },
330
+ { N_A4, STEP }, { N_C5, STEP }, { N_E5, STEP*2 }, { N_C5, STEP }, { N_A4, STEP*2 }, { N_REST, STEP },
331
+ { N_D4, STEP }, { N_F4, STEP }, { N_A4, STEP*2 }, { N_F4, STEP }, { N_D4, STEP*2 }, { N_A4, STEP },
332
+ { N_G4, STEP }, { N_B4, STEP }, { N_D5, STEP*2 }, { N_B4, STEP }, { N_G4, STEP*2 }, { N_REST, STEP },
333
+ { N_C5, STEP }, { N_B4, STEP }, { N_A4, STEP }, { N_G4, STEP }, { N_A4, STEP }, { N_B4, STEP }, { N_C5, STEP*2 },
334
+ { N_E4, STEP }, { N_G4, STEP }, { N_C5, STEP }, { N_E5, STEP }, { N_C5, STEP*2 }, { N_G4, STEP*2 },
30
335
  };
31
- /* 8x8 ball. */
32
- static const uint8_t ball_sprite[64] = {
33
- 0x3C,0,0, 0x7E,0,0, 0xFF,0,0, 0xFF,0,0,
34
- 0xFF,0,0, 0xFF,0,0, 0x7E,0,0, 0x3C,0,0,
35
- 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0,
36
- 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0,
37
- 0,0,0, 0,
336
+ static const Note bassline[] = {
337
+ /* Octave-pumping bass the filter sweep chews on this. */
338
+ { N_C3, STEP*3 }, { N_C4, STEP }, { N_C3, STEP*2 }, { N_G3, STEP*2 },
339
+ { N_A3, STEP*3 }, { N_E3, STEP }, { N_A3, STEP*2 }, { N_C4, STEP*2 },
340
+ { N_F3, STEP*3 }, { N_C3, STEP }, { N_F3, STEP*2 }, { N_A3, STEP*2 },
341
+ { N_G3, STEP*3 }, { N_D3, STEP }, { N_G3, STEP*2 }, { N_B3, STEP*2 },
38
342
  };
343
+ #define MELODY_LEN (sizeof(melody) / sizeof(melody[0]))
344
+ #define BASS_LEN (sizeof(bassline) / sizeof(bassline[0]))
39
345
 
40
- static void wait_vblank(void) {
41
- while (PEEK(VIC_RASTER) < 250) { }
42
- while (PEEK(VIC_RASTER) >= 250) { }
346
+ static uint8_t m_pos[2], m_left[2];
347
+ static uint16_t filter_cut; /* 11-bit cutoff, 0-2047 */
348
+ static uint8_t filter_up;
349
+
350
+ static void music_trigger(uint8_t v, uint16_t freq, uint8_t wave) {
351
+ if (freq == N_REST) {
352
+ POKE(SID_CTRL(v), wave); /* gate off: release tail plays */
353
+ return;
354
+ }
355
+ POKE(SID_FREQ_LO(v), (uint8_t)(freq & 0xFF));
356
+ POKE(SID_FREQ_HI(v), (uint8_t)(freq >> 8));
357
+ POKE(SID_CTRL(v), wave); /* gate OFF then ON — the 6581/8580 */
358
+ POKE(SID_CTRL(v), wave | SID_GATE); /* envelope only retriggers on the
359
+ * 0→1 gate edge */
43
360
  }
44
361
 
45
- /* Paint a Pong court into the 40x25 character matrix so the table reads as
46
- * a real court instead of a flat black void: a dithered green playfield
47
- * (two greens, so two colours share the screen and neither dominates),
48
- * solid top/bottom boundary rails, and a dashed centre net. Cosmetic only —
49
- * the paddle/ball sprites move over the top. */
50
- static void draw_court(void) {
51
- uint16_t i;
52
- uint8_t r, c;
53
- /* Dithered green playfield fills every cell. */
54
- for (i = 0; i < 1000; i++) {
55
- SCREEN[i] = 0xA0; /* solid block */
56
- COLORS[i] = ((i ^ (i >> 5)) & 1) ? 0x05 : 0x09; /* green / brown */
362
+ static void music_init(void) {
363
+ /* Melody: pulse at 50% duty, snappy envelope. */
364
+ POKE(SID_PW_LO(0), 0x00); POKE(SID_PW_HI(0), 0x08);
365
+ POKE(SID_AD(0), 0x07); /* attack 0, decay 7 */
366
+ POKE(SID_SR(0), 0x84); /* sustain 8, release 4 */
367
+ /* Bass: sawtooth (harmonically rich — gives the filter teeth to chew). */
368
+ POKE(SID_AD(1), 0x06);
369
+ POKE(SID_SR(1), 0xA5);
370
+ /* Filter: route VOICE 1 ONLY into it (bit 1 of $D417), resonance 13/15. */
371
+ POKE(SID_RES_FILT, 0xD2);
372
+ /* Lowpass mode + master volume 15. NOTE bits shared with volume, and bit
373
+ * 7 (3OFF) stays 0 or voice-2 sound effects go silent see block doc. */
374
+ POKE(SID_VOL_MODE, 0x1F);
375
+ filter_cut = 0x180; filter_up = 1;
376
+ m_pos[0] = m_pos[1] = 0;
377
+ m_left[0] = m_left[1] = 1; /* triggers both voices on the next update */
378
+ }
379
+
380
+ static void music_update(void) {
381
+ /* Note sequencing, one table per voice. */
382
+ if (--m_left[0] == 0) {
383
+ music_trigger(0, melody[m_pos[0]].freq, SID_PULSE);
384
+ m_left[0] = melody[m_pos[0]].len;
385
+ if (++m_pos[0] >= MELODY_LEN) m_pos[0] = 0;
57
386
  }
58
- /* Top + bottom boundary rails (rows 1 and 23) in white. */
59
- for (c = 0; c < 40; c++) {
60
- SCREEN[1 * 40 + c] = 0xA0; COLORS[1 * 40 + c] = 0x01;
61
- SCREEN[23 * 40 + c] = 0xA0; COLORS[23 * 40 + c] = 0x01;
387
+ if (--m_left[1] == 0) {
388
+ music_trigger(1, bassline[m_pos[1]].freq, SID_SAWTOOTH);
389
+ m_left[1] = bassline[m_pos[1]].len;
390
+ if (++m_pos[1] >= BASS_LEN) m_pos[1] = 0;
62
391
  }
63
- /* Dashed centre net (column 20). */
64
- for (r = 2; r < 23; r++) {
65
- if (r & 1) { SCREEN[r * 40 + 20] = 0xA0; COLORS[r * 40 + 20] = 0x01; }
392
+ /* THE FILTER SWEEP triangle LFO on the cutoff, ~10s round trip.
393
+ * 11-bit value split across two registers: low 3 bits in $D415,
394
+ * high 8 in $D416. */
395
+ if (filter_up) { filter_cut += 6; if (filter_cut >= 0x700) filter_up = 0; }
396
+ else { filter_cut -= 6; if (filter_cut <= 0x180) filter_up = 1; }
397
+ POKE(SID_FILTER_LO, (uint8_t)(filter_cut & 0x07));
398
+ POKE(SID_FILTER_HI, (uint8_t)(filter_cut >> 3));
399
+ }
400
+
401
+ /* ── GAME LOGIC (clay) — screen text. The C64 has NO VRAM port: screen RAM
402
+ * is plain memory, writable any time, mid-frame, no vblank dance. The only
403
+ * translation is ASCII → SCREEN CODES (not PETSCII!): A-Z land at 1-26;
404
+ * space through '?' (incl. digits) keep their ASCII values. ── */
405
+ static void draw_text(uint8_t row, uint8_t col, const char *s) {
406
+ uint16_t off = (uint16_t)row * 40 + col;
407
+ uint8_t ch;
408
+ while ((ch = (uint8_t)*s++) != 0) {
409
+ if (ch >= 'A' && ch <= 'Z') ch -= 64; /* A-Z → screen codes 1-26 */
410
+ SCREEN[off] = ch; /* 32-63 map straight through */
411
+ COLORS[off] = COLOR_WHITE;
412
+ ++off;
66
413
  }
67
414
  }
415
+ /* Blank the whole 40-col row, then draw `s` on it — a clean text BAND, so
416
+ * message text reads cleanly over whatever the court left behind. */
417
+ static void draw_text_band(uint8_t row, uint8_t col, const char *s) {
418
+ uint8_t c;
419
+ volatile uint8_t *p = SCREEN + (uint16_t)row * 40;
420
+ for (c = 0; c < 40; c++) p[c] = 0x20;
421
+ draw_text(row, col, s);
422
+ }
68
423
 
69
- static void copy_sprite(uint8_t slot, const uint8_t *data) {
70
- uint8_t i;
71
- volatile uint8_t *dst = (volatile uint8_t*)(0x2000 + slot * 64); /* $2000, not $0800 (collides w/ $0801 .prg) */
72
- for (i = 0; i < 64; i++) dst[i] = data[i];
424
+ /* One digit, used for the score readouts (a single 0-9). */
425
+ static void draw_digit(uint8_t row, uint8_t col, uint8_t d) {
426
+ uint16_t off = (uint16_t)row * 40 + col;
427
+ SCREEN[off] = (uint8_t)('0' + (d % 10)); /* digit screen code = ASCII */
428
+ COLORS[off] = COLOR_WHITE;
429
+ }
430
+ static void draw_u16(uint8_t row, uint8_t col, uint16_t v) {
431
+ uint8_t i, dgt[5];
432
+ uint16_t off = (uint16_t)row * 40 + col;
433
+ for (i = 0; i < 5; i++) { dgt[i] = v % 10; v /= 10; }
434
+ for (i = 0; i < 5; i++) {
435
+ SCREEN[off + i] = (uint8_t)('0' + dgt[4 - i]);
436
+ COLORS[off + i] = COLOR_WHITE;
437
+ }
438
+ }
439
+
440
+ /* ── GAME LOGIC (clay) — xorshift16 PRNG (a few instructions) ────────────────
441
+ * A versus game NEEDS this: the C64 is fully deterministic, so without a
442
+ * noise source two fixed strategies lock into an infinite rally loop (the
443
+ * exact same cycle, forever — an idle match would never end). random8() is
444
+ * ticked once per play frame, and a ±1 "spin" rides every deflection, so
445
+ * identical game states a few seconds apart diverge and the rally resolves. */
446
+ static uint16_t rng = 0xACE1;
447
+ static uint8_t random8(void) {
448
+ uint16_t r = rng;
449
+ r ^= r << 7;
450
+ r ^= r >> 9;
451
+ r ^= r << 8;
452
+ rng = r;
453
+ return (uint8_t)r;
454
+ }
455
+
456
+ /* ── GAME LOGIC (clay — reshape freely) — court geometry + match rules ───────
457
+ * The court window is char rows 3..24 (the raster split fixes rows 0-2 as the
458
+ * HUD bar). Paddles + ball are HARDWARE SPRITES positioned in VIC sprite-pixel
459
+ * coordinates, NOT char cells. VIC visible area starts at sprite X≈24, Y≈50;
460
+ * the 320×200 display spans X 24..343, Y 50..249. We keep the playfield inside
461
+ * a top/bottom margin so the ball stays under the HUD bar. */
462
+ #define COURT_TOP 84 /* sprite-Y of the top rail (under the bar) */
463
+ #define COURT_BOT 240 /* sprite-Y just below the court floor */
464
+ #define PADDLE_H 21 /* sprite is 21 px tall */
465
+ #define PADDLE_X1 40 /* P1 paddle X (left) */
466
+ #define PADDLE_X2 300 /* P2/CPU paddle X (right) */
467
+ #define BALL_LEFT 48 /* ball past here → P2 scores */
468
+ #define BALL_RIGHT 296 /* ball past here → P1 scores */
469
+ #define BALL_SZ 8 /* ball collision box */
470
+ #define WIN_SCORE 5 /* first to 5 takes the match */
471
+ #define P_SPEED 2 /* human paddle px/frame */
472
+ #define CPU_SPEED 1 /* CPU px/frame — half speed: BEATABLE */
473
+
474
+ static int16_t p1y, p2y; /* paddle top Y (sprite px) */
475
+ static int16_t bx, by; /* ball position (sprite px) */
476
+ static int8_t bdx, bdy; /* ball velocity (px/frame) */
477
+ static uint8_t score1, score2;
478
+ static uint8_t serve_timer; /* freeze frames between points */
479
+ static uint8_t two_player; /* title pick: 0 = vs CPU, 1 = 2P versus */
480
+ static uint8_t streak; /* current 1P-vs-CPU win streak (RAM) */
481
+ static uint16_t best_streak; /* record — see end_match / the seam */
482
+ static uint8_t new_record; /* result screen shows NEW RECORD */
483
+
484
+ /* Game states — the shell every example shares: title → play → result. */
485
+ #define ST_TITLE 0
486
+ #define ST_PLAY 1
487
+ #define ST_OVER 2
488
+ static uint8_t state;
489
+ static uint8_t prev0, prev1; /* edge-triggered FIRE per port */
490
+
491
+ /* ── HARDWARE IDIOM (load-bearing) — staging a sprite with the 9th X bit.
492
+ * VIC sprite X is 9 bits: low 8 in $D000+2n, bit 8 for ALL sprites packed
493
+ * into $D010. Forget $D010 and anything past X=255 wraps back to the left
494
+ * edge — the classic "my sprite teleports at two-thirds screen" bug. The
495
+ * right paddle at X=300 lives ENTIRELY past 255, so this is load-bearing
496
+ * here, not optional. We accumulate the MSB bits while staging and commit
497
+ * the byte once. ── */
498
+ static uint8_t spr_msb, spr_ena;
499
+ static void stage_begin(void) { spr_msb = 0; spr_ena = 0; }
500
+ static void stage_sprite(uint8_t slot, int16_t x, uint8_t y) {
501
+ POKE(VIC_SPRITE_X(slot), (uint8_t)(x & 0xFF));
502
+ POKE(VIC_SPRITE_Y(slot), y);
503
+ if (x > 255) spr_msb |= (uint8_t)(1 << slot);
504
+ spr_ena |= (uint8_t)(1 << slot);
505
+ }
506
+ static void stage_commit(void) {
507
+ POKE(VIC_SPRITES_X8, spr_msb);
508
+ POKE(VIC_SPR_ENA, spr_ena); /* unstaged slots vanish — no stale sprites */
509
+ }
510
+ static void stage_actors(void) {
511
+ stage_begin();
512
+ stage_sprite(SLOT_P1, PADDLE_X1, (uint8_t)p1y);
513
+ stage_sprite(SLOT_P2, PADDLE_X2, (uint8_t)p2y);
514
+ stage_sprite(SLOT_BALL, bx, (uint8_t)by);
515
+ stage_commit();
516
+ }
517
+
518
+ /* ── GAME LOGIC (clay) — the HUD bar (rows 0-2, the fixed split) ────────────
519
+ * Scores live on the bar — the ONLY thing that changes during play, and it's
520
+ * one digit each. The court chars below never change mid-match, so play frames
521
+ * touch only 3 sprite positions + (on a point) 1 digit. ── */
522
+ static void draw_bar_labels(void) {
523
+ uint8_t c;
524
+ for (c = 0; c < 40; c++) { /* row 1: solid divider line */
525
+ SCREEN[40 + c] = 0xA0; /* reverse-space block */
526
+ COLORS[40 + c] = COLOR_DARK_GRAY;
527
+ SCREEN[80 + c] = 0x20; /* row 2: the blank spacer the
528
+ * raster split hides in */
529
+ SCREEN[c] = 0x20;
530
+ }
531
+ draw_text(0, 1, "P1");
532
+ draw_text(0, 31, two_player ? "P2" : "CPU");
533
+ }
534
+ static void draw_bar_stats(void) {
535
+ draw_digit(0, 4, score1);
536
+ draw_digit(0, 35, score2);
537
+ }
538
+
539
+ /* ── GAME LOGIC (clay) — court field chars (rows 3..24). Painted ONCE per
540
+ * match start (a static screen — free to write directly), never during play.
541
+ * Top + bottom rails frame the court; a dashed net runs down the centre; a
542
+ * faint floor speckle so the arena reads as a court instead of a black void.
543
+ * (Compare the puzzle template's cell-diff: a Pong court doesn't change during
544
+ * play, so it needs NO per-frame repaint machinery at all.) ── */
545
+ #define CH_RAIL 0xA0 /* reverse-space solid block = rail */
546
+ #define CH_NET 0x5D /* vertical-bar glyph = centre net */
547
+ #define CH_DOT 0x2E /* '.' faint floor speckle */
548
+ #define CH_BLANK 0x20
549
+ #define FIELD_TOP 3
550
+ #define NET_COL 19 /* centre column of the 40-col field */
551
+
552
+ static void paint_court(void) {
553
+ uint8_t r, c;
554
+ for (r = FIELD_TOP; r < 25; r++) {
555
+ volatile uint8_t *srow = SCREEN + (uint16_t)r * 40;
556
+ volatile uint8_t *crow = COLORS + (uint16_t)r * 40;
557
+ for (c = 0; c < 40; c++) {
558
+ uint8_t ch = CH_BLANK, col = COLOR_BLACK;
559
+ if (r == FIELD_TOP || r == 24) { ch = CH_RAIL; col = COLOR_LIGHT_GRAY; }
560
+ else if (c == NET_COL) { ch = CH_NET; col = COLOR_DARK_GRAY; }
561
+ else if (((uint8_t)(c + (r << 2)) & 7) == 0) { ch = CH_DOT; col = COLOR_GREEN; }
562
+ srow[c] = ch;
563
+ crow[c] = col;
564
+ }
565
+ }
566
+ }
567
+
568
+ /* Clear the whole 25-row screen to blanks. Static-screen op — cheap once. */
569
+ static void clear_screen(void) {
570
+ uint16_t i;
571
+ for (i = 0; i < 1000; i++) { SCREEN[i] = CH_BLANK; COLORS[i] = COLOR_BLACK; }
572
+ }
573
+
574
+ /* ── GAME LOGIC (clay) — the title screen (static, free to repaint) ── */
575
+ static void paint_title(void) {
576
+ clear_screen();
577
+ two_player = 0;
578
+ draw_bar_labels();
579
+ draw_bar_stats();
580
+ draw_text_band(8, (40 - (sizeof(GAME_TITLE) - 1)) / 2, GAME_TITLE);
581
+ draw_text_band(12, 11, "PORT 2 FIRE - 1P VS CPU");
582
+ draw_text_band(14, 11, "PORT 1 FIRE - 2P VERSUS");
583
+ draw_text_band(16, 12, "UP DOWN - MOVE PADDLE");
584
+ draw_text_band(18, 13, "FIRST TO 5 WINS");
585
+ draw_text_band(21, 11, "BEST STREAK");
586
+ draw_u16(21, 23, best_streak);
587
+ POKE(VIC_SPR_ENA, 0); /* no sprites on the title */
588
+ state = ST_TITLE;
589
+ }
590
+
591
+ /* ── GAME LOGIC (clay) — serve: ball to centre, toward the chosen side ──
592
+ * bx centre = 172 (midway between BALL_LEFT 48 and BALL_RIGHT 296). The serve
593
+ * angle alternates so successive serves don't trace the same path. */
594
+ static void serve_ball(uint8_t to_left) {
595
+ bx = 172;
596
+ by = 160;
597
+ bdx = to_left ? -2 : 2;
598
+ bdy = ((score1 + score2) & 1) ? -1 : 1;
599
+ serve_timer = 30; /* ~half-second breather */
600
+ }
601
+
602
+ /* ── GAME LOGIC (clay) — start a match ── */
603
+ static void start_match(uint8_t players) {
604
+ two_player = players;
605
+ p1y = 150; p2y = 150;
606
+ score1 = 0; score2 = 0;
607
+ new_record = 0;
608
+ /* Stir the PRNG with time-spent-on-title so runs differ. */
609
+ rng ^= (uint16_t)frame_count ^ ((uint16_t)frame_count << 7);
610
+ if (rng == 0) rng = 0xACE1;
611
+ clear_screen();
612
+ draw_bar_labels();
613
+ draw_bar_stats();
614
+ paint_court();
615
+ serve_ball(0);
616
+ state = ST_PLAY;
617
+ prev0 = prev1 = 0x1F; /* swallow the FIRE that started the match */
618
+ sfx_tone(2, 0x00, 0x20, 10); /* start jingle */
619
+ }
620
+
621
+ /* ── GAME LOGIC (clay) — match over: result + record bookkeeping.
622
+ * For a VERSUS sports game a raw hi-score is meaningless (every match ends
623
+ * 5-x), so we persist the longest 1P-vs-CPU win streak — the stat a returning
624
+ * player actually chases. 2P matches never touch it. ── */
625
+ static void end_match(void) {
626
+ uint8_t p1_won = (score1 >= WIN_SCORE);
627
+ clear_screen();
628
+ draw_bar_labels();
629
+ draw_bar_stats();
630
+ if (two_player) {
631
+ draw_text_band(8, 16, p1_won ? "P1 WINS" : "P2 WINS");
632
+ } else if (p1_won) {
633
+ draw_text_band(8, 16, "YOU WIN");
634
+ ++streak;
635
+ if (streak > best_streak) {
636
+ best_streak = streak;
637
+ new_record = 1;
638
+ hiscore_save(best_streak); /* the persistence seam — see its block doc */
639
+ }
640
+ } else {
641
+ draw_text_band(8, 16, "CPU WINS");
642
+ streak = 0; /* the streak dies with the loss */
643
+ }
644
+ draw_text_band(11, 13, "P1");
645
+ draw_digit(11, 17, score1);
646
+ draw_text_band(13, 13, two_player ? "P2" : "CPU");
647
+ draw_digit(13, 18, score2);
648
+ draw_text_band(16, 11, "BEST STREAK");
649
+ draw_u16(16, 23, best_streak);
650
+ if (new_record) draw_text_band(18, 15, "NEW RECORD");
651
+ draw_text_band(21, 13, "FIRE - TITLE");
652
+ POKE(VIC_SPR_ENA, 0); /* sprites off on the result screen */
653
+ sfx_noise(24); /* end-of-match whistle */
654
+ state = ST_OVER;
655
+ prev0 = prev1 = 0x1F; /* swallow the held FIRE */
656
+ }
657
+
658
+ /* ── GAME LOGIC (clay) — one point scored ── */
659
+ static void score_point(uint8_t for_p1) {
660
+ if (for_p1) ++score1; else ++score2;
661
+ sfx_noise(6);
662
+ draw_bar_stats();
663
+ if (score1 >= WIN_SCORE || score2 >= WIN_SCORE) end_match();
664
+ else serve_ball(for_p1); /* loser of the point serves toward winner */
665
+ }
666
+
667
+ /* ── GAME LOGIC (clay) — paddle hit: deflect by where the ball struck.
668
+ * Centre = flat-ish, edges = steep. Max |bdy| is 2 — the CPU moves at 1,
669
+ * so an edge hit is exactly how a human beats it. A ±1 random "spin" on
670
+ * every return keeps rallies from repeating (see the PRNG note above). ── */
671
+ static void deflect(int16_t paddle_y) {
672
+ int16_t rel = (by + BALL_SZ / 2) - (paddle_y + PADDLE_H / 2);
673
+ bdy = (int8_t)(rel >> 4);
674
+ bdy += (int8_t)((random8() & 2) - 1); /* spin: -1 or +1 */
675
+ if (bdy > 2) bdy = 2;
676
+ if (bdy < -2) bdy = -2;
677
+ if (bdy == 0) bdy = (rel < 0) ? -1 : 1; /* never return a flat ball */
678
+ sfx_tone(2, 0x00, 0x30, 4); /* paddle ping */
679
+ }
680
+
681
+ /* ── GAME LOGIC (clay) — one player's paddle from a stick read ── */
682
+ static void move_paddle(int16_t *py, uint8_t pad) {
683
+ if ((pad & JOY_UP) && *py > COURT_TOP) *py -= P_SPEED;
684
+ if ((pad & JOY_DOWN) && *py < COURT_BOT - PADDLE_H) *py += P_SPEED;
73
685
  }
74
686
 
75
687
  void main(void) {
76
- uint8_t p1y = 130, p2y = 130;
77
- int16_t bx = 150, by = 130;
78
- int8_t bdx = 2, bdy = 1;
79
- uint8_t pad;
80
- uint8_t p1_score = 0, p2_score = 0;
81
-
82
- POKE(VIC_SPR_ENA, 0);
83
- copy_sprite(0, paddle_sprite);
84
- copy_sprite(1, paddle_sprite);
85
- copy_sprite(2, ball_sprite);
86
- SPRITE_POINTERS[0] = 0x80; /* $2000/64 */
87
- SPRITE_POINTERS[1] = 0x81;
88
- SPRITE_POINTERS[2] = 0x82;
89
- POKE(VIC_SPR_COL(0), 0x01); /* white */
90
- POKE(VIC_SPR_COL(1), 0x01);
91
- POKE(VIC_SPR_COL(2), 0x07); /* yellow ball */
92
-
93
- POKE(VIC_BORDER, 0x00); /* black border frames the court */
94
- POKE(VIC_BG0, 0x05); /* green court background */
95
- draw_court(); /* paint the textured Pong court */
96
-
97
- /* P1 paddle on left, P2 on right. */
98
- POKE(VIC_SPRITE_X(0), 30);
99
- /* P2 at X=310 — the right side of the REAL court. The old 240 sat only
100
- * ~2/3 across the screen ("computer player too close to center"): C64
101
- * sprite X is 9-bit, the extra bit lives in $D010, and the court design
102
- * had been squeezed into 8 bits. */
103
- POKE(VIC_SPRITE_X(1), 310 - 256);
688
+ uint8_t pad0, pad1;
689
+ uint8_t i;
690
+
691
+ /* ── HARDWARE IDIOM (load-bearing) — boot order. VIC + SID config before
692
+ * the IRQ goes live; sfx_init BEFORE music_init (sfx_init writes a plain
693
+ * volume to $D418, music_init re-asserts the filter-mode bits on top). ── */
694
+ POKE(VIC_SPR_ENA, 0); /* sprites off until staged */
695
+ POKE(VIC_BORDER, COLOR_BLUE); /* a coloured border keeps the
696
+ * screen visibly alive (no single
697
+ * colour dominates the pixel scan) */
698
+ POKE(VIC_BG0, COLOR_BLACK);
699
+ POKE(VIC_CTRL2, D016_BAR); /* 38-col mode from the start */
700
+ POKE(CIA1_DDRA, 0xFF); /* port A drives keyboard columns */
701
+ POKE(CIA1_DDRB, 0x00); /* port B reads rows / stick 1 */
702
+
703
+ /* Upload the two sprite images and point all three slots at them. */
704
+ {
705
+ volatile uint8_t *pd = (volatile uint8_t*)SPR_DATA(IMG_PADDLE);
706
+ volatile uint8_t *bd = (volatile uint8_t*)SPR_DATA(IMG_BALL);
707
+ for (i = 0; i < 64; i++) { pd[i] = paddle_sprite[i]; bd[i] = ball_sprite[i]; }
708
+ }
709
+ SPRITE_POINTERS[SLOT_P1] = SPR_PTR(IMG_PADDLE);
710
+ SPRITE_POINTERS[SLOT_P2] = SPR_PTR(IMG_PADDLE);
711
+ SPRITE_POINTERS[SLOT_BALL] = SPR_PTR(IMG_BALL);
712
+ POKE(VIC_SPR_COL(SLOT_P1), COLOR_CYAN);
713
+ POKE(VIC_SPR_COL(SLOT_P2), COLOR_LIGHT_RED);
714
+ POKE(VIC_SPR_COL(SLOT_BALL), COLOR_YELLOW);
104
715
 
105
716
  sfx_init();
106
- POKE(VIC_SPR_ENA, 0x07);
717
+ music_init();
718
+ best_streak = hiscore_load(); /* 0 until the core save round lands */
719
+ streak = 0;
720
+
721
+ clear_screen();
722
+ install_raster_irq(); /* the split + heartbeat go live */
723
+ paint_title();
107
724
 
108
725
  for (;;) {
109
- pad = (uint8_t)(~PEEK(CIA1_PRA) & 0x1F);
110
- wait_vblank();
726
+ wait_frame(); /* the line-251 IRQ paces everything */
727
+
728
+ music_update();
111
729
  sfx_update();
730
+ pad0 = read_stick_port2(); /* P1 — control port 2 (convention) */
731
+ pad1 = read_stick_port1(); /* P2 — control port 1 */
732
+
733
+ if (state == ST_TITLE) {
734
+ /* Mode select doubles as a controls demo: the stick that presses FIRE
735
+ * picks the mode — port 2 starts 1P vs CPU, port 1 starts 2P versus. */
736
+ if ((pad0 & JOY_FIRE) && !(prev0 & JOY_FIRE)) start_match(0);
737
+ else if ((pad1 & JOY_FIRE) && !(prev1 & JOY_FIRE)) start_match(1);
738
+ prev0 = pad0; prev1 = pad1;
739
+ continue;
740
+ }
112
741
 
113
- if (pad & JOY_UP && p1y > 60) p1y -= 3;
114
- if (pad & JOY_DOWN && p1y < 220) p1y += 3;
742
+ if (state == ST_OVER) {
743
+ if (((pad0 & JOY_FIRE) && !(prev0 & JOY_FIRE)) ||
744
+ ((pad1 & JOY_FIRE) && !(prev1 & JOY_FIRE))) paint_title();
745
+ prev0 = pad0; prev1 = pad1;
746
+ continue;
747
+ }
115
748
 
116
- /* AI right paddle tracks ball. */
117
- if (p2y + 10 < by && p2y < 220) p2y += 2;
118
- else if (p2y + 10 > by && p2y > 60) p2y -= 2;
749
+ /* ── ST_PLAY ─────────────────────────────────────────────────────────
750
+ * Both paddles update EVERY frame: P1 from port 2, and either P2 from
751
+ * port 1 (2P) or the CPU (1P). Simultaneous versus, never alternating. */
752
+ random8(); /* tick the noise source per frame */
753
+ move_paddle(&p1y, pad0);
119
754
 
755
+ if (two_player) {
756
+ move_paddle(&p2y, pad1); /* P2 — control port 1 */
757
+ } else {
758
+ /* CPU — chases the ball centre at half player speed with a dead zone.
759
+ * Beatable by design: a steep edge-deflection outruns it. */
760
+ int16_t target = by + BALL_SZ / 2 - PADDLE_H / 2;
761
+ if (p2y + 2 < target && p2y < COURT_BOT - PADDLE_H) p2y += CPU_SPEED;
762
+ else if (p2y > target + 2 && p2y > COURT_TOP) p2y -= CPU_SPEED;
763
+ }
764
+ prev0 = pad0; prev1 = pad1;
765
+
766
+ /* Ball update (frozen during the post-point serve pause). */
767
+ if (serve_timer > 0) { --serve_timer; stage_actors(); continue; }
120
768
  bx += bdx;
121
769
  by += bdy;
122
- if (by < 55) { by = 55; bdy = -bdy; sfx_tone(2, 0x80, 0x18, 2); }
123
- if (by > 240) { by = 240; bdy = -bdy; sfx_tone(2, 0x80, 0x18, 2); }
124
- /* Paddle 1 collision */
125
- if (bdx < 0 && bx < 40 && bx > 24 && by > p1y - 8 && by < p1y + 22) {
126
- bdx = -bdx; sfx_tone(0, 0x40, 0x20, 3);
770
+
771
+ /* Rail bounce (top/bottom of the court). */
772
+ if (by < COURT_TOP) { by = COURT_TOP; bdy = -bdy; sfx_tone(2, 0x00, 0x20, 3); }
773
+ if (by + BALL_SZ > COURT_BOT) { by = COURT_BOT - BALL_SZ; bdy = -bdy; sfx_tone(2, 0x00, 0x20, 3); }
774
+
775
+ /* Paddle collisions (direction-gated so the ball can't double-hit). */
776
+ if (bdx < 0
777
+ && bx <= PADDLE_X1 + 8 && bx + BALL_SZ >= PADDLE_X1
778
+ && by + BALL_SZ > p1y && by < p1y + PADDLE_H) {
779
+ bdx = -bdx;
780
+ bx = PADDLE_X1 + 8;
781
+ deflect(p1y);
127
782
  }
128
- /* Paddle 2 collision */
129
- if (bdx > 0 && bx > 296 && bx < 314 && by > p2y - 8 && by < p2y + 22) {
130
- bdx = -bdx; sfx_tone(0, 0x40, 0x20, 3);
783
+ if (bdx > 0
784
+ && bx + BALL_SZ >= PADDLE_X2 && bx <= PADDLE_X2 + 8
785
+ && by + BALL_SZ > p2y && by < p2y + PADDLE_H) {
786
+ bdx = -bdx;
787
+ bx = PADDLE_X2 - BALL_SZ;
788
+ deflect(p2y);
131
789
  }
132
- /* Score */
133
- if (bx < 5) { p2_score++; if (p2_score > 9) p2_score = 0; sfx_noise(20); bx = 170; by = 130; bdx = 2; }
134
- if (bx > 330) { p1_score++; if (p1_score > 9) p1_score = 0; sfx_tone(0, 0x80, 0x10, 16); bx = 170; by = 130; bdx = -2; }
135
-
136
- POKE(VIC_SPRITE_Y(0), p1y);
137
- POKE(VIC_SPRITE_Y(1), p2y);
138
- POKE(VIC_SPRITE_X(2), (uint8_t)bx);
139
- POKE(VIC_SPRITES_X8, (uint8_t)(0x02 | ((bx > 255) ? 0x04 : 0x00)));
140
- POKE(VIC_SPRITE_Y(2), (uint8_t)by);
790
+
791
+ /* Off either side point. (score_point may end the match.) */
792
+ if (bx < BALL_LEFT) { score_point(0); if (state != ST_PLAY) continue; }
793
+ if (bx > BALL_RIGHT) { score_point(1); if (state != ST_PLAY) continue; }
794
+
795
+ stage_actors(); /* commit the 3 sprite positions */
141
796
  }
142
797
  }