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.
- package/AGENTS.md +53 -43
- package/CHANGELOG.md +91 -0
- package/README.md +3 -3
- package/examples/README.md +7 -7
- package/examples/atari2600/templates/platformer.asm +1225 -332
- package/examples/atari2600/templates/puzzle.asm +1056 -0
- package/examples/atari2600/templates/racing.asm +906 -275
- package/examples/atari2600/templates/shmup.asm +1031 -239
- package/examples/atari2600/templates/sports.asm +1135 -253
- package/examples/atari7800/templates/platformer.c +991 -156
- package/examples/atari7800/templates/puzzle.c +1091 -148
- package/examples/atari7800/templates/racing.c +952 -124
- package/examples/atari7800/templates/shmup.c +812 -134
- package/examples/atari7800/templates/sports.c +820 -184
- package/examples/c64/templates/platformer.c +879 -164
- package/examples/c64/templates/puzzle.c +855 -178
- package/examples/c64/templates/racing.c +873 -97
- package/examples/c64/templates/shmup.c +757 -161
- package/examples/c64/templates/sports.c +755 -100
- package/examples/gb/templates/platformer.c +841 -179
- package/examples/gb/templates/puzzle.c +986 -246
- package/examples/gb/templates/racing.c +754 -174
- package/examples/gb/templates/shmup.c +673 -175
- package/examples/gb/templates/sports.c +790 -159
- package/examples/gba/templates/platformer.c +626 -165
- package/examples/gba/templates/puzzle.c +519 -269
- package/examples/gba/templates/racing.c +511 -206
- package/examples/gba/templates/shmup.c +564 -179
- package/examples/gba/templates/sports.c +454 -174
- package/examples/gbc/templates/platformer.c +944 -180
- package/examples/gbc/templates/puzzle.c +363 -109
- package/examples/gbc/templates/racing.c +884 -180
- package/examples/gbc/templates/shmup.c +821 -185
- package/examples/gbc/templates/sports.c +870 -162
- package/examples/genesis/templates/platformer.c +747 -129
- package/examples/genesis/templates/puzzle.c +694 -261
- package/examples/genesis/templates/racing.c +726 -203
- package/examples/genesis/templates/shmup.c +535 -142
- package/examples/genesis/templates/sports.c +495 -158
- package/examples/gg/templates/platformer.c +880 -215
- package/examples/gg/templates/puzzle.c +875 -216
- package/examples/gg/templates/racing.c +915 -172
- package/examples/gg/templates/shmup.c +714 -191
- package/examples/gg/templates/sports.c +732 -129
- package/examples/lynx/templates/platformer.c +604 -69
- package/examples/lynx/templates/puzzle.c +498 -158
- package/examples/lynx/templates/racing.c +538 -102
- package/examples/lynx/templates/shmup.c +458 -131
- package/examples/lynx/templates/sports.c +496 -72
- package/examples/msx/platformer/main.c +649 -162
- package/examples/msx/puzzle/main.c +742 -240
- package/examples/msx/racing/main.c +669 -178
- package/examples/msx/shmup/main.c +460 -178
- package/examples/msx/sports/main.c +592 -126
- package/examples/nes/templates/platformer.c +589 -171
- package/examples/nes/templates/puzzle.c +563 -242
- package/examples/nes/templates/racing.c +502 -208
- package/examples/nes/templates/shmup.c +339 -145
- package/examples/nes/templates/sports.c +341 -183
- package/examples/pce/platformer/main.c +874 -205
- package/examples/pce/puzzle/main.c +802 -287
- package/examples/pce/racing/main.c +783 -208
- package/examples/pce/shmup/main.c +638 -212
- package/examples/pce/sports/main.c +586 -169
- package/examples/porting-across-platforms/README.md +1 -1
- package/examples/sms/templates/platformer.c +762 -177
- package/examples/sms/templates/puzzle.c +752 -212
- package/examples/sms/templates/racing.c +808 -145
- package/examples/sms/templates/shmup.c +599 -162
- package/examples/sms/templates/sports.c +630 -122
- package/examples/snes/templates/music_demo.c +7 -0
- package/examples/snes/templates/platformer-data.asm +123 -24
- package/examples/snes/templates/platformer-hdr.asm +57 -0
- package/examples/snes/templates/platformer.c +586 -165
- package/examples/snes/templates/puzzle-data.asm +116 -21
- package/examples/snes/templates/puzzle-hdr.asm +57 -0
- package/examples/snes/templates/puzzle.c +614 -235
- package/examples/snes/templates/racing-data.asm +390 -32
- package/examples/snes/templates/racing-hdr.asm +57 -0
- package/examples/snes/templates/racing.c +807 -196
- package/examples/snes/templates/shmup-data.asm +87 -29
- package/examples/snes/templates/shmup-hdr.asm +57 -0
- package/examples/snes/templates/shmup.c +459 -198
- package/examples/snes/templates/sports-data.asm +48 -2
- package/examples/snes/templates/sports-hdr.asm +57 -0
- package/examples/snes/templates/sports.c +414 -163
- package/package.json +12 -12
- package/src/cores/wasm/bluemsx_libretro.js +1 -1
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +1 -1
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +1 -1
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +1 -1
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +1 -1
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +1 -1
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +1 -1
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +1 -1
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +1 -1
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +84 -8
- package/src/http/tool-registry.js +11 -11
- package/src/mcp/tools/cheats.js +2 -1
- package/src/mcp/tools/frame.js +3 -2
- package/src/mcp/tools/index.js +3 -3
- package/src/mcp/tools/input.js +5 -4
- package/src/mcp/tools/lifecycle.js +6 -4
- package/src/mcp/tools/memory.js +131 -24
- package/src/mcp/tools/platform-docs.js +1 -1
- package/src/mcp/tools/preview-tile.js +6 -2
- package/src/mcp/tools/project.js +1098 -130
- package/src/mcp/tools/record.js +6 -7
- package/src/mcp/tools/rom-id.js +5 -1
- package/src/mcp/tools/run-until.js +12 -4
- package/src/mcp/tools/snippets.js +6 -6
- package/src/mcp/tools/sprite-pipeline.js +14 -2
- package/src/mcp/tools/state.js +2 -1
- package/src/mcp/tools/tile-inspect.js +8 -1
- package/src/mcp/tools/toolchain.js +12 -1
- package/src/mcp/tools/watch-memory.js +53 -10
- package/src/observer/bus.js +73 -0
- package/src/observer/livestream.html +4 -2
- package/src/observer/tool-wrap.js +17 -14
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +32 -3
- package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
- package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
- package/src/platforms/c64/MENTAL_MODEL.md +11 -4
- package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
- package/src/platforms/gb/MENTAL_MODEL.md +3 -3
- package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
- package/src/platforms/gb/lib/c/README.md +10 -11
- package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
- package/src/platforms/gb/lib/c/patch-header.js +13 -3
- package/src/platforms/gba/MENTAL_MODEL.md +4 -4
- package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
- package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
- package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
- package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
- package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
- package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
- package/src/platforms/gbc/lib/c/README.md +10 -11
- package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
- package/src/platforms/gbc/lib/c/patch-header.js +13 -3
- package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
- package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
- package/src/platforms/gg/MENTAL_MODEL.md +4 -4
- package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
- package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
- package/src/platforms/gg/lib/c/joypad_read.c +29 -0
- package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
- package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
- package/src/platforms/msx/MENTAL_MODEL.md +5 -5
- package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
- package/src/platforms/msx/lib/c/msx_hw.h +1 -0
- package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
- package/src/platforms/nes/MENTAL_MODEL.md +2 -2
- package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
- package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
- package/src/platforms/pce/MENTAL_MODEL.md +5 -5
- package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
- package/src/platforms/pce/lib/c/pce_hw.h +11 -0
- package/src/platforms/pce/lib/c/pce_video.c +32 -0
- package/src/platforms/sms/MENTAL_MODEL.md +6 -6
- package/src/platforms/snes/MENTAL_MODEL.md +2 -2
- package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
- package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
- package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
- package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
- package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
- package/src/toolchains/index.js +27 -11
|
@@ -1,212 +1,927 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
456
|
+
static uint8_t spike[MAP_COLS]; /* generated at boot (see init_spikes) */
|
|
64
457
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
/*
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
POKE(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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 (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 (
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
/*
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
/*
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
/*
|
|
201
|
-
*
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
}
|