Files
aee/CLAUDE.md

23 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 docs/scenes-migration-plan.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.

Migration Status (2026-04-16)

Phases 07b of the original runtime plan are done. Current effort is the scene-by-scene rewrite of source/game/modulesequence.cpp over a scenes:: layer in source/scenes/:

  • Done: MortScene (state 100), BannerScene (2..5), MenuScene (0), IntroNewLogoScene (255 when use_new_logo), SlidesScene (1, 7), CreditsScene (8), SecretaScene (6). Each registered in Director::init via SceneRegistry. Each removed from the legacy ModuleSequence::Go() switch and deleted from modulesequence.cpp.
  • Pending: IntroScene (state 255 when !use_new_logo — the old JAILGAMES letter-by-letter), IntroSpritesScene (the Sam + momies animation with 3 random variants, hardest of the lot, currently still called from IntroNewLogoScene::Phase::Delegate via a temporary doIntroSprites exposed as public in ModuleSequence). Final cleanup of modulesequence.cpp comes after those two.
  • SceneRegistry lookup happens inside gameFiberEntry() before falling back to legacy ModuleSequence::Go(), with a redirect num_piramide == 6 && diners < 200 → 7 replicated ahead of the lookup to match the legacy flow.
  • For quick tests, Options::game exposes piramide_inicial, habitacio_inicial, vides, diamants_inicial, diners_inicial, use_new_logo, show_title_credits — all persisted in config.yaml.

The scenes layer itself lives in source/scenes/: scene.hpp (interface), scene_registry.hpp/.cpp, timeline.hpp/.cpp, sprite_mover.hpp/.cpp, frame_animator.hpp/.cpp, palette_fade.hpp/.cpp, surface_handle.hpp/.cpp, scene_utils.hpp/.cpp (playMusic). Scenes are pure tick-based (no fibers, no while, no JG_ShouldUpdate) — the cooperative fiber still runs underneath them but JD8_Flip() inside the mini-while in gameFiberEntry is what yields. Once IntroScene + IntroSpritesScene are migrated, the fiber can be dismantled along with ModuleGame.

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 (Director stops resuming the game fiber + 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.

Execution Model (Single-threaded Fibers)

Since Phase 4+5, the old game thread + publishFrame mutex/cv has been removed. The game code (ModuleGame, ModuleSequence, all their Go() methods with internal while loops) runs inside a cooperative fiber (see fiber.hpp / fiber.cpp). The whole process is single-threaded.

Main thread (only thread)
─────────────────────────
Director::run() loop {
  SDL_PollEvent()
  GlobalInputs, Mouse, KeyRemap
  JA_Update()                     ← audio pump
  if !paused:
    GameFiber::resume()            ← hands control to game code
    ↓                               (runs until next JD8_Flip)
    ... game code runs ...
    JD8_Flip():
      palette → ARGB → pixel_data
      GameFiber::yield()           ← returns control to Director
    ↓
  copy JD8_GetFramebuffer() → game_frame
  memcpy game_frame → presentation_buffer
  Overlay::render(presentation_buffer)
  Screen::present(presentation_buffer)
  SDL_Delay to hit 60fps
}

Fiber backend (fiber.cpp):

  • Linux / macOS: ucontext_t + makecontext/swapcontext (deprecated in POSIX.1-2008 but still functional in glibc and macOS libc; warning silenced with #pragma).
  • Windows: ConvertThreadToFiber / CreateFiber / SwitchToFiber (native Fibers API).
  • Emscripten: not yet. Phase 7 will add an emscripten_fiber_* or Asyncify backend.

Key points:

  • Single-threaded: zero std::thread, zero std::mutex, zero std::condition_variable.
  • JD8_Flip() is the natural sync point: it calls GameFiber::yield() instead of the old blocking publishFrame.
  • Pause (F11) works by Director skipping resume(): the fiber stays frozen at its last yield, and Director keeps repainting the last frame with fresh overlay.
  • Double buffer still exists (game_frame + presentation_buffer) because Director can present multiple frames per game frame during pause or slow sections. Eliminating it is marginal work and the extra 256 KB copy is cheap at 320×200.
  • The state machine alternating ModuleSequence (state=1) and ModuleGame (state=0) now lives in gameFiberEntry() inside an anonymous namespace in director.cpp, called once as the fiber entry point.
  • SDL events still processed only on the main thread (which is now the only thread anyway).

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: Fixed in Phase 3. jail_audio is now a header-only inline module (single .cpp stub only hosts the stb_vorbis implementation to avoid multiple definitions). Music uses true streaming via stb_vorbis_open_memory + JA_PumpMusic with a 0.5s low-water-mark instead of decoding the whole OGG into RAM. Mixing/fade update runs manually via JA_Update() called once per frame from Director::run(). Ported from the jaildoctors_dilemma codebase.
  7. Game thread + publishFrame mutex/cv: Fixed in Phase 4+5. Replaced by a cooperative GameFiber (ucontext on POSIX, Fibers API on Windows). JD8_Flip() calls GameFiber::yield(), Director calls GameFiber::resume() once per frame. Zero threads, zero mutexes. Emscripten fiber backend still pending for Phase 7.

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) lives inside gameFiberEntry() in an anonymous namespace in director.cpp, invoked as the entry of the cooperative fiber (single-threaded).