From 3b45572d384de5ae5fa49f723809cd80c2d8a220 Mon Sep 17 00:00:00 2001 From: LeSerjant Date: Mon, 16 Mar 2026 20:29:18 +0000 Subject: [PATCH] Add game state debug log with binary ring buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement src/engine/debuglog module that records a comprehensive snapshot of game state every tick into a 4 MB in-memory ring buffer. Activated by --debug-log command-line flag. Press F12 during gameplay to dump the ring buffer to a human-readable debug_log.txt file. The buffer also auto-flushes every 10 seconds as a safety net. Each tick snapshot captures: input state (held/pressed/released bitmasks), full player state (position, velocity, health, dash, aim, timers), camera position, physics globals, level name, and a variable-length list of all active entity positions/velocities/health. New files: - src/engine/debuglog.h — API and snapshot data structures - src/engine/debuglog.c — ring buffer, record, and dump logic Modified files: - include/config.h — DEBUGLOG_BUFFER_SIZE constant - src/engine/input.h/c — input_get_snapshot() to pack input bitmasks - src/engine/core.c — debuglog_record_tick() call after update - src/main.c — CLI flag, init/shutdown, F12 hotkey, set_level calls Closes #19 --- include/config.h | 3 + src/engine/core.c | 2 + src/engine/debuglog.c | 474 ++++++++++++++++++++++++++++++++++++++++++ src/engine/debuglog.h | 91 ++++++++ src/engine/input.c | 14 ++ src/engine/input.h | 6 + src/main.c | 28 +++ 7 files changed, 618 insertions(+) create mode 100644 src/engine/debuglog.c create mode 100644 src/engine/debuglog.h diff --git a/include/config.h b/include/config.h index 96d6f68..e18f36f 100644 --- a/include/config.h +++ b/include/config.h @@ -49,4 +49,7 @@ typedef enum TransitionStyle { #define MAX_ASSETS 128 #define ASSET_PATH_MAX 256 +/* ── Debug log ──────────────────────────────────────── */ +#define DEBUGLOG_BUFFER_SIZE (4 * 1024 * 1024) /* 4 MB ring buffer */ + #endif /* JNR_CONFIG_H */ diff --git a/src/engine/core.c b/src/engine/core.c index f67ef0c..444d5de 100644 --- a/src/engine/core.c +++ b/src/engine/core.c @@ -3,6 +3,7 @@ #include "engine/renderer.h" #include "engine/audio.h" #include "engine/assets.h" +#include "engine/debuglog.h" #include #ifdef __EMSCRIPTEN__ @@ -118,6 +119,7 @@ static void engine_frame(void) { if (s_callbacks.update) { s_callbacks.update(DT); } + debuglog_record_tick(); input_consume(); g_engine.tick++; s_accumulator -= DT; diff --git a/src/engine/debuglog.c b/src/engine/debuglog.c new file mode 100644 index 0000000..677c6b3 --- /dev/null +++ b/src/engine/debuglog.c @@ -0,0 +1,474 @@ +#include "engine/debuglog.h" +#include "engine/core.h" +#include "engine/input.h" +#include "engine/physics.h" +#include "engine/entity.h" +#include "game/level.h" +#include "game/player.h" +#include +#include +#include + +/* ═══════════════════════════════════════════════════ + * File-scope state + * ═══════════════════════════════════════════════════ */ + +/* Ring buffer header stored at the start of the memory block. */ +typedef struct RingHeader { + uint32_t magic; /* 0x44424C47 = "DBLG" */ + uint32_t version; + uint32_t write_cursor; /* byte offset into data region */ + uint32_t total_written; /* total snapshots written (wraps) */ + uint32_t data_size; /* usable data bytes after header */ +} RingHeader; + +#define RING_MAGIC 0x44424C47 +#define RING_VERSION 1 + +/* Flush the buffer to disk every this many ticks (10 s at 60 Hz). */ +#define FLUSH_INTERVAL 600 + +static bool s_enabled; +static uint8_t *s_buffer; /* full allocation: header + data */ +static RingHeader *s_header; +static uint8_t *s_data; /* start of ring data region */ +static Level *s_level; +static char s_level_name[32]; /* human-readable level label */ +static int s_flush_counter; + +/* Path used for periodic safety-net flushes. */ +static const char *s_flush_path = "debug_log_autosave.bin"; + +/* ═══════════════════════════════════════════════════ + * Internal helpers + * ═══════════════════════════════════════════════════ */ + +/* Write raw bytes into the ring, wrapping at the boundary. */ +static void ring_write(const void *src, uint32_t len) { + uint32_t cursor = s_header->write_cursor; + uint32_t cap = s_header->data_size; + const uint8_t *p = (const uint8_t *)src; + + uint32_t first = cap - cursor; + if (first >= len) { + memcpy(s_data + cursor, p, len); + } else { + memcpy(s_data + cursor, p, first); + memcpy(s_data, p + first, len - first); + } + s_header->write_cursor = (cursor + len) % cap; +} + +/* Read raw bytes from an arbitrary ring offset, wrapping. */ +static void ring_read(uint32_t offset, void *dst, uint32_t len) { + uint32_t cap = s_header->data_size; + offset = offset % cap; + uint8_t *out = (uint8_t *)dst; + + uint32_t first = cap - offset; + if (first >= len) { + memcpy(out, s_data + offset, len); + } else { + memcpy(out, s_data + offset, first); + memcpy(out + first, s_data, len - first); + } +} + +/* Flush the entire buffer (header + data) to a binary file. */ +static void flush_to_file(const char *path) { + FILE *f = fopen(path, "wb"); + if (!f) { + fprintf(stderr, "Warning: debuglog flush failed — cannot open %s\n", path); + return; + } + uint32_t total = (uint32_t)sizeof(RingHeader) + s_header->data_size; + size_t written = fwrite(s_buffer, 1, total, f); + fclose(f); + if (written != total) { + fprintf(stderr, "Warning: debuglog flush incomplete (%zu / %u bytes)\n", + written, total); + } +} + +/* Find the player entity in the current level, or NULL. */ +static Entity *find_player(void) { + if (!s_level) return NULL; + EntityManager *em = &s_level->entities; + for (int i = 0; i < MAX_ENTITIES; i++) { + Entity *e = &em->entities[i]; + if (e->active && e->type == ENT_PLAYER) return e; + } + return NULL; +} + +/* Pack player-specific flags into a single byte. + * Bit layout: 0=on_ground, 1=jumping, 2=dashing, 3=invincible, 4=has_gun */ +static uint8_t pack_player_flags(const Entity *player) { + uint8_t f = 0; + if (player->body.on_ground) f |= (1 << 0); + PlayerData *pd = (PlayerData *)player->data; + if (pd) { + if (pd->jumping) f |= (1 << 1); + if (pd->dash_timer > 0) f |= (1 << 2); + if (pd->inv_timer > 0) f |= (1 << 3); + if (pd->has_gun) f |= (1 << 4); + } + return f; +} + +/* Pack entity-level flags into a single byte. + * Bit layout: 0=active, 1=facing_left, 2=dead, 3=invincible */ +static uint8_t pack_entity_flags(const Entity *e) { + uint8_t f = 0; + if (e->active) f |= (1 << 0); + if (e->flags & ENTITY_FACING_LEFT) f |= (1 << 1); + if (e->flags & ENTITY_DEAD) f |= (1 << 2); + if (e->flags & ENTITY_INVINCIBLE) f |= (1 << 3); + return f; +} + +/* Names for entity types — kept in sync with EntityType enum. */ +static const char *entity_type_name(uint8_t type) { + switch ((EntityType)type) { + case ENT_NONE: return "NONE"; + case ENT_PLAYER: return "PLAYER"; + case ENT_ENEMY_GRUNT: return "GRUNT"; + case ENT_ENEMY_FLYER: return "FLYER"; + case ENT_PROJECTILE: return "PROJ"; + case ENT_PICKUP: return "PICKUP"; + case ENT_PARTICLE: return "PARTICLE"; + case ENT_TURRET: return "TURRET"; + case ENT_MOVING_PLATFORM: return "PLATFORM"; + case ENT_FLAME_VENT: return "FLAME"; + case ENT_FORCE_FIELD: return "FORCEFIELD"; + case ENT_POWERUP: return "POWERUP"; + case ENT_DRONE: return "DRONE"; + case ENT_ASTEROID: return "ASTEROID"; + case ENT_SPACECRAFT: return "SPACECRAFT"; + case ENT_LASER_TURRET: return "LASER_TURRET"; + case ENT_ENEMY_CHARGER: return "CHARGER"; + case ENT_SPAWNER: return "SPAWNER"; + default: return "???"; + } +} + +/* Names for aim directions. */ +static const char *aim_dir_name(uint8_t aim) { + switch ((AimDir)aim) { + case AIM_FORWARD: return "FORWARD"; + case AIM_UP: return "UP"; + case AIM_DIAG_UP: return "DIAG_UP"; + default: return "???"; + } +} + +/* Names for input actions. */ +static const char *action_name(int a) { + switch ((Action)a) { + case ACTION_LEFT: return "LEFT"; + case ACTION_RIGHT: return "RIGHT"; + case ACTION_UP: return "UP"; + case ACTION_DOWN: return "DOWN"; + case ACTION_JUMP: return "JUMP"; + case ACTION_SHOOT: return "SHOOT"; + case ACTION_DASH: return "DASH"; + case ACTION_PAUSE: return "PAUSE"; + default: return "???"; + } +} + +/* ═══════════════════════════════════════════════════ + * Public API + * ═══════════════════════════════════════════════════ */ + +void debuglog_init(void) { + uint32_t total = (uint32_t)sizeof(RingHeader) + DEBUGLOG_BUFFER_SIZE; + s_buffer = calloc(1, total); + if (!s_buffer) { + fprintf(stderr, "Warning: debuglog_init failed to allocate %u bytes\n", total); + return; + } + + s_header = (RingHeader *)s_buffer; + s_data = s_buffer + sizeof(RingHeader); + + s_header->magic = RING_MAGIC; + s_header->version = RING_VERSION; + s_header->write_cursor = 0; + s_header->total_written = 0; + s_header->data_size = DEBUGLOG_BUFFER_SIZE; + + s_enabled = false; + s_level = NULL; + s_flush_counter = 0; + + printf("Debug log initialized (%u KB ring buffer)\n", + DEBUGLOG_BUFFER_SIZE / 1024); +} + +void debuglog_shutdown(void) { + if (!s_buffer) return; + + if (s_enabled && s_header->total_written > 0) { + flush_to_file(s_flush_path); + printf("Debug log flushed to %s on shutdown (%u snapshots)\n", + s_flush_path, s_header->total_written); + } + + free(s_buffer); + s_buffer = NULL; + s_header = NULL; + s_data = NULL; + s_enabled = false; + s_level = NULL; +} + +void debuglog_enable(void) { + s_enabled = true; + printf("Debug log recording enabled\n"); +} + +bool debuglog_is_enabled(void) { + return s_enabled; +} + +void debuglog_set_level(Level *lvl, const char *name) { + s_level = lvl; + if (name && name[0]) { + snprintf(s_level_name, sizeof(s_level_name), "%s", name); + } else { + s_level_name[0] = '\0'; + } +} + +void debuglog_record_tick(void) { + if (!s_enabled || !s_buffer) return; + + /* Count active entities (skip player — recorded separately). */ + uint16_t ent_count = 0; + if (s_level) { + EntityManager *em = &s_level->entities; + for (int i = 0; i < MAX_ENTITIES && ent_count < MAX_ENTITIES; i++) { + Entity *e = &em->entities[i]; + if (e->active && e->type != ENT_PLAYER) ent_count++; + } + } + + uint32_t frame_size = (uint32_t)sizeof(TickSnapshot) + + (uint32_t)(ent_count * sizeof(EntitySnapshot)); + + /* Don't write if the frame won't fit in the ring at all. */ + if (frame_size > s_header->data_size) return; + + /* Build the header portion on the stack. */ + TickSnapshot snap; + memset(&snap, 0, sizeof(snap)); + snap.tick = g_engine.tick; + snap.frame_size = frame_size; + + /* Input. */ + input_get_snapshot(&snap.input); + + /* Player state. */ + Entity *player = find_player(); + if (player) { + snap.player_x = player->body.pos.x; + snap.player_y = player->body.pos.y; + snap.player_vx = player->body.vel.x; + snap.player_vy = player->body.vel.y; + snap.player_health = (int8_t)player->health; + snap.player_flags = pack_player_flags(player); + PlayerData *pd = (PlayerData *)player->data; + if (pd) { + snap.player_dash_timer = pd->dash_timer; + snap.player_dash_charges = (int8_t)pd->dash_charges; + snap.player_inv_timer = pd->inv_timer; + snap.player_coyote = pd->coyote_timer; + snap.player_aim_dir = (uint8_t)pd->aim_dir; + } + } + + /* Camera. */ + if (s_level) { + snap.cam_x = s_level->camera.pos.x; + snap.cam_y = s_level->camera.pos.y; + } + + /* Physics globals. */ + snap.gravity = physics_get_gravity(); + snap.wind = physics_get_wind(); + + /* Level name (truncated to 31 chars + NUL). */ + if (s_level_name[0]) { + snprintf(snap.level_name, sizeof(snap.level_name), "%s", s_level_name); + } + + snap.entity_count = ent_count; + + /* Write header into ring. */ + ring_write(&snap, sizeof(TickSnapshot)); + + /* Write entity snapshots. */ + if (s_level && ent_count > 0) { + EntityManager *em = &s_level->entities; + for (int i = 0; i < MAX_ENTITIES; i++) { + Entity *e = &em->entities[i]; + if (!e->active || e->type == ENT_PLAYER) continue; + + EntitySnapshot es; + es.type = (uint8_t)e->type; + es.flags = pack_entity_flags(e); + es.health = (int16_t)e->health; + es.pos_x = e->body.pos.x; + es.pos_y = e->body.pos.y; + es.vel_x = e->body.vel.x; + es.vel_y = e->body.vel.y; + ring_write(&es, sizeof(EntitySnapshot)); + } + } + + s_header->total_written++; + + /* Periodic safety-net flush. */ + s_flush_counter++; + if (s_flush_counter >= FLUSH_INTERVAL) { + s_flush_counter = 0; + flush_to_file(s_flush_path); + } +} + +void debuglog_dump(const char *path) { + if (!s_buffer || s_header->total_written == 0) { + printf("Debug log: nothing to dump (0 snapshots recorded)\n"); + return; + } + + FILE *f = fopen(path, "w"); + if (!f) { + fprintf(stderr, "Error: cannot open %s for writing\n", path); + return; + } + + fprintf(f, "=== Debug Log Dump ===\n"); + fprintf(f, "Total snapshots written: %u\n\n", s_header->total_written); + + /* Walk backwards from write_cursor to find snapshots. + * We read forward through the ring, starting from the oldest data. */ + + /* To find the oldest snapshot: if the buffer has wrapped, oldest + * starts at write_cursor. Otherwise it starts at 0. */ + uint32_t cap = s_header->data_size; + uint32_t total_data_bytes; + uint32_t read_pos; + + /* Estimate how much data we've written total. If total_written + * snapshots have been recorded and we assume average frame size + * is frame_size, the buffer may have wrapped. We use a simpler + * approach: scan forward from the oldest point. */ + if (s_header->total_written * sizeof(TickSnapshot) >= cap) { + /* Buffer has likely wrapped. Start reading at write_cursor + * (which is where the oldest data begins). */ + read_pos = s_header->write_cursor; + total_data_bytes = cap; + } else { + read_pos = 0; + total_data_bytes = s_header->write_cursor; + } + + uint32_t bytes_read = 0; + uint32_t snapshots_dumped = 0; + uint32_t first_tick = 0; + uint32_t last_tick = 0; + + while (bytes_read + sizeof(TickSnapshot) <= total_data_bytes) { + TickSnapshot snap; + ring_read(read_pos, &snap, sizeof(TickSnapshot)); + + /* Sanity check. */ + if (snap.frame_size < sizeof(TickSnapshot) || + snap.frame_size > cap) { + break; + } + if (bytes_read + snap.frame_size > total_data_bytes) { + break; + } + + if (snapshots_dumped == 0) first_tick = snap.tick; + last_tick = snap.tick; + + fprintf(f, "--- Tick %u ---\n", snap.tick); + + /* Input. */ + fprintf(f, "Input:"); + for (int a = 0; a < ACTION_COUNT && a < 8; a++) { + if (snap.input.held & (1 << a)) { + fprintf(f, " [%s]", action_name(a)); + } + if (snap.input.pressed & (1 << a)) { + fprintf(f, " [%s_pressed]", action_name(a)); + } + if (snap.input.released & (1 << a)) { + fprintf(f, " [%s_released]", action_name(a)); + } + } + fprintf(f, "\n"); + + /* Player. */ + fprintf(f, "Player: pos=(%.1f, %.1f) vel=(%.1f, %.1f) hp=%d", + snap.player_x, snap.player_y, + snap.player_vx, snap.player_vy, + snap.player_health); + fprintf(f, " on_ground=%d", (snap.player_flags >> 0) & 1); + fprintf(f, " jumping=%d", (snap.player_flags >> 1) & 1); + fprintf(f, " dashing=%d", (snap.player_flags >> 2) & 1); + fprintf(f, " inv=%d", (snap.player_flags >> 3) & 1); + fprintf(f, " has_gun=%d", (snap.player_flags >> 4) & 1); + fprintf(f, " aim=%s", aim_dir_name(snap.player_aim_dir)); + fprintf(f, " dash_charges=%d dash_timer=%.2f inv_timer=%.2f coyote=%.3f\n", + snap.player_dash_charges, snap.player_dash_timer, + snap.player_inv_timer, snap.player_coyote); + + /* Camera + physics. */ + fprintf(f, "Camera: (%.1f, %.1f)\n", snap.cam_x, snap.cam_y); + fprintf(f, "Physics: gravity=%.1f wind=%.1f\n", + snap.gravity, snap.wind); + + if (snap.level_name[0]) { + fprintf(f, "Level: %s\n", snap.level_name); + } + + /* Entities. */ + fprintf(f, "Entities (%u active):\n", snap.entity_count); + + uint32_t ent_offset = (read_pos + sizeof(TickSnapshot)) % cap; + uint16_t count = snap.entity_count; + if (count > MAX_ENTITIES) count = MAX_ENTITIES; + for (uint16_t i = 0; i < count; i++) { + EntitySnapshot es; + ring_read(ent_offset, &es, sizeof(EntitySnapshot)); + ent_offset = (ent_offset + sizeof(EntitySnapshot)) % cap; + + fprintf(f, " [%u] %-12s pos=(%.0f, %.0f) vel=(%.0f, %.0f) hp=%d\n", + i, entity_type_name(es.type), + es.pos_x, es.pos_y, + es.vel_x, es.vel_y, + es.health); + } + + fprintf(f, "\n"); + + read_pos = (read_pos + snap.frame_size) % cap; + bytes_read += snap.frame_size; + snapshots_dumped++; + } + + /* Write summary at the top. */ + if (snapshots_dumped > 0) { + float seconds = (float)(last_tick - first_tick) / TICK_RATE; + fprintf(f, "=== Summary: ticks %u to %u (%u snapshots, %.1f seconds) ===\n", + first_tick, last_tick, snapshots_dumped, seconds); + } + + fclose(f); + printf("Debug log dumped to %s (%u snapshots)\n", path, snapshots_dumped); +} diff --git a/src/engine/debuglog.h b/src/engine/debuglog.h new file mode 100644 index 0000000..16f1f7e --- /dev/null +++ b/src/engine/debuglog.h @@ -0,0 +1,91 @@ +#ifndef JNR_DEBUGLOG_H +#define JNR_DEBUGLOG_H + +#include +#include +#include "config.h" + +/* Forward declaration — game layer type, registered via pointer. */ +typedef struct Level Level; + +/* ═══════════════════════════════════════════════════ + * Snapshot data structures + * ═══════════════════════════════════════════════════ */ + +/* Packed input snapshot — 3 bytes (ACTION_COUNT actions × 3 states). */ +typedef struct InputSnapshot { + uint8_t held; /* bitmask of ACTION_* held this tick */ + uint8_t pressed; /* bitmask of ACTION_* pressed this tick */ + uint8_t released; /* bitmask of ACTION_* released this tick */ +} InputSnapshot; + +/* Per-entity summary — 20 bytes each. */ +typedef struct EntitySnapshot { + uint8_t type; /* EntityType */ + uint8_t flags; /* active, facing, dead, invincible */ + int16_t health; + float pos_x, pos_y; + float vel_x, vel_y; +} EntitySnapshot; + +/* Full tick snapshot — fixed-size header + variable entity list. + * Written sequentially into the ring buffer; frame_size stores + * the total byte count so the reader can skip forward. */ +typedef struct TickSnapshot { + uint32_t tick; /* g_engine.tick */ + uint32_t frame_size; /* total bytes of this record */ + /* Input */ + InputSnapshot input; /* 3 bytes */ + uint8_t _pad0; /* align to 4 bytes */ + /* Player state (expanded) */ + float player_x, player_y; + float player_vx, player_vy; + int8_t player_health; + uint8_t player_flags; /* on_ground, jumping, dashing, inv, has_gun */ + uint8_t player_aim_dir; + int8_t player_dash_charges; + float player_dash_timer; + float player_inv_timer; + float player_coyote; + /* Camera */ + float cam_x, cam_y; + /* Physics globals */ + float gravity; + float wind; + /* Level info */ + char level_name[32]; /* truncated level path/tag */ + /* Entity summary */ + uint16_t entity_count; /* number of active entities */ + uint16_t _pad1; /* alignment padding */ + /* EntitySnapshot entities[] follows immediately in the buffer. */ +} TickSnapshot; + +/* ═══════════════════════════════════════════════════ + * Public API + * ═══════════════════════════════════════════════════ */ + +/* Allocate ring buffer. Safe to call even if logging stays disabled. */ +void debuglog_init(void); + +/* Final flush + free. */ +void debuglog_shutdown(void); + +/* Turn on recording. */ +void debuglog_enable(void); + +/* Query state. */ +bool debuglog_is_enabled(void); + +/* Register the active Level pointer so the debuglog can read game state. + * Pass NULL before freeing a level. + * name is an optional label (e.g. file path); NULL or "" to clear. */ +void debuglog_set_level(Level *lvl, const char *name); + +/* Called once per tick from engine_frame, after update, before consume. + * Captures input + game state into the ring buffer. */ +void debuglog_record_tick(void); + +/* Dump the ring buffer contents to a human-readable text file. */ +void debuglog_dump(const char *path); + +#endif /* JNR_DEBUGLOG_H */ diff --git a/src/engine/input.c b/src/engine/input.c index 2f2dfe8..6f3bd40 100644 --- a/src/engine/input.c +++ b/src/engine/input.c @@ -1,4 +1,5 @@ #include "engine/input.h" +#include "engine/debuglog.h" #include static bool s_current[ACTION_COUNT]; @@ -196,6 +197,19 @@ bool input_key_held(SDL_Scancode key) { return s_key_state && s_key_state[key]; } +/* ── Debug log snapshot ───────────────────────────── */ + +void input_get_snapshot(InputSnapshot *out) { + out->held = 0; + out->pressed = 0; + out->released = 0; + for (int i = 0; i < ACTION_COUNT && i < 8; i++) { + if (s_current[i]) out->held |= (uint8_t)(1 << i); + if (s_latched_pressed[i]) out->pressed |= (uint8_t)(1 << i); + if (s_latched_released[i]) out->released |= (uint8_t)(1 << i); + } +} + void input_shutdown(void) { /* Nothing to clean up */ } diff --git a/src/engine/input.h b/src/engine/input.h index 292ef01..1f94c81 100644 --- a/src/engine/input.h +++ b/src/engine/input.h @@ -50,4 +50,10 @@ int input_mouse_scroll(void); bool input_key_pressed(SDL_Scancode key); bool input_key_held(SDL_Scancode key); +/* Pack current input state into a compact bitmask snapshot. + * Used by the debug log to record per-tick input without + * exposing internal arrays. */ +typedef struct InputSnapshot InputSnapshot; /* defined in debuglog.h */ +void input_get_snapshot(InputSnapshot *out); + #endif /* JNR_INPUT_H */ diff --git a/src/main.c b/src/main.c index ed4c7ff..a718758 100644 --- a/src/main.c +++ b/src/main.c @@ -1,6 +1,7 @@ #include "engine/core.h" #include "engine/input.h" #include "engine/font.h" +#include "engine/debuglog.h" #include "game/level.h" #include "game/levelgen.h" #include "game/editor.h" @@ -34,6 +35,7 @@ static GameMode s_mode = MODE_PLAY; static bool s_use_procgen = false; static bool s_dump_lvl = false; static bool s_use_editor = false; +static bool s_use_debuglog = false; static uint32_t s_gen_seed = 0; static char s_edit_path[256] = {0}; static char s_level_path[ASSET_PATH_MAX] = {0}; /* path of active play-mode level */ @@ -94,6 +96,7 @@ static const char *theme_name(LevelTheme t) { static bool load_level_file(const char *path) { if (!level_load(&s_level, path)) return false; snprintf(s_level_path, sizeof(s_level_path), "%s", path); + debuglog_set_level(&s_level, path); return true; } @@ -192,6 +195,7 @@ static void load_generated_level(void) { g_engine.running = false; } s_level_path[0] = '\0'; /* generated levels have no file path */ + debuglog_set_level(&s_level, "generated"); } static void load_station_level(void) { @@ -217,6 +221,7 @@ static void load_station_level(void) { g_engine.running = false; } s_level_path[0] = '\0'; /* generated levels have no file path */ + debuglog_set_level(&s_level, "generated:station"); } static void load_mars_base_level(void) { @@ -248,6 +253,7 @@ static void load_mars_base_level(void) { g_engine.running = false; } s_level_path[0] = '\0'; + debuglog_set_level(&s_level, "generated:mars_base"); } /* ── Analytics session helpers ── */ @@ -268,6 +274,7 @@ static void end_session(const char *reason) { /* ── Switch to editor mode ── */ static void enter_editor(void) { if (s_mode == MODE_PLAY) { + debuglog_set_level(NULL, NULL); level_free(&s_level); } s_mode = MODE_EDITOR; @@ -307,6 +314,7 @@ static void enter_test_play(void) { /* ── Return from test play to editor ── */ static void return_to_editor(void) { + debuglog_set_level(NULL, NULL); level_free(&s_level); s_mode = MODE_EDITOR; s_testing_from_editor = false; @@ -315,6 +323,7 @@ static void return_to_editor(void) { /* ── Restart current level (file-based or generated) ── */ static void restart_level(void) { + debuglog_set_level(NULL, NULL); level_free(&s_level); if (s_level_path[0]) { if (!load_level_file(s_level_path)) { @@ -329,6 +338,7 @@ static void restart_level(void) { /* ── Level load dispatch — loads the next level based on target string ── */ static void dispatch_level_load(const char *target) { + debuglog_set_level(NULL, NULL); if (target[0] == '\0') { /* Empty target = victory / end of game. */ printf("Level complete! (no next level)\n"); @@ -457,6 +467,7 @@ static void game_update(float dt) { /* Tear down whatever mode we are in. */ if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED || s_mode == MODE_TRANSITION) { + debuglog_set_level(NULL, NULL); transition_reset(&s_transition); level_free(&s_level); } @@ -524,6 +535,11 @@ static void game_update(float dt) { /* ── Play mode ── */ + /* F12: dump debug log to text file. */ + if (input_key_pressed(SDL_SCANCODE_F12) && debuglog_is_enabled()) { + debuglog_dump("debug_log.txt"); + } + /* Pause on escape (return to editor during test play) */ if (input_pressed(ACTION_PAUSE)) { if (s_testing_from_editor) { @@ -539,6 +555,7 @@ static void game_update(float dt) { if (!s_testing_from_editor && input_key_pressed(SDL_SCANCODE_E)) { /* Load the current level file into the editor if available */ snprintf(s_edit_path, sizeof(s_edit_path), "%s", s_level_path); + debuglog_set_level(NULL, NULL); level_free(&s_level); enter_editor(); return; @@ -550,6 +567,7 @@ static void game_update(float dt) { if (r_pressed && !r_was_pressed) { printf("\n=== Regenerating level ===\n"); end_session("quit"); + debuglog_set_level(NULL, NULL); level_free(&s_level); s_gen_seed = (uint32_t)time(NULL); s_use_procgen = true; @@ -647,6 +665,7 @@ static void game_render(float interpolation) { static void game_shutdown(void) { end_session("quit"); + debuglog_set_level(NULL, NULL); /* Always free both — editor may have been initialized even if we're * currently in play mode (e.g. shutdown during test play). editor_free @@ -675,6 +694,8 @@ int main(int argc, char *argv[]) { if (i + 1 < argc) { s_gen_seed = (uint32_t)atoi(argv[++i]); } + } else if (strcmp(argv[i], "--debug-log") == 0) { + s_use_debuglog = true; } else if (strcmp(argv[i], "--edit") == 0 || strcmp(argv[i], "-e") == 0) { s_use_editor = true; /* Optional: next arg is a file path */ @@ -687,6 +708,7 @@ int main(int argc, char *argv[]) { printf(" --dump, -d Dump generated level to assets/levels/generated.lvl\n"); printf(" --seed N, -s N Set RNG seed for generation\n"); printf(" --edit [file], -e [file] Open level editor (optionally load a .lvl file)\n"); + printf(" --debug-log Record game state every tick (F12 to dump)\n"); printf("\nIn-game:\n"); printf(" R Regenerate level with new random seed\n"); printf(" E Open level editor\n"); @@ -739,6 +761,11 @@ int main(int argc, char *argv[]) { return 1; } + debuglog_init(); + if (s_use_debuglog) { + debuglog_enable(); + } + engine_set_callbacks((GameCallbacks){ .init = game_init, .update = game_update, @@ -747,6 +774,7 @@ int main(int argc, char *argv[]) { }); engine_run(); + debuglog_shutdown(); engine_shutdown(); return 0;