From 972c7ab3b8aa348a785f14cbbb28186b436a4209 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 9 Aug 2026 12:02:38 +0200 Subject: [PATCH] fix(input): focus loss no longer faults on an unpublished movement controller (#356) Losing window focus calls CameraPointerInputController.HandleFocusChanged -> MouseLookController.EndForLifecycle -> PlayerMovementController .EndMouseLook, and EnsurePublishedForRuntimeOperation throws when the controller exists but is not yet published (mid-login) or already retired (post-logout). A focus callback can land in either window, so a simple alt-tab during the login stream took the whole process down with an unhandled InvalidOperationException. Hit live during the Campaign A listening-session launches. EndAndRestoreCursor already guarded 'no controller'; publication state is the finer-grained form of the same condition, so the guard is completed with the new CanExecuteLiveMovement predicate (the exact lifecycle set EnsurePublishedForRuntimeOperation accepts) rather than wrapping the call in a catch. Cursor restore still runs unconditionally - presentation is always safe. Published-controller behaviour is unchanged. Also files issue #357: the login placement stall this session exposed (reveal ready=True, player Place edge never executes, world never opens). That one is NOT fixed here - full evidence chain, wire capture, and probe output are in the issue. It is a placement-domain bug and blocks the Campaign A listening gate. Suite: green except the known load-dependent measurement flake (RuntimeCollisionReportingStateTests allocation pin), which passes in isolation. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 59 +++++++++++++++++++ src/AcDream.App/Input/MouseLookController.cs | 8 ++- .../Gameplay/PlayerMovementController.cs | 12 ++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a1f708b8..55f4d603 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,65 @@ 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. +## #357 — Login stalls: reveal reaches ready=True but the player is never placed; UI + sky render, world never opens + +**Status:** OPEN — filed 2026-08-08 during the Campaign A listening session, +which it blocks. **This is a placement/streaming bug, not an audio bug** — +read `docs/research/2026-08-05-c4-closeout-handoff.md` and +`claude-memory/project_physics_collision_digest.md` before touching it. + +**Symptom:** login proceeds normally (handshake, CharacterList, EnterWorld, +~6,670 CreateObjects streamed, first player position received, streaming +recentered to (9,4) @0x09040008, all reveal domains converge — +`render=True composites=True collision=True ready=True`) — and then nothing. +`materialized=False completed=False visible=False` forever. The user sees the +retail UI and the sky/background; the world viewport never opens. Client is +healthy: no stderr, frame loop ticking (~15% of one core), graceful close +works. + +**Evidence chain (all from 2026-08-08, exact binary `aa82ff7b` + rebuilds):** + +1. **Nondeterministic on the SAME binary.** Two launches at ~08:51/08:53 + reached `auto-entered player mode` and `event=complete`; every launch from + ~08:56 onward stalls identically. No code change flips it: a control run + without `ACDREAM_RETAIL_UI`, a run with the uncommitted focus-crash fix + reverted (pure committed tree), and post-ACE-restart runs all stall. +2. **Not the server.** Loopback capture (35 s, `login-capture.pcap` in the + session scratchpad): ACE sends `PlayerCreate` (0xF746, from :9001) and the + player guid `0x5000000A` appears in 48 payloads. A retail client logs into + the same ACE fine (user-confirmed). +3. **`ACDREAM_PROBE_PARK=1` shows a park storm:** 19 bodies (remotes + 0x8xxxxxxx + generated statics 0x709xxxxx in landblocks 0x0904/0x0905) all + park with `cause=unplaceable`, `eligible=True captured=True`, and **zero + restores** over 30+ s. `ACDREAM_PROBE_PLACEMENT_FAIL=1` emits nothing. +4. **The player guid appears in ZERO park lines** — the local player's route-1 + Place edge never executes at all, so `PlayerMovementController` never + publishes, `PlayerModeAutoEntry.IsPlayerControllerReady` never becomes + true, auto-entry never fires, and the reveal never completes. In the + working 08:51 run, `[step-h]` player resolves appear BEFORE the final + readiness event and auto-entry lands immediately after it. + +**Working hypothesis (unverified):** a pre-existing streaming/placement race +in the login path — collision reveal-readiness and the placement pipeline's +placeability read different convergence points, and a timing shift (machine +warmth / page cache) made the losing interleaving consistent. The +restore-pump silence (19 eligible parks, zero restores) suggests whatever +re-drives parked placements after collision publication is not firing for +this interleaving; the same mechanism failing globally would also explain the +player's Place edge never arming. Smells adjacent to the +`feedback_streaming_residence_race` class (#168/#169) and the C4 route-1 +machinery, but NOT confirmed — nobody has read the route-1 executor against +this trace yet. + +**Repro:** launch Release live against local ACE (standard env) on a warm +machine; stall reproduces every time as of filing. Probes: +`ACDREAM_PROBE_PARK=1 ACDREAM_PROBE_PLACEMENT_FAIL=1`. + +**Next step:** read the C4 handoff's route-1 recipe, then instrument the +route-1 accepted-position drive (what arms the login Place edge, and what +re-drives parked operations when a collision generation publishes) against a +stalled run. Do NOT band-aid with a retry loop — find the missed edge. + ## #355 — Sound probability was never applied: every gated cue played on every trigger **Status:** CLOSED 2026-08-08 (Campaign A slice A1) — user gate finding diff --git a/src/AcDream.App/Input/MouseLookController.cs b/src/AcDream.App/Input/MouseLookController.cs index 9e5bbe92..998969ae 100644 --- a/src/AcDream.App/Input/MouseLookController.cs +++ b/src/AcDream.App/Input/MouseLookController.cs @@ -196,8 +196,14 @@ internal sealed class MouseLookController : IMouseLookInputFrameController bool stateWasActive = _state.Active; _state.Release(); + // The null check alone is not enough: a controller can also be present + // but UNPUBLISHED (mid-login) or retired (post-logout), and a focus-loss + // callback can land in either window. EndMouseLook faults on both, so a + // simple alt-tab during login used to take the process down. Cursor + // restore below still runs — that is presentation and always safe. PlayerMovementController? controller = _playerController.Controller; - if (controller is not null && controller.EndMouseLook(_movementInput.Capture())) + if (controller is { CanExecuteLiveMovement: true } + && controller.EndMouseLook(_movementInput.Capture())) { _outbound.TrySendMovement( _session.CurrentSession, diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index 623e57b9..fd9474a6 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -764,6 +764,18 @@ public sealed class PlayerMovementController internal bool IsRuntimePublished => _publicationLifecycle is PlayerMovementControllerPublicationLifecycle.RuntimePublished; + /// + /// True when this controller may execute a live movement operation — the + /// same predicate throws + /// on. Exposed so LIFECYCLE callers (window focus loss, session teardown) + /// can skip the operation instead of faulting: a focus change can arrive at + /// any moment, including before the controller is published during login + /// and after it is retired on logout, and neither is an error. + /// + public bool CanExecuteLiveMovement => _publicationLifecycle + is PlayerMovementControllerPublicationLifecycle.StandalonePublished + or PlayerMovementControllerPublicationLifecycle.RuntimePublished; + private bool _dormantSetPositionGroundPhase; internal void BeginDormantSetPositionGroundPhase()