0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI LEVEL_FLOW_IMPLEMENTATION
UTC 00:00:00
◀ RETURN
LEVEL_FLOW_IMPLEMENTATION.md 3414 words ~16 min read Updated 2026-07-03

CRADL Level Flow Implementation

Companion to LEVEL_FLOW_SYSTEM.md (the contract), PLAYFLOW_SYSTEM.md (the arrival gate + loading screen this builds on), and ARCHITECTURE.md. This doc tracks the build order for v1 level flow: phased delivery, per-phase rationale, task checklists, and verification gates. The contract says what level flow is; this doc says what we build first, what depends on what, and how we know each step works.

Greenfield: both runtime subsystems (UCradlLevelIntroSubsystem, UCradlAmbienceSubsystem) are new. The work extends ULevelDefinition and reuses existing seams rather than inventing — the cinematic-input arrest (ACradlPlayerController::BeginCinematicControl), the loadout preview spawn pattern (ULoadoutPreviewSubsystem), the travel/death camera fade (StartCameraFade), the reserved loading-screen processor seam (ILoadingProcessInterface), and the settings-owned audio routing (UCradlSettingsSubsystem). It adds an arrival presentation arc, not a new foundation.

Not a prerequisite: map-to-map travel. The contract's intro trigger fires on any UCradlGameFlowSubsystem::HasActiveLevel() arrival, which is true today for menu→level Play. Map-to-map travel (a PLAYFLOW build, not derived here) only adds more arrival occasions; it is a sibling, not a dependency. Every phase below verifies against a menu→level arrival.

Conventions

  • Phase status legend: [ ] not started · [~] in progress · [x] done · [!] blocked / deferred.
  • Verification gate: every phase ends with a runnable demo / observable behavior. If a phase can't be verified end-to-end, it's split.
  • Cheat commands: test fixtures land under ACradlPlayerController exec functions guarded by #if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded.
  • Author tasks (★): some verifications need a WBP / IMC / Input Action / LevelSequence asset the user authors (Blueprints are data containers per CLAUDE.md — no Event Graph logic). These are marked ★ author task and are the user's to create; Claude builds the C++ they bind to.
  • Per CLAUDE.md "validators in lockstep": the phase that touches ULevelDefinition updates UCradlLevelDefinitionValidator in the same change.
  • Per CLAUDE.md "Building": after the C++ edits for a phase land, Claude compiles with the documented Build.bat call (UE: Build Editor (Development)) to verify the phase links clean before reporting it done. Runtime/PIE verification remains the user's job.
  • Replication: every runtime object introduced here is local-only / non-replicated per the contract's Replication Audit. No phase adds a replicated property; each phase's tasks restate this where it would otherwise be tempting (pawn-hide, proxy spawn).

Phase tracking

Phase Title Status Unblocks
0 Tag & data scaffolding [x] All later phases
1 Ambience & music manager [x] 4 · parallel-able with 2, 3
2 Intro playback core (trigger → play → teardown) [x] 3, 4, 5 · parallel-able with 1
3 Customization injection (Player / PlayerProxy) [x] 5
4 Audio handoff (intro ⇄ ambience) + HUD hide [x] 5
5 Exit fade + re-arrival polish [x]

Phase 0 — Tag & Data Scaffolding

Goal. A compile-clean codebase carrying the level-flow authoring surface and tag/module dependencies, with zero behavior. After this phase a designer can set an intro sequence + cadence on a ULevelDefinition and the validator gates it; nothing plays yet.

Rationale. Every later phase resolves IntroSequence / IntroCadence off the definition and references Cinematic.LevelIntro / Ambience.* by tag; the LevelSequence + MovieScene modules must link before any sequence code compiles. Landing all of it first unblocks phases 1–5 without forward-reference churn.

Tasks.

  • [x] Module deps — CRADL.Build.cs:
  • [x] Add "LevelSequence" and "MovieScene" to PublicDependencyModuleNames (confirmed absent today — this is the enabling link step).
  • [x] Tags — CradlGameplayTags.h + Config/DefaultGameplayTags.ini:
  • [x] Cinematic.LevelIntro (both C++ + .ini — referenced by name as the BeginCinematicControl mode key and the ambience suppression reason; new leaf under the existing Cinematic root.)
  • [x] Ambience (.ini root only — no C++ symbol yet; Phase 4 adds it when the intro filters by the parent tag in code, per feedback_gameplay_tag_decl_minimal.md.)
  • [x] Ambience.Bed (.ini — world-ambience category.)
  • [x] Ambience.Music (.ini — area-music category.)
  • [x] Data shape — CradlLevelDefinition.h:
  • [x] enum class ELevelIntroCadence : uint8 { EveryEntry, OncePerLaunch } (new.)
  • [x] TSoftObjectPtr<ULevelSequence> IntroSequence (soft — async-loaded on arrival, per contract.)
  • [x] ELevelIntroCadence IntroCadence = ELevelIntroCadence::OncePerLaunch (EditCondition on !IntroSequence.IsNull().)
  • [x] bool bIntroSkippable = true (EditCondition on !IntroSequence.IsNull().)
  • [x] Validator update — CradlLevelDefinitionValidator / .cpp:
  • [x] If IntroSequence is set, it must resolve in the AssetRegistry — mirror the existing Level / MapBackgroundTexture "references missing X" checks. IntroCadence / bIntroSkippable need no check (enum + bool).

Verification.

  • Compile clean (Claude builds via Build.bat; links exit-0).
  • ★ author task: open a ULevelDefinition asset, set IntroSequence to a valid LevelSequence → validator passes; point it at a since-deleted asset → validator fails with the missing-reference message. IntroCadence + bIntroSkippable appear in the Details panel only when IntroSequence is set.

Exits. Phases 1–5 now have: the Ambience.* / Cinematic.LevelIntro tags, the linked sequence modules, and the per-level intro authoring fields + cadence enum.


Phase 1 — Ambience & Music Manager

Parallel-able with Phase 2 — no shared state until Phase 4 joins them.

Goal. A working UCradlAmbienceSubsystem (UWorldSubsystem) that placed UCradlAmbientSourceComponent sources register with, exposing refcounted suppression with defer-start. Verifiable in isolation via cheat commands — no intro involved yet.

Rationale. The audio spine before its consumer (the intro's audio handoff, Phase 4). Building it standalone with a test fixture (cheat-driven push/release) proves the suppression + defer-start semantics before the intro depends on them — the contract's "policy here, volume there" boundary is easiest to validate when nothing else is moving.

Tasks.

  • [x] Subsystem — CradlAmbienceSubsystem.h / .cpp (new UWorldSubsystem):
  • [x] Source registry: RegisterSource / UnregisterSource(UCradlAmbientSourceComponent*).
  • [x] Refcounted suppression: PushSuppression(FGameplayTag CategoryFilter, FGameplayTag ReasonTag) / ReleaseSuppression(FGameplayTag ReasonTag) — release removes all pushes under that reason (suppress by category, release by reason, per contract footgun).
  • [x] IsSuppressed(FGameplayTag Category) const — a source is audible iff no active suppression matches its category (tag-inheritance via MatchesTag).
  • [x] On push: stop/fade matching live sources. On release: start any matching sources that deferred. (Implemented as a blanket RefreshAllSources() — each source no-ops when its desired state is unchanged, so the push/release delta is computed source-side and stays race-free.)
  • [x] Non-replicated — local per-peer; dies with the world (don't cache a source list across travel).
  • [x] Source component — Source/CRADL/Audio/CradlAmbientSourceComponent.h / .cpp (new):
  • [x] FGameplayTag Category (Ambience.Bed / Ambience.Music); the component subclasses UAudioComponent so it IS the routed source — author assigns a Sound + a SoundClassOverride of Music / SFX (per UCradlAudioSettings) in the Details panel so user volumes apply.
  • [x] On BeginPlay: register with the subsystem; defer-start (bAutoActivate=false + a RefreshPlayState() that plays only if !IsSuppressed(Category), else waits for the release callback).
  • [x] On EndPlay: unregister.
  • [x] Cheat commands — CradlPlayerController (decl unconditional, body #if !UE_BUILD_SHIPPING):
  • [x] Debug_AmbiencePush <Category> <Reason> / Debug_AmbienceRelease <Reason>.

Verification.

  • Compile clean.
  • ★ author task: a test map with one Ambience.Bed and one Ambience.Music source placed.
  • PIE: Debug_AmbiencePush Ambience Cinematic.LevelIntro → both go silent; Debug_AmbienceRelease Cinematic.LevelIntro → both resume. A source placed while suppressed defer-starts (no audio) and enters on release.
  • Lower the Music channel in settings while a source plays → it attenuates (proves routing stays the settings subsystem's job; the manager never touched volume).

Footguns.

  • The manager never calls SetSoundMixClassOverride / PushSoundMixModifier — volume routing is UCradlSettingsSubsystem's exclusively (the two-axes rule). Mute is policy (stop/defer the source), not a volume write.
  • Registration discipline: a vanilla AmbientSound or a raw PlaySound2D escapes suppression. Ambience plays only via UCradlAmbientSourceComponent.

Exits. Phase 4 can push Cinematic.LevelIntro suppression from the intro's prepare step and release it in teardown.


Phase 2 — Intro Playback Core (trigger → play → teardown)

Parallel-able with Phase 1.

Goal. A camera-only intro that triggers on arrival, holds the loading screen until parked at frame 0, suppresses input while it plays, restores input on finish, and routes skip through the same single teardown. No customization injection, no ambience, no exit fade yet.

Rationale. The intro spine before its verbs (customization in 3, audio in 4, fade polish in 5). A camera-only test sequence is the "test fixture before content" — it exercises the full trigger→hold→play→teardown loop without needing the proxy/binding machinery, so each later phase bolts onto a proven core.

Tasks.

  • [x] Trigger hook — LoadoutVisualsComponent.h / .cpp:
  • [x] Add a C++ multicast delegate (FOnLoadoutVisualsApplied) fired in the same spot the existing OnVisualsApplied BIE fires, carrying (FPrimaryAssetId, ALoadoutVisualsActor*). Non-dynamic, C++ subscribers only (mirrors ACradlCharacter::OnPlayerStateReplicated). Added a HasAppliedVisuals() latch so a subscriber that binds after the first apply catches up.
  • [x] Subsystem skeleton — CradlLevelIntroSubsystem.h / .cpp (new ULocalPlayerSubsystem):
  • [x] Per-arrival: FCoreUObjectDelegates::PostLoadMapWithWorld picks up each world; locate the local pawn's ULoadoutVisualsComponent (via the PC's OnPossessedPawnChanged + immediate-pawn check) and subscribe to the new delegate (re-bind each world; unbind on teardown/Deinitialize).
  • [x] Gate: only proceed when UCradlGameFlowSubsystem::HasActiveLevel() is true (direct-PIE never plays).
  • [x] Cadence: TSet<FPrimaryAssetId> PlayedThisLaunch; consult ELevelIntroCadence from the resolved ULevelDefinition; OncePerLaunch skips if already in the set. Mark visited in teardown, not on Play (per contract footgun).
  • [x] Resolve IntroSequence for GetActiveLevelId(); async-load via FStreamableManager (never sync-load). The definition itself is resident-fast-path / async-LoadPrimaryAsset (never sync) before reading IntroSequence.
  • [x] Loading-screen hold: ULoadingScreenManager::RegisterLoadingProcessor(this); implement ILoadingProcessInterface::ShouldShowLoadingScreen to return "still loading" until the player is parked at frame 0 (hold released at reveal); unregister at teardown/Deinitialize.
  • [x] Playback — same subsystem:
  • [x] Create ULevelSequencePlayer (CreateLevelSequencePlayer), park paused at frame 0 via SetPlaybackPosition(FFrameTime(0), Jump) so the camera-cut evaluates before reveal.
  • [x] Input suppression: Cast<ACradlPlayerController>(GetLocalPC())->BeginCinematicControl(Cinematic.LevelIntro).
  • [x] Release loading-screen hold, then Play().
  • [x] Single teardown on ULevelSequencePlayer::OnFinished: EndCinematicControl(Cinematic.LevelIntro), destroy the player (+ its ALevelSequenceActor), mark the level visited.
  • [x] Skip: RequestSkip() (gated on bIntroSkippable via the stashed bPendingSkippable) pauses the player and routes into BeginExitTeardown(bInstantBlack=true)hard-cut to black (StartIntroExitSnapToBlack), swap/teardown under cover, fade up on gameplay. Deliberately no seek-to-end and no fade-out: teleporting the cinematic camera to its final pose is visible before any fade-out could mask it, and fading from a mid-shot just lingers on a frame the player asked to dismiss. The composed final frame is only ever revealed by playing to it (natural completion). Never GoToEndAndStop()/Stop() here — the player is stopped in Teardown, under black.
  • [x] One intro at a time: a re-arrival mid-play (next PostLoadMapWithWorld) forces the prior teardown first.
  • [x] Skip input — CradlPlayerController:
  • [x] Added SkipCinematicAction (direct UInputAction ref, like BackInputAction), bound in SetupInputComponent; the handler forwards to the local UCradlLevelIntroSubsystem::RequestSkip(). CinematicInputContexts carrying Cinematic.LevelIntroIMC_LevelIntro stays the ★ author task on the BP CDO.
  • [x] Cheat — Debug_ResetIntroCadence clears PlayedThisLaunch so OncePerLaunch re-tests without a relaunch.

Verification.

  • Compile clean.
  • ★ author tasks: IMC_LevelIntro (binds only IA_SkipCinematic), IA_SkipCinematic, and a camera-only test IntroSequence (a cine-camera flythrough) set on the test level's ULevelDefinition; add the Cinematic.LevelIntroIMC_LevelIntro entry on the BP_CradlPlayerController CDO.
  • PIE menu→level: the loading screen holds until the sequence is loaded, reveals on frame 0, the flythrough plays, movement/click input is dead during it, and input returns the instant it ends. Pressing the skip action jumps to the end and restores input identically. With OncePerLaunch, a second arrival into the same level skips straight to gameplay; EveryEntry replays.
  • Cheat Debug_ResetIntroCadence (clears PlayedThisLaunch) lets the user re-test OncePerLaunch without relaunching.

Footguns.

  • Never use FMovieSceneSequencePlaybackSettings::bDisableMovementInput / bHideHud — they route through legacy SetCinematicMode, colliding with Enhanced Input + the CTM/WASD arbitration (feedback_wasd_ctm_arbitration.md). Input is BeginCinematicControl's job.
  • Subscribe to loadout-visuals-applied, not OnPossess — possession can precede the loadout rig's async resolve, framing a bare/mid-swap pawn.
  • The subsystem is a ULocalPlayerSubsystem (survives OpenLevel → the cadence set persists). A UWorldSubsystem would reset cadence every load.
  • Register/unregister the loading processor in lockstep with the world — a processor outliving its world holds the next world's screen forever.

Exits. Phases 3–5 bolt onto the prepared/teardown bookends: binding fill + proxy (3), ambience push/release + HUD hide (4), exit fade (5).


Phase 3 — Customization Injection (Player / PlayerProxy)

Goal. The sequence frames the player's actual loadout: the Player binding resolves to the real pawn (reference-only), and the optional PlayerProxy binding resolves to a locally-spawned rig built from the active loadout's VisualsActor, with the real pawn hidden during and restored after.

Rationale. This is requirement #2 ("customization available to the sequence") made literal, layered onto the proven Phase 2 core. It reuses the ULoadoutPreviewSubsystem resolve-and-spawn pattern (with its supersede guard) rather than inventing a parallel one.

Tasks.

  • [x] Binding fill — CradlLevelIntroSubsystem (in StartPlayback, before the frame-0 park / Play):
  • [x] Player tag → bind the real local pawn. (Filled via ALevelSequenceActor::SetBindingByTag — the binding-override API lives on the actor, not the player — guarded by a silent FindBindingsByTag probe so an un-tagged sequence no-ops — FindNamedBinding would raise a PIE warning for the absent optional tag.) Reference-only — never drive transform/anim tracks on it (contract rule; not validator-enforceable).
  • [x] PlayerProxy tag (optional) → spawn a local, non-replicated rig from ULoadoutComponent::ActiveLoadoutIdULoadoutDefinition::VisualsActor (two-stage async-load, supersede guard ActiveProxyLoadoutIdLocal mirroring ULoadoutPreviewSubsystem), then bind it. (Only when the sequence authors the PlayerProxy binding; the loading-screen hold spans the proxy load — FinalizeStartPlayback is the shared tail reached with or without a proxy. Loadout missing a VisualsActor → author placeholder stands.)
  • [x] Pawn hide/unhide — same subsystem:
  • [x] When a PlayerProxy is in use, SetActorHiddenInGame(true) on the real pawn (+ its ALoadoutVisualsActor) in SpawnProxyRig. Local-only — never a replicated visibility change. bPawnHidden latch so teardown unhides exactly once.
  • [x] In teardown (the single OnFinished path): destroy the proxy, SetActorHiddenInGame(false) on the pawn.

Verification.

  • Compile clean.
  • ★ author task: a test IntroSequence with a PlayerProxy-tagged binding that flies the rig through the shot (and a Player-tagged camera-target binding).
  • PIE: the rig shown matches the equipped loadout; switching loadout before arrival changes the rig; the real pawn is hidden for the duration and visible again after (verify from a second vantage that other peers — listen-server self-play stand-in — would still see it, i.e. the hide didn't replicate).

Footguns.

  • The Player binding is a camera target, not a puppet — any transform/anim track on it is the bug (client correction overrides it; host would replicate the cutscene).
  • Proxy spawn must carry the supersede guard — a loadout change mid-prepare must not bind a stale rig.
  • Hide is SetActorHiddenInGame local-only; never a replicated property.

Exits. Phase 5's exit fade now has a proxy→pawn swap to cover under black.


Phase 4 — Audio Handoff (intro ⇄ ambience) + HUD hide

Pre-work. Depends on Phase 1's UCradlAmbienceSubsystem (the suppression API) and Phase 2's prepare/teardown bookends.

Goal. Frame-0 silence: the intro holds level ambience/music for its duration and hands the world its audio on finish, and the gameplay HUD is hidden under the intro.

Rationale. Requirement #5 — ambience "not necessarily audible during the sequence" — realized by the contract's defer-start handshake rather than a volume duck. Lands after both halves exist so the handshake is wiring, not new mechanism.

Tasks.

  • [x] Tag — Ambience parent C++ symbol (deferred from Phase 0 per feedback_gameplay_tag_decl_minimal.md; added now that the intro filters by the parent in code): CradlGameplayTags.h/.cpp UE_DECLARE/DEFINE_GAMEPLAY_TAG(Ambience, "Ambience"). Bed/Music leaves stay .ini-only.
  • [x] Suppression handshake — CradlLevelIntroSubsystem:
  • [x] In FinalizeStartPlayback (before reveal/Play, alongside BeginCinematicControl): resolve ArmedWorld's UCradlAmbienceSubsystem and PushSuppression(CradlTags::Ambience, CradlTags::Cinematic_LevelIntro) (parent filter → both Bed + Music defer). Latched (bAmbienceSuppressed) so the push is once.
  • [x] In Teardown (single OnFinished/forced path): ReleaseSuppression(Cinematic.LevelIntro) → deferred sources enter. Latch clears intent-first so a torn-down world can't strand it.
  • [x] HUD hide — same subsystem + UCradlHUDLayout:
  • [x] New UCradlHUDLayout::SetHiddenForCinematic(bool) — caches prior ESlateVisibility, sets Collapsed, restores exactly on unhide. Idempotent. Not the sequence's bHideHud.
  • [x] Intro calls SetIntroHUDHidden(true) in FinalizeStartPlayback, false in Teardown (via the PC's GetHUDLayout()). Latch clears intent-first so a stale latch can't block the next arrival's hide.

Verification.

  • Compile clean.
  • PIE menu→level (test level has placed Ambience.Bed + Ambience.Music sources, an IntroSequence, and ★ optionally a music track on the sequence routed through the Music SoundClass): ambience is silent at frame 0, the sequence's own audio plays, and ambience enters the moment the intro ends or is skipped. The HUD is hidden during, restored after.
  • Mute the Music channel in settings → the sequence's music is silent too (the no-cutscene-channel proof: cutscene audio honors user mutes).

Footguns.

  • Push the suppression in prepare, before the ambience sources' BeginPlay may have run — the WorldSubsystem exists at world-init, and defer-start means a source registering during the window simply won't start. (Ordering is safe: manager up at world-init, intro pushes after, on the same local timeline.)
  • Release exactly once, in the single teardown — a stray release path would un-suppress mid-intro.

Exits. Only the boundary fade remains.


Phase 5 — Exit Fade + Re-arrival Polish

Goal. A seamless gameplay handoff: skip or natural-end fades to black, performs the proxy→pawn swap and camera re-anchor under black, then fades in — identical for both endings. Rapid re-travel tears down cleanly.

Rationale. Polish last, per the phasing heuristic. The exit fade is contract-mandated code-owned (a baked tail never plays under skip), so it's the final guarantee that the single-teardown invariant reads seamlessly.

Tasks.

  • [x] Exit fade — CradlLevelIntroSubsystem teardown + CradlPlayerController:
  • [x] PC owns the fade (camera manager lives there): new StartIntroExitFadeOut() (0→1, bHoldWhenFinished=true) / StartIntroExitFadeIn() (1→0) + a shared IntroExitFadeDuration (Cinematic category, default 0.5s) — same StartCameraFade shape as death/travel.
  • [x] BeginExitTeardown(bInstantBlack) is the shared exit (entered by HandleIntroFinished on OnFinished for natural completion, by RequestSkip for skip). Natural completion (bInstantBlack=false): fade to black over the held final frame — the player is paused there (bPauseAtEnd=true), so the cinematic camera stays live for the whole fade-out — then arm ExitFadeTimer. Skip (bInstantBlack=true): StartIntroExitSnapToBlack (0-duration held black) then CompleteFadedTeardown synchronously. A bExitFading latch guards skip/re-entry during the window.
  • [x] Under black (CompleteFadedTeardownTeardown): Stop() + destroy the paused sequence player (the deliberate camera-cut release — snaps to the gameplay spring-arm invisibly under black), EndCinematicControl, destroy proxy + unhide pawn, release ambience, unhide HUD, mark visited. Then StartIntroExitFadeIn.
  • [x] Fade-in is code-owned (StartIntroExitFadeIn) — the subsystem owns the exit; the author owns only the entry fade (below).
  • [x] Entry-fade path: unchanged-by-code — a Sequencer Fade Track at the sequence head reveals correctly off the loading-screen hard-cut to black frame 0 (★ author/PIE verification; the contract's "baked entry fade" needs no subsystem work).
  • [x] Re-arrival robustness: Teardown cancels ExitFadeTimer and clears bExitFading up front, so a forced mid-fade teardown (re-travel via HandlePostLoadMap, or Deinitialize) runs the same synchronous cleanup without a stranded under-black completion or a leaked proxy.

Verification.

  • Compile clean.
  • PIE: skip the intro mid-play → screen fades to black, the swap-back happens unseen, gameplay fades in on the spring-arm camera with no hard cut and no camera-blend pop. Natural completion looks identical.
  • ★ author task: add a fade-from-black Fade Track to the head of the test sequence → the loading-screen→intro reveal is a smooth fade-up rather than a cut.
  • Rapid menu→level→(return)→level re-entry: each intro tears down cleanly; no stuck-black, no orphaned proxy, no doubled audio.

Footguns.

  • Don't bake an exit/gameplay-handoff fade into the sequence — it never plays under skip (skip hard-cuts to black and the code fade takes over). The author owns the entry fade; the subsystem owns the exit.
  • The swap-back must complete inside the black window — if the fade-back starts before the camera re-anchors, the blend shows. (Implementation timing, not contract; the place the seam shows if it's going to.)
  • The fade tints the 3D viewport only — UI renders on top, so the HUD-unhide and the fade are independent and must both be sequenced in teardown.

Open Questions (carried from the contract)

These do not block any phase above; v1 ships without them.

  1. Spawn-grace during the intro (attackable-while-watching). A local-only intro can't grant invulnerability. If wanted, a server-side spawn-grace UGameplayEffect applied at spawn — independent of this build, and per project_persistent_effect_allowlist.md it must not be Effect.Persistent (transient grace, dies on respawn). Out of v1 scope.
  2. PlayerProxy hand-off polish. v1 hides the seam with a cut/fade (Phase 5). A frame-accurate proxy→pawn transform match is a content nicety, revisited only if intros end on a live (un-cut) pawn reveal.
  3. P2P intro timing. Single-player today; the per-peer-local design composes with host-driven ServerTravel unchanged. Open: whether HasActiveLevel() is seeded from the travel URL on a session-joined client. Tracked jointly with PLAYFLOW Open Question #3.

Resolved in the contract (2026-06-10): cross-level music continuity (music restarts per transition — accepted) and stray-SFX-during-intro (combat/world SFX leaks — accepted). No phase work.