# R9 — Dungeon Streaming & Portal Space (Inter-Landblock Teleportation) > **Scope.** Everything the acdream client must do once the player steps off > the outdoor heightmap and into the `0xAAAA0000` family of "dungeon" > landblocks, plus the whole ceremony of the portal/recall/lifestone > transition between *any* two points in the world. This document is the > retail contract. All numbers, flag bits, and message layouts are cited > from the decompiled client, DatReaderWriter, ACE, ACViewer, ACME > WorldBuilder, or holtburger. Any place where two references disagree > is called out explicitly and the decompiled client wins. --- ## TL;DR - A dungeon landblock is a regular `0xLLYY0000` landblock where **every height sample is 0**, `LandBlockInfo.NumCells > 0`, and `LandBlockInfo.Buildings.Count == 0`. It still has a `LandBlock` (`0xLLYYFFFF`) — we don't skip it — we just don't draw terrain for it. (ACE `Landblock.IsDungeon`, chunk `005E0000` confirms the `0xFFFE`/`0xFFFF` split.) - Interior geometry lives in **EnvCells** with ids `0xLLYYccc` where `ccc >= 0x100`. Each EnvCell names an **Environment** (`0x0D000000`-family), which hosts one or more **CellStruct** geometries (vertices + polygons + BSP + **portal polygon indices**). The EnvCell picks which CellStruct via `CellStructure`. - Visibility between cells is a **portal-based BFS**. `EnvCell.Portals[]` gives the local doorway list; `EnvCell.VisibleCells[]` is the precomputed PVS the dat ships to avoid traversing portals across already-established rooms. Both are combined in ACViewer's `build_visible_cells()`. - Portal space is a **client state** driven by one server message: `PlayerTeleport` (opcode `0xF751`, 2-byte `teleport_sequence` then a 4-byte align). While this flag is set, movement input is frozen, the avatar is hidden, collision is disabled, and the client is expected to keep sending a LoginComplete-ack-sequence pump until `UpdatePosition` arrives at the new location. The terminal state is "fully materialized" (`OnTeleportComplete` in ACE). - Recall types are all *server*-initiated animations; the client only sends the **request** game action (e.g. `TeleToLifestone = 0x0063`, `TeleToMansion = 0x0278`, `TeleToHouse = 0x0262`, `TeleToMarketPlace = 0x028D`, `RecallAllegianceHometown = 0x02AB`, `TeleToPkArena = 0x0027`). The server plays a canned motion, waits for the animation length, and **only then** sends `PlayerTeleport` + `UpdatePosition`. - There is **no "loading screen" overlay**. Retail used a simple black-fade-to-black + "pink bubble" avatar state (hidden + transparent) while the new landblock streams in. `GlobalFogColor` can force a dungeon into black; ACE preserves that behavior. - Multi-floor stair walking is not special — portals work the same in all 6 axes. The physics engine's `find_transit_cells` walks portals for *both* sphere sweeps and the per-part bounding box tests (ACViewer `Physics/Common/EnvCell.cs:257`). --- ## 1. Dungeon Landblock Format ### 1.1 How we detect a dungeon From ACE `Source/ACE.Server/Physics/Common/Landblock.cs:575`: ```csharp public bool IsDungeon { get { if (isDungeon != null) return isDungeon.Value; // NW island edge-case hack (map > y1976 on blocks x<64) if (BlockCoord.X < 64 && BlockCoord.Y > 1976) { isDungeon = false; return isDungeon.Value; } // a dungeon landblock is determined by: // - all heights being 0 // - having at least 1 EnvCell (0x100+) // - contains no buildings foreach (var height in Height) if (height != 0) { isDungeon = false; return false; } isDungeon = Info != null && Info.NumCells > 0 && Info.Buildings != null && Info.Buildings.Count == 0; return isDungeon.Value; } } ``` `HasDungeon` is a weaker predicate used for *mixed* landblocks (a village with a cellar or a mansion with a basement): `NumCells > 0` and no buildings, but height non-zero anywhere. **What this means for the streamer.** The existing `AcDream.App.Streaming.StreamingRegion` encodes every landblock as `(lbX << 24) | (lbY << 16) | 0xFFFF` — the terrain-dat id. That's fine for dungeons too; we just don't *emit terrain geometry* if `IsDungeon` is true. The streaming window radius can stay at the outdoor value because dungeons are usually 1 landblock wide and the player is either fully inside or fully outside; we never need a "large dungeon visible from outside" streaming mode. ### 1.2 Where the indoor geometry lives The `LandBlockInfo` (`0xLLYYFFFE`) carries `NumCells` (uint32). For each `i in 0..NumCells`, the EnvCell id is: ``` envCellId = (landblockId & 0xFFFF0000u) | (0x100 + i) ``` (ACE `Physics.Util.AdjustCell.cs:23–37`, ACViewer `LScape.get_landcell:156`.) Each EnvCell is an independent dat entry in the cell-dat at that id. Dungeons typically have 30–200 cells; the largest retail dungeons (Aerfalle's Sanctum, Mhoire Castle) are around 500–1500. ### 1.3 Mixed landblocks Landblocks with `HasDungeon && !IsDungeon` carry **both**: - the outdoor heightmap + landscape terrain textures, - a set of EnvCells for the interior spaces under/inside structures (house basements, allegiance mansion vaults, etc.), - `BuildingInfo` records for the above-ground structures. Collision and visibility have to handle the transition: the player can walk from the outdoor landblock *into* a building/cellar without crossing a landblock boundary — the portal is between an outdoor land cell and an interior EnvCell. This is the "mixed landblock" path in ACME `EnvCellManager._mixedLandblocks`. ### 1.4 The 0xFFFE / 0xFFFF pair Every landblock has exactly two top-level dat records: | Id suffix | Object type | Contents | |-----------|---------------|----------------------------------------------------------| | `0xFFFF` | `LandBlock` | 9×9 height + terrain type + road/scene. Dungeon: zeros. | | `0xFFFE` | `LandBlockInfo` | `NumCells`, `Objects[]` (Stabs), `Buildings[]`, `RestrictionTable`. | We already read both in `AcDream.Core.World.LandblockLoader.Load` (line 18–22). A dungeon extension only adds the cell loop: ``` for i in 0..info.NumCells: envCellId = (landblockId & 0xFFFF0000u) | (0x100 + i) envCell = dats.Get(envCellId) yield envCell ``` --- ## 2. EnvCell Deep-Dive ### 2.1 Wire layout From `DatReaderWriter/Generated/DBObjs/EnvCell.generated.cs:68–97`: ``` struct EnvCell { DBObjHeader header; // id + flags uint32 Flags; // EnvCellFlags uint32 _cellId; // ignored — redundant with header.id uint8 numSurfaces; uint8 numPortals; uint16 numVisibleCells; uint16 surfaces[numSurfaces]; // | 0x08000000 → Surface file id uint16 environmentId; // | 0x0D000000 → Environment file id uint16 cellStructure; // key into Environment.Cells Frame position; // world-space transform CellPortal portals[numPortals]; uint16 visibleCells[numVisibleCells]; if (Flags & HasStaticObjs) { uint32 numStabs; Stab staticObjects[numStabs]; } if (Flags & HasRestrictionObj) { uint32 restrictionObj; } } ``` `EnvCellFlags` (`Generated/Enums/EnvCellFlags.generated.cs`): ``` SeenOutside = 0x01 HasStaticObjs = 0x02 HasRestrictionObj= 0x08 ``` Note the skip of `0x04`. There is no `0x04` defined in retail — do not assume it's unused; ACEmulator preserves the gap and so must we. ### 2.2 Surfaces vs Environment vs CellStructure This is easy to get wrong. Three separate ids: - **Surfaces[]** are the *textures applied to this cell's polygons*. They are short-form (`ushort`); the full file id is `0x08000000 | surf`. Length is `numSurfaces`, usually 3–10 per cell. Indexing is by `Polygon.SurfaceIndex` within the cell's CellStructure. - **EnvironmentId** names a `DBObj.Environment` at `0x0D000000 | envId`. An `Environment` is a *library of CellStruct geometries* — a dungeon might reuse the same Environment for 50 cells that all look like "dungeon corridor variant A" but have different textures. - **CellStructure** is a `ushort` key into `Environment.Cells` that selects which CellStruct (vertices + polygons + BSP + portal polygon indices) this cell actually uses. So two cells that *look identical* share Environment+CellStructure but typically override Surfaces to get different signage / staining / damage overlays. ### 2.3 CellPortal (the link record) From `Generated/Types/CellPortal.generated.cs:23–49`: ``` struct CellPortal { ushort Flags; // PortalFlags: ExactMatch=0x01 | PortalSide=0x02 ushort PolygonId; // index into CellStructure.Polygons (the doorway poly) ushort OtherCellId; // local cell id on the other side, OR 0xFFFF for outside ushort OtherPortalId; // reverse-link index on the other side }; ``` **`holtburger` is wrong on this struct** (missing `PolygonId`). Use DatReaderWriter / ACViewer for the correct layout. `PortalFlags.PortalSide` is the sign bit of the plane equation: when set, the "inside" of this cell is on the *positive* side of the portal plane; when unset, "inside" is the *negative* side. ACViewer's `find_transit_cells` uses it as: ```csharp var dist = Vector3.Dot(center, portalPoly.Plane.Normal) + portalPoly.Plane.D; if (portal.PortalSide) { if (dist < -rad) continue; } else { if (dist > rad) continue; } ``` i.e. "only consider this portal for transit if the sphere center is on or past the portal plane in the allowed direction." **`OtherCellId = 0xFFFF` is the "this portal opens to outside" marker.** Dungeon entrance cells have at least one of these; the physics code takes that branch into `LandCell.add_all_outside_cells` to splice the outdoor cells in, i.e. the dungeon is seamlessly adjacent to the outdoor landblock at that cell. ### 2.4 VisibleCells (pre-baked PVS) The `VisibleCells[]` list is a precomputed **Potentially Visible Set** shipped in the dat: "from this cell, you can at most see these cells." It is a *superset* of the true runtime-visible set (depends on where in the cell the camera is, frustum, portal occlusion) but a *subset* of the portal graph's transitive closure — it excludes cells that are in the physical portal graph but that the level designers marked as never-directly-visible (e.g. the room behind a closed door). ACViewer builds a dictionary of them at load (`EnvCell.cs:127`): ```csharp public void build_visible_cells() { VisibleCells = new Dictionary(); foreach (var visibleCellID in VisibleCellIDs) { var blockCellID = ID & 0xFFFF0000 | visibleCellID; if (VisibleCells.ContainsKey(blockCellID)) continue; var cell = (EnvCell)LScape.get_landcell(blockCellID); VisibleCells.Add(visibleCellID, cell); } } ``` For render culling we will use this list directly (no BFS needed past this); we only fall back to portal BFS for physics transit checks. ### 2.5 StaticObjects Identical format to the outdoor `LandBlockInfo.Objects`: a `Stab` (uint32 id + Frame). Frames are **local to the EnvCell position**, not world-space. Phase 2d shipped this path already — the lesson from memory `project_phase_2d_state.md` applies: **do not add the cell origin**. Transform is `cellPosition * stabLocalFrame` in column-major convention. ### 2.6 RestrictionObj A `uint` guid of a server weenie that gates entry to this cell. The physics engine calls `check_entry_restrictions(transition)` (ACViewer `EnvCell.cs:88`) which asks the server whether the player satisfies the restriction (common uses: quest-locked room, house access control, Olthoi-only tunnel). For acdream R9 we can stub this as "always permit" until we wire server actions back through; the wire format is just a uint guid, no extra data. --- ## 3. CellPortal Geometry: the Doorway Polygon The *geometry* of a portal is a **single polygon** in the cell's CellStructure, indexed by `CellPortal.PolygonId`. This is the doorway quad you'd see if you lit it up — typically a 4-sided planar polygon filling the doorframe. The cell's `CellStructure.Portals[]` is a parallel list of polygon indices: From `CellStruct.generated.cs:26–28`: ``` Dictionary Polygons; // all polys indexed by id List Portals; // indices into Polygons for portal polys ``` ACViewer uses this layout (`Physics/Common/EnvCell.cs:163`): ```csharp var portal = Portals[portalId]; var portalPoly = CellStructure.Portals[portalId]; // polygon index // then looks up: CellStructure.Polygons[portalPoly] ``` Wait — the key subtlety: the *polygon index* is both in `CellPortal.PolygonId` *and* in `CellStruct.Portals`. These should be consistent; think of `CellStruct.Portals` as a convenience fast-path that lists portal polygons without walking all polys, and `CellPortal.PolygonId` as the authoritative reference. **The polygon is NOT a physical obstacle.** It has no collision. It's a virtual plane used by: 1. **Portal-side testing**: does the camera/sphere/bbox lie on the "inside" face of this cell relative to the portal plane? 2. **Visibility clipping**: does the view frustum actually pierce the polygon, or is the neighboring cell fully behind the wall? 3. **Transit detection**: is a moving sphere about to cross the plane from inside to outside (i.e. leave this cell)? The polygon is typically 4 vertices but up to ~8 in retail. Use the polygon's **plane** (normal + D from the first 3 vertices) for the side tests; use the **bounding box of the polygon** for the tighter visibility-through-aperture frustum test. --- ## 4. `PlayerTeleport` Message (server → client, 0xF751) ### 4.1 Wire bytes From ACE `GameMessagePlayerTeleport.cs`: ```csharp public GameMessagePlayerTeleport(Player player) : base(GameMessageOpcode.PlayerTeleport, GameMessageGroup.SmartboxQueue, 21) { Writer.Write(player.Sequences.GetNextSequence(Sequence.SequenceType.ObjectTeleport)); Writer.Align(); } ``` `PacketOpCodeNames.cs:{532,533}` has the decompiled client's matching event name: `Evt_Physics__PlayerTeleport_ID = 63313 = 0xF751`. holtburger `protocol/messages/movement/messages/teleport.rs` confirms the unpack: ```rust pub struct PlayerTeleportData { pub teleport_sequence: u16, // u16 little-endian } // then align_offset(offset, 4) // 2 bytes padding → 4 bytes total payload ``` **Bytes after the opcode:** | offset | size | field | |--------|------|--------------------| | 0 | 2 | teleport_sequence | | 2 | 2 | align padding (0) | Total: 4 bytes payload + 4-byte opcode = 8 bytes in the game-message body. The message is in `SmartboxQueue` (group 21) so it rides the main ordered stream. ### 4.2 When the server sends it ACE `Player_Location.Teleport:686`: ```csharp Teleporting = true; LastTeleportTime = DateTime.UtcNow; LastTeleportStartTimestamp = Time.GetUnixTime(); if (fromPortal) LastPortalTeleportTimestamp = LastTeleportStartTimestamp; Session.Network.EnqueueSend(new GameMessagePlayerTeleport(this)); // send a "fake" update position to get the client to start loading asap var prevLoc = Location; Location = newPosition; SendUpdatePosition(); Location = prevLoc; DoTeleportPhysicsStateChanges(); // hidden=true, ignoreCollisions=true PhysicsObj.report_collision_end(true); if (UnderLifestoneProtection) LifestoneProtectionDispel(); HandlePreTeleportVisibility(newPosition); UpdatePlayerPosition(new Position(newPosition), true); ``` So the server's send-order is: 1. `PlayerTeleport(seq)` — enter portal space. 2. `UpdatePosition(newLocation)` — "fake" position at the destination so the client can start loading the target landblock. 3. `DoTeleportPhysicsStateChanges` → broadcast hidden/no-collision. 4. (after landblock loads) second `UpdatePosition` at the actual destination. 5. (after `CreateWorldObjectsCompleted`) `OnTeleportComplete` broadcasts physics-state change to fully materialized. ### 4.3 How the client responds From holtburger `client/messages.rs:434`: ```rust GameMessage::PlayerTeleport(data) => { log::info!("Portal transition started (seq: {})", data.teleport_sequence); self.send_login_complete().await?; Ok(()) } ``` **Key behavior**: the client re-sends `LoginComplete` (game action `0x00A1`). This is not a literal re-login; it's how the retail client tells the server "I have finished loading the new landblock and am ready to receive object spawns." Without this, the server holds the player in the pink-bubble state indefinitely. --- ## 5. Portal-Space State Machine ### 5.1 Client flag From the decompiled client, chunk `005D0000` and `00560000`: - `*(iVar6 + 0x238) != '\0'` is the "in portal space" flag on the primary client object (the Player). It blocks combat mode entry (chunk `00560000:8593`: `"You can't enter combat mode while in portal space"`). - While the flag is set, additional input gates in the command interpreter reject: combat mode toggles, UI shortcut casts that would teleport again, and (per AC wiki) skill trainer dialogs. ### 5.2 Derived state (acdream implementation plan) ``` enum TeleportPhase { Idle, WaitingForLandblock, // received PlayerTeleport; streaming target landblock Materializing, // landblock loaded, received final UpdatePosition Done // received physics-state "fully materialized" } ``` While `Phase != Idle`: - WASD/space input ignored by input handler. - Camera orbit still works (retail permits looking around). - Chat still works (retail permits chat from portal space). - Avatar is rendered **hidden** (fully transparent or pink-bubble particle overlay — see §6). - Collision is disabled (player does not push into world geometry that's in transit). - Stream radius is temporarily increased or the target landblock is force-loaded on a high priority so the player doesn't come out before terrain is up. ### 5.3 Exit condition Retail's `OnTeleportComplete` (ACE `Player_Location.cs:740`): ```csharp if (CurrentLandblock != null && !CurrentLandblock.CreateWorldObjectsCompleted) { // keep pink bubble state — retry in 100ms actionChain.AddDelaySeconds(0.1).AddAction(this, OnTeleportComplete); return; } if (CloakStatus != CloakStatus.On) ReportCollisions = true; IgnoreCollisions = false; Hidden = false; Teleporting = false; CheckMonsters(); CheckHouse(); EnqueueBroadcastPhysicsState(); ``` So the client can't unilaterally exit — the **server drives the exit** via the final physics-state broadcast (`Evt_Physics__SetState`). In offline mode we mimic: once the target landblock has applied terrain *and* at least one drain-completion frame has elapsed, flip the flag. --- ## 6. Loading Screen: there isn't one ### 6.1 What retail actually does Searched `docs/research/decompiled/` for `L"Loading"`, `L"Entering"`, `L"Welcome"`, progress-bar primitives, "ProgressBar", fade shaders — **no hits**. There is no dedicated loading-screen overlay in retail. What retail *does* do: 1. **GlobalFogColor** / **EnvironChangeType** can black out the scene during transit. ACE `Player_Location.cs:667` explicitly sinks a 1-second clear-fog delay before teleporting so dungeons don't inherit outdoor fog state. If we preserve this, crossing a portal into a dungeon looks like a quick fade-to-black (fog clamp tightens to the player) followed by a fade-to-dungeon-ambient as the new environment streams in. 2. **Pink bubble avatar**: while `Hidden == true && IgnoreCollisions == true`, retail renders the player as a semi-transparent bubble (the `PlayScript.Hide` effect in `DoPreTeleportHide`). This is a PSTACK+alpha-blended material swap, not a separate UI element. 3. **No progress bar, no hourglass, no splash.** The text "*You have been teleported too recently!*" is the only UI feedback for rejected teleports. ### 6.2 Acceptable deviations for acdream Because our streaming is not instantaneous and we want to *not* render a black frame if the landblock is slow, we can add: - A short (< 500ms) alpha fade on the world rendertarget while `TeleportPhase != Idle`. - A tiny text string "Teleporting…" in the debug overlay if diagnostics are on. - Never a blocking modal. The player should still see camera orbit, chat, and the player motion anim during the transit. This is a clear deviation from retail and should be marked as such in the spec — call it "acdream courtesy fade" and keep it below the threshold where it changes gameplay feel. --- ## 7. Recall Mechanics ### 7.1 Taxonomy of recalls All of these are **server-side teleport destinations** triggered by client game-action sends. The client does not compute the destination; it asks, the server approves + plays an animation, *then* `PlayerTeleport` + `UpdatePosition` arrive. | Recall | Game action opcode | Animation (MotionCommand) | Server handler | |---------------------|--------------------|---------------------------|-----------------------------| | Lifestone (`/ls`) | `0x0063` | `LifestoneRecall` | `HandleActionTeleToLifestone` | | House recall | `0x0262` | `HouseRecall` | `HandleActionTeleToHouse` | | Allegiance hometown | `0x02AB` | `AllegianceHometownRecall`| `HandleActionRecallAllegianceHometown` | | Mansion/Villa | `0x0278` | `HouseRecall` | `HandleActionTeleToMansion` | | Marketplace | `0x028D` | `MarketplaceRecall` | `HandleActionTeleToMarketPlace` | | PK Arena | `0x0027` | `PKArenaRecall` | `HandleActionTeleToPkArena` | | PKL Arena | (reuses 0x0027?) | `PKArenaRecall` | `HandleActionTeleToPklArena`| References: - Game-action table: `ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs` lines 21, 53, 120, 133, 138, 149. - Animation table: `ACE/Source/ACE.Server/WorldObjects/Player_Location.cs` lines 47–49, 197, 264, 469. ### 7.2 Server validation (ACE Player_Location.cs:132) For every recall: 1. Reject if `PKTimerActive` (recent PK combat). 2. Reject if `RecallsDisabled` (training academy). 3. Reject if `TooBusyToRecall` (busy flag or suicide in progress). 4. Reject if specific preconditions fail (no Sanctuary → lifestone fails; no house → house recall fails; no allegiance → hometown fails). 5. `SendMotionAsCommands(recallMotion, NonCombat)` broadcasts the animation. 6. `ActionChain.AddDelaySeconds(animLength)` waits for the anim. 7. Checks the player hasn't moved more than `RecallMoveThresholdSq` (= 64 m²) during the anim; if so, reject with `WeenieError.YouHaveMovedTooFar`. 8. `Teleport(destination)` → fires the full `PlayerTeleport` flow. ### 7.3 Portal use (not a recall but same class) Portal objects are weenies with `ActivationResponse |= ActivationResponse.Use`. When the player **uses** the portal (via `Event_UseObject = 0x0019` with the portal's guid), the server runs `Portal.CheckUseRequirements` (level, PK status, quest flag, advocate, Olthoi, Account15Days, Throne of Destiny, etc.), then `ActOnUse`: ```csharp var portalDest = new Position(Destination); AdjustDungeon(portalDest); WorldManager.ThreadSafeTeleport(player, portalDest, ..., fromPortal: true); ``` The `AdjustDungeon` call matters: some retail dungeons have the portal destination pinned to a position that's *inside the wall* of the first cell (old data bug, never fixed in retail data). `AdjustPos` carries a hand-maintained dictionary of `(dungeonId → badPos → goodPos)` overrides that bump the player to a safe cell. See `ACE/Source/ACE.Server/Physics/Util/AdjustPos.cs`. We will need the same table; ACE's version is empty today because live-ACE's data has been patched, but the original dungeons still need it. `AdjustDungeonCells` walks the dungeon's EnvCells (via `AdjustCell.Get(dungeonId)`) and calls `envCell.point_in_cell(pos)` to find the correct starting cell. This is our canonical "given a world point, which EnvCell am I in?" query. ### 7.4 Lifestone attunement Linking to a lifestone is a **client game action** (not in the recall table because it's a one-off action): it writes the current player position into the character's `Sanctuary` position slot on the server side. No special teleport mechanics — just storage. Subsequent `/ls` reads `Sanctuary` as the destination. --- ## 8. Dungeon Streaming Policy ### 8.1 All-at-once vs streamed From DatReaderWriter + ACE usage, **the retail pattern is load-all** for a dungeon landblock: once you cross the dungeon entrance portal, the server considers you in that landblock and sends `UpdatePosition` with the dungeon cell id. The client loads the entire `LandBlockInfo.NumCells` cell set on landblock-enter. This is fine for small dungeons (<100 cells) but large ones like Aerfalle's Sanctum (~800 cells) or Freebooter Keep Black Market show a noticeable hitch on retail. Retail accepts this hitch. ### 8.2 Recommended acdream policy For R9 we ship the retail policy: **on landblock-enter, load all N EnvCells synchronously into the cell cache**. Rationale: - Interior cells are small (the typical `DrawingBSP` is < 50 KB). - Most dungeons are < 200 cells so total load < 10 MB. - Portal traversal correctness depends on every visible-cell being resident at query time; deferred loading introduces a race between "camera sees into next room" and "geometry uploaded." ACME `EnvCellManager.LoadedDungeonCellCount` caps this at 10,000 to protect against pathological data, but that cap is per-system not per-dungeon. A future R9.1 optimization could stream cells by portal BFS distance (cells within 2 portals of the camera loaded eagerly, rest deferred), but R9 should match retail. ### 8.3 Integration with `LandblockLoader` Extend `LoadedLandblock` to carry an `IReadOnlyList` plus the `Environment` lookup: ```csharp public sealed record LoadedLandblock( uint Id, LandBlock Terrain, IReadOnlyList OutdoorEntities, IReadOnlyList EnvCells, // NEW IReadOnlyDictionary Environments // NEW (shared) ); ``` and in `LandblockLoader.Load`: ```csharp var envCells = new List((int)(info?.NumCells ?? 0)); for (uint i = 0; i < (info?.NumCells ?? 0); i++) { var cellId = (landblockId & 0xFFFF0000u) | (0x100u + i); var cell = dats.Get(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(); var queue = new Queue(); visited.Add(cameraCell.CellId); result.VisibleCellIds.Add(cameraCell.CellId); queue.Enqueue(cameraCell); uint lbMask = cameraCell.CellId & 0xFFFF0000; while (queue.Count > 0) { var cell = queue.Dequeue(); for (int i = 0; i < cell.Portals.Count; i++) { var portal = cell.Portals[i]; if (portal.OtherCellId == 0xFFFF) { result.HasExitPortalVisible = true; continue; } uint neighborId = lbMask | portal.OtherCellId; if (visited.Contains(neighborId)) continue; if (!_cellLookup.TryGetValue(neighborId, out var neighbor)) continue; // Portal-side plane test if (i < cell.ClipPlanes.Count) { var plane = cell.ClipPlanes[i]; var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform); float dot = Vector3.Dot(plane.Normal, localCam) + plane.D; if (plane.InsideSide == 0 && dot < -PointInCellEpsilon) continue; if (plane.InsideSide == 1 && dot > PointInCellEpsilon) continue; } // Frustum test on neighbor bbox var neighborBounds = new BoundingBox( neighbor.WorldPosition - new Vector3(CellBoundsRadius), neighbor.WorldPosition + new Vector3(CellBoundsRadius)); if (!frustum.IntersectsBoundingBox(neighborBounds)) continue; visited.Add(neighborId); result.VisibleCellIds.Add(neighborId); queue.Enqueue(neighbor); } } return result; } ``` (Cited verbatim from ACME `EnvCellManager.cs:1421–1475` because it's the exact algorithm we want.) ### 9.2 The portal-polygon aperture refinement The above BFS walks portal graph edges. A tighter version *shrinks the frustum to the portal polygon at each step*, so a neighbor cell is only visible if the frustum ∩ portal polygon is non-empty. Retail does NOT do this refinement — it's "fast and loose": a cell is visible if any portal connects to it and the neighbor's bbox intersects the view frustum. This is good enough because cells are small (~10 units) and the portal-side plane test already cuts backwards-facing neighbors. We ship the simpler version for R9. ### 9.3 "Can see through the door to the next room" — the load-bearing test The user emphasized this. Concretely: 1. Player is in cell A. 2. A has a portal `portals[0]` to cell B (OtherCellId=0x123, OtherPortalId=2). 3. The portal polygon is the doorframe quad. 4. Camera is inside A, facing the doorway. 5. We want B's geometry, B's static objects, B's NPCs (server-spawned weenies physically in B) all rendered. The BFS above gets us (1)–(4) for free. For (5) — NPC rendering — we need the per-object `current_cell_id` to be correct so that `IsVisibleIndoors(npc.currentCell)` returns true: ```csharp public bool IsVisibleIndoors(ObjCell cell) { var blockDist = PhysicsObj.GetBlockDist(ID, cell.ID); if (blockDist == 0) { var cellID = cell.ID & 0xFFFF; if (VisibleCells.ContainsKey(cellID)) return true; } return SeenOutside && blockDist <= 1; } ``` (ACViewer `EnvCell.cs:455`.) So for indoor rendering, we enumerate the visible-cell set, and for each living entity whose `currentCell.Id & 0xFFFF` is in that set, we render it. ### 9.4 Edge case: camera at the exact portal plane When the camera is *on* the portal plane (dot ≈ 0), retail keeps both cells visible via `PointInCellEpsilon`: ```csharp if (plane.InsideSide == 0 && dot < -PointInCellEpsilon) continue; if (plane.InsideSide == 1 && dot > PointInCellEpsilon) continue; ``` So if `|dot| < epsilon`, the portal is not culled and both cells render. Epsilon value: ACME uses `0.01` (1 cm). Avoid zero — it causes popping at doorways. --- ## 10. Multi-Floor Stair Walking ### 10.1 The challenge Dungeons have vertical structure. A spiral staircase connects cells at different Z heights. The player is mostly in one cell at a time, but while walking up the stairs, the collision sphere crosses the portal plane between cell[N] and cell[N+1]. If the rendering / physics don't agree on which cell the player is in, the player either falls through the floor (physics says "I'm in upper cell, lower cell's floor no longer collides") or gets stuck (rendering says "I'm in upper cell, upper cell's walls clip me back"). ### 10.2 Retail solution: sphere path transit ACViewer `Physics/Common/EnvCell.cs:323–383` — `find_transit_cells` walks all portals and tests the *sphere path*, not the *point*. A sphere of radius `r` straddling a portal plane generates a `cellArray` containing *both* sides: ```csharp foreach (var portal in Portals) { var portalPoly = CellStructure.Polygons[portal.PolygonId]; if (portal.OtherCellId == 0xFFFF) { // test for outside transit foreach (var sphere in spheres) { var dist = Vector3.Dot(center, portalPoly.Plane.Normal) + portalPoly.Plane.D; if (dist > -rad && dist < rad) { checkOutside = true; break; } } } else { var otherCell = GetVisible(portal.OtherCellId); if (otherCell != null) { foreach (var sphere in spheres) { var center = otherCell.Pos.Frame.GlobalToLocal(sphere.Center); var _sphere = new Sphere(center, sphere.Radius); if (otherCell.CellStructure.sphere_intersects_cell(_sphere) != BoundingType.Outside) { cellArray.add_cell(otherCell.ID, otherCell); break; } } } } } ``` So while the player sphere straddles a vertical portal, *both* the upper and lower cell's collision geometry is active. No falling, no sticking. ### 10.3 Vertical portals don't need special handling The portal plane can have any normal — horizontal (typical doorway), vertical (ceiling-to-ceiling in a stair well where two cells overlap vertically), or arbitrary (sloped). The BSP tests work regardless. ### 10.4 Implementation consequence for acdream Our player collision currently operates in a single cell. When we port this, the player's `CurrentCell` becomes an `IReadOnlyList` 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 visible)` pass that: - Finds the camera cell via `FindCameraCell`. - If outside all cells → render buildings the old way, skip interior pass. - If inside a cell → run `PortalVisibility.BFS`, render only `VisibleCellIds`. 2. Depth clearing: when the camera enters a dungeon cell, clear the depth buffer between the terrain pass and the EnvCell pass. ACME does this — otherwise the terrain's Z values occlude the interior geometry that's supposed to be below ground. 3. ACME's `DungeonDepthOffset = -50f` applies to dungeon-only cells to push them below terrain Z — prevents Z-fighting when a dungeon landblock happens to share a block id with a terrain landblock. ### 12.4 Physics integration 1. Port `ObjCell.find_transit_cells` (sphere variant + parts variant). 2. Port `EnvCell.point_in_cell` using the CellStruct BSP. 3. Port `EnvCell.FindEnvCollisions` to run collision against `CellStructure.PhysicsBSP`. 4. Port the multi-cell straddle logic so the player's body can span multiple cells during stair walking. 5. Apply `AdjustCell.GetCell(pos)` on every position update inside a dungeon to correct the server-authoritative cell id before physics runs (handles both imprecise server positions and the "bad data" overrides in `AdjustPos`). ### 12.5 Teleport controller ```csharp public enum TeleportPhase { Idle, WaitingForLandblock, Materializing, Done } public sealed class TeleportController { public TeleportPhase Phase { get; private set; } public ushort LastTeleportSequence { get; private set; } public void OnPlayerTeleport(PlayerTeleportMessage msg) { LastTeleportSequence = msg.TeleportSequence; Phase = TeleportPhase.WaitingForLandblock; _input.BlockMovement(true); _render.SetAvatarHidden(true); _physics.IgnoreCollisions = true; _network.SendLoginComplete(); // holtburger-confirmed _streaming.BoostRadiusTemporarily(3); // load target LB fast } public void OnUpdatePosition(UpdatePositionMessage msg, bool isFinal) { if (Phase == TeleportPhase.Idle) return; if (!isFinal) { // intermediate: target LB id revealed, ensure it's loading _streaming.PrioritizeLandblock(msg.LandblockId); return; } Phase = TeleportPhase.Materializing; } public void OnPhysicsStateMaterialized() { if (Phase != TeleportPhase.Materializing) return; Phase = TeleportPhase.Done; _input.BlockMovement(false); _render.SetAvatarHidden(false); _physics.IgnoreCollisions = false; _streaming.RestoreRadius(); Phase = TeleportPhase.Idle; } } ``` Acceptance test: observer sees (a) avatar hides, (b) 200–1000ms pink bubble, (c) avatar re-appears at new location fully materialized. In offline mode we fake `OnPhysicsStateMaterialized` after `applyTerrain` completes for the target landblock. ### 12.6 Recall actions Wire the six recall game-action builders. All are zero-payload sends — just the opcode. Example for lifestone: ```csharp public static class GameActionBuilder { public static GameAction TeleToLifestone() => new(GameActionType.TeleToLifestone); // 0x0063, empty payload public static GameAction TeleToMansion() => new(GameActionType.TeleToMansion); // 0x0278 public static GameAction TeleToHouse() => new(GameActionType.TeleToHouse); // 0x0262 public static GameAction TeleToMarketPlace() => new(GameActionType.TeleToMarketPlace); // 0x028D public static GameAction RecallAllegianceHometown() => new(GameActionType.RecallAllegianceHometown); // 0x02AB public static GameAction TeleToPkArena() => new(GameActionType.TeleToPkArena); // 0x0027 } ``` All other state (animation length, sanity checks, destination resolution) lives on the server. The client just waits for the `PlayerTeleport` that follows. ### 12.7 Conformance tests - `EnvCellTests.cs`: round-trip decode/encode on 10 sample EnvCells covering the flag combinations (none, HasStaticObjs, HasRestrictionObj, SeenOutside). - `PortalVisibilityTests.cs`: hand-built cell graph (A ↔ B ↔ C, A ↔ D) with portal planes, asserts BFS output for various camera positions. - `AdjustCellTests.cs`: synthetic dungeon with 3 cells, point-in-cell queries across all 3. - `TeleportFlowTests.cs`: fake wire messages, assert state machine moves Idle → Waiting → Materializing → Done and input gating flips correctly. - `DungeonClassificationTests.cs`: feed the exact ACE formula with edge cases (the NW island hack, a landblock with height 0 in one cell but not others, a landblock with 0 cells). ### 12.8 Phase sequencing on the roadmap R9 depends on: - R1–R8 mostly shipped (phase state in memory). - The sequence counter work from Sprint 1 (memory `project_sprint_state.md`). - Physics collision port (completed — `project_collision_port.md`). R9 **enables**: - All indoor quest progression (dungeons are currently invisible). - Housing (house interiors are EnvCells in mixed landblocks). - Allegiance mansion vaults. - The complete recall UX suite (the keybinds exist; the wire messages need to go out and the return flow handled). ### 12.9 Acceptance criteria - Walk through the first dungeon entrance in Holtburg and see the interior render correctly. - `dotnet build` green, `dotnet test` green including new conformance suites. - Visual confirmation: the drudge in the first cell is in the right position, the portals match retail's visual layout, no Z-fighting between dungeon floor and outdoor terrain when the landblock is mixed. - `/ls` works: client sends action, server responds with `PlayerTeleport`, client shows pink bubble, player re-appears at lifestone position. - Cross-cell visibility works: standing in one cell, the next cell's geometry visible through the doorway, no popping, no "other cell visible from behind a wall" bug. --- ## 13. Open Questions / Follow-up Research 1. **Environment dat load size on disk.** We need to sample the dat sizes for representative Environments to set the cache memory budget. Plan: add a diag command that dumps every loaded Environment's byte size. 2. **Cell transit when the portal polygon is concave.** Retail's polygons are always convex (BSP design assumption). Confirm no dat file violates this before committing to convex-only clipping. 3. **How the server picks between `0xFFFF` (outside) and a real neighbor cell when both are eligible.** The physics engine's `add_all_outside_cells` adds the entire outdoor cell ring; that's probably expensive to replicate exactly. A simpler heuristic: for `0xFFFF` portals, treat the portal as opening into the outdoor landblock and run the outdoor cell's BSP query. Measure first. 4. **Dungeon-specific fog color.** Retail has `GlobalFogColor` set per-dungeon by server data. We don't yet carry this on our `LoadedLandblock`. It's likely a world-db column in ACE; we can seed a default-black for all dungeons until we load real server data. 5. **PKL vs PK arena opcode.** Our table shows both using `0x0027`; ACE has separate handlers (`HandleActionTeleToPklArena` on the same opcode path). Worth double-checking the decompiled client to see if there's actually a separate opcode hiding. Search chunk_005F0000 for the game-action dispatcher and trace. --- ## 14. References Cited | # | File | Purpose | |---|------|---------| | 1 | `references/DatReaderWriter/.../EnvCell.generated.cs` | Wire format of EnvCell | | 2 | `references/DatReaderWriter/.../CellPortal.generated.cs` | Wire format of CellPortal | | 3 | `references/DatReaderWriter/.../LandBlockInfo.generated.cs` | NumCells + Objects + Buildings layout | | 4 | `references/DatReaderWriter/.../CellStruct.generated.cs` | Polygon + Portal list + BSP layout | | 5 | `references/DatReaderWriter/.../EnvCellFlags.generated.cs` | Flag bit values | | 6 | `references/DatReaderWriter/.../PortalFlags.generated.cs` | PortalSide + ExactMatch bits | | 7 | `references/ACViewer/ACViewer/Physics/Common/EnvCell.cs` | Ground truth for runtime EnvCell behavior, portal traversal, transit, point_in_cell | | 8 | `references/ACE/.../Physics/Common/LScape.cs` | get_landblock / get_landcell entry points | | 9 | `references/ACE/.../Physics/Common/Landblock.cs:575` | IsDungeon / HasDungeon formula | | 10 | `references/ACE/.../Physics/Util/AdjustCell.cs` | "Which dungeon cell contains this point?" | | 11 | `references/ACE/.../Physics/Util/AdjustPos.cs` | Per-dungeon position override table | | 12 | `references/ACE/.../WorldObjects/Portal.cs` | Portal-use server flow | | 13 | `references/ACE/.../WorldObjects/Player_Location.cs` | Teleport/OnTeleportComplete/recall handlers | | 14 | `references/ACE/.../GameMessagePlayerTeleport.cs` | Wire format of 0xF751 | | 15 | `references/ACE/.../GameMessageOpcode.cs:61` | PlayerTeleport = 0xF751 | | 16 | `references/ACE/.../GameActionType.cs` | TeleToX opcodes | | 17 | `references/holtburger/.../messages/movement/messages/teleport.rs` | Client-side unpack of PlayerTeleport | | 18 | `references/holtburger/.../client/messages.rs:434` | Client re-sends LoginComplete on teleport | | 19 | `references/WorldBuilder-ACME-Edition/.../EnvCellManager.cs` | Chorizite/Silk.NET rendering pipeline reference | | 20 | `docs/research/decompiled/chunk_00560000.c:8593` | Portal-space combat-mode rejection; confirms `*(player + 0x238)` as the in-portal-space flag | | 21 | `docs/research/decompiled/chunk_00570000.c:1964–2036` | Full lifestone/portal error string catalog | | 22 | `docs/research/decompiled/chunk_005D0000.c:8828–8843` | "AC1: LandBlocks Rendered" / "AC1: EnvCells Rendered" / "AC1: Portals Traversed" render stats; confirms portal-based culling model |