Merge campaign-hover-ui-round: fix #417 — silence world audio at the logout reset
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 18:46:04 +02:00
commit 0228876a8d
8 changed files with 104 additions and 2 deletions

View file

@ -24,6 +24,32 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #417 — World ambience keeps playing (and re-firing) on the character-select screen after the in-world logoff
**Status:** ✅ FIXED 2026-08-17 (logout-audio round; fix + tests in the same
commit as this entry). **Symptom:** log out to character select — the old
world's ambient noise continues. **Root cause:** the character-session
reset manifest had NO audio step at all. Retail's logoff destroys the
world's sound sources WITH the world, so character select is silent; our
OpenAL world pool and the ambient scheduler are process-lifetime, so after
the reset the playing voices (including the continuous ambient beds) ran
on, and `AmbientSoundController.Tick` kept RE-FIRING deadlines against the
stale listener (the region stays installed and nothing suspends the
controller — `Suspend`/`StopAll` had zero callers outside the class; the
existing `WorldGenerationQuiescence` suspend only cycles around generation
REPLACES, i.e. teleports, never the logout reset). **Fix:** a new
`WorldAudioSessionGate` (engine `SuspendWorldAudio` — stops all sixteen
world-pool voices and gates new plays — plus ambient `StopAll` — drops
every deadline; the soundscape rebuilds on the next objcell observation
exactly as a cell change always did) wired as the reset manifest's new
"world audio" step, with the pool reopened at the entered-world edge via
the new `LiveSessionEnteredWorldBindings.ResumeWorldAudio` binding
(default-null, headless-safe), invoked FIRST in `ApplyEnteredWorld` so no
entered-world callback can emit into a closed pool. Covers logout,
reconnect, and full-stop uniformly (all run the same manifest). UI-pool
sounds (interface bank, portal cues) are untouched by design — retail's
logoff plays its cue through the same interface path.
## #416 — Character-select roster hover highlight never clears (sweeping the roster leaves every row highlighted) ## #416 — Character-select roster hover highlight never clears (sweeping the roster leaves every row highlighted)
**Status:** ✅ FIXED 2026-08-17 (same round as #414; fix + tests in the same **Status:** ✅ FIXED 2026-08-17 (same round as #414; fix + tests in the same

View file

@ -0,0 +1,39 @@
namespace AcDream.App.Audio;
/// <summary>
/// Logout-audio round (2026-08-17): the character-session reset's world-audio
/// teardown. Retail's logoff destroys every world sound source WITH the world
/// (<c>CPlayerSystem::ExecuteLogOff @ 0x0055D780</c> world teardown — the
/// DirectSound buffers die with their owners), so the character-select screen
/// is silent. acdream's OpenAL voices and the ambient scheduler are
/// process-lifetime, so the session reset must stop them explicitly:
/// <see cref="SuspendForSessionReset"/> stops all sixteen world-pool voices
/// (the continuous ambient beds included) and drops every ambient deadline so
/// nothing re-fires against the stale listener at character select. The pool
/// re-opens at the next entered-world edge
/// (<c>LiveSessionEnteredWorldBindings.ResumeWorldAudio</c>); the ambient
/// soundscape needs no explicit resume — the next
/// <c>AmbientSoundController.ObserveListener</c> objcell change rebuilds it,
/// exactly as a cell change always does.
/// </summary>
public sealed class WorldAudioSessionGate
{
private readonly OpenAlAudioEngine _engine;
private readonly AmbientSoundController? _ambient;
public WorldAudioSessionGate(
OpenAlAudioEngine engine,
AmbientSoundController? ambient)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_ambient = ambient;
}
public void SuspendForSessionReset()
{
_engine.SuspendWorldAudio();
_ambient?.StopAll();
}
public void ResumeForWorldEntry() => _engine.ResumeWorldAudio();
}

View file

@ -1118,6 +1118,11 @@ internal sealed class SessionPlayerCompositionPhase
live.SelectionInteractions), live.SelectionInteractions),
new LiveSessionWorldRuntime( new LiveSessionWorldRuntime(
content.Dats, content.Dats,
content.Audio?.Engine is { } sessionAudioEngine
? new AcDream.App.Audio.WorldAudioSessionGate(
sessionAudioEngine,
content.Audio.Ambient)
: null,
live.WorldState, live.WorldState,
live.LiveEntities, live.LiveEntities,
sessionEvents, sessionEvents,

View file

@ -14,6 +14,7 @@ internal sealed class LiveSessionResetBindings
public required Action MouseCapture { get; init; } public required Action MouseCapture { get; init; }
public required Action PlayerPresentation { get; init; } public required Action PlayerPresentation { get; init; }
public required Action TeleportPresentation { get; init; } public required Action TeleportPresentation { get; init; }
public required Action WorldAudio { get; init; }
public required Action SessionDialogs { get; init; } public required Action SessionDialogs { get; init; }
public required Action SettingsCharacterContext { get; init; } public required Action SettingsCharacterContext { get; init; }
public required Action EquippedChildren { get; init; } public required Action EquippedChildren { get; init; }
@ -48,6 +49,14 @@ internal static class LiveSessionResetManifest
new("mouse capture", bindings.MouseCapture), new("mouse capture", bindings.MouseCapture),
new("player presentation", bindings.PlayerPresentation), new("player presentation", bindings.PlayerPresentation),
new("teleport presentation", bindings.TeleportPresentation), new("teleport presentation", bindings.TeleportPresentation),
// Logout-audio round (2026-08-17): retail's logoff destroys the
// world's sound sources with the world; ours must stop the
// sixteen world-pool voices (continuous ambient beds included)
// and drop the ambient deadlines, or the character-select screen
// keeps playing — and re-firing — the old world's ambience. The
// pool reopens at the next entered-world edge
// (LiveSessionEnteredWorldBindings.ResumeWorldAudio).
new("world audio", bindings.WorldAudio),
new("session dialogs", bindings.SessionDialogs), new("session dialogs", bindings.SessionDialogs),
new("settings character context", bindings.SettingsCharacterContext), new("settings character context", bindings.SettingsCharacterContext),
// Attachment projections own GL-backed registrations and must leave // Attachment projections own GL-backed registrations and must leave

View file

@ -67,6 +67,10 @@ internal sealed record LiveSessionInteractionRuntime(
internal sealed record LiveSessionWorldRuntime( internal sealed record LiveSessionWorldRuntime(
IDatReaderWriter Dats, IDatReaderWriter Dats,
// Logout-audio round (2026-08-17): null only when audio is disabled
// (ACDREAM_NO_AUDIO / init failure) — the reset step and entered-world
// resume both no-op then.
Audio.WorldAudioSessionGate? WorldAudio,
GpuWorldState WorldState, GpuWorldState WorldState,
LiveEntityRuntime LiveEntities, LiveEntityRuntime LiveEntities,
LiveEntitySessionController EntitySession, LiveEntitySessionController EntitySession,
@ -210,7 +214,11 @@ internal sealed class LiveSessionRuntimeFactory
}, },
SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(), SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(),
LoadCharacterSettings: _interaction.Settings.LoadCharacterContext, LoadCharacterSettings: _interaction.Settings.LoadCharacterContext,
ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm), ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm,
// Logout-audio round (2026-08-17): reopen the world-audio
// pool the session reset closed (see the reset manifest's
// "world audio" step).
ResumeWorldAudio: () => _world.WorldAudio?.ResumeForWorldEntry()),
Connecting: (host, port, user) => Connecting: (host, port, user) =>
_domain.Communication.Chat.OnSystemMessage( _domain.Communication.Chat.OnSystemMessage(
$"connecting to {host}:{port} as {user}", $"connecting to {host}:{port} as {user}",
@ -251,6 +259,7 @@ internal sealed class LiveSessionRuntimeFactory
PlayerPresentation = ResetPlayerPresentation, PlayerPresentation = ResetPlayerPresentation,
TeleportPresentation = TeleportPresentation =
_world.Teleport.ResetGenerationPresentation, _world.Teleport.ResetGenerationPresentation,
WorldAudio = () => _world.WorldAudio?.SuspendForSessionReset(),
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(), SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
SettingsCharacterContext = SettingsCharacterContext =
_interaction.Settings.RestoreDefaultCharacterContext, _interaction.Settings.RestoreDefaultCharacterContext,

View file

@ -30,7 +30,13 @@ public sealed record LiveSessionEnteredWorldBindings(
Action RestoreLayout, Action RestoreLayout,
Action SyncToolbar, Action SyncToolbar,
Action<string> LoadCharacterSettings, Action<string> LoadCharacterSettings,
Action ArmPlayerModeAutoEntry); Action ArmPlayerModeAutoEntry,
/// <summary>Logout-audio round (2026-08-17): re-enables world-pool audio
/// after the session reset's suspend (retail's logoff tears the world's
/// sound sources down with the world; ours must re-open the pool at the
/// next world entry). Default no-op preserves headless and existing
/// construction sites.</summary>
Action? ResumeWorldAudio = null);
public sealed record LiveSessionHostBindings( public sealed record LiveSessionHostBindings(
LiveSessionRoutingFactories Routing, LiveSessionRoutingFactories Routing,
@ -269,6 +275,11 @@ public sealed class LiveSessionHost
private void ApplyEnteredWorld(LiveSessionCharacterSelection selection) private void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
{ {
string name = selection.CharacterName; string name = selection.CharacterName;
// FIRST: the world-audio pool must be open before any entered-world
// callback can emit a sound (retail never closes its pool across a
// logoff — the sources die with the world — so the reopened pool is
// the earliest faithful moment).
_enteredWorld.ResumeWorldAudio?.Invoke();
_enteredWorld.SetActiveCharacter(name); _enteredWorld.SetActiveCharacter(name);
_enteredWorld.RestoreLayout(); _enteredWorld.RestoreLayout();
_enteredWorld.SyncToolbar(); _enteredWorld.SyncToolbar();

View file

@ -210,6 +210,7 @@ public sealed class LiveSessionResetPlanTests
MouseCapture = Stage("mouse capture"), MouseCapture = Stage("mouse capture"),
PlayerPresentation = Stage("player presentation"), PlayerPresentation = Stage("player presentation"),
TeleportPresentation = Stage("teleport presentation"), TeleportPresentation = Stage("teleport presentation"),
WorldAudio = Stage("world audio"),
SessionDialogs = Stage("session dialogs"), SessionDialogs = Stage("session dialogs"),
SettingsCharacterContext = Stage("settings character context"), SettingsCharacterContext = Stage("settings character context"),
EquippedChildren = Stage("equipped children"), EquippedChildren = Stage("equipped children"),
@ -295,6 +296,7 @@ public sealed class LiveSessionResetPlanTests
"mouse capture", "mouse capture",
"player presentation", "player presentation",
"teleport presentation", "teleport presentation",
"world audio",
"session dialogs", "session dialogs",
"settings character context", "settings character context",
"equipped children", "equipped children",

View file

@ -1019,6 +1019,7 @@ public sealed class CurrentGameRuntimeAdapterTests
MouseCapture = noop, MouseCapture = noop,
PlayerPresentation = noop, PlayerPresentation = noop,
TeleportPresentation = noop, TeleportPresentation = noop,
WorldAudio = noop,
SessionDialogs = noop, SessionDialogs = noop,
SettingsCharacterContext = noop, SettingsCharacterContext = noop,
EquippedChildren = noop, EquippedChildren = noop,