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,8 +1,34 @@
1
- // ── shmup.c — Atari Lynx vertical-shooter scaffold ───────────────────
2
- //
3
- // Cross-platform shmup: player + 4 bullets + 4 enemies (pools), wave
4
- // spawner, AABB collisions, MIKEY sound effects. cc65's tgi handles
5
- // Suzy's blitter for us.
1
+ /* ── shmup.c — Atari Lynx depth-dive shooter (complete example game) ─────────
2
+ *
3
+ * A COMPLETE, working game title screen, score + lives, in-session
4
+ * hi-score, MIKEY music + SFX, and the Lynx's signature party trick:
5
+ * HARDWARE SPRITE SCALING. Enemies dive at you out of the horizon and
6
+ * Suzy (the blitter) scales them up in HARDWARE as they approach —
7
+ * far = tiny speck, near = looming hull — by changing two 8.8 fixed-point
8
+ * fields in the sprite's control block. No CPU pixel work at all.
9
+ *
10
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
11
+ * very different one. The markers tell you what's what:
12
+ * HARDWARE IDIOM (load-bearing) — dodges a documented Lynx footgun;
13
+ * reshape your gameplay around it (see TROUBLESHOOTING before changing).
14
+ * GAME LOGIC (clay) — enemy patterns, scoring, tuning, art: reshape freely.
15
+ *
16
+ * What depends on what:
17
+ * lynx_sfx.{h,c} — MIKEY 4-voice audio (voice 0 = player SFX, voice 1 =
18
+ * background melody, voice 2 = impact SFX, voice 3 = noise/explosions).
19
+ * vendor/cc65/libsrc/lynx/ — the FULL cc65 Lynx driver source shipped into
20
+ * your project. The TGI driver (tgi/lynx-160-102-16.s) is REQUIRED
21
+ * reading when graphics misbehave: every TGI call is itself a Suzy
22
+ * sprite, and our scaled sprites ride the same engine via tgi_ioctl(0).
23
+ *
24
+ * PLAYERS: 1. This is a handheld — multiplayer on real hardware is ComLynx,
25
+ * a cable between TWO Lynx units. A single emulator instance has nobody on
26
+ * the other end of the cable, so this example is honestly single-player
27
+ * (no fake "P2" that could never work).
28
+ *
29
+ * SCREEN: 160x102. The system font is 8x8, so a full row of text is 20
30
+ * characters — keep HUD lines short and the layout compact.
31
+ */
6
32
 
7
33
  #include <tgi.h>
8
34
  #include <joystick.h>
@@ -10,38 +36,50 @@
10
36
  #include <stdint.h>
11
37
  #include "lynx_sfx.h"
12
38
 
13
- #define MAX_BULLETS 4
14
- #define MAX_ENEMIES 4
39
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
40
+ * name here automatically. Keep it <=16 chars of A-Z 0-9 space dash. */
41
+ #define GAME_TITLE "VOID PLUNGE"
15
42
 
16
- typedef struct { uint8_t x, y, alive; } Obj;
43
+ /* ── GAME LOGIC (clay reshape freely) — object pools & tuning ───────────── */
44
+ #define MAX_BULLETS 4
45
+ #define MAX_ENEMIES 4
46
+ #define START_LIVES 3
17
47
 
18
- static Obj player;
19
- static Obj bullets[MAX_BULLETS];
20
- static Obj enemies[MAX_ENEMIES];
21
- static uint8_t spawn_timer;
22
- static uint8_t prev_btn;
48
+ /* The depth corridor enemies dive through (screen-Y is our depth axis):
49
+ * Y_FAR is the horizon (vanishing band), Y_NEAR is "in your face". */
50
+ #define Y_FAR 22
51
+ #define Y_NEAR 97
52
+ #define DEPTH_SPAN (Y_NEAR - Y_FAR) /* 75 px of travel */
23
53
 
24
- static uint8_t aabb(Obj *a, Obj *b) {
25
- return a->x < b->x + 6 && a->x + 6 > b->x
26
- && a->y < b->y + 6 && a->y + 6 > b->y;
27
- }
54
+ /* Suzy scale (8.8 fixed point: $0100 = 1.0 = one screen pixel per texel).
55
+ * The 8x8 art renders 2 px wide at the horizon and 20 px wide up close —
56
+ * a 10x growth you can't miss, and the hardware does ALL of it. */
57
+ #define SCALE_FAR 0x0040u /* 0.25x → 2 px */
58
+ #define SCALE_NEAR 0x0280u /* 2.50x → 20 px */
59
+ #define SHIP_SCALE 0x0200u /* your ship: fixed 2x */
28
60
 
29
- static void fire(void) {
30
- uint8_t i;
31
- for (i = 0; i < MAX_BULLETS; i++) {
32
- if (!bullets[i].alive) {
33
- bullets[i].x = player.x;
34
- bullets[i].y = player.y - 4;
35
- bullets[i].alive = 1;
36
- return;
37
- }
38
- }
39
- }
61
+ typedef struct { uint8_t alive; uint8_t lane; unsigned y_fp; } Enemy;
62
+ typedef struct { uint8_t alive; uint8_t x, y; } Bullet;
63
+
64
+ static Enemy enemies[MAX_ENEMIES];
65
+ static Bullet bullets[MAX_BULLETS];
66
+ static uint8_t ship_x, ship_y; /* ship CENTER (sprites draw centered) */
67
+ static uint8_t lives, level, kills;
68
+ static unsigned score;
69
+ static unsigned hiscore; /* in-session only — see EEPROM note */
70
+ static unsigned enemy_speed; /* 8.8 px/frame down the corridor */
71
+ static uint8_t spawn_interval, spawn_timer;
72
+ static uint8_t fire_cd, hurt_timer;
73
+ static uint8_t prev_joy;
40
74
 
41
- /* Galois LFSR (taps $B8), period 255 -- real per-spawn randomness.
42
- * The old code derived the spawn column from spawn_timer, but the caller
43
- * resets spawn_timer just before calling here, so it was CONSTANT and
44
- * every enemy spawned in the same left column/lane. */
75
+ /* Game states the shell every example shares: title → play → game over. */
76
+ #define ST_TITLE 0
77
+ #define ST_PLAY 1
78
+ #define ST_OVER 2
79
+ static uint8_t state;
80
+ static uint8_t over_new_hi;
81
+
82
+ /* ── GAME LOGIC (clay) — Galois LFSR (taps $B8), period 255 ── */
45
83
  static uint8_t rng_state = 0xA5;
46
84
  static uint8_t rand8(void) {
47
85
  uint8_t lsb = (uint8_t)(rng_state & 1);
@@ -50,134 +88,423 @@ static uint8_t rand8(void) {
50
88
  return rng_state;
51
89
  }
52
90
 
53
- static void spawn(void) {
91
+ /* Scrolling starfield so the dark space field is never one flat colour
92
+ * (a >=92% single-colour frame trips the render-health audit as "blank"). */
93
+ #define N_STARS 24
94
+ static uint8_t star_x[N_STARS];
95
+ static uint8_t star_y[N_STARS];
96
+
97
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
98
+ * SUZY HARDWARE SPRITE SCALING — the Lynx signature. Suzy renders every
99
+ * sprite through a Sprite Control Block (SCB) it walks in cart/work RAM.
100
+ * Two SCB fields, HSIZE and VSIZE, are 8.8 fixed-point scale factors
101
+ * ($0100 = 1.0): the SAME 8x8 source pixels render at any size, every
102
+ * frame, for free. That is this whole game's depth illusion.
103
+ *
104
+ * The SCB, field by field (this is cc65's SCB_REHV_PAL from <_suzy.h>):
105
+ * sprctl0 %BBxx
106
+ * bits 7-6 = bits per pixel (11 = 4bpp), bits 2-0 = sprite TYPE.
107
+ * TYPE_NORMAL (4) draws pens 1-15 and treats pen 0 as
108
+ * TRANSPARENT — that's how shaped sprites sit over the field.
109
+ * sprctl1 bit 7 LITERAL (data below is raw nybbles, no RLE packets) +
110
+ * bits 5-4 reload depth: REHV means "this SCB carries HPOS,
111
+ * VPOS, HSIZE, VSIZE". The reload bits ARE the struct layout —
112
+ * mismatch them and Suzy reads palette bytes as size words.
113
+ * sprcoll $20 = NO_COLLIDE. We do gameplay collision in C (in DEPTH
114
+ * coordinates, which the collision buffer knows nothing about).
115
+ * next pointer to the next SCB, 0 = end of chain. One blit per call
116
+ * here; chain SCBs and one SPRGO draws them all.
117
+ * data sprite pixel data (format below).
118
+ * hpos/vpos signed SCREEN position of the sprite's top-left corner.
119
+ * hsize/vsize 8.8 scale — THE party trick. We recompute these every
120
+ * frame from each enemy's depth.
121
+ * penpal[8] 16 nybbles mapping pixel values 0-15 → palette pens
122
+ * (identity here; sprite art can be recoloured per-SCB for free
123
+ * — e.g. one art block, four enemy colours).
124
+ *
125
+ * LITERAL 4bpp data format (hand-encodable): each sprite LINE is
126
+ * [offset byte][width/2 bytes of raw nybble pixels]
127
+ * where offset = 1 + bytes of pixel data (Suzy adds it to find the next
128
+ * line), and a final offset of 0 ends the sprite. 8 px @ 4bpp = 4 data
129
+ * bytes, so every line starts with 5. (The packed/RLE format is what
130
+ * sprpck emits; literal is friendlier to author by hand.)
131
+ *
132
+ * Drawing: tgi_sprite(&scb) → tgi_ioctl(0, &scb) — the TGI driver's
133
+ * documented escape hatch (see CONTROL in vendor/cc65/libsrc/lynx/tgi/
134
+ * lynx-160-102-16.s). It points Suzy's SCBNEXT at your SCB, aims VIDBAS
135
+ * at TGI's current DRAW page (so scaled sprites land in the same
136
+ * double-buffered frame as tgi_bar/tgi_outtextxy), fires SPRGO, and
137
+ * sleeps the CPU until SPRSYS reports the blit done.
138
+ *
139
+ * Requires: the cc65 crt0 Suzy init (SUZYBUSEN=1, SPRSYS, HOFF/VOFF=0 —
140
+ * already done before main()), and calls only between the tgi_busy()
141
+ * wait and tgi_updatedisplay() — i.e. while TGI's draw buffer is the
142
+ * blit target. Draw order = paint order: background bars first, scaled
143
+ * sprites after, HUD text last.
144
+ */
145
+ static SCB_REHV_PAL scb = {
146
+ BPP_4 | TYPE_NORMAL, /* sprctl0: 4bpp, pen 0 transparent */
147
+ LITERAL | REHV, /* sprctl1: literal data, HV+size SCB */
148
+ 0x20, /* sprcoll: NO_COLLIDE */
149
+ 0, /* next: single-SCB chain */
150
+ 0, /* data: set per draw */
151
+ 0, 0, /* hpos, vpos */
152
+ 0x0100, 0x0100, /* hsize, vsize (8.8) */
153
+ { 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF } /* identity pens */
154
+ };
155
+
156
+ /* Draw an 8x8 literal sprite CENTERED on (cx,cy) at the given 8.8 scale.
157
+ * Centering matters: hpos/vpos are the TOP-LEFT, so a sprite scaled around
158
+ * its corner would slide right/down as it grows. Anchoring the centre keeps
159
+ * the dive reading as "coming straight at you". */
160
+ static void draw_scaled(unsigned char *data, int cx, int cy, unsigned scale) {
161
+ unsigned w = scale >> 5; /* on-screen size: (8 * scale) >> 8 */
162
+ if (w == 0) w = 1;
163
+ scb.data = data;
164
+ scb.hsize = scale;
165
+ scb.vsize = scale;
166
+ scb.hpos = cx - (int)(w >> 1);
167
+ scb.vpos = cy - (int)(w >> 1);
168
+ tgi_sprite(&scb);
169
+ }
170
+
171
+ /* ── GAME LOGIC (clay) — 8x8 4bpp literal sprite art ────────────────────────
172
+ * Pens use the TGI default palette: 2 = red, 3 = pink, 9 = yellow,
173
+ * $D = blue, $F = white, 0 = transparent. Each line: 5, then 4 nybble
174
+ * bytes; final 0 byte ends the sprite (format in the idiom block above). */
175
+ static unsigned char spr_enemy[] = {
176
+ 5, 0x00, 0x02, 0x20, 0x00, /* . . . 2 2 . . . red diver, pink core */
177
+ 5, 0x00, 0x23, 0x32, 0x00, /* . . 2 3 3 2 . . */
178
+ 5, 0x02, 0x3F, 0xF3, 0x20, /* . 2 3 F F 3 2 . */
179
+ 5, 0x22, 0x3F, 0xF3, 0x22, /* 2 2 3 F F 3 2 2 */
180
+ 5, 0x23, 0x33, 0x33, 0x32, /* 2 3 3 3 3 3 3 2 */
181
+ 5, 0x02, 0x23, 0x32, 0x20, /* . 2 2 3 3 2 2 . */
182
+ 5, 0x00, 0x22, 0x22, 0x00, /* . . 2 2 2 2 . . */
183
+ 5, 0x00, 0x02, 0x20, 0x00, /* . . . 2 2 . . . */
184
+ 0
185
+ };
186
+ static unsigned char spr_ship[] = {
187
+ 5, 0x00, 0x0F, 0xF0, 0x00, /* . . . F F . . . yellow interceptor */
188
+ 5, 0x00, 0x09, 0x90, 0x00, /* . . . 9 9 . . . */
189
+ 5, 0x00, 0x99, 0x99, 0x00, /* . . 9 9 9 9 . . */
190
+ 5, 0x00, 0x9D, 0xD9, 0x00, /* . . 9 D D 9 . . */
191
+ 5, 0x09, 0x9D, 0xD9, 0x90, /* . 9 9 D D 9 9 . */
192
+ 5, 0x99, 0x99, 0x99, 0x99, /* 9 9 9 9 9 9 9 9 */
193
+ 5, 0x90, 0x9D, 0xD9, 0x09, /* 9 . 9 D D 9 . 9 */
194
+ 5, 0x00, 0xD0, 0x0D, 0x00, /* . . D . . D . . */
195
+ 0
196
+ };
197
+
198
+ /* ── GAME LOGIC (clay) — depth → screen mapping ─────────────────────────────
199
+ * Screen-Y doubles as the depth axis: an enemy at the horizon (Y_FAR) is
200
+ * far away; at Y_NEAR it has reached you. Scale and X both interpolate on
201
+ * the same depth fraction, so divers fan OUT of the vanishing point toward
202
+ * their lane while they grow — a poor man's perspective projection. */
203
+ static unsigned scale_for_y(uint8_t y) {
204
+ unsigned span = (unsigned)(y - Y_FAR);
205
+ return SCALE_FAR + (span * (SCALE_NEAR - SCALE_FAR)) / DEPTH_SPAN;
206
+ }
207
+ static uint8_t enemy_screen_x(const Enemy *e) {
208
+ uint8_t y = (uint8_t)(e->y_fp >> 8);
209
+ int span = (int)(y - Y_FAR);
210
+ return (uint8_t)(80 + ((int)(e->lane - 80) * span) / DEPTH_SPAN);
211
+ }
212
+ /* Current half-width in pixels (collision box tracks the HARDWARE scale —
213
+ * a far speck is genuinely harder to hit than a looming hull). */
214
+ static uint8_t enemy_half(const Enemy *e) {
215
+ return (uint8_t)(scale_for_y((uint8_t)(e->y_fp >> 8)) >> 6); /* (w/2) */
216
+ }
217
+
218
+ static void spawn_enemy(void) {
54
219
  uint8_t i;
55
220
  for (i = 0; i < MAX_ENEMIES; i++) {
56
221
  if (!enemies[i].alive) {
57
- enemies[i].x = (uint8_t)(8 + (rand8() % (160 - 16)));
58
- enemies[i].y = 0;
59
222
  enemies[i].alive = 1;
223
+ enemies[i].lane = (uint8_t)(14 + (rand8() % 132)); /* target column */
224
+ enemies[i].y_fp = (unsigned)Y_FAR << 8;
60
225
  return;
61
226
  }
62
227
  }
63
228
  }
64
229
 
65
- /* Scrolling starfield: a handful of stars that drift down so the dark
66
- * space field is never a flat single colour (would read as "blank"). */
67
- #define N_STARS 24
68
- static uint8_t star_x[N_STARS];
69
- static uint8_t star_y[N_STARS];
230
+ static void fire_bullet(void) {
231
+ uint8_t i;
232
+ for (i = 0; i < MAX_BULLETS; i++) {
233
+ if (!bullets[i].alive) {
234
+ bullets[i].alive = 1;
235
+ bullets[i].x = ship_x;
236
+ bullets[i].y = ship_y - 8;
237
+ sfx_tone(0, 70, 4); /* voice 0: pew */
238
+ return;
239
+ }
240
+ }
241
+ }
242
+
243
+ /* ── GAME LOGIC (clay) — score text (no sprintf: it drags in ~6KB) ── */
244
+ static char numbuf[6];
245
+ static char *fmt5(unsigned v) {
246
+ uint8_t i;
247
+ for (i = 0; i < 5; i++) { numbuf[4 - i] = (char)('0' + v % 10); v /= 10; }
248
+ numbuf[5] = 0;
249
+ return numbuf;
250
+ }
251
+
252
+ /* ── GAME LOGIC (clay) — shared scene painter (runs every frame) ────────────
253
+ * Full-redraw, painter's order: space field, horizon bands, stars, then the
254
+ * caller layers sprites + text on top. Layered bands keep any one colour
255
+ * comfortably under the render-health blank threshold. */
256
+ static void draw_scene(void) {
257
+ uint8_t i;
258
+ tgi_setcolor(COLOR_BLACK);
259
+ tgi_bar(0, 0, 159, 101); /* deep space */
260
+ tgi_setcolor(COLOR_DARKGREY);
261
+ tgi_bar(0, 0, 159, 8); /* HUD bar */
262
+ tgi_bar(0, Y_FAR - 4, 159, Y_FAR - 3); /* horizon glow, outer */
263
+ tgi_setcolor(COLOR_PURPLE);
264
+ tgi_bar(0, Y_FAR - 2, 159, Y_FAR - 1); /* horizon glow, inner */
265
+ tgi_setcolor(COLOR_WHITE);
266
+ for (i = 0; i < N_STARS; i++) {
267
+ tgi_setpixel(star_x[i], star_y[i]);
268
+ tgi_setpixel(star_x[i], (uint8_t)((star_y[i] + 1) % 102));
269
+ }
270
+ }
271
+ static void drift_stars(void) {
272
+ uint8_t i;
273
+ for (i = 0; i < N_STARS; i++) {
274
+ if (star_y[i] >= 101) { star_y[i] = Y_FAR; star_x[i] = (uint8_t)(rand8() % 160); }
275
+ else star_y[i]++;
276
+ }
277
+ }
278
+
279
+ /* ── GAME LOGIC (clay) — start a run ── */
280
+ static void start_game(void) {
281
+ uint8_t i;
282
+ for (i = 0; i < MAX_BULLETS; i++) bullets[i].alive = 0;
283
+ for (i = 0; i < MAX_ENEMIES; i++) enemies[i].alive = 0;
284
+ ship_x = 80; ship_y = 92;
285
+ lives = START_LIVES; level = 1; kills = 0;
286
+ score = 0;
287
+ enemy_speed = 0x00B0; /* 0.69 px/frame — ~109-frame dives */
288
+ spawn_interval = 120; /* level 1: one diver at a time */
289
+ spawn_timer = 30;
290
+ fire_cd = 0; hurt_timer = 0;
291
+ state = ST_PLAY;
292
+ }
293
+
294
+ static void game_over(void) {
295
+ over_new_hi = 0;
296
+ if (score > hiscore) {
297
+ /* ── In-session hi-score ONLY — and here's the honest why. Real Lynx
298
+ * carts persist via a 93Cxx serial EEPROM on the cart PCB (cc65 even
299
+ * ships lynx_eeprom_read/write for it — bit-banged over A7/A1/AUDIN;
300
+ * see vendor/cc65/libsrc/lynx/eeprom.s). PROBED 2026-06: the bundled
301
+ * handy core emulates CEEPROM internally but its libretro build
302
+ * exposes NO save path — retro_get_memory(SAVE_RAM) returns
303
+ * NULL/size 0, so nothing can survive host.hardReset(), and the
304
+ * bit-banged round-trip reads back garbage under the WASM build.
305
+ * Wiring the EEPROM to SAVE_RAM is a future core round; until then a
306
+ * fake "save" would be lying. The hi-score DOES survive title↔play
307
+ * cycles within one power-on. ── */
308
+ hiscore = score;
309
+ over_new_hi = 1;
310
+ }
311
+ sfx_tone(2, 240, 24); /* voice 2: low game-over drone */
312
+ state = ST_OVER;
313
+ }
314
+
315
+ /* ── GAME LOGIC (clay) — per-state frames. Each runs INSIDE the canonical
316
+ * loop below: scene already painted, tgi_updatedisplay not yet called. ── */
317
+
318
+ static unsigned attract_y_fp = (unsigned)Y_FAR << 8;
319
+ static uint8_t attract_lane = 120;
320
+
321
+ static void frame_title(uint8_t joy) {
322
+ uint8_t ty;
323
+ /* Attract demo: one enemy dives on a loop — the scaling idiom IS the
324
+ * title screen's pitch. */
325
+ attract_y_fp += 0x00C0;
326
+ ty = (uint8_t)(attract_y_fp >> 8);
327
+ if (ty >= Y_NEAR) { attract_y_fp = (unsigned)Y_FAR << 8; attract_lane = (uint8_t)(30 + (rand8() % 100)); }
328
+ else {
329
+ Enemy demo;
330
+ demo.lane = attract_lane; demo.y_fp = attract_y_fp;
331
+ draw_scaled(spr_enemy, enemy_screen_x(&demo), ty, scale_for_y(ty));
332
+ }
333
+ draw_scaled(spr_ship, 80, 92, SHIP_SCALE);
334
+
335
+ tgi_setcolor(COLOR_WHITE);
336
+ tgi_outtextxy(36, 1, GAME_TITLE); /* on the HUD bar */
337
+ tgi_setcolor(COLOR_YELLOW);
338
+ tgi_outtextxy(48, 38, "PRESS A");
339
+ tgi_setcolor(COLOR_LIGHTGREY);
340
+ tgi_outtextxy(28, 50, "HI ");
341
+ tgi_outtextxy(52, 50, fmt5(hiscore));
342
+ tgi_outtextxy(24, 62, "1 PLAYER GAME"); /* handheld honesty */
343
+
344
+ if (JOY_BTN_1(joy) && !JOY_BTN_1(prev_joy)) start_game();
345
+ }
346
+
347
+ static void frame_over(uint8_t joy) {
348
+ tgi_setcolor(COLOR_DARKGREY);
349
+ tgi_bar(20, 34, 139, 70);
350
+ tgi_setcolor(COLOR_WHITE);
351
+ tgi_outtextxy(44, 38, "GAME OVER");
352
+ tgi_setcolor(COLOR_YELLOW);
353
+ tgi_outtextxy(36, 48, "SCORE ");
354
+ tgi_outtextxy(84, 48, fmt5(score));
355
+ if (over_new_hi) { tgi_setcolor(COLOR_LIGHTGREEN); tgi_outtextxy(32, 58, "NEW HI SCORE"); }
356
+ else { tgi_setcolor(COLOR_LIGHTGREY); tgi_outtextxy(36, 58, "A = TITLE"); }
357
+ if (JOY_BTN_1(joy) && !JOY_BTN_1(prev_joy)) state = ST_TITLE;
358
+ }
359
+
360
+ static void frame_play(uint8_t joy) {
361
+ uint8_t i, j, ex, ey, hw;
362
+
363
+ /* ── draw: enemies (each rescaled from its depth EVERY frame), ship,
364
+ * bullets, HUD ── */
365
+ for (i = 0; i < MAX_ENEMIES; i++) {
366
+ if (!enemies[i].alive) continue;
367
+ ey = (uint8_t)(enemies[i].y_fp >> 8);
368
+ draw_scaled(spr_enemy, enemy_screen_x(&enemies[i]), ey, scale_for_y(ey));
369
+ }
370
+ if (hurt_timer == 0 || (hurt_timer & 4)) /* blink while hurt */
371
+ draw_scaled(spr_ship, ship_x, ship_y, SHIP_SCALE);
372
+ tgi_setcolor(COLOR_WHITE);
373
+ for (i = 0; i < MAX_BULLETS; i++)
374
+ if (bullets[i].alive)
375
+ tgi_bar(bullets[i].x - 1, bullets[i].y - 2, bullets[i].x, bullets[i].y + 1);
376
+
377
+ tgi_setcolor(COLOR_WHITE);
378
+ tgi_outtextxy(2, 1, "SC");
379
+ tgi_outtextxy(20, 1, fmt5(score));
380
+ tgi_setcolor(COLOR_LIGHTGREY);
381
+ tgi_outtextxy(66, 1, "HI");
382
+ tgi_outtextxy(84, 1, fmt5(hiscore));
383
+ tgi_setcolor(COLOR_YELLOW);
384
+ tgi_outtextxy(132, 1, "L");
385
+ numbuf[0] = (char)('0' + lives); numbuf[1] = 0;
386
+ tgi_outtextxy(140, 1, numbuf);
387
+
388
+ /* ── update: ship ── */
389
+ if ((joy & JOY_LEFT_MASK) && ship_x > 9) ship_x -= 2;
390
+ if ((joy & JOY_RIGHT_MASK) && ship_x < 150) ship_x += 2;
391
+ if ((joy & JOY_UP_MASK) && ship_y > 70) ship_y--;
392
+ if ((joy & JOY_DOWN_MASK) && ship_y < 96) ship_y++;
393
+ if (JOY_BTN_1(joy) && fire_cd == 0) { fire_bullet(); fire_cd = 8; }
394
+ if (fire_cd) fire_cd--;
395
+ if (hurt_timer) hurt_timer--;
396
+
397
+ /* bullets fly "away" up the corridor */
398
+ for (i = 0; i < MAX_BULLETS; i++) {
399
+ if (!bullets[i].alive) continue;
400
+ if (bullets[i].y < Y_FAR + 3) { bullets[i].alive = 0; continue; }
401
+ bullets[i].y -= 3;
402
+ }
403
+
404
+ /* enemies dive (subpixel 8.8 speed) */
405
+ for (i = 0; i < MAX_ENEMIES; i++) {
406
+ if (!enemies[i].alive) continue;
407
+ enemies[i].y_fp += enemy_speed;
408
+ ey = (uint8_t)(enemies[i].y_fp >> 8);
409
+ if (ey >= Y_NEAR) {
410
+ /* Reached your depth plane: ram you, or whoosh past. */
411
+ ex = enemy_screen_x(&enemies[i]);
412
+ hw = enemy_half(&enemies[i]);
413
+ enemies[i].alive = 0;
414
+ if (hurt_timer == 0
415
+ && (uint8_t)(ex > ship_x ? ex - ship_x : ship_x - ex) < hw + 7
416
+ && (uint8_t)(ey > ship_y ? ey - ship_y : ship_y - ey) < hw + 6) {
417
+ sfx_tone(2, 220, 10); /* voice 2: thump */
418
+ sfx_noise(12); /* voice 3: crunch */
419
+ hurt_timer = 45;
420
+ if (lives) lives--;
421
+ if (lives == 0) { game_over(); return; }
422
+ }
423
+ }
424
+ }
425
+
426
+ /* bullets vs enemies — in the SCALED box: the hitbox grows with the
427
+ * hardware sprite, so range determines difficulty (far 3pt speck, mid
428
+ * 2pt, near 1pt barn door). */
429
+ for (i = 0; i < MAX_BULLETS; i++) {
430
+ if (!bullets[i].alive) continue;
431
+ for (j = 0; j < MAX_ENEMIES; j++) {
432
+ if (!enemies[j].alive) continue;
433
+ ex = enemy_screen_x(&enemies[j]);
434
+ ey = (uint8_t)(enemies[j].y_fp >> 8);
435
+ hw = enemy_half(&enemies[j]);
436
+ if ((uint8_t)(bullets[i].x > ex ? bullets[i].x - ex : ex - bullets[i].x) < hw + 2
437
+ && (uint8_t)(bullets[i].y > ey ? bullets[i].y - ey : ey - bullets[i].y) < hw + 3) {
438
+ bullets[i].alive = 0;
439
+ enemies[j].alive = 0;
440
+ sfx_noise(8); /* voice 3: boom */
441
+ score += (ey < Y_FAR + 25) ? 3 : (ey < Y_FAR + 50) ? 2 : 1;
442
+ kills++;
443
+ if (kills >= 10) { /* level ramp */
444
+ kills = 0;
445
+ level++;
446
+ if (enemy_speed < 0x0200) enemy_speed += 0x18;
447
+ if (spawn_interval > 40) spawn_interval -= 10;
448
+ }
449
+ break;
450
+ }
451
+ }
452
+ }
453
+
454
+ if (spawn_timer == 0) { spawn_timer = spawn_interval; spawn_enemy(); }
455
+ else spawn_timer--;
456
+ }
70
457
 
71
458
  void main(void) {
72
- uint8_t joy, fire_now, i, j;
459
+ uint8_t joy, i;
73
460
  uint32_t srng = 0x1234;
74
461
 
75
462
  tgi_install(&lynx_160_102_16_tgi);
76
463
  tgi_init();
77
464
  joy_install(&lynx_stdjoy_joy);
78
- sfx_init();
465
+ sfx_init(); /* MIKEY up; background melody starts on voice 1 */
79
466
 
80
- player.x = 76; player.y = 90; player.alive = 1;
81
- for (i = 0; i < MAX_BULLETS; i++) bullets[i].alive = 0;
82
- for (i = 0; i < MAX_ENEMIES; i++) enemies[i].alive = 0;
83
467
  for (i = 0; i < N_STARS; i++) {
84
468
  srng = srng * 1103515245u + 12345u;
85
469
  star_x[i] = (uint8_t)((srng >> 16) % 160);
86
470
  srng = srng * 1103515245u + 12345u;
87
- star_y[i] = (uint8_t)((srng >> 16) % 102);
471
+ star_y[i] = (uint8_t)(Y_FAR + ((srng >> 16) % (102 - Y_FAR)));
88
472
  }
89
- spawn_timer = 0;
90
- prev_btn = 0;
473
+ state = ST_TITLE;
474
+ prev_joy = 0;
91
475
 
92
476
  for (;;) {
93
- /* CANONICAL LYNX GAME LOOPfull-redraw every frame. The reliable order:
94
- * 1. WAIT for the Suzy blitter to finish the previous frame:
95
- * while (tgi_busy()) { }
96
- * Skipping this is the #1 "Lynx screen stays blank" trap — drawing
97
- * while the blitter is mid-flight loses the frame.
98
- * 2. CLEAR with a full-screen tgi_bar in the background colour, NOT
99
- * tgi_clear() (which can leave the framebuffer stale in this
100
- * toolchain+emulator path).
101
- * 3. DRAW every object.
102
- * 4. tgi_updatedisplay() to push the frame. */
477
+ /* ── HARDWARE IDIOM (load-bearingreshape gameplay around this; see TROUBLESHOOTING) ──
478
+ * CANONICAL LYNX GAME LOOP full-redraw every frame, in this order:
479
+ * 1. while (tgi_busy()) { } — WAIT for the previous frame's page
480
+ * flip. Skipping this is the #1 "Lynx screen stays blank" trap:
481
+ * drawing while the swap is pending loses the frame.
482
+ * 2. Repaint the WHOLE scene with tgi_bar fills NOT tgi_clear()
483
+ * (which can leave the framebuffer stale on this toolchain+
484
+ * emulator path). TGI double-buffers; the back buffer holds the
485
+ * frame from two flips ago, so partial redraws ghost.
486
+ * 3. Draw every object (every TGI call and every tgi_sprite() is a
487
+ * synchronous Suzy blit into the SAME draw page).
488
+ * 4. tgi_updatedisplay() — request the page flip at next VBL.
489
+ * 5. sfx_update() IMMEDIATELY after — MIKEY voice writes must land
490
+ * in vblank: handy reschedules its timer sweep on the spot when
491
+ * a voice CTL bit-3 write lands, and mid-frame that sweep can
492
+ * preempt an in-flight Suzy blit and eat sprites (the R57 bug —
493
+ * history in lynx_sfx.c). sfx_tone()/sfx_noise() only STAGE;
494
+ * sfx_update() is the hardware flush. */
103
495
  while (tgi_busy()) { }
104
496
 
105
- /* ── Background scene (drawn every frame; without it the dark space
106
- * field is a near-flat single colour and the render-health audit
107
- * flags the screen as blank). Layered bands keep any one colour well
108
- * under the threshold:
109
- * - deep-blue upper space
110
- * - grey nebula band across the middle
111
- * - green planet surface along the bottom
112
- * - a drifting white/yellow starfield over the space. */
113
- tgi_setcolor(COLOR_BLUE);
114
- tgi_bar(0, 0, tgi_getmaxx(), tgi_getmaxy()); /* base space field */
115
- tgi_setcolor(COLOR_GREY);
116
- tgi_bar(0, 34, 159, 60); /* nebula band */
117
- tgi_setcolor(COLOR_GREEN);
118
- tgi_bar(0, 84, 159, 101); /* planet surface */
119
- tgi_setcolor(COLOR_LIGHTGREEN);
120
- tgi_bar(0, 78, 159, 83); /* surface horizon */
121
- /* starfield (bright specks; also drifts downward each frame) */
122
- tgi_setcolor(COLOR_WHITE);
123
- for (i = 0; i < N_STARS; i++) {
124
- tgi_setpixel(star_x[i], star_y[i]);
125
- tgi_setpixel(star_x[i], (star_y[i] + 1) % 102);
126
- }
497
+ draw_scene();
498
+ joy = joy_read(JOY_1);
499
+
500
+ if (state == ST_TITLE) frame_title(joy);
501
+ else if (state == ST_PLAY) frame_play(joy);
502
+ else frame_over(joy);
127
503
 
128
- /* Render game objects on top */
129
- tgi_setcolor(COLOR_YELLOW);
130
- tgi_bar(player.x, player.y, player.x + 6, player.y + 6);
131
- tgi_setcolor(COLOR_WHITE);
132
- for (i = 0; i < MAX_BULLETS; i++) {
133
- if (bullets[i].alive) tgi_bar(bullets[i].x, bullets[i].y, bullets[i].x + 2, bullets[i].y + 4);
134
- }
135
- tgi_setcolor(COLOR_RED);
136
- for (i = 0; i < MAX_ENEMIES; i++) {
137
- if (enemies[i].alive) tgi_bar(enemies[i].x, enemies[i].y, enemies[i].x + 6, enemies[i].y + 6);
138
- }
139
504
  tgi_updatedisplay();
140
505
  sfx_update();
141
506
 
142
- /* drift the starfield downward */
143
- for (i = 0; i < N_STARS; i++) {
144
- if (star_y[i] >= 101) star_y[i] = 0; else star_y[i]++;
145
- }
146
-
147
- /* Input + state */
148
- joy = joy_read(JOY_1);
149
- fire_now = JOY_BTN_1(joy) ? 1 : 0;
150
- if (JOY_LEFT(joy) && player.x > 0) player.x--;
151
- if (JOY_RIGHT(joy) && player.x < 154) player.x++;
152
- if (JOY_UP(joy) && player.y > 8) player.y--;
153
- if (JOY_DOWN(joy) && player.y < 96) player.y++;
154
- if (fire_now && !prev_btn) { fire(); sfx_tone(0, 80, 4); }
155
- prev_btn = fire_now;
156
-
157
- for (i = 0; i < MAX_BULLETS; i++) {
158
- if (!bullets[i].alive) continue;
159
- if (bullets[i].y < 2) { bullets[i].alive = 0; continue; }
160
- bullets[i].y -= 3;
161
- }
162
- for (i = 0; i < MAX_ENEMIES; i++) {
163
- if (!enemies[i].alive) continue;
164
- enemies[i].y++;
165
- if (enemies[i].y >= 102) enemies[i].alive = 0;
166
- }
167
- spawn_timer++;
168
- if (spawn_timer >= 28) { spawn_timer = 0; spawn(); }
169
-
170
- for (i = 0; i < MAX_BULLETS; i++) {
171
- if (!bullets[i].alive) continue;
172
- for (j = 0; j < MAX_ENEMIES; j++) {
173
- if (!enemies[j].alive) continue;
174
- if (aabb(&bullets[i], &enemies[j])) {
175
- bullets[i].alive = 0;
176
- enemies[j].alive = 0;
177
- sfx_noise(8);
178
- break;
179
- }
180
- }
181
- }
507
+ drift_stars();
508
+ prev_joy = joy;
182
509
  }
183
510
  }