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