diff --git a/projects/mazegame/CLAUDE.md b/projects/mazegame/CLAUDE.md index df76ba3..cebaee3 100644 --- a/projects/mazegame/CLAUDE.md +++ b/projects/mazegame/CLAUDE.md @@ -82,16 +82,26 @@ so `./build.sh run` gives openMSX the FM-PAC (`-ext fmpac`). The PSG carries no 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`). -- **Playback protocol (banking):** because the tracks live in the bank-2 window and gameplay - is constantly switching segment 2 for tiles/sprites, two helpers in `mazegame.c` bracket - every player call with a save/restore of the current segment: `PlayTrack(seg)` banks the - track in and calls `LVGM_Play((const u8*)0x8000, TRUE)`; `PlayFrame()` banks the current - track's segment (`g_MusSeg`) in, calls `LVGM_Decode()`, and restores. **Every `Halt()` in a - wait/animation loop is paired with a `PlayFrame()`** so the track advances at 60 Hz — - including inside `ScrollDraw`, the gameplay input loops (which were made frame-synced for - this), and `WaitKeySequence`. `MSXMusic_Initialize()` runs once at the top of `main()`. +- **Decode runs in the VBlank interrupt (this is the key design point).** `MusicVBlank()` is + installed as the `H_TIMI` hook (`BIOS_SetHookCallback`) and calls `LVGM_Decode()` once per + VBlank — 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`; `msxgl_config.h` sets `LVGM_USE_PSG`/`LVGM_USE_MSXMUSIC` on (rest off) and `PSG_ACCESS = PSG_DIRECT`. +- 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. - The boot **logo animation is intentionally silent** (no track). ### VRAM layout (off-screen caches above the visible 256×212) diff --git a/projects/mazegame/emul/rom/mazegame.rom b/projects/mazegame/emul/rom/mazegame.rom index 6d60f1f..992573c 100644 Binary files a/projects/mazegame/emul/rom/mazegame.rom and b/projects/mazegame/emul/rom/mazegame.rom differ diff --git a/projects/mazegame/mazegame.c b/projects/mazegame/mazegame.c index b6984d4..42a69a3 100644 --- a/projects/mazegame/mazegame.c +++ b/projects/mazegame/mazegame.c @@ -7,6 +7,7 @@ //----------------------------------------------------------------------------- #include "msxgl.h" #include "rom_mapper.h" +#include "bios_hook.h" #include "psg.h" #include "msx-music.h" #include "vgm/lvgm_player.h" @@ -102,37 +103,40 @@ u8 g_BaseY; // resting vertical scroll offset (mult //============================================================================= // MUSIC (lVGM, MSX-Music / OPLL) — one looping track per screen, in banked ROM //============================================================================= -// Each track fills the start of its own bank-2 segment (0x8000 window). Only one -// plays at a time; LVGM_Decode advances a pointer inside that window, so the -// track's segment must be banked in around every decode. Each track is < 8KB, so -// it never crosses a segment boundary. PlayTrack/PlayFrame save & restore the -// caller's bank-2 segment, so the music never disturbs the tile/sprite banking -// that gameplay drawing relies on. +// Each track fills the start of its own ROM segment (the `_b2` files are compiled +// at the bank-2 link address, but at runtime the track is mapped into the *bank-3* +// window at 0xA000-0xBFFF and left there for the whole screen). Gameplay only ever +// switches bank 2 (0x8000) for tiles/sprites, so bank 3 is never disturbed and the +// track stays put. Decoding runs in the H_TIMI VBlank interrupt (MusicVBlank), so +// the music tempo is rock-steady no matter how long a frame's game work takes — the +// scroll animation can overrun a VBlank without dragging the music down with it. +// Because the ISR reads only from bank 3, it never races the main code's bank-2 +// switching, and needs no bank save/restore. Each track is < 8KB (one segment). #define MUS_SEG_TITLE 26 // mazegame_s26_b2.c (g_MusTitle) #define MUS_SEG_HINTS 27 // mazegame_s27_b2.c (g_MusHints) #define MUS_SEG_MAZE 28 // mazegame_s28_b2.c (g_MusMaze) #define MUS_SEG_WIN 29 // mazegame_s29_b2.c (g_MusWin) -u8 g_MusSeg; // bank-2 segment of the currently playing track +#define MUS_WINDOW ((const u8*)0xA000) // bank-3 window the track is mapped into -// Start a looping track: bank in its segment, begin playback, remember the segment. -static void PlayTrack(u8 seg) +// H_TIMI (VBlank) hook: advance the current track one frame. No-op until a track is +// playing. Reads only from the bank-3 window, which nothing else touches. +void MusicVBlank(void) { - u8 prev = GET_BANK_SEGMENT(2); - SET_BANK_SEGMENT(2, seg); - LVGM_Play((const u8*)0x8000, TRUE); - g_MusSeg = seg; - SET_BANK_SEGMENT(2, prev); + if (LVGM_IsPlaying()) + LVGM_Decode(); } -// Advance the current track by one frame. Safe with any bank-2 segment selected; -// the caller's segment is restored on return. Call once per VBlank (after Halt()). -static void PlayFrame(void) +// Switch to a looping track: silence the previous track's lingering note (key-off all +// 9 FM channels), map the new track into bank 3, and start it. Interrupts are disabled +// across the switch so MusicVBlank can't decode from a half-updated pointer or segment. +static void PlayTrack(u8 seg) { - u8 prev = GET_BANK_SEGMENT(2); - SET_BANK_SEGMENT(2, g_MusSeg); - LVGM_Decode(); - SET_BANK_SEGMENT(2, prev); + DisableInterrupt(); + MSXMusic_Mute(); + SET_BANK_SEGMENT(3, seg); + LVGM_Play(MUS_WINDOW, TRUE); + EnableInterrupt(); } //============================================================================= @@ -640,7 +644,6 @@ u8 ScrollDraw(i8 dx, i8 dy) // Vertical: R#23 (MSX2). Offset runs base → base±64. for (u8 i = 0; i < ANIM_STEPS; i++) { Halt(); - PlayFrame(); u8 new_vy = (u8)((i16)SPR_DRAW_Y + (i16)dy * g_AO[i]); g_SprFrame = (g_SprFrame + 1) & 3; DrawSpriteCompV(spr_vx, spr_vy, new_vy, old_ox, old_oy); @@ -670,7 +673,6 @@ u8 ScrollDraw(i8 dx, i8 dy) u8 prev = 0; for (u8 i = 0; i < ANIM_STEPS; i++) { Halt(); - PlayFrame(); u8 cur = g_AO[i]; u8 w = cur - prev; if (dx == 1) { @@ -873,11 +875,9 @@ static u8 AnyInput(void) // Drain held inputs, wait for a fresh press, then wait for release. static void WaitKeySequence(void) { - // Halt()+PlayFrame() keeps the current track advancing (60 Hz) while waiting, - // and syncs one keyboard read per VBlank. Callers (hints/win) have music playing. - while (AnyInput()) { Halt(); PlayFrame(); } - while (!AnyInput()) { Halt(); PlayFrame(); } - while (AnyInput()) { Halt(); PlayFrame(); } + while (AnyInput()); + while (!AnyInput()); + while (AnyInput()); } // Show the hints screen image (SCREEN 8) before the game begins. @@ -886,6 +886,9 @@ static void WaitKeySequence(void) static void ShowHints(void) { VDP_EnableDisplay(FALSE); + // Drawn at absolute VRAM coords — clear any leftover hardware scroll offset. + VDP_SetVerticalOffset(0); + VDP_SetHorizontalOffset(0); for (u8 y = 0; y < IMG_ROWS; y++) WriteImgRow(IMG_SEG_HINTS, y); VDP_EnableDisplay(TRUE); @@ -901,6 +904,12 @@ void TitleScreen(void) VDP_EnableDisplay(FALSE); VDP_ClearVRAM(); + // This image is drawn at absolute VRAM coords, so clear any hardware scroll + // offset left over from a previous game (gameplay rests at g_BaseX/g_BaseY in + // {0,64,128,192}); without this the returning title screen appears shifted. + VDP_SetVerticalOffset(0); + VDP_SetHorizontalOffset(0); + // Stream image A (no title) to VRAM display area for (u8 y = 0; y < IMG_ROWS; y++) WriteImgRow(IMG_SEG_A, y); @@ -909,7 +918,7 @@ void TitleScreen(void) PlayTrack(MUS_SEG_TITLE); // Hold image A for ~2 seconds (100 VBlanks at 50Hz) - for (u8 i = 0; i < 100; i++) { Halt(); PlayFrame(); } + for (u8 i = 0; i < 100; i++) Halt(); // 16-step scanline interleave dissolve to image B (with title). // Step S updates every 16th row starting at row S. @@ -917,12 +926,12 @@ void TitleScreen(void) for (u8 step = 0; step < 16; step++) { for (u8 y = step; y < IMG_ROWS; y += 16) WriteImgRow(IMG_SEG_B, y); - Halt(); PlayFrame(); Halt(); PlayFrame(); Halt(); PlayFrame(); + Halt(); Halt(); Halt(); } - // Wait for any key or joystick input, then release (music keeps playing) - while (!AnyInput()) { Halt(); PlayFrame(); } - while (AnyInput()) { Halt(); PlayFrame(); } + // Wait for any key or joystick input, then release + while (!AnyInput()); + while (AnyInput()); } //============================================================================= @@ -986,8 +995,10 @@ static void ShowWinScreen(void) void main(void) { - // Init the MSX-Music (OPLL) chip once, before any track plays. + // Init the MSX-Music (OPLL) chip once, then install the VBlank hook that decodes + // the current track every frame (MusicVBlank is a no-op until a track is playing). MSXMusic_Initialize(); + BIOS_SetHookCallback(H_TIMI, MusicVBlank); ShowLogoAnimation(); @@ -1077,8 +1088,6 @@ void main(void) if (!hasDir) { // Wait for all arrow keys and joystick direction released (debounce) do { - Halt(); - PlayFrame(); UpdateTimer(); row8 = Keyboard_Read(8); g_Joy = Joystick_Read(JOY_PORT_1); @@ -1088,8 +1097,6 @@ void main(void) // Wait for an arrow key, joystick direction, C (cheat), M/joy-A (minimap), S (cat sense) u8 row3, row4, row5; do { - Halt(); - PlayFrame(); UpdateTimer(); row8 = Keyboard_Read(8); row3 = Keyboard_Read(3); @@ -1123,7 +1130,6 @@ void main(void) // past the BIOS interrupt that rewrites the keyboard row selector). do { Halt(); - PlayFrame(); row4 = Keyboard_Read(4); g_Joy = Joystick_Read(JOY_PORT_1); } while (IS_KEY_PRESSED(row4, KEY_M) || IS_JOY_PRESSED(g_Joy, JOY_INPUT_TRIGGER_A)); @@ -1143,7 +1149,6 @@ void main(void) DrawArrow(108, 108, GetExitDir()); do { Halt(); - PlayFrame(); row5 = Keyboard_Read(5); } while (IS_KEY_PRESSED(row5, KEY_S)); // Restore: redraw tile under player then redraw sprite