diff --git a/docs/research/2026-06-13-dungeon-g3-handoff.md b/docs/research/2026-06-13-dungeon-g3-handoff.md
new file mode 100644
index 00000000..20f818dc
--- /dev/null
+++ b/docs/research/2026-06-13-dungeon-g3-handoff.md
@@ -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.
diff --git a/tests/AcDream.Core.Tests/Conformance/DungeonLandblockDatProbeTests.cs b/tests/AcDream.Core.Tests/Conformance/DungeonLandblockDatProbeTests.cs
new file mode 100644
index 00000000..e1fe5a96
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Conformance/DungeonLandblockDatProbeTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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(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(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(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}");
+ }
+ }
+}