# G.3a — Core Teleport-Into-Dungeon Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make teleporting into a dungeon land the player standing in the dungeon cell (on the floor, walls blocking) instead of snapping to ocean — by holding the player in portal space until the destination landblock/cell streams in, then placing via the existing validated-claim path. **Architecture:** Replace the unconditional snap in `GameWindow.OnLivePositionUpdated` with a small, pure, unit-tested `TeleportArrivalController` state machine. On a teleport arrival the handler recenters streaming (kicks off the load) but **defers** the snap; a per-frame `Tick` reuses the #107 login readiness triplet (`SampleTerrainZ` ∧ (`outdoor` ∨ `IsSpawnCellReady`); `IsSpawnClaimUnhydratable` short-circuits impossible claims) and places the player via the unchanged `PhysicsEngine.Resolve` once the destination is ready. A coarse frame-count timeout fails loudly rather than freezing. Plus a small decouple of EnvCell physics/visibility hydration from the render-mesh guard. **Tech Stack:** C# .NET 10, xUnit, Silk.NET (App layer). No new dependencies. **Spec:** [`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`](../specs/2026-06-13-dungeon-support-design.md) (§3.1, §4, §5). **Scope:** This plan is **G.3a only** — the gated core that ends at the visual acceptance test. G.3b (#95 stab_list bounding, *conditional* on the gate showing a blowup), G.3c (faithful `TeleportAnimState` tunnel FSM), and G.3d (recall game-actions) each get their own plan **after** the G.3a gate passes. --- ## File Structure | File | Responsibility | Action | |---|---|---| | `src/AcDream.App/World/TeleportArrivalController.cs` | Pure state machine: hold a teleport arrival until ready, then place (or force-place on impossible/timeout). No GL/dat/network — readiness + placement are injected delegates. | **Create** | | `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs` | Unit tests for the state machine (all transitions, timeout, re-arm). | **Create** | | `src/AcDream.App/Rendering/GameWindow.cs` | Wire the controller in: construct lazily, the readiness + placement callbacks, replace the unconditional arrival snap (`:4877-4961`) with recenter + `BeginArrival`, add per-frame `Tick` (after `:6838`). Decouple EnvCell physics/visibility hydration from the render-mesh guard (`:5601-5652`). | **Modify** | `TeleportArrivalController` is deliberately a *pure* unit (App layer, `System.Numerics` only) so it is testable without standing up the renderer. GameWindow keeps only the wiring + closures over its runtime state (Code Structure Rule 1). --- ## Task 1: `TeleportArrivalController` (pure state machine, TDD) **Files:** - Create: `src/AcDream.App/World/TeleportArrivalController.cs` - Test: `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs` - [ ] **Step 1: Write the failing tests** Create `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`: ```csharp using System.Collections.Generic; using System.Numerics; using AcDream.App.World; using Xunit; namespace AcDream.App.Tests.World; public class TeleportArrivalControllerTests { // Records each Place(destPos, destCell, forced) call. private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced); private static TeleportArrivalController Make( ArrivalReadiness verdict, List placed, int maxHoldFrames = TeleportArrivalController.DefaultMaxHoldFrames) => new( readiness: (_, _) => verdict, place: (pos, cell, forced) => placed.Add(new PlaceCall(pos, cell, forced)), maxHoldFrames: maxHoldFrames); [Fact] public void BeginArrival_EntersHolding() { var placed = new List(); var c = Make(ArrivalReadiness.NotReady, placed); c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); Assert.Empty(placed); } [Fact] public void Tick_WhenIdle_IsNoOp() { var placed = new List(); var c = Make(ArrivalReadiness.Ready, placed); c.Tick(); // never began Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); Assert.Empty(placed); } [Fact] public void Tick_NotReady_KeepsHolding_DoesNotPlace() { var placed = new List(); var c = Make(ArrivalReadiness.NotReady, placed); c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); c.Tick(); c.Tick(); Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); Assert.Empty(placed); } [Fact] public void Tick_Ready_PlacesUnforced_AndIdles() { var placed = new List(); var c = Make(ArrivalReadiness.Ready, placed); c.BeginArrival(new Vector3(30, -60, 6.005f), 0x01250126u); c.Tick(); Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); var call = Assert.Single(placed); Assert.False(call.Forced); Assert.Equal(0x01250126u, call.Cell); Assert.Equal(new Vector3(30, -60, 6.005f), call.Pos); } [Fact] public void Tick_Impossible_PlacesForced_AndIdles() { var placed = new List(); var c = Make(ArrivalReadiness.Impossible, placed); c.BeginArrival(new Vector3(1, 2, 3), 0x0125FF00u); c.Tick(); Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); var call = Assert.Single(placed); Assert.True(call.Forced); } [Fact] public void Tick_Timeout_PlacesForced_AfterMaxHoldFrames() { var placed = new List(); var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3); c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); c.Tick(); // 1 c.Tick(); // 2 Assert.Empty(placed); Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); c.Tick(); // 3 -> timeout var call = Assert.Single(placed); Assert.True(call.Forced); Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); } [Fact] public void BeginArrival_AfterPlace_ReArms() { var placed = new List(); var c = Make(ArrivalReadiness.Ready, placed); c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u); c.Tick(); // places #1, idle c.BeginArrival(new Vector3(2, 0, 0), 0x01250127u); c.Tick(); // places #2, idle Assert.Equal(2, placed.Count); Assert.Equal(0x01250127u, placed[1].Cell); } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"` Expected: FAIL — `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` do not exist (compile error). - [ ] **Step 3: Write the implementation** Create `src/AcDream.App/World/TeleportArrivalController.cs`: ```csharp using System; using System.Numerics; namespace AcDream.App.World; /// Verdict from the per-frame readiness probe for a held teleport arrival. public enum ArrivalReadiness { /// Destination not yet hydrated; keep holding. NotReady, /// Destination terrain + cell are ready; place now. Ready, /// The claim can never hydrate (e.g. an indoor cell id outside the dat's /// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net /// demote rather than hold forever. Impossible, } /// Lifecycle of a single teleport arrival. public enum TeleportArrivalPhase { Idle, Holding } /// /// G.3a (#133) — holds a teleport arrival in portal space until the destination /// dungeon landblock/cell has streamed in, THEN places the player. Replaces the /// unconditional snap in GameWindow.OnLivePositionUpdated that resolved the /// arrival against the resident (old) landblocks before the destination hydrated /// and landed the player in ocean. /// /// The controller is pure: readiness and placement are injected delegates, /// so it carries no GL / dat / network dependency and is fully unit-testable. The /// player stays input-frozen while this is Holding because the GameWindow keeps /// PlayerState.PortalSpace until the placement delegate flips it back to /// InWorld. /// /// The timeout is a coarse frame count (not wall-clock) so the controller /// needs no external clock; it is a loud safety net for a never-hydrating /// destination, not a precise deadline. /// public sealed class TeleportArrivalController { /// ~10 s at 60 fps. Coarse safety net for a destination that never streams. public const int DefaultMaxHoldFrames = 600; private readonly Func _readiness; private readonly Action _place; // (destPos, destCell, forced) private readonly int _maxHoldFrames; private Vector3 _destPos; private uint _destCell; private int _heldFrames; public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle; public TeleportArrivalController( Func readiness, Action place, int maxHoldFrames = DefaultMaxHoldFrames) { _readiness = readiness ?? throw new ArgumentNullException(nameof(readiness)); _place = place ?? throw new ArgumentNullException(nameof(place)); _maxHoldFrames = maxHoldFrames; } /// Begin holding for a teleport arrival. Called from OnLivePositionUpdated /// AFTER the streaming origin has been recentered on the destination landblock. /// Re-calling with a fresh server position resets the hold (server-authoritative). public void BeginArrival(Vector3 destPos, uint destCell) { _destPos = destPos; _destCell = destCell; _heldFrames = 0; Phase = TeleportArrivalPhase.Holding; } /// Per-frame: evaluate readiness and place when ready / impossible / timed out. /// No-op when Idle. public void Tick() { if (Phase != TeleportArrivalPhase.Holding) return; _heldFrames++; ArrivalReadiness verdict = _readiness(_destPos, _destCell); if (verdict == ArrivalReadiness.Ready) { Place(forced: false); return; } if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames) { Place(forced: true); } // else NotReady -> keep holding } private void Place(bool forced) { _place(_destPos, _destCell, forced); Phase = TeleportArrivalPhase.Idle; } } ``` - [ ] **Step 4: Run the tests to verify they pass** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"` Expected: PASS (7 tests). - [ ] **Step 5: Commit** ```bash git add src/AcDream.App/World/TeleportArrivalController.cs tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs git commit -m "feat(G.3a): TeleportArrivalController hold-until-hydration state machine (#133) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 2: Wire `TeleportArrivalController` into GameWindow **Files:** - Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add field; lazy construct + 2 callbacks; replace the arrival snap at `:4877-4961`; per-frame `Tick` after `:6838`) This task has no isolated unit test (it edits the 10k-line runtime god-object). It is verified by `dotnet build` + `dotnet test` green and the Task 4 visual gate. Make the edits exactly as shown. - [ ] **Step 1: Add the field + the lazy-construct helper + the two callbacks** Add near the other player/teleport fields in `GameWindow.cs` (anywhere in the field region; e.g. just above `OnTeleportStarted` at `:4971`): ```csharp // G.3a (#133): holds a teleport arrival in portal space until the destination // dungeon landblock/cell has hydrated, then places the player via the unchanged // validated-claim Resolve path. Lazily constructed on the first teleport (all // runtime deps are wired by then). private AcDream.App.World.TeleportArrivalController? _teleportArrival; private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity; private void EnsureTeleportArrivalController() { if (_teleportArrival is not null) return; _teleportArrival = new AcDream.App.World.TeleportArrivalController( readiness: TeleportArrivalReadiness, place: PlaceTeleportArrival); } // Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated // against the teleport's (destPos, destCell): an impossible indoor claim short- // circuits to immediate placement; otherwise hold until terrain is sampled and, // for an indoor cell, the cell struct has hydrated. private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness( System.Numerics.Vector3 destPos, uint destCell) { if (IsSpawnClaimUnhydratable(destCell)) return AcDream.App.World.ArrivalReadiness.Impossible; if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null) return AcDream.App.World.ArrivalReadiness.NotReady; bool indoor = (destCell & 0xFFFFu) >= 0x0100u; if (indoor && !_physicsEngine.IsSpawnCellReady(destCell)) return AcDream.App.World.ArrivalReadiness.NotReady; return AcDream.App.World.ArrivalReadiness.Ready; } // The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only // once the destination is ready (or force-run on impossible/timeout, logged loud). private void PlaceTeleportArrival( System.Numerics.Vector3 destPos, uint destCell, bool forced) { var resolved = _physicsEngine.Resolve( destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight); var snappedPos = new System.Numerics.Vector3( resolved.Position.X, resolved.Position.Y, resolved.Position.Z); if (forced) Console.WriteLine( $"live: teleport HOLD gave up (impossible/timeout) — force-snapping " + $"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}"); if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) { pe.SetPosition(snappedPos); pe.ParentCellId = resolved.CellId; pe.Rotation = _pendingTeleportRot; } _playerController.SetPosition(snappedPos, resolved.CellId); _chaseCamera?.Update(snappedPos, _playerController.Yaw); _retailChaseCamera?.Update(snappedPos, _playerController.Yaw, playerVelocity: System.Numerics.Vector3.Zero, isOnGround: true, contactPlaneNormal: System.Numerics.Vector3.UnitZ, dt: 1f / 60f); _playerController.State = AcDream.App.Input.PlayerState.InWorld; Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); // Tell the server the client finished loading the new landblock (holtburger // client/messages.rs:434 — re-send LoginComplete after each portal transition). _liveSession?.SendGameAction( AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); } ``` - [ ] **Step 2: Construct the controller when a teleport starts** In `OnTeleportStarted` (`GameWindow.cs:4971-4976`), add the ensure-call after setting PortalSpace: ```csharp private void OnTeleportStarted(uint sequence) { if (_playerController is not null) _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; EnsureTeleportArrivalController(); Console.WriteLine($"live: teleport started (seq={sequence})"); } ``` - [ ] **Step 3: Replace the unconditional arrival snap with recenter + BeginArrival** Replace the entire arrival block at `GameWindow.cs:4877-4961` (from `// Phase B.3: portal-space arrival detection.` through its closing brace) with: ```csharp // Phase B.3 / G.3a (#133): portal-space arrival detection. // Only runs for our own player character while in PortalSpace. if (_playerController is not null && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace && update.Guid == _playerServerGuid) { // Compute old landblock coords from controller position (using the // current streaming origin as the reference center). var oldPos = _playerController.Position; int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f); int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f); bool differentLandblock = (lbX != oldLbX || lbY != oldLbY); Console.WriteLine( $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); System.Numerics.Vector3 newWorldPos; if (differentLandblock) { // Recenter the streaming controller on the new landblock NOW (kick // off the dungeon load). After recentering, the destination is // (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin. _liveCenterX = lbX; _liveCenterY = lbY; newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); } else { newWorldPos = worldPos; } // G.3a: do NOT snap here. The destination dungeon landblock has not // streamed in yet; an immediate Resolve falls back to the resident // (old) landblocks and lands the player in ocean (#133). HOLD the snap // in portal space — TeleportArrivalController.Tick (per frame) places // the player via PlaceTeleportArrival once the destination cell // hydrates (TeleportArrivalReadiness == Ready), or force-places on an // impossible claim / timeout. PortalSpace keeps input frozen meanwhile. EnsureTeleportArrivalController(); _pendingTeleportRot = rot; _teleportArrival!.BeginArrival(newWorldPos, p.LandblockId); } ``` - [ ] **Step 4: Add the per-frame Tick after the live-session drain** In `OnUpdate`, immediately after `_liveSessionController?.Tick();` (`GameWindow.cs:6838`), add: ```csharp // G.3a (#133): advance any held teleport arrival. Runs AFTER streaming // (which applies the destination landblock) and the live-session drain // (which may have just called BeginArrival), so a destination that // hydrated this frame is placed the same frame. _teleportArrival?.Tick(); ``` - [ ] **Step 5: Build + run the full suites** Run: `dotnet build` Expected: build succeeds (0 errors). Run: `dotnet test` Expected: all suites green (App / Core / UI / Net) — no regressions. (Counts at baseline: App 264+1skip / Core 1445+2skip / UI 420 / Net 294.) - [ ] **Step 6: Commit** ```bash git add src/AcDream.App/Rendering/GameWindow.cs git commit -m "feat(G.3a): hold teleport arrival until dungeon hydrates, then place (#133) Replaces the unconditional OnLivePositionUpdated snap (which resolved against the resident old landblocks before the destination streamed in -> ocean) with a recenter + deferred BeginArrival; per-frame Tick places via the unchanged #111 validated-claim Resolve once SampleTerrainZ + IsSpawnCellReady report ready, or force-snaps loudly on an impossible claim / ~10s timeout. Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 3: Decouple EnvCell physics/visibility hydration from the render-mesh guard **Files:** - Modify: `src/AcDream.App/Rendering/GameWindow.cs:5601-5652` **Why:** `BuildLoadedCell` (the portal-visibility node) and `CacheCellStruct` (the physics BSP) currently sit *inside* `if (cellSubMeshes.Count > 0)`. A collision cell with an empty render mesh would silently get no collision and no visibility node — retail couples neither to visible geometry. This is insurance for any geometry-less dungeon cell. **It touches the shared (building) hydration path**, so its acceptance includes a no-regression check on the frozen building/cellar demo. - [ ] **Step 1: Make the edit** In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5601-5652`), the current shape is: ```csharp var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); if (cellSubMeshes.Count > 0) { _pendingCellMeshes[envCellId] = cellSubMeshes; var physicsCellOrigin = envCell.Position.Origin + lbOffset; var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); var cellTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); var physicsCellTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); _envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... */); BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); } ``` Restructure so the transforms + physics/visibility hydration run unconditionally (they don't depend on visible geometry), and only the render registration stays behind the submesh-count guard: ```csharp var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); // G.3a (#133) hydration decouple: the cell transforms and the physics + // visibility hydration are INDEPENDENT of whether the cell has drawable // geometry. Retail couples neither collision nor portal visibility to a render // mesh. Previously these sat behind `cellSubMeshes.Count > 0`, which silently // dropped collision (CellTransit.GetCellStruct -> null -> fall through floor) // and the visibility node for any geometry-less collision cell. CacheCellStruct // self-gates on a null PhysicsBSP (PhysicsDataCache.cs:172), so this is safe for // cells that genuinely have no physics. var physicsCellOrigin = envCell.Position.Origin + lbOffset; var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); var cellTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); var physicsCellTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); // Render registration only when the cell actually has drawable submeshes. if (cellSubMeshes.Count > 0) { _pendingCellMeshes[envCellId] = cellSubMeshes; _envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... — UNCHANGED args ... */); } ``` Keep the `_envCellRenderer?.RegisterCell(...)` call's argument list exactly as it is today (`cellTransform`, `cellOrigin`, etc.) — only its position in the block changes (now inside the `Count > 0` guard, with the transforms hoisted above). - [ ] **Step 2: Build + run the full suites** Run: `dotnet build` Expected: build succeeds. Run: `dotnet test` Expected: all suites green — in particular no regression in any existing EnvCell / streaming / membership test. - [ ] **Step 3: Commit** ```bash git add src/AcDream.App/Rendering/GameWindow.cs git commit -m "fix(G.3a): hydrate EnvCell physics + visibility independent of render mesh (#133) BuildLoadedCell + CacheCellStruct were gated behind cellSubMeshes.Count > 0, so a geometry-less collision cell got no collision (fall-through) and no visibility node. Retail couples neither to visible geometry; CacheCellStruct self-gates on a null PhysicsBSP, so this is safe. Render registration stays behind the submesh guard. Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 4: Visual acceptance gate (STOP — user verification) This is the M1.5 dungeon-demo gate and the empirical test of #95 + the hydration decouple. It cannot be automated; hand the running client to the user. - [ ] **Step 1: Build green** Run: `dotnet build` Expected: 0 errors. - [ ] **Step 2: Launch against the live ACE server** ```powershell $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" $env:ACDREAM_LIVE = "1" $env:ACDREAM_TEST_HOST = "127.0.0.1" $env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_USER = "testaccount" $env:ACDREAM_TEST_PASS = "testpassword" $env:ACDREAM_PROBE_CELL = "1" dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-g3a-gate.log" ``` Run in the background; give it ~8 s to reach in-world. Use the meeting-hall portal (or `/ls` once G.3d lands) to teleport into the dungeon. - [ ] **Step 2: User verifies (the acceptance criteria)** The user confirms, in the running client: - Player **stands in the dungeon cell**, on the floor — not ocean, not falling. - The dungeon renders; the user can **navigate 3-5 rooms**; **walls block** movement. - **No ocean / no ACE `failed transition` spam** (check the ACE console + `launch-g3a-gate.log`). - **#95 check:** no see-through-walls, no other-dungeon geometry rendering inside the current dungeon (if it DOES blow up → proceed to the G.3b plan). - **Hydration-decouple no-regression:** re-walk a Holtburg building + cellar (the frozen M1.5 demo) — walls still block, no new phantom collisions, interiors render as before. - [ ] **Step 3: On pass — record the milestone progress** - Move #133 to **Recently closed** in `docs/ISSUES.md` with the G.3a commit SHAs. - If #95 did NOT reproduce, add a one-line note closing #95 as superseded (its repro was the T4-deleted WB cell-cache path); if it DID, leave #95 open and start the G.3b plan. - Update the roadmap G.3 row + the milestones doc (G.3a core landed). - Then proceed to the G.3c (faithful `TeleportAnimState`) and G.3d (recalls) plans. --- ## Self-Review **Spec coverage (against `2026-06-13-dungeon-support-design.md` §3.1):** - Hold-until-hydration on the arrival path → Task 2 (BeginArrival + Tick). - Reuse #107 `IsSpawnCellReady` + `IsSpawnClaimUnhydratable` → Task 2 `TeleportArrivalReadiness`. - #111 validated-claim EnvCell placement → Task 2 `PlaceTeleportArrival` (unchanged `Resolve`). - Readiness predicate reuses `SampleTerrainZ` (the synced refinement) → Task 2. - Dest-coord validation → handled by the Impossible (indoor) + timeout (outdoor) paths; **no separate task** (YAGNI — the timeout IS the malformed-dest safety net; noted in spec §10.3). - Timeout safety (fail loudly, never freeze) → Task 1 `_maxHoldFrames` + Task 2 forced-place loud log. - Decouple physics/visibility hydration from the render-mesh guard → Task 3. - Visual gate (also settles #95 + hydration coupling) → Task 4. **Placeholder scan:** Task 1 + its tests are complete code. Task 2/3 are exact edits with full code; the only `/* ... */` is the deliberately-unchanged `RegisterCell(...)` arg list (instruction: keep verbatim, only move it) — not a content gap. Task 4 is a manual gate (correctly not code). **Type consistency:** `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` and the delegate shapes `Func` + `Action` match between Task 1's class, its tests, and Task 2's `EnsureTeleportArrivalController` / `TeleportArrivalReadiness` / `PlaceTeleportArrival`. `BeginArrival(Vector3,uint)` and `Tick()` signatures match across all three. **Deferred to other plans (out of G.3a scope):** #95 stab_list bounding (G.3b, conditional), `TeleportAnimState` tunnel FSM (G.3c), recall game-actions (G.3d).