From 388d932df79fae24d1244682c269eeff05a1bf58 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:07:32 -0400 Subject: [PATCH] =?UTF-8?q?port(audio):=20D202=20sndp=20fixes=20=E2=80=94?= =?UTF-8?q?=20ownerless-loop=20guard=20(M-65),=20sndCreatePostEvent=20unst?= =?UTF-8?q?ub,=20option-C=20loop=20expiration=20(M-66b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three #ifdef PORT changes in src/snd.c (N64 build zero-diff), all documented in findings.md D202: 1. M-65 voice-leak guard: the preemption scan reclaims provably-ownerless SOUND_FLAG_LOOPED SFX voices only, and only under pool pressure. Sound 203 (METAL_SLIDE_CLOSE_SFX) is played fire-and-forget by doorPlayCloseSound0/1 with a NULL owner; stock GE never preempts flag-0x12 voices, so leaked loops accumulate until new SFX are dropped. 2. sndCreatePostEvent un-stub (D138 stub removed): the stub had deleted ALL distance attenuation from SFX volume/pan. Verified rt=0.996, drop 0. 3. M-66b option-C expiration (user-chosen disposition for D202): new PC-only AL_SNDP_PORT_EXPIRE_EVT (reuses the unused 1<<13 slot in snd.h). After a voice starts, if it is provably ownerless (LOOPED + FINAL_IN_SEQUENCE + not RETRIGGER + state->state == NULL) and its wave carries an ADPCM loop with count == -1, post the expire at D202_EXPIRE_DELAY_US (2 s); the handler re-validates the predicate, fades volume to 0 over D202_EXPIRE_FADE_US (500 ms), and posts END_EVT. Not a reused STOP_EVT: its ramp uses the envelope releaseTime (~2.7 ms for sound 203) and would hard-cut/click. Stale-event safe because sndDisposeSound removes ALL pending events for a state; owned loops and retrigger sounds untouched. Placed in sndp (not port/src/mixer.c) because ownership is only visible there. Measured: pre-fix capture had the 256.7 Hz loop line flat at max t=39->361 s; post-fix run shows transient bursts with full silence between and ends allocated=0/8. --- src/snd.c | 272 +++++++++++++++++++++++++++++++++++++++++++++++++++--- src/snd.h | 8 ++ 2 files changed, 265 insertions(+), 15 deletions(-) diff --git a/src/snd.c b/src/snd.c index cfa54a29..9ffa1ec8 100644 --- a/src/snd.c +++ b/src/snd.c @@ -3,6 +3,10 @@ #include #include "music.h" #include "snd.h" +#ifdef PORT +#include +#include +#endif //likely named gslibaudio.c from xbla /** * EU .data, offset from start of data_seg : 0x3620 @@ -15,6 +19,19 @@ #define DEFAULT_SETUP_PITCH_SHIFT (-0x1770) +#ifdef PORT +/* D202/M-66 (PC port only): ownerless infinite-loop SFX voices (sound 203, + * METAL_SLIDE_CLOSE_SFX, is the one in the wild) can never be stopped by the + * game -- played fire-and-forget, looped voices skip the decay->stop chain, + * and stock preemption never touches flag-0x12 voices -- so on N64 they ring + * until level exit. On PC we let them play for D202_EXPIRE_DELAY_US, then fade + * over D202_EXPIRE_FADE_US (the stock STOP ramp uses the envelope releaseTime, + * a few ms here, which would hard-cut the loop). ALMicroTime = microseconds. + * See docs/dev/findings.md D202, M-66. */ +#define D202_EXPIRE_DELAY_US 2000000 +#define D202_EXPIRE_FADE_US 500000 +#endif + /** * Based on \n64devkit\ultra\usr\src\pr\libsrc\libultra\audio\sndp.h * ALSndpEvent @@ -318,7 +335,27 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) { ALSoundState *iterState = (ALSoundState *) D_800243E4.node.prev; do { - if (!(iterState->unk3e & 0x12) && (iterState->unk3e & 0x4) && + /* D202/M-65 (PORT, experimental): the stock scan refuses to + * preempt any looped or retriggering voice (0x12). That is + * right for a voice somebody still owns -- its owner will + * sndDeactivate it. But a SOUND_FLAG_LOOPED voice whose + * state->state is NULL was started with pendingState == NULL + * (doorPlayCloseSound0/1 do exactly this for + * METAL_SLIDE_CLOSE_SFX, whose envelope decayTime is -1), so + * no owner exists, no STOP_EVT is ever posted for it, and it + * holds one of the 8 voices until level exit. Measured: 7 of + * 8 voices held by that one sound after 115 s of Bunker + * attract. Allow reclaiming ONLY that provably-unstoppable + * case; owned loops are still protected exactly as before. + * Companion (M-66, option C): the AL_SNDP_PORT_EXPIRE_EVT + * post in PLAY_EVT fades such voices out after a bounded + * time; this guard only reclaims their SLOTS under pool + * pressure so new sounds can still start. */ + s32 ownerlessLoop = (iterState->unk3e & SOUND_FLAG_LOOPED) && + !(iterState->unk3e & SOUND_FLAG_RETRIGGER) && + iterState->state == NULL; + if ((!(iterState->unk3e & 0x12) || ownerlessLoop) && + (iterState->unk3e & 0x4) && iterState->playingState != SOUND_STATE_PREEMPT) { // Found a lower-priority sound; it can be preempted ALSndpEvent interruptEvent; @@ -358,6 +395,51 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) { alSynStartVoice(sndp->drvr, &soundState->voice, sound->wavetable); soundState->playingState = SOUND_STATE_PLAYING; g_sndAllocatedVoicesCount++; +#ifdef PORT + /* D202/M-65 diag (temporary): pair every voice acquire with + * its release to find which sounds hold the 8 voices for + * good. Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + static FILE *fv = NULL; + if (!fv) { fv = fopen("audiotrace.log", "a"); if (fv) setvbuf(fv, NULL, _IONBF, 0); } + if (fv) fprintf(fv, "[VOICE+] sound=%p state=%p flags=%d count=%d\n", + (void *)sound, (void *)soundState, (int)soundState->unk3e, + (int)g_sndAllocatedVoicesCount); + } +#endif +#ifdef PORT + /* D202/M-66 (PORT deviation, option C): schedule the fade-out + * for a provably ownerless infinite-loop voice. Predicate: + * LOOPED (decayTime == -1) and FINAL_IN_SEQUENCE (single sound, + * so the handler's sequence walk stops at this state), not + * RETRIGGER (those are managed by PLAY_SFX/DEACTIVATE chains), + * no owner (state->state == NULL: doorPlayCloseSound0/1 plays + * with a NULL owner and nothing ever sndDeactivates it), and the + * wave itself carries an ADPCM loop with count == -1 (finite + * loops self-terminate in load.c and must not be cut short). + * Stale-event safety: any dispose of this state (preemption, + * natural end, deactivate) runs sndDisposeSound, which removes + * ALL pending events for it (sndRemoveEvents ... 0xffff), and a + * rebind always disposes first -- so this event can never fire + * against a new binding. */ + if ((soundState->unk3e & (SOUND_FLAG_LOOPED | SOUND_FLAG_RETRIGGER | SOUND_FLAG_FINAL_IN_SEQUENCE)) + == (SOUND_FLAG_LOOPED | SOUND_FLAG_FINAL_IN_SEQUENCE) && + soundState->state == NULL && + sound->wavetable != NULL && sound->wavetable->type == AL_ADPCM_WAVE && + sound->wavetable->waveInfo.adpcmWave.loop != NULL && + sound->wavetable->waveInfo.adpcmWave.loop->count == -1) { + ALSndpEvent expireEvt; + expireEvt.common.type = AL_SNDP_PORT_EXPIRE_EVT; + expireEvt.common.state = soundState; + alEvtqPostEvent(&sndp->evtq, (ALEvent *) &expireEvt, D202_EXPIRE_DELAY_US); + if (getenv("GE_AUDIOTRACE")) { + static FILE *fx = NULL; + if (!fx) { fx = fopen("audiotrace.log", "a"); if (fx) setvbuf(fx, NULL, _IONBF, 0); } + if (fx) fprintf(fx, "[EXPIRE] sound=%p state=%p delay=%dus fade=%dus (ownerless infinite loop; D202/M-66)\n", + (void *)sound, (void *)soundState, (int)D202_EXPIRE_DELAY_US, (int)D202_EXPIRE_FADE_US); + } + } +#endif delta = sound->envelope->attackTime / soundState->pitch_2c / soundState->pitch_28; volume = @@ -504,13 +586,41 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) { sndPlaySfx(event->playSfx.soundBank, event->playSfx.soundIndex, soundState->state); } break; +#ifdef PORT + case AL_SNDP_PORT_EXPIRE_EVT: + /* D202/M-66 (PORT deviation, option C): fade out an ownerless + * infinite-loop SFX voice. Re-validate the full predicate at + * fire time: if the state was disposed in the interim this + * event was already removed (sndDisposeSound); if it was + * rebound, the new binding fails the predicate and we no-op. + * Mirrors the stock STOP_EVT ramp-to-zero + END_EVT pattern, + * with a fixed fade length (see D202_EXPIRE_FADE_US). */ + if (soundState->playingState == SOUND_STATE_PLAYING && + soundState->state == NULL && + (soundState->unk3e & (SOUND_FLAG_LOOPED | SOUND_FLAG_RETRIGGER | SOUND_FLAG_FINAL_IN_SEQUENCE)) + == (SOUND_FLAG_LOOPED | SOUND_FLAG_FINAL_IN_SEQUENCE) && + sound->wavetable != NULL && sound->wavetable->type == AL_ADPCM_WAVE && + sound->wavetable->waveInfo.adpcmWave.loop != NULL && + sound->wavetable->waveInfo.adpcmWave.loop->count == -1) { + alSynSetVol(sndp->drvr, &soundState->voice, 0, D202_EXPIRE_FADE_US); + spAC.common.type = AL_SNDP_END_EVT; + spAC.common.state = soundState; + alEvtqPostEvent(&sndp->evtq, (ALEvent *) &spAC, D202_EXPIRE_FADE_US); + soundState->playingState = SOUND_STATE_STOPPING; + } + break; +#endif default: break; } soundState = nextState; isEventForSingleSound = event->common.type & (AL_SNDP_PLAY_EVT | AL_SNDP_PITCH_EVT | AL_SNDP_DECAY_EVT | - AL_SNDP_END_EVT | AL_SNDP_PLAY_SFX_EVT); + AL_SNDP_END_EVT | AL_SNDP_PLAY_SFX_EVT +#ifdef PORT + | AL_SNDP_PORT_EXPIRE_EVT /* D202/M-66: never sequence-walk */ +#endif + ); if (soundState != NULL && !isEventForSingleSound) { lastInSequence = soundState->unk3e & SOUND_FLAG_FINAL_IN_SEQUENCE; @@ -746,6 +856,15 @@ void sndUnlinkClearSound(ALSoundState *state) if ((state->unk3e & 4) != 0) { g_sndAllocatedVoicesCount--; +#ifdef PORT + if (getenv("GE_AUDIOTRACE")) { /* D202/M-65 diag; remove once root-caused */ + static FILE *fw = NULL; + if (!fw) { fw = fopen("audiotrace.log", "a"); if (fw) setvbuf(fw, NULL, _IONBF, 0); } + if (fw) fprintf(fw, "[VOICE-] sound=%p state=%p flags=%d count=%d\n", + (void *)state->sound, (void *)state, (int)state->unk3e, + (int)g_sndAllocatedVoicesCount); + } +#endif } state->playingState = AL_STOPPED; @@ -857,6 +976,25 @@ ALSoundState *sndPlaySfx(struct ALBankAlt_s *soundBank, s16 soundIndex, ALSoundS sound = (soundBank->instArray[0]->soundArray[soundIndex]); #ifdef PORT + /* D202 diag probe (temporary): trace which soundIndex resolves to + * which ALSound*, to root-cause the M-52 "wrong sample plays" + * playtest report. Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + static FILE *f = NULL; + extern unsigned long long audioDumpBytePos(void); + if (!f) { f = fopen("audiotrace.log", "a"); if (f) setvbuf(f, NULL, _IONBF, 0); } + if (f) fprintf(f, "[AUDIOTRACE] dumppos=%llu sndPlaySfx: bank=%p soundIndex=%d -> sound=%p keyMap=%p wavetable=%p base=%p len=%d type=%d flags=%d book=%p\n", + (unsigned long long)audioDumpBytePos(), + (void *)soundBank, (int)soundIndex, (void *)sound, + sound ? (void *)sound->keyMap : NULL, + (sound && sound->wavetable) ? (void *)sound->wavetable : NULL, + (sound && sound->wavetable) ? (void *)sound->wavetable->base : NULL, + (sound && sound->wavetable) ? sound->wavetable->len : -1, + (sound && sound->wavetable) ? (int)sound->wavetable->type : -1, + (sound && sound->wavetable) ? (int)sound->wavetable->flags : -1, + (sound && sound->wavetable && sound->wavetable->type == AL_ADPCM_WAVE) ? + (void *)sound->wavetable->waveInfo.adpcmWave.book : NULL); + } /* C7 / D127: the libaudio subsystem is Phase-3 parked (no real bank * playback yet). On some levels (Surface1, -level_36) a requested * soundIndex resolves to a bogus ALSound* -- the converted bank tree @@ -877,6 +1015,70 @@ ALSoundState *sndPlaySfx(struct ALBankAlt_s *soundBank, s16 soundIndex, ALSoundS newState = sndSetupSound(soundBank, sound); +#ifdef PORT + /* D202 diag probe (temporary): log the ALSoundState* handed back per + * soundIndex so a later sndDeactivate trace can be correlated back + * to "which chain link was this". Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + static FILE *f2 = NULL; + ALKeyMap *kmp = sound ? sound->keyMap : NULL; + if (!f2) { f2 = fopen("audiotrace.log", "a"); if (f2) setvbuf(f2, NULL, _IONBF, 0); } + /* D202/M-65: the retrigger machinery is what the user's three + * symptoms all route through, and none of it was traced before. + * Rare repurposed ALKeyMap: velocityMax is the retrigger PERIOD + * in 33ms ticks, velocityMin is the NEXT soundIndex in the chain + * this call is walking, and keyMax>>4 seeds unk3e (bit 0x10 = + * SOUND_FLAG_RETRIGGER = "replay me forever until deactivated"). + * Bad values here produce, in order: wrong sound (bad chain + * link), several sounds at once (chain too long), and a sound + * that loops until level exit (period too short / flag stuck). */ + /* D202/M-65: flags bit 1 (SOUND_FLAG_LOOPED) is derived purely + * from envelope->decayTime == -1, and a looped sound never posts + * AL_SNDP_STOP_EVT -- it holds its voice until something + * deactivates it. Print the envelope so a mis-assigned envelope + * pointer can be told from genuine ROM data. */ + /* D202/M-65: an infinite ENVELOPE decay only stops the STOP_EVT + * from being posted -- it does not by itself make a sample + * repeat. If the WAVE also carries an ADPCM loop with count -1 + * the sound is audibly endless; if it has no loop, the sample + * should run out and go silent, and anything still audible is a + * port-side mixer fault rather than a lifecycle one. */ + if (f2 && sound && sound->wavetable && sound->wavetable->type == AL_ADPCM_WAVE) { + ALADPCMloop *lp = sound->wavetable->waveInfo.adpcmWave.loop; + fprintf(f2, "[WAVELOOP] soundIndex=%d wave=%p len=%d loop=%p start=%d end=%d count=%d\n", + (int)soundIndex, (void *)sound->wavetable, (int)sound->wavetable->len, + (void *)lp, + lp ? (int)lp->start : -1, lp ? (int)lp->end : -1, + lp ? (int)lp->count : -1); + } + if (f2 && sound && sound->envelope) + fprintf(f2, "[ENVELOPE] soundIndex=%d env=%p attack=%d decay=%d release=%d aVol=%u dVol=%u\n", + (int)soundIndex, (void *)sound->envelope, + (int)sound->envelope->attackTime, + (int)sound->envelope->decayTime, + (int)sound->envelope->releaseTime, + (unsigned)sound->envelope->attackVolume, + (unsigned)sound->envelope->decayVolume); + if (f2) fprintf(f2, "[AUDIOTRACE] sndPlaySfx: soundIndex=%d -> newState=%p flags=%d " + "keyMap=%p velMin(next)=%d velMax(period)=%d keyMin=0x%02x keyMax=0x%02x " + "keyBase=%d detune=%d retrig=%d deltaLoopUs=%d\n", + (int)soundIndex, (void *)newState, newState ? (int)newState->unk3e : -1, + (void *)kmp, + kmp ? (int)kmp->velocityMin : -1, kmp ? (int)kmp->velocityMax : -1, + kmp ? (unsigned)kmp->keyMin : 0u, kmp ? (unsigned)kmp->keyMax : 0u, + kmp ? (int)kmp->keyBase : -1, kmp ? (int)kmp->detune : -1, + (newState && (newState->unk3e & 0x10)) ? 1 : 0, + kmp ? (int)(kmp->velocityMax * DELTA_33_MS) : -1); + /* D202/M-65: only 8 voices exist (MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS). + * A SOUND_FLAG_LOOPED voice never self-releases and is skipped by + * the preemption scan, so each leaked one permanently costs a + * voice. Once this hits maxSounds nothing new can ever sound. */ + if (f2) fprintf(f2, "[VOICES] allocated=%d / max=%d\n", + (int)g_sndAllocatedVoicesCount, + (int)g_sndPlayerPtr->maxSounds); + } +#endif + if (newState != NULL) { ALSndpEvent playEvent; @@ -933,12 +1135,41 @@ ALSoundState *sndPlaySfx(struct ALBankAlt_s *soundBank, s16 soundIndex, ALSoundS playSfxEvent.playSfx.soundIndex = eventSoundIndex; // types dont match playSfxEvent.playSfx.soundBank = soundBank; +#ifdef PORT + /* D202/M-65 diag (temporary): this is the self-retrigger post -- + * the sound schedules itself to play again in playSfxDelta us, + * forever, until sndDeactivate clears bit 0x10. A stuck looping + * sound IS this event never stopping; a "piling up" mix is this + * period being far too short. Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + static FILE *f4 = NULL; + if (!f4) { f4 = fopen("audiotrace.log", "a"); if (f4) setvbuf(f4, NULL, _IONBF, 0); } + if (f4) fprintf(f4, "[AUDIOTRACE] RETRIGGER-POST: state=%p soundIndex=%d delayUs=%d (%.1f ms)\n", + (void *)nextState, (int)eventSoundIndex, (int)playSfxDelta, + (double)playSfxDelta / 1000.0); + } +#endif alEvtqPostEvent(&g_sndPlayerPtr->evtq, (ALEvent *)&playSfxEvent, playSfxDelta); } } if (pendingState != NULL) { +#ifdef PORT + /* D202/M-65 diag (temporary): callers like doorPlayOpenSound0 pass + * &door->openSoundState here (an ALSoundState** punned as an + * ALSoundState*), relying on ALLink.next sitting at offset 0. This + * write IS how a looping SFX gets an owner that can later stop it. + * Log it so "slot never written" can be told apart from "slot + * written then cleared". Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + static FILE *f5 = NULL; + if (!f5) { f5 = fopen("audiotrace.log", "a"); if (f5) setvbuf(f5, NULL, _IONBF, 0); } + if (f5) fprintf(f5, "[SLOTWRITE] slot=%p <- state=%p (was %p)\n", + (void *)pendingState, (void *)nextState, + (void *)pendingState->link.next); + } +#endif pendingState->link.next = (void*)nextState; } @@ -957,6 +1188,18 @@ void sndDeactivate(ALSoundState *state) evt.common.type = AL_SNDP_DEACTIVATE_EVT; evt.common.state = state; +#ifdef PORT + /* D202 diag probe (temporary): does this ever fire for the stuck + * door-loop voice? Correlate against the sndPlaySfx newState= trace. + * Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + static FILE *f3 = NULL; + if (!f3) { f3 = fopen("audiotrace.log", "a"); if (f3) setvbuf(f3, NULL, _IONBF, 0); } + if (f3) fprintf(f3, "[AUDIOTRACE] sndDeactivate: state=%p (%s)\n", + (void *)state, state ? "non-null" : "NULL-noop"); + } +#endif + if (state != NULL) { state->unk3e = (s8) (state->unk3e & (~(s16)(0x10))); @@ -1037,18 +1280,18 @@ void sndCreatePostEvent(ALSoundState *state, s16 eventType, s32 arg2) { ALSndpEvent evt; -#ifdef PORT - /* D138: libaudio is Phase-3 parked. The audio thread never drains - * g_sndPlayerPtr->evtq->allocList, so every positional-sound tick - * (chrobjSndCreatePostEvent, called per objTick for each ambient-sound - * object) appends an item and alEvtqPostEvent walks the whole list to - * insert it -> O(n^2). Facility has enough machinery ambience that after - * a few thousand frames one insert takes seconds and the kernel-heartbeat - * watchdog trips ("no frame rendered"). Skip event posting until audio - * lands (D77); no gameplay effect. */ - (void) evt; (void) eventType; (void) arg2; (void) state; - return; -#else + /* D202/M-65: this was stubbed out on PORT by D138, back when libaudio was + * Phase-3 parked and nothing drained g_sndPlayerPtr->evtq->allocList -- + * every positional-sound tick appended an item that was never consumed, + * so alEvtqPostEvent's ordered insert walked an ever-growing list (O(n^2)) + * until the kernel-heartbeat watchdog tripped on Facility's ambience. + * That premise expired when the Phase-3 audio thread landed (D198-D201): + * amMain now drains the queue every audio frame, so the list stays short. + * + * Leaving the stub in place is not neutral. Every caller posts type 8 + * (AL_SNDP_VOL_EVT), so the stub silently removed ALL distance-based + * volume attenuation, and with it the only per-tick contact the engine + * has with a playing voice. See docs/dev/findings.md D202/M-65. */ evt.common.type = eventType; evt.common.state = state; evt.unks32.val8 = arg2; @@ -1057,7 +1300,6 @@ void sndCreatePostEvent(ALSoundState *state, s16 eventType, s32 arg2) { alEvtqPostEvent(&g_sndPlayerPtr->evtq, (ALEvent *)&evt, 0); } -#endif } /** diff --git a/src/snd.h b/src/snd.h index 46899b38..e25de90e 100644 --- a/src/snd.h +++ b/src/snd.h @@ -54,6 +54,14 @@ typedef enum ALSndpMsgType_e { AL_SNDP_UNUSED_15_EVT = (1 << 15) // defined here for 16 bit completion } ALSndpMsgType; +#ifdef PORT +/* D202/M-66 (PC port only): reuses a slot marked unused above. sndp posts one + * of these for ownerless infinite-loop SFX voices so they fade out after a + * bounded time instead of ringing until level exit (the faithful N64 + * behavior). See docs/dev/findings.md D202, M-66. */ +#define AL_SNDP_PORT_EXPIRE_EVT AL_SNDP_UNUSED_13_EVT +#endif + // based on n64devkit\ultra\usr\src\pr\libsrc\libultra\audio\sndp.h struct ALSoundState_s; typedef struct ALSoundState_s {