diff --git a/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md b/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md new file mode 100644 index 00000000..4391fca3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md @@ -0,0 +1,633 @@ +# 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). diff --git a/docs/superpowers/specs/2026-06-13-dungeon-support-design.md b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md index c0fae795..6f62f41c 100644 --- a/docs/superpowers/specs/2026-06-13-dungeon-support-design.md +++ b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md @@ -130,9 +130,12 @@ All five verified against current code this session (high confidence). a 5×5 near window; physics ready +1-2 frames. - Recenter keeps the old neighborhood until hysteresis unload (NearRadius+2 demote, FarRadius+2 unload), so the player isn't instantly stranded. -- **New code needed:** a "destination landblock applied" query + dest-coord - validation (reject out-of-world coords — a malformed portal dest would otherwise - leave the player in an invisible, unloadable landblock). +- **New code needed:** reuse the #107 login-gate **terrain-ready signal** + `_physicsEngine.SampleTerrainZ(x,y) is not null` (non-null once the destination + terrain landblock has applied) — no separate "landblock applied" query is + required. Plus dest-coord validation (reject out-of-world coords — a malformed + portal dest would otherwise leave the player in an invisible, unloadable + landblock). ### 2.4 EnvCell hydration coupling (latent landmine — decouple) - In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5564-5651`), both @@ -203,17 +206,22 @@ the dungeon cell, on the floor, with walls blocking — no ocean, no ACE via the safety-net demote (loud log), exit PortalSpace. - `now > deadline` (timeout, ~10 s) → force-snap via safety-net demote + loud log, exit PortalSpace. (See §5 — failure-surfacing, not symptom-masking.) - - `IsLandblockApplied(destLb) && IsSpawnCellReady(destCell)` → ready: go to 3. + - `SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell))` + → ready: go to 3. - else stay frozen, retry next frame. 3. `Placing`: call the **existing** `Resolve(destPos, destCell, Vector3.Zero, …)`. Because the cell is now hydrated, Resolve takes the #111 validated-claim branch → `WalkableFloorZNearest` grounds the player on the EnvCell floor. Snap entity + controller (existing `:4935-4939` code), exit PortalSpace, resume input. -**New streaming query — `IsLandblockApplied(uint landblockId)`** (on -`StreamingController` / `GpuWorldState`): true once the landblock's terrain has -been applied (AABB set in `ApplyLoadedTerrainLocked`) **and** `AddLandblock` has -run into physics. Gate the hold on this, not on the GPU mesh alone. +**Readiness predicate — reuse the #107 login triplet (no new query).** The +hold gates on exactly the three checks the login auto-entry gate already uses +(`GameWindow.cs:1010-1024`), evaluated against the teleport's `(destPos, +destCell)` instead of the spawn claim: `SampleTerrainZ(destPos.X, destPos.Y) is +not null` (destination terrain applied) ∧ (outdoor cell OR +`IsSpawnCellReady(destCell)`); `IsSpawnClaimUnhydratable(destCell)` short-circuits +an impossible claim to immediate placement. This reuses proven, validated code +rather than introducing a parallel "landblock applied" query. **Dest-coord validation:** in `OnLivePositionUpdated`, reject a destination whose `(lbX, lbY)` is out of the world grid before recenter; log + abort the teleport @@ -288,7 +296,7 @@ command bus. 2. fake UpdatePosition(destCell) → validate dest coords → recenter streaming to dest lb → prioritize-load dest lb → re-send LoginComplete 3. HOLD (TeleportArrivalController.Tick, each frame in PortalSpace): - ready = IsLandblockApplied(destLb) && IsSpawnCellReady(destCell) + ready = SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell)) - not ready → stay frozen, retry [G.3c: tunnel holds in TAS_TUNNEL/_CONTINUE] - impossible → IsSpawnClaimUnhydratable → safety-net demote + loud log - timeout → force-snap + loud log + leave PortalSpace @@ -374,7 +382,7 @@ visual gate. | Unit | Location | Does | Depends on | |---|---|---|---| | `TeleportArrivalController` | `AcDream.App/World/` | Owns the `Idle/Holding/Placing` phase + `_pendingArrival`; decides hold-vs-place each frame | readiness predicate (injected), `Resolve` (injected), PortalSpace state | -| readiness predicate | `StreamingController` + `PhysicsEngine` | `IsLandblockApplied(lb)` ∧ `IsSpawnCellReady(cell)`; `IsSpawnClaimUnhydratable(cell)` | dat `LandBlockInfo`, `DataCache` | +| readiness predicate | `PhysicsEngine` (reused #107 triplet) | `SampleTerrainZ(pos)` ∧ (outdoor ∨ `IsSpawnCellReady(cell)`); `IsSpawnClaimUnhydratable(cell)` | `DataCache`, dat `LandBlockInfo` | | hydration decouple | `GameWindow.BuildInteriorEntitiesForStreaming` | `BuildLoadedCell` + `CacheCellStruct` gated on cellStruct/BSP, not render mesh | `cellStruct`, `PhysicsBSP` | | `TeleportAnimState` FSM (G.3c) | `AcDream.App` UI/render | Portal-tunnel fade FSM; hold-exit gated on the readiness predicate | `m_pPortalSpace` viewport, the readiness predicate | | recall builders (G.3d) | `AcDream.Core/Network/Actions` | Zero-payload outbound game actions | command bus | @@ -419,7 +427,8 @@ visual gate. (`IsSpawnClaimUnhydratable`); hold plausible-but-slow with a ~10 s ceiling; on timeout force-snap + loud log (fail visibly, never freeze). 3. **Big-jump streaming?** Verified to work (Chebyshev recenter). Add only - `IsLandblockApplied` + dest-coord validation. + dest-coord validation; the readiness gate reuses `SampleTerrainZ` (no new + streaming query). 4. **EnvCell placement vs flat terrain?** The #111 `WalkableFloorZNearest` EnvCell path (identical to the cellar path that already works); the flat terrain renders below. The gate guarantees the cell is hydrated before Resolve runs.