From b0f4fcd9b6503d44f67b63b9ba17d43b0cd7b5b7 Mon Sep 17 00:00:00 2001 From: David Racine Date: Mon, 20 Jul 2026 17:58:51 -0400 Subject: [PATCH 01/20] Rename PauseWarp.cpp to PauseMenuSongs.cpp Pure rename so history/blame follow into the expanded feature. Content changes come in the next commit. Co-Authored-By: Claude Opus 4.8 --- soh/soh/Enhancements/QoL/{PauseWarp.cpp => PauseMenuSongs.cpp} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename soh/soh/Enhancements/QoL/{PauseWarp.cpp => PauseMenuSongs.cpp} (100%) diff --git a/soh/soh/Enhancements/QoL/PauseWarp.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp similarity index 100% rename from soh/soh/Enhancements/QoL/PauseWarp.cpp rename to soh/soh/Enhancements/QoL/PauseMenuSongs.cpp From 69886b26f2a4a75a27002b63239eb948bc9aeaa5 Mon Sep 17 00:00:00 2001 From: David Racine Date: Mon, 20 Jul 2026 18:00:47 -0400 Subject: [PATCH 02/20] Play non-warp ocarina songs from the pause menu Extends the pause-menu song shortcut to the six non-warp songs. Selecting a song closes the menu and triggers its effect in gameplay: warp songs warp as before, and songs with a zone effect (Zelda's Lullaby, Song of Storms, Song of Time) hand the matching in-range staff spot straight to its own listening handler, so vanilla keeps owning every effect, cutscene and precondition. Splits the old "Pause Warp" toggle into "Play Songs from Pause Menu" and "Skip Warp Cutscenes", with a config migration carrying the old flag onto both. Co-Authored-By: Claude Opus 4.8 --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 229 +++++++++++++----- .../Enhancements/QoL/SkipWarpAnimation.cpp | 32 +++ soh/soh/SohGui/SohMenuEnhancements.cpp | 12 +- soh/soh/config/ConfigUpdaters.cpp | 8 + .../ovl_En_Okarina_Tag/z_en_okarina_tag.c | 29 +++ 5 files changed, 248 insertions(+), 62 deletions(-) create mode 100644 soh/soh/Enhancements/QoL/SkipWarpAnimation.cpp diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 58f7ab5d492..5ea1f9fd65f 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -11,12 +11,15 @@ extern "C" { extern PlayState* gPlayState; u8 Randomizer_GetSettingValue(RandomizerSettingKey); +void EnOkarinaTag_ActivateFromPauseMenu(PlayState* play); } static constexpr int32_t CVAR_PAUSE_WARP_DEFAULT = 0; -#define CVAR_PAUSE_WARP_NAME CVAR_ENHANCEMENT("PauseWarp") +#define CVAR_PAUSE_WARP_NAME CVAR_ENHANCEMENT("PauseMenuSongs") #define CVAR_PAUSE_WARP_VALUE CVarGetInteger(CVAR_PAUSE_WARP_NAME, CVAR_PAUSE_WARP_DEFAULT) +// --- Warp songs (QUEST_SONG_MINUET through QUEST_SONG_PRELUDE) --- + static const int songMessageMap[] = { TEXT_WARP_MINUET_OF_FOREST, TEXT_WARP_BOLERO_OF_FIRE, TEXT_WARP_SERENADE_OF_WATER, TEXT_WARP_REQUIEM_OF_SPIRIT, TEXT_WARP_NOCTURNE_OF_SHADOW, TEXT_WARP_PRELUDE_OF_LIGHT, @@ -41,7 +44,42 @@ static const int songAudioMap[] = { NA_BGM_OCA_REQUIEM, NA_BGM_OCA_NOCTURNE, NA_BGM_OCA_LIGHT, }; +// --- Non-warp songs (QUEST_SONG_LULLABY through QUEST_SONG_STORMS) --- +// Indexed by (QUEST_SONG_* - QUEST_SONG_LULLABY) + +static const int questSongToOcarinaSong[] = { + OCARINA_SONG_LULLABY, // QUEST_SONG_LULLABY + OCARINA_SONG_EPONAS, // QUEST_SONG_EPONA + OCARINA_SONG_SARIAS, // QUEST_SONG_SARIA + OCARINA_SONG_SUNS, // QUEST_SONG_SUN + OCARINA_SONG_TIME, // QUEST_SONG_TIME + OCARINA_SONG_STORMS, // QUEST_SONG_STORMS +}; + +static const int nonWarpSongFanfareMap[] = { + NA_BGM_OCA_ZELDA, // QUEST_SONG_LULLABY + NA_BGM_OCA_EPONA, // QUEST_SONG_EPONA + NA_BGM_OCA_SARIA, // QUEST_SONG_SARIA + NA_BGM_OCA_SUNS, // QUEST_SONG_SUN + NA_BGM_OCA_TIME, // QUEST_SONG_TIME + NA_BGM_OCA_STORM, // QUEST_SONG_STORMS +}; + +// Effect actor IDs indexed by (OCARINA_SONG_* - OCARINA_SONG_SARIAS) +// Mirrors sOcarinaEffectActorIds in z_message_PAL.c +static const int effectActorIds[] = { + ACTOR_OCEFF_WIPE3, // OCARINA_SONG_SARIAS + ACTOR_OCEFF_WIPE2, // OCARINA_SONG_EPONAS + ACTOR_OCEFF_WIPE, // OCARINA_SONG_LULLABY + ACTOR_OCEFF_SPOT, // OCARINA_SONG_SUNS + ACTOR_OCEFF_WIPE, // OCARINA_SONG_TIME + ACTOR_OCEFF_STORM, // OCARINA_SONG_STORMS +}; +static const int effectActorParams[] = { 0, 0, 0, 0, 1, 0 }; + static bool isWarpActive = false; +static bool isSongActive = false; +static bool needsOcarinaCleanup = false; static void PauseWarp_Execute() { if (!isWarpActive || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { @@ -52,20 +90,72 @@ static void PauseWarp_Execute() { if (gPlayState->msgCtx.choiceIndex != 0) { return; } + + // Set up respawn destination before spawning the warp actor if (IS_RANDO) { Entrance_SetWarpSongEntrance(); - return; + } else { + for (int i = 0; i < ARRAY_COUNT(ocarinaSongMap); i++) { + if (gPlayState->msgCtx.lastPlayedSong == ocarinaSongMap[i]) { + gSaveContext.respawn[RESPAWN_MODE_RETURN].entranceIndex = entranceIndexMap[i]; + gSaveContext.respawn[RESPAWN_MODE_RETURN].playerParams = 0x5FF; + gSaveContext.respawn[RESPAWN_MODE_RETURN].data = gPlayState->msgCtx.lastPlayedSong; + Interface_SetSubTimerToFinalSecond(gPlayState); + break; + } + } + } + + // Trigger the warp cutscene (matches Player_Action_8084E3C4's OCARINA_MODE_02 handling) + Player* player = GET_PLAYER(gPlayState); + player->csAction = 0; + player->stateFlags1 &= ~PLAYER_STATE1_IN_CUTSCENE; + Player_TryCsAction(gPlayState, NULL, 8); + gPlayState->mainCamera.unk_14C &= ~8; + player->stateFlags1 |= PLAYER_STATE1_IN_ITEM_CS | PLAYER_STATE1_IN_CUTSCENE; + player->stateFlags2 |= PLAYER_STATE2_OCARINA_PLAYING; + + if (Actor_Spawn(&gPlayState->actorCtx, gPlayState, ACTOR_DEMO_KANKYO, 0.0f, 0.0f, 0.0f, 0, 0, 0, 0xF) == NULL) { + Environment_WarpSongLeave(gPlayState); } - gPlayState->transitionTrigger = TRANS_TRIGGER_START; - gPlayState->transitionType = TRANS_TYPE_FADE_WHITE_FAST; - for (int i = 0; i < ARRAY_COUNT(ocarinaSongMap); i++) { - if (gPlayState->msgCtx.lastPlayedSong == ocarinaSongMap[i]) { - gPlayState->nextEntranceIndex = entranceIndexMap[i]; - Interface_SetSubTimerToFinalSecond(gPlayState); - return; + + gSaveContext.seqId = (u8)NA_BGM_DISABLED; + gSaveContext.natureAmbienceId = NATURE_ID_DISABLED; +} + +static void PauseSong_Execute() { + if (needsOcarinaCleanup) { + // Restore ocarina/message state to rest after the trigger frame. The Water Temple triforce + // leaves msgMode = MSGMODE_PAUSED, which would otherwise block the pause menu (z_play gates on NONE). + gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; + if (gPlayState->msgCtx.msgMode == MSGMODE_PAUSED) { + gPlayState->msgCtx.msgMode = MSGMODE_NONE; } + needsOcarinaCleanup = false; + return; + } + + if (!isSongActive || gPlayState->pauseCtx.state != 0 || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { + return; + } + isSongActive = false; + + int song = gPlayState->msgCtx.lastPlayedSong; + if (song < OCARINA_SONG_SARIAS || song > OCARINA_SONG_STORMS) { + return; } - gPlayState->transitionTrigger = TRANS_TRIGGER_OFF; + int idx = song - OCARINA_SONG_SARIAS; + if (song == OCARINA_SONG_EPONAS) { + DREG(53) = 1; + } + Player* player = GET_PLAYER(gPlayState); + Actor_Spawn(&gPlayState->actorCtx, gPlayState, effectActorIds[idx], player->actor.world.pos.x, + player->actor.world.pos.y, player->actor.world.pos.z, 0, 0, 0, effectActorParams[idx]); + + // Flag the correct-song state and hand matching in-range spots to their listening handler. + gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_03; + EnOkarinaTag_ActivateFromPauseMenu(gPlayState); + needsOcarinaCleanup = true; } static void ActivateWarp(PauseContext* pauseCtx, int song) { @@ -84,68 +174,89 @@ static void ActivateWarp(PauseContext* pauseCtx, int song) { isWarpActive = true; } -static void PauseWarp_HandleSelection() { - if (gSaveContext.inventory.items[SLOT_OCARINA] != ITEM_NONE) { - int aButtonPressed = CHECK_BTN_ALL(gPlayState->state.input->press.button, BTN_A); - int song = gPlayState->pauseCtx.cursorPoint[PAUSE_QUEST]; - if (aButtonPressed && CHECK_QUEST_ITEM(song) && song >= QUEST_SONG_MINUET && song <= QUEST_SONG_PRELUDE && - gPlayState->pauseCtx.pageIndex == PAUSE_QUEST && gPlayState->pauseCtx.state == 6) { - if (gSaveContext.ship.quest.id == QUEST_RANDOMIZER && - Randomizer_GetSettingValue(RSK_SHUFFLE_OCARINA_BUTTONS)) { - bool canplay = false; - switch (song) { - case QUEST_SONG_MINUET: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_UP); - break; - case QUEST_SONG_BOLERO: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); - break; - case QUEST_SONG_SERENADE: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); - break; - case QUEST_SONG_REQUIEM: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); - break; - case QUEST_SONG_NOCTURNE: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); - break; - case QUEST_SONG_PRELUDE: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_UP); - break; - } - if (!canplay) { - return; - } +static void ActivateSong(PauseContext* pauseCtx, int questSong) { + int idx = questSong - QUEST_SONG_LULLABY; + Interface_SetDoAction(gPlayState, DO_ACTION_NONE); + pauseCtx->state = 0x12; + WREG(2) = -6240; + func_800F64E0(0); + pauseCtx->unk_1E4 = 0; + gPlayState->msgCtx.lastPlayedSong = questSongToOcarinaSong[idx]; + // Intentionally no Audio_SetSoundBanksMute(0x20): it mutes BANK_OCARINA and is only cleared via + // AudioOcarina_SetInstrument(OFF), which this in-scene path never hits, silencing the next real ocarina. + Audio_PlayFanfare(nonWarpSongFanfareMap[idx]); + isSongActive = true; +} + +static void PauseMenuSongs_HandleSelection() { + if (gSaveContext.inventory.items[SLOT_OCARINA] == ITEM_NONE) { + return; + } + int aButtonPressed = CHECK_BTN_ALL(gPlayState->state.input->press.button, BTN_A); + int song = gPlayState->pauseCtx.cursorPoint[PAUSE_QUEST]; + if (!aButtonPressed || !CHECK_QUEST_ITEM(song) || gPlayState->pauseCtx.pageIndex != PAUSE_QUEST || + gPlayState->pauseCtx.state != 6) { + return; + } + + if (song >= QUEST_SONG_MINUET && song <= QUEST_SONG_PRELUDE) { + if (gSaveContext.ship.quest.id == QUEST_RANDOMIZER && Randomizer_GetSettingValue(RSK_SHUFFLE_OCARINA_BUTTONS)) { + bool canplay = false; + switch (song) { + case QUEST_SONG_MINUET: + canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_UP); + break; + case QUEST_SONG_BOLERO: + canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); + break; + case QUEST_SONG_SERENADE: + canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); + break; + case QUEST_SONG_REQUIEM: + canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); + break; + case QUEST_SONG_NOCTURNE: + canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); + break; + case QUEST_SONG_PRELUDE: + canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_UP); + break; + } + if (!canplay) { + return; } - ActivateWarp(&gPlayState->pauseCtx, song); } + ActivateWarp(&gPlayState->pauseCtx, song); + } else if (song >= QUEST_SONG_LULLABY && song <= QUEST_SONG_STORMS) { + ActivateSong(&gPlayState->pauseCtx, song); } } static void RegisterPauseMenuHooks() { COND_HOOK(OnKaleidoUpdate, CVAR_PAUSE_WARP_VALUE, [] { if (GameInteractor::IsSaveLoaded()) { - PauseWarp_HandleSelection(); + PauseMenuSongs_HandleSelection(); } }); COND_HOOK(OnGameFrameUpdate, CVAR_PAUSE_WARP_VALUE, [] { if (GameInteractor::IsSaveLoaded()) { PauseWarp_Execute(); + PauseSong_Execute(); } }); } diff --git a/soh/soh/Enhancements/QoL/SkipWarpAnimation.cpp b/soh/soh/Enhancements/QoL/SkipWarpAnimation.cpp new file mode 100644 index 00000000000..b4554544b9b --- /dev/null +++ b/soh/soh/Enhancements/QoL/SkipWarpAnimation.cpp @@ -0,0 +1,32 @@ +#include "soh/Enhancements/game-interactor/GameInteractor_Hooks.h" +#include "soh/ShipInit.hpp" + +extern "C" { +#include "functions.h" +#include "macros.h" +#include "variables.h" + +extern PlayState* gPlayState; +} + +static constexpr int32_t CVAR_SKIP_WARP_DEFAULT = 0; +#define CVAR_SKIP_WARP_NAME CVAR_ENHANCEMENT("WarpSongSkipAnimation") +#define CVAR_SKIP_WARP_VALUE CVarGetInteger(CVAR_SKIP_WARP_NAME, CVAR_SKIP_WARP_DEFAULT) + +// DEMOKANKYO_WARP_OUT = 0x0F; the warp-song departure animation spawned by Player_Action_8084E3C4 +static constexpr s16 DEMOKANKYO_WARP_OUT_PARAM = 0x0F; + +static void RegisterSkipWarpHooks() { + COND_HOOK(OnActorInit, CVAR_SKIP_WARP_VALUE, [](void* refActor) { + Actor* actor = static_cast(refActor); + if (actor->id == ACTOR_DEMO_KANKYO && actor->params == DEMOKANKYO_WARP_OUT_PARAM) { + Environment_WarpSongLeave(gPlayState); + // Switch arrival spawn mode from WARP_SONG to IDLE so DEMO_KANKYO WARP_IN is never spawned + gSaveContext.respawn[RESPAWN_MODE_RETURN].playerParams = + (gSaveContext.respawn[RESPAWN_MODE_RETURN].playerParams & ~(0xF << 8)) | (PLAYER_START_MODE_IDLE << 8); + Actor_Kill(actor); + } + }); +} + +static RegisterShipInitFunc initFunc(RegisterSkipWarpHooks, { CVAR_SKIP_WARP_NAME }); diff --git a/soh/soh/SohGui/SohMenuEnhancements.cpp b/soh/soh/SohGui/SohMenuEnhancements.cpp index 5ded153733a..96de1ffe5cf 100644 --- a/soh/soh/SohGui/SohMenuEnhancements.cpp +++ b/soh/soh/SohGui/SohMenuEnhancements.cpp @@ -246,10 +246,11 @@ void SohMenu::AddMenuEnhancements() { .DefaultIndex(PAUSE_ANY_CURSOR_RANDO_ONLY) .Tooltip("Allows the cursor on the pause menu to be over any slot. Sometimes required in Randomizer " "to select certain items.")); - AddWidget(path, "Pause Warp", WIDGET_CVAR_CHECKBOX) - .CVar(CVAR_ENHANCEMENT("PauseWarp")) + AddWidget(path, "Play Songs from Pause Menu", WIDGET_CVAR_CHECKBOX) + .CVar(CVAR_ENHANCEMENT("PauseMenuSongs")) .Options(CheckboxOptions().Tooltip( - "Selection of warp song in pause menu initiates a warp. Disables song playback.")); + "Pressing A on any song in the Quest screen closes the menu and triggers the song's effect in gameplay. " + "Disables song in-menu playback.")); path.column = SECTION_COLUMN_2; AddWidget(path, "Controls", WIDGET_SEPARATOR_TEXT); @@ -431,6 +432,11 @@ void SohMenu::AddMenuEnhancements() { AddWidget(path, "Skip Song Cutscenes", WIDGET_CVAR_CHECKBOX) .CVar(CVAR_ENHANCEMENT("TimeSavers.SkipCutscene.LearnSong")) .Options(CheckboxOptions().DefaultValue(IS_RANDO)); + AddWidget(path, "Skip Warp Cutscenes", WIDGET_CVAR_CHECKBOX) + .CVar(CVAR_ENHANCEMENT("WarpSongSkipAnimation")) + .Options(CheckboxOptions().Tooltip( + "Warp songs skip the departure and arrival cutscenes, fading immediately to the destination. " + "Applies both when playing the ocarina manually and when using the pause menu shortcut.")); AddWidget(path, "Skip Boss Introductions", WIDGET_CVAR_CHECKBOX) .CVar(CVAR_ENHANCEMENT("TimeSavers.SkipCutscene.BossIntro")) .Options(CheckboxOptions().DefaultValue(IS_RANDO)); diff --git a/soh/soh/config/ConfigUpdaters.cpp b/soh/soh/config/ConfigUpdaters.cpp index 996f064adce..3a7d609d5a7 100644 --- a/soh/soh/config/ConfigUpdaters.cpp +++ b/soh/soh/config/ConfigUpdaters.cpp @@ -1669,5 +1669,13 @@ void ConfigVersion7Updater::Update(Ship::Config* conf) { // Kakariko Gate setting removed; the gate opens when starting with an unshuffled letter CVarClear("gRandoSettings.KakarikoGate"); + + // PauseWarp was split into PauseMenuSongs (covers all songs) + WarpSongSkipAnimation. + // Old behavior skipped the animation, so migrate both flags on. + if (CVarGetInteger("gEnhancements.PauseWarp", 0)) { + CVarSetInteger("gEnhancements.PauseMenuSongs", 1); + CVarSetInteger("gEnhancements.WarpSongSkipAnimation", 1); + } + CVarClear("gEnhancements.PauseWarp"); } } // namespace SOH diff --git a/soh/src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.c b/soh/src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.c index 801bcf69f45..bdcce31b462 100644 --- a/soh/src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.c +++ b/soh/src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.c @@ -324,6 +324,35 @@ void func_80ABF7CC(EnOkarinaTag* this, PlayState* play) { } } +// Hands the in-range staff spot matching the played song to its listening handler; the caller sets +// ocarinaMode=OCARINA_MODE_03 to fire the effect. All effect and scene logic stays in the handler. +void EnOkarinaTag_ActivateFromPauseMenu(PlayState* play) { + Player* player = GET_PLAYER(play); + u16 song = play->msgCtx.lastPlayedSong; + // Type-7 spots store the song in ocarinaSong as an offset from Saria (Lullaby = 2). + u8 songIndex = (u8)(song - OCARINA_SONG_SARIAS); + Actor* actor = play->actorCtx.actorLists[ACTORCAT_PROP].head; + + while (actor != NULL) { + Actor* next = actor->next; + if (actor->id == ACTOR_EN_OKARINA_TAG) { + EnOkarinaTag* tag = (EnOkarinaTag*)actor; + if ((tag->actor.xzDistToPlayer < (90.0f + tag->interactRange)) && + (fabsf(player->actor.world.pos.y - tag->actor.world.pos.y) < 80.0f)) { + if (tag->actionFunc == func_80ABEF2C && tag->ocarinaSong == songIndex) { + tag->actionFunc = func_80ABF0CC; + } else if (tag->actionFunc == func_80ABF28C && + ((song == OCARINA_SONG_LULLABY && (tag->type == 1 || tag->type == 6)) || + (song == OCARINA_SONG_STORMS && tag->type == 2) || + (song == OCARINA_SONG_TIME && tag->type == 4))) { + tag->actionFunc = func_80ABF4C8; + } + } + } + actor = next; + } +} + void EnOkarinaTag_Update(Actor* thisx, PlayState* play) { EnOkarinaTag* this = (EnOkarinaTag*)thisx; From 01474d26e5029eee2eeccc4b770201b48b97c70d Mon Sep 17 00:00:00 2001 From: David Racine Date: Mon, 20 Jul 2026 19:02:32 -0400 Subject: [PATCH 03/20] Cast lastPlayedSong to s8 for respawn data (fix MSVC /WX) RespawnData.data is s8; lastPlayedSong is u16. gcc is quiet but Windows CI treats the narrowing (C4244) as an error. Co-Authored-By: Claude Opus 4.8 --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 5ea1f9fd65f..66685f836be 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -99,7 +99,7 @@ static void PauseWarp_Execute() { if (gPlayState->msgCtx.lastPlayedSong == ocarinaSongMap[i]) { gSaveContext.respawn[RESPAWN_MODE_RETURN].entranceIndex = entranceIndexMap[i]; gSaveContext.respawn[RESPAWN_MODE_RETURN].playerParams = 0x5FF; - gSaveContext.respawn[RESPAWN_MODE_RETURN].data = gPlayState->msgCtx.lastPlayedSong; + gSaveContext.respawn[RESPAWN_MODE_RETURN].data = (s8)gPlayState->msgCtx.lastPlayedSong; Interface_SetSubTimerToFinalSecond(gPlayState); break; } From 99fb7c2bdde396fd279fc4531344855a00164aa3 Mon Sep 17 00:00:00 2001 From: David Racine Date: Tue, 21 Jul 2026 08:20:18 -0400 Subject: [PATCH 04/20] Move okarina-tag activation into the enhancement, out of decomp Addresses review feedback: keep the play-songs-from-pause-menu feature self-contained in its hook instead of exporting a helper from the En_Okarina_Tag overlay. The staff-spot listening handlers are declared extern in PauseMenuSongs.cpp (same idiom as RebottleBlueFire), so z_en_okarina_tag.c returns to vanilla. Co-Authored-By: Claude Opus 4.8 --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 37 ++++++++++++++++++- .../ovl_En_Okarina_Tag/z_en_okarina_tag.c | 29 --------------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 66685f836be..80debf069e3 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -7,11 +7,17 @@ extern "C" { #include "functions.h" #include "macros.h" #include "variables.h" +#include "src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.h" extern PlayState* gPlayState; u8 Randomizer_GetSettingValue(RandomizerSettingKey); -void EnOkarinaTag_ActivateFromPauseMenu(PlayState* play); + +// Staff-spot (En_Okarina_Tag) idle/listening handlers, not exposed in a header. +void func_80ABEF2C(EnOkarinaTag* tag, PlayState* play); +void func_80ABF0CC(EnOkarinaTag* tag, PlayState* play); +void func_80ABF28C(EnOkarinaTag* tag, PlayState* play); +void func_80ABF4C8(EnOkarinaTag* tag, PlayState* play); } static constexpr int32_t CVAR_PAUSE_WARP_DEFAULT = 0; @@ -123,6 +129,33 @@ static void PauseWarp_Execute() { gSaveContext.natureAmbienceId = NATURE_ID_DISABLED; } +// Hand each in-range staff spot matching the played song to its listening handler, so the vanilla +// handler (with ocarinaMode == OCARINA_MODE_03 set by the caller) fires all effect and scene logic. +static void PauseSong_ActivateOkarinaTags() { + Player* player = GET_PLAYER(gPlayState); + u16 song = gPlayState->msgCtx.lastPlayedSong; + // Type-7 spots store the song in ocarinaSong as an offset from Saria (Lullaby = 2). + u8 songIndex = (u8)(song - OCARINA_SONG_SARIAS); + + for (Actor* actor = gPlayState->actorCtx.actorLists[ACTORCAT_PROP].head; actor != NULL; actor = actor->next) { + if (actor->id != ACTOR_EN_OKARINA_TAG) { + continue; + } + EnOkarinaTag* tag = (EnOkarinaTag*)actor; + if ((tag->actor.xzDistToPlayer < (90.0f + tag->interactRange)) && + (fabsf(player->actor.world.pos.y - tag->actor.world.pos.y) < 80.0f)) { + if (tag->actionFunc == func_80ABEF2C && tag->ocarinaSong == songIndex) { + tag->actionFunc = func_80ABF0CC; + } else if (tag->actionFunc == func_80ABF28C && + ((song == OCARINA_SONG_LULLABY && (tag->type == 1 || tag->type == 6)) || + (song == OCARINA_SONG_STORMS && tag->type == 2) || + (song == OCARINA_SONG_TIME && tag->type == 4))) { + tag->actionFunc = func_80ABF4C8; + } + } + } +} + static void PauseSong_Execute() { if (needsOcarinaCleanup) { // Restore ocarina/message state to rest after the trigger frame. The Water Temple triforce @@ -154,7 +187,7 @@ static void PauseSong_Execute() { // Flag the correct-song state and hand matching in-range spots to their listening handler. gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_03; - EnOkarinaTag_ActivateFromPauseMenu(gPlayState); + PauseSong_ActivateOkarinaTags(); needsOcarinaCleanup = true; } diff --git a/soh/src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.c b/soh/src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.c index bdcce31b462..801bcf69f45 100644 --- a/soh/src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.c +++ b/soh/src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.c @@ -324,35 +324,6 @@ void func_80ABF7CC(EnOkarinaTag* this, PlayState* play) { } } -// Hands the in-range staff spot matching the played song to its listening handler; the caller sets -// ocarinaMode=OCARINA_MODE_03 to fire the effect. All effect and scene logic stays in the handler. -void EnOkarinaTag_ActivateFromPauseMenu(PlayState* play) { - Player* player = GET_PLAYER(play); - u16 song = play->msgCtx.lastPlayedSong; - // Type-7 spots store the song in ocarinaSong as an offset from Saria (Lullaby = 2). - u8 songIndex = (u8)(song - OCARINA_SONG_SARIAS); - Actor* actor = play->actorCtx.actorLists[ACTORCAT_PROP].head; - - while (actor != NULL) { - Actor* next = actor->next; - if (actor->id == ACTOR_EN_OKARINA_TAG) { - EnOkarinaTag* tag = (EnOkarinaTag*)actor; - if ((tag->actor.xzDistToPlayer < (90.0f + tag->interactRange)) && - (fabsf(player->actor.world.pos.y - tag->actor.world.pos.y) < 80.0f)) { - if (tag->actionFunc == func_80ABEF2C && tag->ocarinaSong == songIndex) { - tag->actionFunc = func_80ABF0CC; - } else if (tag->actionFunc == func_80ABF28C && - ((song == OCARINA_SONG_LULLABY && (tag->type == 1 || tag->type == 6)) || - (song == OCARINA_SONG_STORMS && tag->type == 2) || - (song == OCARINA_SONG_TIME && tag->type == 4))) { - tag->actionFunc = func_80ABF4C8; - } - } - } - actor = next; - } -} - void EnOkarinaTag_Update(Actor* thisx, PlayState* play) { EnOkarinaTag* this = (EnOkarinaTag*)thisx; From c221fc291479cc8c200cf71605fdbdaac7cbd88d Mon Sep 17 00:00:00 2001 From: David Racine Date: Tue, 21 Jul 2026 10:12:19 -0400 Subject: [PATCH 05/20] Fire the okarina-tag handler in the same frame Call the matching spot's listening handler directly and reset the ocarina/message state inline, instead of flipping actionFunc and deferring cleanup to the next frame. Drops the needsOcarinaCleanup state and keeps the whole trigger in one frame. Co-Authored-By: Claude Opus 4.8 --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 29 ++++++++------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 80debf069e3..23f51e546ac 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -85,7 +85,6 @@ static const int effectActorParams[] = { 0, 0, 0, 0, 1, 0 }; static bool isWarpActive = false; static bool isSongActive = false; -static bool needsOcarinaCleanup = false; static void PauseWarp_Execute() { if (!isWarpActive || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { @@ -129,8 +128,8 @@ static void PauseWarp_Execute() { gSaveContext.natureAmbienceId = NATURE_ID_DISABLED; } -// Hand each in-range staff spot matching the played song to its listening handler, so the vanilla -// handler (with ocarinaMode == OCARINA_MODE_03 set by the caller) fires all effect and scene logic. +// Fire each in-range staff spot matching the played song by calling its listening handler directly. +// The caller has set ocarinaMode == OCARINA_MODE_03, so the handler runs its full effect/scene logic. static void PauseSong_ActivateOkarinaTags() { Player* player = GET_PLAYER(gPlayState); u16 song = gPlayState->msgCtx.lastPlayedSong; @@ -145,29 +144,18 @@ static void PauseSong_ActivateOkarinaTags() { if ((tag->actor.xzDistToPlayer < (90.0f + tag->interactRange)) && (fabsf(player->actor.world.pos.y - tag->actor.world.pos.y) < 80.0f)) { if (tag->actionFunc == func_80ABEF2C && tag->ocarinaSong == songIndex) { - tag->actionFunc = func_80ABF0CC; + func_80ABF0CC(tag, gPlayState); } else if (tag->actionFunc == func_80ABF28C && ((song == OCARINA_SONG_LULLABY && (tag->type == 1 || tag->type == 6)) || (song == OCARINA_SONG_STORMS && tag->type == 2) || (song == OCARINA_SONG_TIME && tag->type == 4))) { - tag->actionFunc = func_80ABF4C8; + func_80ABF4C8(tag, gPlayState); } } } } static void PauseSong_Execute() { - if (needsOcarinaCleanup) { - // Restore ocarina/message state to rest after the trigger frame. The Water Temple triforce - // leaves msgMode = MSGMODE_PAUSED, which would otherwise block the pause menu (z_play gates on NONE). - gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; - if (gPlayState->msgCtx.msgMode == MSGMODE_PAUSED) { - gPlayState->msgCtx.msgMode = MSGMODE_NONE; - } - needsOcarinaCleanup = false; - return; - } - if (!isSongActive || gPlayState->pauseCtx.state != 0 || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { return; } @@ -185,10 +173,15 @@ static void PauseSong_Execute() { Actor_Spawn(&gPlayState->actorCtx, gPlayState, effectActorIds[idx], player->actor.world.pos.x, player->actor.world.pos.y, player->actor.world.pos.z, 0, 0, 0, effectActorParams[idx]); - // Flag the correct-song state and hand matching in-range spots to their listening handler. + // Flag the correct-song state, fire matching in-range spots, then return ocarina/message state to + // rest. The Water Temple triforce leaves msgMode = MSGMODE_PAUSED, which would otherwise block the + // pause menu (z_play gates on NONE). gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_03; PauseSong_ActivateOkarinaTags(); - needsOcarinaCleanup = true; + gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; + if (gPlayState->msgCtx.msgMode == MSGMODE_PAUSED) { + gPlayState->msgCtx.msgMode = MSGMODE_NONE; + } } static void ActivateWarp(PauseContext* pauseCtx, int song) { From f68f2c779ca5638a4c818b6752637e4c17d3f51e Mon Sep 17 00:00:00 2001 From: David Racine Date: Tue, 21 Jul 2026 14:46:54 -0400 Subject: [PATCH 06/20] Add a specific logic for Mido --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 83 +++++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 23f51e546ac..5bb213163dd 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -8,6 +8,7 @@ extern "C" { #include "macros.h" #include "variables.h" #include "src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.h" +#include "src/overlays/actors/ovl_En_Md/z_en_md.h" extern PlayState* gPlayState; @@ -18,6 +19,10 @@ void func_80ABEF2C(EnOkarinaTag* tag, PlayState* play); void func_80ABF0CC(EnOkarinaTag* tag, PlayState* play); void func_80ABF28C(EnOkarinaTag* tag, PlayState* play); void func_80ABF4C8(EnOkarinaTag* tag, PlayState* play); +// Mido (En_Md) block/listen handlers, not exposed in a header. +void EnMd_BlockPath(EnMd* actor, PlayState* play); +void EnMd_ListenToOcarina(EnMd* actor, PlayState* play); +void Player_StartTalking(PlayState* play, Actor* actor); } static constexpr int32_t CVAR_PAUSE_WARP_DEFAULT = 0; @@ -130,11 +135,13 @@ static void PauseWarp_Execute() { // Fire each in-range staff spot matching the played song by calling its listening handler directly. // The caller has set ocarinaMode == OCARINA_MODE_03, so the handler runs its full effect/scene logic. -static void PauseSong_ActivateOkarinaTags() { +// Returns true if a spot was fired. +static bool PauseSong_ActivateOkarinaTags() { Player* player = GET_PLAYER(gPlayState); u16 song = gPlayState->msgCtx.lastPlayedSong; // Type-7 spots store the song in ocarinaSong as an offset from Saria (Lullaby = 2). u8 songIndex = (u8)(song - OCARINA_SONG_SARIAS); + bool matched = false; for (Actor* actor = gPlayState->actorCtx.actorLists[ACTORCAT_PROP].head; actor != NULL; actor = actor->next) { if (actor->id != ACTOR_EN_OKARINA_TAG) { @@ -145,17 +152,76 @@ static void PauseSong_ActivateOkarinaTags() { (fabsf(player->actor.world.pos.y - tag->actor.world.pos.y) < 80.0f)) { if (tag->actionFunc == func_80ABEF2C && tag->ocarinaSong == songIndex) { func_80ABF0CC(tag, gPlayState); + matched = true; } else if (tag->actionFunc == func_80ABF28C && ((song == OCARINA_SONG_LULLABY && (tag->type == 1 || tag->type == 6)) || (song == OCARINA_SONG_STORMS && tag->type == 2) || (song == OCARINA_SONG_TIME && tag->type == 4))) { func_80ABF4C8(tag, gPlayState); + matched = true; } } } + return matched; +} + +// --- Per-actor hand-off for non-tag ocarina NPCs --- +// Unlike the staff spots, these actors have no generic way to be driven, and going through the vanilla +// play-for-actor flow (PLAYER_STATE2_ATTEMPT_PLAY_FOR_ACTOR) opens a real note-input prompt the player +// can't satisfy from the menu. So, exactly like the tags, we set the actor's actionFunc straight to its +// listening handler and leave OCARINA_MODE_03 set; the handler reacts on the next frame. Any dialogue it +// offers is then started here, since the player -- never in its ocarina action -- won't do it itself. +static Actor* npcTalkActor = NULL; +static int npcHandoffTimer = 0; + +static void PauseSong_EndNpcHandoff() { + if (gPlayState->msgCtx.ocarinaMode == OCARINA_MODE_03 || gPlayState->msgCtx.ocarinaMode == OCARINA_MODE_04) { + gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; + } + npcTalkActor = NULL; + npcHandoffTimer = 0; +} + +// Hand the played song to a matching in-range NPC. Returns true if one was engaged, in which case +// OCARINA_MODE_03 is left set for it to consume next frame. +static bool PauseSong_ActivateNpcActors() { + Player* player = GET_PLAYER(gPlayState); + u16 song = gPlayState->msgCtx.lastPlayedSong; + + for (Actor* actor = gPlayState->actorCtx.actorLists[ACTORCAT_NPC].head; actor != NULL; actor = actor->next) { + // Mido, in the Lost Woods, moves aside when played Saria's Song. + if (actor->id == ACTOR_EN_MD && song == OCARINA_SONG_SARIAS && gPlayState->sceneNum == SCENE_LOST_WOODS) { + EnMd* mido = (EnMd*)actor; + if (mido->actionFunc == EnMd_BlockPath && mido->interactInfo.talkState == NPC_TALK_STATE_IDLE && + actor->xzDistToPlayer < 100.0f) { + mido->actionFunc = EnMd_ListenToOcarina; + npcTalkActor = actor; + return true; + } + } + } + return false; +} + +// Returns true while a hand-off is running, so the caller skips starting another song this frame. +static bool PauseSong_AdvanceNpcHandoff() { + if (npcTalkActor == NULL) { + return false; + } + // The actor consumed OCARINA_MODE_03 (set MODE_04) and reacted; start any dialogue it offered. + if (gPlayState->msgCtx.ocarinaMode != OCARINA_MODE_03 || ++npcHandoffTimer > 30) { + if (gPlayState->msgCtx.ocarinaMode != OCARINA_MODE_03 && npcTalkActor->textId != 0) { + Player_StartTalking(gPlayState, npcTalkActor); + } + PauseSong_EndNpcHandoff(); + } + return true; } static void PauseSong_Execute() { + if (PauseSong_AdvanceNpcHandoff()) { + return; + } if (!isSongActive || gPlayState->pauseCtx.state != 0 || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { return; } @@ -173,11 +239,18 @@ static void PauseSong_Execute() { Actor_Spawn(&gPlayState->actorCtx, gPlayState, effectActorIds[idx], player->actor.world.pos.x, player->actor.world.pos.y, player->actor.world.pos.z, 0, 0, 0, effectActorParams[idx]); - // Flag the correct-song state, fire matching in-range spots, then return ocarina/message state to - // rest. The Water Temple triforce leaves msgMode = MSGMODE_PAUSED, which would otherwise block the - // pause menu (z_play gates on NONE). + // Flag the correct-song state and fire matching in-range spots. A staff spot runs synchronously, so + // reset the ocarina/message state right after. The Water Temple triforce leaves msgMode = + // MSGMODE_PAUSED, which would otherwise block the pause menu (z_play gates on NONE). gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_03; - PauseSong_ActivateOkarinaTags(); + bool matched = PauseSong_ActivateOkarinaTags(); + + // No staff spot took it: try a matching NPC (Mido, ...). If one engages, leave OCARINA_MODE_03 set + // for it to consume next frame; the hand-off machinery finishes and restores state. + if (!matched && PauseSong_ActivateNpcActors()) { + return; + } + gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; if (gPlayState->msgCtx.msgMode == MSGMODE_PAUSED) { gPlayState->msgCtx.msgMode = MSGMODE_NONE; From b0c337649d1fac3822e25d264b4611b08545ae33 Mon Sep 17 00:00:00 2001 From: David Racine Date: Wed, 22 Jul 2026 08:43:40 -0400 Subject: [PATCH 07/20] Trigger Song of Time blocks and Great Fairies from the pause menu Song of Time blocks (Obj_Timeblock) and Great Fairy spawners (Shot_Sun) react to OCARINA_MODE_04 + lastPlayedSong, not the MODE_03 "correct song" event the staff spots and NPCs use, so the menu path never reached them. Drive the in-range actors into their song-complete state and hold MODE_04 for a couple frames so they consume it on their next update. Also fix the type 1/2/4/6 okarina-tag spots (Door of Time, drain well, Zora waterfall, Royal Family Tomb): they deferred their listening handler by a frame while the same-frame reset cleared MODE_03 before it ran. Run it synchronously, like the type-7 spots right above. Co-Authored-By: Claude Opus 4.8 --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 104 ++++++++++++++++++-- 1 file changed, 94 insertions(+), 10 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 5bb213163dd..be9fb70cb8a 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -9,6 +9,8 @@ extern "C" { #include "variables.h" #include "src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.h" #include "src/overlays/actors/ovl_En_Md/z_en_md.h" +#include "src/overlays/actors/ovl_Obj_Timeblock/z_obj_timeblock.h" +#include "src/overlays/actors/ovl_Shot_Sun/z_shot_sun.h" extern PlayState* gPlayState; @@ -23,6 +25,12 @@ void func_80ABF4C8(EnOkarinaTag* tag, PlayState* play); void EnMd_BlockPath(EnMd* actor, PlayState* play); void EnMd_ListenToOcarina(EnMd* actor, PlayState* play); void Player_StartTalking(PlayState* play, Actor* actor); + +// Song of Time block (Obj_Timeblock) and Great Fairy spawner (Shot_Sun) observers, not exposed in a header. +u8 ObjTimeblock_PlayerIsInRange(ObjTimeblock* timeblock, PlayState* play); +s32 ObjTimeblock_WaitForOcarina(ObjTimeblock* timeblock, PlayState* play); +s32 ObjTimeblock_WaitForSong(ObjTimeblock* timeblock, PlayState* play); +void ShotSun_UpdateFairySpawner(ShotSun* shotSun, PlayState* play); } static constexpr int32_t CVAR_PAUSE_WARP_DEFAULT = 0; @@ -153,12 +161,17 @@ static bool PauseSong_ActivateOkarinaTags() { if (tag->actionFunc == func_80ABEF2C && tag->ocarinaSong == songIndex) { func_80ABF0CC(tag, gPlayState); matched = true; - } else if (tag->actionFunc == func_80ABF28C && - ((song == OCARINA_SONG_LULLABY && (tag->type == 1 || tag->type == 6)) || - (song == OCARINA_SONG_STORMS && tag->type == 2) || - (song == OCARINA_SONG_TIME && tag->type == 4))) { - func_80ABF4C8(tag, gPlayState); - matched = true; + } else if (tag->actionFunc == func_80ABF28C) { + // Type 1/6 react to Zelda's Lullaby, type 2 to Song of Storms, type 4 to Song of Time. + bool songMatchesType = (((tag->type == 1) || (tag->type == 6)) && (song == OCARINA_SONG_LULLABY)) || + ((tag->type == 2) && (song == OCARINA_SONG_STORMS)) || + ((tag->type == 4) && (song == OCARINA_SONG_TIME)); + if (songMatchesType) { + // Like the type-7 spots, run the listening handler now while MODE_03 is set so it + // fires this frame (its actionFunc is func_80ABF28C, so it consumes it). + func_80ABF4C8(tag, gPlayState); + matched = true; + } } } } @@ -203,6 +216,73 @@ static bool PauseSong_ActivateNpcActors() { return false; } +// --- Song of Time blocks and Great Fairy spawners --- +// These don't consume the MODE_03 "correct song" event the staff spots and NPCs do. Instead each runs a +// small state machine that, driven by a real ocarina play-for-actor, ends by reading OCARINA_MODE_04 + +// lastPlayedSong. We can't reach that through the menu, so -- as with the tags and Mido -- we push the +// matching in-range actors straight into that final state and hold MODE_04 for a couple frames, since +// they update before this hook and only react on their next pass. +static int songEventHoldTimer = 0; + +// Clears the held MODE_04 once the block/fairy has had its frame(s) to read it. Returns true while holding. +static bool PauseSong_AdvanceSongEvent() { + if (songEventHoldTimer <= 0) { + return false; + } + if (--songEventHoldTimer == 0 && gPlayState->msgCtx.ocarinaMode == OCARINA_MODE_04) { + gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; + } + return true; +} + +// Engage any in-range Song of Time block (Song of Time) or Great Fairy spawner (Sun's Song / Song of +// Storms). Returns true if at least one was engaged, in which case MODE_04 is left set for it to consume. +static bool PauseSong_ActivateSongEventActors() { + Player* player = GET_PLAYER(gPlayState); + u16 song = gPlayState->msgCtx.lastPlayedSong; + bool engaged = false; + + if (song == OCARINA_SONG_TIME) { + for (Actor* actor = gPlayState->actorCtx.actorLists[ACTORCAT_ITEMACTION].head; actor != NULL; + actor = actor->next) { + if (actor->id != ACTOR_OBJ_TIMEBLOCK) { + continue; + } + ObjTimeblock* timeblock = (ObjTimeblock*)actor; + // Only idle blocks the player could actually reach; skip ones mid-sequence or out of range. + if (timeblock->songObserverFunc == ObjTimeblock_WaitForOcarina && + ObjTimeblock_PlayerIsInRange(timeblock, gPlayState)) { + // WaitForSong reports completion once lastPlayedSong is Song of Time and its countdown ends; + // a non-sentinel prior song plus a 1-frame timer makes it fire on the block's next update. + timeblock->songObserverFunc = ObjTimeblock_WaitForSong; + timeblock->unk_172 = OCARINA_SONG_TIME; + timeblock->songEndTimer = 1; + engaged = true; + } + } + } else if (song == OCARINA_SONG_SUNS || song == OCARINA_SONG_STORMS) { + s32 wantParams = (song == OCARINA_SONG_SUNS) ? 0x40 : 0x41; + for (Actor* actor = gPlayState->actorCtx.actorLists[ACTORCAT_PROP].head; actor != NULL; actor = actor->next) { + if (actor->id != ACTOR_SHOT_SUN || (actor->params & 0xFF) != wantParams) { + continue; + } + ShotSun* shotSun = (ShotSun*)actor; + // fairySpawnerState 2 is the "song finished" state; the actor reads MODE_04 + song from there. + if (shotSun->actionFunc == ShotSun_UpdateFairySpawner && + Math3D_Vec3fDistSq(&actor->world.pos, &player->actor.world.pos) <= 22500.0f) { + shotSun->fairySpawnerState = 2; + engaged = true; + } + } + } + + if (engaged) { + gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_04; + songEventHoldTimer = 2; + } + return engaged; +} + // Returns true while a hand-off is running, so the caller skips starting another song this frame. static bool PauseSong_AdvanceNpcHandoff() { if (npcTalkActor == NULL) { @@ -219,7 +299,7 @@ static bool PauseSong_AdvanceNpcHandoff() { } static void PauseSong_Execute() { - if (PauseSong_AdvanceNpcHandoff()) { + if (PauseSong_AdvanceSongEvent() || PauseSong_AdvanceNpcHandoff()) { return; } if (!isSongActive || gPlayState->pauseCtx.state != 0 || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { @@ -239,14 +319,18 @@ static void PauseSong_Execute() { Actor_Spawn(&gPlayState->actorCtx, gPlayState, effectActorIds[idx], player->actor.world.pos.x, player->actor.world.pos.y, player->actor.world.pos.z, 0, 0, 0, effectActorParams[idx]); - // Flag the correct-song state and fire matching in-range spots. A staff spot runs synchronously, so + // Simulate the ocarina system reporting a successful song check. A staff spot runs synchronously, so // reset the ocarina/message state right after. The Water Temple triforce leaves msgMode = // MSGMODE_PAUSED, which would otherwise block the pause menu (z_play gates on NONE). gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_03; bool matched = PauseSong_ActivateOkarinaTags(); - // No staff spot took it: try a matching NPC (Mido, ...). If one engages, leave OCARINA_MODE_03 set - // for it to consume next frame; the hand-off machinery finishes and restores state. + // No staff spot took it: try the MODE_04 song-event actors (Song of Time blocks, Great Fairy + // spawners), then a matching NPC (Mido, ...). Either leaves the ocarina result set for the actor to + // consume next frame, and its hand-off machinery finishes and restores state. + if (!matched && PauseSong_ActivateSongEventActors()) { + return; + } if (!matched && PauseSong_ActivateNpcActors()) { return; } From 8db8709fbae17ef6784d33b5e9880e2a3bf2cd3d Mon Sep 17 00:00:00 2001 From: David Racine Date: Wed, 22 Jul 2026 17:53:40 -0400 Subject: [PATCH 08/20] Add Darunia and adult Malon to the pause-menu song hand-off Extend the NPC hand-off (previously Mido-only) to two more song-reactive NPCs: Darunia in Goron City (Saria's Song -> Darunia's Joy) and adult Malon at Lon Lon Ranch (Epona's Song). Like Mido, we set the actor's actionFunc straight to its listening handler and leave OCARINA_MODE_03 for it to consume next frame. Unify the hand-off state: only Mido offers a dialogue the player would normally start (gated by npcHandoffStartTalk); Darunia drives its own cutscene and Malon force-talks itself, so those just need the ocarina state restored afterward. Skull Kid (En_Skj) is intentionally left out: it is ACTORCAT_ENEMY with a static-struct minigame that repositions the player and is entangled with item/randomizer logic; it needs separate handling. Co-Authored-By: Claude Opus 4.8 --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 53 ++++++++++++++++----- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index be9fb70cb8a..454ad563787 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -9,6 +9,8 @@ extern "C" { #include "variables.h" #include "src/overlays/actors/ovl_En_Okarina_Tag/z_en_okarina_tag.h" #include "src/overlays/actors/ovl_En_Md/z_en_md.h" +#include "src/overlays/actors/ovl_En_Du/z_en_du.h" +#include "src/overlays/actors/ovl_En_Ma2/z_en_ma2.h" #include "src/overlays/actors/ovl_Obj_Timeblock/z_obj_timeblock.h" #include "src/overlays/actors/ovl_Shot_Sun/z_shot_sun.h" @@ -21,9 +23,14 @@ void func_80ABEF2C(EnOkarinaTag* tag, PlayState* play); void func_80ABF0CC(EnOkarinaTag* tag, PlayState* play); void func_80ABF28C(EnOkarinaTag* tag, PlayState* play); void func_80ABF4C8(EnOkarinaTag* tag, PlayState* play); -// Mido (En_Md) block/listen handlers, not exposed in a header. + +// NPC ocarina idle/listen handlers (Mido, Darunia, adult Malon), not exposed in a header. void EnMd_BlockPath(EnMd* actor, PlayState* play); void EnMd_ListenToOcarina(EnMd* actor, PlayState* play); +void func_809FE3C0(EnDu* actor, PlayState* play); // Darunia: idle, waiting for the ocarina +void func_809FE4A4(EnDu* actor, PlayState* play); // Darunia: listening for Saria's Song +void EnMa2_WaitForOcarina(EnMa2* actor, PlayState* play); +void EnMa2_WaitForEponasSong(EnMa2* actor, PlayState* play); void Player_StartTalking(PlayState* play, Actor* actor); // Song of Time block (Obj_Timeblock) and Great Fairy spawner (Shot_Sun) observers, not exposed in a header. @@ -184,31 +191,54 @@ static bool PauseSong_ActivateOkarinaTags() { // can't satisfy from the menu. So, exactly like the tags, we set the actor's actionFunc straight to its // listening handler and leave OCARINA_MODE_03 set; the handler reacts on the next frame. Any dialogue it // offers is then started here, since the player -- never in its ocarina action -- won't do it itself. -static Actor* npcTalkActor = NULL; +static Actor* npcHandoffActor = NULL; +static bool npcHandoffStartTalk = false; static int npcHandoffTimer = 0; static void PauseSong_EndNpcHandoff() { if (gPlayState->msgCtx.ocarinaMode == OCARINA_MODE_03 || gPlayState->msgCtx.ocarinaMode == OCARINA_MODE_04) { gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; } - npcTalkActor = NULL; + npcHandoffActor = NULL; + npcHandoffStartTalk = false; npcHandoffTimer = 0; } // Hand the played song to a matching in-range NPC. Returns true if one was engaged, in which case -// OCARINA_MODE_03 is left set for it to consume next frame. +// OCARINA_MODE_03 is left set for it to consume next frame; the hand-off machinery then restores state +// and, for actors that offer a dialogue the player would normally start, begins that talk. static bool PauseSong_ActivateNpcActors() { Player* player = GET_PLAYER(gPlayState); u16 song = gPlayState->msgCtx.lastPlayedSong; + s16 scene = gPlayState->sceneNum; for (Actor* actor = gPlayState->actorCtx.actorLists[ACTORCAT_NPC].head; actor != NULL; actor = actor->next) { - // Mido, in the Lost Woods, moves aside when played Saria's Song. - if (actor->id == ACTOR_EN_MD && song == OCARINA_SONG_SARIAS && gPlayState->sceneNum == SCENE_LOST_WOODS) { + // Mido, in the Lost Woods, moves aside for Saria's Song, then offers a dialogue we must start. + if (actor->id == ACTOR_EN_MD && scene == SCENE_LOST_WOODS && song == OCARINA_SONG_SARIAS) { EnMd* mido = (EnMd*)actor; if (mido->actionFunc == EnMd_BlockPath && mido->interactInfo.talkState == NPC_TALK_STATE_IDLE && actor->xzDistToPlayer < 100.0f) { mido->actionFunc = EnMd_ListenToOcarina; - npcTalkActor = actor; + npcHandoffActor = actor; + npcHandoffStartTalk = true; + return true; + } + } + // Darunia, in Goron City as a child, dances (a cutscene) for Saria's Song. + if (actor->id == ACTOR_EN_DU && scene == SCENE_GORON_CITY && song == OCARINA_SONG_SARIAS) { + EnDu* darunia = (EnDu*)actor; + if (darunia->actionFunc == func_809FE3C0 && actor->xzDistToPlayer < 120.0f) { + darunia->actionFunc = func_809FE4A4; + npcHandoffActor = actor; + return true; + } + } + // Adult Malon, at Lon Lon Ranch, reacts to Epona's Song (she starts her own dialogue). + if (actor->id == ACTOR_EN_MA2 && scene == SCENE_LON_LON_RANCH && song == OCARINA_SONG_EPONAS) { + EnMa2* malon = (EnMa2*)actor; + if (malon->actionFunc == EnMa2_WaitForOcarina && actor->xzDistToPlayer < 60.0f) { + malon->actionFunc = EnMa2_WaitForEponasSong; + npcHandoffActor = actor; return true; } } @@ -285,13 +315,14 @@ static bool PauseSong_ActivateSongEventActors() { // Returns true while a hand-off is running, so the caller skips starting another song this frame. static bool PauseSong_AdvanceNpcHandoff() { - if (npcTalkActor == NULL) { + if (npcHandoffActor == NULL) { return false; } - // The actor consumed OCARINA_MODE_03 (set MODE_04) and reacted; start any dialogue it offered. + // The actor consumed OCARINA_MODE_03 (set MODE_04) and reacted. For actors that offer a dialogue the + // player would normally start (Mido), start it now; others drive their own cutscene / talk. if (gPlayState->msgCtx.ocarinaMode != OCARINA_MODE_03 || ++npcHandoffTimer > 30) { - if (gPlayState->msgCtx.ocarinaMode != OCARINA_MODE_03 && npcTalkActor->textId != 0) { - Player_StartTalking(gPlayState, npcTalkActor); + if (npcHandoffStartTalk && gPlayState->msgCtx.ocarinaMode != OCARINA_MODE_03 && npcHandoffActor->textId != 0) { + Player_StartTalking(gPlayState, npcHandoffActor); } PauseSong_EndNpcHandoff(); } From a8289bf02840a26fca2302b6529024287b461e62 Mon Sep 17 00:00:00 2001 From: David Racine Date: Fri, 24 Jul 2026 00:19:05 -0400 Subject: [PATCH 09/20] Guard pause-menu songs behind an ocarina-playable check Only inject a played song when Link is in a normal, controllable field state -- grounded and not in a cutscene, message, mid-song, in water, on horseback, or dying. Mirrors the footing the vanilla ocarina action needs to start, and avoids leaving the ocarina/message system in a bad state. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HjDjMt2YeFw6wzqYL1zM6Y --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 454ad563787..03831fe863e 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -388,6 +388,25 @@ static void ActivateWarp(PauseContext* pauseCtx, int song) { isWarpActive = true; } +// Only inject a played song when Link is in a normal, controllable field state -- the footing the vanilla +// ocarina action itself needs to start (see Player_ActionHandler_13: grounded and not in a blocking state). +// Firing from an item cutscene, a message, mid-song, mid-air, in water, while talking, mounted, or dying +// could leave the ocarina/message system in a bad state, which is the class of corruption we want to avoid. +static bool PauseSong_CanPlayOcarina() { + Player* player = GET_PLAYER(gPlayState); + if (gPlayState->msgCtx.msgMode != MSGMODE_NONE || gPlayState->msgCtx.ocarinaMode != OCARINA_MODE_00) { + return false; + } + if (player->stateFlags1 & (PLAYER_STATE1_DEAD | PLAYER_STATE1_IN_ITEM_CS | PLAYER_STATE1_IN_CUTSCENE | + PLAYER_STATE1_TALKING | PLAYER_STATE1_ON_HORSE)) { + return false; + } + if (player->stateFlags2 & PLAYER_STATE2_OCARINA_PLAYING) { + return false; + } + return (player->actor.bgCheckFlags & BGCHECKFLAG_GROUND) != 0; +} + static void ActivateSong(PauseContext* pauseCtx, int questSong) { int idx = questSong - QUEST_SONG_LULLABY; Interface_SetDoAction(gPlayState, DO_ACTION_NONE); @@ -457,6 +476,9 @@ static void PauseMenuSongs_HandleSelection() { } ActivateWarp(&gPlayState->pauseCtx, song); } else if (song >= QUEST_SONG_LULLABY && song <= QUEST_SONG_STORMS) { + if (!PauseSong_CanPlayOcarina()) { + return; + } ActivateSong(&gPlayState->pauseCtx, song); } } From 7eb286ad0d3594cd3d69d09d5f246150b7b0a0ad Mon Sep 17 00:00:00 2001 From: David Racine Date: Fri, 24 Jul 2026 00:27:22 -0400 Subject: [PATCH 10/20] Unify the pause-menu song hand-off into one deferred path The non-tag actors (Song of Time blocks, Great Fairy spawners, and the ocarina NPCs) all update before the enhancement hook and just poll ocarinaMode + lastPlayedSong, so they were each driven by a slightly different ad-hoc mechanism: a MODE_04 hold timer for the blocks/fairies and a separate MODE_03 hand-off timer for the NPCs. Collapse those two into a single hold-the-mode/restore path (PauseSong_HoldMode + PauseSong_AdvancePending): push the matching in-range actor into its song-finished state, leave the matching mode set for it to read next frame, then restore state (starting Mido's dialogue when it reacts). Staff spots still run inline -- driving them there, after every actor has updated, keeps the MODE_04 they set from leaking to the MODE_04 readers. Behavior is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HjDjMt2YeFw6wzqYL1zM6Y --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 144 +++++++++----------- 1 file changed, 66 insertions(+), 78 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 03831fe863e..1e173773776 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -185,30 +185,53 @@ static bool PauseSong_ActivateOkarinaTags() { return matched; } -// --- Per-actor hand-off for non-tag ocarina NPCs --- -// Unlike the staff spots, these actors have no generic way to be driven, and going through the vanilla -// play-for-actor flow (PLAYER_STATE2_ATTEMPT_PLAY_FOR_ACTOR) opens a real note-input prompt the player -// can't satisfy from the menu. So, exactly like the tags, we set the actor's actionFunc straight to its -// listening handler and leave OCARINA_MODE_03 set; the handler reacts on the next frame. Any dialogue it -// offers is then started here, since the player -- never in its ocarina action -- won't do it itself. -static Actor* npcHandoffActor = NULL; -static bool npcHandoffStartTalk = false; -static int npcHandoffTimer = 0; - -static void PauseSong_EndNpcHandoff() { - if (gPlayState->msgCtx.ocarinaMode == OCARINA_MODE_03 || gPlayState->msgCtx.ocarinaMode == OCARINA_MODE_04) { - gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; +// --- Deferred hand-off for the actors that can't be driven inline --- +// The staff spots above are safe to run synchronously, but the Song of Time blocks, Great Fairy spawners, +// and the ocarina NPCs (Mido, Darunia, adult Malon) all update *before* this hook and simply poll +// ocarinaMode + lastPlayedSong. Going through the vanilla play-for-actor flow instead +// (PLAYER_STATE2_ATTEMPT_PLAY_FOR_ACTOR) would open a real note-input prompt the player can't satisfy from +// the menu. So for all of them we push the matching in-range actors straight into their song-finished +// state, leave the matching ocarinaMode set, and hold it a couple frames for them to read; then we restore +// it. Mido is the one actor whose follow-up dialogue the player would normally start, so we do that here +// once it has reacted. +static u8 pendingMode = OCARINA_MODE_00; +static Actor* pendingTalkActor = NULL; +static int pendingTimer = 0; + +static void PauseSong_HoldMode(u8 mode) { + gPlayState->msgCtx.ocarinaMode = mode; + pendingMode = mode; + pendingTimer = 2; +} + +// Restores the held ocarina/message state once the actor has had its frame(s) to read it. Returns true +// while a hand-off is in flight, so the caller skips starting another song. The NPCs flip ocarinaMode to +// MODE_04 when they react, which is how we notice a hand-off completed early. +static bool PauseSong_AdvancePending() { + if (pendingTimer <= 0) { + return false; + } + bool reacted = gPlayState->msgCtx.ocarinaMode != pendingMode; + if (--pendingTimer == 0 || reacted) { + if (pendingTalkActor != NULL && reacted && pendingTalkActor->textId != 0) { + Player_StartTalking(gPlayState, pendingTalkActor); + } + if (gPlayState->msgCtx.ocarinaMode == OCARINA_MODE_03 || gPlayState->msgCtx.ocarinaMode == OCARINA_MODE_04) { + gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; + } + if (gPlayState->msgCtx.msgMode == MSGMODE_PAUSED) { + gPlayState->msgCtx.msgMode = MSGMODE_NONE; + } + pendingMode = OCARINA_MODE_00; + pendingTalkActor = NULL; + pendingTimer = 0; } - npcHandoffActor = NULL; - npcHandoffStartTalk = false; - npcHandoffTimer = 0; + return true; } -// Hand the played song to a matching in-range NPC. Returns true if one was engaged, in which case -// OCARINA_MODE_03 is left set for it to consume next frame; the hand-off machinery then restores state -// and, for actors that offer a dialogue the player would normally start, begins that talk. +// Hand the played song to a matching in-range NPC. Returns true if one was engaged, leaving MODE_03 held +// for it to consume next frame. static bool PauseSong_ActivateNpcActors() { - Player* player = GET_PLAYER(gPlayState); u16 song = gPlayState->msgCtx.lastPlayedSong; s16 scene = gPlayState->sceneNum; @@ -219,8 +242,8 @@ static bool PauseSong_ActivateNpcActors() { if (mido->actionFunc == EnMd_BlockPath && mido->interactInfo.talkState == NPC_TALK_STATE_IDLE && actor->xzDistToPlayer < 100.0f) { mido->actionFunc = EnMd_ListenToOcarina; - npcHandoffActor = actor; - npcHandoffStartTalk = true; + pendingTalkActor = actor; + PauseSong_HoldMode(OCARINA_MODE_03); return true; } } @@ -229,7 +252,7 @@ static bool PauseSong_ActivateNpcActors() { EnDu* darunia = (EnDu*)actor; if (darunia->actionFunc == func_809FE3C0 && actor->xzDistToPlayer < 120.0f) { darunia->actionFunc = func_809FE4A4; - npcHandoffActor = actor; + PauseSong_HoldMode(OCARINA_MODE_03); return true; } } @@ -238,7 +261,7 @@ static bool PauseSong_ActivateNpcActors() { EnMa2* malon = (EnMa2*)actor; if (malon->actionFunc == EnMa2_WaitForOcarina && actor->xzDistToPlayer < 60.0f) { malon->actionFunc = EnMa2_WaitForEponasSong; - npcHandoffActor = actor; + PauseSong_HoldMode(OCARINA_MODE_03); return true; } } @@ -246,27 +269,9 @@ static bool PauseSong_ActivateNpcActors() { return false; } -// --- Song of Time blocks and Great Fairy spawners --- -// These don't consume the MODE_03 "correct song" event the staff spots and NPCs do. Instead each runs a -// small state machine that, driven by a real ocarina play-for-actor, ends by reading OCARINA_MODE_04 + -// lastPlayedSong. We can't reach that through the menu, so -- as with the tags and Mido -- we push the -// matching in-range actors straight into that final state and hold MODE_04 for a couple frames, since -// they update before this hook and only react on their next pass. -static int songEventHoldTimer = 0; - -// Clears the held MODE_04 once the block/fairy has had its frame(s) to read it. Returns true while holding. -static bool PauseSong_AdvanceSongEvent() { - if (songEventHoldTimer <= 0) { - return false; - } - if (--songEventHoldTimer == 0 && gPlayState->msgCtx.ocarinaMode == OCARINA_MODE_04) { - gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; - } - return true; -} - // Engage any in-range Song of Time block (Song of Time) or Great Fairy spawner (Sun's Song / Song of -// Storms). Returns true if at least one was engaged, in which case MODE_04 is left set for it to consume. +// Storms) by pushing it into its song-finished state. Returns true if at least one was engaged, leaving +// MODE_04 held for the actor(s) to read. static bool PauseSong_ActivateSongEventActors() { Player* player = GET_PLAYER(gPlayState); u16 song = gPlayState->msgCtx.lastPlayedSong; @@ -307,30 +312,13 @@ static bool PauseSong_ActivateSongEventActors() { } if (engaged) { - gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_04; - songEventHoldTimer = 2; + PauseSong_HoldMode(OCARINA_MODE_04); } return engaged; } -// Returns true while a hand-off is running, so the caller skips starting another song this frame. -static bool PauseSong_AdvanceNpcHandoff() { - if (npcHandoffActor == NULL) { - return false; - } - // The actor consumed OCARINA_MODE_03 (set MODE_04) and reacted. For actors that offer a dialogue the - // player would normally start (Mido), start it now; others drive their own cutscene / talk. - if (gPlayState->msgCtx.ocarinaMode != OCARINA_MODE_03 || ++npcHandoffTimer > 30) { - if (npcHandoffStartTalk && gPlayState->msgCtx.ocarinaMode != OCARINA_MODE_03 && npcHandoffActor->textId != 0) { - Player_StartTalking(gPlayState, npcHandoffActor); - } - PauseSong_EndNpcHandoff(); - } - return true; -} - static void PauseSong_Execute() { - if (PauseSong_AdvanceSongEvent() || PauseSong_AdvanceNpcHandoff()) { + if (PauseSong_AdvancePending()) { return; } if (!isSongActive || gPlayState->pauseCtx.state != 0 || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { @@ -350,25 +338,25 @@ static void PauseSong_Execute() { Actor_Spawn(&gPlayState->actorCtx, gPlayState, effectActorIds[idx], player->actor.world.pos.x, player->actor.world.pos.y, player->actor.world.pos.z, 0, 0, 0, effectActorParams[idx]); - // Simulate the ocarina system reporting a successful song check. A staff spot runs synchronously, so - // reset the ocarina/message state right after. The Water Temple triforce leaves msgMode = - // MSGMODE_PAUSED, which would otherwise block the pause menu (z_play gates on NONE). + // Staff spots are the one category safe to drive inline: calling them here, after every actor has + // already updated this frame, lets them consume MODE_03 and set their own MODE_04 without it leaking to + // the MODE_04 readers below. So drive and reset them within this frame. (The Water Temple triforce + // leaves msgMode = MSGMODE_PAUSED, which would otherwise block the pause menu -- z_play gates on NONE.) gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_03; - bool matched = PauseSong_ActivateOkarinaTags(); - - // No staff spot took it: try the MODE_04 song-event actors (Song of Time blocks, Great Fairy - // spawners), then a matching NPC (Mido, ...). Either leaves the ocarina result set for the actor to - // consume next frame, and its hand-off machinery finishes and restores state. - if (!matched && PauseSong_ActivateSongEventActors()) { - return; - } - if (!matched && PauseSong_ActivateNpcActors()) { + if (PauseSong_ActivateOkarinaTags()) { + gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; + if (gPlayState->msgCtx.msgMode == MSGMODE_PAUSED) { + gPlayState->msgCtx.msgMode = MSGMODE_NONE; + } return; } - gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; - if (gPlayState->msgCtx.msgMode == MSGMODE_PAUSED) { - gPlayState->msgCtx.msgMode = MSGMODE_NONE; + + // Otherwise hand the song to a deferred actor: a Song of Time block / Great Fairy spawner (MODE_04), or + // a matching NPC (MODE_03). Either holds the mode for the actor to poll next frame; AdvancePending then + // finishes the hand-off and restores state. + if (!PauseSong_ActivateSongEventActors()) { + PauseSong_ActivateNpcActors(); } } From 475cd3d08f09174794e5ab9013c2f50752c90572 Mon Sep 17 00:00:00 2001 From: David Racine Date: Fri, 24 Jul 2026 00:49:58 -0400 Subject: [PATCH 11/20] Document the raw decompiled symbols the staff-spot path calls The En_Okarina_Tag handlers this feature calls are already non-static on develop, they just have no descriptive names yet. Spell out what each func_ symbol does where they are declared and at the call sites, rather than inventing names for them. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HjDjMt2YeFw6wzqYL1zM6Y --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 1e173773776..2fdfaa8292e 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -18,10 +18,15 @@ extern PlayState* gPlayState; u8 Randomizer_GetSettingValue(RandomizerSettingKey); -// Staff-spot (En_Okarina_Tag) idle/listening handlers, not exposed in a header. +// Staff-spot (En_Okarina_Tag) idle/listening handlers. These decomp functions have no descriptive names +// yet, so we reference them by their raw symbols: +// func_80ABEF2C - idle, waiting for the player to approach +// func_80ABF28C - listening for the ocarina +// func_80ABF0CC - waiting for the type-7 playback result +// func_80ABF4C8 - handling a matched song event (types 1/2/4/6) void func_80ABEF2C(EnOkarinaTag* tag, PlayState* play); -void func_80ABF0CC(EnOkarinaTag* tag, PlayState* play); void func_80ABF28C(EnOkarinaTag* tag, PlayState* play); +void func_80ABF0CC(EnOkarinaTag* tag, PlayState* play); void func_80ABF4C8(EnOkarinaTag* tag, PlayState* play); // NPC ocarina idle/listen handlers (Mido, Darunia, adult Malon), not exposed in a header. @@ -166,17 +171,17 @@ static bool PauseSong_ActivateOkarinaTags() { if ((tag->actor.xzDistToPlayer < (90.0f + tag->interactRange)) && (fabsf(player->actor.world.pos.y - tag->actor.world.pos.y) < 80.0f)) { if (tag->actionFunc == func_80ABEF2C && tag->ocarinaSong == songIndex) { - func_80ABF0CC(tag, gPlayState); + func_80ABF0CC(tag, gPlayState); // waiting for the playback result matched = true; - } else if (tag->actionFunc == func_80ABF28C) { + } else if (tag->actionFunc == func_80ABF28C) { // listening for the ocarina // Type 1/6 react to Zelda's Lullaby, type 2 to Song of Storms, type 4 to Song of Time. bool songMatchesType = (((tag->type == 1) || (tag->type == 6)) && (song == OCARINA_SONG_LULLABY)) || ((tag->type == 2) && (song == OCARINA_SONG_STORMS)) || ((tag->type == 4) && (song == OCARINA_SONG_TIME)); if (songMatchesType) { - // Like the type-7 spots, run the listening handler now while MODE_03 is set so it - // fires this frame (its actionFunc is func_80ABF28C, so it consumes it). - func_80ABF4C8(tag, gPlayState); + // Like the type-7 spots, run the listening handler now while MODE_03 is set so it fires + // this frame (its actionFunc is the ocarina-listening state, so it consumes it). + func_80ABF4C8(tag, gPlayState); // handle the matched song event matched = true; } } From 536a55631e1b5e428712c7140c6e94db52775dbe Mon Sep 17 00:00:00 2001 From: David Racine Date: Fri, 24 Jul 2026 01:29:22 -0400 Subject: [PATCH 12/20] Show a "can't play here" message instead of silently ignoring the input When a non-warp song is selected somewhere the ocarina can't be played, close the pause menu and show a short textbox -- the same hand-off a warp song uses, minus the song -- rather than swallowing the button press with no feedback. ActivateCannotPlay mirrors ActivateWarp; PauseCannotPlay_Execute restores control once the message closes. The message is a CustomMessage displayed through the OnOpenText hook (EN/DE/FR, phrased impersonally to avoid the tu/vous choice; JP falls back to English), keyed on a new TEXT_CANNOT_PLAY_OCARINA_MSG id. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HjDjMt2YeFw6wzqYL1zM6Y --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 38 +++++++++++++++++++ .../custom-message/CustomMessageTypes.h | 1 + 2 files changed, 39 insertions(+) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 2fdfaa8292e..b801ab91f0f 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -1,4 +1,5 @@ #include +#include "soh/Enhancements/custom-message/CustomMessageManager.h" #include "soh/Enhancements/custom-message/CustomMessageTypes.h" #include "soh/Enhancements/game-interactor/GameInteractor_Hooks.h" #include "soh/ShipInit.hpp" @@ -110,6 +111,15 @@ static const int effectActorParams[] = { 0, 0, 0, 0, 1, 0 }; static bool isWarpActive = false; static bool isSongActive = false; +static bool isCannotPlayActive = false; + +// Shown (through the OnOpenText hook below) when a song is selected somewhere the ocarina can't be played. +// Phrased impersonally in each language so it reads consistently and sidesteps the tu/vous (du/Sie) choice. +static CustomMessage cannotPlayHereMsg = CustomMessage( + "Can't play the Ocarina here!" + CustomMessage::MESSAGE_END(), + "Hier kann die Okarina" + CustomMessage::NEWLINE() + "nicht gespielt werden!" + CustomMessage::MESSAGE_END(), + "Impossible de jouer" + CustomMessage::NEWLINE() + "de l'ocarina ici !" + CustomMessage::MESSAGE_END(), + TEXTBOX_TYPE_BLACK); static void PauseWarp_Execute() { if (!isWarpActive || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { @@ -414,6 +424,27 @@ static void ActivateSong(PauseContext* pauseCtx, int questSong) { isSongActive = true; } +// Selected a non-warp song somewhere the ocarina can't be played: close the menu and show a short message, +// the same hand-off a warp song uses (minus the song). PauseCannotPlay_Execute restores control after. +static void ActivateCannotPlay(PauseContext* pauseCtx) { + Interface_SetDoAction(gPlayState, DO_ACTION_NONE); + pauseCtx->state = 0x12; + WREG(2) = -6240; + func_800F64E0(0); + pauseCtx->unk_1E4 = 0; + Message_StartTextbox(gPlayState, TEXT_CANNOT_PLAY_OCARINA_MSG, NULL); + GET_PLAYER(gPlayState)->stateFlags1 |= PLAYER_STATE1_IN_CUTSCENE; + isCannotPlayActive = true; +} + +static void PauseCannotPlay_Execute() { + if (!isCannotPlayActive || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { + return; + } + isCannotPlayActive = false; + GET_PLAYER(gPlayState)->stateFlags1 &= ~PLAYER_STATE1_IN_CUTSCENE; +} + static void PauseMenuSongs_HandleSelection() { if (gSaveContext.inventory.items[SLOT_OCARINA] == ITEM_NONE) { return; @@ -470,6 +501,7 @@ static void PauseMenuSongs_HandleSelection() { ActivateWarp(&gPlayState->pauseCtx, song); } else if (song >= QUEST_SONG_LULLABY && song <= QUEST_SONG_STORMS) { if (!PauseSong_CanPlayOcarina()) { + ActivateCannotPlay(&gPlayState->pauseCtx); return; } ActivateSong(&gPlayState->pauseCtx, song); @@ -485,9 +517,15 @@ static void RegisterPauseMenuHooks() { COND_HOOK(OnGameFrameUpdate, CVAR_PAUSE_WARP_VALUE, [] { if (GameInteractor::IsSaveLoaded()) { PauseWarp_Execute(); + PauseCannotPlay_Execute(); PauseSong_Execute(); } }); + COND_ID_HOOK(OnOpenText, TEXT_CANNOT_PLAY_OCARINA_MSG, CVAR_PAUSE_WARP_VALUE, + [](uint16_t* textId, bool* loadFromMessageTable) { + cannotPlayHereMsg.LoadIntoFont(); + *loadFromMessageTable = false; + }); } static RegisterShipInitFunc initFunc(RegisterPauseMenuHooks, { CVAR_PAUSE_WARP_NAME }); diff --git a/soh/soh/Enhancements/custom-message/CustomMessageTypes.h b/soh/soh/Enhancements/custom-message/CustomMessageTypes.h index 6867f5922c3..42fe57ed5d7 100644 --- a/soh/soh/Enhancements/custom-message/CustomMessageTypes.h +++ b/soh/soh/Enhancements/custom-message/CustomMessageTypes.h @@ -243,6 +243,7 @@ typedef enum { TEXT_SAVE_MSG = 0x9213, TEXT_CONTINUE_OVERWORLD_MSG = 0x9214, TEXT_CONTINUE_DUNGEON_MSG = 0x9215, + TEXT_CANNOT_PLAY_OCARINA_MSG = 0x9216, } TextIDs; #ifdef __cplusplus From 724fd6037ccc6ba236c96535afb2ad7f9724b72c Mon Sep 17 00:00:00 2001 From: David Racine Date: Fri, 24 Jul 2026 10:29:57 -0400 Subject: [PATCH 13/20] Trigger the Lost Woods Skull Kid from the pause menu The stump Skull Kid that rewards Saria's Song runs its listen/reward state machine on the visible actor, which init moves to ACTORCAT_NPC (the blue lock-on), not ACTORCAT_ENEMY. Fold it into the existing NPC hand-off loop: when it is idling in EnSkj_WaitInRange, push it to EnSkj_WaitForSong, hold MODE_03, and start the reward talk once it reacts -- same pattern as Mido. Being in WaitInRange already implies the player is in range (it leaves that state otherwise), so no extra distance check is needed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HjDjMt2YeFw6wzqYL1zM6Y --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index b801ab91f0f..fcf77943d90 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -14,6 +14,7 @@ extern "C" { #include "src/overlays/actors/ovl_En_Ma2/z_en_ma2.h" #include "src/overlays/actors/ovl_Obj_Timeblock/z_obj_timeblock.h" #include "src/overlays/actors/ovl_Shot_Sun/z_shot_sun.h" +#include "src/overlays/actors/ovl_En_Skj/z_en_skj.h" extern PlayState* gPlayState; @@ -39,6 +40,10 @@ void EnMa2_WaitForOcarina(EnMa2* actor, PlayState* play); void EnMa2_WaitForEponasSong(EnMa2* actor, PlayState* play); void Player_StartTalking(PlayState* play, Actor* actor); +// Lost Woods stump Skull Kid (En_Skj) Saria's-Song handlers, not exposed in a header. +void EnSkj_WaitInRange(EnSkj* actor, PlayState* play); +void EnSkj_SetupWaitForSong(EnSkj* actor); + // Song of Time block (Obj_Timeblock) and Great Fairy spawner (Shot_Sun) observers, not exposed in a header. u8 ObjTimeblock_PlayerIsInRange(ObjTimeblock* timeblock, PlayState* play); s32 ObjTimeblock_WaitForOcarina(ObjTimeblock* timeblock, PlayState* play); @@ -280,6 +285,19 @@ static bool PauseSong_ActivateNpcActors() { return true; } } + // The visible Lost Woods stump Skull Kid (moved to ACTORCAT_NPC on init) reacts to Saria's Song. + // Its idle WaitInRange state normally opens a real ocarina prompt, so -- like Mido -- push it into + // WaitForSong, hold MODE_03, and start the reward talk once it reacts. Being in WaitInRange already + // implies the player is in range (it drops out of that state otherwise). + if (actor->id == ACTOR_EN_SKJ && song == OCARINA_SONG_SARIAS) { + EnSkj* skullKid = (EnSkj*)actor; + if (skullKid->actionFunc == EnSkj_WaitInRange) { + EnSkj_SetupWaitForSong(skullKid); + pendingTalkActor = actor; + PauseSong_HoldMode(OCARINA_MODE_03); + return true; + } + } } return false; } From 5732c97cee3583894110ee75280dc79b5fb08221 Mon Sep 17 00:00:00 2001 From: David Racine Date: Fri, 24 Jul 2026 12:43:07 -0400 Subject: [PATCH 14/20] Factor out the shared pause-menu close, tighten a few comments Extract the menu-close prologue duplicated across ActivateWarp/ActivateSong/ ActivateCannotPlay into PauseMenu_BeginClose, dedup the ocarinaMode reset in PauseSong_Execute, and trim two verbose comments. No behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HjDjMt2YeFw6wzqYL1zM6Y --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 49 +++++++++------------ 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index fcf77943d90..9c31f78d442 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -205,15 +205,11 @@ static bool PauseSong_ActivateOkarinaTags() { return matched; } -// --- Deferred hand-off for the actors that can't be driven inline --- -// The staff spots above are safe to run synchronously, but the Song of Time blocks, Great Fairy spawners, -// and the ocarina NPCs (Mido, Darunia, adult Malon) all update *before* this hook and simply poll -// ocarinaMode + lastPlayedSong. Going through the vanilla play-for-actor flow instead -// (PLAYER_STATE2_ATTEMPT_PLAY_FOR_ACTOR) would open a real note-input prompt the player can't satisfy from -// the menu. So for all of them we push the matching in-range actors straight into their song-finished -// state, leave the matching ocarinaMode set, and hold it a couple frames for them to read; then we restore -// it. Mido is the one actor whose follow-up dialogue the player would normally start, so we do that here -// once it has reacted. +// --- Deferred hand-off for actors that can't be driven inline --- +// The Song of Time blocks, Great Fairy spawners, and ocarina NPCs update before this hook and just poll +// ocarinaMode + lastPlayedSong. We can't use the real play-for-actor flow (it opens a note-input prompt the +// menu can't satisfy), so we push the matching in-range actor into its song-finished state, hold the +// matching mode a couple frames for it to read, then restore it. Only Mido needs its dialogue started here. static u8 pendingMode = OCARINA_MODE_00; static Actor* pendingTalkActor = NULL; static int pendingTimer = 0; @@ -371,19 +367,19 @@ static void PauseSong_Execute() { Actor_Spawn(&gPlayState->actorCtx, gPlayState, effectActorIds[idx], player->actor.world.pos.x, player->actor.world.pos.y, player->actor.world.pos.z, 0, 0, 0, effectActorParams[idx]); - // Staff spots are the one category safe to drive inline: calling them here, after every actor has - // already updated this frame, lets them consume MODE_03 and set their own MODE_04 without it leaking to - // the MODE_04 readers below. So drive and reset them within this frame. (The Water Temple triforce - // leaves msgMode = MSGMODE_PAUSED, which would otherwise block the pause menu -- z_play gates on NONE.) + // Staff spots are the one category safe to drive inline: calling them now, after every actor has already + // updated this frame, lets them consume MODE_03 and set their own MODE_04 without it leaking to the + // MODE_04 readers below, so we can reset within this frame. gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_03; - if (PauseSong_ActivateOkarinaTags()) { - gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; + bool tagMatched = PauseSong_ActivateOkarinaTags(); + gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; + if (tagMatched) { + // The Water Temple triforce tag leaves msgMode = MSGMODE_PAUSED, which would block the pause menu. if (gPlayState->msgCtx.msgMode == MSGMODE_PAUSED) { gPlayState->msgCtx.msgMode = MSGMODE_NONE; } return; } - gPlayState->msgCtx.ocarinaMode = OCARINA_MODE_00; // Otherwise hand the song to a deferred actor: a Song of Time block / Great Fairy spawner (MODE_04), or // a matching NPC (MODE_03). Either holds the mode for the actor to poll next frame; AdvancePending then @@ -393,13 +389,18 @@ static void PauseSong_Execute() { } } -static void ActivateWarp(PauseContext* pauseCtx, int song) { - AudioOcarina_SetInstrument(OCARINA_INSTRUMENT_OFF); +// Start the pause-menu close/hand-off, the way vanilla does when a song is played from the quest screen. +static void PauseMenu_BeginClose(PauseContext* pauseCtx) { Interface_SetDoAction(gPlayState, DO_ACTION_NONE); pauseCtx->state = 0x12; WREG(2) = -6240; func_800F64E0(0); pauseCtx->unk_1E4 = 0; +} + +static void ActivateWarp(PauseContext* pauseCtx, int song) { + AudioOcarina_SetInstrument(OCARINA_INSTRUMENT_OFF); + PauseMenu_BeginClose(pauseCtx); int idx = song - QUEST_SONG_MINUET; gPlayState->msgCtx.lastPlayedSong = ocarinaSongMap[idx]; Audio_SetSfxBanksMute(0x20); @@ -430,11 +431,7 @@ static bool PauseSong_CanPlayOcarina() { static void ActivateSong(PauseContext* pauseCtx, int questSong) { int idx = questSong - QUEST_SONG_LULLABY; - Interface_SetDoAction(gPlayState, DO_ACTION_NONE); - pauseCtx->state = 0x12; - WREG(2) = -6240; - func_800F64E0(0); - pauseCtx->unk_1E4 = 0; + PauseMenu_BeginClose(pauseCtx); gPlayState->msgCtx.lastPlayedSong = questSongToOcarinaSong[idx]; // Intentionally no Audio_SetSoundBanksMute(0x20): it mutes BANK_OCARINA and is only cleared via // AudioOcarina_SetInstrument(OFF), which this in-scene path never hits, silencing the next real ocarina. @@ -445,11 +442,7 @@ static void ActivateSong(PauseContext* pauseCtx, int questSong) { // Selected a non-warp song somewhere the ocarina can't be played: close the menu and show a short message, // the same hand-off a warp song uses (minus the song). PauseCannotPlay_Execute restores control after. static void ActivateCannotPlay(PauseContext* pauseCtx) { - Interface_SetDoAction(gPlayState, DO_ACTION_NONE); - pauseCtx->state = 0x12; - WREG(2) = -6240; - func_800F64E0(0); - pauseCtx->unk_1E4 = 0; + PauseMenu_BeginClose(pauseCtx); Message_StartTextbox(gPlayState, TEXT_CANNOT_PLAY_OCARINA_MSG, NULL); GET_PLAYER(gPlayState)->stateFlags1 |= PLAYER_STATE1_IN_CUTSCENE; isCannotPlayActive = true; From a207b205c98188faf2a87a4b06ca433db519efc3 Mon Sep 17 00:00:00 2001 From: David Racine Date: Sun, 26 Jul 2026 21:06:14 -0400 Subject: [PATCH 15/20] Apply the ocarina-playable guard to warp songs too Warp songs are just songs played from the menu, so gate them on the same PauseSong_CanPlayOcarina() check as the rest instead of letting them fire from states the game would never allow the ocarina in (mid-air, underwater, on horseback, ...). Hoisting the guard ahead of the warp/non-warp branch also lets both share it and the "can't play here" message. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HjDjMt2YeFw6wzqYL1zM6Y --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 9c31f78d442..a5d4d726652 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -467,7 +467,19 @@ static void PauseMenuSongs_HandleSelection() { return; } - if (song >= QUEST_SONG_MINUET && song <= QUEST_SONG_PRELUDE) { + bool isWarpSong = (song >= QUEST_SONG_MINUET) && (song <= QUEST_SONG_PRELUDE); + if (!isWarpSong && (song < QUEST_SONG_LULLABY || song > QUEST_SONG_STORMS)) { + return; // cursor isn't on a playable song + } + + // Every song needs the ocarina to be playable here -- warp songs included, so you can no longer warp + // from places the game would never let you pull the ocarina out (mid-air, underwater, on horseback...). + if (!PauseSong_CanPlayOcarina()) { + ActivateCannotPlay(&gPlayState->pauseCtx); + return; + } + + if (isWarpSong) { if (gSaveContext.ship.quest.id == QUEST_RANDOMIZER && Randomizer_GetSettingValue(RSK_SHUFFLE_OCARINA_BUTTONS)) { bool canplay = false; switch (song) { @@ -510,11 +522,7 @@ static void PauseMenuSongs_HandleSelection() { } } ActivateWarp(&gPlayState->pauseCtx, song); - } else if (song >= QUEST_SONG_LULLABY && song <= QUEST_SONG_STORMS) { - if (!PauseSong_CanPlayOcarina()) { - ActivateCannotPlay(&gPlayState->pauseCtx); - return; - } + } else { ActivateSong(&gPlayState->pauseCtx, song); } } From 73887b2b9c65da7a7a29f9b046be69fe4a5aba0b Mon Sep 17 00:00:00 2001 From: David Racine Date: Mon, 27 Jul 2026 21:24:06 -0400 Subject: [PATCH 16/20] Retry pause-menu songs briefly and fade the "can't play" notice A song selected from the pause menu could be blocked by a previous song's ocarina/message state still settling after unpause, wrongly reporting the spot as unplayable. Close the menu, then re-check for a few frames before concluding the ocarina truly can't be played here. When it genuinely can't, show the standard textbox and hand it the game's fading end type so it dismisses itself after a few seconds instead of waiting on A. Link keeps normal control and physics while it fades. Co-Authored-By: Claude Opus 4.8 --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 184 +++++++++++++------- 1 file changed, 118 insertions(+), 66 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index a5d4d726652..cfc9b81c616 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -116,15 +116,27 @@ static const int effectActorParams[] = { 0, 0, 0, 0, 1, 0 }; static bool isWarpActive = false; static bool isSongActive = false; + +// A song selected while the ocarina momentarily can't be played (see PauseSong_RetryPlay). +static int retrySong = 0; +static bool retryIsWarp = false; +static int retryTimer = 0; +// A handful of game-update frames (roughly a quarter second). The block is usually a previous song's +// ocarina/message state still settling, which clears a frame or two after unpausing; this leaves margin +// without a noticeable delay before a genuine "can't play here". +static constexpr int OCARINA_RETRY_GRACE_FRAMES = 6; + static bool isCannotPlayActive = false; +// How long the "can't play here" textbox lingers before fading out on its own, in message-system +// updates (~20/sec, so this is about three seconds). Long enough to read, short enough not to nag. +static constexpr int CANNOT_PLAY_FADE_FRAMES = 60; -// Shown (through the OnOpenText hook below) when a song is selected somewhere the ocarina can't be played. -// Phrased impersonally in each language so it reads consistently and sidesteps the tu/vous (du/Sie) choice. +// Shown (through the OnOpenText hook below) once the grace retry gives up: a song was selected somewhere +// the ocarina genuinely can't be played. static CustomMessage cannotPlayHereMsg = CustomMessage( "Can't play the Ocarina here!" + CustomMessage::MESSAGE_END(), "Hier kann die Okarina" + CustomMessage::NEWLINE() + "nicht gespielt werden!" + CustomMessage::MESSAGE_END(), - "Impossible de jouer" + CustomMessage::NEWLINE() + "de l'ocarina ici !" + CustomMessage::MESSAGE_END(), - TEXTBOX_TYPE_BLACK); + "Impossible de jouer" + CustomMessage::NEWLINE() + "de l'ocarina ici !" + CustomMessage::MESSAGE_END()); static void PauseWarp_Execute() { if (!isWarpActive || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { @@ -398,9 +410,9 @@ static void PauseMenu_BeginClose(PauseContext* pauseCtx) { pauseCtx->unk_1E4 = 0; } -static void ActivateWarp(PauseContext* pauseCtx, int song) { +// Start warp-song playback. The caller has already begun closing the quest screen. +static void StartWarpPlayback(int song) { AudioOcarina_SetInstrument(OCARINA_INSTRUMENT_OFF); - PauseMenu_BeginClose(pauseCtx); int idx = song - QUEST_SONG_MINUET; gPlayState->msgCtx.lastPlayedSong = ocarinaSongMap[idx]; Audio_SetSfxBanksMute(0x20); @@ -429,9 +441,9 @@ static bool PauseSong_CanPlayOcarina() { return (player->actor.bgCheckFlags & BGCHECKFLAG_GROUND) != 0; } -static void ActivateSong(PauseContext* pauseCtx, int questSong) { +// Start non-warp song playback. The caller has already begun closing the quest screen. +static void StartSongPlayback(int questSong) { int idx = questSong - QUEST_SONG_LULLABY; - PauseMenu_BeginClose(pauseCtx); gPlayState->msgCtx.lastPlayedSong = questSongToOcarinaSong[idx]; // Intentionally no Audio_SetSoundBanksMute(0x20): it mutes BANK_OCARINA and is only cleared via // AudioOcarina_SetInstrument(OFF), which this in-scene path never hits, silencing the next real ocarina. @@ -439,21 +451,89 @@ static void ActivateSong(PauseContext* pauseCtx, int questSong) { isSongActive = true; } -// Selected a non-warp song somewhere the ocarina can't be played: close the menu and show a short message, -// the same hand-off a warp song uses (minus the song). PauseCannotPlay_Execute restores control after. -static void ActivateCannotPlay(PauseContext* pauseCtx) { - PauseMenu_BeginClose(pauseCtx); - Message_StartTextbox(gPlayState, TEXT_CANNOT_PLAY_OCARINA_MSG, NULL); - GET_PLAYER(gPlayState)->stateFlags1 |= PLAYER_STATE1_IN_CUTSCENE; - isCannotPlayActive = true; +// Randomizer's "Shuffle Ocarina Buttons" setting turns each ocarina button (A and the four C-buttons) +// into a separate item to find; until you hold every button a warp song's melody uses, the real ocarina +// won't play it. Mirror that here. Always true outside that setting (nothing to gate on). +static bool WarpSongButtonsUnlocked(int song) { + if (gSaveContext.ship.quest.id != QUEST_RANDOMIZER || !Randomizer_GetSettingValue(RSK_SHUFFLE_OCARINA_BUTTONS)) { + return true; + } + switch (song) { + case QUEST_SONG_MINUET: + return Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_UP); + case QUEST_SONG_BOLERO: + return Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); + case QUEST_SONG_SERENADE: + return Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); + case QUEST_SONG_REQUIEM: + return Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); + case QUEST_SONG_NOCTURNE: + return Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); + case QUEST_SONG_PRELUDE: + return Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && + Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_UP); + } + return true; +} + +// After the menu closes, keep re-checking whether the ocarina can be played and start the pending song +// once it can. A block at selection time is usually a previous song's ocarina/message state still settling +// -- which only advances once unpaused -- so we give it a short grace before concluding it truly can't be +// played here. Runs each game frame; a no-op unless a retry is armed. +static void PauseSong_RetryPlay() { + if (retryTimer <= 0) { + return; + } + // The state we're waiting on can't change while the quest screen is still up or a message is open. + if (gPlayState->pauseCtx.state != 0 || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { + return; + } + if (PauseSong_CanPlayOcarina()) { + if (retryIsWarp) { + StartWarpPlayback(retrySong); + } else { + StartSongPlayback(retrySong); + } + retryTimer = 0; + } else if (--retryTimer == 0) { + // Grace expired and the ocarina still can't come out: this really is a spot it can't be played. + // The menu is already closed, so show the standard in-game textbox. Link keeps normal control and + // physics -- this is pure feedback, and freezing him here (a cutscene hold) would suspend his + // physics mid-air/underwater, exactly the states this message fires in. + Message_StartTextbox(gPlayState, TEXT_CANNOT_PLAY_OCARINA_MSG, NULL); + isCannotPlayActive = true; + } } +// Drives the "can't play here" textbox. Once its text has finished animating in, hand it the game's own +// fade-out end type -- the same one cutscene messages use -- so it dismisses itself after a few seconds +// instead of waiting on an A press (and A can't close a fading box early). The isCannotPlayActive guard +// keeps this scoped to our own message so ordinary textboxes are never touched. static void PauseCannotPlay_Execute() { - if (!isCannotPlayActive || gPlayState->msgCtx.msgMode != MSGMODE_NONE) { + if (!isCannotPlayActive) { return; } - isCannotPlayActive = false; - GET_PLAYER(gPlayState)->stateFlags1 &= ~PLAYER_STATE1_IN_CUTSCENE; + MessageContext* msgCtx = &gPlayState->msgCtx; + if (msgCtx->msgMode == MSGMODE_TEXT_DONE && msgCtx->textboxEndType == TEXTBOX_ENDTYPE_DEFAULT) { + msgCtx->textboxEndType = TEXTBOX_ENDTYPE_FADING; + msgCtx->stateTimer = CANNOT_PLAY_FADE_FRAMES; + } else if (msgCtx->msgMode == MSGMODE_NONE) { + isCannotPlayActive = false; + } } static void PauseMenuSongs_HandleSelection() { @@ -472,58 +552,29 @@ static void PauseMenuSongs_HandleSelection() { return; // cursor isn't on a playable song } - // Every song needs the ocarina to be playable here -- warp songs included, so you can no longer warp - // from places the game would never let you pull the ocarina out (mid-air, underwater, on horseback...). - if (!PauseSong_CanPlayOcarina()) { - ActivateCannotPlay(&gPlayState->pauseCtx); + // If randomizer's shuffled ocarina buttons mean this warp song isn't playable yet, do nothing and + // leave the menu open -- same as the real ocarina refusing it. + if (isWarpSong && !WarpSongButtonsUnlocked(song)) { return; } - if (isWarpSong) { - if (gSaveContext.ship.quest.id == QUEST_RANDOMIZER && Randomizer_GetSettingValue(RSK_SHUFFLE_OCARINA_BUTTONS)) { - bool canplay = false; - switch (song) { - case QUEST_SONG_MINUET: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_UP); - break; - case QUEST_SONG_BOLERO: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); - break; - case QUEST_SONG_SERENADE: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); - break; - case QUEST_SONG_REQUIEM: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); - break; - case QUEST_SONG_NOCTURNE: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_A) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_DOWN); - break; - case QUEST_SONG_PRELUDE: - canplay = Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_LEFT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_RIGHT) && - Flags_GetRandomizerInf(RAND_INF_HAS_OCARINA_C_UP); - break; - } - if (!canplay) { - return; - } + // Commit to playing: close the quest screen now. If the ocarina can come out this instant, start the + // song immediately; otherwise arm a short grace retry (PauseSong_RetryPlay) instead of giving up, since + // the block is often just a previous song's ocarina/message state still settling. Every song needs the + // ocarina playable here -- warp songs included, so you can no longer warp from spots the game would + // never let you pull the ocarina out (mid-air, underwater, on horseback...). + PauseMenu_BeginClose(&gPlayState->pauseCtx); + retrySong = song; + retryIsWarp = isWarpSong; + if (PauseSong_CanPlayOcarina()) { + retryTimer = 0; + if (isWarpSong) { + StartWarpPlayback(song); + } else { + StartSongPlayback(song); } - ActivateWarp(&gPlayState->pauseCtx, song); } else { - ActivateSong(&gPlayState->pauseCtx, song); + retryTimer = OCARINA_RETRY_GRACE_FRAMES; } } @@ -536,8 +587,9 @@ static void RegisterPauseMenuHooks() { COND_HOOK(OnGameFrameUpdate, CVAR_PAUSE_WARP_VALUE, [] { if (GameInteractor::IsSaveLoaded()) { PauseWarp_Execute(); - PauseCannotPlay_Execute(); PauseSong_Execute(); + PauseSong_RetryPlay(); + PauseCannotPlay_Execute(); } }); COND_ID_HOOK(OnOpenText, TEXT_CANNOT_PLAY_OCARINA_MSG, CVAR_PAUSE_WARP_VALUE, From db4a49caca0d1c2ea17a769be0ce8561d1b335c9 Mon Sep 17 00:00:00 2001 From: David Racine Date: Mon, 27 Jul 2026 21:30:29 -0400 Subject: [PATCH 17/20] Tidy pause-menu-songs macros and comments Rename the file-local CVAR_PAUSE_WARP_* macros to CVAR_PAUSE_SONGS_*, since the toggle now covers every song rather than just warps. Trim comments that had accumulated duplicate rationale, keeping one home for each explanation. Co-Authored-By: Claude Opus 4.8 --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 63 ++++++++------------- 1 file changed, 25 insertions(+), 38 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index cfc9b81c616..5cfc8d732b8 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -51,9 +51,9 @@ s32 ObjTimeblock_WaitForSong(ObjTimeblock* timeblock, PlayState* play); void ShotSun_UpdateFairySpawner(ShotSun* shotSun, PlayState* play); } -static constexpr int32_t CVAR_PAUSE_WARP_DEFAULT = 0; -#define CVAR_PAUSE_WARP_NAME CVAR_ENHANCEMENT("PauseMenuSongs") -#define CVAR_PAUSE_WARP_VALUE CVarGetInteger(CVAR_PAUSE_WARP_NAME, CVAR_PAUSE_WARP_DEFAULT) +static constexpr int32_t CVAR_PAUSE_SONGS_DEFAULT = 0; +#define CVAR_PAUSE_SONGS_NAME CVAR_ENHANCEMENT("PauseMenuSongs") +#define CVAR_PAUSE_SONGS_VALUE CVarGetInteger(CVAR_PAUSE_SONGS_NAME, CVAR_PAUSE_SONGS_DEFAULT) // --- Warp songs (QUEST_SONG_MINUET through QUEST_SONG_PRELUDE) --- @@ -121,14 +121,11 @@ static bool isSongActive = false; static int retrySong = 0; static bool retryIsWarp = false; static int retryTimer = 0; -// A handful of game-update frames (roughly a quarter second). The block is usually a previous song's -// ocarina/message state still settling, which clears a frame or two after unpausing; this leaves margin -// without a noticeable delay before a genuine "can't play here". +// ~6 game-update frames (about a quarter second) of grace; see PauseSong_RetryPlay for why. static constexpr int OCARINA_RETRY_GRACE_FRAMES = 6; static bool isCannotPlayActive = false; -// How long the "can't play here" textbox lingers before fading out on its own, in message-system -// updates (~20/sec, so this is about three seconds). Long enough to read, short enough not to nag. +// Frames the "can't play here" textbox stays up before fading on its own (message updates run ~20/sec). static constexpr int CANNOT_PLAY_FADE_FRAMES = 60; // Shown (through the OnOpenText hook below) once the grace retry gives up: a song was selected somewhere @@ -206,8 +203,7 @@ static bool PauseSong_ActivateOkarinaTags() { ((tag->type == 2) && (song == OCARINA_SONG_STORMS)) || ((tag->type == 4) && (song == OCARINA_SONG_TIME)); if (songMatchesType) { - // Like the type-7 spots, run the listening handler now while MODE_03 is set so it fires - // this frame (its actionFunc is the ocarina-listening state, so it consumes it). + // Run the listening handler now, while MODE_03 is set, so it fires this frame. func_80ABF4C8(tag, gPlayState); // handle the matched song event matched = true; } @@ -221,7 +217,7 @@ static bool PauseSong_ActivateOkarinaTags() { // The Song of Time blocks, Great Fairy spawners, and ocarina NPCs update before this hook and just poll // ocarinaMode + lastPlayedSong. We can't use the real play-for-actor flow (it opens a note-input prompt the // menu can't satisfy), so we push the matching in-range actor into its song-finished state, hold the -// matching mode a couple frames for it to read, then restore it. Only Mido needs its dialogue started here. +// matching mode a couple frames for it to read, then restore it. static u8 pendingMode = OCARINA_MODE_00; static Actor* pendingTalkActor = NULL; static int pendingTimer = 0; @@ -293,10 +289,8 @@ static bool PauseSong_ActivateNpcActors() { return true; } } - // The visible Lost Woods stump Skull Kid (moved to ACTORCAT_NPC on init) reacts to Saria's Song. - // Its idle WaitInRange state normally opens a real ocarina prompt, so -- like Mido -- push it into - // WaitForSong, hold MODE_03, and start the reward talk once it reacts. Being in WaitInRange already - // implies the player is in range (it drops out of that state otherwise). + // Lost Woods stump Skull Kid reacts to Saria's Song. WaitInRange already implies the player is in + // range, so push it into WaitForSong and start the reward talk once it reacts. if (actor->id == ACTOR_EN_SKJ && song == OCARINA_SONG_SARIAS) { EnSkj* skullKid = (EnSkj*)actor; if (skullKid->actionFunc == EnSkj_WaitInRange) { @@ -422,10 +416,9 @@ static void StartWarpPlayback(int song) { isWarpActive = true; } -// Only inject a played song when Link is in a normal, controllable field state -- the footing the vanilla -// ocarina action itself needs to start (see Player_ActionHandler_13: grounded and not in a blocking state). -// Firing from an item cutscene, a message, mid-song, mid-air, in water, while talking, mounted, or dying -// could leave the ocarina/message system in a bad state, which is the class of corruption we want to avoid. +// Only play when Link is in a normal, grounded, controllable state -- the footing the vanilla ocarina +// action itself requires (cf. Player_ActionHandler_13). Firing from a cutscene, message, mid-air, water, +// horseback, etc. can leave the ocarina/message system corrupted. static bool PauseSong_CanPlayOcarina() { Player* player = GET_PLAYER(gPlayState); if (gPlayState->msgCtx.msgMode != MSGMODE_NONE || gPlayState->msgCtx.ocarinaMode != OCARINA_MODE_00) { @@ -510,19 +503,17 @@ static void PauseSong_RetryPlay() { } retryTimer = 0; } else if (--retryTimer == 0) { - // Grace expired and the ocarina still can't come out: this really is a spot it can't be played. - // The menu is already closed, so show the standard in-game textbox. Link keeps normal control and - // physics -- this is pure feedback, and freezing him here (a cutscene hold) would suspend his - // physics mid-air/underwater, exactly the states this message fires in. + // Grace expired: this really is a spot the ocarina can't be played. Show the standard textbox, but + // don't freeze Link -- a cutscene hold would suspend his physics in the very states (mid-air, water) + // this message fires in. Message_StartTextbox(gPlayState, TEXT_CANNOT_PLAY_OCARINA_MSG, NULL); isCannotPlayActive = true; } } -// Drives the "can't play here" textbox. Once its text has finished animating in, hand it the game's own -// fade-out end type -- the same one cutscene messages use -- so it dismisses itself after a few seconds -// instead of waiting on an A press (and A can't close a fading box early). The isCannotPlayActive guard -// keeps this scoped to our own message so ordinary textboxes are never touched. +// Once our "can't play here" textbox finishes animating in, give it the game's fading end type (as used by +// cutscene messages) so it dismisses itself after a few seconds instead of waiting on A. The guard scopes +// this to our own message. static void PauseCannotPlay_Execute() { if (!isCannotPlayActive) { return; @@ -552,17 +543,13 @@ static void PauseMenuSongs_HandleSelection() { return; // cursor isn't on a playable song } - // If randomizer's shuffled ocarina buttons mean this warp song isn't playable yet, do nothing and - // leave the menu open -- same as the real ocarina refusing it. + // Rando's shuffled ocarina buttons can leave a warp song not yet playable; if so, leave the menu open. if (isWarpSong && !WarpSongButtonsUnlocked(song)) { return; } - // Commit to playing: close the quest screen now. If the ocarina can come out this instant, start the - // song immediately; otherwise arm a short grace retry (PauseSong_RetryPlay) instead of giving up, since - // the block is often just a previous song's ocarina/message state still settling. Every song needs the - // ocarina playable here -- warp songs included, so you can no longer warp from spots the game would - // never let you pull the ocarina out (mid-air, underwater, on horseback...). + // Commit: close the quest screen. Play now if the ocarina can come out, else arm the grace retry. Warp + // songs are gated the same way, so you can't warp from a spot the game wouldn't let you play in. PauseMenu_BeginClose(&gPlayState->pauseCtx); retrySong = song; retryIsWarp = isWarpSong; @@ -579,12 +566,12 @@ static void PauseMenuSongs_HandleSelection() { } static void RegisterPauseMenuHooks() { - COND_HOOK(OnKaleidoUpdate, CVAR_PAUSE_WARP_VALUE, [] { + COND_HOOK(OnKaleidoUpdate, CVAR_PAUSE_SONGS_VALUE, [] { if (GameInteractor::IsSaveLoaded()) { PauseMenuSongs_HandleSelection(); } }); - COND_HOOK(OnGameFrameUpdate, CVAR_PAUSE_WARP_VALUE, [] { + COND_HOOK(OnGameFrameUpdate, CVAR_PAUSE_SONGS_VALUE, [] { if (GameInteractor::IsSaveLoaded()) { PauseWarp_Execute(); PauseSong_Execute(); @@ -592,11 +579,11 @@ static void RegisterPauseMenuHooks() { PauseCannotPlay_Execute(); } }); - COND_ID_HOOK(OnOpenText, TEXT_CANNOT_PLAY_OCARINA_MSG, CVAR_PAUSE_WARP_VALUE, + COND_ID_HOOK(OnOpenText, TEXT_CANNOT_PLAY_OCARINA_MSG, CVAR_PAUSE_SONGS_VALUE, [](uint16_t* textId, bool* loadFromMessageTable) { cannotPlayHereMsg.LoadIntoFont(); *loadFromMessageTable = false; }); } -static RegisterShipInitFunc initFunc(RegisterPauseMenuHooks, { CVAR_PAUSE_WARP_NAME }); +static RegisterShipInitFunc initFunc(RegisterPauseMenuHooks, { CVAR_PAUSE_SONGS_NAME }); From 944fa412d1d673d6a376849ef0b79d9a6565dc91 Mon Sep 17 00:00:00 2001 From: David Racine Date: Tue, 4 Aug 2026 02:37:42 -0400 Subject: [PATCH 18/20] Fix rando warp songs from the pause menu warping to the wrong place The pause-menu warp had its own rando branch that called Entrance_SetWarpSongEntrance() but never set respawn[RESPAWN_MODE_RETURN], so the DEMO_KANKYO warp actor read a stale return entrance -- the last song played on the real ocarina, or the save's default spawn (freezing at the Great Deku Tree on a fresh load). Set respawn[RESPAWN_MODE_RETURN] from the played song exactly like the vanilla ocarina warp action (z_player.c, OCARINA_MODE_02) and let the DEMO_KANKYO init hook apply entrance-shuffle on top, so pause-menu and real-ocarina warps now share one codepath. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KMnZEp5CRuq4e3ozJbhRZy --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index 5cfc8d732b8..b18365195e4 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -145,18 +145,15 @@ static void PauseWarp_Execute() { return; } - // Set up respawn destination before spawning the warp actor - if (IS_RANDO) { - Entrance_SetWarpSongEntrance(); - } else { - for (int i = 0; i < ARRAY_COUNT(ocarinaSongMap); i++) { - if (gPlayState->msgCtx.lastPlayedSong == ocarinaSongMap[i]) { - gSaveContext.respawn[RESPAWN_MODE_RETURN].entranceIndex = entranceIndexMap[i]; - gSaveContext.respawn[RESPAWN_MODE_RETURN].playerParams = 0x5FF; - gSaveContext.respawn[RESPAWN_MODE_RETURN].data = (s8)gPlayState->msgCtx.lastPlayedSong; - Interface_SetSubTimerToFinalSecond(gPlayState); - break; - } + // Set the warp-return entrance from the played song, like the vanilla ocarina warp (z_player.c + // OCARINA_MODE_02): the DEMO_KANKYO actor spawned below warps to respawn[RETURN]. + for (int i = 0; i < ARRAY_COUNT(ocarinaSongMap); i++) { + if (gPlayState->msgCtx.lastPlayedSong == ocarinaSongMap[i]) { + gSaveContext.respawn[RESPAWN_MODE_RETURN].entranceIndex = entranceIndexMap[i]; + gSaveContext.respawn[RESPAWN_MODE_RETURN].playerParams = 0x5FF; + gSaveContext.respawn[RESPAWN_MODE_RETURN].data = (s8)gPlayState->msgCtx.lastPlayedSong; + Interface_SetSubTimerToFinalSecond(gPlayState); + break; } } From 0cdf268225c884f7d775d1e563528660162034cd Mon Sep 17 00:00:00 2001 From: David Racine Date: Tue, 4 Aug 2026 21:39:29 -0400 Subject: [PATCH 19/20] Make Skip Warp Cutscenes the only warp-song cutscene skipper Rando skipped the warp-song departure cutscene from a hardcoded IS_RANDO branch in Demo_Kankyo, and entrance rando skipped the arrival one by clearing respawnFlag, so the enhancement toggle was only half the story: turning it off still gave rando players a truncated warp. Drop both, and move the entrance-shuffle override onto a new OnWarpSongLeave hook fired from Environment_WarpSongLeave. That is where every warp path -- the cutscene, the skip enhancement, and the spawn failure fallback -- commits its destination, so the override no longer depends on hook ordering at actor init, and a warp song shuffled onto a grotto return keeps its grotto respawn. The curated randomizer preset still enabled the removed PauseWarp CVar; point it at PauseMenuSongs + WarpSongSkipAnimation, and migrate the same rename in user presets on load. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WSy2DYvARYA881k6qSDw4B --- .../Enhancements - Curated Randomizer.json | 3 ++- soh/soh/Enhancements/Presets/Presets.cpp | 26 +++++++++++++++++++ .../Enhancements/QoL/SkipWarpAnimation.cpp | 10 ++++--- .../GameInteractor_HookTable.h | 1 + .../game-interactor/GameInteractor_Hooks.cpp | 4 +++ .../game-interactor/GameInteractor_Hooks.h | 1 + .../Enhancements/randomizer/hook_handlers.cpp | 13 +++++++--- .../randomizer/randomizer_entrance.c | 24 ++++++++--------- soh/src/code/z_kankyo.c | 4 +++ .../actors/ovl_Demo_Kankyo/z_demo_kankyo.c | 8 +----- 10 files changed, 68 insertions(+), 26 deletions(-) diff --git a/soh/assets/custom/presets/Enhancements - Curated Randomizer.json b/soh/assets/custom/presets/Enhancements - Curated Randomizer.json index d7d660caed9..77c2aa0cdf2 100644 --- a/soh/assets/custom/presets/Enhancements - Curated Randomizer.json +++ b/soh/assets/custom/presets/Enhancements - Curated Randomizer.json @@ -86,7 +86,7 @@ "StartingNotes": 5 }, "PauseMenuAnimatedLink": 1, - "PauseWarp": 1, + "PauseMenuSongs": 1, "PersistentMasks": 1, "PulsateBossIcon": 1, "QuickBongoKill": 1, @@ -129,6 +129,7 @@ "ToggleStrength": 1, "TwoHandedIdle": 1, "VisualAgony": 1, + "WarpSongSkipAnimation": 1, "WidescreenActorCulling": 1 }, "gRandoEnhancements": null diff --git a/soh/soh/Enhancements/Presets/Presets.cpp b/soh/soh/Enhancements/Presets/Presets.cpp index 2e2294d8380..dea0423e60b 100644 --- a/soh/soh/Enhancements/Presets/Presets.cpp +++ b/soh/soh/Enhancements/Presets/Presets.cpp @@ -222,8 +222,34 @@ void DrawSectionCheck(const std::string& name, bool empty, bool* pointer, std::s } } +// Presets saved before an enhancement was renamed still carry the old CVar, and applying a block overwrites +// it wholesale, so translate them on load the way ConfigUpdaters translates a config. Only the in-memory +// copy is touched; the file is migrated again next launch, or cleaned up if the user saves over it. +void MigratePreset(nlohmann::json& json) { + if (!json.contains("blocks") || !json["blocks"].is_object()) { + return; + } + for (auto& block : json["blocks"]) { + if (!block.is_object() || !block.contains("gEnhancements") || !block["gEnhancements"].is_object()) { + continue; + } + auto& enhancements = block["gEnhancements"]; + + // PauseWarp was split into PauseMenuSongs (covers all songs) + WarpSongSkipAnimation. + // Old behavior skipped the animation, so migrate both flags on. + if (auto pauseWarp = enhancements.find("PauseWarp"); pauseWarp != enhancements.end()) { + if (pauseWarp->is_number() && pauseWarp->get() != 0) { + enhancements["PauseMenuSongs"] = 1; + enhancements["WarpSongSkipAnimation"] = 1; + } + enhancements.erase("PauseWarp"); + } + } +} + void ParsePreset(nlohmann::json& json, std::string name) { try { + MigratePreset(json); presets[json["presetName"]].presetValues = json; presets[json["presetName"]].fileName = name; if (json.contains("isBuiltIn")) { diff --git a/soh/soh/Enhancements/QoL/SkipWarpAnimation.cpp b/soh/soh/Enhancements/QoL/SkipWarpAnimation.cpp index b4554544b9b..c2ee80c97d1 100644 --- a/soh/soh/Enhancements/QoL/SkipWarpAnimation.cpp +++ b/soh/soh/Enhancements/QoL/SkipWarpAnimation.cpp @@ -20,10 +20,14 @@ static void RegisterSkipWarpHooks() { COND_HOOK(OnActorInit, CVAR_SKIP_WARP_VALUE, [](void* refActor) { Actor* actor = static_cast(refActor); if (actor->id == ACTOR_DEMO_KANKYO && actor->params == DEMOKANKYO_WARP_OUT_PARAM) { + // Leaves right away, and settles the destination: entrance rando redirects from inside it. Environment_WarpSongLeave(gPlayState); - // Switch arrival spawn mode from WARP_SONG to IDLE so DEMO_KANKYO WARP_IN is never spawned - gSaveContext.respawn[RESPAWN_MODE_RETURN].playerParams = - (gSaveContext.respawn[RESPAWN_MODE_RETURN].playerParams & ~(0xF << 8)) | (PLAYER_START_MODE_IDLE << 8); + // Switch arrival spawn mode from WARP_SONG to IDLE so DEMO_KANKYO WARP_IN is never spawned. + // Another mode means something else owns the arrival (a rando grotto return), with no cutscene. + RespawnData* respawn = &gSaveContext.respawn[RESPAWN_MODE_RETURN]; + if (((respawn->playerParams & 0xF00) >> 8) == PLAYER_START_MODE_WARP_SONG) { + respawn->playerParams = (respawn->playerParams & ~0xF00) | (PLAYER_START_MODE_IDLE << 8); + } Actor_Kill(actor); } }); diff --git a/soh/soh/Enhancements/game-interactor/GameInteractor_HookTable.h b/soh/soh/Enhancements/game-interactor/GameInteractor_HookTable.h index 3448ebc08f9..6e1a5214d30 100644 --- a/soh/soh/Enhancements/game-interactor/GameInteractor_HookTable.h +++ b/soh/soh/Enhancements/game-interactor/GameInteractor_HookTable.h @@ -29,6 +29,7 @@ DEFINE_HOOK(OnPlayerUpdate, ()); DEFINE_HOOK(OnSetDoAction, (uint16_t action)); DEFINE_HOOK(OnPlayerSfx, (u16 sfxId)); DEFINE_HOOK(OnOcarinaSongAction, ()); +DEFINE_HOOK(OnWarpSongLeave, ()); DEFINE_HOOK(OnOcarinaNote, (uint8_t note, float modulator, int8_t bend)); DEFINE_HOOK(OnShopSlotChange, (uint8_t cursorIndex, int16_t price)); DEFINE_HOOK(OnDungeonKeyUsed, (uint16_t mapIndex)); diff --git a/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.cpp b/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.cpp index d58a52440b9..38af396b5c3 100644 --- a/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.cpp +++ b/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.cpp @@ -114,6 +114,10 @@ void GameInteractor_ExecuteOnOcarinaSongAction() { GameInteractor::Instance->ExecuteHooks(); } +void GameInteractor_ExecuteOnWarpSongLeave() { + GameInteractor::Instance->ExecuteHooks(); +} + void GameInteractor_ExecuteOnOcarinaNote(uint8_t note, float modulator, int8_t bend) { GameInteractor::Instance->ExecuteHooks(note, modulator, bend); } diff --git a/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.h b/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.h index 39a1f35544a..d96a9785480 100644 --- a/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.h +++ b/soh/soh/Enhancements/game-interactor/GameInteractor_Hooks.h @@ -31,6 +31,7 @@ void GameInteractor_ExecuteOnPlayerUpdate(); void GameInteractor_ExecuteOnSetDoAction(uint16_t action); void GameInteractor_ExecuteOnPlayerSfx(u16 sfxId); void GameInteractor_ExecuteOnOcarinaSongAction(); +void GameInteractor_ExecuteOnWarpSongLeave(); void GameInteractor_ExecuteOnOcarinaNote(uint8_t note, float modulator, int8_t bend); bool GameInteractor_ShouldActorInit(void* actor); void GameInteractor_ExecuteOnActorInit(void* actor); diff --git a/soh/soh/Enhancements/randomizer/hook_handlers.cpp b/soh/soh/Enhancements/randomizer/hook_handlers.cpp index c9b8de0fe34..769cad002bb 100644 --- a/soh/soh/Enhancements/randomizer/hook_handlers.cpp +++ b/soh/soh/Enhancements/randomizer/hook_handlers.cpp @@ -2757,10 +2757,12 @@ void RandomizerOnActorUpdateHandler(void* refActor) { Flags_UnsetRandomizerInf(RAND_INF_SPIRIT_BIG_MIRROR_STATUE_TURNED); } } +} - // In ER, override the warp song locations. Also removes the warp song cutscene - if (RAND_GET_OPTION(RSK_SHUFFLE_ENTRANCES) && actor->id == ACTOR_DEMO_KANKYO && - actor->params == 0x000F) { // Warp Song particles +// In ER, warp songs lead to their shuffled entrance rather than their warp pad. Every warp path commits its +// destination through Environment_WarpSongLeave, so that is the one place the override has to happen. +void RandomizerOnWarpSongLeaveHandler() { + if (RAND_GET_OPTION(RSK_SHUFFLE_ENTRANCES)) { Entrance_SetWarpSongEntrance(); } } @@ -2927,6 +2929,7 @@ static void RandomizerRegisterHooks() { static uint32_t afterSceneCommandsHook = 0; static uint32_t onActorInitHook = 0; static uint32_t onActorUpdateHook = 0; + static uint32_t onWarpSongLeaveHook = 0; static uint32_t onPlayerUpdateHook = 0; static uint32_t onGameFrameUpdateHook = 0; static uint32_t onSceneSpawnActorsHook = 0; @@ -2959,6 +2962,7 @@ static void RandomizerRegisterHooks() { GameInteractor::Instance->UnregisterGameHook(afterSceneCommandsHook); GameInteractor::Instance->UnregisterGameHook(onActorInitHook); GameInteractor::Instance->UnregisterGameHook(onActorUpdateHook); + GameInteractor::Instance->UnregisterGameHook(onWarpSongLeaveHook); GameInteractor::Instance->UnregisterGameHook(onPlayerUpdateHook); GameInteractor::Instance->UnregisterGameHook(onGameFrameUpdateHook); GameInteractor::Instance->UnregisterGameHook(onSceneSpawnActorsHook); @@ -2977,6 +2981,7 @@ static void RandomizerRegisterHooks() { afterSceneCommandsHook = 0; onActorInitHook = 0; onActorUpdateHook = 0; + onWarpSongLeaveHook = 0; onPlayerUpdateHook = 0; onGameFrameUpdateHook = 0; onSceneSpawnActorsHook = 0; @@ -3018,6 +3023,8 @@ static void RandomizerRegisterHooks() { GameInteractor::Instance->RegisterGameHook(RandomizerOnActorInitHandler); onActorUpdateHook = GameInteractor::Instance->RegisterGameHook(RandomizerOnActorUpdateHandler); + onWarpSongLeaveHook = GameInteractor::Instance->RegisterGameHook( + RandomizerOnWarpSongLeaveHandler); onPlayerUpdateHook = GameInteractor::Instance->RegisterGameHook(RandomizerOnPlayerUpdateHandler); onGameFrameUpdateHook = GameInteractor::Instance->RegisterGameHook( diff --git a/soh/soh/Enhancements/randomizer/randomizer_entrance.c b/soh/soh/Enhancements/randomizer/randomizer_entrance.c index b5e39ed39de..33ddceecede 100644 --- a/soh/soh/Enhancements/randomizer/randomizer_entrance.c +++ b/soh/soh/Enhancements/randomizer/randomizer_entrance.c @@ -432,31 +432,34 @@ void Entrance_SetSavewarpEntrance(void) { } void Entrance_SetWarpSongEntrance(void) { - gPlayState->transitionTrigger = TRANS_TRIGGER_START; - gPlayState->transitionType = TRANS_TYPE_FADE_WHITE_FAST; + s16 warpEntrance; switch (gPlayState->msgCtx.lastPlayedSong) { case OCARINA_SONG_MINUET: - gPlayState->nextEntranceIndex = Entrance_OverrideNextIndex(ENTR_SACRED_FOREST_MEADOW_WARP_PAD); // Minuet + warpEntrance = Entrance_OverrideNextIndex(ENTR_SACRED_FOREST_MEADOW_WARP_PAD); // Minuet break; case OCARINA_SONG_BOLERO: - gPlayState->nextEntranceIndex = Entrance_OverrideNextIndex(ENTR_DEATH_MOUNTAIN_CRATER_WARP_PAD); // Bolero + warpEntrance = Entrance_OverrideNextIndex(ENTR_DEATH_MOUNTAIN_CRATER_WARP_PAD); // Bolero break; case OCARINA_SONG_SERENADE: - gPlayState->nextEntranceIndex = Entrance_OverrideNextIndex(ENTR_LAKE_HYLIA_WARP_PAD); // Serenade + warpEntrance = Entrance_OverrideNextIndex(ENTR_LAKE_HYLIA_WARP_PAD); // Serenade break; case OCARINA_SONG_REQUIEM: - gPlayState->nextEntranceIndex = Entrance_OverrideNextIndex(ENTR_DESERT_COLOSSUS_WARP_PAD); // Requiem + warpEntrance = Entrance_OverrideNextIndex(ENTR_DESERT_COLOSSUS_WARP_PAD); // Requiem break; case OCARINA_SONG_NOCTURNE: - gPlayState->nextEntranceIndex = Entrance_OverrideNextIndex(ENTR_GRAVEYARD_WARP_PAD); // Nocturne + warpEntrance = Entrance_OverrideNextIndex(ENTR_GRAVEYARD_WARP_PAD); // Nocturne break; case OCARINA_SONG_PRELUDE: - gPlayState->nextEntranceIndex = Entrance_OverrideNextIndex(ENTR_TEMPLE_OF_TIME_WARP_PAD); // Prelude + warpEntrance = Entrance_OverrideNextIndex(ENTR_TEMPLE_OF_TIME_WARP_PAD); // Prelude break; default: - gPlayState->transitionTrigger = TRANS_TRIGGER_OFF; // if something goes wrong, the animation plays normally + return; // unknown song: leave the warp untouched } + // Environment_WarpSongLeave has pointed the transition at the vanilla warp pad; send it to the shuffled + // entrance instead. Skipping the warp cutscenes is the Skip Warp Cutscenes enhancement's job. + gPlayState->nextEntranceIndex = warpEntrance; + // If one of the warp songs happens to lead to a grotto return, then we // have to force the grotto return afterwards Grotto_ForceGrottoReturnOnSpecialEntrance(); @@ -464,9 +467,6 @@ void Entrance_SetWarpSongEntrance(void) { if (gSaveContext.gameMode != GAMEMODE_NORMAL) { // During DHWW the cutscene must play at the destination gSaveContext.respawnFlag = -3; - } else if (gSaveContext.respawnFlag == -3) { - // Unset Zoneout Type -3 to avoid cutscene at destination (technically it's not needed) - gSaveContext.respawnFlag = 0; } } diff --git a/soh/src/code/z_kankyo.c b/soh/src/code/z_kankyo.c index dc25416a677..a7901760341 100644 --- a/soh/src/code/z_kankyo.c +++ b/soh/src/code/z_kankyo.c @@ -7,6 +7,7 @@ #include "soh/OTRGlobals.h" #include "soh/ResourceManagerHelpers.h" #include "soh/Enhancements/savestate_serialize.h" +#include "soh/Enhancements/game-interactor/GameInteractor_Hooks.h" typedef enum { /* 0 */ LENS_FLARE_CIRCLE0, @@ -2534,6 +2535,9 @@ void Environment_WarpSongLeave(PlayState* play) { play->transitionType = TRANS_TYPE_FADE_WHITE; gSaveContext.nextTransitionType = TRANS_TYPE_FADE_WHITE; + // Where entrance rando redirects the warp: every warp song path decides its destination here. + GameInteractor_ExecuteOnWarpSongLeave(); + switch (play->nextEntranceIndex) { case ENTR_DEATH_MOUNTAIN_CRATER_UPPER_EXIT: Flags_SetEventChkInf(EVENTCHKINF_ENTERED_DEATH_MOUNTAIN_CRATER); diff --git a/soh/src/overlays/actors/ovl_Demo_Kankyo/z_demo_kankyo.c b/soh/src/overlays/actors/ovl_Demo_Kankyo/z_demo_kankyo.c index 5913c92fb93..cee15f87967 100644 --- a/soh/src/overlays/actors/ovl_Demo_Kankyo/z_demo_kankyo.c +++ b/soh/src/overlays/actors/ovl_Demo_Kankyo/z_demo_kankyo.c @@ -794,13 +794,7 @@ void DemoKankyo_DrawWarpSparkles(Actor* thisx, PlayState* play) { this->unk_150[i].unk_0.y = (s16)((Rand_ZeroOne() - 0.5f) * 16.0f * temp_f22); this->unk_150[i].unk_0.z = (s16)((Rand_ZeroOne() - 0.5f) * 16.0f * temp_f22); this->unk_150[i].unk_23 = 0; - - // Skip the first part of warp song cutscenes in rando - if (IS_RANDO && this->actor.params == DEMOKANKYO_WARP_OUT) { - this->unk_150[i].unk_22 = 2; - } else { - this->unk_150[i].unk_22++; - } + this->unk_150[i].unk_22++; case 1: if (this->actor.params == DEMOKANKYO_WARP_OUT) { From a29ac454b588fa3a408026e6c306d48968ee35fb Mon Sep 17 00:00:00 2001 From: David Racine Date: Thu, 6 Aug 2026 01:30:11 -0400 Subject: [PATCH 20/20] Fire the ocarina song-action hook from the pause menu Playing a song from the quest screen never ran GameInteractor_ExecuteOnOcarinaSongAction(), which vanilla fires at the end of MSGMODE_SONG_PLAYED_ACT. Its only listener is Time Travel with Song of Time, so that enhancement silently did nothing for a Song of Time played from the pause menu while it worked from the real ocarina. Fire it from both pause-menu paths at the position vanilla uses: right after the warp prompt opens for warp songs, and after the in-scene hand-off for the rest. Time travel self-gates on there being no time block, staff spot, Door of Time, frogs or gossip stone nearby, so it cannot race the hand-off we just performed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F6N8EuNcBrfvM35N6fMG3t --- soh/soh/Enhancements/QoL/PauseMenuSongs.cpp | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp index b18365195e4..d0fbd5f5e12 100644 --- a/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp +++ b/soh/soh/Enhancements/QoL/PauseMenuSongs.cpp @@ -381,15 +381,16 @@ static void PauseSong_Execute() { if (gPlayState->msgCtx.msgMode == MSGMODE_PAUSED) { gPlayState->msgCtx.msgMode = MSGMODE_NONE; } - return; - } - - // Otherwise hand the song to a deferred actor: a Song of Time block / Great Fairy spawner (MODE_04), or - // a matching NPC (MODE_03). Either holds the mode for the actor to poll next frame; AdvancePending then - // finishes the hand-off and restores state. - if (!PauseSong_ActivateSongEventActors()) { + } else if (!PauseSong_ActivateSongEventActors()) { + // Otherwise hand the song to a deferred actor: a Song of Time block / Great Fairy spawner (MODE_04), + // or a matching NPC (MODE_03). Either holds the mode for the actor to poll next frame; AdvancePending + // then finishes the hand-off and restores state. PauseSong_ActivateNpcActors(); } + + // Vanilla ends a played song with this hook (z_message_PAL.c, MSGMODE_SONG_PLAYED_ACT), which is what + // Time Travel with Song of Time listens on. Warp songs run it from StartWarpPlayback instead. + GameInteractor_ExecuteOnOcarinaSongAction(); } // Start the pause-menu close/hand-off, the way vanilla does when a song is played from the quest screen. @@ -411,6 +412,8 @@ static void StartWarpPlayback(int song) { Message_StartTextbox(gPlayState, songMessageMap[idx], NULL); GET_PLAYER(gPlayState)->stateFlags1 |= PLAYER_STATE1_IN_CUTSCENE; isWarpActive = true; + // Same spot vanilla runs it in: right after the warp prompt opens (z_message_PAL.c, MSGMODE_SONG_PLAYED_ACT). + GameInteractor_ExecuteOnOcarinaSongAction(); } // Only play when Link is in a normal, grounded, controllable state -- the footing the vanilla ocarina