port(audio): D202 sndp fixes — ownerless-loop guard (M-65), sndCreatePostEvent unstub, option-C loop expiration (M-66b)

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.
This commit is contained in:
James Dansereau committed 2026-09-05 19:07:32 -04:00
1 parent 1087820bc3
commit 388d932df7
2 files changed
+265 -15

No files matched your search

+257 -15
View File
@@ -3,6 +3,10 @@
#include <os_extension.h>
#include "music.h"
#include "snd.h"
#ifdef PORT
#include <stdio.h>
#include <stdlib.h>
#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
}
/**
+8
View File
@@ -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 {