Files
aee/CLAUDE.md

20 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

Aventures En Egipte (AEE) — a retro-style 2D game written in C++ using SDL3. The game uses a software-rendered 8-bit paletted graphics engine (320x200, 256 colors), custom audio (JailAudio), and GIF-based assets. The codebase and commit messages are in Valencian/Catalan.

Build

# Linux
cmake -B build
cmake --build build

# Windows (MinGW)
cmake -B build -G "MinGW Makefiles"
cmake --build build

Dependencies: SDL3. Uses CMake (minimum 3.10) with C++20. SPIR-V shaders compiled automatically if glslc is available; precompiled headers used as fallback.

The executable is output to the project root. The data/ folder must be in the working directory at runtime.

Architecture

New Rules (Modernization Phase)

The old "Golden Rule: Do Not Touch Gameplay" has been revoked. The original C-style code (jail engine + gameplay modules) is now a modernization target, not a sacred zone. The parallel-overlay approach has reached its ceiling: fades and cinematics are still blocking loops, audio relies on an async SDL_AddTimer, and the emulator-style game thread blocking at publishFrame is incompatible with an emscripten port.

The five current objectives are:

  1. Idiomatic C++: RAII, std::vector/std::string/std::optional, classes with real constructors/destructors. No more raw malloc/free in structs.
  2. Zero blocking events: no while (...) { poll; }, no SDL_Delay inside gameplay, no cv.wait() in publishFrame. Every subsystem must be able to advance in a single tick call.
  3. Time-based: animations, cinematics and fades measured in milliseconds, not frames. JG_ShouldUpdate() as gameplay gate is on its way out.
  4. Overlay integrated: overlay stops being a post-game layer painted by Director — it becomes part of the same render pass the game tick produces.
  5. SDL3 callbacks: main loop handed over to SDL_AppInit / SDL_AppIterate / SDL_AppEvent / SDL_AppQuit, single-threaded, compatible with emscripten.

Iron rule: zero gameplay regressions. Each phase of the modernization must leave the game playing identically — same feel, same timings, same collisions, same scoring. See glittery-sprouting-pumpkin.md for the phased plan.

The current emulator-thread architecture (Director + game thread + publishFrame mutex/cv) is transitional. It will be dismantled in Phase 5 and replaced by a single-threaded SDL_AppIterate loop in Phase 7.

Modernization Targets

Invariants to preserve (touch these and you broke the game):

  • Gameplay feel, movement speed, enemy AI behavior
  • Collision detection, scoring, lives, level progression
  • Visible animation cadence (once translated to ms, must look identical)
  • Difficulty curves and cinematic timings
  • Cheat codes (reviu, alone, obert) — currently broken, should be restored
  • Original palettes, fades, music cues

Free to change (internal representation):

  • Data structures (structs → classes with RAII)
  • Ownership (raw pointers → std::unique_ptr/std::vector/std::string)
  • Timing representation (frame counters → ms accumulators)
  • Threading model (game thread → single-threaded state machine)
  • Global state (the old info:: namespace is now an inline singleton info::ctx of type GameContext; access is info::ctx.X instead of info::X. Can be reset with info::ctx.reset())
  • API shapes of jail subsystems (as long as callers are updated consistently)

Boundary: Original vs New Code

Path Owner Rule
source/core/jail/ Legacy engine, modernization target Free to modify with care — preserve external behavior
source/game/*.cpp/hpp Legacy gameplay, modernization target Free to modify with care — preserve gameplay invariants
source/core/rendering/ New presentation layer Free to modify
source/core/input/ New input layer Free to modify
source/utils/ New utilities Free to modify
source/game/options,defines,defaults New config system Free to modify
data/*.gif, *.ogg Original assets Do not modify — assets remain untouchable
data/fonts/, data/ui/, data/shaders/ New assets Free to modify

Legacy "Jail" Engine (source/core/jail/) — modernization target

Flat C-style APIs (no classes), prefixed by subsystem. Being progressively converted to idiomatic C++ (see Phase 1 of the plan). External API names are kept stable during the transition to avoid churning call sites.

  • JG (jgame) — Game loop timing: init/finalize, fixed-timestep update via JG_ShouldUpdate()
  • JD8 (jdraw8) — 8-bit paletted software renderer. 320x200 screen buffer (JD8_Surface = Uint8*), palette-indexed blitting with color-key transparency, fade effects. JD8_Flip() converts palette→ARGB and delegates to Screen::present()
  • JA (jail_audio) — Custom audio mixing using SDL3 audio streams (OGG via stb_vorbis, WAV)
  • JI (jinput) — Input: keyboard state polling, key debouncing, cheat code detection. Filters GUI keys from game, calls GlobalInputs::handle() and Mouse::updateCursorVisibility() each update
  • JF (jfile) — File I/O: filesystem folder mode (data/) or packed resource file (.jrf). Config folder at ~/.config/jailgames/aee/

System Layer (source/core/system/)

  • Director (director.hpp/cpp) — Orchestrator singleton. Owns main thread. Launches game thread that runs ModuleGame/ModuleSequence::Go(). Emulator-style architecture: Director runs at ~60 FPS independently, polls SDL events, updates overlay, presents frames. Game thread blocks at JD8_Flip()Director::publishFrame() until Director consumes the frame. Director is non-blocking: if no new frame is available, it re-presents the last known game frame with fresh overlay on top

Presentation Layer (source/core/rendering/)

  • Screen (screen.hpp/cpp) — Singleton. Manages SDL_Window, SDL_Renderer, SDL_Texture. Dual rendering path: SDL3GPU with shaders (primary) or SDL_Renderer fallback. Handles fullscreen, zoom, aspect ratio 4:3, integer scaling, VSync. Counts FPS and updates render info segments
  • Overlay (overlay.hpp/cpp) — Paints directly on the ARGB pixel buffer before presentation. Handles notifications (slide-in animation), animated render info (4 independent segments with per-segment anim + vertical slide state machine), persistent PAUSA indicator, and double-ESC-to-quit logic
  • Text (text.hpp/cpp) — Bitmap font renderer. Loads .fnt + .gif pairs, renders UTF-8 glyphs directly on Uint32* ARGB buffer. Supports drawClipped(x, y, text, color, clip_xmin, clip_xmax, clip_ymin, clip_ymax) for per-pixel 2D clipping (used by menu transitions)
  • Menu (menu.hpp/cpp) — Floating options menu with stack-based page navigation (root → VIDEO/AUDIO/CONTROLS). Uses ItemKind enum: Toggle/Cycle/IntRange/Submenu/KeyBind. Features: vertical expand animation on open (outQuad), horizontal slide + height interpolation on page transitions (forward/backward direction), key capture mode for remapping. Callbacks delegate to Screen::* / Overlay::* / Options::applyAudio() to avoid duplication
  • SDL3GPUShader (sdl3gpu/) — GPU shader backend (Vulkan/Metal). PostFX and CRT-Pi shaders with presets, supersampling (3×/6×/9×), Lanczos downscaling. Supports 4:3 aspect ratio stretch fused into the upscale pass to avoid artifacts

Input Layer (source/core/input/)

  • GlobalInputs (global_inputs.hpp/cpp) — Maps configurable function keys to presentation actions. Uses debounce. Returns whether a key was consumed (to suppress from game layer)
  • Mouse (mouse.hpp/cpp) — Auto-hides cursor after 3 seconds of inactivity
  • Gamepad (gamepad.hpp/cpp) — First-gamepad support with hot-plug. Poll-based each frame: D-pad/left stick (deadzone 12000) → virtual arrow keys for game movement; A/B buttons, Start, Back translate to synthetic SDL key events (F12/ESC/Enter/Backspace) when menu is open, so Director handles them exactly like keyboard. Loads extra mappings from gamecontrollerdb.txt (next to the executable) at init via SDL_AddGamepadMappingsFromFile, extending SDL's built-in controller database
  • KeyRemap (key_remap.hpp/cpp) — Each frame, reads Options::keys_game.* and mirrors physical keyboard state to virtual standard scancodes (SDL_SCANCODE_UP/DOWN/LEFT/RIGHT). Allows full movement key remapping without touching hardcoded game code in prota.cpp/mapa.cpp

Locale Layer (source/core/locale/)

  • Locale (locale.hpp/cpp) — Flat key → string map loaded from YAML at boot. Keys use dot notation (menu.items.zoom, notifications.pause). Returns the key itself when missing (visible fallback for debugging). Strings live in data/locale/ca.yaml (Valencian, default). Designed for future multilanguage support

Configuration System (source/game/)

Follows the pattern from jaildoctors_dilemma, persists to YAML:

  • defines.hpp — Game constants: Texts::WINDOW_TITLE, Texts::VERSION, GameScreen::WIDTH/HEIGHT
  • defaults.hpp — Default values: Defaults::KeysGUI, Defaults::KeysGame, Defaults::Video, Defaults::Audio, Defaults::Window, Defaults::Game
  • options.hpp/cppOptions namespace with inline globals and YAML load/save. Structs: KeysGUI, KeysGame, Video, RenderInfo, Audio, Window, Game, PostFXPreset, CrtPiPreset

Utilities (source/utils/)

  • utils.hpp/cpptoLower() and other helpers
  • easing.hpp/cpp — Easing functions for animations: linear, outQuad, inQuad, inOutQuad, outCubic, inCubic, lerp, lerpInt. Used by Menu transitions, render info slide, and segment animations

Function Key Map

Key Action
F1 Decrease window zoom
F2 Increase window zoom
F3 Toggle fullscreen
F4 Toggle shaders on/off
F5 Toggle aspect ratio (square pixels ↔ 4:3 CRT)
F6 Toggle supersampling
F7 Cycle shader type (PostFX ↔ CRT-Pi)
F8 Cycle shader presets
F9 Toggle stretch filter (nearest ↔ linear)
F10 Cycle render info (off → top → bottom → off)
F11 Toggle pause (blocks game thread at publishFrame + JA_PauseMusic/JA_ResumeMusic)
F12 Toggle floating options menu
ESC Double-press to quit (with overlay notification) / close menu if open
Backspace Go up one menu level / close menu if at root
↑↓←→ / Enter Menu navigation

All key bindings are configurable via Options::keys_gui and stored in config.yaml (section controls: with SDL scancode names). Game movement keys (Options::keys_game.up/down/left/right) can be remapped via the CONTROLS submenu — the KeyRemap module mirrors custom physical keys to virtual standard scancodes so hardcoded game code keeps working.

Threading Model (Emulator Architecture — transitional)

⚠️ This architecture is transitional. It will be dismantled in Phase 5 of the modernization plan (the game thread + publishFrame mutex/cv disappear) and replaced by a single-threaded SDL_AppIterate loop in Phase 7. Document changes here when that happens.

Main thread (Director)              Game thread (ModuleGame/Sequence::Go())
────────────────────                ────────────────────────────────────
loop at ~60 FPS {                   loop {
  SDL_PollEvent()                     ... game logic ...
  GlobalInputs, Mouse                 JD8_Flip():
  if new_frame_available:               palette→ARGB in pixel_data
    copy to game_frame                  publishFrame(pixel_data)  ⏸
    signal → ────────────────────→      (blocks until Director consumes)
  copy game_frame → present_buffer      ←──── signal_consumed
  Overlay::render(present_buffer)       continue game loop
  Screen::present(present_buffer)     }
  SDL_Delay to hit 60fps
}

Key points:

  • Director is NON-BLOCKING: if no new frame, re-presents the last one with fresh overlay
  • Double buffer: game_frame (untouched copy from game) + presentation_buffer (regenerated with overlay each frame)
  • Game thread pauses at JD8_Flip() waiting for Director — natural sync point
  • SDL events processed ONLY on main thread (SDL requirement)
  • JI_Update() no longer polls events — reads Director's state

Rendering Pipeline (inside Screen::present)

Screen::present(pixel_data):
  1. FPS count + render info text update
  2. IF GPU + shaders enabled:
     - uploadPixels → scene_texture (320×200)
     - [IF 4:3] stretch pass fused with upscale: scene → scaled_texture (W×factor, H×factor×1.2)
     - [IF SS] upscale pass: scene → scaled_texture (W×factor, H×factor)
     - PostFX or CRT-Pi shader → swapchain (with viewport letterboxing)
  3. ELSE IF GPU without shaders:
     - uploadPixels → clean render → swapchain
  4. ELSE (fallback):
     - SDL_UpdateTexture → SDL_RenderPresent

Pixel Format

JD8_Flip produces ABGR byte order: 0xFF000000 + R + (G<<8) + (B<<16). SDL texture uses SDL_PIXELFORMAT_ABGR8888. GPU textures use SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM (same byte layout on little-endian). Overlay colors are ABGR format.

Persistence Files

File Content
~/.config/jailgames/aee/config.yaml Main config: video (incl. vsync, integer_scale), audio (incl. enabled master + music_* + sound_*), window, render_info (incl. show_time), game, shader selection, controls (movement keys + menu_toggle + pause_toggle)
~/.config/jailgames/aee/postfx.yaml PostFX shader presets (6 defaults: CRT, NTSC, CURVED, SCANLINES, SUBTLE, CRT LIVE)
~/.config/jailgames/aee/crtpi.yaml CRT-Pi shader presets (4 defaults: DEFAULT, CURVED, SHARP, MINIMAL)

External Libraries (source/external/)

  • gif.h — Header-only GIF decoder. Cannot be included from more than one .cpp (no include guards on functions). Other files use extern declarations for LoadGif()
  • stb_vorbis.h — stb single-header OGG decoder
  • fkyaml_node.hpp — Header-only YAML parser (fkYAML v0.4.2)

Data Assets (data/)

  • *.gif, *.ogg — Original game assets (do not modify)
  • fonts/8bithud.fnt + .gif — Bitmap font for overlay (8×8, 124 glyphs, UTF-8 with accents)
  • shaders/ — GLSL sources: postfx.vert, postfx.frag, upscale.frag, downscale.frag, crtpi_frag.glsl
  • locale/ca.yaml — UI strings in Valencian (menu titles/items/values, notifications). Edit freely; reload at restart
  • ui/ — Reserved for future UI graphics

Known Issues & Technical Debt

  1. gif.h cannot be included twice: Functions are not static or inline, causing multiple definition errors. Text class uses extern forward declarations as workaround
  2. Cheats are broken (reviu, alone, obert): JI_CheatActivated in jinput.cpp:46 compares SDL_Scancode values (e.g. SDL_SCANCODE_R=21) against ASCII chars ('r'=114). They never match. Regression from SDL3 migration. Now fixable — scheduled for Phase 1 of modernization since jail is no longer off-limits.
  3. No sound effects in game: Game code never calls JA_PlaySound*/JA_LoadSound — only music via JA_PlayMusic/JA_FadeOutMusic. The SONS and VOL SONS menu items control volume of an empty channel pool. Infrastructure ready for future SFX.
  4. Raw malloc/free in gameplay structs: Sprite/Entitat use malloc for Frame[] and Animacio[]; jfile.cpp uses a global scratch[255] buffer (UB under concurrent calls); jail_audio.cpp mixes new/malloc/SDL_malloc. Scheduled for Phase 1 (RAII pass).
  5. Blocking loops in cinematics and fades: ModuleSequence::doIntro() has 15+ while(!JG_ShouldUpdate()) spin-waits; JD8_FadeOut/JD8_FadeToPal run 32 internal iterations calling JD8_Flip. Incompatible with SDL_AppIterate. Scheduled for Phase 2 (state-machine refactor).
  6. SDL_AddTimer in audio: JA_Init registers a 30ms timer callback for mixing/fade update. Incompatible with emscripten single-threaded model. Scheduled for Phase 3 (manual JA_Update(delta_ms) driven from Director).
  7. Game thread + publishFrame mutex/cv: the emulator-style architecture is only tenable while native. Incompatible with SDL_AppIterate. Scheduled for Phase 5 (single-threaded state machine).

Pending / Ideas for Later

  • Gamepad: map Y button (North) to P key for Pepe character selection at title screen (only input path not covered by current mapping).
  • Menu items for game: habitacio_inicial, piramide_inicial, vides (already in Options::game, not exposed).
  • Multi-language: add data/locale/es.yaml / en.yaml and a language selector item in menu. Locale::load() already handles arbitrary files.
  • Game keys remap: currently only UP/DOWN/LEFT/RIGHT + menu_toggle. Could add remap for pause_toggle, keys_game.exit (needs care with ESC double-press flow).
  • Notification persistence: notifications clear on each new one (showNotification does notifications_.clear()). Could queue instead.
  • FPS counter jitter: time segment width changes per frame (100 Hz centi updates) causes ~1-2 px horizontal jitter in centered layout. Could lock to max-width or use monospace digits.
  • Notification messages partially hardcoded: overlay/global_inputs/director now use Locale, but the window title (Texts::WINDOW_TITLE) and some game-layer strings remain hardcoded.

Previously Fixed (kept for reference)

  • ESC double-press: Fixed by intercepting KEY_DOWN in Director, setting atomic esc_blocked_ immediately. JI_KeyPressed(ESCAPE) consults this flag. No race condition possible because Director's flag wins before game polls
  • Overlay freeze during intros: Fixed by threading model. Director runs independently at 60 FPS regardless of game delays. Double buffer avoids overlay smearing on re-presented frames
  • ESC-closes-menu then closes game: When menu closes via ESC, esc_swallow_until_release_ flag blocks JI_KeyPressed(ESC) until physical key release. Cleared on ESC KEY_UP
  • Backspace-closes-menu skipping cinematics: menu_keys_held_[SDL_SCANCODE_COUNT] array tracks scancodes consumed by menu on KEY_DOWN; matching KEY_UP is swallowed so game polling (JI_AnyKey) doesn't see them. Also covers F12, Backspace, cursor keys, capture-mode keys
  • Key remap not working after Backspace-close: JI_SetInputBlocked(false) now also called when Menu::handleKey causes menu to close via Backspace (previously only cleared on ESC/F12 close paths)

Virtual Keystates (OR'd sources)

jinput.cpp maintains virtual_keystates[JI_VSRC_COUNT][SDL_SCANCODE_COUNT] with two sources: JI_VSRC_GAMEPAD (from Gamepad::update) and JI_VSRC_REMAP (from KeyRemap::update). JI_KeyPressed returns true if either physical keystate OR any virtual source has the key set. JI_SetInputBlocked still overrides everything (menu open = input suppressed). JI_SetVirtualKey(scancode, source, pressed) is the write API — sources are independent so gamepad and keymap can't clobber each other.

Variable FPS Cap

Director loop uses FRAME_MS_VSYNC = 16 (60 FPS) or FRAME_MS_NO_VSYNC = 4 (~250 FPS) depending on Options::video.vsync. Selected each iteration, so toggling VSync from the menu updates cap immediately. The GPU swapchain is also reconfigured via shader_backend_->setVSync() (IMMEDIATE/MAILBOX vs VSYNC present modes).

Main Entry (source/main.cpp)

Init order: file_setconfigfolderOptions::loadLocale::load("locale/ca.yaml")Options::loadPostFX/CrtPiJG_InitScreen::initJD8_InitJA_InitOptions::applyAudio()Overlay::initMenu::initDirector::init (also calls Gamepad::init()) → Director::run() (blocks until quit). Shutdown: Options::saveDirector::destroyMenu::destroyOverlay::destroyJA_QuitJD8_QuitScreen::destroyJG_Finalize.

The state machine (alternating ModuleSequence state=1 and ModuleGame state=0) now lives inside Director::gameThreadFunc(), running on the game thread.