diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index de358652..0fb401bf 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -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.
- 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)
**Status:** ✅ FIXED 2026-08-17 (same round as #414; fix + tests in the same
diff --git a/src/AcDream.App/Audio/WorldAudioSessionGate.cs b/src/AcDream.App/Audio/WorldAudioSessionGate.cs
new file mode 100644
index 00000000..3044f5cb
--- /dev/null
+++ b/src/AcDream.App/Audio/WorldAudioSessionGate.cs
@@ -0,0 +1,39 @@
+namespace AcDream.App.Audio;
+
+///
+/// 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
+/// (CPlayerSystem::ExecuteLogOff @ 0x0055D780 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:
+/// 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
+/// (LiveSessionEnteredWorldBindings.ResumeWorldAudio); the ambient
+/// soundscape needs no explicit resume — the next
+/// AmbientSoundController.ObserveListener objcell change rebuilds it,
+/// exactly as a cell change always does.
+///
+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();
+}
diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs
index 48cc2cfe..1baac0b7 100644
--- a/src/AcDream.App/Composition/SessionPlayerComposition.cs
+++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs
@@ -1118,6 +1118,11 @@ internal sealed class SessionPlayerCompositionPhase
live.SelectionInteractions),
new LiveSessionWorldRuntime(
content.Dats,
+ content.Audio?.Engine is { } sessionAudioEngine
+ ? new AcDream.App.Audio.WorldAudioSessionGate(
+ sessionAudioEngine,
+ content.Audio.Ambient)
+ : null,
live.WorldState,
live.LiveEntities,
sessionEvents,
diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs
index 2540d1c3..c9aafa9a 100644
--- a/src/AcDream.App/Net/LiveSessionResetManifest.cs
+++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs
@@ -14,6 +14,7 @@ internal sealed class LiveSessionResetBindings
public required Action MouseCapture { get; init; }
public required Action PlayerPresentation { get; init; }
public required Action TeleportPresentation { get; init; }
+ public required Action WorldAudio { get; init; }
public required Action SessionDialogs { get; init; }
public required Action SettingsCharacterContext { get; init; }
public required Action EquippedChildren { get; init; }
@@ -48,6 +49,14 @@ internal static class LiveSessionResetManifest
new("mouse capture", bindings.MouseCapture),
new("player presentation", bindings.PlayerPresentation),
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("settings character context", bindings.SettingsCharacterContext),
// Attachment projections own GL-backed registrations and must leave
diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
index a743c41d..f5af448e 100644
--- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
+++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
@@ -67,6 +67,10 @@ internal sealed record LiveSessionInteractionRuntime(
internal sealed record LiveSessionWorldRuntime(
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,
LiveEntityRuntime LiveEntities,
LiveEntitySessionController EntitySession,
@@ -210,7 +214,11 @@ internal sealed class LiveSessionRuntimeFactory
},
SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(),
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) =>
_domain.Communication.Chat.OnSystemMessage(
$"connecting to {host}:{port} as {user}",
@@ -251,6 +259,7 @@ internal sealed class LiveSessionRuntimeFactory
PlayerPresentation = ResetPlayerPresentation,
TeleportPresentation =
_world.Teleport.ResetGenerationPresentation,
+ WorldAudio = () => _world.WorldAudio?.SuspendForSessionReset(),
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
SettingsCharacterContext =
_interaction.Settings.RestoreDefaultCharacterContext,
diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs
index 66194e77..72e52bd2 100644
--- a/src/AcDream.Runtime/Session/LiveSessionHost.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs
@@ -30,7 +30,13 @@ public sealed record LiveSessionEnteredWorldBindings(
Action RestoreLayout,
Action SyncToolbar,
Action LoadCharacterSettings,
- Action ArmPlayerModeAutoEntry);
+ Action ArmPlayerModeAutoEntry,
+ /// 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.
+ Action? ResumeWorldAudio = null);
public sealed record LiveSessionHostBindings(
LiveSessionRoutingFactories Routing,
@@ -269,6 +275,11 @@ public sealed class LiveSessionHost
private void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
{
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.RestoreLayout();
_enteredWorld.SyncToolbar();
diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
index b9235a49..478933dd 100644
--- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
+++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
@@ -210,6 +210,7 @@ public sealed class LiveSessionResetPlanTests
MouseCapture = Stage("mouse capture"),
PlayerPresentation = Stage("player presentation"),
TeleportPresentation = Stage("teleport presentation"),
+ WorldAudio = Stage("world audio"),
SessionDialogs = Stage("session dialogs"),
SettingsCharacterContext = Stage("settings character context"),
EquippedChildren = Stage("equipped children"),
@@ -295,6 +296,7 @@ public sealed class LiveSessionResetPlanTests
"mouse capture",
"player presentation",
"teleport presentation",
+ "world audio",
"session dialogs",
"settings character context",
"equipped children",
diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
index 24e6948c..45fa4043 100644
--- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
+++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
@@ -1019,6 +1019,7 @@ public sealed class CurrentGameRuntimeAdapterTests
MouseCapture = noop,
PlayerPresentation = noop,
TeleportPresentation = noop,
+ WorldAudio = noop,
SessionDialogs = noop,
SettingsCharacterContext = noop,
EquippedChildren = noop,