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

48 KiB
Raw Blame History

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:

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.

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:

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):

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):

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:

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:

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:

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:

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):

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:

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.

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:

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:

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:

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:

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:

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:323383find_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:

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

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:

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