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