acdream/docs/research/deepdives/r09-dungeon-portal-space.md
Erik 3f913f1999 docs+feat: 13 retail-AC deep-dives (R1-R13) + C# port scaffolds + roadmap E-H
78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.

Research (docs/research/deepdives/):
- 00-master-synthesis.md          (navigation hub + dependency graph)
- r01-spell-system.md        5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md       5.9K words (damage formula, crit, body table)
- r03-motion-animation.md    8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md       5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md         5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md     7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md  6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md       7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md          5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md    4.5K words (deterministic client-side)
- r13-dynamic-lighting.md    4.9K words (8-light cap, hard Range cutoff)

Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.

Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).

C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs    — ItemType/EquipMask enums, ItemInstance,
                             Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs      — SpellDatEntry, SpellComponentEntry,
                             SpellCastStateMachine, ActiveBuff,
                             SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs     — CombatMode/AttackType/DamageType/BodyPart,
                             DamageEvent record, CombatMath (hit-chance
                             sigmoids, power/accuracy mods, damage formula),
                             ArmorBuild
- Audio/AudioModel.cs       — SoundId enum, SoundEntry, WaveData,
                             IAudioEngine / ISoundCache contracts,
                             AudioFalloff (inverse-square)
- Vfx/VfxModel.cs           — 13 ParticleType integrators, EmitterDesc,
                             PhysicsScript + hooks, Particle struct,
                             ParticleEmitter, IParticleSystem contract

All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.

Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
            combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)

Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
2026-04-18 10:32:44 +02:00

1196 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:2337`, ACViewer
`LScape.get_landcell:156`.) Each EnvCell is an independent dat entry
in the cell-dat at that id. Dungeons typically have 30200 cells; the
largest retail dungeons (Aerfalle's Sanctum, Mhoire Castle) are
around 5001500.
### 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 1822). 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:6897`:
```
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 310 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:2349`:
```
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:2628`:
```
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 4749, 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:14211475` 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:323383` `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) 2001000ms 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:
- R1R8 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:19642036` | Full lifestone/portal error string catalog |
| 22 | `docs/research/decompiled/chunk_005D0000.c:88288843` | "AC1: LandBlocks Rendered" / "AC1: EnvCells Rendered" / "AC1: Portals Traversed" render stats; confirms portal-based culling model |