# R9 — Dungeon Streaming & Portal Space (Inter-Landblock Teleportation)

> **Scope.** Everything the acdream client must do once the player steps off
> the outdoor heightmap and into the `0xAAAA0000` family of "dungeon"
> landblocks, plus the whole ceremony of the portal/recall/lifestone
> transition between *any* two points in the world. This document is the
> retail contract. All numbers, flag bits, and message layouts are cited
> from the decompiled client, DatReaderWriter, ACE, ACViewer, ACME
> WorldBuilder, or holtburger. Any place where two references disagree
> is called out explicitly and the decompiled client wins.

---

## TL;DR

- A dungeon landblock is a regular `0xLLYY0000` landblock where **every
  height sample is 0**, `LandBlockInfo.NumCells > 0`, and
  `LandBlockInfo.Buildings.Count == 0`. It still has a
  `LandBlock` (`0xLLYYFFFF`) — we don't skip it — we just don't draw
  terrain for it. (ACE `Landblock.IsDungeon`, chunk `005E0000` confirms
  the `0xFFFE`/`0xFFFF` split.)
- Interior geometry lives in **EnvCells** with ids
  `0xLLYYccc` where `ccc >= 0x100`. Each EnvCell names an
  **Environment** (`0x0D000000`-family), which hosts one or more
  **CellStruct** geometries (vertices + polygons + BSP + **portal polygon
  indices**). The EnvCell picks which CellStruct via `CellStructure`.
- Visibility between cells is a **portal-based BFS**. `EnvCell.Portals[]`
  gives the local doorway list; `EnvCell.VisibleCells[]` is the
  precomputed PVS the dat ships to avoid traversing portals across
  already-established rooms. Both are combined in ACViewer's
  `build_visible_cells()`.
- Portal space is a **client state** driven by one server message:
  `PlayerTeleport` (opcode `0xF751`, 2-byte `teleport_sequence` then a
  4-byte align). While this flag is set, movement input is frozen, the
  avatar is hidden, collision is disabled, and the client is expected
  to keep sending a LoginComplete-ack-sequence pump until
  `UpdatePosition` arrives at the new location. The terminal state is
  "fully materialized" (`OnTeleportComplete` in ACE).
- Recall types are all *server*-initiated animations; the client only
  sends the **request** game action (e.g. `TeleToLifestone = 0x0063`,
  `TeleToMansion = 0x0278`, `TeleToHouse = 0x0262`,
  `TeleToMarketPlace = 0x028D`, `RecallAllegianceHometown = 0x02AB`,
  `TeleToPkArena = 0x0027`). The server plays a canned motion, waits
  for the animation length, and **only then** sends
  `PlayerTeleport` + `UpdatePosition`.
- There is **no "loading screen" overlay**. Retail used a simple
  black-fade-to-black + "pink bubble" avatar state (hidden + transparent)
  while the new landblock streams in. `GlobalFogColor` can force a
  dungeon into black; ACE preserves that behavior.
- Multi-floor stair walking is not special — portals work the same in
  all 6 axes. The physics engine's `find_transit_cells` walks portals
  for *both* sphere sweeps and the per-part bounding box tests
  (ACViewer `Physics/Common/EnvCell.cs:257`).

---

## 1. Dungeon Landblock Format

### 1.1 How we detect a dungeon

From ACE `Source/ACE.Server/Physics/Common/Landblock.cs:575`:

```csharp
public bool IsDungeon
{
    get
    {
        if (isDungeon != null) return isDungeon.Value;

        // NW island edge-case hack (map > y1976 on blocks x<64)
        if (BlockCoord.X < 64 && BlockCoord.Y > 1976)
        {
            isDungeon = false;
            return isDungeon.Value;
        }

        // a dungeon landblock is determined by:
        // - all heights being 0
        // - having at least 1 EnvCell (0x100+)
        // - contains no buildings
        foreach (var height in Height)
            if (height != 0) { isDungeon = false; return false; }

        isDungeon = Info != null
                 && Info.NumCells > 0
                 && Info.Buildings != null
                 && Info.Buildings.Count == 0;
        return isDungeon.Value;
    }
}
```

`HasDungeon` is a weaker predicate used for *mixed* landblocks
(a village with a cellar or a mansion with a basement): `NumCells > 0`
and no buildings, but height non-zero anywhere.

**What this means for the streamer.** The existing
`AcDream.App.Streaming.StreamingRegion` encodes every landblock as
`(lbX << 24) | (lbY << 16) | 0xFFFF` — the terrain-dat id. That's fine
for dungeons too; we just don't *emit terrain geometry* if
`IsDungeon` is true. The streaming window radius can stay at the
outdoor value because dungeons are usually 1 landblock wide and the
player is either fully inside or fully outside; we never need a
"large dungeon visible from outside" streaming mode.

### 1.2 Where the indoor geometry lives

The `LandBlockInfo` (`0xLLYYFFFE`) carries `NumCells` (uint32). For each
`i in 0..NumCells`, the EnvCell id is:

```
envCellId = (landblockId & 0xFFFF0000u) | (0x100 + i)
```

(ACE `Physics.Util.AdjustCell.cs:23–37`, ACViewer
`LScape.get_landcell:156`.) Each EnvCell is an independent dat entry
in the cell-dat at that id. Dungeons typically have 30–200 cells; the
largest retail dungeons (Aerfalle's Sanctum, Mhoire Castle) are
around 500–1500.

### 1.3 Mixed landblocks

Landblocks with `HasDungeon && !IsDungeon` carry **both**:

- the outdoor heightmap + landscape terrain textures,
- a set of EnvCells for the interior spaces under/inside structures
  (house basements, allegiance mansion vaults, etc.),
- `BuildingInfo` records for the above-ground structures.

Collision and visibility have to handle the transition: the player can
walk from the outdoor landblock *into* a building/cellar without
crossing a landblock boundary — the portal is between an outdoor land
cell and an interior EnvCell. This is the "mixed landblock" path in
ACME `EnvCellManager._mixedLandblocks`.

### 1.4 The 0xFFFE / 0xFFFF pair

Every landblock has exactly two top-level dat records:

| Id suffix | Object type   | Contents                                                 |
|-----------|---------------|----------------------------------------------------------|
| `0xFFFF`  | `LandBlock`   | 9×9 height + terrain type + road/scene. Dungeon: zeros.  |
| `0xFFFE`  | `LandBlockInfo` | `NumCells`, `Objects[]` (Stabs), `Buildings[]`, `RestrictionTable`. |

We already read both in `AcDream.Core.World.LandblockLoader.Load`
(line 18–22). A dungeon extension only adds the cell loop:

```
for i in 0..info.NumCells:
    envCellId = (landblockId & 0xFFFF0000u) | (0x100 + i)
    envCell   = dats.Get<EnvCell>(envCellId)
    yield envCell
```

---

## 2. EnvCell Deep-Dive

### 2.1 Wire layout

From `DatReaderWriter/Generated/DBObjs/EnvCell.generated.cs:68–97`:

```
struct EnvCell {
    DBObjHeader header;                 // id + flags
    uint32  Flags;                      // EnvCellFlags
    uint32  _cellId;                    // ignored — redundant with header.id
    uint8   numSurfaces;
    uint8   numPortals;
    uint16  numVisibleCells;
    uint16  surfaces[numSurfaces];      // | 0x08000000 → Surface file id
    uint16  environmentId;              // | 0x0D000000 → Environment file id
    uint16  cellStructure;              // key into Environment.Cells
    Frame   position;                   // world-space transform
    CellPortal portals[numPortals];
    uint16  visibleCells[numVisibleCells];
    if (Flags & HasStaticObjs) {
        uint32 numStabs;
        Stab   staticObjects[numStabs];
    }
    if (Flags & HasRestrictionObj) {
        uint32 restrictionObj;
    }
}
```

`EnvCellFlags` (`Generated/Enums/EnvCellFlags.generated.cs`):

```
SeenOutside      = 0x01
HasStaticObjs    = 0x02
HasRestrictionObj= 0x08
```

Note the skip of `0x04`. There is no `0x04` defined in retail — do not
assume it's unused; ACEmulator preserves the gap and so must we.

### 2.2 Surfaces vs Environment vs CellStructure

This is easy to get wrong. Three separate ids:

- **Surfaces[]** are the *textures applied to this cell's polygons*.
  They are short-form (`ushort`); the full file id is
  `0x08000000 | surf`. Length is `numSurfaces`, usually 3–10 per cell.
  Indexing is by `Polygon.SurfaceIndex` within the cell's CellStructure.
- **EnvironmentId** names a `DBObj.Environment` at `0x0D000000 | envId`.
  An `Environment` is a *library of CellStruct geometries* — a dungeon
  might reuse the same Environment for 50 cells that all look like
  "dungeon corridor variant A" but have different textures.
- **CellStructure** is a `ushort` key into `Environment.Cells` that
  selects which CellStruct (vertices + polygons + BSP + portal polygon
  indices) this cell actually uses.

So two cells that *look identical* share Environment+CellStructure but
typically override Surfaces to get different signage / staining /
damage overlays.

### 2.3 CellPortal (the link record)

From `Generated/Types/CellPortal.generated.cs:23–49`:

```
struct CellPortal {
    ushort Flags;          // PortalFlags: ExactMatch=0x01 | PortalSide=0x02
    ushort PolygonId;      // index into CellStructure.Polygons (the doorway poly)
    ushort OtherCellId;    // local cell id on the other side, OR 0xFFFF for outside
    ushort OtherPortalId;  // reverse-link index on the other side
};
```

**`holtburger` is wrong on this struct** (missing `PolygonId`). Use
DatReaderWriter / ACViewer for the correct layout.

`PortalFlags.PortalSide` is the sign bit of the plane equation: when
set, the "inside" of this cell is on the *positive* side of the portal
plane; when unset, "inside" is the *negative* side. ACViewer's
`find_transit_cells` uses it as:

```csharp
var dist = Vector3.Dot(center, portalPoly.Plane.Normal) + portalPoly.Plane.D;
if (portal.PortalSide) { if (dist < -rad) continue; }
else                   { if (dist >  rad) continue; }
```

i.e. "only consider this portal for transit if the sphere center is on
or past the portal plane in the allowed direction."

**`OtherCellId = 0xFFFF` is the "this portal opens to outside" marker.**
Dungeon entrance cells have at least one of these; the physics code
takes that branch into `LandCell.add_all_outside_cells` to splice the
outdoor cells in, i.e. the dungeon is seamlessly adjacent to the
outdoor landblock at that cell.

### 2.4 VisibleCells (pre-baked PVS)

The `VisibleCells[]` list is a precomputed **Potentially Visible Set**
shipped in the dat: "from this cell, you can at most see these cells."
It is a *superset* of the true runtime-visible set (depends on where
in the cell the camera is, frustum, portal occlusion) but a
*subset* of the portal graph's transitive closure — it excludes cells
that are in the physical portal graph but that the level designers
marked as never-directly-visible (e.g. the room behind a closed door).

ACViewer builds a dictionary of them at load (`EnvCell.cs:127`):

```csharp
public void build_visible_cells() {
    VisibleCells = new Dictionary<uint, EnvCell>();
    foreach (var visibleCellID in VisibleCellIDs) {
        var blockCellID = ID & 0xFFFF0000 | visibleCellID;
        if (VisibleCells.ContainsKey(blockCellID)) continue;
        var cell = (EnvCell)LScape.get_landcell(blockCellID);
        VisibleCells.Add(visibleCellID, cell);
    }
}
```

For render culling we will use this list directly (no BFS needed past
this); we only fall back to portal BFS for physics transit checks.

### 2.5 StaticObjects

Identical format to the outdoor `LandBlockInfo.Objects`: a `Stab`
(uint32 id + Frame). Frames are **local to the EnvCell position**, not
world-space. Phase 2d shipped this path already — the lesson from
memory `project_phase_2d_state.md` applies: **do not add the cell
origin**. Transform is `cellPosition * stabLocalFrame` in column-major
convention.

### 2.6 RestrictionObj

A `uint` guid of a server weenie that gates entry to this cell. The
physics engine calls `check_entry_restrictions(transition)` (ACViewer
`EnvCell.cs:88`) which asks the server whether the player satisfies
the restriction (common uses: quest-locked room, house access control,
Olthoi-only tunnel). For acdream R9 we can stub this as "always
permit" until we wire server actions back through; the wire format is
just a uint guid, no extra data.

---

## 3. CellPortal Geometry: the Doorway Polygon

The *geometry* of a portal is a **single polygon** in the cell's
CellStructure, indexed by `CellPortal.PolygonId`. This is the doorway
quad you'd see if you lit it up — typically a 4-sided planar polygon
filling the doorframe. The cell's `CellStructure.Portals[]` is a
parallel list of polygon indices:

From `CellStruct.generated.cs:26–28`:
```
Dictionary<ushort, Polygon> Polygons;   // all polys indexed by id
List<ushort> Portals;                    // indices into Polygons for portal polys
```

ACViewer uses this layout (`Physics/Common/EnvCell.cs:163`):

```csharp
var portal = Portals[portalId];
var portalPoly = CellStructure.Portals[portalId];   // polygon index
// then looks up: CellStructure.Polygons[portalPoly]
```

Wait — the key subtlety: the *polygon index* is both in `CellPortal.PolygonId`
*and* in `CellStruct.Portals`. These should be consistent; think of
`CellStruct.Portals` as a convenience fast-path that lists portal
polygons without walking all polys, and `CellPortal.PolygonId` as
the authoritative reference.

**The polygon is NOT a physical obstacle.** It has no collision. It's a
virtual plane used by:

1. **Portal-side testing**: does the camera/sphere/bbox lie on the
   "inside" face of this cell relative to the portal plane?
2. **Visibility clipping**: does the view frustum actually pierce the
   polygon, or is the neighboring cell fully behind the wall?
3. **Transit detection**: is a moving sphere about to cross the plane
   from inside to outside (i.e. leave this cell)?

The polygon is typically 4 vertices but up to ~8 in retail. Use the
polygon's **plane** (normal + D from the first 3 vertices) for the
side tests; use the **bounding box of the polygon** for the tighter
visibility-through-aperture frustum test.

---

## 4. `PlayerTeleport` Message (server → client, 0xF751)

### 4.1 Wire bytes

From ACE `GameMessagePlayerTeleport.cs`:

```csharp
public GameMessagePlayerTeleport(Player player)
    : base(GameMessageOpcode.PlayerTeleport, GameMessageGroup.SmartboxQueue, 21)
{
    Writer.Write(player.Sequences.GetNextSequence(Sequence.SequenceType.ObjectTeleport));
    Writer.Align();
}
```

`PacketOpCodeNames.cs:{532,533}` has the decompiled client's matching
event name: `Evt_Physics__PlayerTeleport_ID = 63313 = 0xF751`.

holtburger `protocol/messages/movement/messages/teleport.rs` confirms
the unpack:

```rust
pub struct PlayerTeleportData {
    pub teleport_sequence: u16,   // u16 little-endian
}
// then align_offset(offset, 4)  // 2 bytes padding → 4 bytes total payload
```

**Bytes after the opcode:**

| offset | size | field              |
|--------|------|--------------------|
| 0      | 2    | teleport_sequence  |
| 2      | 2    | align padding (0)  |

Total: 4 bytes payload + 4-byte opcode = 8 bytes in the game-message
body. The message is in `SmartboxQueue` (group 21) so it rides the
main ordered stream.

### 4.2 When the server sends it

ACE `Player_Location.Teleport:686`:

```csharp
Teleporting = true;
LastTeleportTime = DateTime.UtcNow;
LastTeleportStartTimestamp = Time.GetUnixTime();
if (fromPortal) LastPortalTeleportTimestamp = LastTeleportStartTimestamp;

Session.Network.EnqueueSend(new GameMessagePlayerTeleport(this));

// send a "fake" update position to get the client to start loading asap
var prevLoc = Location;
Location = newPosition;
SendUpdatePosition();
Location = prevLoc;

DoTeleportPhysicsStateChanges();        // hidden=true, ignoreCollisions=true
PhysicsObj.report_collision_end(true);
if (UnderLifestoneProtection) LifestoneProtectionDispel();
HandlePreTeleportVisibility(newPosition);
UpdatePlayerPosition(new Position(newPosition), true);
```

So the server's send-order is:

1. `PlayerTeleport(seq)` — enter portal space.
2. `UpdatePosition(newLocation)` — "fake" position at the destination
   so the client can start loading the target landblock.
3. `DoTeleportPhysicsStateChanges` → broadcast hidden/no-collision.
4. (after landblock loads) second `UpdatePosition` at the actual
   destination.
5. (after `CreateWorldObjectsCompleted`) `OnTeleportComplete`
   broadcasts physics-state change to fully materialized.

### 4.3 How the client responds

From holtburger `client/messages.rs:434`:

```rust
GameMessage::PlayerTeleport(data) => {
    log::info!("Portal transition started (seq: {})", data.teleport_sequence);
    self.send_login_complete().await?;
    Ok(())
}
```

**Key behavior**: the client re-sends `LoginComplete` (game action
`0x00A1`). This is not a literal re-login; it's how the retail client
tells the server "I have finished loading the new landblock and am
ready to receive object spawns." Without this, the server holds the
player in the pink-bubble state indefinitely.

---

## 5. Portal-Space State Machine

### 5.1 Client flag

From the decompiled client, chunk `005D0000` and `00560000`:

- `*(iVar6 + 0x238) != '\0'` is the "in portal space" flag on the
  primary client object (the Player). It blocks combat mode entry
  (chunk `00560000:8593`: `"You can't enter combat mode while in portal space"`).
- While the flag is set, additional input gates in the command
  interpreter reject: combat mode toggles, UI shortcut casts that
  would teleport again, and (per AC wiki) skill trainer dialogs.

### 5.2 Derived state (acdream implementation plan)

```
enum TeleportPhase
{
    Idle,
    WaitingForLandblock,   // received PlayerTeleport; streaming target landblock
    Materializing,         // landblock loaded, received final UpdatePosition
    Done                   // received physics-state "fully materialized"
}
```

While `Phase != Idle`:

- WASD/space input ignored by input handler.
- Camera orbit still works (retail permits looking around).
- Chat still works (retail permits chat from portal space).
- Avatar is rendered **hidden** (fully transparent or pink-bubble
  particle overlay — see §6).
- Collision is disabled (player does not push into world geometry
  that's in transit).
- Stream radius is temporarily increased or the target landblock is
  force-loaded on a high priority so the player doesn't come out
  before terrain is up.

### 5.3 Exit condition

Retail's `OnTeleportComplete` (ACE `Player_Location.cs:740`):

```csharp
if (CurrentLandblock != null && !CurrentLandblock.CreateWorldObjectsCompleted)
{
    // keep pink bubble state — retry in 100ms
    actionChain.AddDelaySeconds(0.1).AddAction(this, OnTeleportComplete);
    return;
}

if (CloakStatus != CloakStatus.On) ReportCollisions = true;
IgnoreCollisions = false;
Hidden = false;
Teleporting = false;

CheckMonsters();
CheckHouse();
EnqueueBroadcastPhysicsState();
```

So the client can't unilaterally exit — the **server drives the exit**
via the final physics-state broadcast (`Evt_Physics__SetState`). In
offline mode we mimic: once the target landblock has applied terrain
*and* at least one drain-completion frame has elapsed, flip the flag.

---

## 6. Loading Screen: there isn't one

### 6.1 What retail actually does

Searched `docs/research/decompiled/` for `L"Loading"`, `L"Entering"`,
`L"Welcome"`, progress-bar primitives, "ProgressBar", fade shaders —
**no hits**. There is no dedicated loading-screen overlay in retail.

What retail *does* do:

1. **GlobalFogColor** / **EnvironChangeType** can black out the scene
   during transit. ACE `Player_Location.cs:667` explicitly sinks a
   1-second clear-fog delay before teleporting so dungeons don't
   inherit outdoor fog state. If we preserve this, crossing a portal
   into a dungeon looks like a quick fade-to-black (fog clamp
   tightens to the player) followed by a fade-to-dungeon-ambient as
   the new environment streams in.
2. **Pink bubble avatar**: while `Hidden == true && IgnoreCollisions == true`,
   retail renders the player as a semi-transparent bubble (the
   `PlayScript.Hide` effect in `DoPreTeleportHide`). This is a
   PSTACK+alpha-blended material swap, not a separate UI element.
3. **No progress bar, no hourglass, no splash.** The text "*You
   have been teleported too recently!*" is the only UI feedback for
   rejected teleports.

### 6.2 Acceptable deviations for acdream

Because our streaming is not instantaneous and we want to *not* render
a black frame if the landblock is slow, we can add:

- A short (< 500ms) alpha fade on the world rendertarget while
  `TeleportPhase != Idle`.
- A tiny text string "Teleporting…" in the debug overlay if diagnostics
  are on.
- Never a blocking modal. The player should still see camera orbit,
  chat, and the player motion anim during the transit.

This is a clear deviation from retail and should be marked as such in
the spec — call it "acdream courtesy fade" and keep it below the
threshold where it changes gameplay feel.

---

## 7. Recall Mechanics

### 7.1 Taxonomy of recalls

All of these are **server-side teleport destinations** triggered by
client game-action sends. The client does not compute the destination;
it asks, the server approves + plays an animation, *then*
`PlayerTeleport` + `UpdatePosition` arrive.

| Recall              | Game action opcode | Animation (MotionCommand) | Server handler              |
|---------------------|--------------------|---------------------------|-----------------------------|
| Lifestone (`/ls`)   | `0x0063`           | `LifestoneRecall`         | `HandleActionTeleToLifestone` |
| House recall        | `0x0262`           | `HouseRecall`             | `HandleActionTeleToHouse`   |
| Allegiance hometown | `0x02AB`           | `AllegianceHometownRecall`| `HandleActionRecallAllegianceHometown` |
| Mansion/Villa       | `0x0278`           | `HouseRecall`             | `HandleActionTeleToMansion` |
| Marketplace         | `0x028D`           | `MarketplaceRecall`       | `HandleActionTeleToMarketPlace` |
| PK Arena            | `0x0027`           | `PKArenaRecall`           | `HandleActionTeleToPkArena` |
| PKL Arena           | (reuses 0x0027?)   | `PKArenaRecall`           | `HandleActionTeleToPklArena`|

References:
- Game-action table: `ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs`
  lines 21, 53, 120, 133, 138, 149.
- Animation table: `ACE/Source/ACE.Server/WorldObjects/Player_Location.cs`
  lines 47–49, 197, 264, 469.

### 7.2 Server validation (ACE Player_Location.cs:132)

For every recall:
1. Reject if `PKTimerActive` (recent PK combat).
2. Reject if `RecallsDisabled` (training academy).
3. Reject if `TooBusyToRecall` (busy flag or suicide in progress).
4. Reject if specific preconditions fail (no Sanctuary → lifestone
   fails; no house → house recall fails; no allegiance → hometown fails).
5. `SendMotionAsCommands(recallMotion, NonCombat)` broadcasts the
   animation.
6. `ActionChain.AddDelaySeconds(animLength)` waits for the anim.
7. Checks the player hasn't moved more than `RecallMoveThresholdSq`
   (= 64 m²) during the anim; if so, reject with
   `WeenieError.YouHaveMovedTooFar`.
8. `Teleport(destination)` → fires the full `PlayerTeleport` flow.

### 7.3 Portal use (not a recall but same class)

Portal objects are weenies with `ActivationResponse |= ActivationResponse.Use`.
When the player **uses** the portal (via `Event_UseObject = 0x0019`
with the portal's guid), the server runs `Portal.CheckUseRequirements`
(level, PK status, quest flag, advocate, Olthoi, Account15Days,
Throne of Destiny, etc.), then `ActOnUse`:

```csharp
var portalDest = new Position(Destination);
AdjustDungeon(portalDest);
WorldManager.ThreadSafeTeleport(player, portalDest, ..., fromPortal: true);
```

The `AdjustDungeon` call matters: some retail dungeons have the
portal destination pinned to a position that's *inside the wall* of
the first cell (old data bug, never fixed in retail data). `AdjustPos`
carries a hand-maintained dictionary of `(dungeonId → badPos → goodPos)`
overrides that bump the player to a safe cell. See
`ACE/Source/ACE.Server/Physics/Util/AdjustPos.cs`. We will need the
same table; ACE's version is empty today because live-ACE's data has
been patched, but the original dungeons still need it.

`AdjustDungeonCells` walks the dungeon's EnvCells (via
`AdjustCell.Get(dungeonId)`) and calls `envCell.point_in_cell(pos)` to
find the correct starting cell. This is our canonical "given a world
point, which EnvCell am I in?" query.

### 7.4 Lifestone attunement

Linking to a lifestone is a **client game action** (not in the recall
table because it's a one-off action): it writes the current player
position into the character's `Sanctuary` position slot on the server
side. No special teleport mechanics — just storage. Subsequent `/ls`
reads `Sanctuary` as the destination.

---

## 8. Dungeon Streaming Policy

### 8.1 All-at-once vs streamed

From DatReaderWriter + ACE usage, **the retail pattern is load-all**
for a dungeon landblock: once you cross the dungeon entrance portal,
the server considers you in that landblock and sends `UpdatePosition`
with the dungeon cell id. The client loads the entire
`LandBlockInfo.NumCells` cell set on landblock-enter.

This is fine for small dungeons (<100 cells) but large ones like
Aerfalle's Sanctum (~800 cells) or Freebooter Keep Black Market show
a noticeable hitch on retail. Retail accepts this hitch.

### 8.2 Recommended acdream policy

For R9 we ship the retail policy: **on landblock-enter, load all N
EnvCells synchronously into the cell cache**. Rationale:

- Interior cells are small (the typical `DrawingBSP` is < 50 KB).
- Most dungeons are < 200 cells so total load < 10 MB.
- Portal traversal correctness depends on every visible-cell being
  resident at query time; deferred loading introduces a race between
  "camera sees into next room" and "geometry uploaded." ACME
  `EnvCellManager.LoadedDungeonCellCount` caps this at 10,000 to
  protect against pathological data, but that cap is per-system not
  per-dungeon.

A future R9.1 optimization could stream cells by portal BFS distance
(cells within 2 portals of the camera loaded eagerly, rest deferred),
but R9 should match retail.

### 8.3 Integration with `LandblockLoader`

Extend `LoadedLandblock` to carry an `IReadOnlyList<EnvCell>` plus
the `Environment` lookup:

```csharp
public sealed record LoadedLandblock(
    uint Id,
    LandBlock Terrain,
    IReadOnlyList<WorldEntity> OutdoorEntities,
    IReadOnlyList<EnvCell> EnvCells,                    // NEW
    IReadOnlyDictionary<uint, Environment> Environments  // NEW (shared)
);
```

and in `LandblockLoader.Load`:

```csharp
var envCells = new List<EnvCell>((int)(info?.NumCells ?? 0));
for (uint i = 0; i < (info?.NumCells ?? 0); i++)
{
    var cellId = (landblockId & 0xFFFF0000u) | (0x100u + i);
    var cell   = dats.Get<EnvCell>(cellId);
    if (cell != null) envCells.Add(cell);
}

var environments = LoadEnvironmentsFor(dats, envCells);
```

Environments are shared across cells and across landblocks. A
process-lifetime cache keyed by environment file id is appropriate;
the memory cost is the CellStruct geometry which is immutable.

### 8.4 Threading

From memory: `DatCollection` is not thread-safe. The synchronous
`LandblockStreamer` today does all reads on the render thread. That
stays for R9 — loading a dungeon's cells is a bounded burst (a few
hundred ms at most for the biggest dungeons) on the player-enters
event, not a sustained cost. Keep synchronous.

---

## 9. Cell Visibility Graph

### 9.1 The problem

"Which EnvCells do I need to draw this frame?"

Input: camera world position + frustum, current loaded cell set.
Output: set of cell ids to render.

Two cooperating mechanisms:

**(a) Precomputed PVS** via `EnvCell.VisibleCells[]`. The dat ships
this list per cell. It's a superset of the runtime answer.

**(b) Runtime portal BFS** for tighter culling:

```csharp
public VisibilityResult GetVisibleCells(Vector3 cameraPos, Frustum frustum) {
    var cameraCell = FindCameraCell(cameraPos);
    if (cameraCell == null) return null;   // outside all cells

    var result = new VisibilityResult { CameraCell = cameraCell };
    var visited = new HashSet<uint>();
    var queue   = new Queue<LoadedEnvCell>();

    visited.Add(cameraCell.CellId);
    result.VisibleCellIds.Add(cameraCell.CellId);
    queue.Enqueue(cameraCell);
    uint lbMask = cameraCell.CellId & 0xFFFF0000;

    while (queue.Count > 0) {
        var cell = queue.Dequeue();
        for (int i = 0; i < cell.Portals.Count; i++) {
            var portal = cell.Portals[i];

            if (portal.OtherCellId == 0xFFFF) {
                result.HasExitPortalVisible = true;
                continue;
            }

            uint neighborId = lbMask | portal.OtherCellId;
            if (visited.Contains(neighborId)) continue;
            if (!_cellLookup.TryGetValue(neighborId, out var neighbor)) continue;

            // Portal-side plane test
            if (i < cell.ClipPlanes.Count) {
                var plane = cell.ClipPlanes[i];
                var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
                float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
                if (plane.InsideSide == 0 && dot < -PointInCellEpsilon) continue;
                if (plane.InsideSide == 1 && dot >  PointInCellEpsilon) continue;
            }

            // Frustum test on neighbor bbox
            var neighborBounds = new BoundingBox(
                neighbor.WorldPosition - new Vector3(CellBoundsRadius),
                neighbor.WorldPosition + new Vector3(CellBoundsRadius));
            if (!frustum.IntersectsBoundingBox(neighborBounds)) continue;

            visited.Add(neighborId);
            result.VisibleCellIds.Add(neighborId);
            queue.Enqueue(neighbor);
        }
    }
    return result;
}
```

(Cited verbatim from ACME `EnvCellManager.cs:1421–1475` because it's
the exact algorithm we want.)

### 9.2 The portal-polygon aperture refinement

The above BFS walks portal graph edges. A tighter version *shrinks the
frustum to the portal polygon at each step*, so a neighbor cell is
only visible if the frustum ∩ portal polygon is non-empty. Retail does
NOT do this refinement — it's "fast and loose": a cell is visible if
any portal connects to it and the neighbor's bbox intersects the
view frustum. This is good enough because cells are small (~10 units)
and the portal-side plane test already cuts backwards-facing neighbors.

We ship the simpler version for R9.

### 9.3 "Can see through the door to the next room" — the load-bearing test

The user emphasized this. Concretely:

1. Player is in cell A.
2. A has a portal `portals[0]` to cell B (OtherCellId=0x123,
   OtherPortalId=2).
3. The portal polygon is the doorframe quad.
4. Camera is inside A, facing the doorway.
5. We want B's geometry, B's static objects, B's NPCs
   (server-spawned weenies physically in B) all rendered.

The BFS above gets us (1)–(4) for free. For (5) — NPC rendering —
we need the per-object `current_cell_id` to be correct so that
`IsVisibleIndoors(npc.currentCell)` returns true:

```csharp
public bool IsVisibleIndoors(ObjCell cell) {
    var blockDist = PhysicsObj.GetBlockDist(ID, cell.ID);
    if (blockDist == 0) {
        var cellID = cell.ID & 0xFFFF;
        if (VisibleCells.ContainsKey(cellID)) return true;
    }
    return SeenOutside && blockDist <= 1;
}
```

(ACViewer `EnvCell.cs:455`.) So for indoor rendering, we enumerate the
visible-cell set, and for each living entity whose
`currentCell.Id & 0xFFFF` is in that set, we render it.

### 9.4 Edge case: camera at the exact portal plane

When the camera is *on* the portal plane (dot ≈ 0), retail keeps both
cells visible via `PointInCellEpsilon`:

```csharp
if (plane.InsideSide == 0 && dot < -PointInCellEpsilon) continue;
if (plane.InsideSide == 1 && dot >  PointInCellEpsilon) continue;
```

So if `|dot| < epsilon`, the portal is not culled and both cells
render. Epsilon value: ACME uses `0.01` (1 cm). Avoid zero — it causes
popping at doorways.

---

## 10. Multi-Floor Stair Walking

### 10.1 The challenge

Dungeons have vertical structure. A spiral staircase connects cells
at different Z heights. The player is mostly in one cell at a time,
but while walking up the stairs, the collision sphere crosses the
portal plane between cell[N] and cell[N+1]. If the rendering / physics
don't agree on which cell the player is in, the player either falls
through the floor (physics says "I'm in upper cell, lower cell's
floor no longer collides") or gets stuck (rendering says "I'm in
upper cell, upper cell's walls clip me back").

### 10.2 Retail solution: sphere path transit

ACViewer `Physics/Common/EnvCell.cs:323–383` — `find_transit_cells`
walks all portals and tests the *sphere path*, not the *point*. A
sphere of radius `r` straddling a portal plane generates a
`cellArray` containing *both* sides:

```csharp
foreach (var portal in Portals) {
    var portalPoly = CellStructure.Polygons[portal.PolygonId];
    if (portal.OtherCellId == 0xFFFF) {
        // test for outside transit
        foreach (var sphere in spheres) {
            var dist = Vector3.Dot(center, portalPoly.Plane.Normal) + portalPoly.Plane.D;
            if (dist > -rad && dist < rad) { checkOutside = true; break; }
        }
    } else {
        var otherCell = GetVisible(portal.OtherCellId);
        if (otherCell != null) {
            foreach (var sphere in spheres) {
                var center = otherCell.Pos.Frame.GlobalToLocal(sphere.Center);
                var _sphere = new Sphere(center, sphere.Radius);
                if (otherCell.CellStructure.sphere_intersects_cell(_sphere) != BoundingType.Outside) {
                    cellArray.add_cell(otherCell.ID, otherCell);
                    break;
                }
            }
        }
    }
}
```

So while the player sphere straddles a vertical portal, *both* the
upper and lower cell's collision geometry is active. No falling, no
sticking.

### 10.3 Vertical portals don't need special handling

The portal plane can have any normal — horizontal (typical doorway),
vertical (ceiling-to-ceiling in a stair well where two cells overlap
vertically), or arbitrary (sloped). The BSP tests work regardless.

### 10.4 Implementation consequence for acdream

Our player collision currently operates in a single cell. When we port
this, the player's `CurrentCell` becomes an `IReadOnlyList<EnvCell>`
for the (normally 1, up to 2-3 during stairs) cells the body sphere
straddles. Collision queries iterate all of them. Rendering uses the
*first* (centroid-containing) as the camera cell for the visibility
BFS; that's enough because all straddled cells are direct neighbors
and hence in each other's VisibleCells anyway.

---

## 11. Dungeon Entrance Mechanics

### 11.1 The "gate" tile

A dungeon entrance in the world is a **server weenie of class
Portal** placed at the entrance location on the outdoor landblock.
The weenie has `Destination = Position(dungeonLandblockId, x, y, z, rot)`.

The visual "gate" object (glowing tile, door, archway) is part of the
weenie's model; it is NOT in the dat's static scenery. This is why we
don't see dungeon entrances until Phase 4+ networking lights them up
— they're server-pushed dynamic objects (see
`feedback_weenie_vs_static.md`).

### 11.2 The interaction flow

1. Player clicks the gate: client sends `UseObject(portalGuid)`
   game action (opcode `0x0019`).
2. Server validates (`Portal.CheckUseRequirements`).
3. Server sends `TextSpeechBroadcast` (optional emote) and the
   `Portal`-sound effect.
4. Server calls `ActOnUse → AdjustDungeon(dest) →
   WorldManager.ThreadSafeTeleport(player, dest, callback, fromPortal: true)`.
5. `Teleport` runs the full `PlayerTeleport` flow (§4.2).

There is **no client-side dungeon detection**. The client doesn't
check "am I walking onto the dungeon tile" — it only responds to
server-directed teleport. A click-to-use is always server-arbitrated.

### 11.3 OtherCellId = 0xFFFF portals (the seamless-outdoor case)

Some dungeon entrances use no portal weenie at all: the first cell of
the dungeon has a `CellPortal.OtherCellId == 0xFFFF`. Walking across
the portal plane physically moves the player sphere into the
outdoor-cell set via `LandCell.add_all_outside_cells`. The server
notices the cell-id change, pushes `UpdatePosition`, and the physics
continues. No `PlayerTeleport`, no portal space — it's just a continuous
walk.

This is used for covered walkways, cellar staircases that open
directly to outside, etc. Our transit code MUST handle the 0xFFFF
marker or the player will soft-lock at these thresholds.

### 11.4 Magical portals (spell projectiles)

Spells like "Portal Space" and "Recall Magic" generate transient
portal weenies that last a few minutes. They use the same Portal
weenie mechanism; the only thing that changes is the weenie is
spawned by the caster's spell script rather than being static world
data. For acdream client purposes, they're indistinguishable from
static portals.

---

## 12. Port Plan for acdream R9

### 12.1 New C# types

| Type                     | Location                                              | Purpose |
|--------------------------|-------------------------------------------------------|---------|
| `DungeonLandblock`       | `AcDream.Core.World`                                  | Record holding the loaded EnvCell list + Environment cache for a dungeon-style landblock. Inherits from `LoadedLandblock` via a discriminated field `IsDungeon`. |
| `EnvCellStreamer`        | `AcDream.App.Streaming`                               | Eager-load all EnvCells on landblock-enter; no continuous streaming. |
| `EnvCellRenderer`        | `AcDream.App.Rendering`                               | Owns the per-landblock EnvCell GPU caches. Models on ACME `EnvCellManager`. |
| `PortalVisibility`       | `AcDream.Core.World.Visibility`                       | Pure BFS + frustum + portal-side. Unit-testable. |
| `AdjustCell`             | `AcDream.Core.World.Physics`                          | Port of ACE's class — "given a world point in dungeon X, which EnvCell contains it?" |
| `AdjustPos`              | `AcDream.Core.World.Physics`                          | Port of ACE's per-dungeon position-patch table. |
| `TeleportController`     | `AcDream.App.World`                                   | Owns the `TeleportPhase` state, wires up input gating, stream radius boost, avatar hide. |
| `PlayerTeleportMessage`  | `AcDream.Core.Network.Messages`                       | Wire type for the 0xF751 inbound message. |
| `RecallActionBuilder`    | `AcDream.Core.Network.Actions`                        | Outbound 0x0063/0x0262/etc. builders. |

### 12.2 Data pipeline changes

1. **`LandblockLoader`** — extend `Load` to also read the
   `info.NumCells` EnvCells and environments. Return them on a new
   `LoadedLandblock.EnvCells` / `.Environments` property.
2. **`LoadedLandblock`** — carries the classification `IsDungeon`
   (computed from the ACE formula), plus the EnvCell list.
3. **GPU state** — a new `GpuEnvCellState` keyed by landblock id,
   populated alongside the existing terrain state. Contains per-cell
   VAOs and per-environment shared geometry cache.

### 12.3 Render pipeline integration

1. Add a `RenderEnvCells(Camera cam, IReadOnlyList<LoadedLandblock> visible)`
   pass that:
   - Finds the camera cell via `FindCameraCell`.
   - If outside all cells → render buildings the old way, skip interior pass.
   - If inside a cell → run `PortalVisibility.BFS`, render only
     `VisibleCellIds`.
2. Depth clearing: when the camera enters a dungeon cell, clear the
   depth buffer between the terrain pass and the EnvCell pass. ACME
   does this — otherwise the terrain's Z values occlude the interior
   geometry that's supposed to be below ground.
3. ACME's `DungeonDepthOffset = -50f` applies to dungeon-only cells
   to push them below terrain Z — prevents Z-fighting when a dungeon
   landblock happens to share a block id with a terrain landblock.

### 12.4 Physics integration

1. Port `ObjCell.find_transit_cells` (sphere variant + parts variant).
2. Port `EnvCell.point_in_cell` using the CellStruct BSP.
3. Port `EnvCell.FindEnvCollisions` to run collision against
   `CellStructure.PhysicsBSP`.
4. Port the multi-cell straddle logic so the player's body can span
   multiple cells during stair walking.
5. Apply `AdjustCell.GetCell(pos)` on every position update inside a
   dungeon to correct the server-authoritative cell id before physics
   runs (handles both imprecise server positions and the "bad data"
   overrides in `AdjustPos`).

### 12.5 Teleport controller

```csharp
public enum TeleportPhase { Idle, WaitingForLandblock, Materializing, Done }

public sealed class TeleportController
{
    public TeleportPhase Phase { get; private set; }
    public ushort LastTeleportSequence { get; private set; }

    public void OnPlayerTeleport(PlayerTeleportMessage msg)
    {
        LastTeleportSequence = msg.TeleportSequence;
        Phase = TeleportPhase.WaitingForLandblock;
        _input.BlockMovement(true);
        _render.SetAvatarHidden(true);
        _physics.IgnoreCollisions = true;
        _network.SendLoginComplete();               // holtburger-confirmed
        _streaming.BoostRadiusTemporarily(3);       // load target LB fast
    }

    public void OnUpdatePosition(UpdatePositionMessage msg, bool isFinal)
    {
        if (Phase == TeleportPhase.Idle) return;
        if (!isFinal)
        {
            // intermediate: target LB id revealed, ensure it's loading
            _streaming.PrioritizeLandblock(msg.LandblockId);
            return;
        }
        Phase = TeleportPhase.Materializing;
    }

    public void OnPhysicsStateMaterialized()
    {
        if (Phase != TeleportPhase.Materializing) return;
        Phase = TeleportPhase.Done;
        _input.BlockMovement(false);
        _render.SetAvatarHidden(false);
        _physics.IgnoreCollisions = false;
        _streaming.RestoreRadius();
        Phase = TeleportPhase.Idle;
    }
}
```

Acceptance test: observer sees (a) avatar hides, (b) 200–1000ms pink
bubble, (c) avatar re-appears at new location fully materialized. In
offline mode we fake `OnPhysicsStateMaterialized` after
`applyTerrain` completes for the target landblock.

### 12.6 Recall actions

Wire the six recall game-action builders. All are zero-payload sends
— just the opcode. Example for lifestone:

```csharp
public static class GameActionBuilder
{
    public static GameAction TeleToLifestone() =>
        new(GameActionType.TeleToLifestone);        // 0x0063, empty payload
    public static GameAction TeleToMansion() =>
        new(GameActionType.TeleToMansion);          // 0x0278
    public static GameAction TeleToHouse() =>
        new(GameActionType.TeleToHouse);            // 0x0262
    public static GameAction TeleToMarketPlace() =>
        new(GameActionType.TeleToMarketPlace);      // 0x028D
    public static GameAction RecallAllegianceHometown() =>
        new(GameActionType.RecallAllegianceHometown); // 0x02AB
    public static GameAction TeleToPkArena() =>
        new(GameActionType.TeleToPkArena);          // 0x0027
}
```

All other state (animation length, sanity checks, destination
resolution) lives on the server. The client just waits for the
`PlayerTeleport` that follows.

### 12.7 Conformance tests

- `EnvCellTests.cs`: round-trip decode/encode on 10 sample EnvCells
  covering the flag combinations (none, HasStaticObjs, HasRestrictionObj,
  SeenOutside).
- `PortalVisibilityTests.cs`: hand-built cell graph (A ↔ B ↔ C, A ↔ D)
  with portal planes, asserts BFS output for various camera positions.
- `AdjustCellTests.cs`: synthetic dungeon with 3 cells, point-in-cell
  queries across all 3.
- `TeleportFlowTests.cs`: fake wire messages, assert state machine
  moves Idle → Waiting → Materializing → Done and input gating
  flips correctly.
- `DungeonClassificationTests.cs`: feed the exact ACE formula with
  edge cases (the NW island hack, a landblock with height 0 in one
  cell but not others, a landblock with 0 cells).

### 12.8 Phase sequencing on the roadmap

R9 depends on:

- R1–R8 mostly shipped (phase state in memory).
- The sequence counter work from Sprint 1 (memory
  `project_sprint_state.md`).
- Physics collision port (completed — `project_collision_port.md`).

R9 **enables**:

- All indoor quest progression (dungeons are currently invisible).
- Housing (house interiors are EnvCells in mixed landblocks).
- Allegiance mansion vaults.
- The complete recall UX suite (the keybinds exist; the wire messages
  need to go out and the return flow handled).

### 12.9 Acceptance criteria

- Walk through the first dungeon entrance in Holtburg and see the
  interior render correctly.
- `dotnet build` green, `dotnet test` green including new conformance
  suites.
- Visual confirmation: the drudge in the first cell is in the right
  position, the portals match retail's visual layout, no Z-fighting
  between dungeon floor and outdoor terrain when the landblock is
  mixed.
- `/ls` works: client sends action, server responds with
  `PlayerTeleport`, client shows pink bubble, player re-appears at
  lifestone position.
- Cross-cell visibility works: standing in one cell, the next cell's
  geometry visible through the doorway, no popping, no "other cell
  visible from behind a wall" bug.

---

## 13. Open Questions / Follow-up Research

1. **Environment dat load size on disk.** We need to sample the dat
   sizes for representative Environments to set the cache memory
   budget. Plan: add a diag command that dumps every loaded
   Environment's byte size.
2. **Cell transit when the portal polygon is concave.** Retail's
   polygons are always convex (BSP design assumption). Confirm no dat
   file violates this before committing to convex-only clipping.
3. **How the server picks between `0xFFFF` (outside) and a real
   neighbor cell when both are eligible.** The physics engine's
   `add_all_outside_cells` adds the entire outdoor cell ring; that's
   probably expensive to replicate exactly. A simpler heuristic: for
   `0xFFFF` portals, treat the portal as opening into the outdoor
   landblock and run the outdoor cell's BSP query. Measure first.
4. **Dungeon-specific fog color.** Retail has `GlobalFogColor` set
   per-dungeon by server data. We don't yet carry this on our
   `LoadedLandblock`. It's likely a world-db column in ACE; we can
   seed a default-black for all dungeons until we load real server
   data.
5. **PKL vs PK arena opcode.** Our table shows both using `0x0027`;
   ACE has separate handlers (`HandleActionTeleToPklArena` on the
   same opcode path). Worth double-checking the decompiled client to
   see if there's actually a separate opcode hiding. Search
   chunk_005F0000 for the game-action dispatcher and trace.

---

## 14. References Cited

| # | File | Purpose |
|---|------|---------|
| 1 | `references/DatReaderWriter/.../EnvCell.generated.cs` | Wire format of EnvCell |
| 2 | `references/DatReaderWriter/.../CellPortal.generated.cs` | Wire format of CellPortal |
| 3 | `references/DatReaderWriter/.../LandBlockInfo.generated.cs` | NumCells + Objects + Buildings layout |
| 4 | `references/DatReaderWriter/.../CellStruct.generated.cs` | Polygon + Portal list + BSP layout |
| 5 | `references/DatReaderWriter/.../EnvCellFlags.generated.cs` | Flag bit values |
| 6 | `references/DatReaderWriter/.../PortalFlags.generated.cs` | PortalSide + ExactMatch bits |
| 7 | `references/ACViewer/ACViewer/Physics/Common/EnvCell.cs` | Ground truth for runtime EnvCell behavior, portal traversal, transit, point_in_cell |
| 8 | `references/ACE/.../Physics/Common/LScape.cs` | get_landblock / get_landcell entry points |
| 9 | `references/ACE/.../Physics/Common/Landblock.cs:575` | IsDungeon / HasDungeon formula |
| 10 | `references/ACE/.../Physics/Util/AdjustCell.cs` | "Which dungeon cell contains this point?" |
| 11 | `references/ACE/.../Physics/Util/AdjustPos.cs` | Per-dungeon position override table |
| 12 | `references/ACE/.../WorldObjects/Portal.cs` | Portal-use server flow |
| 13 | `references/ACE/.../WorldObjects/Player_Location.cs` | Teleport/OnTeleportComplete/recall handlers |
| 14 | `references/ACE/.../GameMessagePlayerTeleport.cs` | Wire format of 0xF751 |
| 15 | `references/ACE/.../GameMessageOpcode.cs:61` | PlayerTeleport = 0xF751 |
| 16 | `references/ACE/.../GameActionType.cs` | TeleToX opcodes |
| 17 | `references/holtburger/.../messages/movement/messages/teleport.rs` | Client-side unpack of PlayerTeleport |
| 18 | `references/holtburger/.../client/messages.rs:434` | Client re-sends LoginComplete on teleport |
| 19 | `references/WorldBuilder-ACME-Edition/.../EnvCellManager.cs` | Chorizite/Silk.NET rendering pipeline reference |
| 20 | `docs/research/decompiled/chunk_00560000.c:8593` | Portal-space combat-mode rejection; confirms `*(player + 0x238)` as the in-portal-space flag |
| 21 | `docs/research/decompiled/chunk_00570000.c:1964–2036` | Full lifestone/portal error string catalog |
| 22 | `docs/research/decompiled/chunk_005D0000.c:8828–8843` | "AC1: LandBlocks Rendered" / "AC1: EnvCells Rendered" / "AC1: Portals Traversed" render stats; confirms portal-based culling model |
