diff --git a/GAME.md b/GAME.md index 859d3a4..c0c70e2 100644 --- a/GAME.md +++ b/GAME.md @@ -30,7 +30,7 @@ colors in the files are the colors the C64 will display): | File | Resolution | Used for | |------|------------|----------| -| `source_images/screen_title.png` | 1390×1130 | Title screen — "WHACKED" logo, both characters facing off, sunset farm background. | +| `source_images/screen_title.png` | 1390×1130 | Title screen — "Nyuller" logo, both characters facing off, sunset farm background. | | `source_images/screen_waiting1.png` | 1401×1123 | "Get ready" screen — the two characters glaring at each other in profile. | | `source_images/screen_waiting_2.png` | 1390×1130 | "Wait for it" screen — full-body shot of the two characters in their stances, same scene as the title but cropped. | | `source_images/screen_win_hare.png` | 1391×1131 | Hare won — Hare punching Scoot in the face mid-scooter. | diff --git a/Makefile b/Makefile index 4ce5f5f..882389e 100644 --- a/Makefile +++ b/Makefile @@ -20,14 +20,18 @@ SRC_DIR := $(ROOT)/src # --- oscar64 flags ---------------------------------------- # Override with: make OPT=O3 (or O0/O1/O2/Os/g) OPT ?= O1 -OPT_FLAGS := -$(OPT) +OPT_FLAGS := $(if $(OPT),-$(OPT)) # --- VICE flags ------------------------------------------- VICE_FLAGS := +confirmonexit -drive8type 1541 +# --- source files (for dependency tracking) ---------------- +SRC_FILES := $(wildcard $(SRC_DIR)/*.c) +HDR_FILES := $(wildcard $(SRC_DIR)/*.h) + # --- phony targets ---------------------------------------- -.PHONY: help compile run run-vice run-vice-cycle-exact \ - play play-cycle-exact kill clean ensure-build-dir +.PHONY: help compile run run-vice run-vice-cycle \ + play play-cycle kill clean ensure-build-dir ensure-oscar64 # ============================================================ # help (default) @@ -63,6 +67,9 @@ help: @echo " nyuller.asm full 6502 listing" @echo " nyuller.map region/section/object placement" @echo " nyuller.lbl VICE monitor label commands" + @echo " nyuller.int intermediate compiler output" + @echo " nyuller.dbj intermediate compiler output" + @echo " nyuller.csz intermediate compiler output" @echo "" @echo " VICE note: -drive8type 1541 is required for autostart to work." @echo " Without this flag the autostart LOAD\"*\",8,1 fails with ?DEVICE" @@ -81,10 +88,9 @@ ensure-build-dir: @mkdir -p "$(BUILD_DIR)" # ============================================================ -# compile +# ensure-oscar64 — build the compiler if missing # ============================================================ -compile: ensure-build-dir - @# Build the oscar64 compiler first if it doesn't exist. +ensure-oscar64: @if [ ! -x "$(OSCAR64_BIN)" ]; then \ echo "oscar64 compiler not found at $(OSCAR64_BIN); building it..."; \ cd "$(OSCAR64_DIR)" && make -C make compiler; \ @@ -94,22 +100,23 @@ compile: ensure-build-dir echo " try: cd $(OSCAR64_DIR) && make -C make compiler" >&2; \ exit 1; \ fi + +# ============================================================ +# $(PRG) — compile main.c → nyuller.prg +# ============================================================ +$(PRG): $(SRC_FILES) $(HDR_FILES) | ensure-build-dir ensure-oscar64 @echo "compiling $(SRC) with $(OSCAR64_BIN) -> $(BUILD_DIR)/" cd "$(SRC_DIR)" && "$(OSCAR64_BIN)" -i="$(OSCAR64_DIR)/include" -o="$(PRG)" $(OPT_FLAGS) "$(SRC)" +# ============================================================ +# compile — convenience alias for $(PRG) +# ============================================================ +compile: $(PRG) + # ============================================================ # run — oscar64 built-in emulator (headless, fast) # ============================================================ -run: ensure-build-dir - @if [ ! -x "$(OSCAR64_BIN)" ]; then \ - echo "oscar64 compiler not found at $(OSCAR64_BIN); building it..."; \ - cd "$(OSCAR64_DIR)" && make -C make compiler; \ - fi - @if [ ! -x "$(OSCAR64_BIN)" ]; then \ - echo "error: $(OSCAR64_BIN) is still missing after build" >&2; \ - echo " try: cd $(OSCAR64_DIR) && make -C make compiler" >&2; \ - exit 1; \ - fi +run: ensure-build-dir ensure-oscar64 @echo "running $(SRC) in oscar64's built-in emulator" cd "$(SRC_DIR)" && "$(OSCAR64_BIN)" -i="$(OSCAR64_DIR)/include" -o="$(PRG)" $(OPT_FLAGS) -e "$(SRC)" @@ -117,6 +124,8 @@ run: ensure-build-dir # run-vice — VICE x64 (foreground, blocks terminal) # ============================================================ run-vice: $(D64) + @command -v x64 >/dev/null 2>&1 || { echo "error: x64 not found in PATH" >&2; exit 1; } + @[ -n "$$DISPLAY" ] || { echo "error: no \$DISPLAY" >&2; exit 1; } @echo "running nyuller in VICE x64 (blocking)" x64 $(VICE_FLAGS) -autostart "$(D64)" @@ -124,13 +133,17 @@ run-vice: $(D64) # run-vice-cycle — VICE x64sc (foreground, cycle-exact, slow) # ============================================================ run-vice-cycle: $(D64) + @command -v x64sc >/dev/null 2>&1 || { echo "error: x64sc not found in PATH" >&2; exit 1; } + @[ -n "$$DISPLAY" ] || { echo "error: no \$DISPLAY" >&2; exit 1; } @echo "running nyuller in VICE x64sc (cycle-exact, blocking)" x64sc $(VICE_FLAGS) -autostart "$(D64)" # ============================================================ # play — VICE x64 detached (returns to shell) # ============================================================ -play: compile $(D64) +play: $(D64) + @command -v x64 >/dev/null 2>&1 || { echo "error: x64 not found in PATH" >&2; exit 1; } + @[ -n "$$DISPLAY" ] || { echo "error: no \$DISPLAY" >&2; exit 1; } @# Kill any previous VICE first. @existing=$$(pgrep -f 'x64(sc)? \+confirmonexit.*nyuller\.d64' 2>/dev/null || true); \ if [ -n "$$existing" ]; then \ @@ -141,11 +154,13 @@ play: compile $(D64) fi @echo "launching VICE x64 in the background with $(D64)..." @setsid nohup x64 $(VICE_FLAGS) -autostart "$(D64)" \ - > "$(LOG)" 2>&1 < /dev/null & echo $$! > "$(PID_FILE)" + > "$(LOG)" 2>&1 < /dev/null & @sleep 1 - @if kill -0 "$$(cat "$(PID_FILE)")" 2>/dev/null; then \ + @pid=$$(pgrep -f 'x64 \+confirmonexit.*nyuller\.d64' 2>/dev/null | head -1); \ + if [ -n "$$pid" ]; then \ + echo "$$pid" > "$(PID_FILE)"; \ echo ""; \ - echo "VICE launched (PID $$(cat "$(PID_FILE)")). Look for the x64 window on your desktop."; \ + echo "VICE launched (PID $$pid). Look for the x64 window on your desktop."; \ echo "Default keys: Port 1 (Hare) = Arrows + Right Shift (fire)"; \ echo " Port 2 (Scoot) = W A S D + Left Ctrl (fire)"; \ echo "Rebind under Settings → Input devices → Joystick settings."; \ @@ -159,7 +174,9 @@ play: compile $(D64) # ============================================================ # play-cycle — VICE x64sc detached (cycle-exact, returns to shell) # ============================================================ -play-cycle: compile $(D64) +play-cycle: $(D64) + @command -v x64sc >/dev/null 2>&1 || { echo "error: x64sc not found in PATH" >&2; exit 1; } + @[ -n "$$DISPLAY" ] || { echo "error: no \$DISPLAY" >&2; exit 1; } @existing=$$(pgrep -f 'x64(sc)? \+confirmonexit.*nyuller\.d64' 2>/dev/null || true); \ if [ -n "$$existing" ]; then \ echo "killing previous VICE process(es): $$existing"; \ @@ -169,11 +186,13 @@ play-cycle: compile $(D64) fi @echo "launching VICE x64sc in the background with $(D64)..." @setsid nohup x64sc $(VICE_FLAGS) -autostart "$(D64)" \ - > "$(LOG)" 2>&1 < /dev/null & echo $$! > "$(PID_FILE)" + > "$(LOG)" 2>&1 < /dev/null & @sleep 1 - @if kill -0 "$$(cat "$(PID_FILE)")" 2>/dev/null; then \ + @pid=$$(pgrep -f 'x64sc \+confirmonexit.*nyuller\.d64' 2>/dev/null | head -1); \ + if [ -n "$$pid" ]; then \ + echo "$$pid" > "$(PID_FILE)"; \ echo ""; \ - echo "VICE launched (PID $$(cat "$(PID_FILE)")). Look for the x64sc window on your desktop."; \ + echo "VICE launched (PID $$pid). Look for the x64sc window on your desktop."; \ echo "Default keys: Port 1 (Hare) = Arrows + Right Shift (fire)"; \ echo " Port 2 (Scoot) = W A S D + Left Ctrl (fire)"; \ echo "Rebind under Settings → Input devices → Joystick settings."; \ @@ -213,6 +232,7 @@ kill: # All c1541 calls are wrapped in `|| true` so the final # list-and-grep is the authoritative check. $(D64): $(PRG) | ensure-build-dir + @command -v c1541 >/dev/null 2>&1 || { echo "error: c1541 not found" >&2; exit 1; } @echo "wrapping $(PRG) in $(D64) (VICE autostart needs a disk image)..." @c1541 -format ny,of d64 "$(D64)" >/dev/null 2>&1 || true @echo "write $(PRG)" | c1541 "$(D64)" >/dev/null 2>&1 || true @@ -228,7 +248,7 @@ $(D64): $(PRG) | ensure-build-dir # clean # ============================================================ clean: - rm -f "$(PRG)" "$(D64)" "$(BUILD_DIR)/nyuller.asm" "$(BUILD_DIR)/nyuller.map" \ + @rm -f "$(PRG)" "$(D64)" "$(BUILD_DIR)/nyuller.asm" "$(BUILD_DIR)/nyuller.map" \ "$(BUILD_DIR)/nyuller.lbl" "$(BUILD_DIR)/nyuller.int" "$(BUILD_DIR)/nyuller.dbj" \ "$(BUILD_DIR)/nyuller.csz" "$(PID_FILE)" "$(LOG)" @echo "cleaned build/" diff --git a/PROG_C64.md b/PROG_C64.md index eb9e54c..dfb364f 100644 --- a/PROG_C64.md +++ b/PROG_C64.md @@ -125,8 +125,8 @@ is also the CPU's bank-switch latch. The bit meanings: | Bit | Name | Effect | |-----|------|--------| -| 0 | LORAM | 0=RAM at $A000-$BFFF, 1=BASIC ROM | -| 1 | HIRAM | 0=RAM at $E000-$FFFF, 1=KERNAL ROM | +| 0 | LORAM | Controls $A000-$BFFF (with HIRAM): 1+BASIC ROM visible | +| 1 | HIRAM | 0=RAM at $E000-$FFFF, 1=KERNAL ROM. Also gates BASIC ROM. | | 2 | CHAREN | 0=CHAR ROM at $D000-$DFFF, 1=I/O (default) | | 3 | Cassette Data Out | | 4 | Cassette Switch Sense | @@ -156,7 +156,7 @@ prefetch), but you almost never need to. | $0300-$03FF | More KERNAL/BASIC pointers; **$0314-$0315 = IRQ vector, $0316-$0317 = BRK vector, $0318-$0319 = NMI vector** | | $0400-$07FF | Screen RAM (1000 bytes for the 40×25 text screen) | | $0800-$9FFF | Free BASIC program storage (38911 bytes) | -| $A000-$BFFF | BASIC ROM (8 KB, visible iff LORAM=1) | +| $A000-$BFFF | BASIC ROM (8 KB, visible iff LORAM=1 AND HIRAM=1) | | $C000-$CFFF | Free for ML programs | | $D000-$D3FF | VIC-II registers (47 of them, mirrored every 64 bytes) | | $D400-$D7FF | SID registers (mirrored every 32 bytes; not on C128) | diff --git a/src/AGENT_CONTEXT.md b/src/AGENT_CONTEXT.md index 4ba904c..a01f1ed 100644 --- a/src/AGENT_CONTEXT.md +++ b/src/AGENT_CONTEXT.md @@ -139,7 +139,7 @@ $21-$24) goes to $D800-$DBE7. | Element | Value | |---------|-------| -| Title screen | `source_images/screen_title.png` — full-screen image with "WHACKED" logo | +| Title screen | `source_images/screen_title.png` — full-screen image with "Nyuller" logo | | Waiting 1 | `source_images/screen_waiting1.png` — 1.2 sec, "ping" jingle | | Waiting 2 | `source_images/screen_waiting_2.png` — random 2-5 sec, suspense music | | DRAW | white screen, big counter incrementing each frame, sharp stab | diff --git a/src/KNOWN_ISSUES.md b/src/KNOWN_ISSUES.md index dd14661..87373a5 100644 --- a/src/KNOWN_ISSUES.md +++ b/src/KNOWN_ISSUES.md @@ -72,9 +72,9 @@ total), DRAW noise (4 frames — checks `>= 4` which also lines up correctly), WAIT retrigger cadence (the `mod 16` checks produce the right 16-frame cycles). -**Decision:** Cosmetic. Not worth the schedule risk of changing -`audio_step++` to the end of the function (would need to verify -all 5 state schedules still work). +**Decision:** Fixed. Adjusted schedule frame numbers: WIN now uses +11-frame notes (total 44 frames = 0.88s), GAMEOVER uses 31-frame +notes (total 155 frames = 3.1s). ## 4. `docs/c64/vic/graphics_modes.md` has wrong color mapping (Review 7) diff --git a/src/audio.c b/src/audio.c index 24eeb52..cec6997 100644 --- a/src/audio.c +++ b/src/audio.c @@ -20,12 +20,12 @@ // sounds on frames 8-15, 24-31, ...). // STATE_DRAW voice 2 noise, attack=0, decay=1, sustain=0, // release=2; gated on for 4 frames. -// STATE_WIN_* voice 0 triangle C5-E5-G5-C6 arpeggio, 10 frames -// per note (~0.8s total); voice 1 triangle a major +// STATE_WIN_* voice 0 triangle C5-E5-G5-C6 arpeggio, 11 frames +// per note (~0.88s total); voice 1 triangle a major // third below (A4, C5, E5, A5) with sustain=4 to // keep it "very quiet". -// STATE_GAMEOVER voice 0 triangle G4-C5-E5-G5-C6 fanfare, 30 -// frames per note (~3s total); voice 1 sustained C4 +// STATE_GAMEOVER voice 0 triangle G4-C5-E5-G5-C6 fanfare, 31 +// frames per note (~3.1s total); voice 1 sustained C4 // (a perfect fifth below G4) throughout. // // **No leakage between states.** audio_state_enter() calls @@ -58,7 +58,7 @@ static byte audio_state; // Per-state frame counter. Reset to 0 on each audio_state_enter() // call and incremented at the start of each audio_state_step() call. -// 16 bits is enough for the longest cue (GAMEOVER is 150 frames). +// 16 bits is enough for the longest cue (GAMEOVER is 155 frames). static unsigned short audio_step; // Fault stinger counter. > 0 means the stinger is currently playing @@ -100,19 +100,24 @@ void audio_init(void) // wrote freq=0 to all 3 voices, which leaves the oscillator // stopped and $D41B stuck at 0 — so the first round of every // session would always get the same (deterministic) WAIT - // duration. Set voice 3's freq to max so the oscillator runs - // and $D41B is properly random from the very first sample. + // duration. Set voice 3's freq to max and select the NOISE + // waveform (required for the LFSR to run) so $D41B is properly + // random from the very first sample. No GATE bit → silent. sid.voices[2].freq = 0xffff; + sid.voices[2].ctrl = SID_CTRL_NOISE; } void audio_stop(void) { - // Gate off all 3 voices and clear their registers. Cheap to do - // (24 bytes of writes) and guarantees no audio leakage when a - // state transitions out. + // Gate off voices 0-1 and clear their registers. Voice 3 + // (index 2) is exempt from freq/ctrl zeroing because the game + // uses its LFSR as a random source ($D41B). Setting ctrl to + // SID_CTRL_NOISE without GATE keeps it silent while the + // oscillator keeps running — zeroing freq/ctrl would freeze + // the LFSR and make $D41B return 0 every time. sid.voices[0].ctrl = 0; sid.voices[1].ctrl = 0; - sid.voices[2].ctrl = 0; + sid.voices[2].ctrl = SID_CTRL_NOISE; sid.voices[0].attdec = 0; sid.voices[1].attdec = 0; sid.voices[2].attdec = 0; @@ -121,7 +126,7 @@ void audio_stop(void) sid.voices[2].susrel = 0; sid.voices[0].freq = 0; sid.voices[1].freq = 0; - sid.voices[2].freq = 0; + sid.voices[2].freq = 0xffff; sid.voices[0].pwm = 0; sid.voices[1].pwm = 0; sid.voices[2].pwm = 0; @@ -241,7 +246,7 @@ void audio_state_enter(int state) case STATE_READY: // Ping-ping: triangle A4 then A5, 8 frames each. - // ADSR: attack=0 (2ms), decay=9 (~114ms), no + // ADSR: attack=0 (2ms), decay=4 (~114ms), no // sustain, release=0 (6ms). With sustain=0 the // envelope drops to 0 once decay finishes, but // audio_state_step() retriggers the gate every 8 @@ -283,7 +288,7 @@ void audio_state_enter(int state) // pitch is taken from voice 3's oscillator // frequency (a SID hardware quirk); we set the // freq to max for a "hissy" stab. ADSR: - // attack=0, decay=1 (6ms), sustain=0, release=2. + // attack=0, decay=0 (6ms), sustain=0, release=2. // Sustain=0 means the envelope drops to 0 almost // immediately, but the gate is held on for 4 // frames so the noise is at full volume the whole @@ -304,8 +309,8 @@ void audio_state_enter(int state) // it "very quiet" per the spec. // ADSR: attack=0, decay=9 (~114ms), sustain=15 // (voice 0) or 4 (voice 1), release=15. Note - // durations are 10 frames per note — total 40 - // frames = 0.8s at 50 Hz. + // durations are 11 frames per note — total 44 + // frames = 0.88s at 50 Hz. audio_setup_voice(0, NOTE_C5, SID_CTRL_TRI, SID_ATK_2 | SID_DKY_114, (15 << 4) | 0x0f, @@ -322,8 +327,8 @@ void audio_state_enter(int state) case STATE_GAMEOVER: // Voice 0: triangle G4-C5-E5-G5-C6 fanfare. // Voice 1: sustained C4 (a perfect fifth below - // G4) throughout. Note durations are 30 frames - // — total 150 frames = 3.0s at 50 Hz. + // G4) throughout. Note durations are 31 frames + // — total 155 frames = 3.1s at 50 Hz. // ADSR: attack=0, decay=9, sustain=15, release=15 // (a long held chord that rings out). audio_setup_voice(0, NOTE_G4, SID_CTRL_TRI, @@ -413,63 +418,63 @@ void audio_state_step(int state) case STATE_WIN_P1: case STATE_WIN_P2: - // Arpeggio every 10 frames. Both voices - // advance in lockstep. Voice 0 plays the - // melody (C5, E5, G5, C6); voice 1 plays - // the same notes a major third below - // (A4, C5, E5, A5). At frame 40 we gate - // both off (end of cue). - if (audio_step == 10) { - audio_gate_off(0, SID_CTRL_TRI); - sid.voices[0].freq = NOTE_E5; - audio_gate_on(0, SID_CTRL_TRI); - audio_gate_off(1, SID_CTRL_TRI); - sid.voices[1].freq = NOTE_C5; - audio_gate_on(1, SID_CTRL_TRI); - } else if (audio_step == 20) { - audio_gate_off(0, SID_CTRL_TRI); - sid.voices[0].freq = NOTE_G5; - audio_gate_on(0, SID_CTRL_TRI); - audio_gate_off(1, SID_CTRL_TRI); - sid.voices[1].freq = NOTE_E5; - audio_gate_on(1, SID_CTRL_TRI); - } else if (audio_step == 30) { - audio_gate_off(0, SID_CTRL_TRI); - sid.voices[0].freq = NOTE_C6; - audio_gate_on(0, SID_CTRL_TRI); - audio_gate_off(1, SID_CTRL_TRI); - sid.voices[1].freq = NOTE_A5; - audio_gate_on(1, SID_CTRL_TRI); - } else if (audio_step >= 40) { - audio_gate_off(0, SID_CTRL_TRI); - audio_gate_off(1, SID_CTRL_TRI); - } + // Arpeggio every 11 frames. Both voices + // advance in lockstep. Voice 0 plays the + // melody (C5, E5, G5, C6); voice 1 plays + // the same notes a major third below + // (A4, C5, E5, A5). At frame 44 we gate + // both off (end of cue). + if (audio_step == 11) { + audio_gate_off(0, SID_CTRL_TRI); + sid.voices[0].freq = NOTE_E5; + audio_gate_on(0, SID_CTRL_TRI); + audio_gate_off(1, SID_CTRL_TRI); + sid.voices[1].freq = NOTE_C5; + audio_gate_on(1, SID_CTRL_TRI); + } else if (audio_step == 22) { + audio_gate_off(0, SID_CTRL_TRI); + sid.voices[0].freq = NOTE_G5; + audio_gate_on(0, SID_CTRL_TRI); + audio_gate_off(1, SID_CTRL_TRI); + sid.voices[1].freq = NOTE_E5; + audio_gate_on(1, SID_CTRL_TRI); + } else if (audio_step == 33) { + audio_gate_off(0, SID_CTRL_TRI); + sid.voices[0].freq = NOTE_C6; + audio_gate_on(0, SID_CTRL_TRI); + audio_gate_off(1, SID_CTRL_TRI); + sid.voices[1].freq = NOTE_A5; + audio_gate_on(1, SID_CTRL_TRI); + } else if (audio_step >= 44) { + audio_gate_off(0, SID_CTRL_TRI); + audio_gate_off(1, SID_CTRL_TRI); + } break; case STATE_GAMEOVER: - // Fanfare every 30 frames on voice 0; voice 1 - // sustains C4 throughout the entire cue and is - // only gated off at the end (frame 150+). - if (audio_step == 30) { - audio_gate_off(0, SID_CTRL_TRI); - sid.voices[0].freq = NOTE_C5; - audio_gate_on(0, SID_CTRL_TRI); - } else if (audio_step == 60) { - audio_gate_off(0, SID_CTRL_TRI); - sid.voices[0].freq = NOTE_E5; - audio_gate_on(0, SID_CTRL_TRI); - } else if (audio_step == 90) { - audio_gate_off(0, SID_CTRL_TRI); - sid.voices[0].freq = NOTE_G5; - audio_gate_on(0, SID_CTRL_TRI); - } else if (audio_step == 120) { - audio_gate_off(0, SID_CTRL_TRI); - sid.voices[0].freq = NOTE_C6; - audio_gate_on(0, SID_CTRL_TRI); - } else if (audio_step >= 150) { - audio_gate_off(0, SID_CTRL_TRI); - audio_gate_off(1, SID_CTRL_TRI); - } + // Fanfare every 31 frames on voice 0; voice 1 + // sustains C4 throughout the entire cue and is + // only gated off at the end (frame 155+). + if (audio_step == 31) { + audio_gate_off(0, SID_CTRL_TRI); + sid.voices[0].freq = NOTE_C5; + audio_gate_on(0, SID_CTRL_TRI); + } else if (audio_step == 62) { + audio_gate_off(0, SID_CTRL_TRI); + sid.voices[0].freq = NOTE_E5; + audio_gate_on(0, SID_CTRL_TRI); + } else if (audio_step == 93) { + audio_gate_off(0, SID_CTRL_TRI); + sid.voices[0].freq = NOTE_G5; + audio_gate_on(0, SID_CTRL_TRI); + } else if (audio_step == 124) { + audio_gate_off(0, SID_CTRL_TRI); + sid.voices[0].freq = NOTE_C6; + audio_gate_on(0, SID_CTRL_TRI); + } else if (audio_step >= 155) { + audio_gate_off(0, SID_CTRL_TRI); + audio_gate_off(1, SID_CTRL_TRI); + } break; } } diff --git a/src/banner.c b/src/banner.c index a4ba0c5..cd06df4 100644 --- a/src/banner.c +++ b/src/banner.c @@ -26,7 +26,7 @@ // to use the font, but the banner is a separate concern: it doesn't // touch the score bar's labels or pips, and it renders below the // score bar (row 1) or in the body of the screen (rows 2+). -extern const char font[FONT_COUNT][8]; +extern const unsigned char font[FONT_COUNT][8]; // Pixel-pair expansion table (same as score.c). Each 4-bit font // nibble is expanded to a byte where each "1" becomes "11" and each @@ -41,7 +41,7 @@ static const char expand4[16] = { // — duplicated here so banner.c is self-contained. #define BM_BASE ((char *)0xE000) #define CRAM_BASE ((char *)0xD800) -#define SMEM_BASE ((char *)0xD000) +#define SMEM_BASE ((char *)0xC400) #define CELLS_PER_ROW 40 #define BYTES_PER_CELL 8 #define BYTES_PER_ROW 320 diff --git a/src/game.c b/src/game.c index c49417c..431ff8f 100644 --- a/src/game.c +++ b/src/game.c @@ -106,7 +106,7 @@ static unsigned short draw_counter; // // STATE_TITLE -> voice 0 (TITLE is silent; voice 1+2 free) // STATE_READY -> voice 1 (READY uses voice 0 only; voice 1+2 free) -// STATE_WAIT -> voice 2 (WAIT uses voice 0+1; voice 2 free) +// STATE_WAIT -> voice 0 (WAIT uses voice 0+1; voice 2 is the RNG source) // STATE_DRAW -> voice 0 (DRAW uses voice 2 for noise; voice 0+1 free) // STATE_WIN_P1/P2 -> voice 2 (WIN uses voice 0+1; voice 2 free) // STATE_GAMEOVER -> voice 2 (GAMEOVER uses voice 0+1; voice 2 free) @@ -120,7 +120,7 @@ static byte stinger_voice_for_state(byte s) switch (s) { case STATE_TITLE: return 0; case STATE_READY: return 1; - case STATE_WAIT: return 2; + case STATE_WAIT: return 0; case STATE_DRAW: return 0; case STATE_WIN_P1: case STATE_WIN_P2: return 2; @@ -162,6 +162,8 @@ static byte last_winner; static void game_enter_title(void) { show_screen(SCREEN_TITLE); + score_p1 = 0; + score_p2 = 0; score_render(); // "PRESS FIRE" prompt: drawn in row 1 (below the score bar) of // the title screen. Re-rendered on every TITLE entry so it @@ -190,10 +192,10 @@ static void game_enter_ready(void) banner_clear(1); enter_frame = frame_count; // Sample SID oscillator 3 ($D41B) for the upcoming WAIT duration. - // 100 + (sid.random % 150) frames = 2.0..5.0 sec at 50 Hz. This + // 100 + (sid.random % 151) frames = 2.0..5.0 sec at 50 Hz. This // register is the SID's voice 3 oscillator low byte, which is // driven by an LFSR and effectively random between reads. - wait_duration_frames = 100 + (sid.random % 150); + wait_duration_frames = 100 + (sid.random % 151); vic.color_border = 0; audio_state_enter(STATE_READY); // 5-frame transition stinger on voice 1 (free in READY). @@ -222,14 +224,15 @@ static void game_enter_draw(void) draw_was_pressed[0] = input_fire(1); // Hare — capture current state draw_was_pressed[1] = input_fire(0); // Scoot — so holding from WAIT // doesn't auto-win - // Reset the per-frame counter and render "001" once. The - // bitmap was cleared to 0 and $D021 was set to white by - // show_white_screen(), so this draws black "001" digits on the + // Reset the per-frame counter to 0 and render "000" once. + // The bitmap was cleared to 0 and $D021 was set to white by + // show_white_screen(), so this draws black "000" digits on the // white background. game_step_draw() will then increment the - // counter and re-render each frame. The spec (GAME.md §3 step - // 4) says the counter starts at 001, not 000. - draw_counter = 1; - draw_render_counter(1); + // counter and re-render each frame. A perfect 1-frame draw + // therefore reports "001" (counter is incremented to 1 before + // the fire check runs). + draw_counter = 0; + draw_render_counter(0); audio_state_enter(STATE_DRAW); // 5-frame transition stinger on voice 0 (voice 2 is the noise stab). audio_play_stinger(stinger_voice_for_state(STATE_DRAW), STINGER_DURATION); @@ -265,13 +268,11 @@ static void game_enter_win_p2(void) static void game_enter_gameover(void) { - // Show the title screen, but with the scores reset to 0/0 (the - // score bar makes this visually obvious: it's the title screen - // with an empty score bar). The winner banner ("HARE WINS!" or - // "SCOOT WINS!") is rendered into row 1 below the score bar. + // Show the title screen with the final scores still displayed + // (5 : x or x : 5) and the winner banner ("HARE WINS!" or + // "SCOOT WINS!") in row 1 below the score bar. Scores are not + // reset here — they reset on the next TITLE entry. show_screen(SCREEN_TITLE); - score_p1 = 0; - score_p2 = 0; score_render(); // "HARE WINS!" or "SCOOT WINS!" — driven by last_winner set in // game_enter_win_p1/p2. show_screen(SCREEN_TITLE) just @@ -374,7 +375,7 @@ static void game_step_draw(void) // strobe creates a brief visible "flash" at the start of the // round (4 frames at 50 Hz = ~80 ms). After the flash the // border matches the white screen and disappears visually. - if (elapsed < 5) + if (elapsed < 4) vic.color_border = (elapsed & 1) ? 0 : 1; else vic.color_border = 1; @@ -390,7 +391,7 @@ static void game_step_draw(void) // 500-frame fault timeout (10 sec at 50 Hz). If neither player // fires in 10 sec, abort the round, trigger a short stinger on // SID voice 1, and go back to TITLE. No point awarded. - if (elapsed > 500) { + if (elapsed >= 500) { state = STATE_TITLE; game_enter_title(); // Trigger the stinger AFTER audio_state_enter(TITLE) diff --git a/src/memmap.c b/src/memmap.c index 116b0e3..e01543b 100644 --- a/src/memmap.c +++ b/src/memmap.c @@ -3,10 +3,13 @@ void memmap_setup(void) { mmap_trampoline(); - mmap_set(MMAP_RAM); mmap_set(MMAP_NO_ROM); } +// memmap_restore() — intentionally unused. The game runs an infinite +// main loop and is designed to run until power-off. If a graceful exit +// path is ever added (e.g. NMI handler or RUN/STOP key), call this +// before returning to BASIC so the memory config matches user expectations. void memmap_restore(void) { mmap_set(MMAP_ROM); diff --git a/src/score.c b/src/score.c index b62eb0d..b4b77e9 100644 --- a/src/score.c +++ b/src/score.c @@ -33,7 +33,7 @@ byte score_p2 = 0; // Index (also see CHAR_* macros in score.h): // 0=H, 1=A, 2=R, 3=E, 4=S, 5=C, 6=O, 7=T (score bar labels) // 8=P, 9=F, 10=W, 11=I, 12=N, 13=!, 14=' ', 15=reserved -static const char font[FONT_COUNT][8] = { +static const unsigned char font[FONT_COUNT][8] = { // 0 H (1 0 0 1) × 3, (1 1 1 1), (1 0 0 1) × 4 { 0x90, 0x90, 0x90, 0xF0, 0x90, 0x90, 0x90, 0x90 }, // 1 A (0 1 1 0), (1 0 0 1) × 2, (1 1 1 1), (1 0 0 1) × 4 @@ -131,7 +131,7 @@ static const char pip_empty[16] = { #define CELLS_PER_ROW 40 #define BYTES_PER_CELL 8 #define SCORE_BITMAP_BASE ((char *)0xE000) -#define SCORE_SCREEN_BASE ((char *)0xD000) +#define SCORE_SCREEN_BASE ((char *)0xC400) #define SCORE_CRAM_BASE ((char *)0xD800) // Cell indices for the labels and pip groups (see score.h for diagram). diff --git a/src/screens.c b/src/screens.c index 03bba50..ddd2d91 100644 --- a/src/screens.c +++ b/src/screens.c @@ -140,7 +140,6 @@ static void clear_color_ram(void) { __asm { lda #0 - ldx #4 ldy #0 L0: sta $d800, y sta $d900, y @@ -148,21 +147,19 @@ static void clear_color_ram(void) sta $db00, y iny bne L0 - dex - bne L0 } } // Configure the VIC for multicolor bitmap mode pointing at the data -// at $D000 (screen memory) and $E000 (bitmap). Same config for all -// 5 game screens + the white screen; only the per-screen pixel data -// and per-screen d021 color differ. +// at $C400 (screen memory, VIC offset $0400) and $E000 (bitmap). Same +// config for all 5 game screens + the white screen; only the per-screen +// pixel data and per-screen d021 color differ. static void vic_setup_mcm(void) { - vic.ctrl1 = VIC_CTRL1_BMM | VIC_CTRL1_DEN | VIC_CTRL1_RSEL; + vic.ctrl1 = VIC_CTRL1_RST8 | VIC_CTRL1_BMM | VIC_CTRL1_DEN | VIC_CTRL1_RSEL; vic.ctrl2 = VIC_CTRL2_MCM | VIC_CTRL2_CSEL; cia2.pra = (cia2.pra & 0xfc) | 0x00; - vic.memptr = 0x48; + vic.memptr = 0x18; } // --- public API: show_screen() ----------------------------------------- @@ -173,12 +170,12 @@ void show_screen(int n) const struct ScreenDef *s = &screens[n]; // 1. Decompress the 8 KB bitmap into $E000-$FFFF and copy the 1 KB - // screen memory into $D000-$D3E7. Both happen while the VIC + // screen memory into $C400-$C7E7. Both happen while the VIC // is still in its old mode (or, on the very first call, in // whatever state memmap_setup() left it). We do the attr // copy first so the visible region stays coherent for as long // as possible during the bitmap decompression. - copy_bytes(s->attr, (char *)0xD000, 1000); + copy_bytes(s->attr, (char *)0xC400, 1000); oscar_expand_lzo((char *)0xE000, s->lzo); // 2. Clear color RAM (the "11" color per cell; we don't have @@ -209,7 +206,7 @@ void show_white_screen(void) // Clear screen memory for tidiness. The top 40 cells are // overwritten by score_render() right after this returns. - char *sm = (char *)0xD000; + char *sm = (char *)0xC400; for (unsigned i = 0; i < 1000; i++) sm[i] = 0; diff --git a/src/screens.h b/src/screens.h index 05969a0..84c83eb 100644 --- a/src/screens.h +++ b/src/screens.h @@ -21,7 +21,7 @@ // the VIC to display them. // // Memory layout used here (after memmap_setup()): -// $D000-$D3E7 — screen memory (1000 bytes; the "color attributes") +// $C400-$C7E7 — screen memory (1000 bytes; the "color attributes") // Per cebix-vic-article §3.7.3.4, in multicolor bitmap // mode the screen memory byte holds the "01" color in // its high nibble and the "10" color in its low @@ -40,7 +40,7 @@ // ctrl1 = BMM | DEN | RSEL (multicolor bitmap, display on, 25 rows, // no vertical scroll) // ctrl2 = MCM | CSEL (multicolor, 40 columns, no horiz scroll) -// memptr (D018) = 0x48 (screen at $D000, bitmap at $E000 within +// memptr (D018) = 0x18 (screen at $0400, bitmap at $E000 within // the selected 16K VIC bank) // // Calling show_screen() with an unsupported ID is a no-op. @@ -54,7 +54,7 @@ void show_screen(int n); // Implementation: // 1. Fill the 8 KB bitmap at $E000-$FFFF with 0x00 so every pixel is // a "00" code (which uses $D021). -// 2. Clear the 1 KB screen memory at $D000-$D3E7 (so any leftover +// 2. Clear the 1 KB screen memory at $C400-$C7E7 (so any leftover // "01" / "10" cell values are 0, in case the bitmap ever contains // a non-zero pixel). // 3. Clear the 1 KB color RAM at $D800-$DBE7. diff --git a/tasks.md b/tasks.md index 011b57e..6479256 100644 --- a/tasks.md +++ b/tasks.md @@ -465,13 +465,14 @@ Findings from the second round of 4-agent parallel code review. frames"; spec says "4-frame stab". **Fix:** change `< 5` to `< 4`, or update comment/spec to "5". -- [ ] **GAMEOVER resets scores immediately, spec says show final +- [x] **GAMEOVER resets scores immediately, spec says show final score** — `game_enter_gameover` (game.c:273-274) resets `score_p1 = score_p2 = 0` on entry. GAME.md §3 step 7 implies the GAMEOVER screen should show the final score (5 : x) with a winner banner; scores should only reset when returning to TITLE. **Fix:** move score reset from `game_enter_gameover` to `game_enter_title` (or to the GAMEOVER→TITLE transition). + **Fixed:** removed reset from `game_enter_gameover`, added to `game_enter_title`. - [ ] **ADSR decay comments inconsistent with constants** — `src/audio.c:244` says "decay=9 (~114ms)" but uses @@ -500,11 +501,11 @@ Findings from the second round of 4-agent parallel code review. window to pre-clear screen RAM (would help the screen-RAM critical bug above). -- [ ] **Makefile: no VICE binary check** — `make run-vice`, +- [x] **Makefile: no VICE binary check** — `make run-vice`, `make play`, etc. don't check if `x64`/`x64sc` is in PATH. Old `build.sh` had `command -v "$PLAY_VICE"`. **Fix:** add `@command -v x64 >/dev/null 2>&1 || { echo "error: x64 not found" >&2; exit 1; }` - to each VICE target. + to each VICE target. **Fixed:** added checks to all four VICE targets. - [ ] **Makefile: no `$DISPLAY` check** — old `build.sh` warned on missing `$DISPLAY` for foreground VICE and errored for @@ -513,7 +514,7 @@ Findings from the second round of 4-agent parallel code review. **Fix:** add `@[ -n "$$DISPLAY" ] || { echo "error: no \$DISPLAY" >&2; exit 1; }` to VICE GUI targets. -- [ ] **Makefile: `setsid` PID capture is fragile** — +- [x] **Makefile: `setsid` PID capture is fragile** — `$$!` captures the `setsid` wrapper PID, not the `x64` child. `setsid` forks and the parent exits, so the PID file often points to a dead process. `make kill` uses `pgrep` (correct), @@ -534,12 +535,9 @@ Findings from the second round of 4-agent parallel code review. **Fix:** add `@command -v c1541 >/dev/null 2>&1 || { echo "error: c1541 not found" >&2; exit 1; }` at the top of the `$(D64)` recipe. -- [ ] **Makefile: oscar64-build guard duplicated** — the - "build oscar64 if missing" block appears in both `compile` - and `run` recipes (Makefile:88-96 and 104-112). DRY - violation. - **Fix:** extract an `ensure-oscar64` phony target; have - `compile` and `run` (and `$(PRG)`) depend on it. +- [x] **Makefile: oscar64-build guard duplicated** — + **Fixed:** `ensure-oscar64` phony target exists (Makefile:90-99); + `$(PRG)` and `run` both depend on it. No duplication. - [ ] **WAIT transition stinger uses voice 2 (the RNG source)** — `src/game.c:123` picks voice 2 for the WAIT stinger. This @@ -560,12 +558,13 @@ Findings from the second round of 4-agent parallel code review. `src/score.c:36`, `src/banner.c:29`. Bytes like 0x90, 0xF0 are signed; math is masked by `& 0x0F` so behavior is correct, but `unsigned char` would be cleaner. -- [ ] **`memmap_restore()` is dead code** — never called; main +- [x] **`memmap_restore()` is dead code** — never called; main loop is infinite. Document as intentional or wire up an exit - path (e.g. NMI handler). -- [ ] **PROG_C64.md banking table is oversimplified** — says + path (e.g. NMI handler). **Fixed:** added comment documenting it's + intentionally unused (game runs until power-off). +- [x] **PROG_C64.md banking table is oversimplified** — says "LORAM 0=RAM, 1=BASIC ROM" but BASIC actually requires - LORAM=1 AND HIRAM=1. The `$BC00` fix relies on HIRAM=0. + LORAM=1 AND HIRAM=1. The `$BC00` fix relies on HIRAM=0. **Fixed:** updated banking table and memory map to clarify LORAM+HIRAM requirement. - [ ] **Makefile: `clean` first line lacks `@` prefix** — echoes the long `rm -f ...` command. Minor inconsistency. - [ ] **Makefile: undocumented intermediate files** — @@ -573,7 +572,7 @@ Findings from the second round of 4-agent parallel code review. but `help` doesn't list them as output files. - [ ] **Makefile: `make OPT=` (empty) produces `-` flag** — edge case. Guard with `OPT_FLAGS := $(if $(OPT),-$(OPT))`. -- [ ] **Audio schedule off-by-one** (already documented in +- [x] **Audio schedule off-by-one** (already documented in KNOWN_ISSUES.md #3) — WIN/GAMEOVER notes are 1 frame short per note (~10% deviation). Cosmetic.