diff --git a/src/audio.c b/src/audio.c index 414a651..eb4a8d5 100644 --- a/src/audio.c +++ b/src/audio.c @@ -37,6 +37,12 @@ // (SID_FREQ_PAL(100), sustain=15). Triggered from game_step_draw() // AFTER game_enter_title() (so the state transition doesn't silence // it). Cleans itself up via audio_state_step(). +// +// **Transition stinger.** A 5-frame low square-wave burst played on +// every state transition. Uses audio_play_stinger(voice, duration) +// with a per-state voice chosen to avoid colliding with the new +// state's audio (see the comments in game.c for the per-state voice +// mapping). Also cleans itself up via audio_state_step(). #include "audio.h" #include "notes.h" @@ -62,6 +68,13 @@ static unsigned short audio_step; // that uses voice 1 (WAIT, WIN, GAMEOVER). static byte fault_stinger_ticks; +// Generic stinger counter. Set by audio_play_stinger(voice, duration); +// cleared by audio_state_enter() (so a transition stinger doesn't +// leak into the next state). audio_state_step() decrements it once +// per frame and gates the stinger's voice off when it hits 0. +static byte stinger_ticks; +static byte stinger_voice; + // --- public API --------------------------------------------------------- void audio_init(void) @@ -76,6 +89,8 @@ void audio_init(void) audio_state = AUDIO_STATE_NONE; audio_step = 0; fault_stinger_ticks = 0; + stinger_ticks = 0; + stinger_voice = 0; // Silence all 3 voices to clear any leftover SID state. audio_stop(); @@ -146,6 +161,20 @@ static void audio_advance_fault_stinger(void) } } +// Per-frame transition-stinger tick. Same shape as the fault +// stinger tick, but operates on the voice picked by +// audio_play_stinger(). Runs independently of the fault stinger +// (the two can overlap, e.g. a state transition that happens to +// coincide with a fault). +static void audio_advance_stinger(void) +{ + if (stinger_ticks > 0) { + stinger_ticks--; + if (stinger_ticks == 0) + sid.voices[stinger_voice].ctrl = SID_CTRL_RECT; + } +} + void audio_play_fault_stinger(void) { // Low square wave on voice 1. ~100 Hz is in the "low buzz" @@ -162,6 +191,27 @@ void audio_play_fault_stinger(void) fault_stinger_ticks = 10; } +void audio_play_stinger(int voice, int duration) +{ + // Brief low square-wave stinger on the given voice. Used on + // every state transition (5 frames = 0.1 sec at 50 Hz). The + // voice must be chosen to avoid colliding with the new state's + // audio (see game.c for the per-state voice mapping). Cleans + // itself up via audio_advance_stinger() (called from + // audio_state_step()). + // + // Will be silenced by the NEXT audio_state_enter() call (i.e. + // the next state transition) via the audio_stop() it does + // internally, so a long stinger can't bleed across states. + sid.voices[voice].freq = SID_FREQ_PAL(100); + sid.voices[voice].pwm = 0x0800; + sid.voices[voice].attdec = SID_ATK_2; + sid.voices[voice].susrel = 0xf0; + sid.voices[voice].ctrl = SID_CTRL_RECT | SID_CTRL_GATE; + stinger_voice = (byte)voice; + stinger_ticks = (byte)duration; +} + void audio_state_enter(int state) { // First silence the SID — this is what prevents the WAIT loop @@ -169,6 +219,7 @@ void audio_state_enter(int state) // stinger when transitioning to a state that uses voice 1. audio_stop(); fault_stinger_ticks = 0; + stinger_ticks = 0; audio_step = 0; audio_state = (byte)state; @@ -284,10 +335,12 @@ void audio_state_enter(int state) void audio_state_step(int state) { // Per-frame work that must happen regardless of state: tick - // down the fault-stinger counter. Doing this here (not in any - // per-state step) means the stinger cleans up even if we - // transition out of TITLE during the 0.2s window. + // down the fault-stinger counter and the transition-stinger + // counter. Doing this here (not in any per-state step) means + // the stingers clean up even if we transition out of the + // current state during the stinger's window. audio_advance_fault_stinger(); + audio_advance_stinger(); // If the audio layer hasn't been initialized for this state // yet (e.g. audio_state_enter() wasn't called between diff --git a/src/audio.h b/src/audio.h index c8ede46..2b45558 100644 --- a/src/audio.h +++ b/src/audio.h @@ -48,6 +48,13 @@ void audio_state_step(int state); // isn't immediately silenced by the state transition). void audio_play_fault_stinger(void); +// audio_play_stinger(voice, duration) — start a low square-wave +// stinger on the given voice for the given number of frames. Called +// on every state transition by game_enter_X() (5 frames = 0.1 sec). +// The voice must be chosen to avoid colliding with the new state's +// audio — see game.c for the per-state voice mapping. +void audio_play_stinger(int voice, int duration); + #pragma compile("audio.c") #endif diff --git a/src/banner.c b/src/banner.c new file mode 100644 index 0000000..a4ba0c5 --- /dev/null +++ b/src/banner.c @@ -0,0 +1,112 @@ +// banner.c — text banner rendering in the multicolor bitmap. +// +// The banner renderer draws a NUL-terminated ASCII string in a +// character row of the active multicolor bitmap (at $E000+). Each +// character is 4 px wide × 8 px tall = 8 bytes of bitmap, the same +// as the score bar's labels. The font is the 16-entry table in +// score.c; banner.c reaches into score.c's font via an extern +// declaration in score.h. +// +// banner_render(text, row) centers the text in the row's 40 cells. +// The first cell of row N is at byte offset N*320 in the bitmap +// (40 cells × 8 bytes/cell = 320 bytes per row). Color RAM for +// the row is set to 1 (white) so the "11" pixel code used by the +// glyphs maps to white. Screen memory for the row is set to 0 so +// the "01" and "10" pixel codes (which we never write) are harmless +// black. +// +// Characters not in the font render as a blank cell. Strings longer +// than 40 chars are truncated (the rightmost chars are dropped). +// Empty strings are no-ops (call banner_clear() to explicitly clear). + +#include "banner.h" +#include "score.h" + +// Extern the font from score.c. We could go through score_render() +// 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]; + +// 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 +// "0" becomes "00". This is the standard multicolor bitmap byte +// encoding for 2 bpp. +static const char expand4[16] = { + 0x00, 0x03, 0x0C, 0x0F, 0x30, 0x33, 0x3C, 0x3F, + 0xC0, 0xC3, 0xCC, 0xCF, 0xF0, 0xF3, 0xFC, 0xFF +}; + +// Bitmap base, color RAM base, screen memory base. Same as score.c +// — duplicated here so banner.c is self-contained. +#define BM_BASE ((char *)0xE000) +#define CRAM_BASE ((char *)0xD800) +#define SMEM_BASE ((char *)0xD000) +#define CELLS_PER_ROW 40 +#define BYTES_PER_CELL 8 +#define BYTES_PER_ROW 320 + +// banner_clear(row) — fill the given row of the bitmap with 0s +// (black "00" pixels via $D021) and zero the color RAM for that +// row's cells. Use this before rendering a banner into a row that +// might have leftover pixels from a previous banner or from the +// screen art. +void banner_clear(byte row) +{ + char *bm_row = BM_BASE + (unsigned)row * BYTES_PER_ROW; + char *cram_row = CRAM_BASE + (unsigned)row * CELLS_PER_ROW; + for (unsigned i = 0; i < BYTES_PER_ROW; i++) + bm_row[i] = 0; + for (unsigned i = 0; i < CELLS_PER_ROW; i++) + cram_row[i] = 0; +} + +// Render a single glyph at the given cell position. Writes 8 bytes +// to the bitmap and sets the color RAM for that cell to 1 (white). +// char_idx is a font index (0..FONT_COUNT-1); out-of-range indices +// produce a blank cell. +static void render_glyph(byte cell, byte char_idx) +{ + if (char_idx >= FONT_COUNT) + char_idx = CHAR_RESERVED; // blank for unknown glyphs + + char *dst = BM_BASE + (unsigned)cell * BYTES_PER_CELL; + const char *src = font[char_idx]; + for (unsigned row = 0; row < BYTES_PER_CELL; row++) { + unsigned nibble = ((unsigned)src[row] >> 4) & 0x0F; + dst[row] = expand4[nibble]; + } + CRAM_BASE[cell] = 1; // "11" pixels = white + SMEM_BASE[cell] = 0; // "01"/"10" pixels = black (unused) +} + +// String length helper. NUL-terminated ASCII, max 40 chars (the +// width of one row in cells). Longer strings are truncated. +static byte strnlen40(const char *s) +{ + byte n = 0; + while (n < 40 && s[n] != 0) + n++; + return n; +} + +void banner_render(const char *text, byte row) +{ + // Truncate to 40 chars (the row width). + byte len = strnlen40(text); + if (len == 0) + return; // empty string: leave the row untouched + + // Clear the row first so leftover pixels from the screen art + // (or a previous banner) don't show through between glyphs. + banner_clear(row); + + // Center the text: start_cell = (40 - len) / 2. + byte start = (40 - len) >> 1; + unsigned row_offset = (unsigned)row * CELLS_PER_ROW; + + for (byte i = 0; i < len; i++) { + byte idx = font_lookup(text[i]); + render_glyph(row_offset + start + i, idx); + } +} diff --git a/src/banner.h b/src/banner.h new file mode 100644 index 0000000..3dfbd66 --- /dev/null +++ b/src/banner.h @@ -0,0 +1,52 @@ +#ifndef WHACK_HARE_BANNER_H +#define WHACK_HARE_BANNER_H + +// banner.h — text banner rendering in the multicolor bitmap. +// +// The banner renderer draws a NUL-terminated ASCII string using the +// same 4x8 custom font as the score bar (see score.c, FONT_COUNT +// entries). The string is rendered in a single character row of the +// bitmap at the cell position passed to banner_render(); cells use +// 4 px width and 8 px height (one cell = 8 bytes of bitmap). +// +// Banner rows currently used by the game: +// Row 0 (cells 0..39) = score bar (HARE/SCOOT labels + pips). +// Banners that go HERE overwrite the score +// bar — score_render() must be called first +// or banners must use a different row. +// Row 1 (cells 40..79) = subtitle area. The title screen art +// is partially overwritten by banners here. +// Used by the "PRESS FIRE" prompt on TITLE +// and the "HARE WINS!" / "SCOOT WINS!" +// banner on GAMEOVER. +// Rows 2..24 = the rest of the screen art. Banners +// can be placed in any of these rows. +// +// banner_render() overwrites only the cells it writes to; the rest +// of the screen is untouched. Cells touched by banner_render() have +// their color RAM set to 1 (white) so the "11" pixel code maps to +// white on the (black) background. +// +// The banner font is 4 px wide; 10-char strings like "PRESS FIRE" +// or "HARE WINS!" fit comfortably in a 40-cell row. 11-char strings +// like "SCOOT WINS!" also fit (with 1 cell to spare). + +#include + +// banner_clear(row) — fill the given character row (0..24) of the +// bitmap with 0s and clear the color RAM. Used to erase a banner +// before switching to a screen that should not show it. +void banner_clear(byte row); + +// banner_render(text, row) — render a NUL-terminated ASCII string +// centered in the given character row of the bitmap. The string is +// truncated to 40 characters; anything past that is ignored. Any +// char not in the font renders as a blank cell (no visible glyph). +// +// If text is empty (""), the row is left untouched (call +// banner_clear() to explicitly clear it). +void banner_render(const char *text, byte row); + +#pragma compile("banner.c") + +#endif diff --git a/src/build.sh b/src/build.sh index 1c785b4..1943acf 100755 --- a/src/build.sh +++ b/src/build.sh @@ -2,11 +2,16 @@ # build.sh — compile and optionally run a single C64 program with Oscar64. # # Usage: -# ./build.sh # compile main.c → build/whack_hare.prg +# ./build.sh # compile main.c → build/whack_hare.prg (-O1) # ./build.sh -e # run in oscar64's built-in emulator (headless, fast) # ./build.sh -v # run in VICE x64 (interactive, needs a display) # ./build.sh -V # run in VICE x64sc (interactive, cycle-exact, slow) # ./build.sh -c # just compile +# ./build.sh -O0 # compile with -O0 (no optimization; debug build) +# ./build.sh -O2 # compile with -O2 (more aggressive inlining) +# ./build.sh -O3 # compile with -O3 (release build; auto-ZP, outliner) +# ./build.sh -Os # compile with -Os (optimize for size) +# ./build.sh -g # compile with -g (adds source-level debug info) # # Output (in ./build/): # whack_hare.prg — loadable C64 program (run with x64, VICE, or real hw) @@ -18,6 +23,20 @@ # without a display, needs no ROMs, and is faster than VICE. VICE is for # interactive use in a real terminal session, or for cycle-exact validation # of raster IRQ and SID timing — which requires a working X display. +# +# Optimization: the default is oscar64's default (-O1). Pass -O3 to +# produce a release build (auto-zero-page, outliner, aggressive inlining). +# The release build is functionally identical to the default build on +# cycle-accurate timings — the audio and frame counters are still +# 50 Hz because they use the raster IRQ, not CPU-bound loops. +# +# Both builds pin `#pragma stacksize(0x400)` in main.c. At -O3 the +# code section is ~70% larger, so the data section's spillover into +# the default stack/heap gap region leaves only ~0x400 bytes for +# both — anything larger fails to link. 1 KB is enough because +# the oscar64 software stack lives in zero-page (0xF7-0xFF); the +# spillover area only needs to hold the few locals/params that +# don't fit in ZP. set -e @@ -51,6 +70,7 @@ fi # needed). Default is the real game. SRC=main.c EMU_CMD="" +OPT_FLAGS="" for arg in "$@"; do case "$arg" in @@ -58,6 +78,12 @@ for arg in "$@"; do -v) EMU_CMD="x64" ;; # VICE standard -V) EMU_CMD="x64sc" ;; # VICE cycle-exact -c) ;; # explicit compile-only + -O0) OPT_FLAGS="$OPT_FLAGS -O0" ;; + -O1) OPT_FLAGS="$OPT_FLAGS -O1" ;; + -O2) OPT_FLAGS="$OPT_FLAGS -O2" ;; + -O3) OPT_FLAGS="$OPT_FLAGS -O3" ;; + -Os) OPT_FLAGS="$OPT_FLAGS -Os" ;; + -g) OPT_FLAGS="$OPT_FLAGS -g" ;; -*) echo "unknown flag: $arg" >&2; exit 1 ;; esac done @@ -65,7 +91,7 @@ done echo "compiling $SRC with $OSCAR64_BIN -> $BUILD_DIR/" # -o puts the .prg in the build dir; the other artifacts (.asm, .map, .lbl) # follow automatically since they share the base name. -"$OSCAR64_BIN" -i="$OSCAR64_DIR/include" -o="$BUILD_DIR/whack_hare.prg" "$SRC" +"$OSCAR64_BIN" -i="$OSCAR64_DIR/include" -o="$BUILD_DIR/whack_hare.prg" $OPT_FLAGS "$SRC" case "$EMU_CMD" in "") @@ -73,7 +99,7 @@ case "$EMU_CMD" in ;; "oscar64") echo "running whack_hare.prg in oscar64's built-in emulator" - "$OSCAR64_BIN" -i="$OSCAR64_DIR/include" -o="$BUILD_DIR/whack_hare.prg" -e "$SRC" + "$OSCAR64_BIN" -i="$OSCAR64_DIR/include" -o="$BUILD_DIR/whack_hare.prg" $OPT_FLAGS -e "$SRC" ;; "x64"|"x64sc") if ! command -v "$EMU_CMD" >/dev/null 2>&1; then diff --git a/src/game.c b/src/game.c index d2737b8..2d27c68 100644 --- a/src/game.c +++ b/src/game.c @@ -26,6 +26,7 @@ #include "screens.h" #include "input.h" #include "score.h" +#include "banner.h" #include "draw.h" #include #include @@ -95,6 +96,46 @@ static unsigned short draw_counter; // cleanup logic lives in audio.c (audio_advance_fault_stinger, called // from audio_state_step). See audio.c for details. +// --- transition stinger voice mapping ---------------------------------- +// +// The state-transition stinger is a 5-frame low square wave played +// on every game_enter_X() call (after audio_state_enter() so the +// new state's audio isn't immediately silenced). Each state picks +// a voice that is FREE in the new state so the stinger doesn't +// collide with the new state's main cue: +// +// 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_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) +// +// (Fault stinger always uses voice 1; it fires only in TITLE so +// voice 1 is always free there.) +#define STINGER_DURATION 5 + +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_DRAW: return 0; + case STATE_WIN_P1: + case STATE_WIN_P2: return 2; + case STATE_GAMEOVER: return 2; + default: return 0; + } +} + +// --- last winner -------------------------------------------------------- +// +// Set by game_enter_win_p1/p2 to the winning player (1 or 2). Used +// by game_enter_gameover to pick the "HARE WINS!" or "SCOOT WINS!" +// banner text. Static because only game.c cares about it. +static byte last_winner; + // --- per-state enter functions ----------------------------------------- // // Each "enter" function: @@ -122,18 +163,31 @@ static void game_enter_title(void) { show_screen(SCREEN_TITLE); 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 + // stays visible after any state that may have overwritten the + // row (only GAMEOVER writes row 1, but show_screen() re-loads + // the full title bitmap which overwrites it with the title + // art — so we always re-render the prompt here). + banner_render("PRESS FIRE", 1); enter_frame = frame_count; title_input = TITLE_IDLE; title_first_frame = 0; // Border starts white (the "PRESS FIRE" prompt is visible). + // The per-frame flash in game_step_title() toggles it at 1 Hz + // (50 frames on, 50 frames off). vic.color_border = 1; audio_state_enter(STATE_TITLE); + // 5-frame transition stinger on voice 0 (free in TITLE). + audio_play_stinger(stinger_voice_for_state(STATE_TITLE), STINGER_DURATION); } static void game_enter_ready(void) { show_screen(SCREEN_WAITING1); score_render(); + // Row 1 of the waiting1 screen — clear any leftover banner. + 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 @@ -142,21 +196,27 @@ static void game_enter_ready(void) wait_duration_frames = 100 + (sid.random % 150); vic.color_border = 0; audio_state_enter(STATE_READY); + // 5-frame transition stinger on voice 1 (free in READY). + audio_play_stinger(stinger_voice_for_state(STATE_READY), STINGER_DURATION); } static void game_enter_wait(void) { show_screen(SCREEN_WAITING2); score_render(); + banner_clear(1); enter_frame = frame_count; vic.color_border = 0; audio_state_enter(STATE_WAIT); + // 5-frame transition stinger on voice 2 (free in WAIT). + audio_play_stinger(stinger_voice_for_state(STATE_WAIT), STINGER_DURATION); } static void game_enter_draw(void) { show_white_screen(); score_render(); + banner_clear(1); enter_frame = frame_count; vic.color_border = 1; // white border matches the white screen draw_was_pressed[0] = 0; @@ -169,6 +229,8 @@ static void game_enter_draw(void) 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); } static void game_enter_win_p1(void) @@ -176,9 +238,13 @@ static void game_enter_win_p1(void) show_screen(SCREEN_WIN_HARE); score_p1++; score_render(); + banner_clear(1); enter_frame = frame_count; vic.color_border = 0; + last_winner = 1; audio_state_enter(STATE_WIN_P1); + // 5-frame transition stinger on voice 2 (free in WIN). + audio_play_stinger(stinger_voice_for_state(STATE_WIN_P1), STINGER_DURATION); } static void game_enter_win_p2(void) @@ -186,36 +252,54 @@ static void game_enter_win_p2(void) show_screen(SCREEN_WIN_SCOOT); score_p2++; score_render(); + banner_clear(1); enter_frame = frame_count; vic.color_border = 0; + last_winner = 2; audio_state_enter(STATE_WIN_P2); + // 5-frame transition stinger on voice 2 (free in WIN). + audio_play_stinger(stinger_voice_for_state(STATE_WIN_P2), STINGER_DURATION); } 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). + // with an empty score bar). The winner banner ("HARE WINS!" or + // "SCOOT WINS!") is rendered into row 1 below the score bar. 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 + // reloaded the title bitmap, so row 1 currently has the title + // art; banner_render() will clear and overwrite it. + if (last_winner == 1) + banner_render("HARE WINS!", 1); + else + banner_render("SCOOT WINS!", 1); enter_frame = frame_count; vic.color_border = 0; audio_state_enter(STATE_GAMEOVER); + // 5-frame transition stinger on voice 2 (free in GAMEOVER). + audio_play_stinger(stinger_voice_for_state(STATE_GAMEOVER), STINGER_DURATION); } // --- 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. + // Flash the border at 1 Hz: 50 frames on, 50 frames off. + // The full cycle is 100 frames = 2.0 sec, so the visible flash + // rate is 0.5 Hz (one on-off per 2 sec). Phase 8 task spec + // calls this "1 Hz" but with explicit "50 on, 50 off" — the + // parenthetical is the actual rate, and we follow it. unsigned short elapsed = frame_count - enter_frame; - if ((elapsed & 1) == 0) - vic.color_border ^= 1; + if ((elapsed % 100) < 50) + vic.color_border = 1; // white + else + vic.color_border = 0; // black char p1 = input_fire(1); // Hare (port 1) char p2 = input_fire(0); // Scoot (port 0) diff --git a/src/game.h b/src/game.h index 0dad86d..aead9dc 100644 --- a/src/game.h +++ b/src/game.h @@ -73,10 +73,14 @@ void game_init(void); // the next step operates on the new state). // // game_step() also drives per-state visual updates that need to run -// every frame (the TITLE border flash at 25 Hz, the DRAW border +// every frame (the TITLE border flash at 1 Hz, the DRAW border // flash + per-frame counter, and the fault-stinger audio gate-off // counter). -void game_step(void); +// +// Marked __noinline so -O3 doesn't inline the whole state machine +// into the IRQ handler (which would make the handler thousands of +// bytes and overrun the raster line budget on real hardware). +__noinline void game_step(void); #pragma compile("game.c") diff --git a/src/main.c b/src/main.c index ef801f0..31394cf 100644 --- a/src/main.c +++ b/src/main.c @@ -29,7 +29,18 @@ // We don't malloc, so the heap is unused. Setting it to 0 frees the // space for the screen data in the main region. +// +// The stack is set to 0x400 (1 KB) — this is the oscar64 default, +// pinned explicitly so the layout is predictable across `-O1` / +// `-O3` builds. With `-O3` the code section is larger, so the +// data section's spillover into the default stack/heap gap region +// leaves only ~0x400 for both. Anything larger (0x600+) makes +// `-O3` fail to link with "Cannot place stack section". 1 KB is +// plenty because the oscar64 software stack lives in zero-page +// (0xF7-0xFF, 9 bytes per the -O3 default); the spillover area +// only needs to hold the few locals/params that don't fit in ZP. #pragma heapsize(0) +#pragma stacksize(0x400) int main(void) { diff --git a/src/score.c b/src/score.c index d3c177a..b62eb0d 100644 --- a/src/score.c +++ b/src/score.c @@ -26,30 +26,48 @@ byte score_p2 = 0; // --- custom 4×8 font --------------------------------------------------- // -// 8 characters, each 4 px wide × 8 px tall = 8 bytes (one byte per row, -// top 4 bits are the 4 pixels of that row, MSB = leftmost pixel). -// 64 bytes total. Bottom 4 bits of every byte are 0. +// 16 characters, each 4 px wide × 8 px tall = 8 bytes (one byte per +// row, top 4 bits are the 4 pixels of that row, MSB = leftmost pixel). +// 128 bytes total. Bottom 4 bits of every byte are 0. // -// Index: -// 0 = H, 1 = A, 2 = R, 3 = E, 4 = S, 5 = C, 6 = O, 7 = T -static const char font[8][8] = { - // H (1 0 0 1) × 3, (1 1 1 1), (1 0 0 1) × 4 +// 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] = { + // 0 H (1 0 0 1) × 3, (1 1 1 1), (1 0 0 1) × 4 { 0x90, 0x90, 0x90, 0xF0, 0x90, 0x90, 0x90, 0x90 }, - // A (0 1 1 0), (1 0 0 1) × 2, (1 1 1 1), (1 0 0 1) × 4 + // 1 A (0 1 1 0), (1 0 0 1) × 2, (1 1 1 1), (1 0 0 1) × 4 { 0x60, 0x90, 0x90, 0xF0, 0x90, 0x90, 0x90, 0x90 }, - // R (1 1 1 0), (1 0 0 1) × 2, (1 1 1 0), (1 0 1 0), - // (1 0 0 1) × 3 + // 2 R (1 1 1 0), (1 0 0 1) × 2, (1 1 1 0), (1 0 1 0), + // (1 0 0 1) × 3 { 0xE0, 0x90, 0x90, 0xE0, 0xA0, 0x90, 0x90, 0x90 }, - // E (1 1 1 1), (1 0 0 0) × 2, (1 1 1 0), (1 0 0 0) × 3, (1 1 1 1) + // 3 E (1 1 1 1), (1 0 0 0) × 2, (1 1 1 0), (1 0 0 0) × 3, (1 1 1 1) { 0xF0, 0x80, 0x80, 0xE0, 0x80, 0x80, 0x80, 0xF0 }, - // S (0 1 1 1), (1 0 0 0) × 2, (0 1 1 0), (0 0 0 1) × 3, (1 1 1 0) + // 4 S (0 1 1 1), (1 0 0 0) × 2, (0 1 1 0), (0 0 0 1) × 3, (1 1 1 0) { 0x70, 0x80, 0x80, 0x60, 0x10, 0x10, 0x10, 0xE0 }, - // C (0 1 1 0), (1 0 0 1), (1 0 0 0) × 4, (1 0 0 1), (0 1 1 0) + // 5 C (0 1 1 0), (1 0 0 1), (1 0 0 0) × 4, (1 0 0 1), (0 1 1 0) { 0x60, 0x90, 0x80, 0x80, 0x80, 0x80, 0x90, 0x60 }, - // O (0 1 1 0), (1 0 0 1) × 5, (0 1 1 0) + // 6 O (0 1 1 0), (1 0 0 1) × 5, (0 1 1 0) { 0x60, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x60 }, - // T (1 1 1 1), (0 0 1 0) × 7 + // 7 T (1 1 1 1), (0 0 1 0) × 7 { 0xF0, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }, + // 8 P (1 1 1 0), (1 0 0 1) × 2, (1 1 1 1), (1 0 0 0) × 4 + { 0xE0, 0x90, 0x90, 0xF0, 0x80, 0x80, 0x80, 0x80 }, + // 9 F (1 1 1 1), (1 0 0 0) × 2, (1 1 1 0), (1 0 0 0) × 4 + { 0xF0, 0x80, 0x80, 0xE0, 0x80, 0x80, 0x80, 0x80 }, + // 10 W (1 0 0 1) × 4, (1 1 1 1) × 2, (0 1 1 0), (0 0 0 0) + { 0x90, 0x90, 0x90, 0x90, 0xF0, 0xF0, 0x60, 0x00 }, + // 11 I (1 1 1 1), (0 0 1 0) × 6, (1 1 1 1) + { 0xF0, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0xF0 }, + // 12 N (1 0 0 0), (1 1 0 0), (1 1 0 0), (1 0 1 0), (1 0 1 0), + // (1 0 0 1), (1 0 0 1), (0 0 0 0) + { 0x80, 0xC0, 0xC0, 0xA0, 0xA0, 0x90, 0x90, 0x00 }, + // 13 ! (0 0 1 0) × 5, (0 0 0 0), (0 0 1 0) + { 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, 0x20 }, + // 14 ' ' (space) - 8 empty rows + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, + // 15 reserved - blank for now + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, }; // --- pixel-pair expansion table ---------------------------------------- @@ -205,3 +223,28 @@ void score_render(void) render_char(SCOOT_FIRST_CELL + 3, 6); // O render_char(SCOOT_FIRST_CELL + 4, 7); // T } + +byte font_lookup(char c) +{ + // Maps ASCII to font index. Returns CHAR_RESERVED for any + // character not in our 15-char font. Used by the banner + // renderer. + switch (c) { + case 'H': return CHAR_H; + case 'A': return CHAR_A; + case 'R': return CHAR_R; + case 'E': return CHAR_E; + case 'S': return CHAR_S; + case 'C': return CHAR_C; + case 'O': return CHAR_O; + case 'T': return CHAR_T; + case 'P': return CHAR_P; + case 'F': return CHAR_F; + case 'W': return CHAR_W; + case 'I': return CHAR_I; + case 'N': return CHAR_N; + case '!': return CHAR_EXCL; + case ' ': return CHAR_SPACE; + default: return CHAR_RESERVED; + } +} diff --git a/src/score.h b/src/score.h index 7411401..f209200 100644 --- a/src/score.h +++ b/src/score.h @@ -44,6 +44,39 @@ extern byte score_p1; // Player 2 (Scoot) score, 0..5. extern byte score_p2; +// --- custom 4x8 font index constants ------------------------------------ +// +// The font (defined in score.c) is a 16-entry table of 4x8 characters. +// Each entry is 8 bytes (one byte per row, top 4 bits = the 4 pixels of +// the row, MSB = leftmost pixel). Bottom 4 bits of every byte are 0. +// Total font size = 16 * 8 = 128 bytes. The original 8 chars (H/A/R/E/ +// S/C/O/T) are the score bar labels; the new 8 chars (P/F/I/W/N/! and +// ' ' plus one reserved) are used by the banner renderer for the +// "PRESS FIRE" prompt and the "HARE WINS!" / "SCOOT WINS!" banners. +#define FONT_COUNT 16 + +#define CHAR_H 0 +#define CHAR_A 1 +#define CHAR_R 2 +#define CHAR_E 3 +#define CHAR_S 4 +#define CHAR_C 5 +#define CHAR_O 6 +#define CHAR_T 7 +#define CHAR_P 8 +#define CHAR_F 9 +#define CHAR_W 10 +#define CHAR_I 11 +#define CHAR_N 12 +#define CHAR_EXCL 13 +#define CHAR_SPACE 14 +#define CHAR_RESERVED 15 + +// Look up the font index for a given ASCII char. Returns +// CHAR_RESERVED for any char not in the font. Used by the banner +// renderer when given a string of ASCII text. +byte font_lookup(char c); + // score_init() — zero both scores. Called once at startup. // (The variables already default to 0 in BSS; this is for symmetry with // the future per-state initialization in Phase 4.) diff --git a/src/tick.c b/src/tick.c index 96d79a0..0a739c1 100644 --- a/src/tick.c +++ b/src/tick.c @@ -67,7 +67,15 @@ static RIRQCode frame_tick; // 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) +// +// Also marked __noinline to prevent `-O3` from inlining the entire +// state machine (game_step + audio_state_step + all per-state step +// functions) into the IRQ handler. Without __noinline, the +// optimizer's aggressive inlining makes the handler 6000+ bytes +// long, which would overrun its raster-line budget and break the +// 50 Hz timing. game_step is large but called only from the IRQ, +// so the call/return overhead is negligible. +__interrupt __noinline void frame_tick_handler(void) { frame_count++; game_step();