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>
14 KiB
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 0x8000–0x9FFF, 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 |
|---|---|---|
| 2–8 | mazegame_s2..8_b2.c |
Opening screen image (SCREEN 8, 256×212) |
| 9–15 | mazegame_s9..15_b2.c |
Opening screen with titles |
| 16 | mazegame_s16_b2.c |
Sprite frames: north (dir 0) + south (dir 1), 0–3 |
| 17 | mazegame_s17_b2.c |
Sprite frames: east + west, frames 0–3 |
| 18 | mazegame_s18_b2.c |
All 8 tile bitmaps (1024 B each, 32×32) |
| 19–25 | 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 30–31 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 (2–8, 9–15, 19–25) fromopeningscreen.png,openingscreenwithtitles.png,hintscreen.png.convert_tiles.py→ prints C arrays for the tile bitmaps to stdout (manual paste intomazegame_s18_b2.c).
All converters encode pixels as SCREEN 8 GRB (bits 7–5 = G3, 4–2 = R3, 1–0 = 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
.lvgmfiles were embedded as themazegame_s26..29_b2.cdata 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 asconst u8 g_Mus*[N]in the matchings{26..29}_b2.c). See~/Repositories/msx-music-generator(the generator + itsmsxgl-example/README.md). - Decode runs in the VBlank interrupt (this is the key design point).
MusicVBlank()is installed as theH_TIMIhook (BIOS_SetHookCallback) and each VBlank callsLVGM_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 perHalt()in the main loop; the heavy per-step VDP work inScrollDrawcould 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 plainHalt()/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 read0xA000at any moment without a bank save/restore and without racing the main code.PlayTrack(seg)runs underDisableInterrupt()/EnableInterrupt()(mute the old track's hanging note → map bank 3 →LVGM_Play(0xA000, TRUE)) soMusicVBlankcan't decode mid-switch.MSXMusic_Initialize()and the hook install run once at the top ofmain();MusicVBlankis a no-op until a track is playing (LVGM_IsPlaying()). - Config:
LibModulesaddspsg,msx-music,vgm/lvgm_player,ayfx/ayfx_player;msxgl_config.hsetsLVGM_USE_PSG/LVGM_USE_MSXMUSICon (rest off),PSG_ACCESS = PSG_INDIRECTandAYFX_BUFFER = AYFX_BUFFER_DEFAULT(ayFX writes the PSG buffer thatPSG_Applyflushes), andPSG_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 wrapsJoystick_ReadinDisableInterrupt()/EnableInterrupt().Joystick_Readpolls the PSG directly (R#14/R#15); the ISR'sPSG_Applyalso touches the PSG, so an un-guarded read gets corrupted and can misdirect a port-select write into a sound register. (Keyboard_Readuses the PPI, not the PSG, so it needs no guard.)PlaySfxandPlayTracklikewise 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 at0= all channels enabled (active-low), whichPSG_Applywould drive as noise; ayFX re-enables its own channel when it plays an effect.ayFX_InitBank/SetChannel(PSG_CHANNEL_A)/SetMode(FIXED)run once inmain. - 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 viaVDP_USE_COMMANDinmsxgl_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/DrawTileadd it to their HMMM/HMMV destination; theWriteVVhelper 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 whyWriteVVexists. - Fill targets in
ScrollDraware base-relative but the fill timing (which animation step is safe to write each row/col) is base-invariant; atbase = 0every 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_BaseXin staging (PreRenderStrip), so the full-width row copies stay at destination x=0; horizontal column fills go throughFillStripColumns, which splits the 224-row copy at the 256-row wrap forg_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_BaseYafter — the minimap overlay does this. main()game setup resetsg_BaseX = g_BaseY = 0and the offset registers before the firstDrawFull.
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_XXnames (documented inline at the top ofmazegame.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 wholeMSXgl-1.4.1tree, pushing to themazegameremote); work happens directly onmain. MSXgl itself was patched for macOS (see../../SETUP_MACOS.md); those engine edits are untracked, so don't assume they're versioned.