fix: decouple VBlank ISR and main loop to prevent IRQ stack overflow

- Make handle_vblank strictly increment frame counter (frame++)
- Move heavy frame logic (background updates, OAM updates, palette animations, and console stats) to update_frame() on the main thread synchronized with swiWaitForVBlank()
- Eliminate 256-byte ARM9 IRQ stack (sp_irq) overflow caused by bg_update and iprintf that was corrupting DTCM stack and causing data aborts
- Preserve all playfield entities on game over with top-screen defeat summary and smooth background deceleration

Co-authored-by: Antigravity <antigravity@google.com>
This commit is contained in:
phillipandAntigravity committed 2026-10-04 00:21:09 +02:00
1 parent 91908d1f15
commit 496de1a31f
10 files changed
+171 -81

No files matched your search

+64 -38
View File
@@ -323,6 +323,14 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
const bool hard = state.hardMode;
const bool over = state.gameOver;
if (over) {
if (bg_ctx.game_over_fade < 31) {
bg_ctx.game_over_fade++;
}
} else {
bg_ctx.game_over_fade = 0;
}
// 1. Hardware Scrolling Update
if (state.scene == Scene::Menu) {
// Menu: Majestic downward drift for the cyber-horizon feel
@@ -337,11 +345,7 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
static_cast<uint16_t>(bg_ctx.scroll_sub_x_fp + 24U);
} else {
// In-Game Playfield:
if (over) {
// Gentle halt on game over
bg_ctx.scroll_sub_x_fp =
static_cast<uint16_t>(bg_ctx.scroll_sub_x_fp + 4U);
} else if (cursed) {
if (cursed && !over) {
// High-speed warp and glitch jitter when cursed!
const int16_t jitter =
(bg_ctx.anim_phase % 4U == 0U) ? 0x0180 : -0x0100;
@@ -353,7 +357,7 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
bg_ctx.scroll_sub_y_fp = static_cast<uint16_t>(
bg_ctx.scroll_sub_y_fp + 0x00E0U + (warp_intensity >> 1U));
} else {
// Smooth ambient drift scaling gradually with intensity
// Smooth ambient drift scaling gradually with intensity (decelerating gently if game over)
const uint16_t base_x = hard ? 24U : 16U;
const uint16_t base_y = hard ? 20U : 12U;
const uint16_t max_x = hard ? 76U : 56U;
@@ -362,17 +366,34 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
base_x + ((static_cast<uint32_t>(intensity_31) * (max_x - base_x)) / 31U));
const uint16_t speed_y = static_cast<uint16_t>(
base_y + ((static_cast<uint32_t>(intensity_31) * (max_y - base_y)) / 31U));
uint16_t sub_step_x = speed_x;
uint16_t sub_step_y = speed_y;
if (bg_ctx.game_over_fade > 0) {
const uint8_t fade = bg_ctx.game_over_fade;
sub_step_x = static_cast<uint16_t>(
speed_x + ((static_cast<int>(4U - speed_x) * fade) / 31));
sub_step_y = static_cast<uint16_t>(
speed_y - ((static_cast<int>(speed_y) * fade) / 31));
}
bg_ctx.scroll_sub_x_fp =
static_cast<uint16_t>(bg_ctx.scroll_sub_x_fp + speed_x);
static_cast<uint16_t>(bg_ctx.scroll_sub_x_fp + sub_step_x);
bg_ctx.scroll_sub_y_fp =
static_cast<uint16_t>(bg_ctx.scroll_sub_y_fp + speed_y);
static_cast<uint16_t>(bg_ctx.scroll_sub_y_fp + sub_step_y);
}
// Top screen HUD / horizon drift scaling with intensity
const uint16_t speed_main_y = static_cast<uint16_t>(
// Top screen HUD / horizon drift scaling with intensity (halting on game over)
uint16_t speed_main_y = static_cast<uint16_t>(
16U + ((static_cast<uint32_t>(intensity_31) * 24U) / 31U));
const uint16_t speed_main_x = static_cast<uint16_t>(
uint16_t speed_main_x = static_cast<uint16_t>(
8U + ((static_cast<uint32_t>(intensity_31) * 12U) / 31U));
if (bg_ctx.game_over_fade > 0) {
const uint8_t fade = bg_ctx.game_over_fade;
speed_main_y = static_cast<uint16_t>(
speed_main_y - ((static_cast<int>(speed_main_y) * fade) / 31));
speed_main_x = static_cast<uint16_t>(
speed_main_x - ((static_cast<int>(speed_main_x) * fade) / 31));
}
bg_ctx.scroll_main_y_fp =
static_cast<uint16_t>(bg_ctx.scroll_main_y_fp + speed_main_y);
bg_ctx.scroll_main_x_fp =
@@ -401,18 +422,7 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
uint16_t col_frame = 0;
uint16_t col_frame_accent = 0;
if (over) {
// Somber dark charcoal / muted red
col_faint = make_rgb15(3, 2, 2);
col_subtle = make_rgb15(6, 3, 3);
col_primary = make_rgb15(10, 4, 4);
col_star_dim = make_rgb15(5, 2, 2);
col_star_med = make_rgb15(8, 3, 3);
col_star_bright = make_rgb15(12, 4, 4);
col_star_flare = make_rgb15(16, 6, 6);
col_frame = make_rgb15(8, 3, 3);
col_frame_accent = make_rgb15(12, 4, 4);
} else if (cursed) {
if (cursed && !over) {
// Radioactive neon purple / violet storm
const bool strobe = (bg_ctx.anim_phase % 6U < 3U);
col_faint = make_rgb15(8, 1, 10);
@@ -488,26 +498,42 @@ void bg_update(BackgroundContext &bg_ctx, const GameState &state,
col_frame_accent = lerp_color(c_frame_accent_0, c_frame_accent_max, intensity_31);
}
// Smoothly blend toward somber dark charcoal / muted red tone if game over
if (bg_ctx.game_over_fade > 0) {
const uint8_t fade = bg_ctx.game_over_fade;
col_faint = lerp_color(col_faint, make_rgb15(3, 2, 2), fade);
col_subtle = lerp_color(col_subtle, make_rgb15(6, 3, 3), fade);
col_primary = lerp_color(col_primary, make_rgb15(10, 4, 4), fade);
col_star_dim = lerp_color(col_star_dim, make_rgb15(5, 2, 2), fade);
col_star_med = lerp_color(col_star_med, make_rgb15(8, 3, 3), fade);
col_star_bright = lerp_color(col_star_bright, make_rgb15(12, 4, 4), fade);
col_star_flare = lerp_color(col_star_flare, make_rgb15(16, 6, 6), fade);
col_frame = lerp_color(col_frame, make_rgb15(8, 3, 3), fade);
col_frame_accent = lerp_color(col_frame_accent, make_rgb15(12, 4, 4), fade);
}
// Deep space backdrop color dynamically illuminates with intensity
if (in_game && !over) {
if (in_game) {
uint16_t bg_sub_target = 0;
uint16_t bg_main_target = 0;
if (hard) {
BG_PALETTE_SUB[0] = lerp_color(make_rgb15(3, 1, 1), make_rgb15(6, 2, 2), intensity_31);
BG_PALETTE[0] = lerp_color(make_rgb15(2, 1, 1), make_rgb15(5, 1, 2), intensity_31);
bg_sub_target = lerp_color(make_rgb15(3, 1, 1), make_rgb15(6, 2, 2), intensity_31);
bg_main_target = lerp_color(make_rgb15(2, 1, 1), make_rgb15(5, 1, 2), intensity_31);
} else {
BG_PALETTE_SUB[0] = lerp_color(make_rgb15(3, 4, 7), make_rgb15(5, 7, 12), intensity_31);
BG_PALETTE[0] = lerp_color(make_rgb15(2, 3, 6), make_rgb15(3, 5, 10), intensity_31);
bg_sub_target = lerp_color(make_rgb15(3, 4, 7), make_rgb15(5, 7, 12), intensity_31);
bg_main_target = lerp_color(make_rgb15(2, 3, 6), make_rgb15(3, 5, 10), intensity_31);
}
if (bg_ctx.game_over_fade > 0) {
BG_PALETTE_SUB[0] = lerp_color(bg_sub_target, make_rgb15(3, 2, 2), bg_ctx.game_over_fade);
BG_PALETTE[0] = lerp_color(bg_main_target, make_rgb15(2, 1, 1), bg_ctx.game_over_fade);
} else {
BG_PALETTE_SUB[0] = bg_sub_target;
BG_PALETTE[0] = bg_main_target;
}
} else if (over) {
BG_PALETTE_SUB[0] = make_rgb15(3, 2, 2);
BG_PALETTE[0] = make_rgb15(2, 1, 1);
} else {
if (hard) {
BG_PALETTE_SUB[0] = make_rgb15(3, 1, 1);
BG_PALETTE[0] = make_rgb15(2, 1, 1);
} else {
BG_PALETTE_SUB[0] = make_rgb15(2, 2, 6);
BG_PALETTE[0] = make_rgb15(2, 2, 6);
}
BG_PALETTE_SUB[0] = make_rgb15(2, 2, 6);
BG_PALETTE[0] = make_rgb15(2, 2, 6);
}
// Animate twinkling stars using sine oscillations
+3
View File
@@ -65,6 +65,7 @@ bool handle_input_frame(InputContext &ctx) {
if ((ctx.state->paused || ctx.state->gameOver) && (keys != 0U) && ((keys & KEY_B) != 0U)) {
ctx.state->scene = Scene::Menu;
ctx.state->paused = false;
ctx.state->gameOver = false;
setBackdropColor(RGB15(2, 2, 6));
setBackdropColorSub(RGB15(2, 2, 6));
draw_menu(*ctx.state, *ctx.renderAssets, audio_is_music_enabled(*ctx.audio));
@@ -182,6 +183,8 @@ bool handle_input_frame(InputContext &ctx) {
} else {
ctx.init_game();
}
} else if (ctx.state->gameOver && (keys != 0U) && ((keys & KEY_A) != 0U)) {
ctx.init_game();
}
return true;
+3
View File
@@ -47,6 +47,7 @@ bool check_collision_self(GameState &state) {
for (auto it = state.snake.begin() + 1; it != state.snake.end(); ++it) {
if ((*it)[0] == headpos[0] && (*it)[1] == headpos[1]) {
state.gameOver = true;
state.gameOverReason = GameOverReason::SelfCollision;
return true;
}
}
@@ -91,6 +92,7 @@ bool check_collision_apples_rotten(GameState &state) {
state.bad_apples.erase(it);
if (state.snakelength < 2) {
state.gameOver = true;
state.gameOverReason = GameOverReason::RottenAppleDepleted;
}
return true;
}
@@ -471,6 +473,7 @@ bool check_collision_barriers(GameState &state) {
const auto &head = state.snake.front();
if (is_barrier_cell(state, head[0], head[1], false)) {
state.gameOver = true;
state.gameOverReason = GameOverReason::BarrierCollision;
return true;
}
return false;
+51 -24
View File
@@ -153,38 +153,62 @@ void draw_apples_cursed(const GameState &state, const RenderAssets &assets,
} // namespace
void draw_game_over_screen(const GameState &state) {
iprintf("\x1b[1;2H============================");
iprintf("\x1b[2;2H * GAME OVER * ");
iprintf("\x1b[3;2H============================");
iprintf("\x1b[5;4HFinal Score: %5i", state.score);
iprintf("\x1b[6;4HApples Eaten: %5i", state.applesEaten);
iprintf("\x1b[7;4HSnake Length: %5i", state.snakelength);
iprintf("\x1b[8;4HDifficulty: %s", state.hardMode ? "Hard " : "Normal");
iprintf("\x1b[10;4HCause of Defeat:");
const char *cause = "Collision";
switch (state.gameOverReason) {
case GameOverReason::SelfCollision:
cause = "Bite Collision (Hit Body)";
break;
case GameOverReason::BarrierCollision:
cause = "Energy Barrier Impact";
break;
case GameOverReason::RottenAppleDepleted:
cause = "Depleted by Rotten Apple";
break;
default:
cause = "Collision";
break;
}
iprintf("\x1b[11;4H %s", cause);
iprintf("\x1b[14;2H----------------------------");
iprintf("\x1b[16;4HA / START : Play Again");
iprintf("\x1b[17;4HB : Main Menu");
iprintf("\x1b[18;4HR : Swap Screens");
iprintf("\x1b[20;2H============================");
}
void invalidate_hud(RenderAssets &assets) { assets.hudCache.dirty = true; }
void draw_game(const GameState &state, const RenderAssets &assets) {
// OAM slots below BARRIER_OAM_SLOTS are owned by draw_barriers() (VBlank)
uint16_t oamId = BARRIER_OAM_SLOTS;
if (!state.gameOver) {
draw_apples(state, assets, oamId);
draw_apples_rotten(state, assets, oamId);
draw_apples_cursed(state, assets, oamId);
draw_snake(state, assets, oamId);
draw_apples(state, assets, oamId);
draw_apples_rotten(state, assets, oamId);
draw_apples_cursed(state, assets, oamId);
draw_snake(state, assets, oamId);
// Hide all unused sprites up to hardware limit (128) to eliminate trailing
// ghost sprites
if (oamId < 128) {
oamClear(&oamSub, oamId, 128 - oamId);
}
return;
// Hide all unused sprites up to hardware limit (128) to eliminate trailing
// ghost sprites
if (oamId < 128) {
oamClear(&oamSub, oamId, 128 - oamId);
}
// On Game Over, hide all sprites so none are left dangling
oamClear(&oamSub, 0, 128);
iprintf("\x1b[11;12HGAME OVER!");
consoleSelect(assets.topScreen);
iprintf("\x1b[10;10H Score: %i ", state.score);
iprintf("\x1b[11;12HGAME OVER!");
}
void draw_barriers(const GameState &state, const RenderAssets &assets,
uint8_t frame) {
if (state.scene != Scene::Playing || state.gameOver || !state.hardMode) {
if (state.scene != Scene::Playing || !state.hardMode) {
oamClear(&oamSub, 0, BARRIER_OAM_SLOTS);
return;
}
@@ -257,6 +281,12 @@ void draw_barriers(const GameState &state, const RenderAssets &assets,
void draw_stats(const GameState &state, RenderAssets &assets, bool internals,
bool musicEnabled, uint8_t frame) {
if (state.gameOver) {
return;
}
HudCache &cache = assets.hudCache;
if (internals) {
iprintf("\x1b[0;0HFrame = %i\nTick = %i\ndir = %i", frame, state.ticks,
state.snakeDirection);
@@ -279,7 +309,6 @@ void draw_stats(const GameState &state, RenderAssets &assets, bool internals,
}
}
HudCache &cache = assets.hudCache;
const bool cursed = (state.curseTicksRemaining > 0);
const bool needs_score_update = cache.dirty || (cache.score != state.score);
@@ -305,9 +334,7 @@ void draw_stats(const GameState &state, RenderAssets &assets, bool internals,
if (needs_pause_update) {
if (!state.paused) {
if (state.gameOver) {
iprintf("\x1b[23;0HB = Main Menu");
} else if (cache.paused) {
if (cache.paused) {
// Clear any leftover pause strings if previously paused
iprintf("\x1b[21;0H ");
iprintf("\x1b[22;0H ");
+29 -11
View File
@@ -205,19 +205,33 @@ void setup() {
}
void handle_vblank() {
frame++;
}
void update_frame() {
bg_update(bgContext, gameState, frame);
if (gameState.scene == Scene::Playing) {
consoleSelect(&topScreen);
draw_stats(gameState, renderAssets, internals,
audio_is_music_enabled(audioContext), frame);
if (!gameState.paused) {
tick_barriers_frame(gameState);
if (gameState.gameOver) {
HudCache &cache = renderAssets.hudCache;
if (cache.dirty || (cache.gameOver != gameState.gameOver)) {
consoleClear();
draw_game_over_screen(gameState);
cache.gameOver = gameState.gameOver;
cache.dirty = false;
}
} else {
draw_stats(gameState, renderAssets, internals,
audio_is_music_enabled(audioContext), frame);
if (!gameState.paused) {
tick_barriers_frame(gameState);
}
}
draw_barriers(gameState, renderAssets, frame);
}
// Dynamic pulsing radioactive glow for Cursed Apples (smooth 60fps breathing)
const uint8_t pulse = (frame >> 2U) & 7U; // 8-step cycle
const auto pulse = static_cast<uint8_t>(
(static_cast<uint32_t>(frame) >> 2U) & 7U); // 8-step cycle
const uint8_t wave =
(pulse < 4U) ? pulse
: static_cast<uint8_t>(7U - pulse); // 0, 1, 2, 3, 3, 2, 1, 0
@@ -233,8 +247,9 @@ void handle_vblank() {
// Dynamic festering mold & spore respiration for Rotten Apples (smooth 60fps
// organic pulse)
const uint8_t rot_pulse = ((frame + 3U) >> 2U) &
7U; // 8-step cycle, phase-shifted from cursed apple
const auto rot_pulse = static_cast<uint8_t>(
(static_cast<uint32_t>(frame + 3U) >> 2U) &
7U); // 8-step cycle, phase-shifted from cursed apple
const uint8_t rot_wave =
(rot_pulse < 4U)
? rot_pulse
@@ -292,6 +307,7 @@ void do_tick() {
if (!was_game_over && gameOver) {
audio_stop_music();
audio_play_fail(audioContext);
invalidate_hud(renderAssets);
}
// consoleSelect(&bottomScreen);
draw_game(gameState, renderAssets);
@@ -309,6 +325,7 @@ void init_game() {
score = 0;
ticks = 0;
gameOver = false;
gameState.gameOverReason = GameOverReason::None;
snakeDirection = 1;
gameState.curseTicksRemaining = 0;
apples.clear();
@@ -338,10 +355,6 @@ void init_game() {
}
int main(void) {
// Set up VBlank handler
irqSet(IRQ_VBLANK, handle_vblank);
setup();
setBackdropColor(RGB15(2, 2, 6));
setBackdropColorSub(RGB15(2, 2, 6));
@@ -354,7 +367,12 @@ int main(void) {
inputContext.topScreen = &topScreen;
inputContext.init_game = init_game;
// Set up VBlank handler only after all subsystems, assets, and contexts are initialized
irqSet(IRQ_VBLANK, handle_vblank);
while (1) {
swiWaitForVBlank();
update_frame();
if (!handle_input_frame(inputContext)) {
break;
}