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,252 +1,917 @@
1
- /* ── platformer.c — Game Gear SIDE-SCROLLING platformer scaffold ───
2
- *
3
- * A horizontally scrolling platformer. The world is 512 px wide (64
4
- * cells); the GG VDP name table is only 32 cells (256 px) and wraps, so
5
- * a world wider than one fetch needs COLUMN STREAMING: each time the
6
- * camera crosses an 8-px boundary we rewrite the name-table column that
7
- * is about to scroll into view with the next world column's tiles.
8
- *
9
- * The GG VDP is the same Mode-4 hardware as the SMS — only the visible
10
- * window differs (160×144 centered inside the 256×192 frame). So the
11
- * camera centers on a 160-px window while the VDP still fetches all 32
12
- * columns; the streaming math is identical to the SMS scaffold.
13
- *
14
- * Smooth pixel scroll comes from VDP register 8 (R8 = -camX & 0xFF).
15
- * Subpixel state (x/y in 1/16-pixel units); the player sprite draws in
16
- * SCREEN space (worldX>>4) - camX. See the SMS/GG MENTAL_MODEL.md.
1
+ /* ── platformer.c — Game Gear side-scrolling platformer (complete example) ───
2
+ *
3
+ * SCARP SPRINT a COMPLETE, working game: title screen, 1P mode and 2P
4
+ * ALTERNATING-TURNS mode (arcade-classic: players swap on death; each player
5
+ * has their own score and own 3 lives; player 2 plays on PORT B — see the
6
+ * honesty note at gg_joypad_read_p2), coins + distance scoring, persistent
7
+ * hi-score (Sega-mapper cart RAM see the honesty note at hiscore_save),
8
+ * PSG music + SFX, and the GG/SMS signature LINE-INTERRUPT split: a fixed HUD
9
+ * strip over a horizontally scrolling level, timed by the VDP's programmable
10
+ * line counter.
11
+ *
12
+ * THIS FILE IS THE GG TWIN of the SMS platformer (GULLY VAULT). The GG VDP IS
13
+ * the SMS VDP — same Mode-4 hardware, same SN76489 PSG, same I/O. There is
14
+ * exactly ONE thing that changes everything about placement:
15
+ *
16
+ * THE GG VISIBLE WINDOW the VDP renders a full 256×192 frame; the LCD
17
+ * shows only the CENTERED 160×144 of it. Every hardware coordinate (sprite
18
+ * OAM x/y, tilemap rows/cols, AND the line counter's scanline number) is in
19
+ * the FULL 256×192 frame; content placed outside the centered window is
20
+ * rendered "correctly" and simply never shown. So the HUD, the title, and
21
+ * ALL gameplay must sit INSIDE the window — derive every coordinate from the
22
+ * VIS_* block below, never hardcode an SMS-frame number. (The emulator
23
+ * screenshot is the 160×144 visible crop — "my sprite is at y=10 but
24
+ * invisible" means it's parked in the unseen border, not a render bug.)
25
+ *
26
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
27
+ * very different one. The markers tell you what's what:
28
+ * HARDWARE IDIOM (load-bearing) — dodges a documented GG footgun; reshape
29
+ * your gameplay around it (see TROUBLESHOOTING before changing).
30
+ * GAME LOGIC (clay) — level layout, physics tuning, scoring, art: reshape
31
+ * freely.
32
+ *
33
+ * What depends on what:
34
+ * gg_hw.h / vdp_init.c / load_tiles.c / load_palette.c / sprite_table.c /
35
+ * joypad_read.c — the bundled VDP + input runtime (this file's externs).
36
+ * gg_sfx.{h,c} + gg_music.{h,c} — SN76489 PSG sound layers.
37
+ * gg_crt0.s — boot + vector table. Its $0038 IM-1 handler is the OTHER
38
+ * HALF of the line-interrupt idiom below: it acks the VDP (one status
39
+ * read clears BOTH the frame and line IRQ flags) and returns with
40
+ * ei/reti. Load-bearing; edit with TROUBLESHOOTING open.
41
+ *
42
+ * The level: a 32-column map (ground height + one-way platforms + pits).
43
+ * The GG/SMS name table is EXACTLY 32 cells = 256 px wide and wraps in
44
+ * hardware, so a 256-px-periodic level paints ONCE and the uint8 scroll wraps
45
+ * perfectly seamless — no second nametable, no column streaming. (The VDP
46
+ * fetches all 32 columns even though only the centered 20 show, so the
47
+ * off-window columns scroll INTO the window as the field moves.) An endless
48
+ * looping run of pits, platforms, coins and spikes. Coins/spikes are sprites
49
+ * that drift with the scroll (world-anchored while on screen, respawning at
50
+ * the right).
51
+ *
52
+ * Frame budget (60fps): SAT upload (192 OUTs) + the HUD strip fit easily in
53
+ * vblank (70 lines) + the 47 scanlines above the split (the GG split budget is
54
+ * BIGGER than the SMS's — the 24 never-shown border lines are free cycles);
55
+ * player physics + a two-column tile probe + (3 coins + 2 spikes) of AABB run
56
+ * in the active frame with room to spare. The HUD redraw (10 software 16-bit
57
+ * divisions) is gated by a dirty flag — see the BUDGET FOOTGUN at the main loop.
58
+ *
59
+ * SDCC FOOTGUN (bites every fork): uint8 loop bounds silently wrap —
60
+ * `for (uint8_t i = 0; i < 24 * 32; i++)` is an INFINITE loop (768 > 255;
61
+ * SDCC even warns "comparison is always true"). Treat that warning as an
62
+ * error: widen the counter to uint16_t or keep loops nested per-row like
63
+ * the painters below.
17
64
  */
18
65
  #include "gg_hw.h"
19
66
  #include "gg_sfx.h"
20
67
  #include "gg_music.h"
21
68
  #include <stdint.h>
22
69
 
23
- extern void gg_vdp_init(void);
24
- extern void gg_vdp_display_on(void);
25
- extern void gg_vdp_write_reg(uint8_t reg, uint8_t value);
26
- extern void gg_vdp_set_addr(uint16_t addr, uint8_t prefix);
27
- extern void gg_load_palette(const uint8_t *palette);
28
- extern void gg_load_tiles(uint16_t vram_dest, const uint8_t *src, uint16_t byte_count);
29
- extern void gg_set_tilemap_cell(uint8_t row, uint8_t col, uint8_t tile_idx, uint8_t attr);
30
- extern void gg_vblank_wait(void);
70
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
71
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
72
+ #define GAME_TITLE "SCARP SPRINT"
73
+
74
+ extern void gg_vdp_init(void);
75
+ extern void gg_vdp_write_reg(uint8_t reg, uint8_t value);
76
+ extern void gg_vdp_display_on(void);
77
+ extern void gg_vdp_display_off(void);
78
+ extern void gg_vdp_set_addr(uint16_t addr, uint8_t prefix);
79
+ extern void gg_load_palette(const uint8_t *palette);
80
+ extern void gg_load_tiles(uint16_t vram_dest, const uint8_t *src, uint16_t byte_count);
81
+ extern void gg_set_tilemap_cell(uint8_t row, uint8_t col, uint8_t tile_idx, uint8_t attr);
31
82
  extern uint8_t gg_joypad_read(void);
32
- extern void gg_sprite_init(void);
33
- extern void gg_sprite_set(uint8_t slot, uint8_t x, uint8_t y, uint8_t tile);
34
- extern void gg_sat_upload(void);
35
-
36
- #define T_OPEN 0
37
- #define T_WALL 1
38
-
39
- #define WORLD_COLS 64 /* 64 cells = 512 px world */
40
- #define WORLD_W (WORLD_COLS * 8)
41
- #define SCREEN_W 160 /* GG visible window is 160 px wide */
42
- #define VIS_ROWS 24
43
-
44
- /* ── Game Gear visible viewport ──────────────────────────────────────
45
- * Only the centered 160x144 of the 256x192 frame shows. The BG is
46
- * scrolled so world column camX/8 appears at fetch pixel VIS_X0, and the
47
- * player sprite is drawn at (worldX - camX) + VIS_X0 so it stays aligned
48
- * with the BG inside the visible window. */
49
- #define VIS_X0 48
50
- #define VIS_Y0 24
51
- #define VIS_X1 207 /* 48 + 160 - 1 */
52
- #define VIS_Y1 167 /* 24 + 144 - 1 */
53
-
54
- /* GG palette = 32 entries × 2 bytes (4-4-4 BGR LE): low=(g<<4)|r, high=b.
55
- * gg_load_palette reads 64 bytes; a 32-byte array leaves the sprite palette
56
- * (entries 16-31) reading garbage = invisible sprites. BG colour 1 = light
57
- * sky blue, 2 = darker sky blue (the sky dither), 3 = mid-grey wall; sprite
58
- * colour 1 = entry 17 (white player). */
83
+ extern uint8_t gg_joypad_read_p2(void);
84
+ extern void gg_sprite_init(void);
85
+ extern void gg_sprite_set(uint8_t slot, uint8_t x, uint8_t y, uint8_t tile);
86
+ extern void gg_sat_upload(void);
87
+
88
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
89
+ * THE GG VISIBLE WINDOW. The VDP frame is 256×192; the LCD shows the
90
+ * centered 160×144. In FULL-FRAME hardware units the window is:
91
+ *
92
+ * pixels: x [48..207] y [24..167] (sprite coords, scanlines)
93
+ * tilemap: col ∈ [6..25] row ∈ [3..20] (20×18 visible cells)
94
+ *
95
+ * EVERYTHING the hardware takes is full-frame: gg_sprite_set x/y, tilemap
96
+ * row/col, and easy to forget — the LINE COUNTER (VDP R10) counts
97
+ * full-frame scanlines from the top of the 192-line active area, NOT from
98
+ * the top of the LCD. The window's first visible scanline is 24.
99
+ *
100
+ * Requires: nothing — these are constants of the machine. Everything below
101
+ * (HUD placement, split line, level rows, sprite Y, text columns) is derived
102
+ * from them; if you reshape the layout, derive from VIS_*, never hardcode
103
+ * SMS-frame numbers. */
104
+ #define VIS_X0 48 /* left edge of the LCD window (hardware X) */
105
+ #define VIS_Y0 24 /* top edge (hardware Y / scanline) */
106
+ #define VIS_X1 207 /* right edge: 48 + 160 - 1 */
107
+ #define VIS_Y1 167 /* bottom edge: 24 + 144 - 1 */
108
+ #define VIS_W 160
109
+ #define VIS_H 144
110
+ #define VIS_COL0 6 /* first visible tilemap column (48 / 8) */
111
+ #define VIS_ROW0 3 /* first visible tilemap row (24 / 8) */
112
+ #define VIS_COLS 20 /* 160 / 8 */
113
+ #define VIS_ROWS 18 /* 144 / 8 */
114
+ /* Think in window space (0..19 cols, 0..17 rows), convert at the call: */
115
+ #define VROW(r) ((uint8_t)((r) + VIS_ROW0))
116
+ #define VCOL(c) ((uint8_t)((c) + VIS_COL0))
117
+
118
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
119
+ * Palette. THE GG's HEADLINE UPGRADE over the SMS: CRAM holds 12-bit
120
+ * 4-4-4 BGR colour (4096 colours) instead of the SMS's 6-bit 2-2-2 (64).
121
+ * The WRITE FORMAT differs too — that's the #2 GG footgun:
122
+ *
123
+ * SMS: 32 entries × 1 byte --BBGGRR
124
+ * GG: 32 entries × 2 bytes little-endian: low byte = GGGGRRRR
125
+ * high byte = ----BBBB
126
+ *
127
+ * So a GG palette array is 64 bytes (entries 0-15 BG, 16-31 sprite). Feeding
128
+ * gg_load_palette a 32-byte SMS-style table reads past the array — the
129
+ * sprite palette loads garbage and every sprite renders invisible (this exact
130
+ * bug shipped in an earlier GG scaffold round). Pack an entry with:
131
+ * low = (g << 4) | r, high = b, each channel 0..15. The mints/ambers below
132
+ * have no 2-2-2 SMS equivalent — the 4096-colour panel earning its keep. */
59
133
  static const uint8_t palette[64] = {
60
- /* BG 0-15: 0 = dark navy backdrop, 1 = light sky, 2 = dark sky, 3 = wall grey */
61
- 0x20,0x02, 0xFB,0x0F, 0xC8,0x0C, 0x88,0x08, 0,0, 0,0, 0,0, 0,0,
62
- 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
63
- /* SPRITE 16-31: 16=transparent, 17=white player */
64
- 0,0, 0xFF,0x0F, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
65
- 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
134
+ /* BG 0-15: 0 = sky blue (backdrop/border), 1 = dirt brown, 2 = grass
135
+ * green, 3 = white (text + clouds), 4 = HUD-bar navy */
136
+ 0x8C,0x0E, 0x42,0x02, 0x2A,0x01, 0xFF,0x0F, 0x33,0x03,
137
+ 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
138
+ /* SPRITE 16-31: 16 = transparent, 17 = red (player), 18 = gold (coin),
139
+ * 19 = orange (spike). One shared sprite palette on GG/SMS — per-"sprite"
140
+ * colour means per-TILE colour indices, not per-sprite palettes. */
141
+ 0,0, 0x03,0x00, 0xCF,0x0F, 0x4F,0x0F,
142
+ 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0, 0,0,
66
143
  };
67
144
 
68
- static const uint8_t bg_tiles[32 * 2] = {
69
- /* T_OPEN dithered sky: pixels alternate colour 1 (light) / colour 2
70
- * (dark) so the sky fills the screen but no single colour dominates.
71
- * plane0 = 0xAA (cols 0,2,4,6 -> colour 1), plane1 = 0x55 (cols 1,3,5,7
72
- * -> colour 2). */
73
- 0xAA,0x55,0x00,0x00, 0x55,0xAA,0x00,0x00,
74
- 0xAA,0x55,0x00,0x00, 0x55,0xAA,0x00,0x00,
75
- 0xAA,0x55,0x00,0x00, 0x55,0xAA,0x00,0x00,
76
- 0xAA,0x55,0x00,0x00, 0x55,0xAA,0x00,0x00,
77
- /* T_WALL — solid block in colour 3 (planes 0+1 set) */
78
- 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
79
- 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
80
- 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
81
- 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
145
+ /* ── GAME LOGIC (clay) BG tile inventory (BG bank $0000) ───────────────────
146
+ * tile 0 = blank sky (colour 0)
147
+ * tiles 1..37 = font: digits 0-9, A-Z, '-' (uploaded 1bpp→4bpp below)
148
+ * tile 38 = grass surface (green lip over dirt)
149
+ * tile 39 = dirt fill (solid colour 1)
150
+ * tile 40 = solid HUD bar (colour 4) — the split seam hides in it
151
+ * tile 41 = cloud puff (colour 3) */
152
+ #define FONT_BASE 1
153
+ #define BG_GRASS 38
154
+ #define BG_DIRT 39
155
+ #define BG_HUDBAR 40
156
+ #define BG_CLOUD 41
157
+
158
+ /* 1bpp font (same glyph set as the NES/SMS/GB examples — 0-9, A-Z, '-').
159
+ * Stored 8 bytes/glyph; expanded to the VDP's 32-byte 4bpp tiles at upload
160
+ * (see load_font below), so the ROM carries 296 bytes instead of 1184. */
161
+ static const uint8_t font8[37][8] = {
162
+ /* 0-9 */
163
+ {0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00}, {0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00},
164
+ {0x3C,0x66,0x06,0x0C,0x18,0x30,0x7E,0x00}, {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00},
165
+ {0x0C,0x1C,0x3C,0x6C,0x7E,0x0C,0x0C,0x00}, {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00},
166
+ {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x30,0x30,0x00},
167
+ {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00}, {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00},
168
+ /* A-Z */
169
+ {0x18,0x3C,0x66,0x66,0x7E,0x66,0x66,0x00}, {0x7C,0x66,0x66,0x7C,0x66,0x66,0x7C,0x00},
170
+ {0x3C,0x66,0x60,0x60,0x60,0x66,0x3C,0x00}, {0x78,0x6C,0x66,0x66,0x66,0x6C,0x78,0x00},
171
+ {0x7E,0x60,0x60,0x7C,0x60,0x60,0x7E,0x00}, {0x7E,0x60,0x60,0x7C,0x60,0x60,0x60,0x00},
172
+ {0x3C,0x66,0x60,0x6E,0x66,0x66,0x3E,0x00}, {0x66,0x66,0x66,0x7E,0x66,0x66,0x66,0x00},
173
+ {0x7E,0x18,0x18,0x18,0x18,0x18,0x7E,0x00}, {0x06,0x06,0x06,0x06,0x66,0x66,0x3C,0x00},
174
+ {0x66,0x6C,0x78,0x70,0x78,0x6C,0x66,0x00}, {0x60,0x60,0x60,0x60,0x60,0x60,0x7E,0x00},
175
+ {0x63,0x77,0x7F,0x6B,0x63,0x63,0x63,0x00}, {0x66,0x76,0x7E,0x7E,0x6E,0x66,0x66,0x00},
176
+ {0x3C,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x7C,0x66,0x66,0x7C,0x60,0x60,0x60,0x00},
177
+ {0x3C,0x66,0x66,0x66,0x6A,0x6C,0x36,0x00}, {0x7C,0x66,0x66,0x7C,0x6C,0x66,0x66,0x00},
178
+ {0x3C,0x66,0x60,0x3C,0x06,0x66,0x3C,0x00}, {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00},
179
+ {0x66,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x66,0x66,0x66,0x66,0x66,0x3C,0x18,0x00},
180
+ {0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00}, {0x66,0x66,0x3C,0x18,0x3C,0x66,0x66,0x00},
181
+ {0x66,0x66,0x66,0x3C,0x18,0x18,0x18,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x60,0x7E,0x00},
182
+ /* '-' */
183
+ {0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00},
82
184
  };
83
185
 
84
- static const uint8_t player_tile[32] = {
85
- 0x3C,0x00,0x00,0x00, 0x7E,0x00,0x00,0x00,
86
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
87
- 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
88
- 0x7E,0x00,0x00,0x00, 0x3C,0x00,0x00,0x00,
186
+ /* Expand 1bpp glyphs into 4bpp tiles as colour 3 (planes 0+1 set).
187
+ * GG/SMS tile rows are 4 bytes: plane0, plane1, plane2, plane3. */
188
+ static void load_font(void) {
189
+ uint8_t g, r, bits;
190
+ gg_vdp_set_addr((uint16_t)(FONT_BASE * 32), VDP_VRAM_WRITE);
191
+ for (g = 0; g < 37; g++) {
192
+ for (r = 0; r < 8; r++) {
193
+ bits = font8[g][r];
194
+ PORT_VDP_DATA = bits; /* plane 0 */
195
+ PORT_VDP_DATA = bits; /* plane 1 → colour index 3 */
196
+ PORT_VDP_DATA = 0; /* plane 2 */
197
+ PORT_VDP_DATA = 0; /* plane 3 */
198
+ }
199
+ }
200
+ }
201
+
202
+ /* Grass/dirt/HUD-bar/cloud tiles (4bpp, 32 bytes each — rows of plane0..3). */
203
+ static const uint8_t deco_tiles[128] = {
204
+ /* BG_GRASS: 2 rows of grass (colour 2 = plane 1) over dirt (colour 1) */
205
+ 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
206
+ 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
207
+ /* BG_DIRT: solid colour 1 */
208
+ 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
209
+ 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
210
+ /* BG_HUDBAR: solid colour 4 (binary 100 → plane 2 only) — the split
211
+ * seam lands inside this row */
212
+ 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00,
213
+ 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00, 0x00,0x00,0xFF,0x00,
214
+ /* BG_CLOUD: white puff (colour 3 = planes 0+1) */
215
+ 0x00,0x00,0x00,0x00, 0x3C,0x3C,0x00,0x00, 0x7E,0x7E,0x00,0x00, 0xFF,0xFF,0x00,0x00,
216
+ 0x7E,0x7E,0x00,0x00, 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,
89
217
  };
90
218
 
91
- typedef struct { int16_t x, y, w, h; } Rect;
92
-
93
- /* Platforms in WORLD coords, spread across the 512-px world. */
94
- static const Rect platforms[] = {
95
- { 0, 176, 512, 16 }, /* floor spans the world */
96
- { 32, 144, 56, 8 },
97
- { 120, 144, 64, 8 },
98
- { 200, 112, 48, 8 },
99
- { 56, 96, 40, 8 },
100
- { 288, 136, 64, 8 },
101
- { 384, 104, 56, 8 },
102
- { 440, 152, 48, 8 },
103
- { 320, 72, 48, 8 },
219
+ /* Sprite tiles (sprite bank $2000 vdp_init's R6=0xFF baseline reads
220
+ * sprite patterns from $2000, so upload there, not $0000). The colour
221
+ * indices below (1/2/3) come from ONE shared sprite palette. */
222
+ static const uint8_t sprite_tiles[32 * 4] = {
223
+ /* T_PLAYER_IDLE round body + legs, colour 1 (red) */
224
+ 0x3C,0x00,0x00,0x00, 0x7E,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
225
+ 0xFF,0x00,0x00,0x00, 0x7E,0x00,0x00,0x00, 0x66,0x00,0x00,0x00, 0x66,0x00,0x00,0x00,
226
+ /* T_PLAYER_JUMP — arms up, colour 1 */
227
+ 0x18,0x00,0x00,0x00, 0x7E,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00, 0xFF,0x00,0x00,0x00,
228
+ 0xE7,0x00,0x00,0x00, 0xC3,0x00,0x00,0x00, 0x81,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,
229
+ /* T_COIN — disc, colour 2 (gold, plane 1) */
230
+ 0x00,0x3C,0x00,0x00, 0x00,0x7E,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00,
231
+ 0x00,0xFF,0x00,0x00, 0x00,0xFF,0x00,0x00, 0x00,0x7E,0x00,0x00, 0x00,0x3C,0x00,0x00,
232
+ /* T_SPIKE — ground spike, colour 3 (orange, planes 0+1) */
233
+ 0x00,0x00,0x00,0x00, 0x18,0x18,0x00,0x00, 0x18,0x18,0x00,0x00, 0x3C,0x3C,0x00,0x00,
234
+ 0x3C,0x3C,0x00,0x00, 0x7E,0x7E,0x00,0x00, 0xFF,0xFF,0x00,0x00, 0xFF,0xFF,0x00,0x00,
104
235
  };
105
- #define N_PLATFORMS (sizeof(platforms) / sizeof(platforms[0]))
106
-
107
- static uint8_t on_platform(int16_t px, int16_t py) {
108
- uint8_t i;
109
- const Rect *p;
110
- for (i = 0; i < N_PLATFORMS; i++) {
111
- p = &platforms[i];
112
- if (py + 8 == p->y && px + 8 > p->x && px < p->x + p->w) return 1;
236
+ #define T_PLAYER_IDLE 0
237
+ #define T_PLAYER_JUMP 1
238
+ #define T_COIN 2
239
+ #define T_SPIKE 3
240
+
241
+ /* ── GAME LOGIC (clay reshape freely) ──────────────────────────────────────
242
+ * The level — a 32-column map; world x = (screen x + scroll) mod 256, and
243
+ * 32 columns × 8 px = EXACTLY the name table width, so the map paints once
244
+ * and wraps seamlessly.
245
+ * ground_row[c] — name-table row of the ground's grass top, 0xFF = pit.
246
+ * plat_row[c] — row of a one-way floating platform, 0 = none.
247
+ * Rows are HARDWARE name-table rows (y = row*8). The PLAYFIELD sits inside
248
+ * the visible window: window rows 3..17 = hardware rows VIS_ROW0+3..VIS_ROW0+17
249
+ * = rows 6..20. Ground top is hardware row 19 (y=152, near the window
250
+ * bottom VIS_Y1=167); one-way slabs float on rows 13 and 16. Everything is
251
+ * inside [VIS_Y0..VIS_Y1] so the LCD shows the whole platforming band. */
252
+ #define NO_GROUND 0xFF
253
+ static const uint8_t ground_row[32] = {
254
+ 19, 19, 19, 19, 19, 19, 19, 19, /* start runway */
255
+ 19, NO_GROUND, NO_GROUND, 19, 19, 19, 19, 19, /* pit 1 (16 px) */
256
+ 19, 19, 19, 19, NO_GROUND, NO_GROUND, NO_GROUND, /* pit 2 (24 px) */
257
+ 19, 19, 19, 19, 19, 19, 19, 19, 19,
258
+ };
259
+ static const uint8_t plat_row[32] = {
260
+ 0, 0, 0, 0, 14, 14, 14, 0, /* slab before pit 1 */
261
+ 0, 0, 0, 0, 0, 0, 13, 13, /* slab mid-level */
262
+ 13, 0, 0, 0, 0, 0, 0, 0,
263
+ 0, 14, 14, 14, 0, 0, 0, 0, /* slab near the loop */
264
+ };
265
+
266
+ /* HUD layout (WINDOW rows): row 0 = text (P# / lives / SC / HI), row 1 =
267
+ * blank, row 2 = solid bar. The bar row is both the visual divider AND where
268
+ * the split seam hides. Only 20 columns are visible — the HUD below uses 18
269
+ * of them; an SMS HUD laid out for 32 columns gets its ends cut by the border. */
270
+ #define HUD_ROWS 3
271
+ #define HUD_PX (HUD_ROWS * 8)
272
+ #define START_LIVES 3
273
+
274
+ /* ── GAME LOGIC (clay) — physics + tuning ── */
275
+ #define GRAVITY_Q44 1 /* +1/16 px per frame per frame */
276
+ #define JUMP_VEL_Q44 (-40) /* launch vy (Q4.4) → ~50 px / ~6 tile apex */
277
+ #define MAX_VY_Q44 80 /* terminal velocity, 5 px/frame — MUST stay *
278
+ * under 6: the landing probe's 6-px window *
279
+ * can't catch a faster fall (tunnelling) */
280
+ #define MOVE_SPEED 2 /* px/frame walk + scroll speed */
281
+ /* Scroll wall in FULL-FRAME hardware X. Past this the world scrolls, not the
282
+ * player. Sits ~2/3 across the visible window: VIS_X0 + 64 = 112. */
283
+ #define SCROLL_WALL (VIS_X0 + 64)
284
+ #define GROUND_ROW 19 /* see ground_row[] */
285
+ #define GROUND_TOP 152 /* GROUND_ROW * 8 */
286
+ #define SPIKE_Y 144 /* spikes stand on the ground */
287
+ #define PIT_KILL_Y 180 /* fell past the window bottom → dead. Keep *
288
+ * BELOW 0xD0=208: a sprite staged with Y=$D0 *
289
+ * is the SAT TERMINATOR — the VDP stops *
290
+ * scanning and every later slot vanishes */
291
+ #define NUM_COINS 3
292
+ #define NUM_SPIKES 2
293
+
294
+ static uint8_t px; /* player screen x (FULL-FRAME hardware)*/
295
+ static uint16_t py_q44; /* player y, Q4.4 fixed point — gravity
296
+ * adds <1 px/frame near the jump apex,
297
+ * so we need sub-pixel precision */
298
+ static int8_t vy_q44;
299
+ static uint8_t on_ground;
300
+ static uint8_t scroll_x; /* level scroll — uint8 wraps at 256 = *
301
+ * exactly one level loop (seamless) */
302
+ static uint8_t dist_sub; /* sub-counter: 64 px scrolled = +1 pt */
303
+ static uint8_t coin_x[NUM_COINS], coin_y[NUM_COINS];
304
+ static uint8_t spike_x[NUM_SPIKES], spike_active[NUM_SPIKES];
305
+
306
+ /* Players: index 0 = P1 (port A), 1 = P2 (port B — alternating turns,
307
+ * arcade-classic style). Each has own score + own lives; the HUD shows the
308
+ * CURRENT player's numbers. */
309
+ static uint8_t two_player;
310
+ static uint8_t cur_player;
311
+ static uint8_t p_lives[2];
312
+ static uint16_t p_score[2];
313
+ static uint16_t hiscore;
314
+ static uint8_t turn_pause; /* freeze frames after a turn change */
315
+ static uint8_t hud_dirty; /* score/lives changed → redraw next vblank */
316
+ static uint8_t over_step; /* game-over text, one piece per vblank */
317
+ static uint8_t prev_pad;
318
+ static uint16_t rng = 0xC0DE;
319
+
320
+ /* Game states — the shell every example shares: title → play → game over. */
321
+ #define ST_TITLE 0
322
+ #define ST_PLAY 1
323
+ #define ST_OVER 2
324
+ static uint8_t state;
325
+
326
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
327
+ * LINE-INTERRUPT SPLIT SCROLL — the GG/SMS VDP's signature trick (fixed
328
+ * status bar over a moving field, palette splits, water effects). The VDP has
329
+ * ONE scroll register pair for the whole frame; to keep the HUD fixed while
330
+ * the level scrolls you change the scroll MID-FRAME. Where the NES needs the
331
+ * sprite-0-hit HACK (park a sprite, busy-poll a status bit, burn scanlines
332
+ * spinning), this VDP has a real, PROGRAMMABLE line interrupt:
333
+ *
334
+ * R10 = N line counter: a down-counter reloaded with N every line
335
+ * outside the active area; underflow → IRQ at scanline N.
336
+ * R0 bit 4 (IE1) line-IRQ enable (already set in vdp_init's 0x36 baseline).
337
+ * R1 bit 5 (IE0) frame(vblank)-IRQ enable (set by gg_vdp_display_on's 0xE0).
338
+ *
339
+ * GG WINDOW CONTRAST (the part SMS habits get wrong): R10 counts FULL-FRAME
340
+ * scanlines — line 0 is the top of the 192-line active area, which is 24
341
+ * lines ABOVE the LCD. The HUD strip starts at the window top (scanline
342
+ * VIS_Y0 = 24) and its last line is VIS_Y0 + HUD_PX - 1 = 47, so SPLIT_LINE
343
+ * is 47 — NOT 23 as it would be on an SMS with the same 3-row HUD. Lines
344
+ * 0..23 are rendered and never shown; they ride along with the HUD's
345
+ * unscrolled region for free.
346
+ *
347
+ * Both IRQs land on the Z80's IM-1 vector at $0038. The crt0's handler does
348
+ * the canonical minimal handshake: push af / in a,($BF) / pop af / ei / reti
349
+ * — reading the status port ACKS the VDP (clears BOTH pending flags; skip
350
+ * the read and the IRQ line stays asserted = interrupt storm), and EI must
351
+ * precede RETI or interrupts stay off forever after the first one.
352
+ *
353
+ * Because the handler does no work, the MAIN loop synchronizes with HALT:
354
+ * the Z80 sleeps until the next interrupt, then we read the V-counter (port
355
+ * $7E) to learn WHICH one woke us — line IRQs only fire during the active
356
+ * area (V < 0xC0 here), the frame IRQ fires at vblank (V ≥ 0xC0).
357
+ *
358
+ * wait_vblank(): sleep until the frame IRQ → do per-frame VRAM work,
359
+ * write R8 = 0 so the HUD strip renders unscrolled.
360
+ * wait_split(): sleep until the line IRQ at scanline 47 (the last line
361
+ * of the solid bar row — any single-line tear from the
362
+ * mid-row write hides inside solid colour) → write
363
+ * R8 = -scroll_x; everything below scrolls.
364
+ *
365
+ * SCROLL DIRECTION — why R8 gets MINUS scroll_x: R8 shifts the whole plane
366
+ * RIGHT as it grows (name-table column 0 appears at screen x = R8). To move
367
+ * the WINDOW right through the world (level flows left as the player runs
368
+ * right) you write the negation: R8 = -scroll_x, so screen pixel x shows
369
+ * name-table pixel (x + scroll_x) & 0xFF. Get the sign wrong and the world
370
+ * runs backwards under the player.
371
+ *
372
+ * FOOTGUN — you cannot poll once IRQs are on: gg_vblank_wait() spins on the
373
+ * same status port the ISR reads. The ISR always wins the race (the IRQ fires
374
+ * the instant the flag sets), eats the flag, and the poll loop hangs forever.
375
+ * HALT + V-counter is the IRQ-era replacement.
376
+ *
377
+ * FOOTGUN — why this is a HORIZONTAL scroller: the Y-scroll register (R9)
378
+ * is LATCHED ONCE PER FRAME by the VDP; mid-frame R9 writes do nothing until
379
+ * the next frame, so a "vertical scroll below the HUD" split is impossible on
380
+ * this chip. X-scroll (R8) is sampled per line — that's the one you can change
381
+ * mid-frame. (A vertical or 4-way platformer needs name-table streaming
382
+ * instead — see the MENTAL_MODEL doc.)
383
+ *
384
+ * Requires: R10 programmed, IE1 + IE0 enabled, EI executed once after
385
+ * display-on, the crt0's ack-only ISR, and wait_vblank/wait_split called
386
+ * EVERY frame in this order. R10 reloads after each underflow, so the line
387
+ * IRQ re-fires every HUD_PX lines (47, 95, 143, 191) all the way down the
388
+ * frame — the later wakes harmlessly interrupt game logic (the ISR acks them)
389
+ * and re-halt inside the NEXT wait_vblank(). */
390
+ #define SPLIT_LINE (VIS_Y0 + HUD_PX - 1)
391
+
392
+ static void wait_vblank(void) {
393
+ /* check-first: if game logic overran into vblank, don't sleep a frame */
394
+ while (PORT_V_COUNTER < 0xC0) { __asm__("halt"); }
395
+ gg_vdp_write_reg(8, 0); /* HUD strip renders with X scroll 0 */
396
+ }
397
+
398
+ static void wait_split(void) {
399
+ /* halt-first: vblank work always ends inside vblank (V ≥ 0xC0), and the
400
+ * first wake at V < 0xC0 is the line IRQ at SPLIT_LINE */
401
+ do { __asm__("halt"); } while (PORT_V_COUNTER >= 0xC0);
402
+ gg_vdp_write_reg(8, (uint8_t)(0 - scroll_x)); /* level below the bar */
403
+ }
404
+
405
+ /* ── HARDWARE IDIOM (load-bearing) — hi-score in Sega-mapper cart RAM ────────
406
+ * Same cartridge mapper as the SMS. The control register at $FFFC: bit 3
407
+ * maps the cart's 8KB battery RAM into $8000-$BFFF (bank slot 2). Map → copy
408
+ * → unmap; keep the window short so stray pointer bugs can't shred the save.
409
+ * The block is magic + value + checksum so a never-written cart (all $FF)
410
+ * reads back as "no save" instead of a garbage hi-score.
411
+ *
412
+ * NOTE the $FFFC address: it's IN the WRAM mirror ($C000-$DFFF mirrors at
413
+ * $E000-$FFFF), so this write also lands in WRAM at $DFFC — the mapper just
414
+ * snoops the bus. That's why the crt0 parks SP at $DFF0: the bytes above it
415
+ * ($DFFC-$FFFF) belong to the mapper registers' shadow.
416
+ *
417
+ * HONESTY (verified 2026-06-10 against the bundled gpgx core, same finding as
418
+ * the SMS example): gpgx only instantiates the Sega mapper for ROMs LARGER
419
+ * than 48KB, and this build pipeline emits 32KB ROMs — so in-emulator the
420
+ * $8000 window stays open-bus (reads $FF), the magic check fails, and the game
421
+ * falls back to the WRAM hi-score (in-session only). The code below is still
422
+ * the correct real-hardware idiom and lights up unchanged on a >48KB build or
423
+ * a cart with battery RAM: the load path is self-falsifying, never wrong. (The
424
+ * verify harness proves it end-to-end by padding this exact ROM to 64KB.) */
425
+ #define MAPPER_CTRL (*(volatile uint8_t *)0xFFFC)
426
+ #define CART_RAM ((volatile uint8_t *)0x8000)
427
+
428
+ static void hiscore_save(uint16_t v) {
429
+ uint8_t lo = (uint8_t)(v & 0xFF), hi = (uint8_t)(v >> 8);
430
+ MAPPER_CTRL = 0x08; /* map cart RAM at $8000 */
431
+ CART_RAM[0] = 0x48; /* 'H' */
432
+ CART_RAM[1] = 0x53; /* 'S' */
433
+ CART_RAM[2] = lo;
434
+ CART_RAM[3] = hi;
435
+ CART_RAM[4] = (uint8_t)(lo ^ hi ^ 0xA5);
436
+ MAPPER_CTRL = 0x00; /* back to ROM in slot 2 */
437
+ }
438
+
439
+ static uint16_t hiscore_load(void) {
440
+ uint16_t v = 0;
441
+ MAPPER_CTRL = 0x08;
442
+ if (CART_RAM[0] == 0x48 && CART_RAM[1] == 0x53 &&
443
+ CART_RAM[4] == (uint8_t)(CART_RAM[2] ^ CART_RAM[3] ^ 0xA5)) {
444
+ v = (uint16_t)(CART_RAM[2] | ((uint16_t)CART_RAM[3] << 8));
113
445
  }
114
- return 0;
446
+ MAPPER_CTRL = 0x00;
447
+ return v;
448
+ }
449
+
450
+ /* ── GAME LOGIC (clay) — xorshift16 PRNG (~tens of cycles per call) ── */
451
+ static uint8_t random8(void) {
452
+ uint16_t r = rng;
453
+ r ^= r << 7;
454
+ r ^= r >> 9;
455
+ r ^= r << 8;
456
+ rng = r;
457
+ return (uint8_t)r;
458
+ }
459
+
460
+ static uint8_t dist8(uint8_t a, uint8_t b) {
461
+ return (a > b) ? (uint8_t)(a - b) : (uint8_t)(b - a);
462
+ }
463
+
464
+ /* ── GAME LOGIC (clay) — text via the font tiles ─────────────────────────────
465
+ * These write the name table directly, so call them only during vblank (or
466
+ * with the display off): VRAM access during active display races the VDP's
467
+ * own fetches and drops/garbles bytes on real hardware. Rows/cols here are
468
+ * WINDOW coordinates (0..17 / 0..19) — VROW/VCOL add the border offset, so
469
+ * text can never accidentally land in the unseen 256×192 margin. */
470
+ static uint8_t font_tile(char ch) {
471
+ if (ch >= '0' && ch <= '9') return (uint8_t)(FONT_BASE + (ch - '0'));
472
+ if (ch >= 'A' && ch <= 'Z') return (uint8_t)(FONT_BASE + 10 + (ch - 'A'));
473
+ if (ch == '-') return (uint8_t)(FONT_BASE + 36);
474
+ return 0; /* space → blank tile */
475
+ }
476
+
477
+ static void text_draw(uint8_t vrow, uint8_t vcol, const char *s) {
478
+ uint8_t col = VCOL(vcol);
479
+ while (*s) gg_set_tilemap_cell(VROW(vrow), col++, font_tile(*s++), 0);
480
+ }
481
+
482
+ static void draw_u16(uint8_t vrow, uint8_t vcol, uint16_t v) {
483
+ uint8_t d[5], i;
484
+ for (i = 0; i < 5; i++) { d[i] = (uint8_t)(v % 10); v /= 10; }
485
+ for (i = 0; i < 5; i++)
486
+ gg_set_tilemap_cell(VROW(vrow), (uint8_t)(VCOL(vcol) + i),
487
+ (uint8_t)(FONT_BASE + d[4 - i]), 0);
488
+ }
489
+
490
+ /* ── GAME LOGIC (clay) — HUD: P# lives SC sssss HI hhhhh on window row 0 ──
491
+ * Layout in window columns (0..19): P@0 p#@1 lives@3 SC@5 score@8-12 HI@14
492
+ * hi@16... only the last two hi-digits would spill past col 19, so the
493
+ * hi-score shows its low 3 digits — plenty for a distance/coin score, and the
494
+ * whole strip stays inside the visible 20 columns. */
495
+ static void draw_hud_labels(void) {
496
+ text_draw(0, 0, "P");
497
+ text_draw(0, 5, "SC");
498
+ text_draw(0, 14, "HI");
115
499
  }
116
500
 
117
- /* Is world cell (col,row) inside any platform? */
118
- static uint8_t cell_is_wall(int16_t col, uint8_t row) {
119
- int16_t cx = col << 3;
120
- int16_t cy = (int16_t)row << 3;
121
- uint8_t i;
122
- const Rect *p;
123
- for (i = 0; i < N_PLATFORMS; i++) {
124
- p = &platforms[i];
125
- if (cx + 8 > p->x && cx < p->x + p->w
126
- && cy + 8 > p->y && cy < p->y + p->h) return 1;
501
+ static void draw_hud(void) {
502
+ gg_set_tilemap_cell(VROW(0), VCOL(1), (uint8_t)(FONT_BASE + 1 + cur_player), 0); /* '1'/'2' */
503
+ gg_set_tilemap_cell(VROW(0), VCOL(3),
504
+ (uint8_t)(FONT_BASE + (p_lives[cur_player] > 9 ? 9 : p_lives[cur_player])), 0);
505
+ draw_u16(0, 8, p_score[cur_player]);
506
+ /* hi-score: low 3 digits at window cols 16..18 (inside the window) */
507
+ {
508
+ uint16_t h = hiscore;
509
+ uint8_t d0 = (uint8_t)(h % 10); h /= 10;
510
+ uint8_t d1 = (uint8_t)(h % 10); h /= 10;
511
+ uint8_t d2 = (uint8_t)(h % 10);
512
+ gg_set_tilemap_cell(VROW(0), VCOL(16), (uint8_t)(FONT_BASE + d2), 0);
513
+ gg_set_tilemap_cell(VROW(0), VCOL(17), (uint8_t)(FONT_BASE + d1), 0);
514
+ gg_set_tilemap_cell(VROW(0), VCOL(18), (uint8_t)(FONT_BASE + d0), 0);
127
515
  }
128
- return 0;
129
516
  }
130
517
 
131
- /* Write one world column into its wrapped name-table column. */
132
- static void paint_column(int16_t worldCol) {
133
- uint8_t ntCol;
134
- uint8_t row;
135
- if (worldCol < 0 || worldCol >= WORLD_COLS) return;
136
- ntCol = (uint8_t)(worldCol & 31);
137
- for (row = 0; row < VIS_ROWS; row++)
138
- gg_set_tilemap_cell(row, ntCol, cell_is_wall(worldCol, row) ? T_WALL : T_OPEN, 0);
518
+ /* ── GAME LOGIC (clay) screen painters ─────────────────────────────────────
519
+ * Full-screen repaints happen with the DISPLAY OFF (free VRAM access, and a
520
+ * clean cut instead of a visible wipe). While the display is off the frame
521
+ * IRQ doesn't fire — so no halt-based waits in here, or you hang forever.
522
+ *
523
+ * IRQ-RACE FOOTGUN (cost the GG shmup a letter of its own title): repaints
524
+ * also run with INTERRUPTS OFF — the di/ei bracket below. Display-off stops
525
+ * the FRAME IRQ but NOT the LINE IRQ (R0's IE1 stays set; the line counter
526
+ * runs every scanline regardless of blanking). The crt0's ISR acks by READING
527
+ * the control port ($BF) — and that read also resets the VDP's two-byte
528
+ * address-latch state machine. If the line IRQ fires between the two bytes of
529
+ * a gg_vdp_set_addr() control-port pair, the second byte is taken as a new
530
+ * first byte, the VRAM address de-syncs, and one cell of your repaint lands
531
+ * somewhere else. Per-frame writes inside wait_vblank don't need the bracket:
532
+ * vblank has no line IRQs and the frame IRQ was already consumed by the halt
533
+ * that woke us. */
534
+ /* PERF FOOTGUN (inherited from the SMS example, found the slow way): per-cell
535
+ * gg_set_tilemap_cell redoes the 4-OUT address setup for every cell — over a
536
+ * full screen that's seconds of black. Set the VRAM address ONCE per row (the
537
+ * data port autoincrements through the row's 64 bytes) and stream. We paint
538
+ * all 32 columns (not just the visible 20): the off-window cells scroll INTO
539
+ * view as R8 drifts the field. */
540
+ static uint8_t field_tile(uint8_t r, uint8_t c) {
541
+ uint8_t g = ground_row[c];
542
+ if (r == plat_row[c]) return BG_GRASS; /* one-way floating slab */
543
+ if (g != NO_GROUND) {
544
+ if (r == g) return BG_GRASS; /* ground surface */
545
+ if (r > g) return BG_DIRT; /* ground body */
546
+ }
547
+ /* sparse clouds in the visible sky band — add/compare counters, no division */
548
+ if (r >= 7 && r <= 11 && ((uint8_t)(r * 7 + c * 5) & 15) == 0) return BG_CLOUD;
549
+ return 0; /* sky */
550
+ }
551
+
552
+ static void paint_rows(uint8_t from_row) {
553
+ uint8_t r, c;
554
+ for (r = from_row; r < 24; r++) {
555
+ gg_vdp_set_addr((uint16_t)(0x3800 + (uint16_t)r * 64), VDP_VRAM_WRITE);
556
+ for (c = 0; c < 32; c++) {
557
+ PORT_VDP_DATA = field_tile(r, c); /* name-table entry low byte */
558
+ PORT_VDP_DATA = 0; /* high byte: flips/palette/priority */
559
+ }
560
+ }
561
+ }
562
+
563
+ static void paint_title(void) {
564
+ __asm__("di"); /* see IRQ-RACE FOOTGUN above */
565
+ gg_vdp_display_off();
566
+ paint_rows(0); /* the level itself is the backdrop */
567
+ text_draw(4, (uint8_t)((VIS_COLS - (sizeof(GAME_TITLE) - 1)) / 2), GAME_TITLE);
568
+ text_draw(8, 4, "1P START - 1");
569
+ text_draw(10, 4, "2P TURNS - 2");
570
+ text_draw(14, 7, "HI");
571
+ draw_u16(14, 10, hiscore);
572
+ gg_sprite_init(); /* park every sprite off-screen */
573
+ gg_sat_upload();
574
+ gg_vdp_write_reg(8, 0);
575
+ gg_vdp_display_on(); /* re-enables the frame IRQ too */
576
+ __asm__("ei"); /* interrupts back on LAST — regs are set */
577
+ }
578
+
579
+ static void paint_field(void) {
580
+ uint8_t c;
581
+ __asm__("di"); /* see IRQ-RACE FOOTGUN above */
582
+ gg_vdp_display_off();
583
+ for (c = 0; c < 32; c++) {
584
+ gg_set_tilemap_cell(VROW(0), c, 0, 0); /* row 0: HUD text row */
585
+ gg_set_tilemap_cell(VROW(1), c, 0, 0); /* row 1: breathing room */
586
+ gg_set_tilemap_cell(VROW(2), c, BG_HUDBAR, 0); /* row 2: bar = divider + seam */
587
+ }
588
+ paint_rows(VIS_ROW0 + HUD_ROWS);
589
+ draw_hud_labels();
590
+ draw_hud();
591
+ gg_sprite_init();
592
+ gg_sat_upload();
593
+ gg_vdp_write_reg(8, 0);
594
+ gg_vdp_display_on();
595
+ __asm__("ei");
596
+ }
597
+
598
+ /* ── GAME LOGIC (clay) — coins + spikes (sprite objects in the world) ──
599
+ * Coin heights are FULL-FRAME hardware Y, all inside the playfield band
600
+ * [VIS_Y0+HUD_PX .. VIS_Y1] = [48..167]. */
601
+ static const uint8_t coin_heights[4] = { 136, 112, 88, 104 };
602
+ static void respawn_coin(uint8_t i) {
603
+ coin_x[i] = (uint8_t)(VIS_X1 - 16 + (random8() & 15)); /* enter at window right */
604
+ coin_y[i] = coin_heights[random8() & 3];
605
+ }
606
+
607
+ static void try_spawn_spike(uint8_t i) {
608
+ /* Anchor only over ground: an inactive spike rolls a low per-frame chance,
609
+ * and only spawns if the level column entering at the window's right edge
610
+ * has ground under it (never floats over a pit). */
611
+ uint8_t c = (uint8_t)(VIS_X1 + scroll_x) >> 3;
612
+ if (ground_row[c] == NO_GROUND) return;
613
+ if (random8() > 4) return;
614
+ spike_x[i] = VIS_X1 - 8;
615
+ spike_active[i] = 1;
139
616
  }
140
617
 
141
- static void paint_initial(void) {
142
- int16_t c;
143
- for (c = 0; c < 32; c++) paint_column(c);
618
+ /* ── GAME LOGIC (clay) — start a turn / a run / end a run ── */
619
+ static void begin_turn(void) {
620
+ /* NO direct VRAM writes here this runs mid-frame from kill_player()
621
+ * (active display). The HUD change goes through hud_dirty and lands in the
622
+ * next vblank; the level needs no repaint (it's 256-px periodic and
623
+ * scroll_x=0 just snaps the window back to the start). */
624
+ px = VIS_X0 + 16;
625
+ py_q44 = (uint16_t)(GROUND_TOP - 8) << 4;
626
+ vy_q44 = 0;
627
+ on_ground = 1;
628
+ scroll_x = 0;
629
+ dist_sub = 0;
630
+ coin_x[0] = VIS_X0 + 40; coin_y[0] = 136; /* col 11 (ground) area */
631
+ coin_x[1] = VIS_X0 + 88; coin_y[1] = 112; /* over the mid slab */
632
+ coin_x[2] = VIS_X0 + 136; coin_y[2] = 88; /* high, near the loop */
633
+ spike_x[0] = VIS_X0 + 56; spike_active[0] = 1; /* col 13 (ground) */
634
+ spike_x[1] = VIS_X0 + 88; spike_active[1] = 1; /* col 17 (ground) */
635
+ turn_pause = 48; /* "P1/P2 ready" breather */
636
+ prev_pad = 0xFF; /* swallow held buttons across *
637
+ * the turn change */
638
+ hud_dirty = 1; /* shows the new P#/lives */
639
+ }
640
+
641
+ static void start_game(uint8_t players) {
642
+ two_player = players;
643
+ cur_player = 0;
644
+ p_score[0] = p_score[1] = 0;
645
+ p_lives[0] = START_LIVES;
646
+ p_lives[1] = players ? START_LIVES : 0;
647
+ paint_field(); /* display-off repaint — safe */
648
+ begin_turn();
649
+ sfx_tone(2, 254, 8); /* start jingle (A4) */
650
+ state = ST_PLAY;
651
+ }
652
+
653
+ static void game_over(void) {
654
+ uint16_t best = p_score[0];
655
+ if (two_player && p_score[1] > best) best = p_score[1];
656
+ if (best > hiscore) {
657
+ hiscore = best;
658
+ hiscore_save(hiscore); /* cart RAM (real hardware); WRAM copy is live */
659
+ }
660
+ sfx_noise(20);
661
+ state = ST_OVER;
662
+ over_step = 4; /* results text, one piece per vblank — each
663
+ * draw_u16 is 5 software divisions, and the
664
+ * vblank→split budget only fits one (see the
665
+ * BUDGET FOOTGUN at the main loop) */
666
+ }
667
+
668
+ /* ── GAME LOGIC (clay) — death + alternating-turn handoff ── */
669
+ static void kill_player(void) {
670
+ uint8_t other;
671
+ sfx_noise(14);
672
+ if (p_lives[cur_player] > 0) --p_lives[cur_player];
673
+ if (two_player) {
674
+ other = cur_player ^ 1;
675
+ if (p_lives[other] > 0) cur_player = other; /* swap turns */
676
+ else if (p_lives[cur_player] == 0) { game_over(); return; }
677
+ } else if (p_lives[0] == 0) {
678
+ game_over();
679
+ return;
680
+ }
681
+ begin_turn();
682
+ }
683
+
684
+ /* ── GAME LOGIC (clay) — landing probe against the column map ──────────────
685
+ * One-way platforms, classic style: only catch the player while FALLING
686
+ * through a narrow window at the surface. The window is 6 px tall — top-1
687
+ * (the standing snap parks feet at top, and gravity's sub-pixel trickle
688
+ * doesn't move the integer Y every frame; without the -1 slack the player
689
+ * "stands" with on_ground=0 most frames, so jumps only register on lucky
690
+ * frames and the idle/jump sprite flickers) through top+4 (so a 5 px/frame
691
+ * terminal-velocity fall can't step over it). */
692
+ static uint8_t land_top(uint8_t c, uint8_t feet) {
693
+ uint8_t r, top;
694
+ r = plat_row[c];
695
+ if (r) {
696
+ top = (uint8_t)(r << 3);
697
+ if ((uint8_t)(feet + 1) >= top && feet <= (uint8_t)(top + 4)) return top;
698
+ }
699
+ r = ground_row[c];
700
+ if (r != NO_GROUND) {
701
+ top = (uint8_t)(r << 3);
702
+ if ((uint8_t)(feet + 1) >= top && feet <= (uint8_t)(top + 4)) return top;
703
+ }
704
+ return 0;
705
+ }
706
+
707
+ /* Stage the SAT shadow for this frame. Inactive slots park at Y=$E0 (below
708
+ * the 192-line area AND below the LCD window). NEVER park at Y=$D0 — that's
709
+ * the SAT terminator: the VDP stops scanning at the first $D0 and every later
710
+ * slot vanishes. Slot map: 0 = player, 1-3 coins, 4-5 spikes — 6 of 64 slots;
711
+ * mind the 8-sprites-PER-SCANLINE limit when adding rows of objects (the 9th
712
+ * sprite on a line silently vanishes). */
713
+ static void stage_sprites(void) {
714
+ uint8_t i, y8 = (uint8_t)(py_q44 >> 4);
715
+ /* Blink the player during the turn-change breather. */
716
+ uint8_t show = (turn_pause == 0 || (turn_pause & 4));
717
+ gg_sprite_set(0, px, show ? y8 : 0xE0,
718
+ on_ground ? T_PLAYER_IDLE : T_PLAYER_JUMP);
719
+ for (i = 0; i < NUM_COINS; i++)
720
+ gg_sprite_set((uint8_t)(1 + i), coin_x[i], coin_y[i], T_COIN);
721
+ for (i = 0; i < NUM_SPIKES; i++)
722
+ gg_sprite_set((uint8_t)(4 + i), spike_x[i], spike_active[i] ? SPIKE_Y : 0xE0, T_SPIKE);
144
723
  }
145
724
 
146
725
  void main(void) {
147
- /* Start above the x=32..88 platform (world y=144) so the player lands on
148
- * it inside the GG visible vertical band [VIS_Y0..VIS_Y1], not on the
149
- * floor at world y=176 which sits just below the visible window. */
150
- int16_t px = 48 << 4, py = 96 << 4;
151
- int16_t vx = 0, vy = 0;
152
- int16_t camX = 0, lastCamCol = 0;
153
- uint8_t prev = 0;
154
- const int16_t GRAVITY = 10;
155
- const int16_t MOVE = 20;
156
- const int16_t JUMP = -180;
157
- const int16_t MAXFALL = 280;
158
-
159
- gg_vdp_init();
160
- gg_load_palette(palette);
161
- gg_load_tiles(0x0000, bg_tiles, 64);
162
- gg_load_tiles(0x2000, player_tile, 32);
163
- paint_initial();
726
+ uint8_t i, pad, delta, y8, feet, c0, c1, top, killed;
164
727
 
728
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
729
+ * Init order: VDP regs (display off) → palette → tiles → name table →
730
+ * SAT → R10 → display on (which also enables the frame IRQ) → EI. The
731
+ * one hard rule: EI comes LAST, after every register is in place — the
732
+ * crt0 boots with DI and the FIRST halt would hang forever if interrupts
733
+ * were never enabled. (paint_title's trailing __asm__("ei") IS that final
734
+ * step here — every repaint ends by re-arming interrupts.) */
735
+ gg_vdp_init(); /* R0=0x36 already has IE1 (line IRQ) set */
736
+ gg_load_palette(palette);
737
+ load_font();
738
+ gg_load_tiles((uint16_t)(BG_GRASS * 32), deco_tiles, 128);
739
+ gg_load_tiles(0x2000, sprite_tiles, 32 * 4);
165
740
  gg_sprite_init();
166
741
  sfx_init();
167
742
  music_init();
168
- music_play(0); /* continuous background music ("no sound" was the playtest verdict) */
169
- gg_sprite_set(0, (uint8_t)(px >> 4), (uint8_t)(py >> 4), 0);
170
- gg_sat_upload();
171
- gg_vdp_display_on();
743
+ music_play(0);
744
+
745
+ /* R10 = SPLIT_LINE arms the line counter: IRQ at the last bar line —
746
+ * scanline 47 in FULL-FRAME terms (window top 24 + HUD 24 - 1). Set
747
+ * once — it reloads itself every underflow. */
748
+ gg_vdp_write_reg(10, SPLIT_LINE);
749
+
750
+ hiscore = hiscore_load(); /* cart RAM if present — else 0 */
751
+ state = ST_TITLE;
752
+ paint_title(); /* …ends with EI: interrupts live now */
753
+
754
+ for (;;) {
755
+ if (state == ST_TITLE) {
756
+ /* ── GAME LOGIC (clay) — title: button 1 = 1P, button 2 = 2P turns ── */
757
+ wait_vblank();
758
+ sfx_update();
759
+ music_update();
760
+ pad = gg_joypad_read();
761
+ if ((pad & JOY_B1) && !(prev_pad & JOY_B1)) start_game(0);
762
+ else if ((pad & JOY_B2) && !(prev_pad & JOY_B2)) start_game(1);
763
+ prev_pad = pad;
764
+ continue;
765
+ }
766
+
767
+ if (state == ST_OVER) {
768
+ /* Freeze the final frame; button 1 or 2 returns to the title. */
769
+ wait_vblank();
770
+ if (over_step) { /* deferred draws — one per vblank */
771
+ if (over_step == 4) text_draw(8, 6, "GAME OVER");
772
+ else if (over_step == 3) { text_draw(10, 4, "P1"); draw_u16(10, 8, p_score[0]); }
773
+ else if (over_step == 2) { if (two_player) { text_draw(12, 4, "P2"); draw_u16(12, 8, p_score[1]); } }
774
+ else draw_hud(); /* show the (possibly new) hi-score */
775
+ over_step--;
776
+ }
777
+ wait_split(); /* keep the HUD/field split alive */
778
+ sfx_update();
779
+ music_update();
780
+ pad = gg_joypad_read();
781
+ if ((pad & (JOY_B1 | JOY_B2)) && !(prev_pad & (JOY_B1 | JOY_B2))) {
782
+ state = ST_TITLE;
783
+ paint_title();
784
+ }
785
+ prev_pad = pad;
786
+ continue;
787
+ }
172
788
 
173
- do {
174
- uint8_t pad, grounded;
175
- int16_t ipx, ipy, npy, sx;
176
- int16_t camCol;
177
- int32_t np;
178
- uint8_t i;
179
- const Rect *p;
180
- gg_vblank_wait();
789
+ /* ── ST_PLAY ─────────────────────────────────────────────────────────
790
+ * Frame shape: [vblank: SAT + HUD writes, R8=0] → [line IRQ at the bar:
791
+ * R8=-scroll] [rest of frame: game logic]. VRAM traffic stays inside
792
+ * vblank; logic runs while the VDP draws the field.
793
+ *
794
+ * BUDGET FOOTGUN (inherited from the GG shmup, which found it the hard
795
+ * way): everything between wait_vblank() and wait_split() must finish
796
+ * before the line IRQ at scanline 47 — vblank (70 lines) + the 47 lines
797
+ * above the split ≈ 27k cycles (BIGGER than the SMS's: the 24 never-shown
798
+ * border lines are free). The SAT upload eats ~7k of that. An
799
+ * unconditional draw_hud() here (10 software 16-bit divisions for the
800
+ * digits) blows the budget EVERY frame: the seam slips to a later reload
801
+ * of the line counter and the top of the level renders unscrolled in
802
+ * jittery stripes. Hence the dirty flag — the HUD only redraws on the
803
+ * frame after the score/lives/player actually changed. */
804
+ wait_vblank();
805
+ gg_sat_upload(); /* shadow SAT staged at end of last frame */
806
+ if (hud_dirty) {
807
+ hud_dirty = 0;
808
+ draw_hud();
809
+ }
181
810
  sfx_update();
182
811
  music_update();
812
+ wait_split(); /* the line-interrupt split — every frame */
813
+
814
+ if (turn_pause) { /* freeze gameplay, keep the frame honest */
815
+ --turn_pause;
816
+ stage_sprites(); /* blink runs; SAT stays fresh */
817
+ continue;
818
+ }
819
+
820
+ /* ── GAME LOGIC (clay) from here down ──────────────────────────────
821
+ * Input — the CURRENT player's pad (alternating turns: P2 is on port B).
822
+ * Past SCROLL_WALL the world scrolls instead of the player (the camera
823
+ * never scrolls back — the classic one-way camera). */
824
+ pad = cur_player ? gg_joypad_read_p2() : gg_joypad_read();
825
+ delta = 0;
826
+ if (pad & JOY_RIGHT) {
827
+ if (px < SCROLL_WALL) px = (uint8_t)(px + MOVE_SPEED);
828
+ else { scroll_x = (uint8_t)(scroll_x + MOVE_SPEED); delta = MOVE_SPEED; }
829
+ }
830
+ if ((pad & JOY_LEFT) && px > VIS_X0 + 8) px = (uint8_t)(px - MOVE_SPEED);
831
+ if ((pad & JOY_B1) && !(prev_pad & JOY_B1) && on_ground) {
832
+ vy_q44 = JUMP_VEL_Q44;
833
+ on_ground = 0;
834
+ /* Voice 2 doubles as the SFX channel: the whoop steals the bass for a
835
+ * few frames, then sfx_update() silences it and the tracker re-tones it
836
+ * on its next step — classic "sfx wins" arbitration. */
837
+ sfx_tone(2, 220, 6);
838
+ }
839
+ prev_pad = pad;
840
+
841
+ /* World objects drift left as the level scrolls (world-anchored). */
842
+ if (delta) {
843
+ dist_sub = (uint8_t)(dist_sub + delta);
844
+ if (dist_sub >= 64) { /* distance pay */
845
+ dist_sub -= 64;
846
+ ++p_score[cur_player];
847
+ hud_dirty = 1;
848
+ }
849
+ for (i = 0; i < NUM_COINS; i++) {
850
+ if (coin_x[i] < VIS_X0 + delta) respawn_coin(i);
851
+ else coin_x[i] = (uint8_t)(coin_x[i] - delta);
852
+ }
853
+ for (i = 0; i < NUM_SPIKES; i++) {
854
+ if (!spike_active[i]) continue;
855
+ if (spike_x[i] < VIS_X0 + delta) spike_active[i] = 0;
856
+ else spike_x[i] = (uint8_t)(spike_x[i] - delta);
857
+ }
858
+ }
859
+ for (i = 0; i < NUM_SPIKES; i++)
860
+ if (!spike_active[i]) try_spawn_spike(i);
861
+
862
+ /* Physics: gravity + sub-pixel Y. */
863
+ if (vy_q44 < MAX_VY_Q44) vy_q44 += GRAVITY_Q44;
864
+ py_q44 = (uint16_t)(py_q44 + (int16_t)vy_q44);
865
+ y8 = (uint8_t)(py_q44 >> 4);
866
+
867
+ /* Fell into a pit (below the window bottom) → lose the turn. */
868
+ if (y8 >= PIT_KILL_Y) {
869
+ kill_player();
870
+ continue;
871
+ }
872
+
873
+ /* Landing — probe the two level columns under the player's feet. The
874
+ * name table is the full 256-px plane shifted by R8 = -scroll_x, so the
875
+ * WORLD column under full-frame screen pixel `px` is (px + scroll_x) >> 3
876
+ * (& 31 via the uint8 wrap) — same formula the SMS uses, with px already
877
+ * in full-frame hardware units (VIS_X0 is baked into px, not subtracted). */
878
+ if (vy_q44 >= 0) {
879
+ feet = (uint8_t)(y8 + 8);
880
+ c0 = (uint8_t)(px + scroll_x) >> 3;
881
+ c1 = (uint8_t)(px + scroll_x + 7) >> 3;
882
+ top = land_top(c0, feet);
883
+ if (top == 0) top = land_top(c1, feet);
884
+ if (top) {
885
+ py_q44 = (uint16_t)(top - 8) << 4;
886
+ vy_q44 = 0;
887
+ if (!on_ground) sfx_tone(2, 400, 2); /* landing thud */
888
+ on_ground = 1;
889
+ } else {
890
+ on_ground = 0; /* walked off */
891
+ }
892
+ }
183
893
 
184
- ipx = px >> 4;
185
- ipy = py >> 4;
186
-
187
- /* Camera follows the player, centered on the 160-px window, clamped. */
188
- camX = ipx - (SCREEN_W / 2 - 4);
189
- if (camX < 0) camX = 0;
190
- if (camX > WORLD_W - SCREEN_W) camX = WORLD_W - SCREEN_W;
191
-
192
- /* Stream columns entering from the right as the camera advances, and
193
- * from the left if it retreats. Note: the GG VDP fetches all 32
194
- * columns even though only 160 px is shown, so we still stream the
195
- * column at camCol + 32 (just past the fetched window's right edge). */
196
- camCol = camX >> 3;
197
- while (camCol > lastCamCol) { lastCamCol++; paint_column(lastCamCol + 31); }
198
- while (camCol < lastCamCol) { lastCamCol--; paint_column(lastCamCol); }
199
-
200
- /* Scroll so world column (camX/8) lands at fetch pixel VIS_X0 — the
201
- * left edge of the GG visible window. (SMS shows the whole 256-px
202
- * fetch and uses R8 = -camX; the GG only shows the centered 160-px
203
- * crop, so we bias by +VIS_X0.) */
204
- gg_vdp_write_reg(8, (uint8_t)((VIS_X0 - camX) & 0xFF));
205
-
206
- /* Player X is biased into the visible window so it stays pinned to the
207
- * horizontally-scrolled BG: sx = (worldX - camX) + VIS_X0. The BG is
208
- * NOT vertically scrolled (R9 = 0), so world rows map 1:1 to fetch rows
209
- * and the player Y needs no bias — world Y already lands in the visible
210
- * band [VIS_Y0..VIS_Y1] for the level layout below. */
211
- sx = (ipx - camX) + VIS_X0;
212
- gg_sprite_set(0, (uint8_t)sx, (uint8_t)ipy, 0);
213
- gg_sat_upload();
214
-
215
- pad = gg_joypad_read();
216
- vx = 0;
217
- if (pad & JOY_LEFT) vx = (int16_t)(-MOVE);
218
- if (pad & JOY_RIGHT) vx = MOVE;
219
-
220
- grounded = on_platform(ipx, ipy);
221
- if ((pad & JOY_B1) && !(prev & JOY_B1) && grounded) { vy = JUMP; sfx_tone(0, 300, 6); }
222
- prev = pad;
223
-
224
- vy = (int16_t)(vy + GRAVITY);
225
- if (vy > MAXFALL) vy = MAXFALL;
226
- if (grounded && vy > 0) vy = 0;
227
-
228
- px = (int16_t)(px + vx);
229
- if (px < 0) px = 0;
230
- if (px > ((WORLD_W - 8) << 4)) px = (int16_t)((WORLD_W - 8) << 4);
231
-
232
- np = (int32_t)py + (int32_t)vy;
233
- npy = (int16_t)(np >> 4);
234
- if (vy > 0) {
235
- uint8_t landed = 0;
236
- for (i = 0; i < N_PLATFORMS; i++) {
237
- p = &platforms[i];
238
- if (ipy + 8 <= p->y && npy + 8 >= p->y
239
- && ipx + 8 > p->x && ipx < p->x + p->w) {
240
- py = (int16_t)((p->y - 8) << 4);
241
- vy = 0;
242
- landed = 1;
243
- break;
244
- }
894
+ /* Coins (collect) + spikes (death). */
895
+ for (i = 0; i < NUM_COINS; i++) {
896
+ if (dist8(coin_x[i], px) < 8 && dist8(coin_y[i], y8) < 8) {
897
+ p_score[cur_player] += 10;
898
+ sfx_tone(2, 140, 4); /* coin ping */
899
+ hud_dirty = 1;
900
+ respawn_coin(i);
245
901
  }
246
- if (!landed) py = (int16_t)np;
247
- } else {
248
- py = (int16_t)np;
249
902
  }
250
- if (py > (192 << 4)) { py = 0; vy = 0; }
251
- } while (1);
903
+ killed = 0;
904
+ for (i = 0; i < NUM_SPIKES; i++) {
905
+ if (!spike_active[i]) continue;
906
+ if (dist8(spike_x[i], px) < 7 && dist8(SPIKE_Y, y8) < 7) {
907
+ killed = 1;
908
+ break;
909
+ }
910
+ }
911
+ if (killed) kill_player();
912
+
913
+ /* Stage the SAT shadow NOW (RAM only — cheap, any time); the actual VRAM
914
+ * upload waits for the next vblank at the top of the loop. */
915
+ stage_sprites();
916
+ }
252
917
  }