Phase 10: Fix all 28 code review bugs

Critical fixes:
- Screen RAM relocated from  to  (VIC register collision)
- RST8 preserved in vic_setup_mcm() (raster IRQ line 311 stability)
-  random source armed with NOISE waveform (WAIT duration variety)
- Makefile  now has real file rule (run-vice works from clean)
- .PHONY lists corrected

Medium fixes:
- DRAW fault timeout >= 500 (was > 500)
- Counter minimum now 001 (init to 0, increment before render)
- WAIT duration upper bound 250 (was 249)
- Border strobe 4 frames (was 5)
- GAMEOVER shows final score, reset on TITLE entry
- ADSR decay comments corrected
- clear_color_ram() redundant loop removed
- memmap_setup() redundant MMAP_RAM removed
- Makefile: VICE/:0/c1541 checks, setsid pgrep, ensure-oscar64
- WAIT stinger uses voice 0 (protects voice 2 for RNG)

Low fixes:
- WHACKED references updated to Nyuller
- font arrays use unsigned char
- memmap_restore() documented as unused
- PROG_C64.md banking table corrected
- Makefile: clean @ prefix, help docs, OPT guard
- Audio schedule off-by-one corrected (+1 frame/note)
This commit is contained in:
ballz
2026-07-18 20:04:15 +02:00
parent 97d4f4216b
commit 64b0790519
13 changed files with 183 additions and 158 deletions
+1 -1
View File
@@ -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 |
+3 -3
View File
@@ -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)
+77 -72
View File
@@ -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;
}
}
+2 -2
View File
@@ -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
+20 -19
View File
@@ -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)
+4 -1
View File
@@ -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);
+2 -2
View File
@@ -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).
+8 -11
View File
@@ -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;
+3 -3
View File
@@ -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.