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,207 +1,971 @@
1
- /* ── platformer.c — Game Boy Color SIDE-SCROLLING platformer scaffold ──
1
+ /* ── platformer.c — SPECTRA BOUND: Game Boy Color side-scrolling platformer ──
2
2
  *
3
- * A horizontally scrolling platformer: the world is 256 px wide (the
4
- * full wrapping BG map), a camera follows the player and scrolls the BG
5
- * via the SCX register. Gravity + jump + land-on-top collision against
6
- * a static platform list in WORLD coords.
3
+ * A COMPLETE, working game title screen, gravity + jump physics with
4
+ * sub-pixel precision, one-way platforms, pits and spikes, coins + distance
5
+ * scoring, persistent battery hi-score (MBC1+RAM+BATTERY SRAM), music + SFX,
6
+ * the Game Boy's signature WINDOW-layer fixed HUD over an SCX-scrolling
7
+ * looping level — and the GBC's signature feature on top of all of it:
8
+ * TRUE per-tile color. Sky, grass, dirt, platforms and hazards are FIVE
9
+ * REAL CGB palettes (15-bit BGR, loaded through BCPS/BCPD), assigned per BG
10
+ * cell through the VRAM bank-1 attribute map, and the player / coins / spikes
11
+ * are their own OBJ palettes through OCPS — not a colorized monochrome game.
7
12
  *
8
- * Subpixel state (x/y in 1/16-pixel units) for fine acceleration. The
9
- * player sprite draws at SCREEN x = (worldX>>4) - camX.
13
+ * THE GAME: an endless one-way runner. Hold RIGHT to gallop; the world
14
+ * scrolls past a scroll wall (the classic runner camera). A=jump (with
15
+ * coyote-free, grounded-only launch). Hop the lethal pits and the drifting
16
+ * spikes, scoop coins, and the longer you survive the higher your distance
17
+ * score climbs. Three lives; the battery remembers your best run forever.
18
+ * SELECT toggles the music.
10
19
  *
11
- * Color: the BG and OBJ palettes are uploaded through the CGB palette
12
- * registers (BCPS/BCPD and OCPS/OCPD) full 15-bit color, unlike DMG.
20
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
21
+ * very different one. The markers tell you what's what:
22
+ * HARDWARE IDIOM (load-bearing) — dodges a documented GB/GBC footgun;
23
+ * reshape your gameplay around it (see TROUBLESHOOTING before changing).
24
+ * GAME LOGIC (clay) — level layout, physics tuning, scoring, art: reshape
25
+ * freely.
13
26
  *
14
- * The world here is one BG map (256 px) so plain SCX scrolls it with no
15
- * streaming. For a WIDER world, stream the next BG-map column each time
16
- * camX crosses an 8-px boundary see the GBC MENTAL_MODEL.md.
27
+ * SINGLE-PLAYER, honestly: the Game Boy's "player 2" is a LINK CABLE, which
28
+ * one emulator instance cannot provide so handheld examples ship a
29
+ * press-start title and no 2P mode instead of faking one.
30
+ *
31
+ * What depends on what:
32
+ * gb_hardware.h — register names (LCDC/WX/WY/VBK/BCPS/NRxx/...) + masks.
33
+ * gb_runtime.{h,c} — vblank wait (HALT-driven), joypad, shadow OAM + the
34
+ * OAM-DMA-from-HRAM routine, VRAM-safe memcpy, APU helpers (shared GB).
35
+ * gb_crt0.s — boot + interrupt vectors + the cartridge header window. It
36
+ * DECLARES the cart as MBC1+RAM+BATTERY ($0147=$03, $0149=$02): that
37
+ * header is what makes the SRAM hi-score persist (the GB equivalent of
38
+ * the NES BATTERY bit). Load-bearing; edit with TROUBLESHOOTING open.
39
+ * font.h — 0-9 A-Z 2bpp glyphs for all text.
40
+ *
41
+ * The level is a 256-px-wide COLUMN MAP painted ONCE into the wrapping
42
+ * 32-wide BG map (bank-0 tiles + bank-1 palette attributes), so the uint8
43
+ * SCX scroll wraps PERFECTLY seamless — an endless looping run. The color
44
+ * travels with the tiles: each cell's bank-1 attribute byte scrolls along
45
+ * with its tile, so a grass cell stays green wherever it slides on screen.
46
+ *
47
+ * WRAM NOTE: build with dataLoc:0xC200 so our statics sit ABOVE shadow_oam
48
+ * ($C100) — else oam_clear() would zero our state. The project recipe sets
49
+ * that automatically.
17
50
  */
18
51
 
19
52
  #include "gb_hardware.h"
20
53
  #include "gb_runtime.h"
54
+ #include "font.h"
21
55
 
56
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
57
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
58
+ #define GAME_TITLE "SPECTRA BOUND"
59
+
60
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
61
+ * Tile inventory. GB/GBC tiles are 16 bytes: 8 rows × [low-plane byte,
62
+ * high-plane byte]. Pixel colour index = (hi_bit << 1) | lo_bit (0..3); on
63
+ * CGB that index selects a colour WITHIN whichever CGB palette the cell's
64
+ * bank-1 attribute (BG) or the sprite's OAM attr (OBJ) chose. So one grass
65
+ * tile reads green or any other palette purely by its attribute byte. */
22
66
  static const uint8_t tile_blank[16] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 };
23
- static const uint8_t tile_player[16] = {
24
- 0x18,0x18, 0x3C,0x3C, 0x7E,0x7E, 0xFF,0xFF,
25
- 0xFF,0xFF, 0x7E,0x7E, 0x3C,0x3C, 0x18,0x18,
67
+ static const uint8_t tile_player[16] = { /* round body + face */
68
+ 0x3C,0x00, 0x7E,0x24, 0xFF,0x24, 0xFF,0x00,
69
+ 0xFF,0x00, 0x7E,0x00, 0x66,0x00, 0x66,0x00,
70
+ };
71
+ static const uint8_t tile_player_jump[16] = { /* arms up, mid-leap */
72
+ 0x18,0x00, 0x7E,0x24, 0xFF,0x24, 0xFF,0x00,
73
+ 0xE7,0x00, 0xC3,0x00, 0x81,0x00, 0x00,0x00,
74
+ };
75
+ static const uint8_t tile_coin[16] = { /* faceted gem disc */
76
+ 0x00,0x3C, 0x30,0x4E, 0x60,0x9F, 0x40,0xBF,
77
+ 0x02,0xFF, 0x06,0xFF, 0x1C,0x7E, 0x00,0x3C,
78
+ };
79
+ static const uint8_t tile_spike[16] = { /* solid spike (value 3) */
80
+ 0x00,0x00, 0x18,0x18, 0x18,0x18, 0x3C,0x3C,
81
+ 0x3C,0x3C, 0x7E,0x7E, 0x7E,0x7E, 0xFF,0xFF,
82
+ };
83
+ /* Backdrop tiles. tile_sky carries two value-1 dot pixels so even "empty"
84
+ * sky is never one flat colour (the render-health floor every example
85
+ * keeps), and the dots make horizontal scroll motion visible everywhere. */
86
+ static const uint8_t tile_sky[16] = { /* faint specks (value 1) */
87
+ 0x20,0x00, 0x00,0x00, 0x00,0x00, 0x02,0x00,
88
+ 0x00,0x00, 0x08,0x00, 0x00,0x00, 0x40,0x00,
89
+ };
90
+ static const uint8_t tile_cloud[16] = { /* value-3 puff */
91
+ 0x00,0x00, 0x18,0x18, 0x3C,0x3C, 0x7E,0x7E,
92
+ 0x7E,0x7E, 0x00,0x00, 0x00,0x00, 0x00,0x00,
26
93
  };
27
- static const uint8_t tile_platform[16] = {
28
- 0xFF,0xFF, 0x80,0x80, 0x80,0x80, 0x80,0x80,
29
- 0x80,0x80, 0x80,0x80, 0x80,0x80, 0xFF,0xFF,
94
+ static const uint8_t tile_dirt[16] = { /* value-2 fill, value-1 grit*/
95
+ 0x00,0xFF, 0x20,0xDF, 0x00,0xFF, 0x04,0xFB,
96
+ 0x00,0xFF, 0x80,0x7F, 0x00,0xFF, 0x08,0xF7,
30
97
  };
31
- /* ── Backdrop tiles ───────────────────────────────────────────────────
32
- * Fill the whole world so the screen is never one flat colour (the #1 GB
33
- * "why is it blank" footgun). tile_sky is a sparse dot pattern over the
34
- * sky; tile_ground is a textured dirt fill under the floor line. */
35
- static const uint8_t tile_sky[16] = {
36
- 0x00,0x00, 0x00,0x00, 0x00,0x00, 0x20,0x20,
37
- 0x00,0x00, 0x00,0x00, 0x02,0x02, 0x00,0x00,
98
+ static const uint8_t tile_grass[16] = { /* value-3 turf over dirt */
99
+ 0xFF,0xFF, 0xFF,0xFF, 0x00,0xFF, 0x20,0xDF,
100
+ 0x00,0xFF, 0x04,0xFB, 0x00,0xFF, 0x00,0xFF,
38
101
  };
39
- static const uint8_t tile_ground[16] = {
40
- 0xFF,0x00, 0xDB,0x24, 0xFF,0x00, 0x6D,0x92,
41
- 0xFF,0x00, 0xDB,0x24, 0xFF,0x00, 0x6D,0x92,
102
+ static const uint8_t tile_plat[16] = { /* one-way slab top edge */
103
+ 0xFF,0xFF, 0xFF,0xFF, 0x00,0xFF, 0x00,0xDB,
104
+ 0x00,0xFF, 0x00,0x00, 0x00,0x00, 0x00,0x00,
42
105
  };
43
- #define T_BLANK 0
44
- #define T_PLATFORM 2
45
- #define T_SKY 3
46
- #define T_GROUND 4
47
-
48
- static const uint16_t obj_palette[4] = { 0x7FFF, 0x001F, 0x03E0, 0x7C00 };
49
- /* BG palette: 0 sky-blue, 1 mid, 2 dirt-dark, 3 near-black detail. */
50
- static const uint16_t bg_palette[4] = { 0x7E10, 0x5294, 0x114A, 0x0000 };
51
-
52
- typedef struct { int16_t x, y, w, h; } Rect;
53
-
54
- #define WORLD_W 256
55
- #define SCREEN_W 160
56
-
57
- /* Platforms in WORLD coords, spread across the 256-px world. */
58
- static const Rect platforms[] = {
59
- { 0, 128, 256, 16 }, /* floor spans the world */
60
- { 16, 100, 40, 8 },
61
- { 96, 96, 32, 8 },
62
- { 168, 80, 40, 8 },
63
- { 56, 64, 32, 8 },
64
- { 200, 110, 40, 8 },
65
- { 130, 48, 40, 8 },
106
+ static const uint8_t tile_hudbar[16] = { /* solid value-3 divider */
107
+ 0xFF,0xFF, 0xFF,0xFF, 0xFF,0xFF, 0xFF,0xFF,
108
+ 0xFF,0xFF, 0xFF,0xFF, 0xFF,0xFF, 0xFF,0xFF,
109
+ };
110
+
111
+ /* Tile indices ($8000 unsigned addressing LCDC bit 4 set below). Sprites
112
+ * and BG share the $8000 table in this layout, so one upload serves both.
113
+ * Font glyphs follow at FONT_BASE (digits 0-9, then A-Z). */
114
+ #define T_BLANK 0
115
+ #define T_PLAYER 1
116
+ #define T_JUMP 2
117
+ #define T_COIN 3
118
+ #define T_SPIKE 4
119
+ #define T_SKY 5
120
+ #define T_CLOUD 6
121
+ #define T_DIRT 7
122
+ #define T_GRASS 8
123
+ #define T_PLAT 9
124
+ #define T_HUDBAR 10
125
+ #define FONT_BASE 16 /* digit d = 16+d, letter L = 16+10+idx (see font.h) */
126
+
127
+ /* ── GAME LOGIC (clay — reshape freely) ── the CGB palette TABLE (the colours
128
+ * themselves are art; the LOADER below is the hardware idiom).
129
+ * 15-bit BGR: 5 bits each, blue in the high bits — RGB() packs it. Colour 0
130
+ * of a BG palette is the cell's "background" shade; for OBJ palettes colour 0
131
+ * is transparent (the scene shows through). */
132
+ #define RGB(r,g,b) ((uint16_t)(((uint16_t)(b)<<10)|((uint16_t)(g)<<5)|(r)))
133
+
134
+ /* BG palette slots (bank-1 attribute byte bits 0-2 select one of these). */
135
+ #define PAL_SKY 0 /* daytime sky + clouds */
136
+ #define PAL_GRASS 1 /* grass turf top */
137
+ #define PAL_DIRT 2 /* dirt fill / pit walls */
138
+ #define PAL_PLAT 3 /* floating slabs */
139
+ #define PAL_HUD 4 /* HUD bar + all text */
140
+
141
+ static const uint16_t bg_palettes[8][4] = {
142
+ /* 0 sky */ { RGB(18,26,31), RGB(28,31,31), RGB(10,18,28), RGB(31,31,31) },
143
+ /* 1 grass */ { RGB(6,18,8), RGB(12,28,10), RGB(3,12,4), RGB(20,31,16) },
144
+ /* 2 dirt */ { RGB(10,7,4), RGB(18,12,6), RGB(6,4,2), RGB(24,17,9) },
145
+ /* 3 plat */ { RGB(14,8,20), RGB(24,16,31), RGB(8,3,14), RGB(30,24,31) },
146
+ /* 4 hud */ { RGB(2,2,6), RGB(8,9,16), RGB(2,2,6), RGB(31,31,31) },
147
+ /* 5 spare */ { RGB(0,0,0), RGB(10,10,10), RGB(20,20,20), RGB(31,31,31) },
148
+ /* 6 spare */ { RGB(0,0,0), RGB(10,10,10), RGB(20,20,20), RGB(31,31,31) },
149
+ /* 7 spare */ { RGB(0,0,0), RGB(10,10,10), RGB(20,20,20), RGB(31,31,31) },
150
+ };
151
+
152
+ /* OBJ palette slots (OAM attr bits 0-2 select one of these). Colour 0 is
153
+ * always transparent. */
154
+ #define OPAL_PLAYER 0 /* sky-blue hero, white face */
155
+ #define OPAL_COIN 1 /* golden gem */
156
+ #define OPAL_SPIKE 2 /* danger red */
157
+
158
+ static const uint16_t obj_palettes[8][4] = {
159
+ /* 0 player */ { 0, RGB(10,20,31), RGB(31,31,31), RGB(2,6,16) },
160
+ /* 1 coin */ { 0, RGB(31,28,6), RGB(31,20,2), RGB(20,12,0) },
161
+ /* 2 spike */ { 0, RGB(31,8,8), RGB(20,2,2), RGB(31,24,16) },
162
+ /* 3 spare */ { 0, RGB(20,20,20), RGB(31,31,31), RGB(10,10,10) },
163
+ /* 4 spare */ { 0, RGB(20,20,20), RGB(31,31,31), RGB(10,10,10) },
164
+ /* 5 spare */ { 0, RGB(20,20,20), RGB(31,31,31), RGB(10,10,10) },
165
+ /* 6 spare */ { 0, RGB(20,20,20), RGB(31,31,31), RGB(10,10,10) },
166
+ /* 7 spare */ { 0, RGB(20,20,20), RGB(31,31,31), RGB(10,10,10) },
167
+ };
168
+
169
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
170
+ * THE WINDOW-LAYER HUD — the Game Boy's signature "fixed HUD over a
171
+ * scrolling world" technique. The window is a second BG plane with its own
172
+ * 32×32 tile map and NO scroll registers: it always draws its map from
173
+ * (0,0), pinned to the screen, on top of the BG. So the HUD lives in the
174
+ * window and the playfield lives in the BG — SCX scrolls the world all it
175
+ * likes and the HUD never moves. No raster splits, no IRQ timing (the NES
176
+ * needs a sprite-0 polling dance for this exact effect; on GB it's three
177
+ * register writes). On CGB the window cells take bank-1 palette attributes
178
+ * exactly like the BG (set_wcell writes both banks).
179
+ *
180
+ * The three registers, and their two famous footguns:
181
+ * WY ($FF4A) — first screen LINE the window covers. We use 128: lines
182
+ * 0-127 are playfield, 128-143 (two tile rows) are the HUD strip.
183
+ * WX ($FF4B) — screen column PLUS SEVEN. WX=7 means "left edge". The -7
184
+ * offset is hardware fact: WX=0..6 glitches, WX≥167 is off-screen.
185
+ * LCDC bit 5 — window enable; bit 6 — which map it reads ($9800/$9C00).
186
+ *
187
+ * FOOTGUN 1 — "the window ate the bottom of my screen": once the window
188
+ * starts on a line it covers EVERY line from there DOWN, full width. There
189
+ * is no window height register. That is why GB HUDs sit at the BOTTOM of the
190
+ * screen. A TOP HUD needs a STAT-interrupt LYC trick — a different, fragile
191
+ * idiom; don't drift into it by accident by setting WY=0.
192
+ *
193
+ * FOOTGUN 2 — sprites are NOT clipped by the window. OBJs draw over it, so a
194
+ * sprite below line 128 sits ON the HUD. Gameplay keeps every object above
195
+ * PLAY_H (spikes stand on the ground; a player falling into a pit dies at
196
+ * PLAY_H-8, the frame before its sprite would touch the HUD).
197
+ *
198
+ * Requires: window map at $9C00 (LCDC bit 6), tile data at $8000 (bit 4),
199
+ * WX=7, WY=PLAY_H, LCDC bit 5 set during play (title turns the window off). */
200
+ #define PLAY_H 128 /* first HUD line = window top */
201
+ #define LCDC_TITLE (LCDC_LCD_ON | LCDC_BG_ON | LCDC_OBJ_ON | LCDC_TILE_DATA_LO)
202
+ #define LCDC_PLAY (LCDC_TITLE | LCDC_WINDOW_ON | LCDC_WINDOW_MAP_HI)
203
+
204
+ #define VRAM ((volatile uint8_t *)0x9800) /* BG map $9800 base */
205
+ #define WIN_OFF 0x400 /* window map $9C00 = $9800 + $400 */
206
+
207
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
208
+ * BATTERY SRAM — persistent hi-score. MBC1 cart RAM is 8KB at $A000-$BFFF,
209
+ * but it boots DISABLED and writes to a disabled bank are silently
210
+ * discarded (reads float). The gate is the MBC's RAM-enable register: any
211
+ * WRITE to ROM space $0000-$1FFF with $0A in the low nibble enables the RAM;
212
+ * writing $00 disables it again. (Writing "into ROM" feels wrong the first
213
+ * time — ROM-area writes never touch ROM, they talk to the mapper chip.)
214
+ * Leaving RAM enabled all the time "works" in emulators but on real hardware
215
+ * risks corruption at power-off — battery carts since forever do
216
+ * enable → touch → disable, so we do too.
217
+ *
218
+ * First boot is GARBAGE, not zeros: battery RAM holds whatever the silicon
219
+ * woke up with. The magic 'H','S' + checksum is how the load path tells "my
220
+ * save" from "factory noise" — without it a fresh cart shows a junk hi-score.
221
+ *
222
+ * Save block at $A000: 'H' 'S' lo hi (lo^hi^$A5)
223
+ *
224
+ * Requires: gb_crt0.s declaring $0147=$03 (MBC1+RAM+BATTERY) + $0149=$02
225
+ * (8KB) — those header bytes are how the emulator knows to allocate and
226
+ * persist SAVE_RAM. Verify headlessly: play, game over, then
227
+ * memory({op:'read', region:'save_ram'}) shows the block, and the hi-score
228
+ * survives host.hardReset(). */
229
+ #define MBC_RAM_ENABLE (*(volatile uint8_t *)0x0000)
230
+ #define SRAM ((volatile uint8_t *)0xA000)
231
+
232
+ static uint16_t hiscore_load(void) {
233
+ uint16_t v = 0;
234
+ MBC_RAM_ENABLE = 0x0A; /* unlock cart RAM */
235
+ if (SRAM[0] == 'H' && SRAM[1] == 'S' &&
236
+ SRAM[4] == (uint8_t)(SRAM[2] ^ SRAM[3] ^ 0xA5)) {
237
+ v = (uint16_t)(SRAM[2] | ((uint16_t)SRAM[3] << 8));
238
+ }
239
+ MBC_RAM_ENABLE = 0x00; /* re-lock (battery hygiene) */
240
+ return v;
241
+ }
242
+
243
+ static void hiscore_save(uint16_t v) {
244
+ uint8_t lo = (uint8_t)(v & 0xFF), hi = (uint8_t)(v >> 8);
245
+ MBC_RAM_ENABLE = 0x0A;
246
+ SRAM[0] = 'H'; SRAM[1] = 'S';
247
+ SRAM[2] = lo; SRAM[3] = hi;
248
+ SRAM[4] = (uint8_t)(lo ^ hi ^ 0xA5);
249
+ MBC_RAM_ENABLE = 0x00;
250
+ }
251
+
252
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
253
+ * The level — a 32-column map; world x = (screen x + scroll_x) mod 256.
254
+ * ground_row[c] — BG-map row of the ground's grass top, 0xFF = pit.
255
+ * plat_row[c] — row of a one-way floating platform, 0 = none.
256
+ * Rows are BG-map rows (y = row*8). The playfield is rows 0..15 (128 px,
257
+ * everything below is under the window HUD). Pits are 4 columns wide on
258
+ * purpose: at this gravity a 2 px/frame run skims anything narrower (the
259
+ * landing probe's +4 px catch window forgives small sink — see land_top). */
260
+ #define NO_GROUND 0xFF
261
+ #define GROUND 13 /* grass-top row, y = 104 */
262
+ static const uint8_t ground_row[32] = {
263
+ GROUND, GROUND, GROUND, GROUND, GROUND, GROUND, GROUND, GROUND, /* runway */
264
+ NO_GROUND, NO_GROUND, NO_GROUND, NO_GROUND, /* pit 1 */
265
+ GROUND, GROUND, GROUND, GROUND, GROUND, GROUND, GROUND,
266
+ NO_GROUND, NO_GROUND, NO_GROUND, NO_GROUND, /* pit 2 */
267
+ GROUND, GROUND, GROUND, GROUND, GROUND, GROUND, GROUND, GROUND, GROUND,
66
268
  };
67
- #define N_PLATFORMS (sizeof(platforms)/sizeof(platforms[0]))
68
-
69
- static uint8_t on_platform(int16_t px, int16_t py) {
70
- uint8_t i;
71
- const Rect *p;
72
- for (i = 0; i < N_PLATFORMS; i++) {
73
- p = &platforms[i];
74
- if (py + 8 == p->y && px + 8 > p->x && px < p->x + p->w) return 1;
269
+ static const uint8_t plat_row[32] = {
270
+ 0, 0, 0, 0, 10, 10, 10, 0, /* slab on the runway */
271
+ 0, 9, 9, 0, 0, 0, 10, 10, /* stepping stone over pit 1 */
272
+ 10, 0, 0, 0, 9, 9, 0, 0, /* stone over pit 2 */
273
+ 0, 0, 10, 10, 10, 0, 0, 0, /* slab before the loop seam */
274
+ };
275
+
276
+ /* ── GAME LOGIC (clay) physics + tuning (Q4.4 fixed point) ── */
277
+ #define GRAVITY_Q44 2 /* +1/8 px per frame per frame */
278
+ #define JUMP_VEL_Q44 (-52) /* launch vy → ~42 px apex (~5 tile rows) */
279
+ #define MAX_VY_Q44 80 /* terminal velocity, 5 px/frame — MUST stay *
280
+ * under 6: the landing probe's 6-px window *
281
+ * can't catch a faster fall (tunnelling) */
282
+ #define MOVE_SPEED 2 /* px/frame walk + scroll speed */
283
+ #define SCROLL_WALL 72 /* px: past this the world scrolls, not you */
284
+ #define GROUND_TOP 104 /* GROUND row * 8 */
285
+ #define SPIKE_Y 96 /* spikes stand on the ground */
286
+ #define NUM_COINS 3
287
+ #define NUM_SPIKES 2
288
+ #define START_LIVES 3
289
+
290
+ static uint8_t px; /* player screen x */
291
+ static uint16_t py_q44; /* player y, Q4.4 fixed point — gravity
292
+ * adds <1 px/frame near the jump apex,
293
+ * so we need sub-pixel precision */
294
+ static int8_t vy_q44;
295
+ static uint8_t on_ground;
296
+ static uint8_t scroll_x; /* level scroll — uint8 wraps at 256 = *
297
+ * exactly one level loop (seamless) */
298
+ static uint8_t dist_sub; /* sub-counter: 64 px scrolled = +1 pt */
299
+ static uint8_t coin_x[NUM_COINS], coin_y[NUM_COINS];
300
+ static uint8_t spike_x[NUM_SPIKES], spike_active[NUM_SPIKES];
301
+ static uint8_t lives;
302
+ static uint16_t score;
303
+ static uint16_t hiscore; /* live HUD readout: max(score, record) */
304
+ static uint16_t record; /* what the battery SRAM actually holds */
305
+ static uint8_t respawn_pause; /* freeze + blink frames after a death */
306
+ static uint8_t prev_pad;
307
+ static uint8_t hud_dirty; /* queue VRAM writes; vblank commits them */
308
+ static uint8_t msg_stage; /* game-over text: 2 = line 1 pending, 1 = line 2 */
309
+ static uint8_t msg_col; /* BG map col for GAME OVER (scroll-aware) */
310
+
311
+ /* Game states — the shell every example shares: title → play → game over.
312
+ * (Handheld adaptation: title is press-start; consoles add a 1P/2P pick.) */
313
+ #define ST_TITLE 0
314
+ #define ST_PLAY 1
315
+ #define ST_OVER 2
316
+ static uint8_t state;
317
+
318
+ /* ── GAME LOGIC (clay) — Galois LFSR (taps $B8), period 255 ── */
319
+ static uint8_t rng_state = 0xA5;
320
+ static uint8_t rand8(void) {
321
+ uint8_t lsb = (uint8_t)(rng_state & 1);
322
+ rng_state >>= 1;
323
+ if (lsb) rng_state ^= 0xB8;
324
+ return rng_state;
325
+ }
326
+
327
+ static uint8_t dist8(uint8_t a, uint8_t b) {
328
+ return (a > b) ? (uint8_t)(a - b) : (uint8_t)(b - a);
329
+ }
330
+
331
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
332
+ * CGB palette RAM — the BCPS/BCPD (BG) and OCPS/OCPD (OBJ) port pairs.
333
+ * requires: a .gbc build (CGB flag $0143 set — the build pipeline does it);
334
+ * on a DMG build these registers are dead and you get 4-shade green.
335
+ *
336
+ * Palette RAM is NOT memory-mapped: it's 64 bytes (8 palettes × 4 colours ×
337
+ * 2 bytes, little-endian 15-bit BGR) behind an index/data port pair.
338
+ * BCPS = 0x80 | index — set write index; bit 7 = AUTO-INCREMENT, so a
339
+ * burst of BCPD writes walks the whole 64 bytes.
340
+ * BCPD = low byte; BCPD = high byte; ... 32 times = all 8 palettes.
341
+ *
342
+ * TIMING FOOTGUN: palette RAM belongs to the PPU. Writes during active
343
+ * display (mode 3) are IGNORED on real hardware — same constraint as VRAM.
344
+ * Load palettes with the LCD OFF (boot / transitions, as here) or inside
345
+ * vblank. A palette "fade" = a few BCPD writes per vblank, never a mid-frame
346
+ * burst. */
347
+ static void load_bg_palettes(void) {
348
+ uint8_t p, i;
349
+ BCPS = 0x80; /* index 0, auto-increment on */
350
+ for (p = 0; p < 8; p++)
351
+ for (i = 0; i < 4; i++) {
352
+ BCPD = (uint8_t)(bg_palettes[p][i] & 0xFF);
353
+ BCPD = (uint8_t)((bg_palettes[p][i] >> 8) & 0xFF);
354
+ }
355
+ }
356
+
357
+ static void load_obj_palettes(void) {
358
+ uint8_t p, i;
359
+ OCPS = 0x80;
360
+ for (p = 0; p < 8; p++)
361
+ for (i = 0; i < 4; i++) {
362
+ OCPD = (uint8_t)(obj_palettes[p][i] & 0xFF);
363
+ OCPD = (uint8_t)((obj_palettes[p][i] >> 8) & 0xFF);
75
364
  }
76
- return 0;
77
365
  }
78
366
 
367
+ /* ── GAME LOGIC (clay) — VRAM upload + text helpers ──────────────────────────
368
+ * All of these write VRAM, so they run with the LCD OFF (boot/repaints) or
369
+ * inside vblank (the HUD digit commit). memcpy_vram walks a pointer
370
+ * (*dst++ = v) — never index dst[i] through a VRAM pointer (SDCC's sm83 port
371
+ * miscompiles indexed stores through VRAM-pointing pointers). */
79
372
  static void upload_tile(uint8_t slot, const uint8_t *src) {
80
- uint8_t *dst = (uint8_t *)(0x8000 + slot * 16);
81
- /* memcpy_vram (pointer-walk) — NOT an indexed dst[i]=src[i] loop, which
82
- * SDCC sm83 miscompiles when dst points into VRAM ($8000-$9FFF). */
83
- memcpy_vram(dst, src, 16);
84
- }
85
-
86
- static void paint_platforms(void) {
87
- /* Each cell of the BG map is 8×8, BG map base $9800. */
88
- uint8_t *map = (uint8_t *)0x9800;
89
- uint8_t i, j;
90
- uint16_t k;
91
- int16_t cx, cy, cw, ch;
92
- const Rect *p;
93
- /* k MUST be uint16_t: 32*18 = 576 > 255, so a uint8_t counter would
94
- * never reach the bound and this loop would spin forever (the BG map
95
- * never clears, main() never starts). Classic SDCC limited-range trap.
96
- * Fill sky above the floor line (row 16 = y 128) and textured ground
97
- * at and below it, so the whole world is a real scene, not blank. */
98
- for (k = 0; k < 32 * 18; k++) map[k] = (k >= 16 * 32) ? T_GROUND : T_SKY;
99
- for (i = 0; i < N_PLATFORMS; i++) {
100
- p = &platforms[i];
101
- cx = p->x >> 3;
102
- cy = p->y >> 3;
103
- cw = (p->w + 7) >> 3;
104
- ch = (p->h + 7) >> 3;
105
- for (j = 0; j < cw; j++) {
106
- if (cx + j < 32 && cy < 32)
107
- map[cy * 32 + cx + j] = T_PLATFORM; /* platform top edge */
108
- }
373
+ memcpy_vram((uint8_t *)(0x8000 + (uint16_t)slot * 16), src, 16);
374
+ }
375
+
376
+ static void upload_font(void) {
377
+ uint8_t g;
378
+ /* font.h glyphs are already 2bpp (16 bytes each) — straight copy. */
379
+ for (g = 0; g < FONT_GLYPHS; g++)
380
+ memcpy_vram((uint8_t *)(0x8000 + (uint16_t)(FONT_BASE + g) * 16),
381
+ &font_data[g * 16], 16);
382
+ }
383
+
384
+ static uint8_t char_tile(char ch) {
385
+ if (ch >= '0' && ch <= '9') return (uint8_t)(FONT_BASE + (ch - '0'));
386
+ if (ch >= 'A' && ch <= 'Z') return (uint8_t)(FONT_BASE + 10 + (ch - 'A'));
387
+ return T_BLANK; /* space / unknown blank */
388
+ }
389
+
390
+ /* Pre-convert a string to tile indices at full-frame time, so the vblank
391
+ * commit (commit_bg_text) is a dumb byte copy see game_over(). */
392
+ static uint8_t msg_q[20]; /* 9 "GAME OVER" + 11 "PRESS START" */
393
+ static void stage_text(const char *s, uint8_t *out) {
394
+ while (*s) *out++ = char_tile(*s++);
395
+ }
396
+
397
+ /* ── HARDWARE IDIOM (load-bearing reshape gameplay around this; see TROUBLESHOOTING) ──
398
+ * Per-tile color the VRAM bank-1 attribute map (VBK register).
399
+ * requires: CGB mode (see the palette idiom above); writes in a VRAM-safe
400
+ * window (LCD off, or a bounded vblank batch).
401
+ *
402
+ * VRAM is TWO 8KB banks behind the same $8000-$9FFF window; VBK ($FF4F)
403
+ * selects which one the CPU sees. Bank 0 holds what the DMG had: tile pixels
404
+ * + the tile-index maps. Bank 1 at the SAME map address holds one ATTRIBUTE
405
+ * byte per cell:
406
+ * bits 0-2 palette 0-7 ← this game's whole color system
407
+ * bit 3 tile VRAM bank
408
+ * bit 5/6 H/V flip
409
+ * bit 7 BG-over-OBJ priority
410
+ * So coloring a cell is a write PAIR: tile index with VBK=0, attribute with
411
+ * VBK=1, at the SAME offset.
412
+ *
413
+ * FOOTGUN: VBK is global state. Forget to restore VBK=0 and every later
414
+ * "tile" write lands in the attribute map — the screen turns into garbage
415
+ * colors while the tile data you wrote is simply gone. Always end VBK=0
416
+ * (every routine here does). */
417
+ static void set_cell(uint8_t mx, uint8_t my, uint8_t tile, uint8_t pal) {
418
+ uint16_t off = (uint16_t)my * 32 + mx;
419
+ VBK = 0;
420
+ VRAM[off] = tile;
421
+ VBK = 1;
422
+ VRAM[off] = pal;
423
+ VBK = 0;
424
+ }
425
+
426
+ /* same write-pair, into the WINDOW's map at $9C00 (window HUD idiom) */
427
+ static void set_wcell(uint8_t wx, uint8_t wy, uint8_t tile, uint8_t pal) {
428
+ uint16_t off = WIN_OFF + (uint16_t)wy * 32 + wx;
429
+ VBK = 0;
430
+ VRAM[off] = tile;
431
+ VBK = 1;
432
+ VRAM[off] = pal;
433
+ VBK = 0;
434
+ }
435
+
436
+ /* draw a NUL-terminated string into the BG map (palette PAL_HUD = readable). */
437
+ static void draw_text(uint8_t col, uint8_t row, const char *s) {
438
+ uint8_t i;
439
+ for (i = 0; s[i] != 0; i++)
440
+ set_cell((uint8_t)(col + i), row, char_tile(s[i]), PAL_HUD);
441
+ }
442
+
443
+ /* draw a NUL-terminated string into the WINDOW map. */
444
+ static void draw_wtext(uint8_t col, uint8_t row, const char *s) {
445
+ uint8_t i;
446
+ for (i = 0; s[i] != 0; i++)
447
+ set_wcell((uint8_t)(col + i), row, char_tile(s[i]), PAL_HUD);
448
+ }
449
+
450
+ /* Decimal digits WITHOUT divide/modulo (the sm83 has neither — SDCC's
451
+ * software % costs ~700 cycles a call). Repeated power-of-ten subtraction
452
+ * caps at 36 SUBs for any u16. Writes 5 tile slots into out5. */
453
+ static void u16_to_tiles(uint16_t v, uint8_t *out5) {
454
+ static const uint16_t pow10[4] = { 10000, 1000, 100, 10 };
455
+ uint8_t i, d;
456
+ for (i = 0; i < 4; i++) {
457
+ d = 0;
458
+ while (v >= pow10[i]) { v -= pow10[i]; ++d; }
459
+ *out5++ = (uint8_t)(FONT_BASE + d);
460
+ }
461
+ *out5 = (uint8_t)(FONT_BASE + (uint8_t)v);
462
+ }
463
+
464
+ /* ── GAME LOGIC (clay) — screen painters (LCD off = free VRAM access) ─────────
465
+ * Paint the level scene into BG rows 0..17 (the SCY=0 screen window; this
466
+ * game never scrolls vertically). For EACH cell we write the tile (bank 0)
467
+ * AND its palette attribute (bank 1) — that pairing is the whole CGB colour
468
+ * story. Clouds use a running divide-free pattern counter (the sm83 has no
469
+ * divide; treat every / and % in a loop as a red flag). */
470
+ static uint8_t tile_pal(uint8_t t) {
471
+ if (t == T_GRASS) return PAL_GRASS;
472
+ if (t == T_DIRT) return PAL_DIRT;
473
+ if (t == T_PLAT) return PAL_PLAT;
474
+ return PAL_SKY; /* sky + clouds */
475
+ }
476
+
477
+ static void paint_scene(uint8_t with_plats) {
478
+ uint8_t r, c, t, g;
479
+ uint8_t cl, clr = 0;
480
+ uint16_t off;
481
+ for (r = 0; r < 18; r++) {
482
+ cl = clr;
483
+ for (c = 0; c < 32; c++) {
484
+ g = ground_row[c];
485
+ t = T_SKY;
486
+ if (with_plats && r == plat_row[c]) t = T_PLAT;
487
+ else if (g != NO_GROUND) {
488
+ if (r == g) t = T_GRASS;
489
+ else if (r > g) t = T_DIRT;
490
+ } else if (r >= 16) {
491
+ t = T_DIRT; /* pit walls below the playfield */
492
+ }
493
+ if (t == T_SKY && r >= 2 && r <= 6 && cl == 0) t = T_CLOUD;
494
+ off = (uint16_t)r * 32 + c;
495
+ VBK = 0; VRAM[off] = t;
496
+ VBK = 1; VRAM[off] = tile_pal(t);
497
+ VBK = 0;
498
+ cl += 5; if (cl >= 13) cl -= 13;
109
499
  }
500
+ clr += 7; if (clr >= 13) clr -= 13;
501
+ }
110
502
  }
111
503
 
112
- void main(void) {
113
- int16_t px = 16 << 4, py = 60 << 4;
114
- int16_t vx = 0, vy = 0;
115
- int16_t ipx, ipy, npy, camX = 0;
116
- int32_t np;
117
- uint8_t grounded;
118
- uint8_t pad, prev = 0, i;
119
- const Rect *p;
120
- const int16_t GRAVITY = 10;
121
- const int16_t MOVE = 20;
122
- const int16_t JUMP = -140; /* was -180: ~100px peak (most of the screen) — 'jumps a little too high' */
123
- const int16_t MAXFALL = 280;
124
-
125
- lcd_init_default();
126
- sound_init();
127
- enable_vblank_irq(); /* MANDATORY: HALT-driven wait_vblank. Without this,
128
- * busy-poll wait_vblank runs ~1/30 speed on the WASM
129
- * emulator and the game loop appears to hang. */
130
- LCDC = 0;
131
-
132
- upload_tile(0, tile_blank);
133
- upload_tile(1, tile_player);
134
- upload_tile(2, tile_platform);
135
- upload_tile(T_SKY, tile_sky);
136
- upload_tile(T_GROUND, tile_ground);
137
-
138
- OCPS = 0x80;
139
- for (i = 0; i < 4; i++) {
140
- OCPD = (uint8_t)(obj_palette[i] & 0xFF);
141
- OCPD = (uint8_t)((obj_palette[i] >> 8) & 0xFF);
504
+ static void paint_title(void) {
505
+ paint_scene(0); /* plain scene text owns the sky */
506
+ draw_text((uint8_t)((20 - (sizeof(GAME_TITLE) - 1)) / 2), 3, GAME_TITLE);
507
+ draw_text(4, 6, "PRESS START");
508
+ draw_text(6, 8, "HI");
509
+ {
510
+ uint8_t d[5], i;
511
+ u16_to_tiles(hiscore, d);
512
+ for (i = 0; i < 5; i++) set_cell((uint8_t)(9 + i), 8, d[i], PAL_HUD);
513
+ }
514
+ draw_text(6, 11, "1P ONLY"); /* see header: no link 2P */
515
+ SCX = 0; SCY = 0;
516
+ scroll_x = 0;
517
+ }
518
+
519
+ /* HUD strip = window rows 0-1: a solid divider bar, then the text row.
520
+ * Columns 0-19 are the visible 20 (WX=7 pins map col 0 to screen x 0). */
521
+ static void paint_hud(void) {
522
+ uint8_t c, d[5], i;
523
+ for (c = 0; c < 20; c++) set_wcell(c, 0, T_HUDBAR, PAL_HUD);
524
+ for (c = 0; c < 20; c++) set_wcell(c, 1, T_BLANK, PAL_HUD);
525
+ draw_wtext(0, 1, "SC");
526
+ u16_to_tiles(score, d);
527
+ for (i = 0; i < 5; i++) set_wcell((uint8_t)(3 + i), 1, d[i], PAL_HUD);
528
+ draw_wtext(9, 1, "HI");
529
+ u16_to_tiles(hiscore, d);
530
+ for (i = 0; i < 5; i++) set_wcell((uint8_t)(12 + i), 1, d[i], PAL_HUD);
531
+ draw_wtext(18, 1, "L");
532
+ set_wcell(19, 1, (uint8_t)(FONT_BASE + lives), PAL_HUD);
533
+ }
534
+
535
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
536
+ * LCD-off repaints. Bulk VRAM rewrites (full title/level repaints) happen
537
+ * with the LCD OFF — free access, no per-byte timing worries. The rule:
538
+ * only flip LCDC bit 7 to 0 DURING VBLANK. Killing the LCD mid-scanline is
539
+ * the classic "damages real DMG hardware" move; emulators shrug, real units
540
+ * can be permanently marked. wait_vblank() first, always.
541
+ * Requires: enable_vblank_irq() already called (wait_vblank HALT path);
542
+ * lcd_off-safe runtime (gb_runtime's wait_vblank bails if LCD is off). */
543
+ static void repaint_with_lcd_off(uint8_t to_title) {
544
+ msg_stage = 0; /* a queued game-over line must not land on
545
+ * the freshly painted screen a frame later */
546
+ wait_vblank(); /* never cut the LCD outside vblank */
547
+ LCDC = 0;
548
+ if (to_title) {
549
+ paint_title();
550
+ oam_clear(); /* hide every sprite slot before re-enable */
551
+ LCDC = LCDC_TITLE; /* window OFF on the title */
552
+ } else {
553
+ paint_scene(1);
554
+ paint_hud();
555
+ LCDC = LCDC_PLAY; /* window ON below WY — the HUD appears */
556
+ }
557
+ }
558
+
559
+ /* ── GAME LOGIC (clay) — sound: frame-ticked tune + jump/coin/death SFX ───────
560
+ * Channel plan keeps SFX from cutting the music: ch2 = music (one
561
+ * sound_play_tone trigger per note, the APU sustains it), ch1 = jump and
562
+ * coin blips, ch4 = noise for deaths. music_tick() runs once per frame from
563
+ * the main loop; the APU needs no other upkeep. Periods are the 11-bit GB
564
+ * frequency code: 2048 - (131072 / Hz). 0 = rest. SELECT toggles it. */
565
+ static const uint16_t tune[16] = {
566
+ 1714, 0, 1750, 0, 1783, 0, 1798, 0, /* G4 A4 B4 C5 */
567
+ 1825, 0, 1798, 0, 1783, 0, 1750, 0, /* D5 C5 B4 A4 */
568
+ };
569
+ static uint8_t music_on = 1, music_pos, music_timer;
570
+ static void music_tick(void) {
571
+ uint16_t n;
572
+ if (!music_on) return;
573
+ if (++music_timer < 14) return;
574
+ music_timer = 0;
575
+ n = tune[music_pos];
576
+ music_pos = (uint8_t)((music_pos + 1) & 15);
577
+ if (n) sound_play_tone(2, n, 12);
578
+ }
579
+ static void music_toggle(void) {
580
+ music_on = (uint8_t)(!music_on);
581
+ if (!music_on) { NR21 = 0x00; NR22 = 0x00; NR24 = 0x80; } /* silence ch2 */
582
+ }
583
+
584
+ /* ── GAME LOGIC (clay) — coins + spikes (sprite objects in the world) ─────────
585
+ * Both live in SCREEN coords and drift left with the scroll delta (world-
586
+ * anchored while visible). Coins respawn at the right edge at a random
587
+ * height; spikes only spawn when the level column entering at the right edge
588
+ * has ground under it (never floating over a pit). */
589
+ static const uint8_t coin_heights[4] = { 80, 64, 48, 72 };
590
+ static void respawn_coin(uint8_t i) {
591
+ coin_x[i] = (uint8_t)(144 + (rand8() & 15)); /* enter at the right */
592
+ coin_y[i] = coin_heights[rand8() & 3];
593
+ }
594
+
595
+ static void try_spawn_spike(uint8_t i) {
596
+ uint8_t c = (uint8_t)((uint8_t)(scroll_x + 160) >> 3);
597
+ if (ground_row[c] == NO_GROUND) return;
598
+ if (rand8() > 4) return; /* ~2% per frame */
599
+ spike_x[i] = 152;
600
+ spike_active[i] = 1;
601
+ }
602
+
603
+ /* ── GAME LOGIC (clay) — landing probe against the column map ──────────────────
604
+ * One-way platforms, classic style: only catch the player while FALLING
605
+ * through a narrow window at the surface. The window is 6 px tall — top-1
606
+ * (the standing snap parks feet at top, and gravity's sub-pixel trickle
607
+ * doesn't move the integer Y every frame; without the -1 slack the player
608
+ * "stands" with on_ground=0 most frames, so jumps only register on lucky
609
+ * frames and the idle/jump sprite flickers) through top+4 (so a 5 px/frame
610
+ * terminal-velocity fall can't step over it). */
611
+ static uint8_t land_top(uint8_t c, uint8_t feet) {
612
+ uint8_t r, top;
613
+ r = plat_row[c];
614
+ if (r) {
615
+ top = (uint8_t)(r << 3);
616
+ if ((uint8_t)(feet + 1) >= top && feet <= (uint8_t)(top + 4)) return top;
617
+ }
618
+ r = ground_row[c];
619
+ if (r != NO_GROUND) {
620
+ top = (uint8_t)(r << 3);
621
+ if ((uint8_t)(feet + 1) >= top && feet <= (uint8_t)(top + 4)) return top;
622
+ }
623
+ return 0;
624
+ }
625
+
626
+ /* ── GAME LOGIC (clay) — state transitions ── */
627
+ static void begin_life(void) {
628
+ uint8_t i;
629
+ px = 24;
630
+ py_q44 = (uint16_t)(GROUND_TOP - 8) << 4;
631
+ vy_q44 = 0;
632
+ on_ground = 1;
633
+ scroll_x = 0;
634
+ dist_sub = 0;
635
+ coin_x[0] = 88; coin_y[0] = 80;
636
+ coin_x[1] = 120; coin_y[1] = 64;
637
+ coin_x[2] = 144; coin_y[2] = 48;
638
+ for (i = 0; i < NUM_SPIKES; i++) spike_active[i] = 0;
639
+ respawn_pause = 48; /* ready breather — player blinks */
640
+ prev_pad = 0xFF; /* swallow held buttons across the reset */
641
+ }
642
+
643
+ static void start_game(void) {
644
+ lives = START_LIVES;
645
+ score = 0;
646
+ hud_dirty = 1; /* restage hud digits — a stale game-over stage
647
+ * queued before the repaint would overwrite the
648
+ * fresh zeros next vblank otherwise */
649
+ begin_life();
650
+ state = ST_PLAY;
651
+ repaint_with_lcd_off(0);
652
+ sound_play_tone(1, 1798, 8); /* start jingle (C5) */
653
+ }
654
+
655
+ static void game_over(void) {
656
+ /* Compare against the SAVED record, not the live `hiscore` readout — the
657
+ * scoring path already raised `hiscore` to track the run, so testing
658
+ * `score > hiscore` here would never fire. */
659
+ if (score > record) {
660
+ record = score;
661
+ hiscore_save(record); /* battery write — survives power-off */
662
+ }
663
+ state = ST_OVER;
664
+ /* The BG has scrolled: map col 0 is no longer screen col 0. Anchor the
665
+ * text relative to the CURRENT scroll so it lands mid-screen. Pre-convert
666
+ * the strings to tile indices HERE (full-frame time) into msg_q — the
667
+ * vblank commit is then a DUMB byte copy. char_tile's per-char compare
668
+ * chain is exactly the work that blows the ~1140-cycle vblank budget; doing
669
+ * it inside the commit dropped the middle of the 11-char PRESS START line
670
+ * (verified). Stage out here, copy in there. */
671
+ msg_col = (uint8_t)(((scroll_x >> 3) + 5) & 31);
672
+ stage_text("GAME OVER", msg_q);
673
+ stage_text("PRESS START", msg_q + 9);
674
+ msg_stage = 2;
675
+ }
676
+
677
+ static void kill_player(void) {
678
+ sound_play_noise(20);
679
+ if (lives) --lives;
680
+ hud_dirty = 1;
681
+ if (lives == 0) { game_over(); return; }
682
+ begin_life(); /* back to the runway, scroll rewinds */
683
+ }
684
+
685
+ /* ── GAME LOGIC (clay) — per-state update (runs OUTSIDE vblank) ── */
686
+ static void update_play(uint8_t pad) {
687
+ uint8_t i, delta, y8, feet, c0, c1, top;
688
+
689
+ delta = 0;
690
+ if (pad & PAD_RIGHT) {
691
+ /* One-way camera: walk until the scroll wall, then the world moves. */
692
+ if (px < SCROLL_WALL) px += MOVE_SPEED;
693
+ else { scroll_x += MOVE_SPEED; delta = MOVE_SPEED; }
694
+ }
695
+ if ((pad & PAD_LEFT) && px > 8) px -= MOVE_SPEED;
696
+ if ((pad & PAD_A) && !(prev_pad & PAD_A) && on_ground) {
697
+ vy_q44 = JUMP_VEL_Q44;
698
+ on_ground = 0;
699
+ sound_play_tone(1, 1849, 6); /* jump whoop (E5) */
700
+ }
701
+
702
+ /* World objects drift left as the level scrolls (world-anchored). */
703
+ if (delta) {
704
+ dist_sub += delta;
705
+ if (dist_sub >= 64) { /* distance pay */
706
+ dist_sub -= 64;
707
+ if (score <= 65525u) ++score;
708
+ if (score > hiscore) hiscore = score; /* live HI readout; SRAM
709
+ * write waits for game over */
710
+ hud_dirty = 1;
142
711
  }
143
- BCPS = 0x80;
144
- for (i = 0; i < 4; i++) {
145
- BCPD = (uint8_t)(bg_palette[i] & 0xFF);
146
- BCPD = (uint8_t)((bg_palette[i] >> 8) & 0xFF);
712
+ for (i = 0; i < NUM_COINS; i++) {
713
+ if (coin_x[i] < 8 + delta) respawn_coin(i);
714
+ else coin_x[i] -= delta;
715
+ }
716
+ for (i = 0; i < NUM_SPIKES; i++) {
717
+ if (!spike_active[i]) continue;
718
+ if (spike_x[i] < 8 + delta) spike_active[i] = 0;
719
+ else spike_x[i] -= delta;
720
+ }
721
+ }
722
+ for (i = 0; i < NUM_SPIKES; i++)
723
+ if (!spike_active[i]) try_spawn_spike(i);
724
+
725
+ /* Physics: gravity + sub-pixel Y. */
726
+ if (vy_q44 < MAX_VY_Q44) vy_q44 += GRAVITY_Q44;
727
+ py_q44 += vy_q44;
728
+ y8 = (uint8_t)(py_q44 >> 4);
729
+
730
+ /* Fell into a pit — die at PLAY_H-8, the frame BEFORE the sprite would
731
+ * overlap the window HUD (footgun 2 above: OBJs draw over the window). */
732
+ if (y8 >= PLAY_H - 8) {
733
+ kill_player();
734
+ return;
735
+ }
736
+
737
+ /* Landing — probe the two level columns under the player's feet.
738
+ * uint8 px+scroll_x wraps at 256 exactly like the level does. */
739
+ if (vy_q44 >= 0) {
740
+ feet = (uint8_t)(y8 + 8);
741
+ c0 = (uint8_t)((uint8_t)(px + scroll_x) >> 3);
742
+ c1 = (uint8_t)((uint8_t)(px + scroll_x + 7) >> 3);
743
+ top = land_top(c0, feet);
744
+ if (top == 0) top = land_top(c1, feet);
745
+ if (top) {
746
+ py_q44 = (uint16_t)(top - 8) << 4;
747
+ vy_q44 = 0;
748
+ if (!on_ground) sound_play_tone(1, 1602, 3); /* landing thud */
749
+ on_ground = 1;
750
+ } else {
751
+ on_ground = 0; /* walked off an edge */
752
+ }
753
+ }
754
+
755
+ /* Coins (collect) + spikes (death). */
756
+ for (i = 0; i < NUM_COINS; i++) {
757
+ if (dist8(coin_x[i], px) < 8 && dist8(coin_y[i], y8) < 8) {
758
+ if (score <= 65525u) score += 10;
759
+ if (score > hiscore) hiscore = score;
760
+ sound_play_tone(1, 1923, 5); /* coin ping (C6) */
761
+ hud_dirty = 1;
762
+ respawn_coin(i);
763
+ }
764
+ }
765
+ for (i = 0; i < NUM_SPIKES; i++) {
766
+ if (!spike_active[i]) continue;
767
+ if (dist8(spike_x[i], px) < 7 && dist8(SPIKE_Y, y8) < 7) {
768
+ kill_player();
769
+ return;
147
770
  }
771
+ }
772
+ }
773
+
774
+ /* ── GAME LOGIC (clay) — stage the shadow OAM for THIS frame ──────────────────
775
+ * Pure WRAM writes (shadow_oam at $C100) — safe any time; only the DMA flush
776
+ * is vblank-sensitive. OAM coords are hardware coords: +16 on Y, +8 on X.
777
+ * A sprite's CGB palette = OAM attr bits 0-2 — that's the whole "color this
778
+ * sprite" story. Slot plan (40 hardware slots, we use 6): 0 = player,
779
+ * 1-3 coins, 4-5 spikes — well under the 10-OBJ/line hardware drop. */
780
+ static void stage_sprites(void) {
781
+ uint8_t i, y8;
782
+ oam_clear();
783
+ if (state == ST_TITLE) {
784
+ /* Guaranteed-visible sprite from the first title frame — proof the OAM
785
+ * pipeline (shadow → HRAM DMA stub → OAM) is alive before any gameplay
786
+ * complicates the picture. */
787
+ oam_set(0, 96 + 16, 76 + 8, T_PLAYER, OPAL_PLAYER);
788
+ return;
789
+ }
790
+ y8 = (uint8_t)(py_q44 >> 4);
791
+ if (respawn_pause == 0 || (respawn_pause & 4)) /* ready-blink */
792
+ oam_set(0, (uint8_t)(y8 + 16), (uint8_t)(px + 8),
793
+ on_ground ? T_PLAYER : T_JUMP, OPAL_PLAYER);
794
+ for (i = 0; i < NUM_COINS; i++)
795
+ oam_set((uint8_t)(1 + i), (uint8_t)(coin_y[i] + 16),
796
+ (uint8_t)(coin_x[i] + 8), T_COIN, OPAL_COIN);
797
+ for (i = 0; i < NUM_SPIKES; i++)
798
+ if (spike_active[i])
799
+ oam_set((uint8_t)(4 + i), SPIKE_Y + 16,
800
+ (uint8_t)(spike_x[i] + 8), T_SPIKE, OPAL_SPIKE);
801
+ }
802
+
803
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
804
+ * Queued VRAM commits — and the bank-0-only HUD write. Two-phase update,
805
+ * mirroring the shadow-OAM discipline: game logic only sets hud_dirty /
806
+ * msg_stage. stage_hud() (full-frame time) does the digit math into hud_q;
807
+ * commit_vram() (vblank time) writes bytes — AT MOST ONE queued item/vblank.
808
+ *
809
+ * THE CGB TWIST (load-bearing): a naive set_wcell() per HUD cell toggles VBK
810
+ * twice + writes two banks PER cell — for 11 HUD cells that's ~33 VBK writes
811
+ * in one vblank, which OVERRUNS the ~1140-cycle window and silently drops the
812
+ * tail writes (the lives digit at col 19 vanished — verified). The fix: the
813
+ * window HUD cells' bank-1 ATTRIBUTE bytes are constant PAL_HUD (painted once
814
+ * by paint_hud at LCD-off and never changed), so the per-frame commit only
815
+ * needs to rewrite bank-0 TILE bytes. We set VBK=0 ONCE and pointer-walk the
816
+ * digit cells — a tight write that fits vblank with room to spare. (Pointer
817
+ * walk, not map[i] indexing — the SDCC VRAM footgun.) */
818
+ static uint8_t hud_q[11]; /* 5 score digits, 5 hi digits, lives tile */
819
+ static uint8_t hud_ready;
820
+ #define WIN_TILE ((volatile uint8_t *)0x9C00) /* window map, bank 0 */
821
+
822
+ static void stage_hud(void) {
823
+ if (!hud_dirty) return;
824
+ hud_dirty = 0;
825
+ u16_to_tiles(score, hud_q);
826
+ u16_to_tiles(hiscore, hud_q + 5);
827
+ hud_q[10] = (uint8_t)(FONT_BASE + lives);
828
+ hud_ready = 1;
829
+ }
830
+
831
+ /* Write a scroll-anchored, pre-staged BG-map line (msg_q tiles) as a single
832
+ * BANK-0 tile copy — a dumb byte walk, no char_tile work and no per-cell VBK
833
+ * toggling. We DELIBERATELY leave the cells' bank-1 attribute alone: the scene
834
+ * painted them PAL_SKY, whose colour-3 (the font ink value) is white — so the
835
+ * text reads white-on-sky with ZERO attribute writes. That halves the vblank
836
+ * cost: an 11-char line as tile+attr pairs overran mode 3 and dropped its
837
+ * middle (verified); tile-only fits with room to spare. col wraps at the
838
+ * 32-col map seam (the text is scroll-anchored, so it can straddle the wrap). */
839
+ static void commit_bg_text(uint8_t row, uint8_t col, const uint8_t *q, uint8_t len) {
840
+ volatile uint8_t *base = VRAM + (uint16_t)row * 32;
841
+ volatile uint8_t *p = base + col;
842
+ uint8_t n = (uint8_t)(32 - col);
843
+ VBK = 0;
844
+ if (n > len) n = len; /* run 1: up to the map seam */
845
+ len -= n;
846
+ while (n--) *p++ = *q++;
847
+ p = base; /* run 2: wrapped remainder */
848
+ while (len--) *p++ = *q++;
849
+ }
850
+
851
+ static void commit_vram(void) {
852
+ uint8_t i;
853
+ if (hud_ready) { /* item 1: HUD digits (bank 0) */
854
+ hud_ready = 0;
855
+ VBK = 0; /* attributes already PAL_HUD */
856
+ for (i = 0; i < 5; i++) WIN_TILE[32 + 3 + i] = hud_q[i]; /* score */
857
+ for (i = 0; i < 5; i++) WIN_TILE[32 + 12 + i] = hud_q[5 + i]; /* hi */
858
+ WIN_TILE[32 + 19] = hud_q[10]; /* lives */
859
+ return;
860
+ }
861
+ if (msg_stage == 2) { /* item 2: GAME OVER line */
862
+ msg_stage = 1;
863
+ commit_bg_text(5, msg_col, msg_q, 9);
864
+ return;
865
+ }
866
+ if (msg_stage == 1) { /* item 3: PRESS START line */
867
+ msg_stage = 0;
868
+ commit_bg_text(7, (uint8_t)((msg_col + 31) & 31), msg_q + 9, 11);
869
+ }
870
+ }
871
+
872
+ void main(void) {
873
+ uint8_t pad;
874
+
875
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
876
+ * Boot order. Three load-bearing calls, in this order:
877
+ * 1. lcd_init_default() — sane LCD state AND it installs the OAM-DMA stub
878
+ * into HRAM ($FF80). During OAM DMA the CPU can only fetch from HRAM;
879
+ * the broken alternative (spinning in ROM) fetches $FF = rst $38 and
880
+ * corrupts the stack — the classic "sprites never show / game dies
881
+ * after a while" GB death. Every oam_dma_flush() depends on this stub.
882
+ * 2. enable_vblank_irq() — flips wait_vblank() from LY-polling to
883
+ * HALT-until-vblank-IRQ. The polling fallback runs at ~1/30 speed on
884
+ * the WASM emulator; the HALT path is full speed everywhere.
885
+ * 3. LCD off (inside vblank) for the bulk VRAM uploads — tiles, font,
886
+ * palettes, first screen — then back on. Tile/palette/map uploads
887
+ * REQUIRE a VRAM-safe window; boot does them all at once, so LCD-off
888
+ * is the only sane choice here. */
889
+ lcd_init_default();
890
+ enable_vblank_irq();
891
+ sound_init();
148
892
 
149
- paint_platforms();
150
- oam_clear();
151
- LCDC = LCDC_LCD_ON | LCDC_BG_ON | LCDC_OBJ_ON | LCDC_TILE_DATA_LO;
152
-
153
- while (1) {
154
- wait_vblank();
155
-
156
- ipx = px >> 4;
157
- ipy = py >> 4;
158
-
159
- /* Camera follows the player, centered, clamped to the world.
160
- * Write SCX so the BG scrolls; player is drawn in SCREEN space. */
161
- camX = ipx - (SCREEN_W / 2 - 4);
162
- if (camX < 0) camX = 0;
163
- if (camX > WORLD_W - SCREEN_W) camX = WORLD_W - SCREEN_W;
164
- SCX = (uint8_t)camX;
165
-
166
- for (i = 0; i < 40; i++) oam_set(i, 0, 0, 0, 0);
167
- oam_set(0, ipy + 16, (ipx - camX) + 8, 1, 0); /* screen x = world - cam */
168
- oam_dma_flush();
169
-
170
- pad = joypad_read();
171
- vx = 0;
172
- if (pad & PAD_LEFT) vx = -MOVE;
173
- if (pad & PAD_RIGHT) vx = MOVE;
174
-
175
- grounded = on_platform(ipx, ipy);
176
- if ((pad & PAD_A) && !(prev & PAD_A) && grounded) {
177
- vy = JUMP;
178
- sound_play_tone(1, 1750, 8); /* jump blip (ch2 square) */
179
- }
180
- prev = pad;
181
-
182
- vy += GRAVITY;
183
- if (vy > MAXFALL) vy = MAXFALL;
184
- if (grounded && vy > 0) vy = 0;
185
-
186
- px += vx;
187
- if (px < 0) px = 0;
188
- if (px > (WORLD_W - 8) << 4) px = (WORLD_W - 8) << 4;
189
-
190
- np = py + vy;
191
- npy = np >> 4;
192
- if (vy > 0) {
193
- for (i = 0; i < N_PLATFORMS; i++) {
194
- p = &platforms[i];
195
- if (ipy + 8 <= p->y && npy + 8 >= p->y
196
- && ipx + 8 > p->x && ipx < p->x + p->w) {
197
- py = (p->y - 8) << 4;
198
- vy = 0;
199
- goto done;
200
- }
201
- }
202
- }
203
- py = np;
204
- if (py > 144 << 4) { py = 0; vy = 0; }
205
- done: ;
893
+ wait_vblank();
894
+ LCDC = 0; /* LCD off — free VRAM access from here */
895
+
896
+ upload_tile(T_BLANK, tile_blank);
897
+ upload_tile(T_PLAYER, tile_player);
898
+ upload_tile(T_JUMP, tile_player_jump);
899
+ upload_tile(T_COIN, tile_coin);
900
+ upload_tile(T_SPIKE, tile_spike);
901
+ upload_tile(T_SKY, tile_sky);
902
+ upload_tile(T_CLOUD, tile_cloud);
903
+ upload_tile(T_DIRT, tile_dirt);
904
+ upload_tile(T_GRASS, tile_grass);
905
+ upload_tile(T_PLAT, tile_plat);
906
+ upload_tile(T_HUDBAR, tile_hudbar);
907
+ upload_font();
908
+
909
+ load_bg_palettes(); /* the CGB BG palettes — sky/grass/dirt/... */
910
+ load_obj_palettes(); /* player / coin / spike OBJ palettes */
911
+
912
+ /* Window position — set once; LCDC bit 5 decides if it shows. */
913
+ WX = 7; /* the +7 quirk: 7 = screen left edge */
914
+ WY = PLAY_H; /* HUD owns lines 128-143 */
915
+
916
+ record = hiscore_load(); /* battery SRAM 0 on first boot */
917
+ hiscore = record;
918
+ state = ST_TITLE;
919
+ paint_title();
920
+ oam_clear();
921
+ LCDC = LCDC_TITLE;
922
+
923
+ for (;;) {
924
+ /* ── full-frame work: input, game state, shadow-OAM staging ── */
925
+ pad = joypad_read();
926
+
927
+ /* SELECT toggles the background music, in any state. */
928
+ if ((pad & PAD_SELECT) && !(prev_pad & PAD_SELECT)) music_toggle();
929
+
930
+ if (state == ST_TITLE) {
931
+ if ((pad & (PAD_START | PAD_A)) && !(prev_pad & (PAD_START | PAD_A))) start_game();
932
+ prev_pad = pad;
933
+ } else if (state == ST_PLAY) {
934
+ if (respawn_pause) { /* ready-blink: freeze gameplay, stay honest */
935
+ --respawn_pause;
936
+ prev_pad = pad;
937
+ } else {
938
+ update_play(pad);
939
+ prev_pad = pad;
940
+ }
941
+ } else { /* ST_OVER freeze the field; START/A returns to title */
942
+ if ((pad & (PAD_START | PAD_A)) && !(prev_pad & (PAD_START | PAD_A))) {
943
+ state = ST_TITLE;
944
+ repaint_with_lcd_off(1);
945
+ }
946
+ prev_pad = pad;
206
947
  }
948
+ stage_sprites();
949
+ stage_hud(); /* digit math out here, not in vblank */
950
+
951
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
952
+ * The vblank slice. wait_vblank() wakes at the START of vblank
953
+ * (~1140 cycles of safe OAM/VRAM access). Order is everything:
954
+ * oam_dma_flush() FIRST — the DMA takes ~165 cycles and MUST finish
955
+ * inside vblank; pushing it later (after VRAM writes that grow over
956
+ * time) slides it into active display, where the PPU is reading OAM
957
+ * = one frame of torn/invisible sprites, intermittent and miserable
958
+ * to debug.
959
+ * commit_vram() second — the few queued HUD/map bytes (one item/frame).
960
+ * SCX last — scroll latches per-scanline, so writing it during vblank
961
+ * (before line 0 renders) moves the WHOLE next frame consistently;
962
+ * the window ignores it by design (the HUD idiom).
963
+ * Game logic above NEVER touches VRAM directly — it sets the dirty flags
964
+ * and shadow OAM, and this slice commits them. Keep that split. */
965
+ wait_vblank();
966
+ oam_dma_flush();
967
+ commit_vram();
968
+ SCX = scroll_x; /* title resets scroll_x to 0; over freezes it */
969
+ music_tick();
970
+ }
207
971
  }