romdevtools 0.27.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 (199) hide show
  1. package/AGENTS.md +56 -44
  2. package/CHANGELOG.md +355 -0
  3. package/README.md +4 -4
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1227 -325
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +909 -257
  8. package/examples/atari2600/templates/shmup.asm +1035 -218
  9. package/examples/atari2600/templates/sports.asm +1143 -229
  10. package/examples/atari7800/templates/hello_sprite.c +8 -4
  11. package/examples/atari7800/templates/platformer.c +991 -152
  12. package/examples/atari7800/templates/puzzle.c +1091 -145
  13. package/examples/atari7800/templates/racing.c +949 -118
  14. package/examples/atari7800/templates/shmup.c +812 -130
  15. package/examples/atari7800/templates/sports.c +820 -181
  16. package/examples/c64/templates/platformer.c +876 -157
  17. package/examples/c64/templates/puzzle.c +881 -143
  18. package/examples/c64/templates/racing.c +873 -88
  19. package/examples/c64/templates/shmup.c +762 -154
  20. package/examples/c64/templates/sports.c +755 -95
  21. package/examples/gb/templates/platformer.c +841 -175
  22. package/examples/gb/templates/puzzle.c +1094 -176
  23. package/examples/gb/templates/racing.c +761 -169
  24. package/examples/gb/templates/shmup.c +679 -169
  25. package/examples/gb/templates/sports.c +790 -153
  26. package/examples/gba/templates/platformer.c +624 -169
  27. package/examples/gba/templates/puzzle.c +535 -207
  28. package/examples/gba/templates/racing.c +513 -196
  29. package/examples/gba/templates/shmup.c +565 -168
  30. package/examples/gba/templates/sports.c +454 -162
  31. package/examples/gbc/templates/platformer.c +944 -176
  32. package/examples/gbc/templates/puzzle.c +1131 -177
  33. package/examples/gbc/templates/racing.c +891 -175
  34. package/examples/gbc/templates/shmup.c +827 -179
  35. package/examples/gbc/templates/sports.c +870 -156
  36. package/examples/genesis/templates/platformer.c +747 -129
  37. package/examples/genesis/templates/puzzle.c +702 -208
  38. package/examples/genesis/templates/racing.c +728 -193
  39. package/examples/genesis/templates/shmup.c +535 -142
  40. package/examples/genesis/templates/shmup_2p.c +13 -1
  41. package/examples/genesis/templates/sports.c +495 -158
  42. package/examples/gg/templates/platformer.c +883 -214
  43. package/examples/gg/templates/puzzle.c +906 -181
  44. package/examples/gg/templates/racing.c +919 -160
  45. package/examples/gg/templates/shmup.c +716 -177
  46. package/examples/gg/templates/sports.c +735 -128
  47. package/examples/lynx/templates/platformer.c +604 -50
  48. package/examples/lynx/templates/puzzle.c +533 -130
  49. package/examples/lynx/templates/racing.c +538 -102
  50. package/examples/lynx/templates/shmup.c +461 -122
  51. package/examples/lynx/templates/sports.c +496 -69
  52. package/examples/msx/platformer/main.c +648 -159
  53. package/examples/msx/puzzle/main.c +750 -185
  54. package/examples/msx/racing/main.c +669 -177
  55. package/examples/msx/shmup/main.c +460 -177
  56. package/examples/msx/sports/main.c +591 -124
  57. package/examples/nes/templates/platformer.c +586 -160
  58. package/examples/nes/templates/puzzle.c +603 -222
  59. package/examples/nes/templates/racing.c +505 -197
  60. package/examples/nes/templates/shmup.c +339 -144
  61. package/examples/nes/templates/sports.c +341 -182
  62. package/examples/pce/platformer/main.c +875 -204
  63. package/examples/pce/puzzle/main.c +797 -216
  64. package/examples/pce/racing/main.c +782 -206
  65. package/examples/pce/shmup/main.c +638 -211
  66. package/examples/pce/sports/main.c +585 -167
  67. package/examples/porting-across-platforms/README.md +1 -1
  68. package/examples/sms/templates/platformer.c +765 -176
  69. package/examples/sms/templates/puzzle.c +783 -177
  70. package/examples/sms/templates/racing.c +812 -133
  71. package/examples/sms/templates/shmup.c +601 -148
  72. package/examples/sms/templates/shmup_2p.c +17 -1
  73. package/examples/sms/templates/sports.c +633 -121
  74. package/examples/snes/templates/music_demo.c +7 -0
  75. package/examples/snes/templates/platformer-data.asm +123 -24
  76. package/examples/snes/templates/platformer-hdr.asm +57 -0
  77. package/examples/snes/templates/platformer.c +587 -149
  78. package/examples/snes/templates/puzzle-data.asm +116 -21
  79. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  80. package/examples/snes/templates/puzzle.c +632 -185
  81. package/examples/snes/templates/racing-data.asm +390 -32
  82. package/examples/snes/templates/racing-hdr.asm +57 -0
  83. package/examples/snes/templates/racing.c +807 -177
  84. package/examples/snes/templates/shmup-data.asm +87 -29
  85. package/examples/snes/templates/shmup-hdr.asm +57 -0
  86. package/examples/snes/templates/shmup.c +459 -180
  87. package/examples/snes/templates/sports-data.asm +48 -2
  88. package/examples/snes/templates/sports-hdr.asm +57 -0
  89. package/examples/snes/templates/sports.c +414 -156
  90. package/package.json +12 -12
  91. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  92. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  93. package/src/cores/wasm/fceumm_libretro.js +1 -1
  94. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  95. package/src/cores/wasm/gambatte_libretro.js +1 -1
  96. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  97. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  98. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  99. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  100. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  101. package/src/cores/wasm/handy_libretro.js +1 -1
  102. package/src/cores/wasm/handy_libretro.wasm +0 -0
  103. package/src/cores/wasm/mgba_libretro.js +1 -1
  104. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  105. package/src/cores/wasm/prosystem_libretro.js +1 -1
  106. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  107. package/src/cores/wasm/snes9x_libretro.js +1 -1
  108. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  109. package/src/cores/wasm/stella2014_libretro.js +1 -1
  110. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  111. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  112. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  113. package/src/host/LibretroHost.js +304 -11
  114. package/src/http/tool-registry.js +11 -11
  115. package/src/mcp/server.js +6 -0
  116. package/src/mcp/tools/cheats.js +2 -1
  117. package/src/mcp/tools/disasm-rebuild.js +315 -65
  118. package/src/mcp/tools/disasm.js +149 -28
  119. package/src/mcp/tools/find-references.js +216 -51
  120. package/src/mcp/tools/frame.js +14 -6
  121. package/src/mcp/tools/index.js +18 -4
  122. package/src/mcp/tools/input.js +31 -7
  123. package/src/mcp/tools/lifecycle.js +6 -4
  124. package/src/mcp/tools/memory.js +208 -39
  125. package/src/mcp/tools/platform-docs.js +1 -1
  126. package/src/mcp/tools/playtest.js +56 -4
  127. package/src/mcp/tools/preview-tile.js +6 -2
  128. package/src/mcp/tools/project.js +1114 -120
  129. package/src/mcp/tools/rom-id.js +5 -1
  130. package/src/mcp/tools/run-until.js +4 -2
  131. package/src/mcp/tools/snippets.js +6 -6
  132. package/src/mcp/tools/sprite-pipeline.js +14 -2
  133. package/src/mcp/tools/state.js +2 -1
  134. package/src/mcp/tools/tile-inspect.js +8 -1
  135. package/src/mcp/tools/toolchain.js +55 -11
  136. package/src/mcp/tools/watch-memory.js +145 -27
  137. package/src/observer/bus.js +73 -0
  138. package/src/observer/livestream.html +4 -2
  139. package/src/observer/tool-wrap.js +17 -14
  140. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  141. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  142. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  143. package/src/platforms/atari7800/MENTAL_MODEL.md +32 -11
  144. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  145. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  146. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  147. package/src/platforms/gb/MENTAL_MODEL.md +19 -4
  148. package/src/platforms/gb/TROUBLESHOOTING.md +101 -6
  149. package/src/platforms/gb/lib/c/README.md +10 -11
  150. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  151. package/src/platforms/gb/lib/c/patch-header.js +19 -6
  152. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  153. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  154. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  155. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  156. package/src/platforms/gbc/MENTAL_MODEL.md +16 -4
  157. package/src/platforms/gbc/TROUBLESHOOTING.md +24 -3
  158. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  159. package/src/platforms/gbc/lib/c/README.md +10 -11
  160. package/src/platforms/gbc/lib/c/font.h +43 -0
  161. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  162. package/src/platforms/gbc/lib/c/patch-header.js +19 -6
  163. package/src/platforms/genesis/MENTAL_MODEL.md +43 -9
  164. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  165. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  166. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  167. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  168. package/src/platforms/gg/TROUBLESHOOTING.md +14 -18
  169. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  170. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  171. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  172. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  173. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  174. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  175. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  176. package/src/platforms/msx/MENTAL_MODEL.md +11 -5
  177. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  178. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  179. package/src/platforms/msx/lib/c/msx_hw.h +3 -0
  180. package/src/platforms/msx/lib/c/msx_vdp.c +70 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +12 -5
  182. package/src/platforms/nes/lib/c/nes_runtime.c +190 -34
  183. package/src/platforms/nes/lib/c/nes_runtime.h +35 -0
  184. package/src/platforms/pce/MENTAL_MODEL.md +14 -5
  185. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  186. package/src/platforms/pce/lib/c/pce_hw.h +13 -1
  187. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  188. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  189. package/src/platforms/sms/MENTAL_MODEL.md +11 -6
  190. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  191. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  192. package/src/platforms/snes/MENTAL_MODEL.md +7 -2
  193. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  194. package/src/playtest/playtest.js +73 -3
  195. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  197. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  198. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  199. package/src/toolchains/index.js +64 -19
@@ -1,133 +1,432 @@
1
- /*
2
- * PC Engine "platformer" — a side-scrolling platformer scaffold.
1
+ /* ── main.c — PC Engine side-scrolling platformer (complete example game) ─────
3
2
  *
4
- * Run and jump across a world wider than one screen. The d-pad moves left/right,
5
- * button I jumps; gravity pulls you down and you land on top of solid platforms.
6
- * The camera follows the player and the background scrolls smoothly via the VDC
7
- * background X-scroll register (BXR/R7).
3
+ * A COMPLETE, working game title screen, 1P mode and 2P ALTERNATING-TURNS
4
+ * mode (arcade-classic: players swap on death; each player has their own
5
+ * score and own 3 lives; player 2 plays on the SECOND pad), coins + distance
6
+ * scoring, in-session hi-score (a bare HuCard can't save — see the hi-score
7
+ * note below), music + SFX, and TWO of
8
+ * the PC Engine's signature features working together:
9
+ * - HARDWARE BG SCROLL: a world wider than one screen scrolled with the
10
+ * VDC's BXR register (zero per-frame tilemap rewrites once a column is
11
+ * painted) — the smoothest, cheapest scroll of any 8-bit machine.
12
+ * - LARGE MULTI-CELL SPRITES: the hero is a 32x32 HuC6270 sprite from ONE
13
+ * SATB entry (four 16x16 cells, 4-aligned pattern) — the kind of big,
14
+ * readable character the NES needs 4+ hardware sprites to draw.
8
15
  *
9
- * The PCE BAT (background map) is a 32x32 virtual screen (256px) that WRAPS, so
10
- * a world wider than 256px needs COLUMN STREAMING: each time the camera crosses
11
- * an 8px boundary we rewrite the BAT column that is about to scroll into view
12
- * with the next world column's tiles. This mirrors the SMS platformer scaffold,
13
- * using BXR instead of SMS R8.
16
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
17
+ * very different one. The markers tell you what's what:
18
+ * HARDWARE IDIOM (load-bearing) dodges a documented PCE footgun; reshape
19
+ * your gameplay around it (see TROUBLESHOOTING before changing).
20
+ * GAME LOGIC (clay) level layout, physics tuning, scoring, art: reshape
21
+ * freely.
14
22
  *
15
- * PCE notes (see pce_hw.h / MENTAL_MODEL.md):
16
- * - disp_enable() turns on BG + sprites + the VBlank IRQ (waitvsync needs it).
17
- * - .bss must be non-empty (pce_video.c's _pce_keep[] covers it).
18
- * - we set BXR every frame via vdc_set_reg(VDC_BXR, camX) for smooth scroll.
23
+ * What depends on what:
24
+ * pce_hw.h / pce_video.c / pce_input.c / pce_sound.c the helper lib
25
+ * (VDC/VCE/PSG register dances + joypad). The HARDWARE IDIOM markers in
26
+ * pce_video.c say which parts are load-bearing.
27
+ * cc65's pce crt0 + pce.lib are auto-linked; the 'rom32k' linker preset
28
+ * (applied automatically to example projects) gives a 32KB HuCard.
19
29
  *
20
- * cc65 is C89 declare locals at the top of a block.
30
+ * 2P, honestly: the stock PC Engine has ONE controller port; 2P needs a
31
+ * TurboTap. The geargrafx core implements the TurboTap and the romdev host
32
+ * now force-ENABLES it (PLATFORM_CORE_OPTIONS pce: geargrafx_turbotap), so a
33
+ * second pad's input reaches the game on pad slot 2 — verified by driving
34
+ * port-1 input and seeing P2 move. So this game ships REAL 2P alternating
35
+ * turns. (On real hardware the player plugs a TurboTap and a second pad.)
36
+ *
37
+ * Frame budget (NTSC, 60fps, 7.16MHz 65C02-class CPU): player physics + a
38
+ * two-column ground probe + (3 coins + 2 spikes) of AABB + a 256-word SATB
39
+ * copy in vblank + at most one streamed BAT column fit comfortably in one
40
+ * frame. Hardware scroll (BXR) is free; rewriting the whole tilemap per frame
41
+ * would NOT fit — column streaming is why this scrolls smoothly.
21
42
  */
22
43
  #include <pce.h>
23
- #include <stdint.h> /* int8_t/int16_t/int32_t for sub-pixel physics + camera */
44
+ #include <stdint.h> /* int16_t/int32_t for sub-pixel physics + camera */
45
+ #include <joystick.h> /* JOY_2 + joy_read for the 2nd pad (TurboTap port 1) */
24
46
  #include "pce_hw.h"
25
47
 
26
- /* ---- VRAM layout (word addresses) --------------------------------------- */
27
- #define BAT_VRAM 0x0000 /* 32x32 background map */
28
- #define SKY_VRAM 0x1000 /* BG tile: sky (solid colour 1) */
29
- #define WALL_VRAM 0x1010 /* BG tile: platform block (colour 2) */
30
- #define WALLTOP_VRAM 0x1020 /* BG tile: platform top edge (colour 3 strip) */
31
- #define PLAYER_VRAM 0x1800 /* 16x16 player */
48
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
49
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
50
+ #define GAME_TITLE "GLADE DASH"
51
+
52
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
53
+ * VRAM map (WORD addresses — the VDC is a 16-bit-word machine; an 8x8 tile is
54
+ * 16 words, a 16x16 sprite cell is 64). Sprites and BG tiles share one 64KB
55
+ * VRAM, so lay it out ONCE and keep the SATB out of pattern space:
56
+ * $0000 BAT (32x32 background map — matches vdc_init's VDC_MWR setting)
57
+ * $1000 font glyphs (38 tiles: blank, 0-9, A-Z, dash)
58
+ * $1400 BG scenery tiles (sky, dirt, grass, slab, hud band)
59
+ * $1800 16x16 sprite cells: coin, spike
60
+ * $1900 PLAYER pattern cells — 4-ALIGNED cell index (32x32 large sprite)
61
+ * $7F00 shadow SATB destination (satb_dma copies it here, VDC reads it) */
62
+ #define BAT_VRAM 0x0000
63
+ #define FONT_VRAM 0x1000
64
+ #define SKY_VRAM 0x1400 /* solid colour 1 — sky */
65
+ #define DIRT_VRAM 0x1410 /* solid colour 2 — ground body */
66
+ #define GRASS_VRAM 0x1420 /* colour-3 lip over colour-2 body */
67
+ #define SLAB_VRAM 0x1430 /* colour-3 thin one-way platform */
68
+ #define HUDBAND_VRAM 0x1440 /* solid colour 2 — band behind the HUD text */
69
+ #define COIN_VRAM 0x1800 /* 16x16 sprite cell */
70
+ #define SPIKE_VRAM 0x1840 /* 16x16 sprite cell */
71
+ #define PLAYER_VRAM 0x1900 /* 4 cells (TL,TR,BL,BR) — 4-aligned (see idiom) */
32
72
 
33
73
  #define BAT_ENTRY(pal, vram) ((u16)(((pal) << 12) | ((vram) >> 4)))
34
74
 
35
- /* ---- world -------------------------------------------------------------- */
36
- #define WORLD_COLS 96 /* 96 cells = 768 px world */
37
- #define WORLD_W (WORLD_COLS * 8)
38
- #define SCREEN_W 256
39
- #define VIS_ROWS 28 /* 224-line display = 28 rows */
40
-
41
- typedef struct { int16_t x, y, w, h; } Rect;
42
-
43
- /* Platforms in WORLD pixel coords, spread across the 768px world. */
44
- static const Rect platforms[] = {
45
- { 0, 200, 768, 24 }, /* floor spans the world */
46
- { 48, 168, 56, 8 },
47
- { 140, 152, 64, 8 },
48
- { 232, 128, 56, 8 },
49
- { 96, 112, 40, 8 },
50
- { 320, 160, 72, 8 },
51
- { 416, 128, 64, 8 },
52
- { 360, 88, 48, 8 },
53
- { 512, 152, 80, 8 },
54
- { 600, 120, 56, 8 },
55
- { 672, 168, 72, 8 },
56
- { 560, 80, 48, 8 }
75
+ /* Sprite pattern codes = VRAM >> 6 (the 16x16 cell index). */
76
+ #define COIN_PAT (COIN_VRAM >> 6)
77
+ #define SPIKE_PAT (SPIKE_VRAM >> 6)
78
+ #define PLAYER_PAT (PLAYER_VRAM >> 6) /* 0x64 — multiple of 4 */
79
+
80
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
81
+ * SATB slot plan (slot order is also priority: LOWER slot wins overlaps on
82
+ * the HuC6270):
83
+ * 0 player (a 32x32 large sprite ONE SATB entry)
84
+ * 1-3 coins
85
+ * 4-5 spikes
86
+ * Everything else stays parked off-screen. */
87
+ #define SLOT_PLAYER 0
88
+ #define SLOT_COIN 1
89
+ #define SLOT_SPIKE 4
90
+ #define NUM_COINS 3
91
+ #define NUM_SPIKES 2
92
+ #define OFFSCREEN_Y 0x1F0 /* park unused sprites below the display */
93
+
94
+ #define PAL_PLAYER 0
95
+ #define PAL_COIN 1
96
+ #define PAL_SPIKE 2
97
+
98
+ /* ── GAME LOGIC (clay) — the world ───────────────────────────────────────────
99
+ * A 96-cell (768px) world, wider than the 256px screen. The BAT is a 32x32
100
+ * virtual map that WRAPS at 256px, so a wider world needs COLUMN STREAMING:
101
+ * each time the camera crosses an 8px boundary we rewrite the BAT column about
102
+ * to scroll into view with the next world column's tiles. Rows are 8px:
103
+ * ground_row[c] — BAT row of the grass top, 0xFF = pit.
104
+ * plat_row[c] — BAT row of a one-way slab, 0 = none.
105
+ * Playfield rows are 3..27 (rows 0-2 sit under the HUD). */
106
+ #define WORLD_COLS 96
107
+ #define WORLD_W (WORLD_COLS * 8)
108
+ #define SCREEN_W 256
109
+ #define VIS_ROWS 28 /* 224-line display = 28 rows */
110
+ #define NO_GROUND 0xFF
111
+ #define GROUND_R 25 /* default ground surface row (y = 200) */
112
+ #define HUD_ROWS 3 /* rows 0-2 reserved for HUD (drawn fixed) */
113
+
114
+ static const u8 ground_row[WORLD_COLS] = {
115
+ 25,25,25,25,25,25,25,25, /* start runway */
116
+ 25,25,25,25, NO_GROUND,NO_GROUND, 25,25, /* pit 1 (16px) */
117
+ 25,25,25,25,25,25,25,25,
118
+ 25,25, NO_GROUND,NO_GROUND,NO_GROUND, 25,25,25, /* pit 2 (24px) */
119
+ 25,25,25,25,25,25,25,25,
120
+ 25,25,25, NO_GROUND,NO_GROUND, 25,25,25, /* pit 3 (16px) */
121
+ 25,25,25,25,25,25,25,25,
122
+ 25, NO_GROUND,NO_GROUND,NO_GROUND, 25,25,25,25, /* pit 4 (24px) */
123
+ 25,25,25,25,25,25,25,25,
124
+ 25,25,25,25, NO_GROUND,NO_GROUND, 25,25, /* pit 5 (16px) */
125
+ 25,25,25,25,25,25,25,25,
126
+ 25,25,25,25,25,25,25,25, /* run-out to the loop */
127
+ };
128
+ static const u8 plat_row[WORLD_COLS] = {
129
+ 0,0,0,0, 21,21,21, 0, /* warm-up slab */
130
+ 0,0, 19,19,19, 0,0,0, /* bridge over pit 1 */
131
+ 0,0,0, 18,18, 0,0,0,
132
+ 0, 20,20, 0,0,0, 0,0, /* hop near pit 2 */
133
+ 0,0,0, 17,17,17, 0,0, /* high ledge */
134
+ 0,0, 19,19, 0,0,0,0, /* over pit 3 */
135
+ 21,21, 0,0,0, 19,19, 0,
136
+ 0, 18,18,18, 0,0,0,0, /* over pit 4 */
137
+ 0,0,0, 20,20, 0,0,0,
138
+ 0,0, 19,19, 0,0,0,0, /* over pit 5 */
139
+ 0, 21,21,21, 0,0,0,0,
140
+ 0,0,0,0,0,0,0,0,
141
+ };
142
+
143
+ typedef struct { int16_t x, y; u8 alive; } Obj;
144
+
145
+ /* ── GAME LOGIC (clay) — physics + tuning (Q4.4 fixed point: 16 = 1 px) ── */
146
+ #define GRAVITY 10
147
+ #define JUMP_VEL (-104) /* ~36px apex (~4.5 tiles) — clears a pit */
148
+ #define MAX_VY 64 /* terminal 4 px/frame — MUST stay under 5:
149
+ * the landing probe's +4 window can't *
150
+ * catch a faster fall (tunnelling) */
151
+ #define MOVE 34 /* px/16 per frame walk + scroll speed */
152
+ #define SCROLL_WALL 120 /* px: past this the world scrolls, not you */
153
+ #define GROUND_TOP (GROUND_R * 8)
154
+ #define SPIKE_Y 192
155
+ #define START_LIVES 3
156
+
157
+ static int16_t px; /* player screen x (px) */
158
+ static int16_t py_q44; /* player y, Q4.4 — gravity adds <1 px/frame
159
+ * near the apex; integer y would stick */
160
+ static int16_t vy_q44;
161
+ static u8 on_ground;
162
+ static int16_t camX, lastCamCol; /* world scroll (px) + last streamed column */
163
+ static u8 dist_sub; /* sub-counter: 64 px scrolled = +1 point */
164
+ static Obj coins[NUM_COINS];
165
+ static Obj spikes[NUM_SPIKES];
166
+
167
+ /* Players: index 0 = P1 (pad 1), 1 = P2 (pad 2 — alternating turns). Each has
168
+ * own score + own lives; the HUD shows the CURRENT player's numbers. */
169
+ static u8 two_player;
170
+ static u8 cur_player;
171
+ static u8 p_lives[2];
172
+ static u16 p_score[2];
173
+ static u16 hiscore;
174
+ static u8 turn_pause; /* freeze frames after a turn change */
175
+ static u16 rng = 0xC0DE;
176
+
177
+ static u8 pad, prev_pad; /* CURRENT-player pad this frame */
178
+ static u8 sfx_timer;
179
+ static u8 hud_dirty;
180
+ static u8 anim_frame; /* player walk-cycle phase */
181
+
182
+ /* Game states — the shell every example shares: title → play → game over. */
183
+ #define ST_TITLE 0
184
+ #define ST_PLAY 1
185
+ #define ST_OVER 2
186
+ static u8 state;
187
+
188
+ static u16 tile_buf[16]; /* scratch for one 8x8 tile */
189
+ static u16 spr_buf[64]; /* scratch for one 16x16 sprite cell */
190
+
191
+ /* ── GAME LOGIC (clay) — 5x7 glyph font: blank, 0-9, A-Z, dash ──────────────
192
+ * Each glyph is 7 rows of 5 bits (bit4 = leftmost). upload_font() expands
193
+ * them into 8x8 1-plane tiles; drawn with BG sub-palette 1 (white). */
194
+ #define G_BLANK 0
195
+ #define G_DIGIT 1 /* '0'..'9' -> glyphs 1..10 */
196
+ #define G_ALPHA 11 /* 'A'..'Z' -> glyphs 11..36 */
197
+ #define G_DASH 37
198
+ #define NUM_GLYPHS 38
199
+
200
+ static const u8 FONT5x7[NUM_GLYPHS][7] = {
201
+ {0,0,0,0,0,0,0},
202
+ {0x0E,0x11,0x13,0x15,0x19,0x11,0x0E}, {0x04,0x0C,0x04,0x04,0x04,0x04,0x0E},
203
+ {0x0E,0x11,0x01,0x02,0x04,0x08,0x1F}, {0x1F,0x02,0x04,0x02,0x01,0x11,0x0E},
204
+ {0x02,0x06,0x0A,0x12,0x1F,0x02,0x02}, {0x1F,0x10,0x1E,0x01,0x01,0x11,0x0E},
205
+ {0x06,0x08,0x10,0x1E,0x11,0x11,0x0E}, {0x1F,0x01,0x02,0x04,0x08,0x08,0x08},
206
+ {0x0E,0x11,0x11,0x0E,0x11,0x11,0x0E}, {0x0E,0x11,0x11,0x0F,0x01,0x02,0x0C},
207
+ {0x0E,0x11,0x11,0x1F,0x11,0x11,0x11}, {0x1E,0x11,0x11,0x1E,0x11,0x11,0x1E},
208
+ {0x0E,0x11,0x10,0x10,0x10,0x11,0x0E}, {0x1E,0x11,0x11,0x11,0x11,0x11,0x1E},
209
+ {0x1F,0x10,0x10,0x1E,0x10,0x10,0x1F}, {0x1F,0x10,0x10,0x1E,0x10,0x10,0x10},
210
+ {0x0E,0x11,0x10,0x17,0x11,0x11,0x0F}, {0x11,0x11,0x11,0x1F,0x11,0x11,0x11},
211
+ {0x0E,0x04,0x04,0x04,0x04,0x04,0x0E}, {0x07,0x02,0x02,0x02,0x02,0x12,0x0C},
212
+ {0x11,0x12,0x14,0x18,0x14,0x12,0x11}, {0x10,0x10,0x10,0x10,0x10,0x10,0x1F},
213
+ {0x11,0x1B,0x15,0x15,0x11,0x11,0x11}, {0x11,0x19,0x15,0x13,0x11,0x11,0x11},
214
+ {0x0E,0x11,0x11,0x11,0x11,0x11,0x0E}, {0x1E,0x11,0x11,0x1E,0x10,0x10,0x10},
215
+ {0x0E,0x11,0x11,0x11,0x15,0x12,0x0D}, {0x1E,0x11,0x11,0x1E,0x14,0x12,0x11},
216
+ {0x0F,0x10,0x10,0x0E,0x01,0x01,0x1E}, {0x1F,0x04,0x04,0x04,0x04,0x04,0x04},
217
+ {0x11,0x11,0x11,0x11,0x11,0x11,0x0E}, {0x11,0x11,0x11,0x11,0x11,0x0A,0x04},
218
+ {0x11,0x11,0x11,0x15,0x15,0x15,0x0A}, {0x11,0x11,0x0A,0x04,0x0A,0x11,0x11},
219
+ {0x11,0x11,0x0A,0x04,0x04,0x04,0x04}, {0x1F,0x01,0x02,0x04,0x08,0x10,0x1F},
220
+ {0x00,0x00,0x00,0x1F,0x00,0x00,0x00},
221
+ };
222
+
223
+ /* ── GAME LOGIC (clay) — the 32x32 hero, two walk frames (32 rows × 32 bits).
224
+ * Two u16 per row (cols 0-15, cols 16-31). body = colour 1 (plane0), the
225
+ * face/cap accents = colour 3 (planes 0+1, a subset of body). A round forest
226
+ * sprite (think a bounding critter) — big and readable, the PCE's strength. */
227
+ static const u16 hero_body_a[64] = {
228
+ 0x0000,0x0000, 0x0000,0x0000, 0x0007,0xE000, 0x001F,0xF800,
229
+ 0x003F,0xFC00, 0x007F,0xFE00, 0x00FF,0xFF00, 0x01FF,0xFF80,
230
+ 0x01FF,0xFF80, 0x03FF,0xFFC0, 0x03FF,0xFFC0, 0x03FF,0xFFC0,
231
+ 0x07FF,0xFFE0, 0x07FF,0xFFE0, 0x07FF,0xFFE0, 0x07FF,0xFFE0,
232
+ 0x07FF,0xFFE0, 0x07FF,0xFFE0, 0x03FF,0xFFC0, 0x03FF,0xFFC0,
233
+ 0x01FF,0xFF80, 0x01FF,0xFF80, 0x00FF,0xFF00, 0x007F,0xFE00,
234
+ 0x003F,0xFC00, 0x003C,0x3C00, 0x0078,0x1E00, 0x0070,0x0E00,
235
+ 0x00E0,0x0700, 0x01C0,0x0380, 0x0380,0x01C0, 0x0700,0x00E0,
236
+ };
237
+ static const u16 hero_body_b[64] = {
238
+ 0x0000,0x0000, 0x0000,0x0000, 0x0007,0xE000, 0x001F,0xF800,
239
+ 0x003F,0xFC00, 0x007F,0xFE00, 0x00FF,0xFF00, 0x01FF,0xFF80,
240
+ 0x01FF,0xFF80, 0x03FF,0xFFC0, 0x03FF,0xFFC0, 0x03FF,0xFFC0,
241
+ 0x07FF,0xFFE0, 0x07FF,0xFFE0, 0x07FF,0xFFE0, 0x07FF,0xFFE0,
242
+ 0x07FF,0xFFE0, 0x07FF,0xFFE0, 0x03FF,0xFFC0, 0x03FF,0xFFC0,
243
+ 0x01FF,0xFF80, 0x01FF,0xFF80, 0x00FF,0xFF00, 0x007F,0xFE00,
244
+ 0x003F,0xFC00, 0x001F,0xF800, 0x003C,0x3C00, 0x0038,0x1C00,
245
+ 0x0070,0x0E00, 0x00E0,0x0700, 0x01C0,0x0380, 0x0380,0x01C0,
246
+ };
247
+ /* eyes/cap accent (colour 3) — same for both frames, near the top of the head */
248
+ static const u16 hero_face[64] = {
249
+ 0x0000,0x0000, 0x0000,0x0000, 0x0000,0x0000, 0x0000,0x0000,
250
+ 0x0000,0x0000, 0x0000,0x0000, 0x0000,0x0000, 0x0030,0x0C00,
251
+ 0x0078,0x1E00, 0x0078,0x1E00, 0x0030,0x0C00, 0x0000,0x0000,
252
+ 0x0000,0x0000, 0x0000,0x0000, 0x00C0,0x0300, 0x00C0,0x0300,
253
+ 0x0070,0x0E00, 0x003F,0xFC00, 0x0000,0x0000, 0x0000,0x0000,
254
+ 0x0000,0x0000, 0x0000,0x0000, 0x0000,0x0000, 0x0000,0x0000,
255
+ 0x0000,0x0000, 0x0000,0x0000, 0x0000,0x0000, 0x0000,0x0000,
256
+ 0x0000,0x0000, 0x0000,0x0000, 0x0000,0x0000, 0x0000,0x0000,
57
257
  };
58
- #define N_PLATFORMS (sizeof(platforms) / sizeof(platforms[0]))
59
258
 
60
- /* ---- state -------------------------------------------------------------- */
61
- static int16_t px, py; /* player position in 1/16-px units */
62
- static int16_t vx, vy;
63
- static int16_t camX, lastCamCol;
64
- static u8 pad, prev_pad;
65
- static u16 spr_buf[64];
66
- static u16 tile_buf[16];
259
+ /* ── GAME LOGIC (clay) — 16x16 sprite masks (16 rows × 16 bits, bit15 left) ── */
260
+ static const u16 coin_mask[16] = {
261
+ 0x0000, 0x07E0, 0x1FF8, 0x3C3C, 0x381C, 0x73CE, 0x77EE, 0x77EE,
262
+ 0x77EE, 0x77EE, 0x73CE, 0x381C, 0x3C3C, 0x1FF8, 0x07E0, 0x0000
263
+ };
264
+ static const u16 spike_mask[16] = {
265
+ 0x0000, 0x0000, 0x0180, 0x0180, 0x03C0, 0x03C0, 0x07E0, 0x07E0,
266
+ 0x0FF0, 0x0FF0, 0x1FF8, 0x1FF8, 0x3FFC, 0x7FFE, 0xFFFF, 0xFFFF
267
+ };
67
268
 
269
+ /* ── GAME LOGIC (clay) — tile/sprite builders ────────────────────────────── */
68
270
  static void make_solid_tile(u16 *t, u8 ci) {
69
271
  u8 r;
70
272
  u8 p0 = (ci & 1) ? 0xFF : 0x00;
71
273
  u8 p1 = (ci & 2) ? 0xFF : 0x00;
72
- u8 p2 = (ci & 4) ? 0xFF : 0x00;
73
- u8 p3 = (ci & 8) ? 0xFF : 0x00;
74
274
  for (r = 0; r < 8; ++r) {
75
275
  t[r] = (u16)(p0 | (p1 << 8));
76
- t[r + 8] = (u16)(p2 | (p3 << 8));
276
+ t[r + 8] = 0;
77
277
  }
78
278
  }
79
279
 
80
- /* platform-top tile: colour 2 body with a colour-3 highlight on the top 2 rows */
81
- static void make_walltop_tile(u16 *t) {
82
- make_solid_tile(t, 2);
83
- /* rows 0,1: set plane0 too so those pixels read colour 3 (planes0+1) */
84
- t[0] = (u16)(0x00FF | (t[0] & 0xFF00));
85
- t[1] = (u16)(0x00FF | (t[1] & 0xFF00));
280
+ /* grass: colour-2 body with a colour-3 lip on the top 2 rows */
281
+ static void make_grass_tile(u16 *t) {
282
+ make_solid_tile(t, 2); /* body = colour 2 (plane1) */
283
+ t[0] |= 0x00FF; /* rows 0,1: set plane0 too colour 3 */
284
+ t[1] |= 0x00FF;
285
+ }
286
+
287
+ /* one-way slab: a colour-3 bar on the TOP 4 rows only (you jump up through
288
+ * the transparent bottom) */
289
+ static void make_slab_tile(u16 *t) {
290
+ u8 r;
291
+ for (r = 0; r < 16; ++r) t[r] = 0;
292
+ for (r = 0; r < 4; ++r) { t[r] = 0x00FF; t[r + 8] = 0x00FF; } /* colour 3 */
86
293
  }
87
294
 
88
- static void make_player_sprite(void) {
89
- static const u16 body[16] = {
90
- 0x07E0, 0x0FF0, 0x1FF8, 0x1818, 0x1FF8, 0x1FF8, 0x3FFC, 0x7FFE,
91
- 0x7FFE, 0x7FFE, 0x3FFC, 0x1FF8, 0x0E70, 0x0C30, 0x0C30, 0x1818
92
- };
93
- static const u16 eyes[16] = {
94
- 0x0000, 0x0000, 0x0000, 0x0000, 0x0990, 0x0990, 0x0000, 0x0000,
95
- 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000
96
- };
295
+ /* one-colour 16x16 sprite cell from a 16-row mask */
296
+ static void make_sprite16(u16 vram, const u16 *mask, u8 ci) {
97
297
  u8 r;
98
298
  for (r = 0; r < 64; ++r) spr_buf[r] = 0;
99
299
  for (r = 0; r < 16; ++r) {
100
- spr_buf[r] = (u16)(body[r] & ~eyes[r]); /* plane0 -> colour 1 */
101
- spr_buf[r + 16] = eyes[r]; /* plane1 -> colour 2 */
300
+ if (ci & 1) spr_buf[r] = mask[r]; /* plane 0 */
301
+ if (ci & 2) spr_buf[r + 16] = mask[r]; /* plane 1 */
102
302
  }
103
- load_tiles(PLAYER_VRAM, spr_buf, 64);
303
+ load_tiles(vram, spr_buf, 64);
104
304
  }
105
305
 
106
- /* Is world cell (col,row) inside any platform? */
107
- static u8 cell_is_wall(int16_t col, u8 row) {
108
- int16_t cx = (int16_t)(col << 3);
109
- int16_t cy = (int16_t)((int16_t)row << 3);
110
- u8 i;
111
- const Rect *p;
112
- for (i = 0; i < N_PLATFORMS; ++i) {
113
- p = &platforms[i];
114
- if (cx + 8 > p->x && cx < p->x + p->w &&
115
- cy + 8 > p->y && cy < p->y + p->h) return 1;
306
+ static void upload_font(void) {
307
+ u8 g, row, bits, px2;
308
+ for (g = 0; g < NUM_GLYPHS; ++g) {
309
+ for (row = 0; row < 16; ++row) tile_buf[row] = 0;
310
+ for (row = 0; row < 7; ++row) {
311
+ bits = FONT5x7[g][row];
312
+ px2 = 0;
313
+ if (bits & 0x10) px2 |= 0x40;
314
+ if (bits & 0x08) px2 |= 0x20;
315
+ if (bits & 0x04) px2 |= 0x10;
316
+ if (bits & 0x02) px2 |= 0x08;
317
+ if (bits & 0x01) px2 |= 0x04;
318
+ tile_buf[row] = (u16)px2;
319
+ }
320
+ load_tiles((u16)(FONT_VRAM + g * 16), tile_buf, 16);
116
321
  }
117
- return 0;
118
322
  }
119
323
 
120
- /* Is world cell the TOP row of a platform (for the highlighted edge tile)? */
121
- static u8 cell_is_top(int16_t col, u8 row) {
122
- int16_t cy = (int16_t)((int16_t)row << 3);
123
- int16_t cx = (int16_t)(col << 3);
124
- u8 i;
125
- const Rect *p;
126
- for (i = 0; i < N_PLATFORMS; ++i) {
127
- p = &platforms[i];
128
- if (cx + 8 > p->x && cx < p->x + p->w && cy >= p->y && cy < p->y + 8) return 1;
324
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
325
+ * LARGE-SPRITE PATTERN LAYOUT the half of the big-hero trick that lives in
326
+ * VRAM. A 32x32 HuC6270 sprite is FOUR 16x16 cells (64 words each) stored
327
+ * consecutively in TL, TR, BL, BR order, and its SATB pattern code must be
328
+ * 4-ALIGNED (the hardware ignores the low 2 bits and adds them back as
329
+ * column/row). Get the order wrong and the hero renders scrambled — four
330
+ * recognizable quarters in the wrong places. The other half of the trick
331
+ * (the SATB attribute bits) is in push_sprites() below.
332
+ *
333
+ * `body` selects the walk frame (hero_body_a / hero_body_b). `face` is the
334
+ * colour-3 accent shared by both. We upload BOTH frames' worth of cells when
335
+ * the walk phase flips — cheap (256 words) and only on phase change.
336
+ *
337
+ * requires: PLAYER_VRAM >> 6 a multiple of 4; 4 consecutive free cells
338
+ * (256 words) at PLAYER_VRAM; set_sprite_ex() from pce_video.c. */
339
+ static void upload_hero(const u16 *body) {
340
+ u8 cr, cc, row;
341
+ u16 body_bits, face_bits;
342
+ u16 vram = PLAYER_VRAM;
343
+ for (cr = 0; cr < 2; ++cr) { /* cell row (top/bottom) */
344
+ for (cc = 0; cc < 2; ++cc) { /* cell col (left/right) */
345
+ for (row = 0; row < 64; ++row) spr_buf[row] = 0;
346
+ for (row = 0; row < 16; ++row) {
347
+ u8 y = (u8)(cr * 16 + row);
348
+ body_bits = body[y * 2 + cc];
349
+ face_bits = hero_face[y * 2 + cc];
350
+ /* body pixels = colour 1 (plane0); face accents = colour 3
351
+ * (planes 0+1) — the accent is a subset of the body. */
352
+ spr_buf[row] = body_bits;
353
+ spr_buf[row + 16] = face_bits;
354
+ }
355
+ load_tiles(vram, spr_buf, 64);
356
+ vram += 64; /* next cell: TL,TR,BL,BR */
357
+ }
129
358
  }
130
- return 0;
359
+ }
360
+
361
+ static void upload_art(void) {
362
+ upload_font();
363
+ make_solid_tile(tile_buf, 1); load_tiles(SKY_VRAM, tile_buf, 16);
364
+ make_solid_tile(tile_buf, 2); load_tiles(DIRT_VRAM, tile_buf, 16);
365
+ make_grass_tile(tile_buf); load_tiles(GRASS_VRAM, tile_buf, 16);
366
+ make_slab_tile(tile_buf); load_tiles(SLAB_VRAM, tile_buf, 16);
367
+ make_solid_tile(tile_buf, 2); load_tiles(HUDBAND_VRAM, tile_buf, 16);
368
+ make_sprite16(COIN_VRAM, coin_mask, 1);
369
+ make_sprite16(SPIKE_VRAM, spike_mask, 1);
370
+ upload_hero(hero_body_a);
371
+ }
372
+
373
+ /* ── GAME LOGIC (clay) — BAT text + level paint ─────────────────────────────── */
374
+ static void put_glyph(u8 col, u8 row, u8 glyph) {
375
+ u16 e = BAT_ENTRY(1, (u16)(FONT_VRAM + glyph * 16)); /* pal 1 = white */
376
+ vram_set_write_addr((u16)(BAT_VRAM + row * 32 + col));
377
+ VDC_DATA_LO = (u8)(e & 0xFF);
378
+ VDC_DATA_HI = (u8)(e >> 8);
379
+ }
380
+
381
+ static void draw_text(u8 col, u8 row, const char *s) {
382
+ u8 c;
383
+ while ((c = (u8)*s++) != 0) {
384
+ u8 g = G_BLANK;
385
+ if (c >= '0' && c <= '9') g = (u8)(G_DIGIT + c - '0');
386
+ else if (c >= 'A' && c <= 'Z') g = (u8)(G_ALPHA + c - 'A');
387
+ else if (c == '-') g = G_DASH;
388
+ put_glyph(col++, row, g);
389
+ }
390
+ }
391
+
392
+ static void draw_num5(u8 col, u8 row, u16 v) {
393
+ u8 i, d[5];
394
+ for (i = 0; i < 5; ++i) { d[i] = (u8)(v % 10); v /= 10; }
395
+ for (i = 0; i < 5; ++i) put_glyph((u8)(col + i), row, (u8)(G_DIGIT + d[4 - i]));
396
+ }
397
+
398
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
399
+ * HARDWARE BG SCROLL via BXR + COLUMN STREAMING — the PCE's smoothest trick.
400
+ * The BAT is a 32x32 (256px) virtual map that WRAPS, and the VDC's R7 (BXR)
401
+ * shifts the whole background horizontally with ZERO CPU per pixel. For a
402
+ * world WIDER than 256px we stream: as the camera advances, each BAT column
403
+ * about to wrap into view is rewritten with the next world column's tiles.
404
+ * Paint a column ONCE when it enters; from then on the scroll is free.
405
+ *
406
+ * THE HUD CAVEAT: BXR scrolls the ENTIRE background, including the top rows.
407
+ * The PCE has no hardware "window" plane (the Genesis trick), and no built-in
408
+ * raster split (the SMS/NES trick) in this minimal lib. So we keep the HUD
409
+ * readable by drawing it into the BAT rows 0-2 EVERY column we stream — the
410
+ * HUD text scrolls with the world, but because it's repainted into each fresh
411
+ * column it appears continuous across the whole top of the screen. A fancier
412
+ * fork can add a raster IRQ to reset BXR mid-frame for a truly fixed HUD; see
413
+ * TROUBLESHOOTING. For a clean teaching scaffold this "painted band" HUD is
414
+ * honest and flicker-free.
415
+ *
416
+ * requires: BXR written every frame (we do, in the loop); each world column
417
+ * painted exactly once as it enters; the BAT 32x32 (vdc_init's MWR). */
418
+ static u16 bat_entry_for(int16_t worldCol, u8 row) {
419
+ u8 g = ground_row[worldCol];
420
+ if (row < HUD_ROWS) return BAT_ENTRY(0, HUDBAND_VRAM); /* HUD band */
421
+ if (row < VIS_ROWS) {
422
+ if (plat_row[worldCol] && row == plat_row[worldCol])
423
+ return BAT_ENTRY(0, SLAB_VRAM); /* one-way slab */
424
+ if (g != NO_GROUND) {
425
+ if (row == g) return BAT_ENTRY(0, GRASS_VRAM); /* grass top */
426
+ if (row > g) return BAT_ENTRY(0, DIRT_VRAM); /* ground body */
427
+ }
428
+ }
429
+ return BAT_ENTRY(0, SKY_VRAM); /* sky backdrop */
131
430
  }
132
431
 
133
432
  /* Write one world column into its wrapped BAT column. */
@@ -137,147 +436,519 @@ static void paint_column(int16_t worldCol) {
137
436
  if (worldCol < 0 || worldCol >= WORLD_COLS) return;
138
437
  ntCol = (u8)(worldCol & 31);
139
438
  for (row = 0; row < 32; ++row) {
140
- if (row < VIS_ROWS && cell_is_wall(worldCol, row)) {
141
- e = cell_is_top(worldCol, row)
142
- ? BAT_ENTRY(0, WALLTOP_VRAM)
143
- : BAT_ENTRY(0, WALL_VRAM);
144
- } else {
145
- e = BAT_ENTRY(0, SKY_VRAM);
146
- }
439
+ e = bat_entry_for(worldCol, row);
147
440
  vram_set_write_addr((u16)(BAT_VRAM + row * 32 + ntCol));
148
441
  VDC_DATA_LO = (u8)(e & 0xFF);
149
442
  VDC_DATA_HI = (u8)(e >> 8);
150
443
  }
151
444
  }
152
445
 
153
- static void paint_initial(void) {
446
+ /* Repaint the first 32 columns (one screen) from scratch — used when (re)entering
447
+ * the level so the visible window is correct before the first scroll. */
448
+ static void paint_screen_from(int16_t firstCol) {
154
449
  int16_t c;
155
- for (c = 0; c < 32; ++c) paint_column(c);
450
+ for (c = firstCol; c < firstCol + 32; ++c)
451
+ if (c >= 0 && c < WORLD_COLS) paint_column(c);
452
+ }
453
+
454
+ /* Fill the whole 32x32 BAT with sky (title / game-over backdrop). */
455
+ static void paint_flat_sky(void) {
456
+ u8 r, c;
457
+ u16 e = BAT_ENTRY(0, SKY_VRAM);
458
+ for (r = 0; r < 32; ++r) {
459
+ vram_set_write_addr((u16)(BAT_VRAM + r * 32));
460
+ for (c = 0; c < 32; ++c) {
461
+ VDC_DATA_LO = (u8)(e & 0xFF);
462
+ VDC_DATA_HI = (u8)(e >> 8);
463
+ }
464
+ }
465
+ }
466
+
467
+ /* HUD: row 1 = "P1 x3 SC 00000 HI 00000". The HUD lives in the BAT band rows
468
+ * (painted into every streamed column), so writing the numbers once at the
469
+ * left of the BAT keeps them at screen-left while BXR scrolls (they reappear
470
+ * via the band but the live digits are what the player reads). */
471
+ static void draw_hud_numbers(void) {
472
+ put_glyph(1, 1, (u8)(G_ALPHA + ('P' - 'A')));
473
+ put_glyph(2, 1, (u8)(G_DIGIT + 1 + cur_player));
474
+ put_glyph(4, 1, (u8)(G_ALPHA + ('X' - 'A')));
475
+ put_glyph(5, 1, (u8)(G_DIGIT + p_lives[cur_player]));
476
+ draw_text(7, 1, "SC");
477
+ draw_num5(10, 1, p_score[cur_player]);
478
+ draw_text(17, 1, "HI");
479
+ draw_num5(20, 1, hiscore);
480
+ }
481
+
482
+ /* ── HARDWARE TRUTH: a bare HuCard CANNOT save a hi-score (in-session only) ──
483
+ * This was researched and corrected: earlier versions wrote the hi-score to
484
+ * BRAM ("backup RAM", bank $F7) and claimed it persisted across power cycles.
485
+ * That is NOT honest for a HuCard game. On REAL hardware a plain HuCard plugged
486
+ * into a base PC Engine / TurboGrafx-16 has NO backup RAM at all — BRAM exists
487
+ * ONLY when a peripheral is attached: the CD-ROM² System (2KB kept by a
488
+ * supercapacitor), the Tennokoe Bank HuCard, or the Memory Base 128. No
489
+ * commercial HuCard self-saved; they used PASSWORDS. (The often-cited Populous
490
+ * "ROMRAM" SRAM was the game's own working RAM, not a battery save.) An
491
+ * emulator like geargrafx exposes BRAM unconditionally, so the old code
492
+ * "worked" in emulation in a way the real machine never would.
493
+ *
494
+ * So this game keeps an IN-SESSION hi-score only (like the honest 2600/Lynx
495
+ * examples) — it survives game-overs within a power-on, resets to 0 on a cold
496
+ * boot. To make it ACTUALLY persist on real hardware you would target a
497
+ * peripheral: write to BRAM only after detecting one (and go through the System
498
+ * Card BIOS's 'HUBM' directory for CD saves), or move the game to a CD-ROM²
499
+ * build. Either is a real-hardware feature, not a property of the cartridge. */
500
+ static u16 hiscore_load(void) {
501
+ return 0; /* cold boot: no persistence on a bare HuCard */
502
+ }
503
+
504
+ static void hiscore_save(u16 v) {
505
+ (void)v; /* in-session only — nowhere to persist on real HW */
506
+ }
507
+
508
+ /* ── GAME LOGIC (clay) — music: a 2-channel tune ticked once per frame ──────
509
+ * PSG channel plan: 5 = melody, 4 = bass, 2/3 = SFX (tones cut by sfx_timer).
510
+ * PCE frequency regs are DIVIDERS: pitch ≈ 3.58MHz / (32 × value), so a
511
+ * BIGGER number is a LOWER note. Note indices into NOTE_DIV below. */
512
+ enum { R = 0, A2N, C3, F3, G3, A3, B3, C4, D4, E4, F4, G4, A4, B4, C5, D5, E5 };
513
+ static const u16 NOTE_DIV[17] = {
514
+ 0, 1017, 854, 641, 571, 508, 453, 427, 381, 339, 320, 285, 254, 226, 214, 190, 170
515
+ };
516
+ /* 16 melody steps + 8 bass steps (one bass note per 2 melody steps) */
517
+ static const u8 MEL_TITLE[16] = { G4,C5,E5,C5, A4,C5,G4,E4, F4,A4,C5,A4, G4,E4,D4,C4 };
518
+ static const u8 BAS_TITLE[8] = { C3,C3, F3,F3, A2N,A2N, G3,G3 };
519
+ static const u8 MEL_PLAY[16] = { C4,E4,G4,E4, F4,A4,G4,E4, D4,F4,A4,G4, E4,G4,C5,R };
520
+ static const u8 BAS_PLAY[8] = { C3,C3, F3,F3, A2N,A2N, G3,G3 };
521
+ static const u8 MEL_OVER[16] = { C5,R,A4,R, G4,R,E4,R, D4,R,C4,R, A2N,R,R,R };
522
+
523
+ static u8 music_song; /* reuses the ST_* ids */
524
+ static u8 music_step, music_timer, music_done;
525
+
526
+ static void music_set(u8 song) {
527
+ music_song = song;
528
+ music_step = 0;
529
+ music_timer = 0;
530
+ music_done = 0;
531
+ psg_off(4);
532
+ psg_off(5);
533
+ }
534
+
535
+ static void music_tick(void) {
536
+ const u8 *mel;
537
+ u8 n;
538
+ if (music_done) return;
539
+ if (music_timer == 0) {
540
+ mel = (music_song == ST_PLAY) ? MEL_PLAY
541
+ : (music_song == ST_OVER) ? MEL_OVER : MEL_TITLE;
542
+ n = mel[music_step & 15];
543
+ if (n != R) psg_tone(5, NOTE_DIV[n], 26);
544
+ else psg_off(5);
545
+ if (music_song != ST_OVER) { /* the game-over jingle has no bass */
546
+ n = ((music_step & 1) == 0)
547
+ ? ((music_song == ST_PLAY) ? BAS_PLAY[(music_step >> 1) & 7]
548
+ : BAS_TITLE[(music_step >> 1) & 7])
549
+ : R;
550
+ if (n != R) psg_tone(4, NOTE_DIV[n], 20);
551
+ }
552
+ ++music_step;
553
+ if (music_song == ST_OVER && music_step >= 16) { /* play once, stop */
554
+ music_done = 1;
555
+ psg_off(4);
556
+ psg_off(5);
557
+ }
558
+ }
559
+ ++music_timer;
560
+ if (music_timer >= 9) music_timer = 0;
561
+ }
562
+
563
+ /* ── GAME LOGIC (clay) — helpers ──────────────────────────────────────────── */
564
+ static u8 random8(void) {
565
+ u16 r = rng;
566
+ r ^= r << 7;
567
+ r ^= r >> 9;
568
+ r ^= r << 8;
569
+ rng = r;
570
+ return (u8)r;
571
+ }
572
+
573
+ static u8 dist8(int16_t a, int16_t b) {
574
+ int16_t d = (int16_t)(a - b);
575
+ if (d < 0) d = (int16_t)-d;
576
+ return (d > 255) ? 255 : (u8)d;
156
577
  }
157
578
 
158
- static u8 on_platform(int16_t ipx, int16_t ipy) {
579
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
580
+ * SPRITE STAGING + THE SATB DMA. The VDC never reads your RAM: sprites live
581
+ * in its INTERNAL sprite attribute table, refreshed by a DMA you schedule by
582
+ * writing R19 (satb_dma() does the copy + the R19 write; the transfer itself
583
+ * happens at the next vblank). So the per-frame contract is:
584
+ * waitvsync() → restage EVERY slot → satb_dma()
585
+ * Stage during vblank — satb_dma() also streams 256 words through the VWR
586
+ * port, and doing that mid-display tears sprite pattern fetches.
587
+ *
588
+ * THE HERO (a PCE signature): ONE 32x32 SATB entry — SPR_CGX_32|SPR_CGY_32 in
589
+ * the attribute word — for a big, readable character. CGX goes 32, CGY goes
590
+ * 32 (or 64 for a 32x64 tower from a single entry). The NES needs 4 hardware
591
+ * sprites (and the per-scanline budget) for the same thing.
592
+ *
593
+ * requires: set_sprite_ex() + the 4-aligned hero cells from upload_hero(). */
594
+ static void push_sprites(void) {
159
595
  u8 i;
160
- const Rect *p;
161
- for (i = 0; i < N_PLATFORMS; ++i) {
162
- p = &platforms[i];
163
- if (ipy + 16 == p->y && ipx + 12 > p->x && ipx + 4 < p->x + p->w) return 1;
596
+ int16_t player_y = (int16_t)(py_q44 >> 4);
597
+ int16_t sx = (int16_t)(px - 8); /* center the 32-wide sprite */
598
+ /* hero (slot 0) — 32x32 large sprite; blink during the turn breather */
599
+ if (state == ST_PLAY && (turn_pause == 0 || (turn_pause & 4)))
600
+ set_sprite_ex(SLOT_PLAYER, (u16)sx, (u16)player_y, PLAYER_PAT, PAL_PLAYER,
601
+ SPR_CGX_32 | SPR_CGY_32);
602
+ else
603
+ set_sprite_ex(SLOT_PLAYER, 0, OFFSCREEN_Y, PLAYER_PAT, PAL_PLAYER,
604
+ SPR_CGX_32 | SPR_CGY_32);
605
+ for (i = 0; i < NUM_COINS; ++i) {
606
+ u8 vis = (state == ST_PLAY) && coins[i].alive &&
607
+ coins[i].x >= 0 && coins[i].x < SCREEN_W;
608
+ set_sprite((u8)(SLOT_COIN + i), vis ? (u16)coins[i].x : 0,
609
+ vis ? (u16)coins[i].y : OFFSCREEN_Y, COIN_PAT, PAL_COIN);
610
+ }
611
+ for (i = 0; i < NUM_SPIKES; ++i) {
612
+ u8 vis = (state == ST_PLAY) && spikes[i].alive &&
613
+ spikes[i].x >= 0 && spikes[i].x < SCREEN_W;
614
+ set_sprite((u8)(SLOT_SPIKE + i), vis ? (u16)spikes[i].x : 0,
615
+ vis ? (u16)spikes[i].y : OFFSCREEN_Y, SPIKE_PAT, PAL_SPIKE);
616
+ }
617
+ }
618
+
619
+ /* ── GAME LOGIC (clay) — coins + spikes (sprite objects in the world) ── */
620
+ static const int16_t coin_heights[4] = { 176, 152, 120, 144 };
621
+ static void respawn_coin(u8 i) {
622
+ coins[i].x = (int16_t)(SCREEN_W + 8 + (random8() & 31)); /* enter right */
623
+ coins[i].y = coin_heights[random8() & 3];
624
+ coins[i].alive = 1;
625
+ }
626
+
627
+ static void try_spawn_spike(u8 i) {
628
+ /* Anchor only over ground: an inactive spike rolls a low per-frame chance
629
+ * and only spawns if the world column entering at the right edge has
630
+ * ground under it (never floats over a pit). */
631
+ int16_t c = (int16_t)(((camX + SCREEN_W + 8) >> 3));
632
+ if (c < 0 || c >= WORLD_COLS) return;
633
+ if (ground_row[c] == NO_GROUND) return;
634
+ if (random8() > 4) return;
635
+ spikes[i].x = (int16_t)(SCREEN_W + 8);
636
+ spikes[i].y = SPIKE_Y;
637
+ spikes[i].alive = 1;
638
+ }
639
+
640
+ /* ── GAME LOGIC (clay) — landing probe against the column map ──────────────
641
+ * One-way platforms, arcade-classic style: only catch the player while
642
+ * FALLING through a narrow window at the surface: top-1 (the standing snap
643
+ * parks feet exactly at top, and gravity's sub-pixel trickle doesn't move the
644
+ * integer y every frame — without the -1 slack the player "stands" with
645
+ * on_ground=0 most frames, so jumps only register on lucky frames) through
646
+ * top+4 (so a fast fall can't step over it). */
647
+ static int16_t land_top(int16_t c, int16_t feet) {
648
+ u8 r;
649
+ int16_t top;
650
+ if (c < 0 || c >= WORLD_COLS) return 0;
651
+ r = plat_row[c];
652
+ if (r) {
653
+ top = (int16_t)(r << 3);
654
+ if (feet + 1 >= top && feet <= top + 4) return top;
655
+ }
656
+ r = ground_row[c];
657
+ if (r != NO_GROUND) {
658
+ top = (int16_t)(r << 3);
659
+ if (feet + 1 >= top && feet <= top + 4) return top;
164
660
  }
165
661
  return 0;
166
662
  }
167
663
 
168
- void main(void) {
169
- const int16_t GRAVITY = 10;
170
- const int16_t MOVE = 22;
171
- const int16_t JUMP = -200;
172
- const int16_t MAXFALL = 300;
173
-
174
- _pce_keep[0] = 0;
175
-
176
- /* palette */
177
- vce_set_color(0, PCE_RGB(1, 2, 5)); /* backdrop sky blue */
178
- vce_set_color(1, PCE_RGB(2, 4, 7)); /* BG c1: sky */
179
- vce_set_color(2, PCE_RGB(3, 2, 1)); /* BG c2: brown platform */
180
- vce_set_color(3, PCE_RGB(1, 6, 1)); /* BG c3: green grassy top */
181
- vce_set_color(256, PCE_RGB(0, 0, 0)); /* spr transparent */
182
- vce_set_color(257, PCE_RGB(7, 1, 1)); /* spr c1: red body */
183
- vce_set_color(258, PCE_RGB(7, 7, 7)); /* spr c2: white eyes */
664
+ /* ── GAME LOGIC (clay) — screen painters (full repaint per state change) ── */
665
+ static void paint_title(void) {
666
+ paint_flat_sky();
667
+ draw_text((u8)((32 - (sizeof(GAME_TITLE) - 1)) / 2), 7, GAME_TITLE);
668
+ draw_text(10, 13, "1P RUN - I");
669
+ draw_text(10, 15, "2P TURNS - II");
670
+ draw_text(11, 19, "HI");
671
+ draw_num5(14, 19, hiscore);
672
+ draw_text(6, 23, "JUMP PITS GRAB COINS");
673
+ }
184
674
 
185
- make_solid_tile(tile_buf, 1); load_tiles(SKY_VRAM, tile_buf, 16);
186
- make_solid_tile(tile_buf, 2); load_tiles(WALL_VRAM, tile_buf, 16);
187
- make_walltop_tile(tile_buf); load_tiles(WALLTOP_VRAM, tile_buf, 16);
188
- make_player_sprite();
675
+ static void paint_over(void) {
676
+ paint_flat_sky();
677
+ draw_text(11, 9, "GAME OVER");
678
+ draw_text(10, 12, "P1");
679
+ draw_num5(14, 12, p_score[0]);
680
+ if (two_player) {
681
+ draw_text(10, 14, "P2");
682
+ draw_num5(14, 14, p_score[1]);
683
+ }
684
+ draw_text(10, 17, "HI");
685
+ draw_num5(14, 17, hiscore);
686
+ draw_text(8, 21, "RUN - TITLE");
687
+ }
189
688
 
190
- paint_initial();
689
+ /* ── GAME LOGIC (clay) — start a turn / a run ── */
690
+ static void begin_turn(void) {
691
+ u8 i;
692
+ px = 24;
693
+ py_q44 = (int16_t)((GROUND_TOP - 16) << 4);
694
+ vy_q44 = 0;
695
+ on_ground = 1;
696
+ camX = 0;
697
+ lastCamCol = 0;
698
+ dist_sub = 0;
699
+ coins[0].x = 120; coins[0].y = 176; coins[0].alive = 1;
700
+ coins[1].x = 200; coins[1].y = 152; coins[1].alive = 1;
701
+ coins[2].x = 248; coins[2].y = 120; coins[2].alive = 1;
702
+ for (i = 0; i < NUM_SPIKES; ++i) spikes[i].alive = 0;
703
+ spikes[0].x = 160; spikes[0].y = SPIKE_Y; spikes[0].alive = 1;
704
+ spikes[1].x = 232; spikes[1].y = SPIKE_Y; spikes[1].alive = 1;
705
+ turn_pause = 30; /* "P1/P2 ready" breather flash */
706
+ prev_pad = 0xFF; /* swallow held buttons */
707
+ paint_screen_from(0); /* repaint the visible window */
708
+ draw_hud_numbers();
709
+ vdc_set_reg(VDC_BXR, 0);
710
+ }
191
711
 
192
- px = (int16_t)(24 << 4);
193
- py = (int16_t)(160 << 4);
194
- vx = 0; vy = 0;
195
- camX = 0; lastCamCol = 0;
196
- prev_pad = 0;
712
+ static void start_game(u8 players) {
713
+ two_player = players;
714
+ cur_player = 0;
715
+ p_score[0] = p_score[1] = 0;
716
+ p_lives[0] = START_LIVES;
717
+ p_lives[1] = players ? START_LIVES : 0;
718
+ begin_turn();
719
+ music_set(ST_PLAY);
720
+ psg_tone(2, 0x180, 28); sfx_timer = 6; /* start blip */
721
+ state = ST_PLAY;
722
+ }
197
723
 
198
- set_sprite(0, (u16)(px >> 4), (u16)(py >> 4), PLAYER_VRAM >> 6, 0);
199
- satb_dma();
724
+ static void game_over(void) {
725
+ u16 best = p_score[0];
726
+ if (two_player && p_score[1] > best) best = p_score[1];
727
+ if (best > hiscore) {
728
+ hiscore = best;
729
+ hiscore_save(hiscore); /* in-session only (no save on a bare HuCard) */
730
+ }
731
+ vdc_set_reg(VDC_BXR, 0); /* unscroll for the flat screen */
732
+ paint_over();
733
+ music_set(ST_OVER);
734
+ state = ST_OVER;
735
+ }
200
736
 
201
- pce_joy_init();
202
- disp_enable();
737
+ /* ── GAME LOGIC (clay) — death + alternating-turn handoff ── */
738
+ static void kill_player(void) {
739
+ u8 other;
740
+ psg_tone(3, 0x500, 31); /* death rumble */
741
+ sfx_timer = 16;
742
+ if (p_lives[cur_player] > 0) --p_lives[cur_player];
743
+ if (two_player) {
744
+ other = (u8)(cur_player ^ 1);
745
+ if (p_lives[other] > 0) cur_player = other; /* swap turns */
746
+ else if (p_lives[cur_player] == 0) { game_over(); return; }
747
+ } else if (p_lives[0] == 0) {
748
+ game_over();
749
+ return;
750
+ }
751
+ begin_turn();
752
+ }
203
753
 
204
- for (;;) {
205
- int16_t ipx, ipy, npy, sx;
206
- int16_t camCol;
207
- int32_t np;
208
- u8 grounded;
209
- u8 i;
210
- const Rect *p;
754
+ /* ── GAME LOGIC (clay) — the per-frame play update ────────────────────────── */
755
+ static void update_play(void) {
756
+ u8 i;
757
+ int16_t delta, y8, feet, c0, c1, top, sx;
758
+ int16_t camCol;
759
+ int32_t np;
760
+
761
+ if (turn_pause) { --turn_pause; return; }
762
+
763
+ /* horizontal move; past SCROLL_WALL the world scrolls instead of the
764
+ * player (the camera never scrolls back — the classic one-way camera). */
765
+ delta = 0;
766
+ if (pad & PCE_JOY_RIGHT) {
767
+ if (px < SCROLL_WALL) px = (int16_t)(px + (MOVE >> 4) + 1);
768
+ else {
769
+ int16_t adv = (int16_t)((MOVE >> 4) + 1);
770
+ if (camX + adv <= WORLD_W - SCREEN_W) { camX = (int16_t)(camX + adv); delta = adv; }
771
+ }
772
+ }
773
+ if ((pad & PCE_JOY_LEFT) && px > 8) px = (int16_t)(px - ((MOVE >> 4) + 1));
211
774
 
212
- waitvsync();
213
- pad = pce_joy_read();
775
+ /* jump (button I), only when grounded */
776
+ if ((pad & PCE_JOY_I) && !(prev_pad & PCE_JOY_I) && on_ground) {
777
+ vy_q44 = JUMP_VEL;
778
+ on_ground = 0;
779
+ psg_tone(2, 0x200, 26); sfx_timer = 6;
780
+ }
781
+
782
+ /* stream the columns entering from the right as the camera advances */
783
+ camCol = (int16_t)(camX >> 3);
784
+ while (camCol > lastCamCol) { lastCamCol++; paint_column((int16_t)(lastCamCol + 31)); }
214
785
 
215
- ipx = px >> 4;
216
- ipy = py >> 4;
786
+ /* smooth pixel scroll via the BG X register — the whole point */
787
+ vdc_set_reg(VDC_BXR, (u16)camX);
217
788
 
218
- /* camera follows player, clamped to world */
219
- camX = (int16_t)(ipx - (SCREEN_W / 2 - 8));
220
- if (camX < 0) camX = 0;
221
- if (camX > WORLD_W - SCREEN_W) camX = (int16_t)(WORLD_W - SCREEN_W);
789
+ /* world objects drift left as the level scrolls (world-anchored) */
790
+ if (delta) {
791
+ dist_sub = (u8)(dist_sub + delta);
792
+ if (dist_sub >= 64) {
793
+ dist_sub = (u8)(dist_sub - 64);
794
+ ++p_score[cur_player];
795
+ hud_dirty = 1;
796
+ }
797
+ for (i = 0; i < NUM_COINS; ++i) {
798
+ if (!coins[i].alive) continue;
799
+ coins[i].x = (int16_t)(coins[i].x - delta);
800
+ if (coins[i].x < -16) respawn_coin(i);
801
+ }
802
+ for (i = 0; i < NUM_SPIKES; ++i) {
803
+ if (!spikes[i].alive) continue;
804
+ spikes[i].x = (int16_t)(spikes[i].x - delta);
805
+ if (spikes[i].x < -16) spikes[i].alive = 0;
806
+ }
807
+ }
808
+ for (i = 0; i < NUM_SPIKES; ++i)
809
+ if (!spikes[i].alive) try_spawn_spike(i);
810
+
811
+ /* physics: gravity + sub-pixel y */
812
+ vy_q44 = (int16_t)(vy_q44 + GRAVITY);
813
+ if (vy_q44 > MAX_VY) vy_q44 = MAX_VY;
814
+ np = (int32_t)py_q44 + (int32_t)vy_q44;
815
+ py_q44 = (int16_t)np;
816
+ y8 = (int16_t)(py_q44 >> 4);
817
+
818
+ /* fell into a pit (below the screen) → lose the turn */
819
+ if (y8 >= 216) { kill_player(); return; }
820
+
821
+ /* landing — probe the two world columns under the player's feet (feet =
822
+ * sprite bottom; the 32px sprite's feet are ~16px below its top y). */
823
+ if (vy_q44 >= 0) {
824
+ feet = (int16_t)(y8 + 16);
825
+ c0 = (int16_t)((camX + px) >> 3);
826
+ c1 = (int16_t)((camX + px + 7) >> 3);
827
+ top = land_top(c0, feet);
828
+ if (top == 0) top = land_top(c1, feet);
829
+ if (top) {
830
+ py_q44 = (int16_t)((top - 16) << 4);
831
+ vy_q44 = 0;
832
+ if (!on_ground) { psg_tone(3, 0x2A0, 22); sfx_timer = 3; }
833
+ on_ground = 1;
834
+ } else {
835
+ on_ground = 0;
836
+ }
837
+ }
222
838
 
223
- /* stream columns entering from the edges */
224
- camCol = camX >> 3;
225
- while (camCol > lastCamCol) { lastCamCol++; paint_column((int16_t)(lastCamCol + 31)); }
226
- while (camCol < lastCamCol) { lastCamCol--; paint_column(lastCamCol); }
839
+ /* coins (collect) + spikes (death). AABB around the player center. */
840
+ sx = (int16_t)(px - 8);
841
+ for (i = 0; i < NUM_COINS; ++i) {
842
+ if (!coins[i].alive) continue;
843
+ if (dist8(coins[i].x, sx) < 18 && dist8(coins[i].y, y8) < 18) {
844
+ coins[i].alive = 0;
845
+ p_score[cur_player] += 10;
846
+ hud_dirty = 1;
847
+ psg_tone(2, 0x0D6, 31); sfx_timer = 6;
848
+ respawn_coin(i);
849
+ }
850
+ }
851
+ for (i = 0; i < NUM_SPIKES; ++i) {
852
+ if (!spikes[i].alive) continue;
853
+ if (dist8(spikes[i].x, sx) < 14 && dist8(spikes[i].y, y8) < 16) {
854
+ kill_player();
855
+ return;
856
+ }
857
+ }
227
858
 
228
- /* smooth pixel scroll via BG X register */
229
- vdc_set_reg(VDC_BXR, (u16)camX);
859
+ /* walk-cycle animation: flip the hero frame every 8 px of camera/x travel */
860
+ if (delta || (pad & (PCE_JOY_LEFT | PCE_JOY_RIGHT))) {
861
+ ++anim_frame;
862
+ if ((anim_frame & 7) == 0) upload_hero((anim_frame & 8) ? hero_body_b : hero_body_a);
863
+ }
864
+ }
230
865
 
231
- /* horizontal move */
232
- vx = 0;
233
- if (pad & PCE_JOY_LEFT) vx = (int16_t)(-MOVE);
234
- if (pad & PCE_JOY_RIGHT) vx = MOVE;
866
+ void main(void) {
867
+ u8 newpad, raw1, raw2;
868
+
869
+ _pce_keep[0] = 0; /* see the EMPTY-BSS TRAP note in pce_hw.h */
870
+
871
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
872
+ * Init order: palette → VRAM uploads → BAT paint → joypad → display ON.
873
+ * disp_enable() also sets the VBlank IRQ bit — without it waitvsync()
874
+ * never returns and the game freezes on its first frame. */
875
+ /* BG sub-pal 0: scenery. BG sub-pal 1: HUD/text (white). */
876
+ vce_set_color(0, PCE_RGB(1, 2, 5)); /* backdrop: dusk blue */
877
+ vce_set_color(1, PCE_RGB(2, 4, 7)); /* BG c1: sky */
878
+ vce_set_color(2, PCE_RGB(3, 2, 1)); /* BG c2: brown dirt */
879
+ vce_set_color(3, PCE_RGB(1, 6, 1)); /* BG c3: grassy green */
880
+ vce_set_color(17, PCE_RGB(7, 7, 7)); /* text: white */
881
+ /* sprite sub-palettes (256 + pal*16 + index) */
882
+ vce_set_color(257, PCE_RGB(7, 4, 1)); /* pal0 c1: hero orange body */
883
+ vce_set_color(259, PCE_RGB(7, 7, 4)); /* pal0 c3: hero face/cap accent */
884
+ vce_set_color(273, PCE_RGB(7, 7, 0)); /* pal1 c1: coin gold */
885
+ vce_set_color(289, PCE_RGB(7, 1, 1)); /* pal2 c1: spike danger red */
886
+
887
+ upload_art();
888
+
889
+ hiscore = hiscore_load(); /* always 0 — no persistence on a bare HuCard */
890
+ state = ST_TITLE;
891
+ paint_title();
892
+ music_set(ST_TITLE);
235
893
 
236
- grounded = on_platform(ipx, ipy);
237
- if ((pad & PCE_JOY_I) && !(prev_pad & PCE_JOY_I) && grounded) {
238
- vy = JUMP;
239
- psg_tone(0, 0x200, 24);
894
+ pce_joy_init();
895
+ disp_enable();
896
+
897
+ for (;;) {
898
+ waitvsync();
899
+
900
+ /* vblank work first: sprites + SATB DMA + queued HUD writes */
901
+ push_sprites();
902
+ satb_dma();
903
+ if (hud_dirty && state == ST_PLAY) { draw_hud_numbers(); hud_dirty = 0; }
904
+
905
+ music_tick();
906
+ if (sfx_timer) {
907
+ --sfx_timer;
908
+ if (sfx_timer == 0) { psg_off(2); psg_off(3); }
240
909
  }
241
- prev_pad = pad;
242
910
 
243
- vy = (int16_t)(vy + GRAVITY);
244
- if (vy > MAXFALL) vy = MAXFALL;
245
- if (grounded && vy > 0) vy = 0;
246
-
247
- /* horizontal integrate + clamp */
248
- px = (int16_t)(px + vx);
249
- if (px < 0) px = 0;
250
- if (px > ((WORLD_W - 16) << 4)) px = (int16_t)((WORLD_W - 16) << 4);
251
-
252
- /* vertical integrate with land-on-top */
253
- np = (int32_t)py + (int32_t)vy;
254
- npy = (int16_t)(np >> 4);
255
- if (vy > 0) {
256
- u8 landed = 0;
257
- for (i = 0; i < N_PLATFORMS; ++i) {
258
- p = &platforms[i];
259
- if (ipy + 16 <= p->y && npy + 16 >= p->y &&
260
- ipx + 12 > p->x && ipx + 4 < p->x + p->w) {
261
- py = (int16_t)((p->y - 16) << 4);
262
- vy = 0;
263
- landed = 1;
264
- break;
265
- }
266
- }
267
- if (!landed) py = (int16_t)np;
911
+ /* ── HARDWARE IDIOM (load-bearing) 2P input via the TurboTap.
912
+ * pce_joy_read() reads pad 1 (slot 0). For pad 2 we read cc65's
913
+ * JOY_2 directly and translate it like pce_input.c does, so the
914
+ * CURRENT player's pad drives the game during their alternating turn.
915
+ * The host enables the TurboTap, so JOY_2 carries real port-1 input.
916
+ * On the title screen we always read pad 1 (the menu pad). */
917
+ raw1 = pce_joy_read();
918
+ if (state == ST_PLAY && cur_player == 1) {
919
+ raw2 = joy_read(JOY_2); /* cc65 raw mask for pad 2 */
920
+ pad = 0; /* translate like pce_input.c */
921
+ if (JOY_UP(raw2)) pad |= PCE_JOY_UP;
922
+ if (JOY_DOWN(raw2)) pad |= PCE_JOY_DOWN;
923
+ if (JOY_LEFT(raw2)) pad |= PCE_JOY_LEFT;
924
+ if (JOY_RIGHT(raw2)) pad |= PCE_JOY_RIGHT;
925
+ if (JOY_BTN_1(raw2)) pad |= PCE_JOY_I;
926
+ if (JOY_BTN_2(raw2)) pad |= PCE_JOY_II;
927
+ if (JOY_BTN_3(raw2)) pad |= PCE_JOY_SELECT;
928
+ if (JOY_BTN_4(raw2)) pad |= PCE_JOY_RUN;
268
929
  } else {
269
- py = (int16_t)np;
930
+ pad = raw1;
270
931
  }
271
- if (py > (224 << 4)) { px = (int16_t)(24 << 4); py = (int16_t)(160 << 4); vy = 0; }
932
+ newpad = (u8)(pad & ~prev_pad);
272
933
 
273
- /* free the jump SFX channel after it rings */
274
- if (vy == 0) psg_off(0);
934
+ if (state == ST_TITLE) {
935
+ prev_pad = pad;
936
+ if (newpad & (PCE_JOY_RUN | PCE_JOY_I)) start_game(0);
937
+ else if (newpad & PCE_JOY_II) start_game(1);
938
+ continue;
939
+ }
940
+ if (state == ST_OVER) {
941
+ prev_pad = pad;
942
+ if (newpad & (PCE_JOY_RUN | PCE_JOY_I)) {
943
+ state = ST_TITLE;
944
+ vdc_set_reg(VDC_BXR, 0);
945
+ paint_title();
946
+ music_set(ST_TITLE);
947
+ }
948
+ continue;
949
+ }
275
950
 
276
- /* draw player in screen space */
277
- sx = (int16_t)((px >> 4) - camX);
278
- if (sx < 0) sx = 0;
279
- if (sx > 240) sx = 240;
280
- set_sprite(0, (u16)sx, (u16)(py >> 4), PLAYER_VRAM >> 6, 0);
281
- satb_dma();
951
+ update_play();
952
+ prev_pad = pad;
282
953
  }
283
954
  }