From d538cf46e2771d371f0b26c77c253884be77f589 Mon Sep 17 00:00:00 2001 From: ballz Date: Fri, 17 Jul 2026 02:02:51 +0200 Subject: [PATCH] Phase 5: raster IRQ + frame timing Replace busy-wait frame counter with a 50 Hz raster IRQ at line 311 (PAL stable line). The IRQ handler increments a 16-bit frame_count and calls game_step() once per frame. Per-state timing now uses enter_frame timestamps + frame_count comparisons. - New tick.h/tick.c: install one RIRQ via Oscar64's rirq library, call a __interrupt handler that bumps frame_count and runs the state machine. Mask CIA 1 + CIA 2 IRQs and set RST8 (the high bit of the 9-bit raster register) so the IRQ fires at line 311 not line 55. - game.h: expose volatile frame_count, replace per-state 'frame' counter with enter_frame timestamps. - game.c: use frame_count - enter_frame everywhere; sample SID $D41B at READY enter for a random 100..250 frame WAIT duration; trigger a low-square-wave stinger on SID voice 1 when DRAW faults out (no fire for 500 frames) and gate it off ~0.2 sec later via a counter decremented every frame. - main.c: replace the busy-wait loop with rasterirq_setup() and an empty for(;;); idle. (Filename is tick.c/.h not rasterirq.c/.h because the oscar64 library's own rasterirq.c does '#include "rasterirq.h"' to pull in its own header, and that include would otherwise pick up ours and lose NUM_IRQS.) --- src/game.c | 418 ++++++++++++++++++++++++++++++++--------------------- src/game.h | 49 +++++-- src/main.c | 31 ++-- src/tick.c | 125 ++++++++++++++++ src/tick.h | 32 ++++ 5 files changed, 464 insertions(+), 191 deletions(-) create mode 100644 src/tick.c create mode 100644 src/tick.h diff --git a/src/game.c b/src/game.c index b0edc94..ec195b4 100644 --- a/src/game.c +++ b/src/game.c @@ -2,32 +2,54 @@ // // See game.h for the transition diagram and game_init() / game_step() // documentation. Each state has an "enter" action (set the screen, -// reset the frame counter, reset per-state state) and a "step" action -// (check inputs, advance the frame counter, transition). +// reset per-state state, sample any randomness) and a "step" action +// (check inputs, compute elapsed frames against enter_frame, decide +// whether to transition). // -// In Phase 4 the main loop is `while (1) game_step();`. In Phase 5 -// the body of the main loop will be replaced by a raster IRQ handler -// that calls game_step() on line 311. Either way, game_step() is the -// only thing that needs to run "once per frame". +// In Phase 4 the main loop was `while (1) game_step();` and the +// per-state "frame counter" was just a count of how many times +// game_step had been called since entering the current state — so +// state durations were CPU-bound, not wall-clock-bound. +// +// In Phase 5 the busy-wait body of the main loop is gone. Instead, +// a raster IRQ at line 311 (PAL stable line) runs at exactly 50 Hz +// and calls game_step() once per frame. All state durations are +// now wall-clock-bound: a state that should last 60 frames lasts +// 60 × 20 ms = 1.2 seconds, regardless of what the CPU is doing +// between IRQs. The 16-bit global frame_count (incremented by the +// IRQ handler *before* calling game_step) is the new "frame +// counter"; per-state timing uses an enter_frame timestamp captured +// on entry. #include "game.h" #include "screens.h" #include "input.h" #include "score.h" #include +#include + +// --- globals ------------------------------------------------------------ // Current state. Set by game_init() and by the per-state step // functions when a transition is triggered. Read by the switch in // game_step(). static byte state; -// Frame counter for the current state. Reset to 0 by each per-state -// "enter" function. Compared against per-state constants in the step -// functions (60 for READY, 100 for WAIT and WIN, 300 for GAMEOVER, -// 500 for the DRAW fault timeout). unsigned short is enough for -// 65535 frames = ~22 minutes at 50 Hz; the longest single state is -// 500 frames (DRAW fault), so 16 bits is more than enough. -static unsigned short frame; +// Per-state "entered at" timestamp. Set by each per-state enter +// function to the current frame_count. The per-state step functions +// compute elapsed = frame_count - enter_frame to decide when to +// transition. 16 bits is enough for 65535 frames = ~22 minutes at +// 50 Hz; the longest single state is 500 frames (DRAW fault), so 16 +// bits is more than enough for a single state. +static unsigned short enter_frame; + +// WAIT random duration (in frames). Sampled in game_enter_ready from +// SID $D41B (the oscillator 3 register, effectively random). +// 100 + (sid.random % 150) gives a 2.0-5.0 second suspense window +// at 50 Hz. The randomness is sampled at READY enter (not WAIT +// enter) so the value is stable for the duration of READY (and the +// subsequent WAIT). +static unsigned short wait_duration_frames; // --- TITLE-specific input state ---------------------------------------- // @@ -56,237 +78,305 @@ static unsigned short title_first_frame; // entry to DRAW (in game_enter_draw()). static char draw_was_pressed[2]; +// --- fault stinger ----------------------------------------------------- +// +// When the DRAW state's 500-frame fault timeout fires, we trigger a +// short low-square-wave stinger on SID voice 1. This counter is +// decremented every frame in game_step() (not in any per-state step, +// so it cleans up even if we transition out of TITLE before the +// stinger would naturally end). When it reaches 0, we gate voice 1 +// off. ~10 frames = ~0.2 sec at 50 Hz. +static byte fault_stinger_ticks; + // --- per-state enter functions ----------------------------------------- // // Each "enter" function: // - calls show_screen() (or show_white_screen() for DRAW) // - calls score_render() -// - resets `frame` to 0 +// - sets enter_frame = frame_count (the per-state timestamp) // - resets any per-state state (e.g. title_input, draw_was_pressed) // - sets the border color (TITLE flashes it; DRAW is all-white; // other states are black) +// - samples the SID random for WAIT duration (in READY enter, not +// WAIT enter, per the Phase 5 spec) static void game_enter_title(void) { - show_screen(SCREEN_TITLE); - score_render(); - frame = 0; - title_input = TITLE_IDLE; - title_first_frame = 0; - // Border starts white (the "PRESS FIRE" prompt is visible). - vic.color_border = 1; + show_screen(SCREEN_TITLE); + score_render(); + enter_frame = frame_count; + title_input = TITLE_IDLE; + title_first_frame = 0; + // Border starts white (the "PRESS FIRE" prompt is visible). + vic.color_border = 1; } static void game_enter_ready(void) { - show_screen(SCREEN_WAITING1); - score_render(); - frame = 0; - vic.color_border = 0; + show_screen(SCREEN_WAITING1); + score_render(); + 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 + // 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); + vic.color_border = 0; } static void game_enter_wait(void) { - show_screen(SCREEN_WAITING2); - score_render(); - frame = 0; - vic.color_border = 0; + show_screen(SCREEN_WAITING2); + score_render(); + enter_frame = frame_count; + vic.color_border = 0; } static void game_enter_draw(void) { - show_white_screen(); - score_render(); - frame = 0; - vic.color_border = 1; // white border matches the white screen - draw_was_pressed[0] = 0; - draw_was_pressed[1] = 0; + show_white_screen(); + score_render(); + enter_frame = frame_count; + vic.color_border = 1; // white border matches the white screen + draw_was_pressed[0] = 0; + draw_was_pressed[1] = 0; } static void game_enter_win_p1(void) { - show_screen(SCREEN_WIN_HARE); - score_p1++; - score_render(); - frame = 0; - vic.color_border = 0; + show_screen(SCREEN_WIN_HARE); + score_p1++; + score_render(); + enter_frame = frame_count; + vic.color_border = 0; } static void game_enter_win_p2(void) { - show_screen(SCREEN_WIN_SCOOT); - score_p2++; - score_render(); - frame = 0; - vic.color_border = 0; + show_screen(SCREEN_WIN_SCOOT); + score_p2++; + score_render(); + enter_frame = frame_count; + vic.color_border = 0; } 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). - show_screen(SCREEN_TITLE); - score_p1 = 0; - score_p2 = 0; - score_render(); - frame = 0; - vic.color_border = 0; + // 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). + show_screen(SCREEN_TITLE); + score_p1 = 0; + score_p2 = 0; + score_render(); + enter_frame = frame_count; + vic.color_border = 0; +} + +// --- fault stinger helpers --------------------------------------------- + +static void game_trigger_fault_stinger(void) +{ + // Low square wave on voice 1. ~100 Hz is in the "low buzz" + // range, not a musical note — appropriate for an "aborted + // round" sting. Attack=0, decay=0, sustain=15 (max), release=0 + // means: the note is full volume the instant the gate goes on + // and stays full volume until gated off (no decay). We gate + // off after ~10 frames from game_step()'s cleanup counter. + sid.voices[1].freq = SID_FREQ_PAL(100); + sid.voices[1].attdec = 0x00; + sid.voices[1].susrel = 0xf0; + sid.voices[1].ctrl = SID_CTRL_RECT | SID_CTRL_GATE; + fault_stinger_ticks = 10; +} + +static void game_advance_fault_stinger(void) +{ + // Called once per frame from game_step(). Decrements the + // counter and gates voice 1 off when it reaches zero. Putting + // this here (rather than in game_step_title) means the stinger + // cleans up even if we leave TITLE during the 0.2 sec window. + if (fault_stinger_ticks > 0) { + fault_stinger_ticks--; + if (fault_stinger_ticks == 0) + sid.voices[1].ctrl = SID_CTRL_RECT; + } } // --- per-state step functions ------------------------------------------ static void game_step_title(void) { - // Flash the border at 25 Hz (toggle every 2 frames at 50 Hz). - // The visible effect is a 12.5 Hz blink on the border around the - // title screen image. Phase 8 will replace this with actual - // "PRESS FIRE" text rendered into the bitmap. - if ((frame & 1) == 0) - vic.color_border ^= 1; + // Flash the border at 25 Hz (toggle every 2 frames at 50 Hz). + // The visible effect is a 12.5 Hz blink on the border around the + // title screen image. Phase 8 will replace this with actual + // "PRESS FIRE" text rendered into the bitmap. + unsigned short elapsed = frame_count - enter_frame; + if ((elapsed & 1) == 0) + vic.color_border ^= 1; - char p1 = input_fire(1); // Hare (port 1) - char p2 = input_fire(0); // Scoot (port 0) - char both = p1 && p2; - char none = !p1 && !p2; + char p1 = input_fire(1); // Hare (port 1) + char p2 = input_fire(0); // Scoot (port 0) + char both = p1 && p2; + char none = !p1 && !p2; - switch (title_input) { - case TITLE_IDLE: - if (both) { - state = STATE_READY; - game_enter_ready(); - } else if (p1 || p2) { - title_input = TITLE_FIRST_HELD; - title_first_frame = frame; - } - break; + switch (title_input) { + case TITLE_IDLE: + if (both) { + state = STATE_READY; + game_enter_ready(); + } else if (p1 || p2) { + title_input = TITLE_FIRST_HELD; + title_first_frame = frame_count; + } + break; - case TITLE_FIRST_HELD: - if (both) { - if (frame - title_first_frame <= 8) { - state = STATE_READY; - game_enter_ready(); - } else { - title_input = TITLE_WAIT_RELEASE; - } - } else if (none) { - title_input = TITLE_IDLE; - } else if (frame - title_first_frame > 8) { - // Held too long without the other button following. - title_input = TITLE_WAIT_RELEASE; - } - // else: one still held, other not yet, within 8 frames. - break; + case TITLE_FIRST_HELD: + if (both) { + if (frame_count - title_first_frame <= 8) { + state = STATE_READY; + game_enter_ready(); + } else { + title_input = TITLE_WAIT_RELEASE; + } + } else if (none) { + title_input = TITLE_IDLE; + } else if (frame_count - title_first_frame > 8) { + // Held too long without the other button following. + title_input = TITLE_WAIT_RELEASE; + } + // else: one still held, other not yet, within 8 frames. + break; - case TITLE_WAIT_RELEASE: - if (none) { - title_input = TITLE_IDLE; - } - break; - } + case TITLE_WAIT_RELEASE: + if (none) { + title_input = TITLE_IDLE; + } + break; + } } static void game_step_ready(void) { - if (frame >= 60) { - state = STATE_WAIT; - game_enter_wait(); - } + if (frame_count - enter_frame >= 60) { + state = STATE_WAIT; + game_enter_wait(); + } } static void game_step_wait(void) { - // Phase 4: fixed 100 frames. Phase 5: random 100-250 from - // PEEK(0xD41B) sampled on READY enter. - if (frame >= 100) { - state = STATE_DRAW; - game_enter_draw(); - } + // Random 100..250 frames, sampled in game_enter_ready. We + // intentionally do NOT read input here — pressing fire during + // WAIT is "cheating" and the game ignores it (GAME.md §9). The + // DRAW state still does rising-edge detection, so any fire + // pressed here is not remembered. + if (frame_count - enter_frame >= wait_duration_frames) { + state = STATE_DRAW; + game_enter_draw(); + } } 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 and go back to TITLE. - if (frame > 500) { - state = STATE_TITLE; - game_enter_title(); - return; - } + unsigned short elapsed = frame_count - enter_frame; - // First to fire wins. Rising-edge detection so holding fire from - // before DRAW doesn't auto-trigger a win (e.g. if the player - // presses during WAIT and keeps it held). P1 wins ties (matches - // the "fire1 first" branch in GAME.md §9). - char now_p1 = input_fire(1); - char now_p2 = input_fire(0); - char edge_p1 = now_p1 && !draw_was_pressed[0]; - char edge_p2 = now_p2 && !draw_was_pressed[1]; - draw_was_pressed[0] = now_p1; - draw_was_pressed[1] = now_p2; + // 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) { + game_trigger_fault_stinger(); + state = STATE_TITLE; + game_enter_title(); + return; + } - if (edge_p1) { - state = STATE_WIN_P1; - game_enter_win_p1(); - } else if (edge_p2) { - state = STATE_WIN_P2; - game_enter_win_p2(); - } + // First to fire wins. Rising-edge detection so holding fire from + // before DRAW doesn't auto-trigger a win (e.g. if the player + // presses during WAIT and keeps it held). P1 wins ties (matches + // the "fire1 first" branch in GAME.md §9). + char now_p1 = input_fire(1); + char now_p2 = input_fire(0); + char edge_p1 = now_p1 && !draw_was_pressed[0]; + char edge_p2 = now_p2 && !draw_was_pressed[1]; + draw_was_pressed[0] = now_p1; + draw_was_pressed[1] = now_p2; + + if (edge_p1) { + state = STATE_WIN_P1; + game_enter_win_p1(); + } else if (edge_p2) { + state = STATE_WIN_P2; + game_enter_win_p2(); + } } static void game_step_win_p1(void) { - if (frame >= 100) { - if (score_p1 >= 5) { - state = STATE_GAMEOVER; - game_enter_gameover(); - } else { - state = STATE_READY; - game_enter_ready(); - } - } + if (frame_count - enter_frame >= 100) { + if (score_p1 >= 5) { + state = STATE_GAMEOVER; + game_enter_gameover(); + } else { + state = STATE_READY; + game_enter_ready(); + } + } } static void game_step_win_p2(void) { - if (frame >= 100) { - if (score_p2 >= 5) { - state = STATE_GAMEOVER; - game_enter_gameover(); - } else { - state = STATE_READY; - game_enter_ready(); - } - } + if (frame_count - enter_frame >= 100) { + if (score_p2 >= 5) { + state = STATE_GAMEOVER; + game_enter_gameover(); + } else { + state = STATE_READY; + game_enter_ready(); + } + } } static void game_step_gameover(void) { - if (frame >= 300) { - state = STATE_TITLE; - game_enter_title(); - } + if (frame_count - enter_frame >= 300) { + state = STATE_TITLE; + game_enter_title(); + } } // --- public API -------------------------------------------------------- +// Definition of the global frame_count declared in game.h. Lives in +// BSS so it's zero at startup; the raster IRQ handler increments it. +volatile unsigned short frame_count; + void game_init(void) { - state = STATE_TITLE; - game_enter_title(); + state = STATE_TITLE; + // enter_frame is set by game_enter_title below; the very first + // game_step() call happens in the same raster IRQ that + // increments frame_count from 0 to 1, so enter_frame=0 there + // is fine (elapsed = 1 on the first call). + game_enter_title(); } void game_step(void) { - frame++; + // Per-frame work that must happen regardless of state: tick down + // the fault-stinger counter and gate voice 1 off when it + // reaches zero. Doing this here (not in any per-state step) + // guarantees the stinger cleans up even if we leave TITLE + // during its 0.2 sec lifetime. + game_advance_fault_stinger(); - switch (state) { - case STATE_TITLE: game_step_title(); break; - case STATE_READY: game_step_ready(); break; - case STATE_WAIT: game_step_wait(); break; - case STATE_DRAW: game_step_draw(); break; - case STATE_WIN_P1: game_step_win_p1(); break; - case STATE_WIN_P2: game_step_win_p2(); break; - case STATE_GAMEOVER: game_step_gameover(); break; - } + switch (state) { + case STATE_TITLE: game_step_title(); break; + case STATE_READY: game_step_ready(); break; + case STATE_WAIT: game_step_wait(); break; + case STATE_DRAW: game_step_draw(); break; + case STATE_WIN_P1: game_step_win_p1(); break; + case STATE_WIN_P2: game_step_win_p2(); break; + case STATE_GAMEOVER: game_step_gameover(); break; + } } diff --git a/src/game.h b/src/game.h index 720c647..2df495e 100644 --- a/src/game.h +++ b/src/game.h @@ -8,7 +8,8 @@ // // STATE_TITLE — title screen, flashing border, wait for both fire // STATE_READY — "ready" screen, 60 frames (1.2 s at 50 Hz) -// STATE_WAIT — "wait" screen, 100 frames (2 s; Phase 5: random) +// STATE_WAIT — "wait" screen, 100..250 frames (random, from +// SID $D41B sampled on READY enter) // STATE_DRAW — white screen, wait for first fire (or 500-frame // fault timeout) // STATE_WIN_P1 — "Hare won" screen, 100 frames, +1 to player 1 @@ -19,7 +20,7 @@ // // TITLE --both fire (within 8 frames)--> READY // READY --60 frames--------------------> WAIT -// WAIT --100 frames-------------------> DRAW +// WAIT --random 100..250 frames------> DRAW // DRAW --port 1 fire (rising edge)---> WIN_P1 // DRAW --port 0 fire (rising edge)---> WIN_P2 // DRAW --500 frames (fault)----------> TITLE @@ -28,6 +29,13 @@ // WIN_P2 --100 frames + score < 5-----> READY // WIN_P2 --100 frames + score == 5----> GAMEOVER // GAMEOVER --300 frames (scores reset)--> TITLE +// +// Timing: the per-state frame counter is replaced (Phase 5) by a +// 16-bit global frame_count incremented by the raster IRQ at 50 Hz +// (line 311). Per-state step actions compute elapsed = +// frame_count - enter_frame to decide when to transition. + +#include #define STATE_TITLE 0 #define STATE_READY 1 @@ -37,23 +45,36 @@ #define STATE_WIN_P2 5 #define STATE_GAMEOVER 6 +// frame_count — global 50 Hz frame counter. Incremented by the +// raster IRQ handler in rasterirq.c (see rasterirq.h) once per +// frame, *before* calling game_step(). So at the time game_step() +// runs, frame_count is the current frame number (1 on the first +// call after power-on, 2 on the second, etc.). +// +// Marked volatile because the IRQ handler is the writer and the +// game-step functions are the readers. 16 bits = 65535 frames = +// ~22 minutes at 50 Hz; the longest single state is 500 frames +// (DRAW fault), so 16 bits is plenty for one state. We never read +// frame_count from the main loop (the main loop is `while (1) {}` +// in Phase 5) so torn-read races are not a concern. +extern volatile unsigned short frame_count; + // game_init() — set up the state machine and enter the TITLE state. -// Call once at startup, after memmap_setup() and score_init(). -// Calls show_screen(SCREEN_TITLE) and score_render() as part of the -// TITLE entry action. +// Call once at startup, after memmap_setup() and score_init() and +// rasterirq_setup(). Calls show_screen(SCREEN_TITLE) and +// score_render() as part of the TITLE entry action. void game_init(void); -// game_step() — advance the state machine by one frame. -// -// In Phase 4 the main loop calls this in a tight busy-wait. In -// Phase 5 a raster IRQ handler at line 311 will call it at 50 Hz. -// Per-state step actions read both fire buttons, advance the per-state -// frame counter, and trigger state transitions (each transition calls -// the new state's "enter" action immediately, so the next step -// operates on the new state). +// game_step() — advance the state machine by one frame. Called from +// the raster IRQ handler at line 311. Per-state step actions read +// both fire buttons, compute elapsed frames against the per-state +// enter_frame timestamp, and trigger state transitions (each +// transition calls the new state's "enter" action immediately, so +// the next step operates on the new state). // // game_step() also drives per-state visual updates that need to run -// every frame (currently just the TITLE border flash at 25 Hz). +// every frame (currently just the TITLE border flash at 25 Hz and +// the fault-stinger audio gate-off counter). void game_step(void); #pragma compile("game.c") diff --git a/src/main.c b/src/main.c index efc2596..f120bc2 100644 --- a/src/main.c +++ b/src/main.c @@ -1,4 +1,4 @@ -// main.c — Whack Hare! entry point (Phase 4: state machine skeleton). +// main.c — Whack Hare! entry point (Phase 5: raster IRQ + idle loop). // // Flow: // 1. memmap_setup() — bank out KERNAL/BASIC/CHAR ROM. @@ -6,21 +6,23 @@ // 3. game_init() — enter the TITLE state (which also loads // the title screen and renders the score // bar). -// 4. while (1) game_step() — the state machine runs forever. +// 4. rasterirq_setup() — install the single RIRQ at line 311. +// The IRQ handler increments frame_count +// and calls game_step() once per frame. +// 5. while (1) {} — idle. All per-frame work happens in +// the IRQ handler. // -// In Phase 4 this is a busy-wait loop: game_step() is called in a -// tight loop with no real timing. The "frame counter" inside game.c -// is just a count of how many times game_step() has been called, so -// the state durations are CPU-bound, not wall-clock-bound. -// -// In Phase 5 the busy-wait body of the main loop becomes a raster -// IRQ handler that runs at 50 Hz, and game_step() is called from -// the IRQ. The function signature doesn't change — only the call -// site does — so the game logic is the same in both phases. +// In Phase 4 step 5 was `while (1) game_step();` — a busy-wait that +// called game_step as fast as the CPU could, so the "frame counter" +// was CPU-bound. In Phase 5 the busy-wait is gone: game_step is +// called from the raster IRQ at exactly 50 Hz, so state durations +// are wall-clock-bound (60 frames = 1.2 sec, etc.) regardless of +// what the CPU is doing between IRQs. #include "memmap.h" #include "game.h" #include "score.h" +#include "tick.h" // We don't malloc, so the heap is unused. Setting it to 0 frees the // space for the screen data in the main region. @@ -31,9 +33,12 @@ int main(void) memmap_setup(); score_init(); game_init(); + rasterirq_setup(); - while (1) - game_step(); + // The raster IRQ does all the per-frame work. The main loop + // is a deliberate spin: nothing to do between IRQs. + for (;;) + ; return 0; } diff --git a/src/tick.c b/src/tick.c new file mode 100644 index 0000000..96d79a0 --- /dev/null +++ b/src/tick.c @@ -0,0 +1,125 @@ +// tick.c — install a single raster IRQ at line 311 (PAL stable +// line) that calls the per-frame game tick handler. +// +// The Oscar64 rasterirq library provides the boilerplate: ISR stub, +// table management, sort, and start. We add the two C64-specific +// bits the library doesn't do for us when running with KERNAL banked +// out: +// +// 1. Mask CIA 1 and CIA 2 IRQs (write 0x7F to $DC0D/$DD0D). This +// stops the jiffy-clock Timer A from latching an IRQ that would +// fire the moment we RTI out of the raster IRQ. (The rirq +// library only does this for the *_kernal() init paths, which +// call into the KERNAL ISR at $EA31 to acknowledge; we can't +// because KERNAL is banked out.) +// +// 2. Use rirq_init(false), which installs the rirq_isr_ram_io +// handler at the hardware IRQ vector ($FFFE/$FFFF). This +// handler does NOT call into KERNAL — it acks the raster IRQ +// (asl $d019) and returns directly via rti. Combined with the +// CIA mask, this means the only IRQ we ever service is the +// raster IRQ. +// +// The RIRQ code is a single "wait for line, then JSR frame_tick_handler, +// RTS" stub. We use rirq_call() to install the JSR. +// +// **PAL line 311 and the 9-bit raster counter.** PAL frames are 312 +// lines (0..311), so the stable line 311 is outside the 8-bit +// $D012 range. The VIC's raster register is 9 bits: the high bit +// is bit 7 of $D011 (VIC_CTRL1_RST8), and the low 8 bits are $D012. +// The oscar64 rirq library's `rirq_set(n, row, code)` takes a `byte +// row` (0..255) and writes (row - 1) to $D012 internally, so it +// cannot directly address line 311. The workaround is: +// +// - Pass row = 56 to rirq_set (i.e. 311 - 256 + 1, where the +1 +// accounts for the library's "one line below" convention). +// - Set VIC_CTRL1_RST8 = 1 after rirq_sort and BEFORE enabling +// CPU IRQ, so the raster comparison becomes (1 << 8) | 55 = 311. +// +// We can't use rirq_start() because it clears RST8 and overwrites +// $D012 with 100 (a "kick start" line that lets the first IRQ fire +// quickly). Instead we do the equivalent of rirq_start inline, with +// RST8 left at 1 and $D012 left at the rirq_sort value. +// +// **File naming.** This file is named tick.c (not rasterirq.c) +// because the oscar64 library's rasterirq.c does `#include +// "rasterirq.h"` to find its own rasterirq.h, and that include +// resolves relative to the compile CWD. If our header were also +// named rasterirq.h, the library would pick up ours and the +// NUM_IRQS / RIRQCode defines would be missing. The naming is +// purely a workaround for the library's include style. + +#include "tick.h" +#include "game.h" +#include +#include +#include + +// The single RIRQ code slot. One IRQ = one wait + one JSR + one RTS. +static RIRQCode frame_tick; + +// The per-frame handler, called from the raster IRQ at line 311. +// +// Marked __interrupt so the compiler saves/restores any zero-page +// registers the function (or game_step) uses. This matches the +// autocrawler.c pattern in the oscar64 samples. Note that this +// function is NOT the 6502 ISR — the rirq_isr_ram_io stub installed +// by rirq_init is the actual ISR. This function is called via JSR +// from the rirq_isr, and returns with RTS. A/X/Y are saved by the +// rirq_isr, so we can clobber them freely. +__interrupt void frame_tick_handler(void) +{ + frame_count++; + game_step(); +} + +void rasterirq_setup(void) +{ + // 1. Mask all CIA 1 and CIA 2 interrupt sources. The ICR at + // $DC0D/$DD0D is a set/clear register: bit 7 = 0 means + // "clear", bits 0-4 = 0x1F means "clear all source mask + // bits". Writing 0x7F disables every source. A second + // write acknowledges any latched IRQ; reading would do the + // same but writing is fine. + cia1.icr = 0x7f; + cia2.icr = 0x7f; + cia1.icr = 0x7f; + cia2.icr = 0x7f; + + // 2. Install the raster IRQ system. false = use the hardware + // IRQ vector at $FFFE, no KERNAL continuation (KERNAL is + // banked out by memmap_setup). + rirq_init(false); + + // 3. Build the RIRQ code: a single call to frame_tick_handler. + // size=1 = one op slot. rirq_call at index 0 replaces the + // STY $xxxx stub at offset 9 with a JSR frame_tick_handler. + // The resulting code is: wait + JSR handler + RTS. + rirq_build(&frame_tick, 1); + rirq_call(&frame_tick, 0, frame_tick_handler); + + // 4. Place this RIRQ at line 56 in the rirq library's 8-bit + // view. Combined with RST8=1 (set in step 6 below) the + // actual raster comparison becomes 256 + (56 - 1) = 311. + rirq_set(0, 56, &frame_tick); + + // 5. Sort the RIRQ list. This also writes $D012 = 56 - 1 = 55 + // (the low byte of the 9-bit row 311) and sets nextIRQ = 0. + rirq_sort(); + + // 6. Set the high bit of the 9-bit raster register. This + // makes the VIC compare the raster counter against 256 + 55 + // = 311 instead of just 55. Must happen before we enable + // CPU IRQ (CLI), otherwise the first IRQ might fire at the + // wrong line. + vic.ctrl1 |= VIC_CTRL1_RST8; + + // 7. Start the raster IRQ. We can't use rirq_start() because + // it would clear RST8 and overwrite $D012 with 100. Instead + // we do the same thing minus those two writes: acknowledge + // any pending VIC IRQ, then enable CPU IRQ. + __asm { + asl $d019 + cli + } +} diff --git a/src/tick.h b/src/tick.h new file mode 100644 index 0000000..2777cd5 --- /dev/null +++ b/src/tick.h @@ -0,0 +1,32 @@ +#ifndef WHACK_HARE_TICK_H +#define WHACK_HARE_TICK_H + +// tick.h — 50 Hz raster IRQ setup for the game tick. +// +// A single RIRQ is installed at raster line 311 (PAL stable line, right +// after vertical blank, before any badlines). The IRQ handler runs +// once per frame and does two things: +// +// 1. Increments the global 16-bit frame_count (50 Hz). +// 2. Calls game_step() — the state machine + per-state step actions +// read both joysticks, update the score bar, and run the +// flashing-text animations. +// +// All game timing is now driven by frame_count comparisons. No more +// busy-wait. The main() loop is an empty `while (1) {}`. +// +// CIA 1 and CIA 2 IRQs are masked at setup so the jiffy-clock handler +// doesn't fire nested inside the raster IRQ. We bank out the KERNAL +// at startup (memmap_setup) so we use rirq_init(false) — the hardware +// IRQ vector, no KERNAL continuation. +// +// (We can't name this file "rasterirq.h" because the oscar64 +// library's rasterirq.c does `#include "rasterirq.h"` to find its +// own rasterirq.h, and that include would resolve to our header +// instead — see the comment in tick.c for details.) + +void rasterirq_setup(void); + +#pragma compile("tick.c") + +#endif