Files
mazegame/projects/mazegame/CLAUDE.md
T
jurjen.ladeniusandClaude Fable 5 4c33bdb880 Document special keys and ayFX in CLAUDE.md
Game flow now lists all movement-loop keys (M/joy-A minimap, S cat
sense, C cheat landing one move short of the exit), and the MSXgl
configuration section covers the full audio stack including ayFX.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:36:09 +02:00

14 KiB
Raw Blame History

CLAUDE.md

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

What this is

An MSX2+ maze game written in C against the MSXgl library (../../engine), built into a 256KB Konami SCC mapper ROM. SCREEN 8 (G7, 256 colors), 32×32-pixel tiles, player always centered with smooth easing scroll. The whole game is one translation unit (mazegame.c) plus ~28 generated data files holding bitmap assets and per-screen music in banked ROM segments. Each screen has its own looping MSX-Music (FM/OPLL) track, with ayFX sound effects layered on the PSG.

Build & run

./build.sh            # compile + link + package -> out/mazegame.rom (copied to emul/rom/)
./build.sh run        # build, then launch in openMSX
./build.sh clean      # remove out/ intermediates

The build is driven by Node (../../engine/script/js/build.js); project name and target come from project_config.js, so no project-name argument is needed. On macOS the toolchain (SDCC, openMSX, node) must come from Homebrew — see ../../SETUP_MACOS.md. There is no test suite; "verifying" means building cleanly and running the ROM in openMSX.

Architecture

Memory model — Konami SCC banked ROM (this is the central constraint)

project_config.js sets Target = "ROM_KONAMI_SCC", ROMSize = 256, ROMMainSegments = 2, BankedCall = true. Code/main data live in the fixed main segments; all bitmap assets live in banked 8KB segments mapped into the bank-2 window at 0x80000x9FFF, switched at runtime with SET_BANK_SEGMENT(2, n) / GET_BANK_SEGMENT(2) (from rom_mapper.h). The _b2 suffix on the generated files means "bank 2". Always save and restore the previous segment around a bank switch — the existing code does this everywhere.

Segment allocation. The build auto-discovers any mazegame_s{N}_b2.c (N up to 31) and pins its data to the start of segment N at the bank-2 window; the file's one const u8 g_* array need not fill the whole 8KB (bitmap assets do; music tracks are smaller and the rest of the segment is zero-padded).

Segments File(s) Contents
28 mazegame_s2..8_b2.c Opening screen image (SCREEN 8, 256×212)
915 mazegame_s9..15_b2.c Opening screen with titles
16 mazegame_s16_b2.c Sprite frames: north (dir 0) + south (dir 1), 03
17 mazegame_s17_b2.c Sprite frames: east + west, frames 03
18 mazegame_s18_b2.c All 8 tile bitmaps (1024 B each, 32×32)
1925 mazegame_s19..25_b2.c Hint screen image
26 mazegame_s26_b2.c Music: title screen (lVGM, FM)
27 mazegame_s27_b2.c Music: hints screen (lVGM, FM)
28 mazegame_s28_b2.c Music: gameplay/maze (lVGM, FM)
29 mazegame_s29_b2.c Music: victory screen (lVGM, FM)

Segments 3031 are free. A 256×212 SCREEN 8 image is 53,248 bytes = 7 segments (last one zero-padded). Sprite frames are 24×24 = 576 bytes each, addressed as 0x8000 + (localDir*4 + frame) * 576. Tiles are addressed as 0x8000 + index*1024.

Asset pipeline (PNG → C, run manually, then rebuild)

These Python scripts (require Pillow) regenerate the _b2.c data files from PNGs. They are not part of build.sh — run them by hand after changing source art:

  • convert_screens.py → rewrites the screen-image segment files (28, 915, 1925) from openingscreen.png, openingscreenwithtitles.png, hintscreen.png.
  • convert_tiles.py → prints C arrays for the tile bitmaps to stdout (manual paste into mazegame_s18_b2.c).

All converters encode pixels as SCREEN 8 GRB (bits 75 = G3, 42 = R3, 10 = B2) via to_grb(). Any new color/asset code must use the same encoding. Sprite segments (16/17) have no checked-in converter — edit them as data directly.

Music & sound — per-screen lVGM (MSX-Music / FM) + ayFX (PSG)

Music and sound effects use different chips, so they mix with zero contention: looping MSX-Music (FM/OPLL) tracks on the YM2413, and ayFX sound effects on the PSG.

Each screen has its own looping track played by MSXgl's vgm/lvgm_player. The tracks are lVGM (compact VGM) files in music/, all MSX-Music (YM2413 / OPLL / FM) — so FM must exist: built into most MSX2+ machines, and project_config.js sets EmulMSXMusic = true so ./build.sh run gives openMSX the FM-PAC (-ext fmpac). The PSG carries no music.

  • Source → ROM: the .lvgm files were embedded as the mazegame_s26..29_b2.c data files (one track per segment; each is < 8KB so it never crosses a segment boundary). To regenerate after replacing a .lvgm, re-run the small embed step (bin2c-style: dump the bytes as const u8 g_Mus*[N] in the matching s{26..29}_b2.c). See ~/Repositories/msx-music-generator (the generator + its msxgl-example/README.md).
  • Decode runs in the VBlank interrupt (this is the key design point). MusicVBlank() is installed as the H_TIMI hook (BIOS_SetHookCallback) and each VBlank calls LVGM_Decode() (music) + ayFX_Update() + PSG_Apply() (SFX) — so the tempo is steady regardless of how long a frame's game work takes. (An earlier design decoded once per Halt() in the main loop; the heavy per-step VDP work in ScrollDraw could overrun a VBlank, so the decode rate — and the music — slowed only while moving. The ISR decouples them.) The game loops therefore contain no decode calls; they are plain Halt()/busy-waits as before the music existed.
  • Banking — why it's race-free: the tracks are compiled at the bank-2 link address but at runtime PlayTrack() maps the current track into the bank-3 window (0xA000-0xBFFF) and leaves it there for the whole screen. Gameplay only ever switches bank 2 (0x8000) for tiles/sprites, so bank 3 is never touched and the ISR can read 0xA000 at any moment without a bank save/restore and without racing the main code. PlayTrack(seg) runs under DisableInterrupt()/EnableInterrupt() (mute the old track's hanging note → map bank 3 → LVGM_Play(0xA000, TRUE)) so MusicVBlank can't decode mid-switch. MSXMusic_Initialize() and the hook install run once at the top of main(); MusicVBlank is a no-op until a track is playing (LVGM_IsPlaying()).
  • Config: LibModules adds psg, msx-music, vgm/lvgm_player, ayfx/ayfx_player; msxgl_config.h sets LVGM_USE_PSG/LVGM_USE_MSXMUSIC on (rest off), PSG_ACCESS = PSG_INDIRECT and AYFX_BUFFER = AYFX_BUFFER_DEFAULT (ayFX writes the PSG buffer that PSG_Apply flushes), and PSG_USE_EXTRA = TRUE.
  • The tracks are authored at 60 Hz; on a 50 Hz (PAL) machine they play a constant ~17 % slower (the decode rate is the display refresh). That's uniform, not movement-specific.

Sound effects (ayFX, PSG). An 8-effect ayFX bank in sfx/sfx_bank.afb (with a descriptive sfx_bank.txt), embedded as sfx_bank.h (g_SFXBank[]) in fixed main ROM so the interrupt reads it with no bank switching. Trigger with the PlaySfx(id, prio) helper; ids and priorities (0 = highest, never interrupted; footsteps lowest so any UI/finish sound cuts over them):

id effect trigger prio
0-2 cat paws (random) each move lowest
4 fanfare landing on the exit highest
5 map unfold press M (minimap) mid
6 laugh press C (cheat) mid
7 ta-daa advancing title / hint / finish screen high

(id 3 is unused.) Two interrupt-safety rules make this work — break either and you get noise and phantom input:

  • All joystick reads go through ReadJoy(), which wraps Joystick_Read in DisableInterrupt()/EnableInterrupt(). Joystick_Read polls the PSG directly (R#14/R#15); the ISR's PSG_Apply also touches the PSG, so an un-guarded read gets corrupted and can misdirect a port-select write into a sound register. (Keyboard_Read uses the PPI, not the PSG, so it needs no guard.) PlaySfx and PlayTrack likewise run under DI/EI.
  • The PSG mixer is muted at startup (PSG_SetMixer(0); PSG_Apply(); before the hook is installed). The zero-initialized PSG buffer has the mixer at 0 = all channels enabled (active-low), which PSG_Apply would drive as noise; ayFX re-enables its own channel when it plays an effect. ayFX_InitBank/SetChannel(PSG_CHANNEL_A)/SetMode(FIXED) run once in main.
  • The boot logo animation is intentionally silent (no track).

VRAM layout (off-screen caches above the visible 256×212)

mazegame.c reserves off-screen VRAM rows in the 128KB space and documents exact coordinates in the constants block — keep these regions non-overlapping when changing them:

  • TILE_CACHE_Y 256 — preloaded tile bitmaps (8 tiles wide).
  • STAGING_Y 288 — pre-rendered scroll strip (horizontal x=0..63, vertical y=288..).
  • MINIMAP_CACHE_X/Y 64/352 — pre-rendered static minimap (no player dot). Rendering uses VDP commands (HMMV/HMMM, enabled via VDP_USE_COMMAND in msxgl_config.h) to blit from these caches rather than re-drawing per frame.

Scrolling model — VRAM torus (why there is no per-move flash)

The visible page is treated as a 256×256 VRAM torus. Two globals, g_BaseX/g_BaseY, hold the resting hardware scroll offset (R#26/27 and R#23); both are always a multiple of 64 and cycle through {0, 64, 128, 192}. A screen pixel (sx, sy) maps to VRAM ((sx+g_BaseX)&255, (sy+g_BaseY)&255).

A move (ScrollDraw) animates the scroll register from base to base ± 64 over the 8 ease steps, rendering the two newly-exposed tile-rows/cols into the VRAM cells that scroll off the opposite edge, then simply keeps the new offset as the resting base. Nothing is bulk-shifted or blanked afterwards — the old content is already at the correct torus position, so there is no offset reset, no bulk HMMM, no strip paste, and no display blank. (The earlier "canonicalize after every move" design did all of those behind a VDP_EnableDisplay(FALSE), which was the visible per-move flash.)

Consequences to respect when editing:

  • All gameplay VRAM drawing must go through the base. DrawTileData/DrawTile add it to their HMMM/HMMV destination; the WriteVV helper does it for per-row CPU writes (sprite, compositors, cat-sense arrow) and splits a run at the 256-column wrap. Tiles are 32-aligned and the base is a multiple of 64, so tiles never straddle; the 24px sprite can straddle mid-animation, which is why WriteVV exists.
  • Fill targets in ScrollDraw are base-relative but the fill timing (which animation step is safe to write each row/col) is base-invariant; at base = 0 every expression reduces exactly to the pre-torus code, which is the easy case to reason about.
  • Strip fills must respect BOTH axis bases, not just the scroll axis. The bulk HMMM fills handle the scroll-axis base in their destination, and the orthogonal axis as follows: vertical strips are pre-rotated by g_BaseX in staging (PreRenderStrip), so the full-width row copies stay at destination x=0; horizontal column fills go through FillStripColumns, which splits the 224-row copy at the 256-row wrap for g_BaseY. (Omitting either writes the new edge 2/4/6 tiles rotated whenever the other axis' base is non-zero — the maze looks wrong while collision stays right: phantom walls/passages.)
  • Vertical has only 32px of slack in the 256-row torus, so one new tile-row is pre-filled (off-screen slack) and the other is deferred until it scrolls off; horizontal fills progressively as columns wrap. This is the delicate part — verify long straight runs in each direction (the base crosses its wrap on the 4th move) and the sprite at screen edges.
  • Anything that draws at absolute VRAM coords must zero the scroll offset while shown and restore g_BaseX/g_BaseY after — the minimap overlay does this.
  • main() game setup resets g_BaseX = g_BaseY = 0 and the offset registers before the first DrawFull.

Game flow (main())

ShowLogoAnimation() (SCREEN 5 fade, silent) → loop of: TitleScreen() (ordered dissolve from opening to opening+titles using the banked images) → srand(*0xFC9E) seed from the BIOS jiffy clock → GenerateMaze() (iterative DFS, g_RW/g_DW wall arrays) → ShowHints() → set up SCREEN 8, preload tiles/minimap into VRAM → inner movement loop → ShowWinScreen() on reaching the exit. Each of these screens starts its own looping track with PlayTrack() (see "Music" above); MSXMusic_Initialize() runs once before the logo.

Movement is an 8-step ease-in/out scroll (g_AO cumulative offsets summing to 64px = two tiles) implemented as a VRAM torus (see "Scrolling model" above) — VDP register scroll plus edge-strip rendering, with no per-move blank/flash. Input (keyboard + joystick, sampled at the last animation frame) chains directly into the next move if a direction is still held. The player is a 24×24 software sprite (DrawSpriteAt) drawn into VRAM — hardware sprites are disabled. Special keys in the movement loop: hold M (or joystick button A) for the minimap overlay; hold S for the cat-sense arrow pointing toward the exit; C (cheat) teleports to an open neighbour of the exit — one move short, so the final step (and the win) is still the player's.

MSXgl configuration

msxgl_config.h is the per-project feature switch for the dynamically-compiled library: SCREEN 5 (G4) and SCREEN 8 (G7) modes, VDP commands, 16×16 sprites, keyboard+joystick input, and the audio stack (lVGM/PSG/MSX-Music replayer + ayFX — see "Music & sound"). Enabling a library feature the game starts using generally requires flipping the matching *_USE_* define here and adding the module to LibModules in project_config.js.

Conventions in this codebase

  • Globals use terse g_XX names (documented inline at the top of mazegame.c); section banners are //==== … ====. Match that style.
  • out/ is generated build output (.asm/.rel/.lst/.sym/.map/.rom) — never edit by hand.
  • The repo root is ../../ (the whole MSXgl-1.4.1 tree, pushing to the mazegame remote); work happens directly on main. MSXgl itself was patched for macOS (see ../../SETUP_MACOS.md); those engine edits are untracked, so don't assume they're versioned.