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>
28 KiB
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-frameTickafter: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 transitionspam (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.mdwith 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 2TeleportArrivalReadiness. - #111 validated-claim EnvCell placement → Task 2
PlaceTeleportArrival(unchangedResolve). - 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).