acdream/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md
Erik c9650bd3bd plan(G.3a): core teleport-into-dungeon implementation plan (#133)
TDD plan for the gated G.3a core: a pure TeleportArrivalController state machine
(hold-until-hydration + force-snap on impossible/timeout) + its GameWindow wiring
(replace the unconditional arrival snap with recenter + deferred BeginArrival;
per-frame Tick; readiness predicate reusing the #107 login triplet) + the EnvCell
physics/visibility hydration decouple + the visual acceptance gate. G.3b/c/d get
their own plans after the gate.

Also syncs the spec: the readiness predicate reuses SampleTerrainZ + IsSpawnCellReady
+ IsSpawnClaimUnhydratable (the validated #107 login gate) rather than a new
IsLandblockApplied query — strictly more faithful, less new surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:02:03 +02:00

28 KiB
Raw Blame History

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 (§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:

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:

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
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):

// 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:

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:

        // 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:

        // 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
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:

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:

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
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
$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).