Plan: Game State Debug Log (Binary Ring Buffer) #19

Closed
opened 2026-03-16 11:10:20 +00:00 by tas · 0 comments
Owner

Plan: Game State Debug Log (Binary Ring Buffer)
Overview
A new src/engine/debuglog module that records a comprehensive snapshot of game state every tick into a fixed-size binary ring buffer file. Activated by --debug-log command-line flag. On crash or manual dump (hotkey), the most recent N seconds of gameplay are preserved for post-mortem analysis. A companion dump function writes the buffer to a human-readable text file.
Module: src/engine/debuglog.h / debuglog.c
Data structures:
/* Packed input snapshot — 3 bytes (8 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 /
typedef struct TickSnapshot {
uint32_t tick; /
g_engine.tick /
uint32_t frame_size; /
total bytes of this record /
/
Input /
InputSnapshot input; /
3 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 /
float player_dash_timer;
int8_t player_dash_charges;
float player_inv_timer;
float player_coyote;
uint8_t player_aim_dir;
/
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 /
EntitySnapshot entities[]; /
variable-length array */
} TickSnapshot;
Ring buffer on disk:

  • Fixed-size file: 4 MB (DEBUGLOG_BUFFER_SIZE). At ~200 bytes/tick (with ~10 active entities average), this holds 20,000 ticks = 333 seconds (~5.5 minutes) of gameplay.
  • File header: magic number, version, write cursor position, total snapshots written.
  • Snapshots are written sequentially, wrapping around when the buffer is full.
  • The write cursor in the header is updated after each snapshot write.
  • Use fwrite with buffered I/O; flush periodically (every ~60 ticks = 1 second) rather than every tick to minimize overhead.
    Actually — in-memory ring buffer with periodic flush is better:
  • Keep the ring buffer in memory (4 MB malloc at init).
  • Write to disk only on: (a) manual dump hotkey, (b) clean shutdown, (c) periodically every 10 seconds as a safety net.
  • This eliminates per-tick I/O overhead entirely. The only cost is the memcpy into the ring buffer (~200 bytes per tick).
    Public API:
    void debuglog_init(void); /* Allocate ring buffer /
    void debuglog_shutdown(void); /
    Final flush + free /
    void debuglog_enable(void); /
    Turn on recording /
    bool debuglog_is_enabled(void); /
    Query state /
    /
    Called once per tick from engine_frame, after update, before consume /
    void debuglog_record_tick(void);
    /
    Dump the ring buffer contents to a readable text file */
    void debuglog_dump(const char *path);
    Integration Points
  1. main.c — Parse --debug-log flag, call debuglog_enable() after engine init.
  2. input.c — New function input_get_snapshot(InputSnapshot *out) that copies the current s_latched_pressed, s_current, and s_latched_released arrays into the packed bitmask struct. This exposes input state without breaking encapsulation of the static arrays.
  3. core.c — In engine_frame(), after s_callbacks.update(DT) and before input_consume(), call debuglog_record_tick(). This captures the input state that was just consumed by the game update, plus the resulting game state.
  4. physics.c — New functions physics_get_gravity() and physics_get_wind() already exist in the header. Good — these are already public.
  5. level.c / level.h — Need a way for the debuglog to access the current Level*. Options:
    • (a) Pass the Level* to debuglog_record_tick() — cleanest but requires the engine layer to know about the game layer.
    • (b) Register a pointer via debuglog_set_level(Level *lvl) — called from main.c when a level loads.
    • I'll go with (b) — a registered pointer, similar to how stats_set_active() works.
  6. main.c — Hotkey for manual dump: F12 key triggers debuglog_dump("debug_log.txt"). Add to the play-mode key handling.
  7. main.c — Call debuglog_set_level(&s_level) after every level load, and debuglog_set_level(NULL) before level free.
    Dump Format (text output)
    When debuglog_dump() is called, it writes a human-readable text file like:
    === Debug Log Dump ===
    Ticks: 18200 to 19800 (1600 ticks, 26.7 seconds)
    Level: assets/levels/mars02.lvl
    --- Tick 18200 ---
    Input: [LEFT] [JUMP_pressed]
    Player: pos=(1234.5, 567.8) vel=(-150.0, -200.3) hp=3 on_ground=0 dashing=0 aim=FORWARD
    Camera: (1100.2, 400.0)
    Physics: gravity=700.0 wind=0.0
    Entities (12 active):
    [0] GRUNT pos=(1400, 580) vel=(-40, 0) hp=2
    [1] FLYER pos=(1600, 300) vel=(0, 10) hp=1
    ...
    --- Tick 18201 ---
    Input: [LEFT]
    Player: pos=(1232.0, 564.5) vel=(-150.0, -180.0) hp=3 ...
    ...
    File Changes Summary
    File Change
    src/engine/debuglog.h New — Header with API and snapshot structs
    src/engine/debuglog.c New — Ring buffer implementation, record/dump logic
    src/engine/input.h Add input_get_snapshot() declaration
    src/engine/input.c Implement input_get_snapshot() (~10 lines)
    src/engine/core.c Add debuglog_record_tick() call in the tick loop (~3 lines)
    src/main.c Parse --debug-log flag, debuglog_set_level() calls, F12 dump hotkey, help text update (~25 lines)
    Makefile Add debuglog.o to the object list (~1 line)
    include/config.h Add DEBUGLOG_BUFFER_SIZE constant (~2 lines)
    Design Tradeoffs
  • In-memory vs. on-disk ring buffer: In-memory is chosen for zero I/O overhead during gameplay. The risk is losing data on a hard crash (segfault). The periodic 10-second flush mitigates this — worst case you lose the last 10 seconds, but still have the preceding minutes.
  • Variable-size records: Entity count varies per tick, so snapshot size varies. The ring buffer tracks record boundaries via frame_size in each header. This is more complex than fixed-size records but avoids wasting space when few entities are active.
  • Engine/game layer boundary: The debuglog module lives in src/engine/ but needs access to Level (a game-layer struct). The registered-pointer pattern (debuglog_set_level()) keeps the dependency soft — the engine header only forward-declares Level, and the game layer opts in.
    Performance Budget
  • Per-tick cost: One input_get_snapshot() call (pack 24 bools into 3 bytes), iterate active entities (~10-50) to fill EntitySnapshot array, memcpy ~200-500 bytes into ring buffer. Total: <10 microseconds per tick, well within the 16ms frame budget.
  • Memory: 4 MB ring buffer. Acceptable for a debug feature.
  • Periodic flush: fwrite of 4 MB every 10 seconds. Takes <1ms on any modern system.
Plan: Game State Debug Log (Binary Ring Buffer) Overview A new src/engine/debuglog module that records a comprehensive snapshot of game state every tick into a fixed-size binary ring buffer file. Activated by --debug-log command-line flag. On crash or manual dump (hotkey), the most recent N seconds of gameplay are preserved for post-mortem analysis. A companion dump function writes the buffer to a human-readable text file. Module: src/engine/debuglog.h / debuglog.c Data structures: /* Packed input snapshot — 3 bytes (8 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 */ typedef struct TickSnapshot { uint32_t tick; /* g_engine.tick */ uint32_t frame_size; /* total bytes of this record */ /* Input */ InputSnapshot input; /* 3 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 */ float player_dash_timer; int8_t player_dash_charges; float player_inv_timer; float player_coyote; uint8_t player_aim_dir; /* 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 */ EntitySnapshot entities[]; /* variable-length array */ } TickSnapshot; Ring buffer on disk: - Fixed-size file: 4 MB (DEBUGLOG_BUFFER_SIZE). At ~200 bytes/tick (with ~10 active entities average), this holds 20,000 ticks = 333 seconds (~5.5 minutes) of gameplay. - File header: magic number, version, write cursor position, total snapshots written. - Snapshots are written sequentially, wrapping around when the buffer is full. - The write cursor in the header is updated after each snapshot write. - Use fwrite with buffered I/O; flush periodically (every ~60 ticks = 1 second) rather than every tick to minimize overhead. Actually — in-memory ring buffer with periodic flush is better: - Keep the ring buffer in memory (4 MB malloc at init). - Write to disk only on: (a) manual dump hotkey, (b) clean shutdown, (c) periodically every 10 seconds as a safety net. - This eliminates per-tick I/O overhead entirely. The only cost is the memcpy into the ring buffer (~200 bytes per tick). Public API: void debuglog_init(void); /* Allocate ring buffer */ void debuglog_shutdown(void); /* Final flush + free */ void debuglog_enable(void); /* Turn on recording */ bool debuglog_is_enabled(void); /* Query state */ /* Called once per tick from engine_frame, after update, before consume */ void debuglog_record_tick(void); /* Dump the ring buffer contents to a readable text file */ void debuglog_dump(const char *path); Integration Points 1. main.c — Parse --debug-log flag, call debuglog_enable() after engine init. 2. input.c — New function input_get_snapshot(InputSnapshot *out) that copies the current s_latched_pressed, s_current, and s_latched_released arrays into the packed bitmask struct. This exposes input state without breaking encapsulation of the static arrays. 3. core.c — In engine_frame(), after s_callbacks.update(DT) and before input_consume(), call debuglog_record_tick(). This captures the input state that was just consumed by the game update, plus the resulting game state. 4. physics.c — New functions physics_get_gravity() and physics_get_wind() already exist in the header. Good — these are already public. 5. level.c / level.h — Need a way for the debuglog to access the current Level*. Options: - (a) Pass the Level* to debuglog_record_tick() — cleanest but requires the engine layer to know about the game layer. - (b) Register a pointer via debuglog_set_level(Level *lvl) — called from main.c when a level loads. - I'll go with (b) — a registered pointer, similar to how stats_set_active() works. 6. main.c — Hotkey for manual dump: F12 key triggers debuglog_dump("debug_log.txt"). Add to the play-mode key handling. 7. main.c — Call debuglog_set_level(&s_level) after every level load, and debuglog_set_level(NULL) before level free. Dump Format (text output) When debuglog_dump() is called, it writes a human-readable text file like: === Debug Log Dump === Ticks: 18200 to 19800 (1600 ticks, 26.7 seconds) Level: assets/levels/mars02.lvl --- Tick 18200 --- Input: [LEFT] [JUMP_pressed] Player: pos=(1234.5, 567.8) vel=(-150.0, -200.3) hp=3 on_ground=0 dashing=0 aim=FORWARD Camera: (1100.2, 400.0) Physics: gravity=700.0 wind=0.0 Entities (12 active): [0] GRUNT pos=(1400, 580) vel=(-40, 0) hp=2 [1] FLYER pos=(1600, 300) vel=(0, 10) hp=1 ... --- Tick 18201 --- Input: [LEFT] Player: pos=(1232.0, 564.5) vel=(-150.0, -180.0) hp=3 ... ... File Changes Summary File Change src/engine/debuglog.h New — Header with API and snapshot structs src/engine/debuglog.c New — Ring buffer implementation, record/dump logic src/engine/input.h Add input_get_snapshot() declaration src/engine/input.c Implement input_get_snapshot() (~10 lines) src/engine/core.c Add debuglog_record_tick() call in the tick loop (~3 lines) src/main.c Parse --debug-log flag, debuglog_set_level() calls, F12 dump hotkey, help text update (~25 lines) Makefile Add debuglog.o to the object list (~1 line) include/config.h Add DEBUGLOG_BUFFER_SIZE constant (~2 lines) Design Tradeoffs - In-memory vs. on-disk ring buffer: In-memory is chosen for zero I/O overhead during gameplay. The risk is losing data on a hard crash (segfault). The periodic 10-second flush mitigates this — worst case you lose the last 10 seconds, but still have the preceding minutes. - Variable-size records: Entity count varies per tick, so snapshot size varies. The ring buffer tracks record boundaries via frame_size in each header. This is more complex than fixed-size records but avoids wasting space when few entities are active. - Engine/game layer boundary: The debuglog module lives in src/engine/ but needs access to Level (a game-layer struct). The registered-pointer pattern (debuglog_set_level()) keeps the dependency soft — the engine header only forward-declares Level, and the game layer opts in. Performance Budget - Per-tick cost: One input_get_snapshot() call (pack 24 bools into 3 bytes), iterate active entities (~10-50) to fill EntitySnapshot array, memcpy ~200-500 bytes into ring buffer. Total: <10 microseconds per tick, well within the 16ms frame budget. - Memory: 4 MB ring buffer. Acceptable for a debug feature. - Periodic flush: fwrite of 4 MB every 10 seconds. Takes <1ms on any modern system.
tas closed this issue 2026-03-16 20:34:37 +00:00
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tas/major_tom#19