romdevtools 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/AGENTS.md +51 -41
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +3 -3
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1225 -332
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +906 -275
  8. package/examples/atari2600/templates/shmup.asm +1031 -239
  9. package/examples/atari2600/templates/sports.asm +1135 -253
  10. package/examples/atari7800/templates/platformer.c +991 -156
  11. package/examples/atari7800/templates/puzzle.c +1091 -148
  12. package/examples/atari7800/templates/racing.c +952 -124
  13. package/examples/atari7800/templates/shmup.c +812 -134
  14. package/examples/atari7800/templates/sports.c +820 -184
  15. package/examples/c64/templates/platformer.c +879 -164
  16. package/examples/c64/templates/puzzle.c +855 -178
  17. package/examples/c64/templates/racing.c +873 -97
  18. package/examples/c64/templates/shmup.c +757 -161
  19. package/examples/c64/templates/sports.c +755 -100
  20. package/examples/gb/templates/platformer.c +841 -179
  21. package/examples/gb/templates/puzzle.c +986 -246
  22. package/examples/gb/templates/racing.c +754 -174
  23. package/examples/gb/templates/shmup.c +673 -175
  24. package/examples/gb/templates/sports.c +790 -159
  25. package/examples/gba/templates/platformer.c +626 -165
  26. package/examples/gba/templates/puzzle.c +519 -269
  27. package/examples/gba/templates/racing.c +511 -206
  28. package/examples/gba/templates/shmup.c +564 -179
  29. package/examples/gba/templates/sports.c +454 -174
  30. package/examples/gbc/templates/platformer.c +944 -180
  31. package/examples/gbc/templates/puzzle.c +363 -109
  32. package/examples/gbc/templates/racing.c +884 -180
  33. package/examples/gbc/templates/shmup.c +821 -185
  34. package/examples/gbc/templates/sports.c +870 -162
  35. package/examples/genesis/templates/platformer.c +747 -129
  36. package/examples/genesis/templates/puzzle.c +694 -261
  37. package/examples/genesis/templates/racing.c +726 -203
  38. package/examples/genesis/templates/shmup.c +535 -142
  39. package/examples/genesis/templates/sports.c +495 -158
  40. package/examples/gg/templates/platformer.c +880 -215
  41. package/examples/gg/templates/puzzle.c +875 -216
  42. package/examples/gg/templates/racing.c +915 -172
  43. package/examples/gg/templates/shmup.c +714 -191
  44. package/examples/gg/templates/sports.c +732 -129
  45. package/examples/lynx/templates/platformer.c +604 -69
  46. package/examples/lynx/templates/puzzle.c +498 -158
  47. package/examples/lynx/templates/racing.c +538 -102
  48. package/examples/lynx/templates/shmup.c +458 -131
  49. package/examples/lynx/templates/sports.c +496 -72
  50. package/examples/msx/platformer/main.c +649 -162
  51. package/examples/msx/puzzle/main.c +742 -240
  52. package/examples/msx/racing/main.c +669 -178
  53. package/examples/msx/shmup/main.c +460 -178
  54. package/examples/msx/sports/main.c +592 -126
  55. package/examples/nes/templates/platformer.c +589 -171
  56. package/examples/nes/templates/puzzle.c +563 -242
  57. package/examples/nes/templates/racing.c +502 -208
  58. package/examples/nes/templates/shmup.c +339 -145
  59. package/examples/nes/templates/sports.c +341 -183
  60. package/examples/pce/platformer/main.c +874 -205
  61. package/examples/pce/puzzle/main.c +802 -287
  62. package/examples/pce/racing/main.c +783 -208
  63. package/examples/pce/shmup/main.c +638 -212
  64. package/examples/pce/sports/main.c +586 -169
  65. package/examples/porting-across-platforms/README.md +1 -1
  66. package/examples/sms/templates/platformer.c +762 -177
  67. package/examples/sms/templates/puzzle.c +752 -212
  68. package/examples/sms/templates/racing.c +808 -145
  69. package/examples/sms/templates/shmup.c +599 -162
  70. package/examples/sms/templates/sports.c +630 -122
  71. package/examples/snes/templates/music_demo.c +7 -0
  72. package/examples/snes/templates/platformer-data.asm +123 -24
  73. package/examples/snes/templates/platformer-hdr.asm +57 -0
  74. package/examples/snes/templates/platformer.c +586 -165
  75. package/examples/snes/templates/puzzle-data.asm +116 -21
  76. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  77. package/examples/snes/templates/puzzle.c +614 -235
  78. package/examples/snes/templates/racing-data.asm +390 -32
  79. package/examples/snes/templates/racing-hdr.asm +57 -0
  80. package/examples/snes/templates/racing.c +807 -196
  81. package/examples/snes/templates/shmup-data.asm +87 -29
  82. package/examples/snes/templates/shmup-hdr.asm +57 -0
  83. package/examples/snes/templates/shmup.c +459 -198
  84. package/examples/snes/templates/sports-data.asm +48 -2
  85. package/examples/snes/templates/sports-hdr.asm +57 -0
  86. package/examples/snes/templates/sports.c +414 -163
  87. package/package.json +1 -1
  88. package/src/host/LibretroHost.js +59 -1
  89. package/src/http/tool-registry.js +11 -11
  90. package/src/mcp/tools/cheats.js +2 -1
  91. package/src/mcp/tools/frame.js +3 -2
  92. package/src/mcp/tools/index.js +3 -3
  93. package/src/mcp/tools/input.js +5 -4
  94. package/src/mcp/tools/lifecycle.js +6 -4
  95. package/src/mcp/tools/platform-docs.js +1 -1
  96. package/src/mcp/tools/preview-tile.js +6 -2
  97. package/src/mcp/tools/project.js +1098 -130
  98. package/src/mcp/tools/rom-id.js +5 -1
  99. package/src/mcp/tools/run-until.js +4 -2
  100. package/src/mcp/tools/snippets.js +6 -6
  101. package/src/mcp/tools/sprite-pipeline.js +14 -2
  102. package/src/mcp/tools/state.js +2 -1
  103. package/src/mcp/tools/tile-inspect.js +8 -1
  104. package/src/mcp/tools/toolchain.js +12 -1
  105. package/src/mcp/tools/watch-memory.js +4 -3
  106. package/src/observer/bus.js +73 -0
  107. package/src/observer/livestream.html +4 -2
  108. package/src/observer/tool-wrap.js +17 -14
  109. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  110. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  111. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  112. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  113. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  114. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  115. package/src/platforms/gb/lib/c/README.md +10 -11
  116. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  117. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  118. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  119. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  120. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  121. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  122. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  123. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  124. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  125. package/src/platforms/gbc/lib/c/README.md +10 -11
  126. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  127. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  128. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  129. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  130. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  131. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  132. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  133. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  134. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  135. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  136. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  137. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  138. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  139. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  140. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  141. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  142. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  143. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  144. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  145. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  146. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  147. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  148. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  149. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  150. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  151. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  152. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  153. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  154. package/src/toolchains/index.js +27 -11
@@ -1,7 +1,52 @@
1
- // ── sports.c — Atari Lynx Pong vs AI ─────────────────────────────────
2
- //
3
- // Lynx is a handheld with one controller. Right paddle = AI tracking
4
- // the ball.
1
+ /* ── sports.c — Atari Lynx 1P-vs-CPU court game (complete example game) ───────
2
+ *
3
+ * A COMPLETE, working game PULSE PARRY, a head-to-head court game (Pong
4
+ * lineage) fit to the Lynx's tiny 160x102 screen: title screen, 1P vs a
5
+ * beatable CPU, first-to-N match flow with a result screen, in-session
6
+ * record, MIKEY music + SFX, AND the Lynx's signature party trick:
7
+ * HARDWARE SPRITE SCALING. The ball is a Suzy scalable sprite that GROWS
8
+ * with its speed (a fast volley looms larger), and the result screen does a
9
+ * SCALE POP — a winner glyph swells then eases back — both pure-hardware
10
+ * "juice" that costs zero CPU pixel work.
11
+ *
12
+ * The game: you are the LEFT paddle; a CPU works the RIGHT. UP/DOWN move you.
13
+ * The ball rallies between you; the angle you return it at depends on where it
14
+ * strikes your paddle (centre = flat, edges = steep), and a ±1 PRNG "spin" on
15
+ * every return guarantees no rally loops forever. First side to WIN_SCORE
16
+ * takes the match → a result screen → back to the title.
17
+ *
18
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
19
+ * very different one. The markers tell you what's what:
20
+ * HARDWARE IDIOM (load-bearing) — dodges a documented Lynx footgun;
21
+ * reshape your gameplay around it (see TROUBLESHOOTING before changing).
22
+ * GAME LOGIC (clay) — court art, ball physics, CPU skill, scoring rules:
23
+ * reshape freely.
24
+ *
25
+ * What depends on what:
26
+ * lynx_sfx.{h,c} — MIKEY 4-voice audio (voice 0 = paddle/score SFX, voice 1 =
27
+ * background melody, voice 2 = wall/whistle SFX, voice 3 = noise/miss).
28
+ * vendor/cc65/libsrc/lynx/ — the FULL cc65 Lynx driver source shipped into
29
+ * your project. The TGI driver (tgi/lynx-160-102-16.s) is REQUIRED
30
+ * reading when graphics misbehave: every TGI call is itself a Suzy
31
+ * sprite, and our scaled ball + result pop ride the same engine via
32
+ * tgi_ioctl(0).
33
+ *
34
+ * NO HARDWARE TILEMAP (read this — it is the platform's biggest "where's the
35
+ * court renderer?" surprise): the Lynx has NO background tilemap. Suzy is a
36
+ * SPRITE BLITTER, not a tile engine. So the court is drawn the honest way:
37
+ * the full-redraw TGI loop repaints the whole arena every frame as a stack
38
+ * of tgi_bar fills + tgi_line markings — cheap on a 160x102 screen. The
39
+ * paddles are flat bars; the ball is a Suzy SCALABLE sprite on top.
40
+ *
41
+ * PLAYERS: 1. This is a handheld — head-to-head on real hardware is ComLynx,
42
+ * a cable between TWO physical Lynx units. A single emulator instance has
43
+ * nobody on the other end of the cable, so this example is honestly 1P vs a
44
+ * CPU opponent (no fake "P2 VERSUS" that could never work here — contrast
45
+ * the NES sports donor, which has a real simultaneous-2P mode).
46
+ *
47
+ * SCREEN: 160x102. The system font is 8x8, so a full row of text is 20
48
+ * characters — the court + HUD are kept compact to fit.
49
+ */
5
50
 
6
51
  #include <tgi.h>
7
52
  #include <joystick.h>
@@ -9,87 +54,466 @@
9
54
  #include <stdint.h>
10
55
  #include "lynx_sfx.h"
11
56
 
12
- #define PADDLE_H 16
13
- #define PADDLE_W 3
14
- #define BALL_SIZE 4
15
- #define COURT_TOP 4
16
- #define COURT_BOT 96
17
- #define PADDLE_X1 6
18
- #define PADDLE_X2 (160 - 6 - PADDLE_W)
57
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
58
+ * name here automatically. Keep it <=16 chars of A-Z 0-9 space dash. */
59
+ #define GAME_TITLE "PULSE PARRY"
60
+
61
+ /* ── GAME LOGIC (clay — reshape freely) — court geometry (fits 160x102) ──────
62
+ * A full-width court with a slim HUD row across the top. COURT_TOP/BOT bound
63
+ * the ball vertically; the paddles ride the left/right edges. */
64
+ #define COURT_TOP 12 /* first playable pixel row */
65
+ #define COURT_BOT 100 /* first pixel row of the bottom rail */
66
+ #define PADDLE_H 20 /* paddle height in px (compact court) */
67
+ #define PADDLE_W 3
68
+ #define PADDLE_X1 5 /* you — left side */
69
+ #define PADDLE_X2 (159 - 5 - PADDLE_W) /* CPU — right side */
70
+ #define BALL_W 6 /* nominal ball footprint (1.0x sprite) */
71
+ #define WIN_SCORE 5 /* first to 5 takes the match */
72
+
73
+ /* Game states — the shell every example shares: title → play → result. */
74
+ #define ST_TITLE 0
75
+ #define ST_PLAY 1
76
+ #define ST_OVER 2
77
+ static uint8_t state;
78
+
79
+ /* ── GAME LOGIC (clay) — match state ── */
80
+ static int16_t p1y, p2y; /* paddle top Y */
81
+ static int16_t bx, by; /* ball top-left */
82
+ static int8_t bdx, bdy; /* ball velocity (px/frame) */
83
+ static uint8_t score_p1, score_p2;
84
+ static uint8_t serve_timer; /* freeze frames between points */
85
+ static uint8_t streak; /* current win streak vs CPU (this run) */
86
+ static uint8_t best_streak; /* in-session record — see end_match() */
87
+ static uint8_t new_record; /* result screen shows NEW RECORD */
88
+ static uint8_t p1_won; /* who took the match (result screen) */
89
+ static uint8_t prev_joy;
90
+
91
+ /* The result SCALE POP: when >0 the winner glyph draws swollen for a few
92
+ * frames (the SCALING signature), counting back down to the resting 1.0x. */
93
+ static uint8_t pop_timer;
94
+ #define POP_FRAMES 10
95
+
96
+ /* ── GAME LOGIC (clay) — xorshift16 PRNG (~tens of cycles per call).
97
+ * A versus game NEEDS this: the Lynx is fully deterministic, so without a
98
+ * noise source two fixed strategies lock into an infinite rally loop (the
99
+ * exact same cycle, forever). rand8() is ticked once per play frame so
100
+ * identical game states a few seconds apart still diverge. */
101
+ static uint16_t rng = 0xC0A7;
102
+ static uint8_t rand8(void) {
103
+ uint16_t r = rng;
104
+ r ^= r << 7;
105
+ r ^= r >> 9;
106
+ r ^= r << 8;
107
+ rng = r;
108
+ return (uint8_t)r;
109
+ }
110
+
111
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
112
+ * SUZY HARDWARE SPRITE SCALING — the Lynx signature. Suzy renders every
113
+ * sprite through a Sprite Control Block (SCB) it walks in cart/work RAM.
114
+ * Two SCB fields, HSIZE and VSIZE, are 8.8 fixed-point scale factors
115
+ * ($0100 = 1.0): the SAME 8x8 source pixels render at any size, every frame,
116
+ * for free. This game uses it two ways:
117
+ * - the BALL is a Suzy sprite whose 8.8 scale tracks its SPEED — a slow
118
+ * serve is a small dot, a fast volley looms larger — recomputed every
119
+ * frame, zero CPU pixel cost (Suzy scales while it blits);
120
+ * - the RESULT POP — for POP_FRAMES after a match ends, the winner glyph is
121
+ * redrawn at >1.0x then eased back to 1.0x, a pure-hardware "juice" flash.
122
+ *
123
+ * The SCB, field by field (this is cc65's SCB_REHV_PAL from <_suzy.h>):
124
+ * sprctl0 bits 7-6 = bits per pixel (11 = 4bpp), bits 2-0 = sprite TYPE.
125
+ * TYPE_NORMAL (4) draws pens 1-15 and treats pen 0 as
126
+ * TRANSPARENT — that's how the round ball sits over the court.
127
+ * sprctl1 bit 7 LITERAL (raw nybbles, no RLE) + bits 5-4 reload depth:
128
+ * REHV means "this SCB carries HPOS, VPOS, HSIZE, VSIZE". The
129
+ * reload bits ARE the struct layout — mismatch them and Suzy reads
130
+ * palette bytes as size words.
131
+ * sprcoll $20 = NO_COLLIDE. Ball/paddle collision is done in C on the court
132
+ * coordinates (the collision buffer knows nothing about gameplay).
133
+ * next pointer to the next SCB, 0 = end of chain (one blit per call).
134
+ * data sprite pixel data (LITERAL 4bpp format below).
135
+ * hpos/vpos signed SCREEN position of the sprite's top-left corner.
136
+ * hsize/vsize 8.8 scale — THE party trick, rewritten per draw.
137
+ * penpal[8] 16 nybbles mapping pixel values 0-15 → palette pens. We RECOLOUR
138
+ * the sprite per draw here (one 8x8 art block, any pen) by pointing
139
+ * the art's pixel value 1 at the wanted pen — no extra art.
140
+ *
141
+ * LITERAL 4bpp data format (hand-encodable): each sprite LINE is
142
+ * [offset byte][width/2 bytes of raw nybble pixels]
143
+ * where offset = 1 + bytes of pixel data; a final offset of 0 ends the sprite.
144
+ * 8 px @ 4bpp = 4 data bytes, so every line starts with 5.
145
+ *
146
+ * Drawing: tgi_sprite(&scb) → tgi_ioctl(0, &scb) — the TGI driver's
147
+ * documented escape hatch (see CONTROL in vendor/cc65/libsrc/lynx/tgi/
148
+ * lynx-160-102-16.s). It points Suzy's SCBNEXT at your SCB, aims VIDBAS at
149
+ * TGI's current DRAW page (so scaled sprites land in the same double-buffered
150
+ * frame as tgi_bar/tgi_outtextxy), fires SPRGO, and sleeps the CPU until
151
+ * SPRSYS reports the blit done.
152
+ *
153
+ * Requires: the cc65 crt0 Suzy init (already done before main()), and calls
154
+ * only between the tgi_busy() wait and tgi_updatedisplay() — i.e. while
155
+ * TGI's draw buffer is the blit target. Draw order = paint order: court
156
+ * fills first, scaled ball/glyph after, HUD text last.
157
+ */
158
+ static SCB_REHV_PAL scb = {
159
+ BPP_4 | TYPE_NORMAL, /* sprctl0: 4bpp, pen 0 transparent */
160
+ LITERAL | REHV, /* sprctl1: literal data, HV+size SCB */
161
+ 0x20, /* sprcoll: NO_COLLIDE */
162
+ 0, /* next: single-SCB chain */
163
+ 0, /* data: set per draw */
164
+ 0, 0, /* hpos, vpos */
165
+ 0x0100, 0x0100, /* hsize, vsize (8.8) */
166
+ { 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF } /* identity pens */
167
+ };
168
+
169
+ /* ── GAME LOGIC (clay) — 8x8 4bpp literal sprite art ────────────────────────
170
+ * A round ball in pixel value 1 (plus value $F = white glint). draw_sprite()
171
+ * recolours value 1 → the wanted pen via the SCB penpal, so one art block
172
+ * paints any colour. Each line: 5, then 4 nybble bytes; a final 0 byte ends
173
+ * the sprite. */
174
+ static unsigned char spr_ball[] = {
175
+ 5, 0x00, 0x11, 0x10, 0x00, /* . . 1 1 1 . . . round ball body */
176
+ 5, 0x01, 0x1F, 0xF1, 0x10, /* . 1 1 F F 1 1 . (white glint) */
177
+ 5, 0x11, 0x11, 0x11, 0x11, /* 1 1 1 1 1 1 1 1 */
178
+ 5, 0x11, 0x11, 0x11, 0x11, /* 1 1 1 1 1 1 1 1 */
179
+ 5, 0x11, 0x11, 0x11, 0x11, /* 1 1 1 1 1 1 1 1 */
180
+ 5, 0x11, 0x11, 0x11, 0x11, /* 1 1 1 1 1 1 1 1 */
181
+ 5, 0x01, 0x11, 0x11, 0x10, /* . 1 1 1 1 1 1 . */
182
+ 5, 0x00, 0x11, 0x10, 0x00, /* . . 1 1 1 . . . */
183
+ 0
184
+ };
185
+ /* A chunky trophy/cup glyph for the result pop (pixel value 1 = body). */
186
+ static unsigned char spr_cup[] = {
187
+ 5, 0x01, 0x11, 0x11, 0x10, /* . 1 1 1 1 1 1 . cup bowl */
188
+ 5, 0x01, 0x11, 0x11, 0x10, /* . 1 1 1 1 1 1 . */
189
+ 5, 0x01, 0x11, 0x11, 0x10, /* . 1 1 1 1 1 1 . */
190
+ 5, 0x00, 0x11, 0x11, 0x00, /* . . 1 1 1 1 . . taper */
191
+ 5, 0x00, 0x01, 0x10, 0x00, /* . . . 1 1 . . . stem */
192
+ 5, 0x00, 0x01, 0x10, 0x00, /* . . . 1 1 . . . */
193
+ 5, 0x00, 0x11, 0x11, 0x00, /* . . 1 1 1 1 . . base */
194
+ 5, 0x01, 0x11, 0x11, 0x10, /* . 1 1 1 1 1 1 . */
195
+ 0
196
+ };
197
+
198
+ /* Draw an 8x8 literal sprite CENTERED on (cx,cy) at the given 8.8 scale,
199
+ * recoloured so art pixel value 1 paints `pen`. Centering matters: hpos/vpos
200
+ * are the TOP-LEFT, so a sprite scaled around its corner would slide as it
201
+ * grows — anchoring the centre keeps a growing ball reading as "coming at
202
+ * you", and the result pop as a uniform swell. */
203
+ static void draw_sprite(unsigned char *data, int cx, int cy, uint8_t pen, unsigned scale) {
204
+ unsigned w = (8u * scale) >> 8;
205
+ if (w == 0) w = 1;
206
+ scb.penpal[0] = (uint8_t)((0u << 4) | pen); /* val0=transparent, val1=pen */
207
+ scb.data = data;
208
+ scb.hsize = scale;
209
+ scb.vsize = scale;
210
+ scb.hpos = cx - (int)(w >> 1);
211
+ scb.vpos = cy - (int)(w >> 1);
212
+ tgi_sprite(&scb);
213
+ }
214
+
215
+ /* Ball scale tracks its speed: |bdx|+|bdy| (1..~5) maps onto 0.75x..1.6x.
216
+ * A faster volley genuinely looms larger — the HARDWARE scale is the speed
217
+ * read-out, not a decoration. */
218
+ static unsigned ball_scale(void) {
219
+ unsigned spd = (unsigned)((bdx < 0 ? -bdx : bdx) + (bdy < 0 ? -bdy : bdy));
220
+ if (spd > 6) spd = 6;
221
+ return 0x00C0u + spd * 0x0028u; /* 0.75x + 0.156x per speed unit */
222
+ }
223
+
224
+ /* Current result-pop scale: 1.0x at rest, swelling to ~2.0x at the peak and
225
+ * easing back. POP drives the SCALING idiom on the result screen. */
226
+ #define POP_SCALE_PEAK 0x0200u /* 2.0x */
227
+ static unsigned pop_scale(void) {
228
+ if (pop_timer == 0) return 0x0100u;
229
+ return 0x0100u + ((unsigned)pop_timer * (POP_SCALE_PEAK - 0x0100u)) / POP_FRAMES;
230
+ }
231
+
232
+ /* ── GAME LOGIC (clay) — score text (no sprintf: it drags in ~6KB) ── */
233
+ static char numbuf[6];
234
+ static char *fmt5(unsigned v) {
235
+ uint8_t i;
236
+ for (i = 0; i < 5; i++) { numbuf[4 - i] = (char)('0' + v % 10); v /= 10; }
237
+ numbuf[5] = 0;
238
+ return numbuf;
239
+ }
240
+
241
+ /* ── GAME LOGIC (clay) — serve: ball to centre, toward the chosen side ── */
242
+ static void serve_ball(uint8_t to_left) {
243
+ bx = 78;
244
+ by = 48;
245
+ bdx = to_left ? -2 : 2;
246
+ bdy = ((score_p1 + score_p2) & 1) ? -1 : 1; /* alternate the angle */
247
+ serve_timer = 30; /* half-second breather */
248
+ }
249
+
250
+ /* ── GAME LOGIC (clay) — paint the court (full redraw, every frame) ──────────
251
+ * No hardware tilemap, so the arena is bars + lines: grass fill, end zones,
252
+ * top/bottom rails, the white boundary + dashed centre net + centre circle.
253
+ * Layered tones keep any one colour comfortably under the render-health blank
254
+ * threshold (>=92% one colour reads as "blank"). */
255
+ static void draw_court(void) {
256
+ int16_t ny;
257
+ tgi_setcolor(COLOR_GREEN);
258
+ tgi_bar(0, 0, 159, 101); /* court grass */
259
+ tgi_setcolor(COLOR_LIGHTGREEN);
260
+ tgi_bar(0, COURT_TOP, 50, COURT_BOT - 1); /* left end zone */
261
+ tgi_bar(109, COURT_TOP, 159, COURT_BOT - 1); /* right end zone */
262
+ tgi_setcolor(COLOR_DARKGREY);
263
+ tgi_bar(0, 0, 159, COURT_TOP - 1); /* top HUD/rail band */
264
+ tgi_bar(0, COURT_BOT, 159, 101); /* bottom rail */
265
+ tgi_setcolor(COLOR_WHITE);
266
+ tgi_line(0, COURT_TOP, 159, COURT_TOP);
267
+ tgi_line(0, COURT_BOT, 159, COURT_BOT);
268
+ for (ny = COURT_TOP; ny < COURT_BOT; ny += 8)
269
+ tgi_bar(79, (unsigned)ny, 80,
270
+ (unsigned)(ny + 3 > COURT_BOT ? COURT_BOT : ny + 3)); /* net */
271
+ tgi_line(70, 46, 90, 46);
272
+ tgi_line(70, 66, 90, 66);
273
+ tgi_line(70, 46, 70, 66);
274
+ tgi_line(90, 46, 90, 66);
275
+ }
276
+
277
+ /* Draw the two paddles. */
278
+ static void draw_paddles(void) {
279
+ tgi_setcolor(COLOR_YELLOW);
280
+ tgi_bar(PADDLE_X1, (unsigned)p1y, PADDLE_X1 + PADDLE_W - 1,
281
+ (unsigned)(p1y + PADDLE_H - 1));
282
+ tgi_setcolor(COLOR_RED);
283
+ tgi_bar(PADDLE_X2, (unsigned)p2y, PADDLE_X2 + PADDLE_W - 1,
284
+ (unsigned)(p2y + PADDLE_H - 1));
285
+ }
286
+
287
+ /* ── GAME LOGIC (clay) — start a match ── */
288
+ static void start_match(void) {
289
+ p1y = 40; p2y = 40;
290
+ score_p1 = 0; score_p2 = 0;
291
+ new_record = 0;
292
+ prev_joy = 0xFF; /* the button that started the match shouldn't
293
+ * also count as the first frame's input */
294
+ sfx_tone(0, 80, 8); /* start chirp */
295
+ serve_ball(0);
296
+ state = ST_PLAY;
297
+ }
298
+
299
+ /* ── GAME LOGIC (clay) — match over: result + record bookkeeping.
300
+ * Persistence choice: for a VERSUS game a raw hi-score is meaningless (every
301
+ * match ends 5-x), so we keep the longest CPU-beating win STREAK — the stat a
302
+ * returning player actually chases — in-session only (see the EEPROM note). */
303
+ static void end_match(void) {
304
+ p1_won = (score_p1 >= WIN_SCORE);
305
+ if (p1_won) {
306
+ ++streak;
307
+ if (streak > best_streak) {
308
+ /* ── In-session record ONLY — and here's the honest why. Real Lynx
309
+ * carts persist via a 93Cxx serial EEPROM on the cart PCB (cc65 even
310
+ * ships lynx_eeprom_read/write for it; see vendor/cc65/libsrc/lynx/
311
+ * eeprom.s). PROBED: the bundled handy core emulates CEEPROM internally
312
+ * but its libretro build exposes NO save path — retro_get_memory(
313
+ * SAVE_RAM) returns NULL/size 0, so nothing survives host.hardReset()
314
+ * and a bit-banged round-trip reads back garbage under the WASM build.
315
+ * Wiring the EEPROM to SAVE_RAM is a future core round; until then a
316
+ * fake "save" would be lying. The record DOES survive title↔play cycles
317
+ * within one power-on. ── */
318
+ best_streak = streak;
319
+ new_record = 1;
320
+ }
321
+ sfx_tone(0, 60, 16); /* victory rise */
322
+ } else {
323
+ streak = 0; /* the streak dies with the loss */
324
+ sfx_tone(2, 220, 18); /* low defeat whistle */
325
+ sfx_noise(12);
326
+ }
327
+ pop_timer = POP_FRAMES; /* trigger the result SCALE POP */
328
+ state = ST_OVER;
329
+ }
330
+
331
+ /* ── GAME LOGIC (clay) — one point scored ── */
332
+ static void score_point(uint8_t for_p1) {
333
+ if (for_p1) ++score_p1; else ++score_p2;
334
+ sfx_noise(6);
335
+ if (score_p1 >= WIN_SCORE || score_p2 >= WIN_SCORE) end_match();
336
+ else serve_ball(for_p1); /* winner of the point receives */
337
+ }
338
+
339
+ /* ── GAME LOGIC (clay) — paddle hit: deflect by where the ball struck.
340
+ * Centre = flat-ish, edges = steep. A ±1 random "spin" on every return keeps
341
+ * rallies from repeating (see the PRNG note above), so an idle match (you
342
+ * never moving) still ENDS — the CPU eventually wins. */
343
+ static void deflect(int16_t paddle_y) {
344
+ int16_t rel = (by + BALL_W / 2) - (paddle_y + PADDLE_H / 2);
345
+ bdy = (int8_t)(rel >> 3);
346
+ bdy += (int8_t)((rand8() & 2) - 1); /* spin: -1 or +1 */
347
+ if (bdy > 3) bdy = 3;
348
+ if (bdy < -3) bdy = -3;
349
+ if (bdy == 0) bdy = (rel < 0) ? -1 : 1; /* never return a flat ball */
350
+ sfx_tone(0, 70, 4);
351
+ }
352
+
353
+ /* ── GAME LOGIC (clay) — HUD: scores + labels across the top band ── */
354
+ static void draw_hud(void) {
355
+ tgi_setcolor(COLOR_YELLOW);
356
+ tgi_outtextxy(2, 2, "P1");
357
+ numbuf[0] = (char)('0' + score_p1); numbuf[1] = 0;
358
+ tgi_outtextxy(20, 2, numbuf);
359
+ tgi_setcolor(COLOR_RED);
360
+ tgi_outtextxy(136, 2, "CPU");
361
+ numbuf[0] = (char)('0' + score_p2); numbuf[1] = 0;
362
+ tgi_outtextxy(124, 2, numbuf);
363
+ tgi_setcolor(COLOR_LIGHTGREY);
364
+ tgi_outtextxy(56, 2, "WIN");
365
+ numbuf[0] = (char)('0' + WIN_SCORE); numbuf[1] = 0;
366
+ tgi_outtextxy(84, 2, numbuf);
367
+ }
368
+
369
+ /* ── GAME LOGIC (clay) — per-state frames. Each runs INSIDE the canonical
370
+ * loop below: court already painted, tgi_updatedisplay not yet called. ── */
371
+
372
+ static unsigned attract_phase;
373
+
374
+ static void frame_title(uint8_t joy) {
375
+ /* attract: a lone ball in the title's clear zone pulses via the SCALING
376
+ * idiom — the same swell the speed-scaled ball + result pop use, shown off
377
+ * on the menu. */
378
+ unsigned t = attract_phase < 64 ? attract_phase : (127 - attract_phase);
379
+ unsigned s = 0x00C0u + (t * (0x0220u - 0x00C0u)) / 63u; /* 0.75x..2.13x */
380
+ attract_phase = (attract_phase + 2) & 127;
381
+ draw_sprite(spr_ball, 80, 34, COLOR_WHITE, s); /* breathing ball */
382
+
383
+ tgi_setcolor(COLOR_WHITE);
384
+ tgi_outtextxy(8, 18, GAME_TITLE);
385
+ tgi_setcolor(COLOR_YELLOW);
386
+ tgi_outtextxy(48, 52, "PRESS A");
387
+ tgi_setcolor(COLOR_LIGHTGREY);
388
+ tgi_outtextxy(36, 66, "BEST ");
389
+ numbuf[0] = (char)('0' + (best_streak > 9 ? 9 : best_streak)); numbuf[1] = 0;
390
+ tgi_outtextxy(76, 66, numbuf);
391
+ tgi_outtextxy(20, 80, "1P VS CPU"); /* handheld honesty */
392
+
393
+ if (JOY_BTN_1(joy) && !JOY_BTN_1(prev_joy)) start_match();
394
+ }
395
+
396
+ static void frame_over(uint8_t joy) {
397
+ unsigned ps = pop_scale();
398
+ draw_paddles();
399
+ /* the SCALE POP: a winner glyph swells then eases back to 1.0x */
400
+ if (p1_won) draw_sprite(spr_cup, 80, 40, COLOR_YELLOW, ps);
401
+ else draw_sprite(spr_ball, 80, 40, COLOR_RED, ps);
402
+ if (pop_timer) pop_timer--;
403
+
404
+ tgi_setcolor(COLOR_DARKGREY);
405
+ tgi_bar(28, 54, 131, 96);
406
+ tgi_setcolor(COLOR_WHITE);
407
+ tgi_outtextxy(44, 58, "GAME OVER");
408
+ tgi_setcolor(p1_won ? COLOR_LIGHTGREEN : COLOR_RED);
409
+ tgi_outtextxy(40, 68, p1_won ? "YOU WIN" : "CPU WINS");
410
+ if (new_record) { tgi_setcolor(COLOR_YELLOW); tgi_outtextxy(36, 78, "NEW RECORD"); }
411
+ else { tgi_setcolor(COLOR_LIGHTGREY); tgi_outtextxy(44, 78, "A = TITLE"); }
412
+ tgi_setcolor(COLOR_LIGHTGREY);
413
+ tgi_outtextxy(40, 88, "SCORE ");
414
+ numbuf[0] = (char)('0' + score_p1); numbuf[1] = '-';
415
+ numbuf[2] = (char)('0' + score_p2); numbuf[3] = 0;
416
+ tgi_outtextxy(88, 88, numbuf);
417
+
418
+ if (JOY_BTN_1(joy) && !JOY_BTN_1(prev_joy)) state = ST_TITLE;
419
+ }
420
+
421
+ static void frame_play(uint8_t joy) {
422
+ /* ── draw: paddles, the SPEED-SCALED ball (Suzy), HUD ── */
423
+ draw_paddles();
424
+ draw_sprite(spr_ball, (int)bx + BALL_W / 2, (int)by + BALL_W / 2,
425
+ COLOR_WHITE, ball_scale());
426
+ draw_hud();
427
+
428
+ /* ── update ── */
429
+ rand8(); /* tick the noise source every play frame */
430
+
431
+ /* you — UP/DOWN, 2px/frame */
432
+ if ((joy & JOY_UP_MASK) && p1y > COURT_TOP) p1y -= 2;
433
+ if ((joy & JOY_DOWN_MASK) && p1y < COURT_BOT - PADDLE_H) p1y += 2;
434
+
435
+ /* CPU — chases the ball centre at 1px/frame (half your speed) with a small
436
+ * dead zone. Beatable by design: steep edge deflections outrun it. */
437
+ {
438
+ int16_t target = by + BALL_W / 2 - PADDLE_H / 2;
439
+ if (p2y + 2 < target && p2y < COURT_BOT - PADDLE_H) p2y += 1;
440
+ else if (p2y > target + 2 && p2y > COURT_TOP) p2y -= 1;
441
+ }
442
+
443
+ /* ball frozen during the post-point serve pause */
444
+ if (serve_timer > 0) { --serve_timer; return; }
445
+ bx += bdx;
446
+ by += bdy;
447
+
448
+ /* rail bounce */
449
+ if (by < COURT_TOP) { by = COURT_TOP; bdy = -bdy; sfx_tone(2, 90, 3); }
450
+ if (by + BALL_W > COURT_BOT) { by = COURT_BOT - BALL_W; bdy = -bdy; sfx_tone(2, 90, 3); }
451
+
452
+ /* paddle collisions (direction-gated so the ball can't double-hit) */
453
+ if (bdx < 0
454
+ && bx <= PADDLE_X1 + PADDLE_W && bx + BALL_W >= PADDLE_X1
455
+ && by + BALL_W > p1y && by < p1y + PADDLE_H) {
456
+ bdx = -bdx; bx = PADDLE_X1 + PADDLE_W; deflect(p1y);
457
+ }
458
+ if (bdx > 0
459
+ && bx + BALL_W >= PADDLE_X2 && bx <= PADDLE_X2 + PADDLE_W
460
+ && by + BALL_W > p2y && by < p2y + PADDLE_H) {
461
+ bdx = -bdx; bx = PADDLE_X2 - BALL_W; deflect(p2y);
462
+ }
463
+
464
+ /* off either side → point */
465
+ if (bx < -BALL_W) score_point(0); /* past you → CPU scores */
466
+ if (bx > 160) score_point(1); /* past CPU → you score */
467
+ }
19
468
 
20
469
  void main(void) {
21
- int16_t p1y = 40, p2y = 40, bx = 78, by = 48;
22
- int8_t bdx = 2, bdy = 1;
23
470
  uint8_t joy;
24
- int16_t ny; /* loop var for the dashed centre net */
25
471
 
26
472
  tgi_install(&lynx_160_102_16_tgi);
27
473
  tgi_init();
28
474
  joy_install(&lynx_stdjoy_joy);
29
- sfx_init();
475
+ sfx_init(); /* MIKEY up; background melody starts on voice 1 */
476
+
477
+ state = ST_TITLE;
478
+ prev_joy = 0;
479
+ attract_phase = 0;
480
+ best_streak = 0;
481
+ streak = 0;
482
+ p1_won = 0;
30
483
 
31
484
  for (;;) {
32
- /* Lynx frame loop: WAIT for the blitter, then clear with a full-screen
33
- * tgi_bar (NOT tgi_clear, which leaves the back page stale on this core)
34
- * — drawing while the blitter is mid-flight loses the frame black.
35
- * (Copied from the shmup scaffold, the LYNX-1 fix.) */
485
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
486
+ * CANONICAL LYNX GAME LOOP full-redraw every frame, in this order:
487
+ * 1. while (tgi_busy()) { } — WAIT for the previous frame's page flip.
488
+ * Skipping this is the #1 "Lynx screen stays blank" trap: drawing
489
+ * while the swap is pending loses the frame.
490
+ * 2. Repaint the WHOLE scene with tgi_bar/tgi_line fills — NOT
491
+ * tgi_clear() (which can leave the framebuffer stale on this
492
+ * toolchain+emulator path). TGI double-buffers; the back buffer holds
493
+ * the frame from two flips ago, so partial redraws ghost. With no
494
+ * hardware tilemap, the COURT is repainted every frame.
495
+ * 3. Draw every object (every TGI call and every tgi_sprite() is a
496
+ * synchronous Suzy blit into the SAME draw page).
497
+ * 4. tgi_updatedisplay() — request the page flip at next VBL.
498
+ * 5. sfx_update() IMMEDIATELY after — MIKEY voice writes must land in
499
+ * vblank: handy reschedules its timer sweep on the spot when a voice
500
+ * CTL bit-3 write lands, and mid-frame that sweep can preempt an
501
+ * in-flight Suzy blit and eat sprites (the R57 bug — history in
502
+ * lynx_sfx.c). sfx_tone()/sfx_noise() only STAGE; sfx_update() is
503
+ * the hardware flush. */
36
504
  while (tgi_busy()) { }
37
505
 
38
- /* ── Background scene (drawn every frame). Without it the court is a
39
- * near-flat single colour and the render-health audit flags the
40
- * screen as blank. A two-tone court with boards + net markings keeps
41
- * several distinct colours well under the threshold:
42
- * - green centre court
43
- * - lighter-green end zones behind each paddle
44
- * - dark-grey boards top and bottom
45
- * - white boundary, dashed centre net + centre circle. */
46
- tgi_setcolor(COLOR_GREEN);
47
- tgi_bar(0, 0, tgi_getmaxx(), tgi_getmaxy()); /* court grass */
48
- tgi_setcolor(COLOR_LIGHTGREEN);
49
- tgi_bar(0, COURT_TOP, 52, COURT_BOT - 1); /* left end zone */
50
- tgi_bar(107, COURT_TOP, 159, COURT_BOT - 1); /* right end zone */
51
- tgi_setcolor(COLOR_DARKGREY);
52
- tgi_bar(0, 0, 159, COURT_TOP - 1); /* top boards */
53
- tgi_bar(0, COURT_BOT, 159, 101); /* bottom boards */
54
- /* white court boundary + dashed centre net + centre circle */
55
- tgi_setcolor(COLOR_WHITE);
56
- tgi_line(0, COURT_TOP, 159, COURT_TOP);
57
- tgi_line(0, COURT_BOT, 159, COURT_BOT);
58
- for (ny = COURT_TOP; ny < COURT_BOT; ny += 8)
59
- tgi_bar(79, (unsigned)ny, 80, (unsigned)(ny + 3 > COURT_BOT ? COURT_BOT : ny + 3));
60
- tgi_line(70, 40, 90, 40);
61
- tgi_line(70, 60, 90, 60);
62
- tgi_line(70, 40, 70, 60);
63
- tgi_line(90, 40, 90, 60);
64
-
65
- /* Playtest: "needs better contrast" — yellow paddles + white ball pop
66
- * against the green court far better than white-on-lightgreen +
67
- * yellow-on-green did. */
68
- tgi_setcolor(COLOR_YELLOW);
69
- tgi_bar(PADDLE_X1, (unsigned)p1y, PADDLE_X1 + PADDLE_W - 1, (unsigned)(p1y + PADDLE_H - 1));
70
- tgi_bar(PADDLE_X2, (unsigned)p2y, PADDLE_X2 + PADDLE_W - 1, (unsigned)(p2y + PADDLE_H - 1));
71
- tgi_setcolor(COLOR_WHITE);
72
- tgi_bar((unsigned)bx, (unsigned)by, (unsigned)(bx + BALL_SIZE - 1), (unsigned)(by + BALL_SIZE - 1));
506
+ draw_court();
507
+
508
+ joy = joy_read(JOY_1);
509
+
510
+ if (state == ST_TITLE) frame_title(joy);
511
+ else if (state == ST_PLAY) frame_play(joy);
512
+ else frame_over(joy);
513
+
73
514
  tgi_updatedisplay();
74
515
  sfx_update();
75
516
 
76
- joy = joy_read(JOY_1);
77
- if (JOY_UP(joy) && p1y > COURT_TOP) p1y -= 2;
78
- if (JOY_DOWN(joy) && p1y < COURT_BOT - PADDLE_H) p1y += 2;
79
-
80
- /* AI */
81
- if (p2y + PADDLE_H/2 < by && p2y < COURT_BOT - PADDLE_H) p2y++;
82
- else if (p2y + PADDLE_H/2 > by && p2y > COURT_TOP) p2y--;
83
-
84
- bx += bdx;
85
- by += bdy;
86
- if (by < COURT_TOP) { by = COURT_TOP; bdy = -bdy; sfx_tone(2, 90, 2); }
87
- if (by + BALL_SIZE > COURT_BOT) { by = COURT_BOT - BALL_SIZE; bdy = -bdy; sfx_tone(2, 90, 2); }
88
- if (bdx < 0 && bx <= PADDLE_X1 + PADDLE_W && bx + BALL_SIZE >= PADDLE_X1
89
- && by + BALL_SIZE > p1y && by < p1y + PADDLE_H) { bdx = -bdx; sfx_tone(0, 70, 3); }
90
- if (bdx > 0 && bx + BALL_SIZE >= PADDLE_X2 && bx <= PADDLE_X2 + PADDLE_W
91
- && by + BALL_SIZE > p2y && by < p2y + PADDLE_H) { bdx = -bdx; sfx_tone(0, 70, 3); }
92
- if (bx < -BALL_SIZE) { bx = 78; by = 48; bdx = 2; sfx_noise(20); }
93
- if (bx > 160) { bx = 78; by = 48; bdx = -2; sfx_tone(0, 50, 12); }
517
+ prev_joy = joy;
94
518
  }
95
519
  }