romdevtools 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/AGENTS.md +53 -43
  2. package/CHANGELOG.md +91 -0
  3. package/README.md +3 -3
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1225 -332
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +906 -275
  8. package/examples/atari2600/templates/shmup.asm +1031 -239
  9. package/examples/atari2600/templates/sports.asm +1135 -253
  10. package/examples/atari7800/templates/platformer.c +991 -156
  11. package/examples/atari7800/templates/puzzle.c +1091 -148
  12. package/examples/atari7800/templates/racing.c +952 -124
  13. package/examples/atari7800/templates/shmup.c +812 -134
  14. package/examples/atari7800/templates/sports.c +820 -184
  15. package/examples/c64/templates/platformer.c +879 -164
  16. package/examples/c64/templates/puzzle.c +855 -178
  17. package/examples/c64/templates/racing.c +873 -97
  18. package/examples/c64/templates/shmup.c +757 -161
  19. package/examples/c64/templates/sports.c +755 -100
  20. package/examples/gb/templates/platformer.c +841 -179
  21. package/examples/gb/templates/puzzle.c +986 -246
  22. package/examples/gb/templates/racing.c +754 -174
  23. package/examples/gb/templates/shmup.c +673 -175
  24. package/examples/gb/templates/sports.c +790 -159
  25. package/examples/gba/templates/platformer.c +626 -165
  26. package/examples/gba/templates/puzzle.c +519 -269
  27. package/examples/gba/templates/racing.c +511 -206
  28. package/examples/gba/templates/shmup.c +564 -179
  29. package/examples/gba/templates/sports.c +454 -174
  30. package/examples/gbc/templates/platformer.c +944 -180
  31. package/examples/gbc/templates/puzzle.c +363 -109
  32. package/examples/gbc/templates/racing.c +884 -180
  33. package/examples/gbc/templates/shmup.c +821 -185
  34. package/examples/gbc/templates/sports.c +870 -162
  35. package/examples/genesis/templates/platformer.c +747 -129
  36. package/examples/genesis/templates/puzzle.c +694 -261
  37. package/examples/genesis/templates/racing.c +726 -203
  38. package/examples/genesis/templates/shmup.c +535 -142
  39. package/examples/genesis/templates/sports.c +495 -158
  40. package/examples/gg/templates/platformer.c +880 -215
  41. package/examples/gg/templates/puzzle.c +875 -216
  42. package/examples/gg/templates/racing.c +915 -172
  43. package/examples/gg/templates/shmup.c +714 -191
  44. package/examples/gg/templates/sports.c +732 -129
  45. package/examples/lynx/templates/platformer.c +604 -69
  46. package/examples/lynx/templates/puzzle.c +498 -158
  47. package/examples/lynx/templates/racing.c +538 -102
  48. package/examples/lynx/templates/shmup.c +458 -131
  49. package/examples/lynx/templates/sports.c +496 -72
  50. package/examples/msx/platformer/main.c +649 -162
  51. package/examples/msx/puzzle/main.c +742 -240
  52. package/examples/msx/racing/main.c +669 -178
  53. package/examples/msx/shmup/main.c +460 -178
  54. package/examples/msx/sports/main.c +592 -126
  55. package/examples/nes/templates/platformer.c +589 -171
  56. package/examples/nes/templates/puzzle.c +563 -242
  57. package/examples/nes/templates/racing.c +502 -208
  58. package/examples/nes/templates/shmup.c +339 -145
  59. package/examples/nes/templates/sports.c +341 -183
  60. package/examples/pce/platformer/main.c +874 -205
  61. package/examples/pce/puzzle/main.c +802 -287
  62. package/examples/pce/racing/main.c +783 -208
  63. package/examples/pce/shmup/main.c +638 -212
  64. package/examples/pce/sports/main.c +586 -169
  65. package/examples/porting-across-platforms/README.md +1 -1
  66. package/examples/sms/templates/platformer.c +762 -177
  67. package/examples/sms/templates/puzzle.c +752 -212
  68. package/examples/sms/templates/racing.c +808 -145
  69. package/examples/sms/templates/shmup.c +599 -162
  70. package/examples/sms/templates/sports.c +630 -122
  71. package/examples/snes/templates/music_demo.c +7 -0
  72. package/examples/snes/templates/platformer-data.asm +123 -24
  73. package/examples/snes/templates/platformer-hdr.asm +57 -0
  74. package/examples/snes/templates/platformer.c +586 -165
  75. package/examples/snes/templates/puzzle-data.asm +116 -21
  76. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  77. package/examples/snes/templates/puzzle.c +614 -235
  78. package/examples/snes/templates/racing-data.asm +390 -32
  79. package/examples/snes/templates/racing-hdr.asm +57 -0
  80. package/examples/snes/templates/racing.c +807 -196
  81. package/examples/snes/templates/shmup-data.asm +87 -29
  82. package/examples/snes/templates/shmup-hdr.asm +57 -0
  83. package/examples/snes/templates/shmup.c +459 -198
  84. package/examples/snes/templates/sports-data.asm +48 -2
  85. package/examples/snes/templates/sports-hdr.asm +57 -0
  86. package/examples/snes/templates/sports.c +414 -163
  87. package/package.json +12 -12
  88. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  89. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  90. package/src/cores/wasm/fceumm_libretro.js +1 -1
  91. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  92. package/src/cores/wasm/gambatte_libretro.js +1 -1
  93. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  94. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  95. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  96. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  97. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  98. package/src/cores/wasm/handy_libretro.js +1 -1
  99. package/src/cores/wasm/handy_libretro.wasm +0 -0
  100. package/src/cores/wasm/mgba_libretro.js +1 -1
  101. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  102. package/src/cores/wasm/prosystem_libretro.js +1 -1
  103. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  104. package/src/cores/wasm/snes9x_libretro.js +1 -1
  105. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  106. package/src/cores/wasm/stella2014_libretro.js +1 -1
  107. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  108. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  109. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  110. package/src/host/LibretroHost.js +84 -8
  111. package/src/http/tool-registry.js +11 -11
  112. package/src/mcp/tools/cheats.js +2 -1
  113. package/src/mcp/tools/frame.js +3 -2
  114. package/src/mcp/tools/index.js +3 -3
  115. package/src/mcp/tools/input.js +5 -4
  116. package/src/mcp/tools/lifecycle.js +6 -4
  117. package/src/mcp/tools/memory.js +131 -24
  118. package/src/mcp/tools/platform-docs.js +1 -1
  119. package/src/mcp/tools/preview-tile.js +6 -2
  120. package/src/mcp/tools/project.js +1098 -130
  121. package/src/mcp/tools/record.js +6 -7
  122. package/src/mcp/tools/rom-id.js +5 -1
  123. package/src/mcp/tools/run-until.js +12 -4
  124. package/src/mcp/tools/snippets.js +6 -6
  125. package/src/mcp/tools/sprite-pipeline.js +14 -2
  126. package/src/mcp/tools/state.js +2 -1
  127. package/src/mcp/tools/tile-inspect.js +8 -1
  128. package/src/mcp/tools/toolchain.js +12 -1
  129. package/src/mcp/tools/watch-memory.js +53 -10
  130. package/src/observer/bus.js +73 -0
  131. package/src/observer/livestream.html +4 -2
  132. package/src/observer/tool-wrap.js +17 -14
  133. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +32 -3
  134. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  135. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  136. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  137. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  138. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  139. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  140. package/src/platforms/gb/lib/c/README.md +10 -11
  141. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  142. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  143. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  144. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  145. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  146. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  147. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  148. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  149. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  150. package/src/platforms/gbc/lib/c/README.md +10 -11
  151. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  152. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  153. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  154. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  155. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  156. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  157. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  158. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  159. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  160. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  161. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  162. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  163. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  164. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  165. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  166. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  167. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  168. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  169. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  170. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  171. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  172. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  173. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  174. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  175. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  176. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  177. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  178. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  179. package/src/toolchains/index.js +27 -11
@@ -1,212 +1,927 @@
1
- // ── platformer.c — Commodore 64 SIDE-SCROLLING platformer ────────────
2
- //
3
- // A horizontally scrolling platformer. C64 scrolling is the fiddliest of
4
- // the platforms: the VIC-II only does a 0-7 px *fine* scroll in hardware
5
- // ($D016 low 3 bits), so a wider world needs a software *coarse* shift
6
- // re-render screen RAM ($0400) AND color RAM ($D800) from a world map
7
- // each time the fine offset wraps past a char boundary.
8
- //
9
- // World is 80 cols (640 px); the visible window is 40 cols (320 px). A
10
- // camera follows the player; fine = camX&7 → $D016, coarse = camX>>3
11
- // indexes which world column sits at screen column 0. We render 40 cols
12
- // each coarse step and switch to 38-column mode to mask the edge garbage
13
- // column the fine scroll exposes.
14
- //
15
- // One VIC-II hardware sprite for the player, drawn in SCREEN space.
16
- // Joystick port 2; B1 jumps. See the C64 MENTAL_MODEL.md.
1
+ /* ── platformer.c — C64 side-scrolling platformer (complete example game) ─────
2
+ *
3
+ * TALUS TROT 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 CONTROL PORT 1),
6
+ * gravity/jump physics, one-way platforms, pits + spikes, coins + distance
7
+ * scoring, in-session hi-score behind the gated-persistence seam, 2-voice SID
8
+ * music with the C64's signature filter sweep + SFX, and the C64's signature
9
+ * raster-IRQ split: a fixed score bar over a HARDWARE-scrolled level.
10
+ *
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 C64 footgun; reshape
14
+ * your gameplay around it (see TROUBLESHOOTING before changing).
15
+ * GAME LOGIC (clay) level layout, physics tuning, scoring, art: reshape
16
+ * freely.
17
+ *
18
+ * What depends on what:
19
+ * c64_registers.h — VIC-II / SID / CIA symbolic addresses (header only).
20
+ * c64_sfx.{h,c} — one-shot SID sound effects on voice 2.
21
+ * The BASIC stub + crt0 come from cc65's c64 target: the .prg loads at
22
+ * $0801, a tiny BASIC line does SYS into the C runtime, and the KERNAL
23
+ * stays banked in (we lean on that for the IRQ vector — see below).
24
+ *
25
+ * Memory map this file assumes (VIC bank 0 = $0000-$3FFF):
26
+ * $0400 screen RAM (40×25 chars) $D800 color RAM (per-cell color)
27
+ * $0801 this program (code+data grow up from here)
28
+ * $3F00 sprite images (2 × 64 bytes) — NOT $0800, which collides with
29
+ * the .prg load address, and NOT $1000-$1FFF, where the VIC sees
30
+ * the character ROM instead of RAM (a classic invisible-sprite trap).
31
+ * Keep the program under ~14 KB so it stays below $3F00.
32
+ *
33
+ * THE SCROLL — C64 horizontal scrolling is the fiddliest of all 14 platforms,
34
+ * and this game does it for real. The VIC-II fine-scrolls only 0-7 px in
35
+ * hardware ($D016 low 3 bits); past that you COARSE-scroll in software by
36
+ * shifting the visible char columns and rendering one fresh column at the
37
+ * edge from a world map. Both halves run here — see scroll_field and the
38
+ * raster split. (C64 MENTAL_MODEL.md → "Horizontal scrolling".)
39
+ */
17
40
 
18
41
  #include "c64_registers.h"
19
42
  #include "c64_sfx.h"
20
43
  #include <stdint.h>
21
44
 
45
+ /* cc65 KERNAL disk-I/O prototypes. We DON'T #include <cbm.h> — it drags in
46
+ * <c64.h>, whose VIC/SID/JOY macros collide with this project's
47
+ * c64_registers.h (cc65 errors "macro redefinition is not identical"). These
48
+ * four are the stable cc65 ABI; declaring them directly avoids the clash. */
49
+ unsigned char __fastcall__ cbm_open(unsigned char lfn, unsigned char device,
50
+ unsigned char sec_addr, const char *name);
51
+ void __fastcall__ cbm_close(unsigned char lfn);
52
+ int __fastcall__ cbm_read(unsigned char lfn, void *buffer, unsigned int size);
53
+ int __fastcall__ cbm_write(unsigned char lfn, const void *buffer, unsigned int size);
54
+
55
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
56
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
57
+ #define GAME_TITLE "TALUS TROT"
58
+
22
59
  #define POKE(addr, val) (*(volatile uint8_t*)(addr) = (val))
23
60
  #define PEEK(addr) (*(volatile uint8_t*)(addr))
24
61
 
25
62
  #define SCREEN ((volatile uint8_t*)0x0400)
26
63
  #define COLORS ((volatile uint8_t*)0xD800)
64
+ #define SPRITE_POINTERS ((volatile uint8_t*)0x07F8) /* last 8 bytes of screen RAM */
65
+
66
+ /* ── Screen layout (the raster split divides bar from scrolling level) ──────
67
+ * char row 0 — score bar text: SC / HI / LV / P# / mode (FIXED)
68
+ * char row 1 — solid divider line (FIXED)
69
+ * char row 2 — blank spacer: the split lands mid-row HERE, where a few
70
+ * raster lines of IRQ jitter are invisible (uniform color)
71
+ * char rows 3-24 — the scrolling level (ground, platforms, pits, sky)
72
+ * PAL raster geometry: with YSCROLL=3 (the power-on default) text row r
73
+ * occupies raster lines 51+8r .. 58+8r. So the spacer row 2 = lines 67-74,
74
+ * and the playfield's first row 3 starts at line 75. */
75
+ #define FIELD_TOP 3
76
+ #define SPLIT_LINE 68 /* inside spacer row 2 (67-74): jitter-proof */
77
+ #define BOTTOM_LINE 251 /* first line below the 25-row text window (ends 250) */
78
+ /* $D016 values for the two halves of the frame. Bit 3 CLEAR = 38-column mode
79
+ * (masks the garbage column fine-X scrolling exposes at the edges — keep all
80
+ * bar text inside columns 1-38). Low 3 bits = fine X scroll 0-7. */
81
+ #define D016_BAR 0xC0 /* fine X = 0, 38 cols — the fixed bar */
82
+
83
+ /* ── GAME LOGIC (clay — reshape freely) — sprite art (24×21, 3 bytes/row) ──
84
+ * Two VIC-II hardware sprites are used: the active player and one coin. The
85
+ * world's ground/platforms/spikes are CHARACTERS in screen RAM (the scroll
86
+ * shifts them), so they cost no sprite slots. */
87
+ #define SLOT_PLAYER 0
88
+ #define SLOT_COIN 1
89
+ #define SPR_DATA(img) (0x3F00 + (img) * 64)
90
+ #define SPR_PTR(img) (uint8_t)(SPR_DATA(img) / 64) /* $3F00/64 = $FC */
91
+ #define IMG_PLAYER 0
92
+ #define IMG_COIN 1
93
+
94
+ static const uint8_t player_sprite[64] = { /* a little hopping critter */
95
+ 0x00,0x00,0x00, 0x07,0xE0,0x00, 0x0F,0xF0,0x00, 0x1C,0x38,0x00,
96
+ 0x1B,0xD8,0x00, 0x1F,0xF8,0x00, 0x1F,0xF8,0x00, 0x0F,0xF0,0x00,
97
+ 0x07,0xE0,0x00, 0x07,0xE0,0x00, 0x0F,0xF0,0x00, 0x1E,0x78,0x00,
98
+ 0x3C,0x3C,0x00, 0x38,0x1C,0x00, 0x30,0x0C,0x00, 0x70,0x0E,0x00,
99
+ 0x60,0x06,0x00, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,
100
+ };
101
+ static const uint8_t coin_sprite[64] = { /* a small spinning disc */
102
+ 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0,
103
+ 0x03,0xC0,0x00, 0x0F,0xF0,0x00, 0x1E,0x78,0x00, 0x1C,0x38,0x00,
104
+ 0x1C,0x38,0x00, 0x1E,0x78,0x00, 0x0F,0xF0,0x00, 0x03,0xC0,0x00,
105
+ 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,
106
+ };
107
+
108
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
109
+ * THE RASTER-IRQ SPLIT — the C64's classic "fixed status bar over a moving
110
+ * world" trick (and the gateway drug to all raster effects). The VIC-II has
111
+ * ONE $D016 fine-scroll for the whole frame; to scroll the level while the
112
+ * score bar stays put, you change $D016 MID-FRAME, at an exact raster line,
113
+ * from an interrupt. Two IRQs ping-pong per frame:
114
+ *
115
+ * line 68 (inside the blank spacer row): $D016 = level fine-scroll
116
+ * → everything drawn below this line fine-scrolls
117
+ * line 251 (just past the text window): $D016 = 0 scroll
118
+ * → next frame's bar rows render fixed; this IRQ is also the
119
+ * game's frame heartbeat (increments frame_count)
120
+ *
121
+ * The handshake, register by register:
122
+ * $D012 raster compare line (low 8 bits)
123
+ * $D011 b7 raster compare bit 8 — MUST be 0 here (both lines < 256).
124
+ * Forgetting this bit is the classic "my IRQ fires on the
125
+ * wrong line / twice" bug when lines ≥ 256 get involved.
126
+ * $D01A b0 raster IRQ enable
127
+ * $D019 IRQ latch. ACK by WRITING THE BITS BACK (write-1-to-clear).
128
+ * THE LOAD-BEARING LINE: skip the ack and the IRQ re-fires the
129
+ * instant it returns, forever — the main loop starves and the
130
+ * machine looks hung.
131
+ * $0314/15 the KERNAL's IRQ indirection. The hardware vector ($FFFE)
132
+ * points into KERNAL ROM, which saves A/X/Y and jumps through
133
+ * $0314 — so with the KERNAL banked in (cc65 default) we just
134
+ * repoint $0314. Exit via jmp $EA81 (KERNAL: restore regs +
135
+ * rti), SKIPPING $EA31's jiffy-clock/keyboard scan.
136
+ * $DC0D CIA1 interrupt control. The KERNAL leaves a 60Hz CIA timer
137
+ * IRQ running (the jiffy clock); disable it ($7F = clear all
138
+ * sources) and ack it (read $DC0D) or it shares the IRQ line
139
+ * with the raster and fires our handler at random lines.
140
+ *
141
+ * JITTER: an IRQ only starts after the current instruction finishes, so the
142
+ * handler begins 0-7 cycles late, plus the KERNAL thunk (~35 cycles) — the
143
+ * $D016 write lands one-to-two raster lines after SPLIT_LINE. We hide that
144
+ * by splitting inside a UNIFORM blank row, where shifting the (invisible)
145
+ * pixels mid-line changes nothing. Splits next to visible detail need
146
+ * cycle-exact stabilization (double-IRQ trick) — don't go there until you do.
147
+ *
148
+ * The handler is ASSEMBLY-IN-C on purpose: cc65's generated C uses shared
149
+ * zero-page scratch registers, so a C-level IRQ body would corrupt whatever
150
+ * the main loop was computing. These asm lines touch only A + the flags
151
+ * (which the KERNAL thunk already saved). requires: KERNAL banked in,
152
+ * frame_count/field_d016 file-scope NON-static (asm %v needs the symbol). */
153
+ volatile uint8_t frame_count; /* bumped by the bottom IRQ — frame heartbeat */
154
+ volatile uint8_t field_d016; /* level $D016 value, precomputed by main */
27
155
 
156
+ void raster_irq(void) {
157
+ asm("lda $d019"); /* read VIC IRQ latch... */
158
+ asm("sta $d019"); /* ...write it back = ACK (write-1-to-clear).
159
+ * THE line you must not lose (see above). */
160
+ asm("lda $d012"); /* which raster line woke us? (self-correcting
161
+ * dispatch — no phase variable to desync) */
162
+ asm("cmp #150");
163
+ asm("bcs %g", at_bottom); /* ≥150 → we're at BOTTOM_LINE */
164
+ /* — split point (line ~68, inside the blank spacer row) — */
165
+ asm("lda %v", field_d016);
166
+ asm("sta $d016"); /* level fine-X from here down */
167
+ asm("lda #251"); /* = BOTTOM_LINE (cc65's asm %b only takes */
168
+ asm("sta $d012"); /* signed bytes, so these are literals — the */
169
+ asm("jmp $ea81"); /* #if below keeps them honest) */
170
+ at_bottom:
171
+ asm("lda #$C0"); /* = D016_BAR */
172
+ asm("sta $d016"); /* bar scroll for the top of the NEXT frame */
173
+ asm("inc %v", frame_count);/* frame heartbeat for the main loop */
174
+ asm("lda #%b", SPLIT_LINE);
175
+ asm("sta $d012"); /* next stop: the split line */
176
+ asm("jmp $ea81"); /* KERNAL: pla/tay/pla/tax/pla/rti */
177
+ }
178
+ #if BOTTOM_LINE != 251 || D016_BAR != 0xC0
179
+ #error raster_irq's asm immediates are out of sync with BOTTOM_LINE / D016_BAR
180
+ #endif
181
+
182
+ static void install_raster_irq(void) {
183
+ asm("sei"); /* no IRQs while we rewire them */
184
+ POKE(CIA1_PRA + 0x0D, 0x7F); /* $DC0D: disable ALL CIA1 IRQ sources
185
+ * (kills the KERNAL jiffy/keyboard IRQ
186
+ * — we read the sticks ourselves) */
187
+ (void)PEEK(CIA1_PRA + 0x0D); /* reading $DC0D acks anything pending */
188
+ POKE(0x0314, (uint8_t)((unsigned)raster_irq & 0xFF));
189
+ POKE(0x0315, (uint8_t)((unsigned)raster_irq >> 8));
190
+ POKE(VIC_CTRL1, 0x1B); /* $D011 = power-on default: screen on,
191
+ * 25 rows, YSCROLL=3, and bit 7 (raster
192
+ * compare bit 8) = 0 — both lines < 256 */
193
+ POKE(VIC_RASTER, SPLIT_LINE); /* first stop */
194
+ POKE(VIC_IRQ_ENA, 0x01); /* raster IRQ on */
195
+ POKE(VIC_IRQ, 0xFF); /* clear any stale latch bits */
196
+ asm("cli");
197
+ }
198
+
199
+ /* Wait for the bottom IRQ's heartbeat. Replaces the usual poll-$D012 loop —
200
+ * the IRQ owns the raster now, the main loop just paces itself on it. */
201
+ static void wait_frame(void) {
202
+ uint8_t f = frame_count;
203
+ while (frame_count == f) { }
204
+ }
205
+
206
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ── reading BOTH
207
+ * joystick ports. CIA1 port A ($DC00) = control port 2, port B ($DC01) =
208
+ * control port 1. Active-low: a pressed switch reads 0, so invert and mask
209
+ * to bits 0-4 (up/down/left/right/fire).
210
+ *
211
+ * THE PORT-1 GOTCHA: $DC01 is ALSO the keyboard row register — the matrix
212
+ * hangs off the same CIA lines. Writing $FF to $DC00 first deselects every
213
+ * keyboard column, so held keys can't pull $DC01 rows low and ghost into
214
+ * the port-1 stick. That's also why "port 2 is the C64 game port": P1 lives
215
+ * there by convention, and this game puts the SECOND player on port 1.
216
+ * requires: install_raster_irq already disabled the KERNAL's keyboard scan,
217
+ * so nothing else rewrites $DC00. */
218
+ static uint8_t read_stick_port2(void) { /* player 1 */
219
+ POKE(CIA1_PRA, 0xFF);
220
+ return (uint8_t)(~PEEK(CIA1_PRA) & 0x1F);
221
+ }
222
+ static uint8_t read_stick_port1(void) { /* player 2 */
223
+ POKE(CIA1_PRA, 0xFF);
224
+ return (uint8_t)(~PEEK(CIA1_PRB) & 0x1F);
225
+ }
226
+ #define JOY_UP 0x01
227
+ #define JOY_DOWN 0x02
28
228
  #define JOY_LEFT 0x04
29
229
  #define JOY_RIGHT 0x08
30
230
  #define JOY_FIRE 0x10
31
231
 
32
- #define SPRITE_POINTERS ((volatile uint8_t*)0x07F8)
33
-
34
- #define WORLD_COLS 80 /* 80 cells = 640 px world */
35
- #define VIS_COLS 40 /* 40 visible char columns (320 px) */
36
- #define VIS_ROWS 25
37
- #define WORLD_W (WORLD_COLS * 8)
38
- #define SCREEN_VIS_W (VIS_COLS * 8)
39
-
40
- /* C64 sprite is 24x21. We draw a 16x16 "filled square" centered. */
41
- static const uint8_t player_sprite[64] = {
42
- 0x00,0x00,0x00, 0x00,0xFF,0x00, 0x00,0xFF,0x00, 0x00,0xFF,0x00,
43
- 0x00,0xFF,0x00, 0x00,0xFF,0x00, 0x00,0xFF,0x00, 0x00,0xFF,0x00,
44
- 0x00,0xFF,0x00, 0x00,0xFF,0x00, 0x00,0xFF,0x00, 0x00,0xFF,0x00,
45
- 0x00,0xFF,0x00, 0x00,0xFF,0x00, 0x00,0xFF,0x00, 0x00,0xFF,0x00,
46
- 0x00,0xFF,0x00, 0x00,0xFF,0x00, 0,0,0, 0,0,0, 0,0,0, 0,
232
+ /* ── HARDWARE IDIOM (load-bearing) — hi-score persistence: DISK SAVE ─────────
233
+ * The C64 has no battery SRAM — the honest save medium is the FLOPPY. A game
234
+ * persists by writing a file to drive 8; VICE commits it into the live 1541
235
+ * disk image (true-drive GCR write-back), so a save survives a power cycle
236
+ * exactly as it did on real hardware. (To capture it headlessly the host does
237
+ * state({op:'exportDisk', path}); re-loading that .d64 restores the save.)
238
+ *
239
+ * REQUIRES THE GAME RUN FROM A DISK: build/package it as a .d64 and load THAT
240
+ * (loadMedia autostarts it). A bare .prg injected straight into RAM has no
241
+ * mounted disk to save to, so the save is a silent no-op — still honest (the
242
+ * value just stays in-session), it simply has nowhere to persist.
243
+ *
244
+ * We keep a 2-byte record in a SEQ file "HI" on drive 8. These are the STABLE
245
+ * SEAM: the game calls hiscore_load at boot and hiscore_save on a new record;
246
+ * reshape the record format freely, just keep the two function signatures. */
247
+ #define SAVE_NAME "@0:HI,S,W" /* @ = replace-if-exists; S=SEQ, W=write */
248
+ #define LOAD_NAME "0:HI,S,R"
249
+
250
+ static uint16_t hiscore_load(void) {
251
+ uint16_t v = 0;
252
+ uint8_t buf[2];
253
+ if (cbm_open(2, 8, 2, LOAD_NAME) == 0) {
254
+ if (cbm_read(2, buf, 2) == 2) v = (uint16_t)buf[0] | ((uint16_t)buf[1] << 8);
255
+ cbm_close(2);
256
+ }
257
+ return v; /* 0 if the file isn't there yet (first ever boot) */
258
+ }
259
+
260
+ static void hiscore_save(uint16_t v) {
261
+ uint8_t buf[2];
262
+ buf[0] = (uint8_t)(v & 0xFF);
263
+ buf[1] = (uint8_t)(v >> 8);
264
+ if (cbm_open(2, 8, 2, SAVE_NAME) == 0) {
265
+ cbm_write(2, buf, 2);
266
+ cbm_close(2);
267
+ }
268
+ /* No disk mounted (ran as a bare .prg) -> cbm_open fails -> silent no-op. */
269
+ }
270
+
271
+ /* ── GAME LOGIC (clay) — SID music: 2 voices + THE filter sweep ─────────────
272
+ * Voice 0 = melody (pulse), voice 1 = bass (sawtooth THROUGH THE FILTER),
273
+ * voice 2 is reserved for sound effects (c64_sfx). Each voice walks a
274
+ * (freq, frames) note table once per frame; end wraps → continuous loop.
275
+ *
276
+ * THE SID FILTER — the C64's sonic signature, and the part most "music
277
+ * drivers ported from other chips" miss. One analog-modeled filter, shared
278
+ * by all voices, four registers:
279
+ * $D415 cutoff low 3 bits $D416 cutoff high 8 bits (11-bit total)
280
+ * $D417 high nibble = resonance 0-15; low 3 bits ROUTE voices into the
281
+ * filter (bit0=voice0, bit1=voice1, bit2=voice2)
282
+ * $D418 bit4=lowpass bit5=bandpass bit6=highpass — AND master volume in
283
+ * bits 0-3. Volume and filter mode share a register: any "set
284
+ * volume" helper that writes plain $0F silently turns the filter
285
+ * OFF (c64_sfx's sfx_init does exactly that, so music_init runs
286
+ * AFTER it and re-asserts the mode bits).
287
+ * FOOTGUN: bit 7 of $D418 is "3OFF" — it MUTES voice 3 entirely.
288
+ * Set it by accident and all your sound effects vanish.
289
+ * The sweep: a triangle LFO walks the cutoff up and down each frame over
290
+ * the resonant lowpass — the bass goes from muffled to snarling and back,
291
+ * the "wah" that screams Commodore. Hear it change: that IS the chip. */
292
+ #define N_A2 0x0F3Cu
293
+ #define N_C3 0x1199u
294
+ #define N_D3 0x13EEu
295
+ #define N_E3 0x1666u
296
+ #define N_F3 0x1798u
297
+ #define N_G3 0x1AE6u
298
+ #define N_A3 0x1E78u
299
+ #define N_B3 0x2253u
300
+ #define N_C4 0x2333u
301
+ #define N_D4 0x27DDu
302
+ #define N_E4 0x2CCCu
303
+ #define N_F4 0x2F30u
304
+ #define N_G4 0x35CCu
305
+ #define N_A4 0x3CF1u
306
+ #define N_B4 0x44A7u
307
+ #define N_C5 0x4666u
308
+ #define N_D5 0x4FBAu
309
+ #define N_E5 0x5998u
310
+ #define N_G5 0x6B99u
311
+ #define N_REST 0u
312
+ #define STEP 9 /* frames per melodic eighth-note (~140 BPM PAL) */
313
+
314
+ typedef struct { uint16_t freq; uint8_t len; } Note;
315
+
316
+ /* The table IS the song — edit these to rescore your fork. A bouncy major run. */
317
+ static const Note melody[] = {
318
+ { N_C4, STEP }, { N_E4, STEP }, { N_G4, STEP*2 }, { N_E4, STEP }, { N_C5, STEP*2 }, { N_G4, STEP },
319
+ { N_F4, STEP }, { N_A4, STEP }, { N_C5, STEP*2 }, { N_A4, STEP }, { N_F4, STEP*2 }, { N_REST, STEP },
320
+ { N_G4, STEP }, { N_B4, STEP }, { N_D5, STEP*2 }, { N_B4, STEP }, { N_G4, STEP*2 }, { N_D5, STEP },
321
+ { N_E5, STEP }, { N_C5, STEP }, { N_G4, STEP }, { N_E4, STEP }, { N_C4, STEP*2 }, { N_REST, STEP },
322
+ { N_C5, STEP }, { N_B4, STEP }, { N_A4, STEP }, { N_G4, STEP }, { N_A4, STEP }, { N_B4, STEP }, { N_C5, STEP*2 },
323
+ { N_F4, STEP }, { N_G4, STEP }, { N_A4, STEP }, { N_F4, STEP }, { N_C5, STEP*2 }, { N_A4, STEP*2 },
47
324
  };
325
+ static const Note bassline[] = {
326
+ /* Octave-pumping bass — the filter sweep chews on this. */
327
+ { N_C3, STEP*3 }, { N_C4, STEP }, { N_C3, STEP*2 }, { N_G3, STEP*2 },
328
+ { N_F3, STEP*3 }, { N_C3, STEP }, { N_F3, STEP*2 }, { N_A3, STEP*2 },
329
+ { N_G3, STEP*3 }, { N_D3, STEP }, { N_G3, STEP*2 }, { N_B3, STEP*2 },
330
+ { N_C3, STEP*3 }, { N_E3, STEP }, { N_G3, STEP*2 }, { N_C4, STEP*2 },
331
+ };
332
+ #define MELODY_LEN (sizeof(melody) / sizeof(melody[0]))
333
+ #define BASS_LEN (sizeof(bassline) / sizeof(bassline[0]))
334
+
335
+ static uint8_t m_pos[2], m_left[2];
336
+ static uint16_t filter_cut; /* 11-bit cutoff, 0-2047 */
337
+ static uint8_t filter_up;
338
+
339
+ static void music_trigger(uint8_t v, uint16_t freq, uint8_t wave) {
340
+ if (freq == N_REST) {
341
+ POKE(SID_CTRL(v), wave); /* gate off: release tail plays */
342
+ return;
343
+ }
344
+ POKE(SID_FREQ_LO(v), (uint8_t)(freq & 0xFF));
345
+ POKE(SID_FREQ_HI(v), (uint8_t)(freq >> 8));
346
+ POKE(SID_CTRL(v), wave); /* gate OFF then ON — the 6581/8580 */
347
+ POKE(SID_CTRL(v), wave | SID_GATE); /* envelope only retriggers on the
348
+ * 0→1 gate edge */
349
+ }
48
350
 
49
- /* Platforms in WORLD char-cell coords (col_start, col_end+1, row). The
50
- * world is 80 cols wide; platforms spread across it. */
51
- static const uint8_t platforms[][3] = {
52
- { 0, 80, 22 }, /* floor spans the world */
53
- { 4, 16, 18 },
54
- { 22, 34, 16 },
55
- { 8, 20, 13 },
56
- { 24, 38, 10 },
57
- { 2, 14, 7 },
58
- { 44, 56, 18 },
59
- { 60, 74, 14 },
60
- { 50, 64, 9 },
61
- { 66, 78, 20 },
351
+ static void music_init(void) {
352
+ /* Melody: pulse at 50% duty, snappy envelope. */
353
+ POKE(SID_PW_LO(0), 0x00); POKE(SID_PW_HI(0), 0x08);
354
+ POKE(SID_AD(0), 0x07); /* attack 0, decay 7 */
355
+ POKE(SID_SR(0), 0x84); /* sustain 8, release 4 */
356
+ /* Bass: sawtooth (harmonically rich — gives the filter teeth to chew). */
357
+ POKE(SID_AD(1), 0x06);
358
+ POKE(SID_SR(1), 0xA5);
359
+ /* Filter: route VOICE 1 ONLY into it (bit 1 of $D417), resonance 13/15. */
360
+ POKE(SID_RES_FILT, 0xD2);
361
+ /* Lowpass mode + master volume 15. NOTE bits shared with volume, and bit
362
+ * 7 (3OFF) stays 0 or voice-2 sound effects go silent — see block doc. */
363
+ POKE(SID_VOL_MODE, 0x1F);
364
+ filter_cut = 0x180; filter_up = 1;
365
+ m_pos[0] = m_pos[1] = 0;
366
+ m_left[0] = m_left[1] = 1; /* triggers both voices on the next update */
367
+ }
368
+
369
+ static void music_update(void) {
370
+ /* Note sequencing, one table per voice. */
371
+ if (--m_left[0] == 0) {
372
+ music_trigger(0, melody[m_pos[0]].freq, SID_PULSE);
373
+ m_left[0] = melody[m_pos[0]].len;
374
+ if (++m_pos[0] >= MELODY_LEN) m_pos[0] = 0;
375
+ }
376
+ if (--m_left[1] == 0) {
377
+ music_trigger(1, bassline[m_pos[1]].freq, SID_SAWTOOTH);
378
+ m_left[1] = bassline[m_pos[1]].len;
379
+ if (++m_pos[1] >= BASS_LEN) m_pos[1] = 0;
380
+ }
381
+ /* THE FILTER SWEEP — triangle LFO on the cutoff, ~10s round trip.
382
+ * 11-bit value split across two registers: low 3 bits in $D415,
383
+ * high 8 in $D416. */
384
+ if (filter_up) { filter_cut += 6; if (filter_cut >= 0x700) filter_up = 0; }
385
+ else { filter_cut -= 6; if (filter_cut <= 0x180) filter_up = 1; }
386
+ POKE(SID_FILTER_LO, (uint8_t)(filter_cut & 0x07));
387
+ POKE(SID_FILTER_HI, (uint8_t)(filter_cut >> 3));
388
+ }
389
+
390
+ /* ── GAME LOGIC (clay) — screen text. The C64 has NO VRAM port: screen RAM
391
+ * is plain memory, writable any time, mid-frame, no vblank dance. The only
392
+ * translation is ASCII → SCREEN CODES (not PETSCII!): A-Z land at 1-26;
393
+ * space through '?' (incl. digits) keep their ASCII values. ── */
394
+ static void draw_text(uint8_t row, uint8_t col, const char *s) {
395
+ uint16_t off = (uint16_t)row * 40 + col;
396
+ uint8_t ch;
397
+ while ((ch = (uint8_t)*s++) != 0) {
398
+ if (ch >= 'A' && ch <= 'Z') ch -= 64; /* A-Z → screen codes 1-26 */
399
+ SCREEN[off] = ch; /* 32-63 map straight through */
400
+ COLORS[off] = COLOR_WHITE;
401
+ ++off;
402
+ }
403
+ }
404
+
405
+ static void draw_u16(uint8_t row, uint8_t col, uint16_t v) {
406
+ uint8_t i, d[5];
407
+ uint16_t off = (uint16_t)row * 40 + col;
408
+ for (i = 0; i < 5; i++) { d[i] = v % 10; v /= 10; }
409
+ for (i = 0; i < 5; i++) {
410
+ SCREEN[off + i] = (uint8_t)('0' + d[4 - i]); /* digit screen code = ASCII */
411
+ COLORS[off + i] = COLOR_WHITE;
412
+ }
413
+ }
414
+
415
+ /* ── GAME LOGIC (clay) — xorshift-style PRNG (cheap, period 255) ── */
416
+ static uint8_t rng_state = 0xB7;
417
+ static uint8_t rand8(void) {
418
+ uint8_t lsb = (uint8_t)(rng_state & 1);
419
+ rng_state >>= 1;
420
+ if (lsb) rng_state ^= 0xB8;
421
+ return rng_state;
422
+ }
423
+
424
+ /* ── GAME LOGIC (clay) — THE LEVEL ──────────────────────────────────────────
425
+ * A LOOPING column map, MAP_COLS wide. Each visible screen column shows world
426
+ * column (coarse + screen_col) mod MAP_COLS, so the camera runs forever and
427
+ * the level wraps seamlessly. Per column:
428
+ * ground_row[c] — char row of the ground's surface, NO_GROUND = a pit
429
+ * plat_row[c] — char row of a one-way floating platform, 0 = none
430
+ * spike[c] — 1 = a lethal spike stands on this column's ground
431
+ * Char rows are screen rows; playfield rows are FIELD_TOP..24, world y = row*8.
432
+ * The bottom of the 25-row window is row 24 (ground sits at row 21). */
433
+ #define MAP_COLS 64 /* 64-cell loop = 512 px of distinct level */
434
+ #define NO_GROUND 0xFF
435
+ #define GROUND_ROW 21 /* the resting ground surface row */
436
+ static const uint8_t ground_row[MAP_COLS] = {
437
+ 21,21,21,21,21,21,21,21, /* start runway (player @ col 8) */
438
+ 21,21,21,21,21,21,21,21, /* ...generous lead-in runway */
439
+ 21,21,21,21,21,21,21,21, /* ...still runway (death-free) */
440
+ 21,21,21,21,NO_GROUND,NO_GROUND,21,21, /* pit 1 (2 cols, jumpable) */
441
+ 21,21,18,18,18,18,21,21, /* a raised mesa to hop onto */
442
+ 21,21,21,NO_GROUND,NO_GROUND,21,21,21, /* pit 2 (2 cols) */
443
+ 21,21,21,21,21,21,21,21, /* runway */
444
+ 21,21,NO_GROUND,NO_GROUND,21,21,21,21, /* pit 3 before the loop seam */
445
+ };
446
+ static const uint8_t plat_row[MAP_COLS] = {
447
+ 0,0,0,0,0,0,0,0,
448
+ 0,0,0,0,0,0,0,0,
449
+ 0,0,0,0,16,16,16,0, /* slab to grab some air */
450
+ 0,0,0,0,0,0,0,0,
451
+ 0,0,0,15,15,15,0,0, /* mid slab over the mesa */
452
+ 0,0,0,0,0,0,0,0,
453
+ 0,0,13,13,13,0,0,0, /* high slab */
454
+ 0,0,0,0,0,0,0,0,
62
455
  };
63
- #define N_PLATFORMS (sizeof(platforms) / sizeof(platforms[0]))
456
+ static uint8_t spike[MAP_COLS]; /* generated at boot (see init_spikes) */
64
457
 
65
- static void wait_vblank(void) {
66
- while (PEEK(VIC_RASTER) < 250) { }
67
- while (PEEK(VIC_RASTER) >= 250) { }
458
+ /* Char codes + colors for level cells (drawn into one column at a time). */
459
+ #define CH_SOLID 0xA0 /* reverse-space solid block */
460
+ #define CH_SPIKE 0x1E /* up-arrow glyph = a spike */
461
+ #define CH_STAR 0x2E /* '.' distant detail in the sky */
462
+ #define CH_BLANK 0x20
463
+
464
+ static void init_spikes(void) {
465
+ uint8_t c;
466
+ for (c = 0; c < MAP_COLS; c++) spike[c] = 0;
467
+ /* A few fixed spikes in the LATER half (cols ≥ 32), each with a clear
468
+ * approach so a hop clears it; never on the lead-in runway, never on a pit
469
+ * column, never adjacent to a pit edge (you'd need a frame-perfect double
470
+ * input). Hand-placed (not random) so every run is fair and reproducible. */
471
+ spike[34] = 1; /* on the raised mesa run-up */
472
+ spike[48] = 1; /* flat stretch after pit 2 */
473
+ spike[56] = 1; /* final flat before pit 3 */
68
474
  }
69
475
 
70
- static void copy_sprite(uint8_t slot, const uint8_t *data) {
71
- uint8_t i;
72
- volatile uint8_t *dst = (volatile uint8_t*)(0x2000 + slot * 64); /* $2000, not $0800 (collides w/ $0801 .prg) */
73
- for (i = 0; i < 64; i++) dst[i] = data[i];
74
- }
75
-
76
- /* Render the 40 visible columns of screen + color RAM from the world map,
77
- * starting at world column `coarseCol`. Called once per coarse-scroll step
78
- * (every 8 px of camera movement) NOT every frame. */
79
- static void render_view(uint8_t coarseCol) {
80
- uint8_t sc, r, i, c8;
81
- uint16_t wc, off;
82
- uint8_t wallrow[VIS_ROWS];
83
- /* PERFORMANCE IS LOAD-BEARING HERE. The old version re-scanned the
84
- * 10-entry platform table for EVERY CELL and computed a 16-bit modulo
85
- * (a cc65 library call) per sky cell — ~2 SECONDS per re-render at
86
- * 1 MHz, re-run on every 8-px coarse step. The game spent nearly all
87
- * its time in here: scrolling froze, jump presses were eaten between
88
- * the multi-second loop passes, and the sprite setup after the first
89
- * renders didn't run for hundreds of frames. This version flags each
90
- * column's platform rows ONCE (40 table scans total, not 1000) and
91
- * uses mask arithmetic for the sky texture. ~20x faster. */
92
- for (sc = 0; sc < VIS_COLS; sc++) {
93
- wc = (uint16_t)coarseCol + sc;
94
- c8 = (uint8_t)wc;
95
- for (r = 0; r < VIS_ROWS; r++) wallrow[r] = 0;
96
- if (wc < WORLD_COLS) {
97
- for (i = 0; i < N_PLATFORMS; i++) {
98
- if (c8 >= platforms[i][0] && c8 < platforms[i][1])
99
- wallrow[platforms[i][2]] = 1;
100
- }
101
- }
102
- off = sc;
103
- for (r = 0; r < VIS_ROWS; r++) {
104
- if (wallrow[r]) {
105
- SCREEN[off] = 0xA0; /* reverse-space solid block */
106
- COLORS[off] = 0x0C; /* mid grey platform */
107
- } else if (r >= 22) {
108
- /* dithered earth below the floor row */
109
- SCREEN[off] = 0xA0;
110
- COLORS[off] = ((c8 ^ r) & 1) ? 0x09 : 0x08; /* brown / orange */
111
- } else {
112
- /* textured sky: sparse '.' stars on a cheap AND-mask lattice */
113
- if (((uint8_t)(c8 + (r << 2)) & 15) == 0) {
114
- SCREEN[off] = 0x2E; /* '.' distant detail */
115
- COLORS[off] = 0x01; /* white */
116
- } else {
117
- SCREEN[off] = 0xA0; /* solid block sky */
118
- COLORS[off] = ((c8 ^ (r >> 1)) & 1) ? 0x06 : 0x0E; /* blue / light blue */
119
- }
120
- }
121
- off += 40;
122
- }
476
+ /* ── HARDWARE IDIOM (load-bearing) the TWO-LAYER scroll trick (straight from
477
+ * the shmup): screen RAM holds the MOVING level chars; COLOR RAM holds a
478
+ * STATIC per-row texture that NEVER scrolls. The coarse shift then touches
479
+ * ONLY screen RAM (half the byte-moves), and chars drifting left pick up each
480
+ * cell's resident color for free. This is THE thing that keeps the scroll
481
+ * fast: shifting BOTH screen AND color RAM every 8 px (≈3400 byte-moves of
482
+ * cc65 C) costs ~6-7 frames per coarse step the loop visibly crawls while
483
+ * you hold a direction (measured: 8 iterations / 60 frames). Screen-only is
484
+ * ~1700 moves and stays real-time. The level's geometry (ground at one row,
485
+ * platforms on a few fixed rows) makes a row-based color texture read fine. */
486
+ static const uint8_t row_color[25] = {
487
+ /* rows 0-2 are the bar (drawn separately); 3..24 are the level */
488
+ 0,0,0,
489
+ COLOR_BLUE, COLOR_BLUE, COLOR_LIGHT_BLUE, COLOR_BLUE, /* high sky */
490
+ COLOR_LIGHT_GRAY, COLOR_BLUE, COLOR_LIGHT_BLUE, COLOR_BLUE, /* slab band */
491
+ COLOR_BLUE, COLOR_LIGHT_GRAY, COLOR_LIGHT_BLUE, COLOR_BLUE, /* slab band */
492
+ COLOR_BLUE, COLOR_LIGHT_GRAY, COLOR_BLUE, COLOR_LIGHT_BLUE, /* mesa band */
493
+ COLOR_BLUE, COLOR_GREEN, /* row 21 grass */
494
+ COLOR_BROWN, COLOR_ORANGE, COLOR_BROWN, /* earth */
495
+ };
496
+
497
+ /* Paint the STATIC color texture for the whole level window — ONCE, at boot. */
498
+ static void paint_colors(void) {
499
+ uint8_t r, c;
500
+ for (r = FIELD_TOP; r < 25; r++) {
501
+ volatile uint8_t *crow = COLORS + (uint16_t)r * 40;
502
+ for (c = 0; c < 40; c++) crow[c] = row_color[r];
123
503
  }
124
504
  }
125
505
 
126
- /* On-platform test in WORLD pixel coords. Player is ~16 px wide. */
127
- static uint8_t on_platform(int16_t worldPx, int16_t py) {
128
- uint8_t i;
129
- int16_t char_row = (py - 50) / 8 + 1;
130
- int16_t char_col = worldPx / 8;
131
- for (i = 0; i < N_PLATFORMS; i++) {
132
- if (char_row == platforms[i][2]
133
- && char_col + 2 > platforms[i][0]
134
- && char_col < platforms[i][1]) {
135
- return 1;
136
- }
506
+ /* Render ONE level column's CHARS into screen RAM at screen column `sc`, for
507
+ * world column `wc`. The COARSE scroll calls this once per 8 px (for the
508
+ * freshly exposed right edge), NOT per cell of the whole screen — a full
509
+ * 40×22 repaint of cc65 C is ~50 frames (a frozen second). Keep it lean. */
510
+ static void draw_column(uint8_t sc, uint8_t wc) {
511
+ uint8_t r, g, pr;
512
+ uint8_t *s = (uint8_t*)(0x0400 + FIELD_TOP * 40) + sc; /* plain RAM (see scroll_field) */
513
+ g = ground_row[wc];
514
+ pr = plat_row[wc];
515
+ for (r = FIELD_TOP; r < 25; r++) {
516
+ uint8_t ch = CH_BLANK;
517
+ if (pr && r == pr) ch = CH_SOLID; /* platform */
518
+ else if (r == GROUND_ROW && spike[wc] && g != NO_GROUND) ch = CH_SPIKE;
519
+ else if (g != NO_GROUND && r >= g) ch = CH_SOLID; /* ground */
520
+ else if (((uint8_t)(wc + (r << 2)) & 15) == 0) ch = CH_STAR; /* sky star */
521
+ *s = ch;
522
+ s += 40;
523
+ }
524
+ }
525
+
526
+ /* Repaint the WHOLE visible level window's CHARS from the world map at camera
527
+ * column `coarse`. Runs ONCE per level start (not per frame). */
528
+ static void paint_level(uint8_t coarse) {
529
+ uint8_t sc;
530
+ for (sc = 0; sc < 40; sc++)
531
+ draw_column(sc, (uint8_t)(coarse + sc) % MAP_COLS);
532
+ }
533
+
534
+ /* COARSE scroll: shift the 40 visible level columns one char LEFT in SCREEN
535
+ * RAM (color RAM is static — see paint_colors), then render the freshly
536
+ * exposed rightmost column from the world map. Runs only on the frame the
537
+ * fine offset wraps (every 8 px). SCHEDULING IS THE TRICK: called right after
538
+ * wait_frame() (i.e. just after the line-251 IRQ). The beam won't draw
539
+ * playfield row 3 until line 75 of the NEXT frame (~8500 cycles away) and then
540
+ * takes 504 cycles/row; this loop spends ~600 cycles/row, so with that head
541
+ * start it stays ahead of the beam — no tearing, no double buffer. (The
542
+ * grown-up alternative is page-flipping screen RAM via $D018.) */
543
+ static void scroll_field(uint8_t new_right_wc) {
544
+ uint8_t r, c;
545
+ /* NON-volatile pointer on purpose: screen RAM is plain memory (not MMIO),
546
+ * so cc65 is free to keep the running pointer in zero page and emit a tight
547
+ * indexed copy. Marking it volatile (as the per-cell game writes do, for
548
+ * mid-frame correctness) would force a reload per access and roughly DOUBLE
549
+ * this loop's cost — and this loop is the scroll's whole frame budget. */
550
+ uint8_t *srow = (uint8_t*)(0x0400 + FIELD_TOP * 40);
551
+ for (r = FIELD_TOP; r < 25; r++) {
552
+ for (c = 0; c < 39; c++) srow[c] = srow[c + 1];
553
+ srow += 40;
554
+ }
555
+ draw_column(39, new_right_wc);
556
+ }
557
+
558
+ /* ── GAME LOGIC (clay) — game state ── */
559
+ #define ST_TITLE 0
560
+ #define ST_PLAY 1
561
+ #define ST_OVER 2
562
+ static uint8_t state;
563
+ static uint8_t two_player;
564
+ static uint8_t cur_player; /* 0 = P1, 1 = P2 (alternating turns) */
565
+ static uint8_t p_lives[2];
566
+ static uint16_t p_score[2], hiscore;
567
+
568
+ /* ── Physics + camera (Q4.4 sub-pixel Y, like the NES platformer) ──
569
+ * The player sits at a FIXED screen X (SCROLL_WALL): pressing RIGHT advances
570
+ * the camera through the world, not the sprite — the classic one-way runner
571
+ * camera. World position is the camera; the player's world column is
572
+ * coarse + player_col. */
573
+ #define GRAVITY_Q44 3 /* +3/16 px per frame per frame */
574
+ #define JUMP_VEL_Q44 (-46) /* launch vy (Q4.4) → a satisfying hop */
575
+ #define MAX_VY_Q44 72 /* terminal velocity ~4.5 px/frame — keep under *
576
+ * 6 so the 6-px landing window can't tunnel */
577
+ #define MOVE_SPEED 1 /* camera advance px/frame — 1 px keeps the *
578
+ * coarse shift to once / 8 frames (like the *
579
+ * shmup), so the loop stays real-time; the *
580
+ * fine scroll makes 1 px/frame look smooth */
581
+ #define PLAYER_COL 8 /* the player's fixed screen column (0..39) */
582
+ /* Sprite Y origin: VIC visible Y starts at 50; char row r top = raster 51+8r,
583
+ * sprite at $D001=y appears at raster y. Player sprite is ~16 px; we park its
584
+ * feet (y+16) on a char-row top. Char row r's top in sprite-Y units = 51+8r,
585
+ * but $D001 counts from raster 0, and our window top (row 0) is at $D001≈50.
586
+ * Empirically: a sprite at $D001 = 51 + 8*r - 16 stands on row r's surface. */
587
+ #define SPR_Y_FOR_ROW(r) (uint8_t)(51 + 8 * (r) - 16)
588
+ #define PLAYER_X_PX ((PLAYER_COL * 8) + 24) /* fixed screen X in sprite px */
589
+
590
+ static uint16_t cam_px; /* camera position in world px (one-way) */
591
+ static uint16_t py_q44; /* player Y, Q4.4 fixed point */
592
+ static int8_t vy_q44;
593
+ static uint8_t on_ground;
594
+ static uint16_t dist_sub; /* sub-counter: 64 px scrolled = +1 point */
595
+ static uint8_t turn_pause; /* freeze frames after a turn change */
596
+ static uint8_t prev0, prev1; /* edge-detect held buttons across turns */
597
+
598
+ /* World column under the player's feet (his fixed screen column + camera). */
599
+ static uint8_t player_world_col(void) {
600
+ return (uint8_t)(((cam_px >> 3) + PLAYER_COL) % MAP_COLS);
601
+ }
602
+
603
+ /* ── HARDWARE IDIOM (load-bearing) — staging a sprite with the 9th X bit.
604
+ * VIC sprite X is 9 bits: low 8 in $D000+2n, bit 8 for ALL sprites packed
605
+ * into $D010. Forget $D010 and anything past X=255 wraps back to the left
606
+ * edge — the classic "my sprite teleports at two-thirds screen" bug. We
607
+ * accumulate the MSB bits while staging and commit the byte once. ── */
608
+ static uint8_t spr_msb, spr_ena;
609
+ static void stage_begin(void) { spr_msb = 0; spr_ena = 0; }
610
+ static void stage_sprite(uint8_t slot, int16_t x, uint8_t y) {
611
+ POKE(VIC_SPRITE_X(slot), (uint8_t)(x & 0xFF));
612
+ POKE(VIC_SPRITE_Y(slot), y);
613
+ if (x > 255) spr_msb |= (uint8_t)(1 << slot);
614
+ spr_ena |= (uint8_t)(1 << slot);
615
+ }
616
+ static void stage_commit(void) {
617
+ POKE(VIC_SPRITES_X8, spr_msb);
618
+ POKE(VIC_SPR_ENA, spr_ena); /* unstaged slots vanish — no stale sprites */
619
+ }
620
+
621
+ /* ── GAME LOGIC (clay) — score bar (rows 0-1) ── */
622
+ static void draw_bar_labels(void) {
623
+ uint8_t c;
624
+ for (c = 0; c < 40; c++) { /* row 1: solid divider line */
625
+ SCREEN[40 + c] = CH_SOLID;
626
+ COLORS[40 + c] = COLOR_DARK_GRAY;
627
+ SCREEN[80 + c] = CH_BLANK; /* row 2: the blank spacer the
628
+ * raster split hides in */
629
+ SCREEN[c] = CH_BLANK;
630
+ }
631
+ draw_text(0, 1, "SC");
632
+ draw_text(0, 11, "HI");
633
+ draw_text(0, 21, "LV");
634
+ draw_text(0, 26, "P");
635
+ draw_text(0, 30, two_player ? "2P" : "1P");
636
+ }
637
+ static void draw_bar_stats(void) {
638
+ draw_u16(0, 4, p_score[cur_player]);
639
+ draw_u16(0, 14, hiscore);
640
+ SCREEN[24] = (uint8_t)('0' + p_lives[cur_player]); /* LV <n> */
641
+ COLORS[24] = COLOR_WHITE;
642
+ SCREEN[28] = (uint8_t)('1' + cur_player); /* P <n> */
643
+ COLORS[28] = COLOR_WHITE;
644
+ }
645
+
646
+ /* ── GAME LOGIC (clay) — coins (a single VIC sprite drifting with the world) ──
647
+ * The active coin is anchored to a world column; it drifts left with the
648
+ * scroll and respawns ahead at the right when collected or passed. */
649
+ #define SCREEN_RIGHT_PX 320
650
+ static uint16_t coin_wpx; /* coin world X in px */
651
+ static uint8_t coin_row; /* coin char row */
652
+ static void respawn_coin(void) {
653
+ coin_wpx = cam_px + (uint16_t)(SCREEN_RIGHT_PX) + (uint16_t)(rand8() & 63);
654
+ coin_row = 13 + (rand8() % 6); /* float at a reachable height */
655
+ }
656
+
657
+ /* ── GAME LOGIC (clay) — title / start / game over ──────────────────────────
658
+ * Transition rule (see paint_level's note): never repaint the whole field on
659
+ * a fire press. The title draws its text ON TOP of the parked level; start
660
+ * repaints the level once (cheap enough at a state change, not per frame). */
661
+ static void draw_text_band(uint8_t row, uint8_t col, const char *s) {
662
+ uint8_t c;
663
+ volatile uint8_t *p = SCREEN + (uint16_t)row * 40;
664
+ for (c = 0; c < 40; c++) p[c] = CH_BLANK;
665
+ draw_text(row, col, s);
666
+ }
667
+
668
+ static void paint_title(void) {
669
+ draw_bar_labels();
670
+ draw_bar_stats();
671
+ draw_text_band(7, (40 - (sizeof(GAME_TITLE) - 1)) / 2, GAME_TITLE);
672
+ draw_text_band(11, 11, "PORT 2 FIRE - 1P");
673
+ draw_text_band(13, 9, "PORT 1 FIRE - 2P TURNS");
674
+ draw_text_band(17, 16, "HI");
675
+ draw_u16(17, 19, hiscore);
676
+ field_d016 = D016_BAR; /* title field holds still (text lives in it) */
677
+ POKE(VIC_SPR_ENA, 0);
678
+ state = ST_TITLE;
679
+ }
680
+
681
+ /* Start one player's RUN (used both at game start and on each turn handoff). */
682
+ static void begin_turn(void) {
683
+ cam_px = 0;
684
+ py_q44 = (uint16_t)SPR_Y_FOR_ROW(GROUND_ROW) << 4;
685
+ vy_q44 = 0;
686
+ on_ground = 1;
687
+ dist_sub = 0;
688
+ turn_pause = 40; /* "P# ready" breather */
689
+ prev0 = prev1 = 0x1F; /* swallow held buttons across the turn */
690
+ respawn_coin();
691
+ field_d016 = D016_BAR;
692
+ paint_level(0); /* repaint the level once for this run */
693
+ draw_bar_labels();
694
+ draw_bar_stats();
695
+ }
696
+
697
+ static void start_game(uint8_t players) {
698
+ two_player = players;
699
+ cur_player = 0;
700
+ p_score[0] = p_score[1] = 0;
701
+ p_lives[0] = 3;
702
+ p_lives[1] = players ? 3 : 0;
703
+ begin_turn();
704
+ sfx_tone(2, 0x40, 0x20, 6); /* start chirp */
705
+ state = ST_PLAY;
706
+ }
707
+
708
+ static void game_over(void) {
709
+ uint16_t best = p_score[0];
710
+ POKE(VIC_SPR_ENA, 0); /* sprites off before the message paints */
711
+ field_d016 = D016_BAR;
712
+ if (two_player && p_score[1] > best) best = p_score[1];
713
+ if (best > hiscore) {
714
+ hiscore = best;
715
+ hiscore_save(hiscore); /* the persistence seam — see its block doc */
716
+ }
717
+ draw_text_band(11, 15, "GAME OVER");
718
+ draw_text_band(13, 13, "FIRE - TITLE");
719
+ draw_bar_stats();
720
+ sfx_noise(24);
721
+ state = ST_OVER;
722
+ }
723
+
724
+ /* ── GAME LOGIC (clay) — death + alternating-turn handoff (arcade-classic) ── */
725
+ static void kill_player(void) {
726
+ uint8_t other;
727
+ sfx_noise(16);
728
+ if (p_lives[cur_player]) --p_lives[cur_player];
729
+ if (two_player) {
730
+ other = (uint8_t)(cur_player ^ 1);
731
+ if (p_lives[other]) cur_player = other; /* swap turns */
732
+ else if (p_lives[cur_player] == 0) { game_over(); return; }
733
+ } else if (p_lives[0] == 0) { game_over(); return; }
734
+ begin_turn();
735
+ }
736
+
737
+ /* ── GAME LOGIC (clay) — landing probe against the column map ──────────────
738
+ * One-way platforms, classic style: only catch the player while FALLING
739
+ * through a narrow window at a surface. feet = sprite Y + 16 (sprite bottom).
740
+ * A surface at char row r has its top at SPR_Y_FOR_ROW(r)+16 in feet units. */
741
+ static uint8_t surface_for_row(uint8_t r) { return (uint8_t)(SPR_Y_FOR_ROW(r) + 16); }
742
+ static uint8_t land_top(uint8_t feet) {
743
+ uint8_t wc = player_world_col();
744
+ uint8_t r, top;
745
+ r = plat_row[wc];
746
+ if (r) {
747
+ top = surface_for_row(r);
748
+ if ((uint8_t)(feet + 1) >= top && feet <= (uint8_t)(top + 5)) return top;
749
+ }
750
+ r = ground_row[wc];
751
+ if (r != NO_GROUND) {
752
+ top = surface_for_row(r);
753
+ if ((uint8_t)(feet + 1) >= top && feet <= (uint8_t)(top + 5)) return top;
137
754
  }
138
755
  return 0;
139
756
  }
140
757
 
758
+ static void copy_sprite_image(uint8_t img, const uint8_t *src) {
759
+ uint8_t i;
760
+ volatile uint8_t *dst = (volatile uint8_t*)SPR_DATA(img);
761
+ for (i = 0; i < 64; i++) dst[i] = src[i];
762
+ }
763
+
141
764
  void main(void) {
142
- int16_t worldPx = 32, py = 120; /* worldPx = player X in world pixels */
143
- int16_t camX = 0;
144
- uint8_t lastCoarse = 0xFF; /* force first render */
145
- int8_t vy = 0;
146
- uint8_t pad, prev = 0;
147
- uint8_t fine, coarse;
148
- int16_t spx;
765
+ uint8_t pad0, pad1, pad, fine_prev = 0;
766
+ uint8_t feet, y8, top;
149
767
 
768
+ /* ── HARDWARE IDIOM (load-bearing) — boot order. VIC + SID config before
769
+ * the IRQ goes live; sfx_init BEFORE music_init (sfx_init writes a plain
770
+ * volume to $D418, music_init re-asserts the filter-mode bits on top). ── */
150
771
  POKE(VIC_SPR_ENA, 0);
151
- copy_sprite(0, player_sprite);
152
- SPRITE_POINTERS[0] = 0x80; /* $2000/64 */
153
- POKE(VIC_SPR_COL(0), 0x07); /* yellow player */
154
- POKE(VIC_BORDER, 0x00); /* black border frames the scene */
155
- POKE(VIC_BG0, 0x06); /* sky-blue (shows through any gaps) */
156
-
157
- render_view(0); /* paint the initial 40-col view */
772
+ POKE(VIC_BORDER, COLOR_BLACK);
773
+ POKE(VIC_BG0, COLOR_BLACK);
774
+ POKE(VIC_CTRL2, D016_BAR); /* 38-col mode from the start */
775
+ copy_sprite_image(IMG_PLAYER, player_sprite);
776
+ copy_sprite_image(IMG_COIN, coin_sprite);
777
+ SPRITE_POINTERS[SLOT_PLAYER] = SPR_PTR(IMG_PLAYER);
778
+ SPRITE_POINTERS[SLOT_COIN] = SPR_PTR(IMG_COIN);
779
+ POKE(VIC_SPR_COL(SLOT_PLAYER), COLOR_YELLOW);
780
+ POKE(VIC_SPR_COL(SLOT_COIN), COLOR_CYAN);
781
+ POKE(CIA1_DDRA, 0xFF); /* port A drives keyboard columns */
782
+ POKE(CIA1_DDRB, 0x00); /* port B reads rows / stick 1 */
158
783
 
784
+ init_spikes();
159
785
  sfx_init();
160
- POKE(VIC_SPR_ENA, 0x01);
786
+ music_init();
787
+ hiscore = hiscore_load(); /* 0 until the core save round lands */
788
+
789
+ field_d016 = D016_BAR;
790
+ paint_colors(); /* STATIC color texture — once, ever */
791
+ paint_level(0); /* the ONE full-field char paint (boot) */
792
+ install_raster_irq(); /* the split + heartbeat go live */
793
+ paint_title();
161
794
 
162
795
  for (;;) {
163
- pad = (uint8_t)(~PEEK(CIA1_PRA) & 0x1F);
164
- wait_vblank();
796
+ wait_frame(); /* the line-251 IRQ paces everything */
797
+
798
+ music_update();
165
799
  sfx_update();
800
+ pad0 = read_stick_port2(); /* P1 — control port 2 (convention) */
801
+ pad1 = read_stick_port1(); /* P2 — control port 1 */
166
802
 
167
- if ((pad & JOY_LEFT) && worldPx > 0) worldPx -= 2;
168
- if ((pad & JOY_RIGHT) && worldPx < WORLD_W - 16) worldPx += 2;
169
- if ((pad & JOY_FIRE) && !(prev & JOY_FIRE) && on_platform(worldPx, py)) {
170
- vy = -8; sfx_tone(0, 0x40, 0x20, 6);
803
+ if (state == ST_TITLE) {
804
+ /* Mode select doubles as a controls demo: the stick that presses FIRE
805
+ * picks the mode port 2 starts 1P, port 1 starts 2P alternating. */
806
+ if ((pad0 & JOY_FIRE) && !(prev0 & JOY_FIRE)) start_game(0);
807
+ else if ((pad1 & JOY_FIRE) && !(prev1 & JOY_FIRE)) start_game(1);
808
+ prev0 = pad0; prev1 = pad1;
809
+ continue;
171
810
  }
172
- prev = pad;
173
811
 
174
- vy++; /* gravity */
175
- if (vy > 6) vy = 6;
176
- py += vy;
177
- if (py < 50) py = 50;
178
- if (py > 240) py = 240;
812
+ if (state == ST_OVER) {
813
+ if (((pad0 & JOY_FIRE) && !(prev0 & JOY_FIRE)) ||
814
+ ((pad1 & JOY_FIRE) && !(prev1 & JOY_FIRE))) paint_title();
815
+ prev0 = pad0; prev1 = pad1;
816
+ continue;
817
+ }
818
+
819
+ /* ── ST_PLAY ─────────────────────────────────────────────────────────
820
+ * The current player's controller drives the run (P2 on control port 1).
821
+ * Set field_d016 EARLY — it must be settled long before the beam reaches
822
+ * SPLIT_LINE — and run the coarse shift right after the heartbeat. */
823
+ pad = cur_player ? pad1 : pad0;
179
824
 
180
- if (vy > 0 && on_platform(worldPx, py)) {
181
- py = py & 0xF8; /* snap to 8-pixel boundary */
182
- vy = 0;
825
+ if (turn_pause) { /* "P# ready" breather */
826
+ --turn_pause;
827
+ /* Do NOT refresh prev0/prev1 here: begin_turn seeded them with FIRE
828
+ * held (0x1F), so the start/respawn FIRE press that's still down is
829
+ * swallowed — the player won't auto-jump the instant control returns.
830
+ * A fresh release+press after the breather makes the first real jump. */
831
+ stage_begin();
832
+ stage_sprite(SLOT_PLAYER, PLAYER_X_PX, (uint8_t)(py_q44 >> 4));
833
+ stage_commit();
834
+ continue;
183
835
  }
184
836
 
185
- /* Camera follows the player, centered, clamped to the world. */
186
- camX = worldPx - (SCREEN_VIS_W / 2 - 8);
187
- if (camX < 0) camX = 0;
188
- if (camX > WORLD_W - SCREEN_VIS_W) camX = WORLD_W - SCREEN_VIS_W;
837
+ /* Horizontal: RIGHT advances the one-way camera; LEFT nudges it back a
838
+ * little (but never past 0 distance covered for the run). */
839
+ if (pad & JOY_RIGHT) {
840
+ cam_px += MOVE_SPEED;
841
+ dist_sub += MOVE_SPEED;
842
+ if (dist_sub >= 64) { dist_sub -= 64; ++p_score[cur_player]; draw_bar_stats(); }
843
+ }
844
+ if ((pad & JOY_LEFT) && cam_px >= MOVE_SPEED) cam_px -= MOVE_SPEED;
189
845
 
190
- fine = (uint8_t)(camX & 7);
191
- coarse = (uint8_t)(camX >> 3);
846
+ /* FINE + COARSE scroll. field_d016 low 3 bits = 7-fine (content moves
847
+ * LEFT as the camera advances). When the fine offset wraps past a char
848
+ * boundary, COARSE-shift the field and expose a fresh world column. */
849
+ {
850
+ uint8_t fine = (uint8_t)(cam_px & 7);
851
+ field_d016 = (uint8_t)(D016_BAR | (7 - fine));
852
+ if (fine != fine_prev && fine == 0)
853
+ scroll_field((uint8_t)(((cam_px >> 3) + 39) % MAP_COLS));
854
+ fine_prev = fine;
855
+ }
856
+
857
+ /* Jump (only when grounded). FIRE = jump. */
858
+ if ((pad & JOY_FIRE) && !((cur_player ? prev1 : prev0) & JOY_FIRE) && on_ground) {
859
+ vy_q44 = JUMP_VEL_Q44;
860
+ on_ground = 0;
861
+ sfx_tone(2, 0x60, 0x30, 4); /* jump chirp — voice 2 */
862
+ }
863
+ prev0 = pad0; prev1 = pad1;
864
+
865
+ /* Gravity + sub-pixel Y. */
866
+ if (vy_q44 < MAX_VY_Q44) vy_q44 += GRAVITY_Q44;
867
+ py_q44 += vy_q44;
868
+ y8 = (uint8_t)(py_q44 >> 4);
869
+
870
+ /* Fell below the window → into a pit → lose the turn. */
871
+ if (y8 >= 224 || (py_q44 >> 4) >= 224) { kill_player(); continue; }
872
+
873
+ /* Landing — probe the column under the player's feet (falling only). */
874
+ if (vy_q44 >= 0) {
875
+ feet = (uint8_t)(y8 + 16);
876
+ top = land_top(feet);
877
+ if (top) {
878
+ py_q44 = (uint16_t)(uint8_t)(top - 16) << 4;
879
+ vy_q44 = 0;
880
+ if (!on_ground) sfx_tone(2, 0xA0, 0x10, 2); /* landing tick */
881
+ on_ground = 1;
882
+ } else {
883
+ on_ground = 0; /* walked off an edge */
884
+ }
885
+ }
192
886
 
193
- /* Coarse step: re-render the view from the world map when the camera
194
- * crosses a char boundary. */
195
- if (coarse != lastCoarse) {
196
- render_view(coarse);
197
- lastCoarse = coarse;
887
+ /* Spike under the feet (only while on the ground over a spike column). */
888
+ if (on_ground && spike[player_world_col()] &&
889
+ ground_row[player_world_col()] != NO_GROUND) {
890
+ kill_player();
891
+ continue;
198
892
  }
199
893
 
200
- /* Fine scroll: $D016 low 3 bits. Content scrolls LEFT as camX grows,
201
- * so use 7-fine. Clear bit 3 38-column mode to mask the edge
202
- * garbage column the fine scroll exposes. */
203
- POKE(VIC_CTRL2, (uint8_t)(7 - fine));
204
-
205
- /* Player sprite in SCREEN space. C64 visible sprite X origin is ~24;
206
- * X can exceed 255 set the high bit in $D010. */
207
- spx = (worldPx - camX) + 24;
208
- POKE(VIC_SPRITE_X(0), (uint8_t)spx);
209
- if (spx > 255) POKE(VIC_SPRITES_X8, 0x01); else POKE(VIC_SPRITES_X8, 0x00);
210
- POKE(VIC_SPRITE_Y(0), (uint8_t)py);
894
+ /* Coin: drifts left with the world; collect on overlap, else respawn
895
+ * once it scrolls off the left edge. */
896
+ {
897
+ int16_t coin_sx = (int16_t)((int32_t)coin_wpx - (int32_t)cam_px); /* screen px */
898
+ uint8_t coin_y = SPR_Y_FOR_ROW(coin_row);
899
+ uint8_t py8 = (uint8_t)(py_q44 >> 4);
900
+ /* collect: player's fixed column vs the coin's screen column + Y near */
901
+ if (coin_sx > -8 && coin_sx < 328) {
902
+ int16_t dx = coin_sx - (PLAYER_COL * 8);
903
+ int16_t dy = (int16_t)coin_y - (int16_t)py8;
904
+ if (dx < 0) dx = -dx;
905
+ if (dy < 0) dy = -dy;
906
+ if (dx < 14 && dy < 14) {
907
+ p_score[cur_player] += 10;
908
+ sfx_tone(2, 0xC0, 0x20, 4); /* coin ping */
909
+ draw_bar_stats();
910
+ respawn_coin();
911
+ }
912
+ } else if (coin_sx <= -8) {
913
+ respawn_coin();
914
+ }
915
+ }
916
+
917
+ /* Stage the player + coin sprites, then commit enable + X-MSB once. */
918
+ stage_begin();
919
+ stage_sprite(SLOT_PLAYER, PLAYER_X_PX, (uint8_t)(py_q44 >> 4));
920
+ {
921
+ int16_t coin_sx = (int16_t)((int32_t)coin_wpx - (int32_t)cam_px) + 24;
922
+ if (coin_sx > 0 && coin_sx < 344)
923
+ stage_sprite(SLOT_COIN, coin_sx, SPR_Y_FOR_ROW(coin_row));
924
+ }
925
+ stage_commit();
211
926
  }
212
927
  }