Fix music tempo, note-on-switch, and title scroll shift

Three follow-up fixes to the per-screen music:

- Decode music in the H_TIMI VBlank interrupt (MusicVBlank) instead of
  once per Halt() in the game loops. The heavy per-step VDP work in
  ScrollDraw could overrun a VBlank, dropping the decode rate so the
  music slowed only while moving; the ISR keeps tempo steady regardless.
  The playing track is mapped into the unused bank-3 window (0xA000) and
  left there for the screen, so the ISR reads it with no bank switching
  and never races the main code's bank-2 tile/sprite swaps. PlayTrack()
  switches tracks under DI/EI. The game loops revert to plain Halt()s.

- PlayTrack() mutes the OPLL (key-off all 9 channels) before starting the
  next track, silencing the note that lingered across screen changes.

- TitleScreen()/ShowHints() zero the hardware scroll offset before drawing
  their absolute-coord images. Gameplay rests at g_BaseX/g_BaseY in
  {0,64,128,192}; without the reset the returning title/hints screen
  appeared horizontally shifted (only after finishing on a non-zero base).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 21:15:55 +02:00
co-authored by Claude Opus 4.8
parent 1c0636e8ff
commit 9faff1cfba
3 changed files with 63 additions and 48 deletions
+18 -8
View File
@@ -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 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 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`). `~/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 - **Decode runs in the VBlank interrupt (this is the key design point).** `MusicVBlank()` is
is constantly switching segment 2 for tiles/sprites, two helpers in `mazegame.c` bracket installed as the `H_TIMI` hook (`BIOS_SetHookCallback`) and calls `LVGM_Decode()` once per
every player call with a save/restore of the current segment: `PlayTrack(seg)` banks the VBlank — so the tempo is **steady regardless of how long a frame's game work takes**. (An
track in and calls `LVGM_Play((const u8*)0x8000, TRUE)`; `PlayFrame()` banks the current earlier design decoded once per `Halt()` in the main loop; the heavy per-step VDP work in
track's segment (`g_MusSeg`) in, calls `LVGM_Decode()`, and restores. **Every `Halt()` in a `ScrollDraw` could overrun a VBlank, so the decode rate — and the music — slowed *only while
wait/animation loop is paired with a `PlayFrame()`** so the track advances at 60 Hz — moving*. The ISR decouples them.) The game loops therefore contain **no** decode calls; they
including inside `ScrollDraw`, the gameplay input loops (which were made frame-synced for are plain `Halt()`/busy-waits as before the music existed.
this), and `WaitKeySequence`. `MSXMusic_Initialize()` runs once at the top of `main()`. - **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 - **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`. `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). - The boot **logo animation is intentionally silent** (no track).
### VRAM layout (off-screen caches above the visible 256×212) ### VRAM layout (off-screen caches above the visible 256×212)
Binary file not shown.
+45 -40
View File
@@ -7,6 +7,7 @@
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
#include "msxgl.h" #include "msxgl.h"
#include "rom_mapper.h" #include "rom_mapper.h"
#include "bios_hook.h"
#include "psg.h" #include "psg.h"
#include "msx-music.h" #include "msx-music.h"
#include "vgm/lvgm_player.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 // 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 // Each track fills the start of its own ROM segment (the `_b2` files are compiled
// plays at a time; LVGM_Decode advances a pointer inside that window, so the // at the bank-2 link address, but at runtime the track is mapped into the *bank-3*
// track's segment must be banked in around every decode. Each track is < 8KB, so // window at 0xA000-0xBFFF and left there for the whole screen). Gameplay only ever
// it never crosses a segment boundary. PlayTrack/PlayFrame save & restore the // switches bank 2 (0x8000) for tiles/sprites, so bank 3 is never disturbed and the
// caller's bank-2 segment, so the music never disturbs the tile/sprite banking // track stays put. Decoding runs in the H_TIMI VBlank interrupt (MusicVBlank), so
// that gameplay drawing relies on. // 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_TITLE 26 // mazegame_s26_b2.c (g_MusTitle)
#define MUS_SEG_HINTS 27 // mazegame_s27_b2.c (g_MusHints) #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_MAZE 28 // mazegame_s28_b2.c (g_MusMaze)
#define MUS_SEG_WIN 29 // mazegame_s29_b2.c (g_MusWin) #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. // H_TIMI (VBlank) hook: advance the current track one frame. No-op until a track is
static void PlayTrack(u8 seg) // playing. Reads only from the bank-3 window, which nothing else touches.
void MusicVBlank(void)
{ {
u8 prev = GET_BANK_SEGMENT(2); if (LVGM_IsPlaying())
SET_BANK_SEGMENT(2, seg); LVGM_Decode();
LVGM_Play((const u8*)0x8000, TRUE);
g_MusSeg = seg;
SET_BANK_SEGMENT(2, prev);
} }
// Advance the current track by one frame. Safe with any bank-2 segment selected; // Switch to a looping track: silence the previous track's lingering note (key-off all
// the caller's segment is restored on return. Call once per VBlank (after Halt()). // 9 FM channels), map the new track into bank 3, and start it. Interrupts are disabled
static void PlayFrame(void) // 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); DisableInterrupt();
SET_BANK_SEGMENT(2, g_MusSeg); MSXMusic_Mute();
LVGM_Decode(); SET_BANK_SEGMENT(3, seg);
SET_BANK_SEGMENT(2, prev); 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. // Vertical: R#23 (MSX2). Offset runs base → base±64.
for (u8 i = 0; i < ANIM_STEPS; i++) { for (u8 i = 0; i < ANIM_STEPS; i++) {
Halt(); Halt();
PlayFrame();
u8 new_vy = (u8)((i16)SPR_DRAW_Y + (i16)dy * g_AO[i]); u8 new_vy = (u8)((i16)SPR_DRAW_Y + (i16)dy * g_AO[i]);
g_SprFrame = (g_SprFrame + 1) & 3; g_SprFrame = (g_SprFrame + 1) & 3;
DrawSpriteCompV(spr_vx, spr_vy, new_vy, old_ox, old_oy); DrawSpriteCompV(spr_vx, spr_vy, new_vy, old_ox, old_oy);
@@ -670,7 +673,6 @@ u8 ScrollDraw(i8 dx, i8 dy)
u8 prev = 0; u8 prev = 0;
for (u8 i = 0; i < ANIM_STEPS; i++) { for (u8 i = 0; i < ANIM_STEPS; i++) {
Halt(); Halt();
PlayFrame();
u8 cur = g_AO[i]; u8 cur = g_AO[i];
u8 w = cur - prev; u8 w = cur - prev;
if (dx == 1) { if (dx == 1) {
@@ -873,11 +875,9 @@ static u8 AnyInput(void)
// Drain held inputs, wait for a fresh press, then wait for release. // Drain held inputs, wait for a fresh press, then wait for release.
static void WaitKeySequence(void) static void WaitKeySequence(void)
{ {
// Halt()+PlayFrame() keeps the current track advancing (60 Hz) while waiting, while (AnyInput());
// and syncs one keyboard read per VBlank. Callers (hints/win) have music playing. while (!AnyInput());
while (AnyInput()) { Halt(); PlayFrame(); } while (AnyInput());
while (!AnyInput()) { Halt(); PlayFrame(); }
while (AnyInput()) { Halt(); PlayFrame(); }
} }
// Show the hints screen image (SCREEN 8) before the game begins. // Show the hints screen image (SCREEN 8) before the game begins.
@@ -886,6 +886,9 @@ static void WaitKeySequence(void)
static void ShowHints(void) static void ShowHints(void)
{ {
VDP_EnableDisplay(FALSE); 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++) for (u8 y = 0; y < IMG_ROWS; y++)
WriteImgRow(IMG_SEG_HINTS, y); WriteImgRow(IMG_SEG_HINTS, y);
VDP_EnableDisplay(TRUE); VDP_EnableDisplay(TRUE);
@@ -901,6 +904,12 @@ void TitleScreen(void)
VDP_EnableDisplay(FALSE); VDP_EnableDisplay(FALSE);
VDP_ClearVRAM(); 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 // Stream image A (no title) to VRAM display area
for (u8 y = 0; y < IMG_ROWS; y++) for (u8 y = 0; y < IMG_ROWS; y++)
WriteImgRow(IMG_SEG_A, y); WriteImgRow(IMG_SEG_A, y);
@@ -909,7 +918,7 @@ void TitleScreen(void)
PlayTrack(MUS_SEG_TITLE); PlayTrack(MUS_SEG_TITLE);
// Hold image A for ~2 seconds (100 VBlanks at 50Hz) // 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). // 16-step scanline interleave dissolve to image B (with title).
// Step S updates every 16th row starting at row S. // 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 step = 0; step < 16; step++) {
for (u8 y = step; y < IMG_ROWS; y += 16) for (u8 y = step; y < IMG_ROWS; y += 16)
WriteImgRow(IMG_SEG_B, y); 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) // Wait for any key or joystick input, then release
while (!AnyInput()) { Halt(); PlayFrame(); } while (!AnyInput());
while (AnyInput()) { Halt(); PlayFrame(); } while (AnyInput());
} }
//============================================================================= //=============================================================================
@@ -986,8 +995,10 @@ static void ShowWinScreen(void)
void main(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(); MSXMusic_Initialize();
BIOS_SetHookCallback(H_TIMI, MusicVBlank);
ShowLogoAnimation(); ShowLogoAnimation();
@@ -1077,8 +1088,6 @@ void main(void)
if (!hasDir) { if (!hasDir) {
// Wait for all arrow keys and joystick direction released (debounce) // Wait for all arrow keys and joystick direction released (debounce)
do { do {
Halt();
PlayFrame();
UpdateTimer(); UpdateTimer();
row8 = Keyboard_Read(8); row8 = Keyboard_Read(8);
g_Joy = Joystick_Read(JOY_PORT_1); 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) // Wait for an arrow key, joystick direction, C (cheat), M/joy-A (minimap), S (cat sense)
u8 row3, row4, row5; u8 row3, row4, row5;
do { do {
Halt();
PlayFrame();
UpdateTimer(); UpdateTimer();
row8 = Keyboard_Read(8); row8 = Keyboard_Read(8);
row3 = Keyboard_Read(3); row3 = Keyboard_Read(3);
@@ -1123,7 +1130,6 @@ void main(void)
// past the BIOS interrupt that rewrites the keyboard row selector). // past the BIOS interrupt that rewrites the keyboard row selector).
do { do {
Halt(); Halt();
PlayFrame();
row4 = Keyboard_Read(4); row4 = Keyboard_Read(4);
g_Joy = Joystick_Read(JOY_PORT_1); g_Joy = Joystick_Read(JOY_PORT_1);
} while (IS_KEY_PRESSED(row4, KEY_M) || IS_JOY_PRESSED(g_Joy, JOY_INPUT_TRIGGER_A)); } 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()); DrawArrow(108, 108, GetExitDir());
do { do {
Halt(); Halt();
PlayFrame();
row5 = Keyboard_Read(5); row5 = Keyboard_Read(5);
} while (IS_KEY_PRESSED(row5, KEY_S)); } while (IS_KEY_PRESSED(row5, KEY_S));
// Restore: redraw tile under player then redraw sprite // Restore: redraw tile under player then redraw sprite