merge: bring main into claude/hopeful-maxwell-214a12 (LayoutDesc importer branch)

main was 65 commits ahead of this branch's fork point. Only conflict was the
divergence register: both sides appended an 'AP-32' row. Resolved by keeping
main's AP-32..AP-36 (cell-shell lift, look-in cells, alpha deferral, dungeon
streaming, point lights) and renumbering the importer's row to AP-37; AP header
count -> 37. GameWindow.cs auto-merged cleanly. Verified: AcDream.App builds
0/0; AcDream.App.Tests 354 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 16:19:15 +02:00
commit 5ac9d8c19c
53 changed files with 6691 additions and 439 deletions

View file

@ -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<PlaceCall> 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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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;
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
public enum ArrivalReadiness
{
/// <summary>Destination not yet hydrated; keep holding.</summary>
NotReady,
/// <summary>Destination terrain + cell are ready; place now.</summary>
Ready,
/// <summary>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.</summary>
Impossible,
}
/// <summary>Lifecycle of a single teleport arrival.</summary>
public enum TeleportArrivalPhase { Idle, Holding }
/// <summary>
/// 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 <c>GameWindow.OnLivePositionUpdated</c> that resolved the
/// arrival against the resident (old) landblocks before the destination hydrated
/// and landed the player in ocean.
///
/// <para>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
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
/// InWorld.</para>
///
/// <para>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.</para>
/// </summary>
public sealed class TeleportArrivalController
{
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
public const int DefaultMaxHoldFrames = 600;
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
private readonly Action<Vector3, uint, bool> _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<Vector3, uint, ArrivalReadiness> readiness,
Action<Vector3, uint, bool> place,
int maxHoldFrames = DefaultMaxHoldFrames)
{
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
_place = place ?? throw new ArgumentNullException(nameof(place));
_maxHoldFrames = maxHoldFrames;
}
/// <summary>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).</summary>
public void BeginArrival(Vector3 destPos, uint destCell)
{
_destPos = destPos;
_destCell = destCell;
_heldFrames = 0;
Phase = TeleportArrivalPhase.Holding;
}
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
/// No-op when Idle.</summary>
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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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<Vector3,uint,ArrivalReadiness>` + `Action<Vector3,uint,bool>` 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).

View file

@ -0,0 +1,455 @@
# Phase G.3 — Dungeon Support (Design Spec)
> **Status:** APPROVED design (brainstorm 2026-06-13). Next: `writing-plans`.
> **Milestone:** M1.5 ("Indoor world feels right"). G.3 is the remaining M1.5
> exit-gate. M2 (CombatMath) stays deferred until this lands.
> **Issue:** [#133](../../ISSUES.md) (teleport-into-dungeon snaps to ocean) +
> [#95](../../ISSUES.md) (dungeon portal-graph visibility blowup — re-assessed
> below).
> **Supersedes** the §12 port-plan of
> [`docs/research/deepdives/r09-dungeon-portal-space.md`](../../research/deepdives/r09-dungeon-portal-space.md):
> most of R9's "new types" (EnvCell loader/renderer/physics, PortalVisibility
> BFS, multi-cell transit) already shipped and power the building/cellar demo.
> r09 stays the **retail contract reference** for the wire formats, the
> EnvCell/CellPortal layout, and the recall taxonomy.
---
## 0. TL;DR
Dungeons don't work because of **one timing+placement gap on one code path**,
not a terrain-less-pipeline rewrite. A dungeon landblock (e.g. `0x0125`, the
Holtburg-area meeting hall) is a **flat-terrain** landblock (`LandBlock`
present, all-zero heights) + 71 EnvCells + no buildings — it already streams,
renders, and collides through the existing pipeline. The teleport-arrival
handler snaps the player **before** that landblock has streamed in, so Resolve
falls back to the resident Holtburg blocks and lands the player in ocean.
The fix is retail's own shape: **hold the player in portal space until the
destination cell is hydrated, then place into the EnvCell** — reusing the
#107/#111 login machinery — and then layer retail's portal-tunnel visual
(`TeleportAnimState`) on top. We ship it in four installments, gated by one
visual acceptance test.
---
## 1. Corrected root cause (verified)
### 1.1 The "terrain-less landblock" framing is WRONG (dat-verified)
A prior research pass assumed dungeon landblocks have no `LandBlock` record, so
`LandblockLoader.Load` returns null and the whole streaming/render/physics
pipeline needs terrain-less support. **A direct dat probe
(`DungeonLandblockDatProbeTests`, committed) refutes that:**
```
0x0125 (dungeon): LandBlock 0x0125FFFF PRESENT, Height[81] allZero=True (flat)
LandBlockInfo: NumCells=71, Buildings=0, Objects=0
EnvCells 0x0100.. present (the 71 dungeon rooms)
0xA9B4 (Holtburg): LandBlock PRESENT, heights non-zero; NumCells=123, Buildings=12, Objects=114
```
A dungeon landblock is a **flat-terrain landblock** (lowest/"ocean" terrain
height index) **plus its EnvCells, no buildings/objects**. `LandblockLoader.Load`
returns a valid flat landblock; the terrain mesh builds a flat plane;
`PhysicsEngine.AddLandblock` gets a valid flat `TerrainSurface`. **The existing
pipeline already streams a dungeon landblock.** This matches ACE's `IsDungeon`
(all heights 0 + `NumCells > 0` + no buildings — `Landblock.cs:575`) and the
single-landblock rule (`Player_Tick.cs:548-560` forbids moving between dungeon
landblocks without a teleport — so "multi-landblock dungeon LOD" is moot).
### 1.2 The real blocker: teleport TIMING + PLACEMENT
`OnLivePositionUpdated` (`src/AcDream.App/Rendering/GameWindow.cs:4877-4961`)
detects teleport arrival as **any** player position update while in PortalSpace
(correct, per #107), then **unconditionally**:
1. Recenters streaming to the destination landblock (`_liveCenterX/Y`, `:4908-4925`).
2. **Immediately** calls `_physicsEngine.Resolve(destPos, destCell, …)` to snap
the player (`:4927-4931`) — **before the destination landblock has streamed in**.
3. Snaps entity + controller (`:4935-4939`), exits PortalSpace (`:4950`), sends
`LoginComplete` (`:4953-4959`).
Because the dungeon landblock isn't resident yet, Resolve can't find the
destination cell, falls back to an **outdoor scan against the still-resident
Holtburg landblocks**, and snaps to `0xA9B3000E` (Holtburg's south edge — local
`(30,60)` maps into the block south of the A9B4 spawn). Streaming then shifts
the frame out from under the player → they slide south into ocean. ACE logs the
matching `failed transition for +Acdream from 0x01250126 … to 0xA9B0000E …`
chain (captured in `launch-dungeon-diag.log`).
**There is no hold-until-hydration on the teleport-arrival path.** The #107
*login* path directly above it (`GameWindow.cs:1010-1024`) HAS exactly this gate;
the teleport path doesn't.
---
## 2. Grounded seam facts (the design rests on these)
All five verified against current code this session (high confidence).
### 2.1 Teleport-arrival + PortalSpace FSM
- `OnTeleportStarted` (`GameWindow.cs:~4971-4976`) — on `PlayerTeleport (0xF751)`
sets `_playerController.State = PlayerState.PortalSpace`, freezing movement.
- `PlayerMovementController.Update` (`PlayerMovementController.cs:840-854`) returns
a zero-movement result while `State == PortalSpace` — **PortalSpace already
doubles as the input-freeze.** It can equally serve as the hydration-wait gate.
- Exit is **only** via the arrival detection in `OnLivePositionUpdated`
(`:4880`). No timeout, no cell-hydration gate today.
### 2.2 #107/#111 login machinery (directly reusable)
- `PhysicsEngine.IsSpawnCellReady(cellId)` (`PhysicsEngine.cs:468-472`): outdoor
(`cellId & 0xFFFF < 0x0100`) → always ready; indoor → `DataCache.GetCellStruct(cellId)
is not null` (the cell's physics BSP has hydrated).
- `IsSpawnClaimUnhydratable(claim)` (`GameWindow.cs:11728-11748`): fetches the dat
`LandBlockInfo` at `(lb & 0xFFFF0000) | 0xFFFE`; a claim whose low word is
`>= 0x0100 + NumCells` (or `NumCells==0`) can **never** hydrate → reject fast
(distinguishes a bogus claim from a not-yet-streamed one).
- #107 login hold (`GameWindow.cs:1010-1024`): `isSpawnGroundReady` waits for
terrain AND (claim outdoor OR `IsSpawnCellReady` OR `IsSpawnClaimUnhydratable`).
No timeout today (login can afford to wait forever; teleport cannot — see §5).
- #111 validated-claim placement (`PhysicsEngine.cs:626-646`): when
`snapDiag (zero-delta) && adjustedFound && indoor`, place via
`WalkableFloorZNearest` (`:383-406`) — projects Z onto the claim cell's **own
physics walkable polygons** (`normal.Z >= PhysicsGlobals.FloorZ`, 0.6642),
cell-local, nearest to the reference Z. Returns `null` if the cell isn't
hydrated → falls through to the legacy `bestCell` scan (**the ocean bug**).
- **The teleport-arrival Resolve call is already the same shape as login entry.**
The gate only needs to sit in front of it; no change to Resolve or
WalkableFloorZNearest. (Both already key on the full prefixed cell id +
indoor/outdoor.)
### 2.3 Streaming far recenter (works as-is)
- `StreamingRegion.RecenterTo` (`StreamingRegion.cs:180-283`) recomputes the
near/far Chebyshev window **from scratch** around the new center — a 42 km jump
is treated identically to a 1-step move. No incremental-movement assumption.
- Drain: `StreamingController` applies ≤ `MaxCompletionsPerFrame` (default 4)
results/frame; `ApplyLoadedTerrainLocked` (`GameWindow.cs:5941-6150`) does GPU
upload + cell-visibility registration + AABB + `PhysicsEngine.AddLandblock` +
EnvCell/portal registration. Estimate: **~7-8 frames (~120-130 ms)** to hydrate
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:** 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
`BuildLoadedCell` (the portal-visibility node) **and**
`_physicsDataCache.CacheCellStruct` (the physics BSP) sit **inside** the render
guard `if (cellSubMeshes.Count > 0)` (`:5602`). A cell whose render mesh is empty
(`CellMesh.Build` returns nothing — e.g. all-untextured/`Stippling.NoPos` polys)
silently gets **no visibility node and no collision**, even if it has walkable
physics polygons. `CellTransit.FindTransitCellsSphere` then `GetCellStruct → null
→ continue` (silently skips it) → fall-through-floor.
- A normal dungeon *room* has textured walls → non-empty submeshes → the guard
passes, so this is **probably not the meeting-hall blocker** — but it is a real
correctness landmine for any geometry-less collision cell, and decoupling is
cheap and retail-correct (physics/visibility do not depend on visible geometry).
**Fix:** gate `CacheCellStruct` on `cellStruct.PhysicsBSP != null` and
`BuildLoadedCell` on `cellStruct != null`, independent of the render submesh
count. (`CacheCellStruct` already early-returns on null BSP internally —
`PhysicsDataCache.cs:172` — so moving it out is safe.)
### 2.5 #95 — dungeon portal-graph visibility blowup (RE-ASSESSED: likely superseded)
- ISSUES.md #95 (`888-913`): on a 2026-05-21 **A6.P1 scen5 (Town Network hub)**
trace, `visibleCells` per cell exploded to 135-145 with spurious cells from
landblocks `0x020A`/`0x0408` (other dungeons). Its "Files" point at the WB
`EnvCellRenderManager`/`VisibilityManager` + the Streaming cell-cache.
- **That code path was DELETED by the T1-T6 render rewrite (2026-06-11)** (T4:
"per-frame ACME BFS deleted… InteriorRenderer/DrawPortal deleted"). The current
flood, `PortalVisibilityBuilder.Build`, (a) confines neighbors to the camera
cell's landblock (`lbMask = cameraCell.CellId & 0xFFFF0000`, `:131`) and (b) has
**enqueue-once termination** (`queued` HashSet, `:165` — "at most N cells are
ever processed"). Since AC dungeons are single-landblock, that confinement is
*correct*, and the cross-landblock 135-cell blowup **structurally cannot
reproduce**: a single-landblock flood visits ≤ `NumCells` distinct cells (71 for
the meeting hall).
- **Verdict (pre-gate, 2026-06-13 AM):** #95's evidence is stale, from a deleted
path; the current pipeline looked bounded. Treated #95 as likely superseded.
- **⚠️ GATE CORRECTION (2026-06-13 PM — #95 is CONFIRMED LIVE):** the G.3a visual
gate ran a real `PlayerTeleport` into the `0x0007` dungeon (Town Network). The
core hold+place worked (player grounded on the dungeon floor, z=0 — no ocean),
but **WB-DIAG exploded to entSeen=6.5M / instances=9.1M / drawsIssued=590K per
frame** (vs. 3345 / 4667 at Holtburg), with a flood of `[mesh-miss] 0x000100xxxx`
interior re-requests → the dungeon renders as "thin air." **#95 reproduces under
the current Option-A pipeline.** The "bounded flood" reasoning was wrong for the
`0x0007` dungeon (the grounding agent's "still live" verdict was correct; this
doc over-discounted it). **G.3b is now REQUIRED, not conditional** (§3.2). The
retail-faithful fix shape stands: port `CEnvCell::grab_visible_cells` (:311878)
stab_list bounding — a `seen_outside==0` cell walks ONLY its `stab_list`.
---
## 3. The plan (Approach C — phased full-G.3)
Each installment lands a **complete retail behavior** (the BR-2 half-port
lesson). The visual gate sits as early as possible, right after the core.
### 3.1 G.3a — Core teleport-into-dungeon (the blocker)
**Goal:** teleporting into the meeting-hall dungeon lands the player standing in
the dungeon cell, on the floor, with walls blocking — no ocean, no ACE
`failed transition` spam.
**New component — `TeleportArrivalController`** (`src/AcDream.App/World/`):
- Owns a small phase: `Idle / Holding / Placing`, plus `_pendingArrival`
`(destPos, destCellId, deadline)`.
- Lives outside `GameWindow` (Code Structure Rule 1: no new feature bodies in the
god-object). `GameWindow.OnLivePositionUpdated` hands the arrival to it and
calls its per-frame `Tick`; `GameWindow` keeps only the wiring.
- Unit-testable in isolation (no GL, fake readiness predicate + fake Resolve).
**Control flow (replaces the unconditional snap at `GameWindow.cs:4927-4950`):**
1. On arrival update in PortalSpace: validate `destCellId`'s landblock coords are
in-world; recenter streaming + prioritize-load the dest landblock (existing
path); stash `_pendingArrival`; enter `Holding`. Re-send `LoginComplete`
immediately (holtburger-conformant — `messages.rs:434`; do **not** wait for
assets to send it).
2. Each frame in `Holding`, evaluate the **readiness predicate**:
- `IsSpawnClaimUnhydratable(destCell)` → impossible claim: stop holding, place
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.)
- `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.
**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
hold rather than recenter to a phantom block.
**Hydration decouple (§2.4):** move `BuildLoadedCell` + `CacheCellStruct` out of
the `cellSubMeshes.Count > 0` guard in `BuildInteriorEntitiesForStreaming`. Gate
each on its own non-null precondition.
**Acceptance (G.3a):** the visual gate in §6. This gate also empirically settles
#95 (does the flood blow up?) and the hydration coupling (does collision work?).
### 3.2 G.3b — #95 visibility bounding (REQUIRED — gate-confirmed 2026-06-13)
**The G.3a gate confirmed the blowup** (9.1M instances/frame in `0x0007`), so this
is the next blocker, not a conditional follow-up. The dungeon will not render
until the portal-visibility flood is bounded to the dungeon's own cell adjacency.
**Fix:** port retail `CEnvCell::grab_visible_cells` (`:311878`) — a cell with
`seen_outside == 0` loads ZERO terrain and walks ONLY its `stab_list` of adjacent
EnvCells; the portal graph is bounded by the dungeon's own cell adjacency, never a
radius / never the whole resident cell set. This is a render-pipeline change in
`PortalVisibilityBuilder` (the flap-/DO-NOT-RETRY-sensitive area) and needs its own
grounding + brainstorm before implementation (verify the dat carries the stab_list
and acdream's EnvCell loader parses it; confirm the `seen_outside` flag is read;
decide how it composes with the outdoor-root look-in floods). **NOT a wing-it
inline fix.**
**Open question surfaced at the gate (possible Bug C):** even with Bug A fixed
(placement keeps the dungeon prefix, `2ce5e5c`), the dungeon's negative-local-Y
coordinate frame may cause the per-tick membership/landblock resolution to drift
(the ACE `movement pre-validation failed` spam). Re-gate after Bug A to see if it
persists; if so, fold the dungeon-coordinate membership handling into G.3b's
grounding (it is plausibly the same `seen_outside` / cross-landblock root as #95).
### 3.3 G.3c — Portal-tunnel loading visual (faithful `TeleportAnimState`)
**Goal:** the retail portal-space transition, ported faithfully (user decision
2026-06-13). Reconciles the older r09 §6 ("there is no loading screen") with the
named-retail decomp where this FSM actually lives.
**Oracle:** `gmSmartBoxUI::BeginTeleportAnimation` (`004d6300`, named-retail line
218888) + the per-frame FSM (`219405-219774`). States:
`TAS_WORLD_FADE_OUT → TAS_TUNNEL_FADE_IN → TAS_TUNNEL / TAS_TUNNEL_CONTINUE →
TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → (off)`. `m_pPortalSpace` is a
`UIElement_Viewport` rendering the tunnel scene (creature-mode objects +
`DISTANT_LIGHT` + smartbox FOV; `SetVisible(1)` on enter, `SetVisible(0)` on the
`TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN` edge at `219742-219747`).
**Key architectural unification:** the `TAS_TUNNEL`/`TAS_TUNNEL_CONTINUE` **hold
state's exit gates on the same readiness predicate as G.3a** — retail's loading
visual and the hold-until-hydration gate are *one mechanism* (the tunnel is the
visual form of the hold). G.3a ships the bare PortalSpace freeze; G.3c wraps it
in the tunnel viewport + the fade FSM, exit-gated identically.
**Port workflow:** grep-named → decompile `BeginTeleportAnimation` + the FSM →
pseudocode (durations, fade math, viewport scene construction) → port → test.
Detail deferred to the G.3c implementation phase; this spec fixes the design
(states, transitions, the readiness-gated hold) + the oracle pointers.
### 3.4 G.3d — Recall game-actions
Outbound **zero-payload** game-action builders (r09 §7.1): `TeleToLifestone
0x0063`, `TeleToHouse 0x0262`, `TeleToMansion 0x0278`, `TeleToMarketPlace 0x028D`,
`RecallAllegianceHometown 0x02AB`, `TeleToPkArena 0x0027`. The client only sends
the request; the server validates, plays the recall animation, then drives the
**same** `PlayerTeleport → UpdatePosition` arrival flow.
Value: (1) doubles as the **easy test lever** for G.3a/G.3c — `/ls` triggers a
teleport with no portal-click choreography; (2) completes the recall UX (keybinds
exist; the wire sends + return handling did not). Wire through the existing
command bus.
---
## 4. Data flow (the teleport happy path)
```
1. PlayerTeleport(0xF751) → OnTeleportStarted: enter PortalSpace, freeze input
[G.3c: BeginTeleportAnimation(TAS_WORLD_FADE_OUT)]
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 = 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
4. READY → Resolve(destPos, destCell) → #111 validated-claim branch
→ WalkableFloorZNearest places on the EnvCell floor
→ SetPosition(entity + controller) → exit PortalSpace, resume input
[G.3c: TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → off]
```
(ACE server send-order, for reference — `Player_Location.Teleport:686`:
`PlayerTeleport(seq)` → fake `UpdatePosition` (start client load) →
`DoTeleportPhysicsStateChanges` (hidden / ignoreCollisions) → real
`UpdatePosition``OnTeleportComplete` after `CreateWorldObjectsCompleted`.)
---
## 5. Error handling
| Failure | Handling | No-workaround rationale |
|---|---|---|
| Impossible / poisoned claim (cell id ∉ `[0x0100, 0x0100+NumCells)`, or no struct + no surface) | `IsSpawnClaimUnhydratable` → safety-net demote (`PhysicsEngine.Resolve` head, `:536-570`) + loud log; never hold forever | Reuses the validated #107/#111 reject; no new masking |
| Dest LB fails to stream (worker crash / corrupt dat / OOB coords) | Timeout ceiling (~10 s) → force-snap + loud log + leave PortalSpace | **Surfaces** the failure (visible bad placement + log), does not freeze the client or silence the cause; gets a divergence-register row |
| Mid-hold entity-rescue race | Already serialized by `_datLock` during recenter (verified, seam-3) | No change |
The timeout is the one judgment call: holding forever on a never-hydrating
landblock would soft-lock the client. The chosen behavior **fails loudly and
visibly** (force-snap + log), which is the opposite of a symptom-masking grace
period — it makes a broken teleport obvious rather than hiding it. It is recorded
as a deliberate adaptation (retail loads synchronously; async streaming has no
direct analog).
---
## 6. Testing & acceptance
### 6.1 Headless / unit
- `TeleportArrivalController` FSM: `Idle → Holding → Placing` happy path;
impossible-claim immediate reject; timeout force-snap; ready-predicate gating
(fake `IsLandblockApplied` / `IsSpawnCellReady`).
- Hydration-decouple test: a geometry-less EnvCell (empty render mesh, non-empty
physics BSP) still gets `CacheCellStruct` + `BuildLoadedCell`.
- `TeleportFlowTests`: fake `PlayerTeleport` + `UpdatePosition` wire → controller
phase transitions + input-gate flips.
- `DungeonLandblockDatProbeTests` (exists): pins `0x0125` = flat + 71 cells.
- G.3c: `TeleportAnimState` FSM transition test (state sequence + the
readiness-gated `TAS_TUNNEL` hold-exit).
- G.3d: recall-builder byte tests (opcode + empty payload, per builder).
### 6.2 Visual gate (the acceptance test — after G.3a)
Teleport into the meeting-hall dungeon via the portal:
- Player stands **in the dungeon cell**, on the floor (not ocean, not falling).
- The dungeon renders; navigate **35 rooms**; **walls block** movement.
- **No ocean / no ACE `failed transition` spam.**
- (Implicitly) the portal flood does **not** blow up (#95 check) and collision
works in every room (hydration-coupling check).
`ACDREAM_PROBE_CELL=1` + `ACDREAM_PROBE_VIEWER=1` + `ACDREAM_WB_DIAG=1` + the
always-on `[snap]` / `live: teleport` lines capture the chain (the
`launch-dungeon-diag.log` protocol from this session).
### 6.3 Per-installment build/test gates
Each installment: `dotnet build` green + `dotnet test` green
(App / Core / UI / Net suites) before it's "done"; G.3a additionally requires the
visual gate.
---
## 7. Retail divergence register impact
- **G.3a timeout force-snap** → NEW row (adaptation: async streaming hold has no
synchronous-retail analog; retail loads the cell set synchronously before
`SetPositionInternal`).
- **Hydration decouple** → NO row (bug fix retiring an incidental render↔physics
coupling; restores retail-correct independence).
- **G.3c** → only a row if a faithful asset can't be reproduced (e.g. the tunnel
viewport scene) and a documented courtesy substitute is shipped.
- **#95 close-as-superseded** (if G.3b not triggered) → ISSUES.md note only.
---
## 8. Component boundaries (what each unit does / depends on)
| 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 | `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 |
`AcDream.Core` gains no GL/window dependency. The controller + FSM live in
`AcDream.App`; the readiness predicate's physics half lives in `AcDream.Core`
(pure), its streaming half in `AcDream.App`.
---
## 9. References cited
- **Current code (verified this session):** `GameWindow.cs` 4877-4961 (arrival),
~4971-4976 (`OnTeleportStarted`), 1010-1024 (#107 login gate), 11728-11748
(`IsSpawnClaimUnhydratable`), 5564-5651 (EnvCell hydration guard), 5941-6150
(`ApplyLoadedTerrainLocked`); `PhysicsEngine.cs` 468-472 (`IsSpawnCellReady`),
626-646 (#111 validated claim), 383-406 (`WalkableFloorZNearest`), 536-570
(Resolve safety net); `StreamingRegion.cs` 180-283 (`RecenterTo`);
`StreamingController.cs` 120-149 (drain); `PortalVisibilityBuilder.cs` 131
(lbMask), 165 (enqueue-once); `CellTransit.cs` 515-516 (null-skip);
`PhysicsDataCache.cs` 172 (null-BSP early-return).
- **Decomp (named-retail):** `BeginTeleportAnimation` `004d6300` (line 218888) +
the `TeleportAnimState` FSM 219405-219774; `m_pPortalSpace` viewport
218829/219363; `CEnvCell::grab_visible_cells` `:311878` (G.3b stab_list).
- **holtburger:** `messages.rs:434` (client re-sends `LoginComplete` on teleport).
- **ACE:** `Player_Location.Teleport:686` (send order); `Landblock.cs:575`
(`IsDungeon`); `Player_Tick.cs:548-560` (single-landblock dungeons); recall
handlers + `Portal.ActOnUse`/`AdjustDungeon`.
- **r09 deepdive:** `docs/research/deepdives/r09-dungeon-portal-space.md` (EnvCell
/ CellPortal wire layout, recall taxonomy, the retail contract).
- **Issues:** [#133](../../ISSUES.md), [#95](../../ISSUES.md).
- **Digests (DO-NOT-RETRY tables apply):** `project_render_pipeline_digest`,
`project_physics_collision_digest`.
---
## 10. Open questions (resolved here; revisit only if the gate disagrees)
1. **Loading visual now or later?** Faithful `TeleportAnimState` in G.3c (user
decision). Unified with the G.3a hold (the tunnel IS the hold's visual).
2. **Hold timeout/failure?** Reject impossible claims instantly
(`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
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.
5. **(New, deferred to G.3b/implementation)** Does the dat carry a parsed
`stab_list` for `grab_visible_cells` bounding? Only matters if the gate shows
the #95 blowup.