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.
48 KiB
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
0xAAAA0000family 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
0xLLYY0000landblock where every height sample is 0,LandBlockInfo.NumCells > 0, andLandBlockInfo.Buildings.Count == 0. It still has aLandBlock(0xLLYYFFFF) — we don't skip it — we just don't draw terrain for it. (ACELandblock.IsDungeon, chunk005E0000confirms the0xFFFE/0xFFFFsplit.) - Interior geometry lives in EnvCells with ids
0xLLYYcccwhereccc >= 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 viaCellStructure. - 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'sbuild_visible_cells(). - Portal space is a client state driven by one server message:
PlayerTeleport(opcode0xF751, 2-byteteleport_sequencethen 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 untilUpdatePositionarrives at the new location. The terminal state is "fully materialized" (OnTeleportCompletein 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 sendsPlayerTeleport+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.
GlobalFogColorcan 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_cellswalks portals for both sphere sweeps and the per-part bounding box tests (ACViewerPhysics/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: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.),
BuildingInforecords for the above-ground structures.
Collision and visibility have to handle the transition: the player can
walk from the outdoor landblock into a building/cellar without
crossing a landblock boundary — the portal is between an outdoor land
cell and an interior EnvCell. This is the "mixed landblock" path in
ACME EnvCellManager._mixedLandblocks.
1.4 The 0xFFFE / 0xFFFF pair
Every landblock has exactly two top-level dat records:
| Id suffix | Object type | Contents |
|---|---|---|
0xFFFF |
LandBlock |
9×9 height + terrain type + road/scene. Dungeon: zeros. |
0xFFFE |
LandBlockInfo |
NumCells, Objects[] (Stabs), Buildings[], RestrictionTable. |
We already read both in AcDream.Core.World.LandblockLoader.Load
(line 18–22). A dungeon extension only adds the cell loop:
for i in 0..info.NumCells:
envCellId = (landblockId & 0xFFFF0000u) | (0x100 + i)
envCell = dats.Get<EnvCell>(envCellId)
yield envCell
2. EnvCell Deep-Dive
2.1 Wire layout
From DatReaderWriter/Generated/DBObjs/EnvCell.generated.cs:68–97:
struct EnvCell {
DBObjHeader header; // id + flags
uint32 Flags; // EnvCellFlags
uint32 _cellId; // ignored — redundant with header.id
uint8 numSurfaces;
uint8 numPortals;
uint16 numVisibleCells;
uint16 surfaces[numSurfaces]; // | 0x08000000 → Surface file id
uint16 environmentId; // | 0x0D000000 → Environment file id
uint16 cellStructure; // key into Environment.Cells
Frame position; // world-space transform
CellPortal portals[numPortals];
uint16 visibleCells[numVisibleCells];
if (Flags & HasStaticObjs) {
uint32 numStabs;
Stab staticObjects[numStabs];
}
if (Flags & HasRestrictionObj) {
uint32 restrictionObj;
}
}
EnvCellFlags (Generated/Enums/EnvCellFlags.generated.cs):
SeenOutside = 0x01
HasStaticObjs = 0x02
HasRestrictionObj= 0x08
Note the skip of 0x04. There is no 0x04 defined in retail — do not
assume it's unused; ACEmulator preserves the gap and so must we.
2.2 Surfaces vs Environment vs CellStructure
This is easy to get wrong. Three separate ids:
- Surfaces[] are the textures applied to this cell's polygons.
They are short-form (
ushort); the full file id is0x08000000 | surf. Length isnumSurfaces, usually 3–10 per cell. Indexing is byPolygon.SurfaceIndexwithin the cell's CellStructure. - EnvironmentId names a
DBObj.Environmentat0x0D000000 | envId. AnEnvironmentis 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
ushortkey intoEnvironment.Cellsthat 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:
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:26–28:
Dictionary<ushort, Polygon> Polygons; // all polys indexed by id
List<ushort> Portals; // indices into Polygons for portal polys
ACViewer uses this layout (Physics/Common/EnvCell.cs:163):
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:
- Portal-side testing: does the camera/sphere/bbox lie on the "inside" face of this cell relative to the portal plane?
- Visibility clipping: does the view frustum actually pierce the polygon, or is the neighboring cell fully behind the wall?
- 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:
PlayerTeleport(seq)— enter portal space.UpdatePosition(newLocation)— "fake" position at the destination so the client can start loading the target landblock.DoTeleportPhysicsStateChanges→ broadcast hidden/no-collision.- (after landblock loads) second
UpdatePositionat the actual destination. - (after
CreateWorldObjectsCompleted)OnTeleportCompletebroadcasts 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 (chunk00560000: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:
- GlobalFogColor / EnvironChangeType can black out the scene
during transit. ACE
Player_Location.cs:667explicitly 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. - Pink bubble avatar: while
Hidden == true && IgnoreCollisions == true, retail renders the player as a semi-transparent bubble (thePlayScript.Hideeffect inDoPreTeleportHide). This is a PSTACK+alpha-blended material swap, not a separate UI element. - 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.cslines 21, 53, 120, 133, 138, 149. - Animation table:
ACE/Source/ACE.Server/WorldObjects/Player_Location.cslines 47–49, 197, 264, 469.
7.2 Server validation (ACE Player_Location.cs:132)
For every recall:
- Reject if
PKTimerActive(recent PK combat). - Reject if
RecallsDisabled(training academy). - Reject if
TooBusyToRecall(busy flag or suicide in progress). - Reject if specific preconditions fail (no Sanctuary → lifestone fails; no house → house recall fails; no allegiance → hometown fails).
SendMotionAsCommands(recallMotion, NonCombat)broadcasts the animation.ActionChain.AddDelaySeconds(animLength)waits for the anim.- Checks the player hasn't moved more than
RecallMoveThresholdSq(= 64 m²) during the anim; if so, reject withWeenieError.YouHaveMovedTooFar. Teleport(destination)→ fires the fullPlayerTeleportflow.
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.
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
DrawingBSPis < 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.LoadedDungeonCellCountcaps 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: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:
- Player is in cell A.
- A has a portal
portals[0]to cell B (OtherCellId=0x123, OtherPortalId=2). - The portal polygon is the doorframe quad.
- Camera is inside A, facing the doorway.
- 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: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:
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
- Player clicks the gate: client sends
UseObject(portalGuid)game action (opcode0x0019). - Server validates (
Portal.CheckUseRequirements). - Server sends
TextSpeechBroadcast(optional emote) and thePortal-sound effect. - Server calls
ActOnUse → AdjustDungeon(dest) → WorldManager.ThreadSafeTeleport(player, dest, callback, fromPortal: true). Teleportruns the fullPlayerTeleportflow (§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
LandblockLoader— extendLoadto also read theinfo.NumCellsEnvCells and environments. Return them on a newLoadedLandblock.EnvCells/.Environmentsproperty.LoadedLandblock— carries the classificationIsDungeon(computed from the ACE formula), plus the EnvCell list.- GPU state — a new
GpuEnvCellStatekeyed by landblock id, populated alongside the existing terrain state. Contains per-cell VAOs and per-environment shared geometry cache.
12.3 Render pipeline integration
- 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 onlyVisibleCellIds.
- Finds the camera cell via
- 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.
- ACME's
DungeonDepthOffset = -50fapplies 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
- Port
ObjCell.find_transit_cells(sphere variant + parts variant). - Port
EnvCell.point_in_cellusing the CellStruct BSP. - Port
EnvCell.FindEnvCollisionsto run collision againstCellStructure.PhysicsBSP. - Port the multi-cell straddle logic so the player's body can span multiple cells during stair walking.
- 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 inAdjustPos).
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) 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:
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 buildgreen,dotnet testgreen 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.
/lsworks: client sends action, server responds withPlayerTeleport, 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
- 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.
- 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.
- How the server picks between
0xFFFF(outside) and a real neighbor cell when both are eligible. The physics engine'sadd_all_outside_cellsadds the entire outdoor cell ring; that's probably expensive to replicate exactly. A simpler heuristic: for0xFFFFportals, treat the portal as opening into the outdoor landblock and run the outdoor cell's BSP query. Measure first. - Dungeon-specific fog color. Retail has
GlobalFogColorset per-dungeon by server data. We don't yet carry this on ourLoadedLandblock. It's likely a world-db column in ACE; we can seed a default-black for all dungeons until we load real server data. - PKL vs PK arena opcode. Our table shows both using
0x0027; ACE has separate handlers (HandleActionTeleToPklArenaon 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 |