handoff: M1.5 dungeon support (G.3) grounded — design research + the terrain-less-premise refutation

Adds the 2026-06-13 dungeon-G.3 handoff doc + a dat-probe test that
RESOLVED the pivotal design ambiguity. A research agent assumed dungeon
landblocks are terrain-less (LandblockLoader.Load returns null ->
"rewrite the pipeline for terrain-less landblocks", 13 seams). The dat
probe refutes it: dungeon landblock 0x0125 has a flat (all-zero-height)
LandBlock record PLUS 71 EnvCells and no buildings/objects -> it streams
fine via the existing pipeline as a flat-terrain landblock.

The real blocker (#133) is narrow: the teleport-arrival handler
(GameWindow.cs:4928) snaps the player via physics.Resolve BEFORE the
dungeon landblock streams in -> Resolve falls back to the resident
Holtburg landblocks -> places the player at A9B3 ocean. Fix shape:
hold-until-hydration (reuse the #107 IsSpawnCellReady gate for the
teleport-arrival path) + place into the EnvCell + the retail
TeleportAnimState portal-space FSM for the full-G.3 loading screen.
ACE confirms dungeons are single-landblock, so "multi-landblock LOD"
is moot.

The handoff captures: this session's closes (#108-residual/#127/#125
gated, #116 partial), the M1.5 re-open decision, the corrected root
cause, the 5-way reference grounding (holtburger/ACE/retail decomp +
the dat probe), the design direction, and the open brainstorm questions.

Next session: resume the brainstorm at "propose approaches" -> spec ->
writing-plans -> implement.

Suites green: App 264+1skip / Core 1445+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-13 14:53:27 +02:00
parent 9c2ceb2336
commit 90786c19e2
2 changed files with 281 additions and 0 deletions

View file

@ -0,0 +1,205 @@
# Handoff (2026-06-13): M1.5 EXTENDED — dungeon support (full Phase G.3). Design grounded; ready to brainstorm → spec → implement.
**Branch:** `claude/thirsty-goldberg-51bb9b`, pushed to BOTH remotes at the
HEAD this doc commits with. Suites green: App 264+1skip / Core 1445+2skip /
UI 420 / Net 294 (the dungeon dat-probe test added this session is
output-only).
This session closed a batch of M1.5 render/physics issues, then — at the
dungeon-demo gate — discovered dungeons don't work and the user **extended
M1.5 to include full dungeon support (Phase G.3)**. M2 is re-deferred. The
design is grounded (5-way reference research + a decisive dat probe); the
next session brainstorms approaches → writes the spec → implements.
---
## 1. What this session shipped (all on branch, pushed, most user-gated)
| Item | Outcome | Commits |
|---|---|---|
| **#108-residual** (cellar grass window) | CLOSED, user-gated "Yes it is fixed." Terrain was drawn DOUBLE-SIDED; the grass was the grade sheet's underside seen from a below-grade cellar eye. Ported retail `landPolysDraw` eye-side gate as terrain backface cull. Membership/viewer EXONERATED by a vertical cellar-ascent harness. | `007af13`, `96a425a`, `bf80067` |
| **#127** (distant-building flood flap) | CLOSED, user-gated "Seems to have been fixed." Died with the W=0 clip port (`987313a`); confirmed by a run-past churn detector (0 churn, 21 buildings × 5 distances × 100 mm-steps). | `4ad6fb9` |
| **#125** (sticky-drop debt) | CLOSED. Bounded upload retry — a failed `UploadMeshData` re-stages for the next frame up to `MaxUploadRetries` (counter on the `ObjectMeshData`); the CPU-cache short-circuit no longer permanently strands a failed upload. | `8682a8d` |
| **#116** (slide-response) | PARTIAL. Ghidra (the user pointed me to the running Ghidra MCP) resolved the BN `test ah,5` branch-sign ambiguity: `slide_sphere` compares squared magnitudes against `F_EPSILON` (0.0002), not `EpsilonSq` (4e-8) — fixed `TransitionTypes.cs:3098,3105`, full physics suite green. The two reported shapes still need a cdb trace (shape-1 = upstream collision-normal recording; shape-2 = D4 first-frame dispatch). | `35961f2`, `bf18a54` |
---
## 2. The milestone churn (read this — the docs were corrected)
- I briefly marked **M1.5 LANDED** on the building/cellar demo and started M2
(`1bf037a`). **The user reverted that:** the indoor world isn't done while
dungeons are broken, so M1.5 is EXTENDED to include dungeon support, and the
user chose the **FULL Phase G.3 scope** (streaming + portal-space loading
screen + `PlayerTeleport` handling). Correction committed `9c2ceb2`.
- **Current truth:** M1.5 ACTIVE; building/cellar demo DONE + user-gated;
dungeon support (G.3) is the remaining M1.5 exit-gate. M2 (CombatMath first
port) DEFERRED. Docs reflect this (milestones doc, CLAUDE.md current-state,
ISSUES.md #133).
---
## 3. The dungeon bug — CORRECTED root cause (issue #133)
User attempted the dungeon demo via the **meeting-hall portal** → "no dungeon,
just ocean." ACE logged a flood of `failed transition for +Acdream from
0x01250126 [30 -60 6.0] to 0xA9B0000E [-32227 -26748 5.9]` … marching south at
Z≈0.9 (underwater).
**Diagnostic capture (`launch-dungeon-diag.log`, probes
`ACDREAM_PROBE_CELL`/`ACDREAM_PROBE_VIEWER`/`ACDREAM_WB_DIAG`):**
```
live: teleport arrival — old lb=(169,180) new lb=(1,37) dist=42524.0
[snap] claim=0xA9B3000E pos=(30,-60,6.005) cells=17 bestCell=0xA9B30103 ... indoor=False -> targetCell=0xA9B3000E
live: teleport complete — snapped to <30,-60,6.005> cell=0xA9B3000E
[cell-transit] A9B3000E -> A9B2000E -> A9B1000E -> ... (sliding south into ocean)
```
ACE correctly placed the player in dungeon cell **0x01250126** (landblock
`0x0125` = (1,37)). acdream's arrival handler (`GameWindow.cs:4908-4931`)
recenters streaming to (1,37) but then **immediately** calls
`_physicsEngine.Resolve(pos=(30,-60,6.005), cell=0x01250126)` to snap the
player — **before the dungeon landblock has streamed in**. Resolve can't find
the dungeon 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 → slides south into ocean.
### ⚠️ The "terrain-less landblock" framing is WRONG (verified by dat probe)
A pipeline-seam research agent *assumed* dungeon landblocks have no `LandBlock`
record (so `LandblockLoader.Load` returns null) and produced a 13-seam
"rewrite the pipeline for terrain-less landblocks" plan. **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** (all-zero height index =
the lowest/"ocean" terrain) **plus its EnvCells, no buildings/objects**. So
`LandblockLoader.Load(0x0125…)` returns a valid flat landblock, the terrain
mesh builds a flat plane, and `PhysicsEngine.AddLandblock` gets a valid flat
`TerrainSurface`. **The existing pipeline can already stream a dungeon
landblock.** The 13 terrain-dependency seams are NOT the blocker.
**The real blocker is narrow: teleport TIMING + PLACEMENT.**
---
## 4. Reference grounding (5-way research; dat agent failed, replaced by the probe above)
**holtburger (client-behavior oracle):**
- PlayerTeleport (0xF751) → enter `EnteringWorld` (portal space), **suspend
physics bodies**, send **LoginComplete immediately** (no waiting for assets).
- Exit portal space → `InWorld` when the server sends ObjectCreate (entities) +
UpdatePosition (player) + the **StartGame** event → resume bodies.
- holtburger does NOT stream landblocks (entity-centric); not our model — we
DO stream from our own dats. Take the **FSM shape** (EnteringWorld/InWorld +
suspend/resume) not the no-streaming part.
- DDD is NOT part of the teleport flow (responds empty). (`messages.rs:480-486`,
`:190-195`, `player.rs:71-79`, `types.rs:169-175`.)
**ACE (server):** `Player_Location.cs:654-707` Teleport() sends PlayerTeleport
(sequence) → a **fake UpdatePosition** to trigger client load → the real
UpdatePosition with PositionPack (CellID = dungeonID<<16 | cellIndex, e.g.
`0x01250126`, xyz, rotation). **Server sends NO geometry — client loads cells
from its own dats by cellID** (matches our dat-driven model). Portal:
`Portal.cs:269-292` ActOnUse → AdjustDungeon (corrects cell id) →
ThreadSafeTeleport. **Dungeons are SINGLE-landblock** (`Player_Tick.cs:548-560`
forbids moving between dungeon landblocks without teleport) → "multi-landblock
LOD" in the full-G.3 scope is MOOT for AC dungeons. IsDungeon = all heights 0 +
NumCells>0 + no buildings (`Landblock.cs:575-631`).
**Retail decomp (client):** terrain (`CLandBlock::grab_visible_cells`) and
dungeon cells (`CEnvCell::grab_visible_cells`, :311878) load on **separate
paths**; a cell with `seen_outside==0` loads ZERO terrain and walks only its
`stab_list` (adjacent EnvCells). **Portal-space = a 6-state `TeleportAnimState`
FSM** (:219682-219774): WORLD_FADE_OUT → TUNNEL_FADE_IN → TUNNEL (hold while
loading) → TUNNEL_FADE_OUT → WORLD_FADE_IN → OFF; `m_pPortalSpace` is the
tunnel viewport (the "loading"/black screen). Retail gates cell-ready on DDD
(server cell push) — **we don't need DDD** (we have the dats); we gate on our
own streaming hydration. Open: no distinct "pink screen" asset found — retail's
loading visual is the portal tunnel.
**acdream pipeline seams (corrected by the dat probe):** the dungeon landblock
streams fine as flat-terrain. Real seams that matter:
- `GameWindow.cs:4908-4931` — teleport arrival: recenter then **Resolve
immediately** (the bug). No hold-until-hydration.
- `PhysicsEngine.IsSpawnCellReady` (`:468`) — the EXISTING #107 gate; already
handles indoor cells (checks DataCache for 0x0100+). **Reuse it for the
teleport-arrival path.**
- EnvCell hydration (render `_cellVisibility`/`EnvCellRenderer`; physics
`CacheCellStruct`) is iterated from `LandBlockInfo.NumCells` and is
**orthogonal to terrain** — should fire for a dungeon landblock once it
streams (`GameWindow.cs:5564-5576`, `6015-6028`). VERIFY it does.
- Placement: the player is at cell `0x01250126`, pos (30,60,6.005); must be
placed in the **EnvCell** (the #107/#111 validated-claim path,
`WalkableFloorZNearest`), not on the flat terrain.
---
## 5. Design direction (to confirm in the brainstorm)
A retail-faithful, much-narrower-than-feared shape:
1. **Teleport state machine (portal space).** On PlayerTeleport: enter a
PortalSpace/EnteringWorld state, suspend player physics, (optionally) start
the retail `TeleportAnimState` tunnel FSM for the loading visual. On arrival
UpdatePosition: recenter streaming on the destination landblock, then **HOLD**
— do not snap — until the destination landblock + the claimed EnvCell hydrate
(reuse `IsSpawnCellReady`). Then place into the EnvCell (validated-claim
path), exit PortalSpace → InWorld, resume physics, send LoginComplete.
(acdream already has `OnTeleportStarted`/portal-space + the #107 hold for
LOGIN — extend that machinery to the teleport-arrival path rather than
snapping at `:4928`.)
2. **Streaming a far teleport.** Confirm the recenter actually drives the
streamer to load the destination dungeon landblock (the Chebyshev window
around the new center) and unloads the old neighborhood without stranding the
player. The dungeon streams as a flat-terrain landblock — no new loader path
needed, but verify the apply path + EnvCell hydration fire.
3. **Render/physics in the dungeon.** Once the EnvCells hydrate, the existing
PView indoor render + per-cell collision should work (same as buildings).
The flat terrain renders below; PView roots at the viewer EnvCell. VERIFY the
3-5-room navigation, walls block, stairs, lighting (A7 not done — expect
lighting findings), transitions.
4. **Portal-space loading screen (full-G.3 polish).** The retail 6-state tunnel
FSM (`TeleportAnimState`) — implement after the core teleport+place works, or
a simpler fade-to-black first.
**Open design questions for the brainstorm:**
- Do we implement the retail `TeleportAnimState` tunnel FSM faithfully now, or a
simpler fade-to-black for M1.5 and the full tunnel later?
- How long to HOLD before giving up (the dungeon may need several frames to
stream); what's the failure/timeout behavior?
- Does the existing streaming controller already load a landblock 42 km away on
recenter, or does it assume incremental movement? (Confirm the recenter→load
path for a big jump.)
- Placement: the cell-local pos (30,60,6.005) vs the EnvCell origins (~(0,30,0))
— confirm the EnvCell BSP contains the point and the #107/#111 walkable-floor
placement lands the player on the dungeon floor.
---
## 6. Apparatus added this session (kept)
| Tool | How | For |
|---|---|---|
| `DungeonLandblockDatProbeTests` | `dotnet test --filter DungeonLandblockDatProbe` | Dumps the dat structure of a dungeon (0x0125) vs outdoor (A9B4) landblock — the terrain-less-vs-flat resolution |
| `launch-dungeon-diag.log` | `ACDREAM_PROBE_CELL=1 ACDREAM_PROBE_VIEWER=1 ACDREAM_WB_DIAG=1` | The teleport→snap→slide capture; `[snap]`/`[cell-transit]`/`live: teleport` lines are the chain |
| `Issue108CellarAscentViewerReplayTests` | App.Tests filter | Vertical cellar-ascent viewer harness (membership EXONERATED for #108) |
| `Issue127FloodFlipReplayTests.DistantBuildingStrafe_NoAdmissionChurn` | App.Tests filter | #127 run-past churn-detector regression pin |
Decomp grounding: holtburger teleport flow, ACE Teleport/Portal/AdjustDungeon,
retail `CEnvCell::grab_visible_cells` (:311878) + `TeleportAnimState` FSM
(:219682-219774). Full raw research in the workflow output (this session).
---
## 7. Next session: brainstorm → spec → implement
The brainstorming skill was invoked and scope was set (full G.3). The next
session resumes the brainstorm at "propose 2-3 approaches" with the grounding
above, settles the design, writes the spec to
`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`, then →
writing-plans → implement. The paste-ready pickup prompt is in the session
message that produced this doc.

View file

@ -0,0 +1,76 @@
using System.Linq;
using DatReaderWriter;
using DatReaderWriter.Options;
using DatLandBlock = DatReaderWriter.DBObjs.LandBlock;
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Conformance;
/// <summary>
/// G.3 dungeon-support research probe (2026-06-13): resolve the pivotal
/// terrain-less-vs-ocean ambiguity for the meeting-hall dungeon landblock
/// 0x0125 (the teleport this session went to cell 0x01250126). Does a dungeon
/// landblock have a LandBlock (0xXXYYFFFF) terrain record at all, or only
/// LandBlockInfo + EnvCells? Output-only — no assertions.
/// </summary>
public sealed class DungeonLandblockDatProbeTests
{
private readonly ITestOutputHelper _out;
public DungeonLandblockDatProbeTests(ITestOutputHelper output) => _out = output;
[Fact]
public void Probe_Dungeon0125_vs_Holtburg_A9B4()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
foreach (uint lb in new uint[] { 0x0125u, 0xA9B4u })
{
_out.WriteLine($"=== landblock 0x{lb:X4} ===");
uint terrainId = (lb << 16) | 0xFFFFu;
var block = dats.Get<DatLandBlock>(terrainId);
if (block is null)
{
_out.WriteLine($" LandBlock 0x{terrainId:X8}: NULL (no terrain record)");
}
else
{
var heights = block.Height;
bool allZero = heights is not null && heights.All(h => h == 0);
int distinct = heights is null ? 0 : heights.Distinct().Count();
_out.WriteLine($" LandBlock 0x{terrainId:X8}: present, Height[{heights?.Length ?? 0}] allZero={allZero} distinctIndices={distinct} first8=[{(heights is null ? "" : string.Join(",", heights.Take(8)))}]");
}
uint infoId = (lb << 16) | 0xFFFEu;
var info = dats.Get<DatLandBlockInfo>(infoId);
if (info is null)
{
_out.WriteLine($" LandBlockInfo 0x{infoId:X8}: NULL");
}
else
{
_out.WriteLine($" LandBlockInfo 0x{infoId:X8}: NumCells={info.NumCells} Buildings={info.Buildings?.Count ?? 0} Objects={info.Objects?.Count ?? 0}");
}
// probe the first few EnvCells
int found = 0;
for (uint low = 0x0100u; low < 0x0110u; low++)
{
uint cellId = (lb << 16) | low;
var cell = dats.Get<DatEnvCell>(cellId);
if (cell is not null)
{
found++;
if (found <= 3)
_out.WriteLine($" EnvCell 0x{cellId:X8}: present, CellStructure={cell.CellStructure} Portals={cell.CellPortals?.Count ?? 0} pos=({cell.Position.Origin.X:F1},{cell.Position.Origin.Y:F1},{cell.Position.Origin.Z:F1})");
}
}
_out.WriteLine($" EnvCells 0x0100..0x010F present: {found}");
}
}
}