Phase 8: polish and end-to-end test

Banner text rendering (PRESS FIRE / HARE WINS! / SCOOT WINS!):
- src/banner.c, src/banner.h: new module; renders NUL-terminated
  ASCII text in any character row of the multicolor bitmap, using
  the same 4x8 custom font as the score bar.  Truncates to 40 chars,
  centers in the row, clears the row first.
- src/score.c, src/score.h: font extended from 8 to 16 entries —
  added P, F, W, I, N, !, space (and one reserved).  font_lookup()
  maps ASCII to font index.
- src/game.c: TITLE renders 'PRESS FIRE' in row 1 below the score
  bar; GAMEOVER renders 'HARE WINS!' or 'SCOOT WINS!' based on
  last_winner (set by game_enter_win_p1/p2).  Other states clear
  row 1 on entry.

State transition stinger:
- src/audio.c, src/audio.h: audio_play_stinger(voice, duration)
  starts a low square-wave burst that auto-cleans up via
  audio_advance_stinger (called from audio_state_step).  A 5-frame
  stinger is fired on every state transition; the per-state voice
  is chosen to avoid colliding with the new state's audio
  (TITLE/READY: voice 0/1 free, WAIT: voice 2 free, DRAW: voice 0
  free, WIN/GAMEOVER: voice 2 free).  Stinger is silenced by the
  next audio_state_enter() via the existing audio_stop() call.

TITLE border flash changed from 12.5 Hz to 1 Hz (50 on, 50 off).

Build system:
- src/build.sh: added -O0/-O1/-O2/-O3/-Os/-g flag handling.  Both
  default (-O1) and -O3 builds produce a 43913-byte .prg
  (well under the 51308-byte LOAD"*",8,1 limit).
- src/main.c: pinned #pragma stacksize(0x400) — the oscar64 default
  is the same, but pinning makes the layout predictable across
  optimization levels.  -O3 needs this exact size; larger values
  cause 'Cannot place stack section' link errors because the
  optimizer's larger code section leaves less room in the
  stack/heap gap.
- src/tick.c: marked frame_tick_handler __noinline so -O3 doesn't
  inline the entire state machine (6000+ bytes) into the IRQ
  handler.  With __noinline, the handler is 136 bytes — small
  enough for the raster line budget.
- src/game.h: marked game_step __noinline for the same reason.

The .prg is 173 blocks (out of 202 max), well within the BASIC
load area.  Both default and -O3 builds run cleanly in the oscar64
built-in emulator.
This commit is contained in:
Whack Hare Agent
2026-07-17 02:55:16 +02:00
parent a8ce56464d
commit b7bd76dee3
11 changed files with 464 additions and 31 deletions
+56 -3
View File
@@ -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
+7
View File
@@ -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
+112
View File
@@ -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);
}
}
+52
View File
@@ -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 <c64/types.h>
// 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
+29 -3
View File
@@ -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
+91 -7
View File
@@ -26,6 +26,7 @@
#include "screens.h"
#include "input.h"
#include "score.h"
#include "banner.h"
#include "draw.h"
#include <c64/vic.h>
#include <c64/sid.h>
@@ -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)
+6 -2
View File
@@ -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")
+11
View File
@@ -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)
{
+57 -14
View File
@@ -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),
// 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;
}
}
+33
View File
@@ -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.)
+9 -1
View File
@@ -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();