acdream/docs/research/acclient_indoor_transitions_pseudocode.md
Erik 7078264291 fix(phys): #106 — outdoor membership crosses landblock boundaries (LandDefs global-lcoord port)
The player's outdoor cell froze at the last in-block cell the moment they
walked over a landblock boundary (10,449-frame playerCell freeze in the
2026-06-09 capture; whole neighbouring-block interiors unenterable, plus
the running-distortion from the stale render anchor). Root cause: the
add_all_outside_cells port clamped BOTH the candidate proposal and the
find_cell_list containing-cell pick to the current landblock's 8x8 grid,
in a frame that silently assumed the current block sits at world origin.
One step over the line -> zero candidates -> FindCellSet returns
currentCellId forever.

Retail has no such clamp. Its cell math runs in a GLOBAL landcell grid
(lcoord 0..2039 spanning the map): get_outside_lcoord = blockid_to_lcoord
+ floor(blockLocalPos/24) with no bounds besides the map edge, and
lcoord_to_gid re-derives the landblock id from the lcoord's upper bits —
crossings are inherent, never special-cased.

The fix, decomp-cited throughout:
- New AcDream.Core.Physics.LandDefs: in_bounds (pc:68509),
  blockid_to_lcoord (pc:68520), inbound_valid_cellid (pc:163438),
  gid_to_lcoord (pc:163500), lcoord_to_gid (pc:171859),
  get_outside_lcoord (pc:438690), adjust_to_outside (pc:438719).
  Cross-checked against ACE LandDefs.cs; three artifacts documented and
  avoided: BN's int8_t mis-render of block_y, BN's dropped 192f
  BlockLength constant, and ACE add_cell_block's "FIXME!" same-block
  guard (an ACE divergence, not retail).
- CellTransit.AddAllOutsideCells rewritten as the faithful sphere
  variant (pc:317499 @0x00533630): adjust_to_outside re-seats the
  (cell, position) pair cross-block, check_add_cell_boundary (pc:317229)
  adds up to 3 neighbours by global lcoord, add_outside_cell (pc:317056)
  has no same-block filter. adjust_to_outside failure breaks the sphere
  loop (pc:533699 verbatim).
- BuildCellSetAndPickContaining: the outdoor containing-cell pick is now
  the global XY-column under the sphere centre (AdjustToOutside), not
  the [0,8)-clamped current-prefix reconstruction. Interior-wins order
  and current-cell-first hysteresis unchanged.
- World->block-local frame conversion via the landblock origin already
  registered in CellGraph (new TryGetTerrainOrigin); Zero fallback
  preserves the legacy anchor-block assumption for unregistered terrain.
- Cross-landblock building entry comes free: the candidate snapshot now
  contains neighbour-block landcells, so GetBuilding/CheckBuildingTransit
  fire for cottages across the line (the capture's one failing entry).

Investigated FIRST per the pickup brief: the b3ce505 #98 stopgap gate is
definitively exonerated — it is a collision-object query gate that fires
only for indoor primary cells; no membership path touches
ShadowObjectRegistry.

Tests: 31 new (25 LandDefs conformance incl. capture-geometry goldens
0xA9B40031 -> 0xA9B30038/0xA9B30034 and the northbound return; 4
AddAllOutsideCells cross-block; 3 FindCellSet membership goldens incl.
the non-anchor-frame origin conversion). Full suite: 294+218+420 green;
Core 1369 green + the 4 pre-existing door/#99-era failures + 1 skip
(unchanged from baseline).

Pseudocode + artifact notes:
docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md.
Remaining acceptance: live boundary walk with ACDREAM_PROBE_CELL=1
(ISSUES.md #106).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:10:59 +02:00

20 KiB
Raw Blame History

Indoor Cell Transitions — Research & Pseudocode

Date: 2026-04-13 Sources cross-referenced:

  • references/ACE/Source/ACE.Server/Physics/Common/EnvCell.cs — primary ACE oracle
  • references/ACE/Source/ACE.Server/Physics/Common/ObjCell.csfind_cell_list
  • references/ACE/Source/ACE.Server/Physics/Common/LandCell.csadd_all_outside_cells
  • references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs — surface building path
  • references/ACE/Source/ACE.Server/Physics/Common/BldPortal.cs — PortalFlags
  • references/ACE/Source/ACE.Server/Physics/Common/CellStruct.cssphere_intersects_cell, box_intersects_cell
  • references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/EnvCellManager.cs — ACME's editor implementation: FindCameraCell, PointInCell, GetVisibleCells
  • docs/research/decompiled/chunk_00530000.c — the 0x531A00-0x532440 range: data structure layout ops (serialize/deserialize), NOT transit cell logic
  • references/AC2D/cWObject.cpp — AC2D does NOT implement client-side cell transitions (sends keys to server, uses server Z)
  • references/holtburger/ — holtburger is TUI only, no spatial physics

Terminology

Term Meaning
CellPortal / DatLoader.Entity.CellPortal A record in an EnvCell's .CellPortals list. Contains PolygonId (the portal polygon in the CellStruct), OtherCellId (low-16 of the destination cell, 0xFFFF = outdoor), and Flags (PortalSide bit).
PortalSide true = the outside-facing normal of the portal polygon points INTO the cell (you enter by crossing from the negative-dist side). false = the opposite. Derived from Flags & 2 in the dat.
CellStruct.Polygons Dictionary of physics + rendering polygons keyed by polygon ID. Each polygon has a Plane (Normal + D).
CellStruct.CellBSP BSP tree used for sphere_intersects_cell and box_intersects_cell and point_in_cell.
EnvCell.VisibleCells Pre-built from VisibleCellIDs list: all cells visible from this one. Used for neighbor lookups.
BldPortal A portal used for above-ground buildings attached to LandCells via SortCell. Uses PortalFlags, ExactMatch, PortalSide, OtherCellId, OtherPortalId.
SortCell An outdoor landblock cell that may contain a BuildingObj (e.g. the Holtburg inn). Delegates transit detection to BuildingObj.find_building_transit_cells.
EPSILON PhysicsGlobals.EPSILON — used as sphere-radius padding in tests. In our codebase, use a small value like 0.02f.

Overall Driver: find_cell_list (ObjCell.cs, static)

This is the top-level function called each movement tick. It builds the list of cells the entity currently occupies or is transitioning into.

find_cell_list(position, numSphere, spheres[], cellArray, ref currCell, path):
    cellArray.Clear()

    visibleCell = GetVisible(position.ObjCellID)   // looks up the cell from the dat

    if position is indoor (ObjCellID & 0xFFFF >= 0x100):
        if path != null: path.HitsInteriorCell = true
        cellArray.add_cell(position.ObjCellID, visibleCell)
    else:
        LandCell.add_all_outside_cells(position, numSphere, spheres, cellArray)

    if visibleCell != null AND numSphere > 0:
        // For each cell already in the array, ask it to find neighbors the sphere touches
        for each cell in cellArray (snapshot — cells may be added during iteration):
            cell.find_transit_cells(position, numSphere, spheres, cellArray, path)

        // Determine which cell the sphere center is actually inside
        if currCell != null:
            currCell = null
            for each cell in cellArray:
                blockOffset = GetBlockOffset(position.ObjCellID, cell.ID)
                localPoint = spheres[0].Center - blockOffset
                if cell.point_in_cell(localPoint):
                    currCell = cell
                    if cell is indoor: path.HitsInteriorCell = true
                    break

    // Visibility filter: when indoors, strip cells that aren't in VisibleCells
    if NOT cellArray.LoadCells AND position is indoor:
        for each cell in cellArray (copy):
            if cell == visibleCell: continue
            if cell not in ((EnvCell)visibleCell).VisibleCells:
                cellArray.remove_cell(cell)

EnvCell.find_transit_cells (sphere variant)

Called from find_cell_list for each cell in the array. Detects which neighboring cells the spheres may have entered.

Source: EnvCell.cs:311-371 (the sphere overload)

EnvCell.find_transit_cells(position, numSphere, spheres[], cellArray, path):
    checkOutside = false

    for each portal in this.Portals:
        portalPoly = this.CellStructure.Polygons[portal.PolygonId]
        // portalPoly.Plane = (Normal, D) in cell-local space

        if portal.OtherCellId == 0xFFFF:           // exit to outdoor
            // Test each sphere for proximity to the portal plane
            for each sphere in spheres:
                rad = sphere.Radius + EPSILON
                center = this.Pos.Frame.GlobalToLocal(sphere.Center)  // world → cell-local
                dist = Dot(center, portalPoly.Plane.Normal) + portalPoly.Plane.D

                if dist > -rad AND dist < rad:       // sphere straddles the plane
                    checkOutside = true
                    break                            // one sphere is enough for this portal
        else:
            otherCell = this.GetVisible(portal.OtherCellId)  // look up in VisibleCells dict

            if otherCell != null:
                // Convert sphere center to otherCell's local space and test containment
                for each sphere in spheres:
                    center = otherCell.Pos.Frame.GlobalToLocal(sphere.Center)
                    localSphere = Sphere(center, sphere.Radius)
                    boundType = otherCell.CellStructure.sphere_intersects_cell(localSphere)
                    if boundType != Outside:
                        cellArray.add_cell(otherCell.ID, otherCell)
                        break
            else:
                // otherCell not yet loaded: use plane-side test for load hint
                for each sphere in spheres:
                    center = this.Pos.Frame.GlobalToLocal(sphere.Center)
                    localSphere = Sphere(center, sphere.Radius + EPSILON)
                    dist = Dot(localSphere.Center, portalPoly.Plane.Normal) + portalPoly.Plane.D
                    portalSide = portal.PortalSide

                    // Sphere is on the "exit" side of the portal
                    if (dist > -localSphere.Radius AND portalSide) OR
                       (dist <  localSphere.Radius AND NOT portalSide):
                        cellArray.add_cell(portal.OtherCellId, null)   // null = load hint
                        break

    if checkOutside:
        LandCell.add_all_outside_cells(position, numSphere, spheres, cellArray)

Key geometry: sphere_intersects_cell

CellStruct.sphere_intersects_cell(sphere) calls CellBSP.sphere_intersects_cell_bsp(sphere). The BSP tree is built from the cell's physics polygons. The result is one of:

  • Inside — sphere entirely inside the cell volume
  • Crossing — sphere straddles a cell boundary plane
  • Outside — sphere entirely outside

For transit detection, Inside and Crossing both qualify ("not Outside").


EnvCell.find_transit_cells (parts/AABB variant)

Used when the entity is represented by physics parts (creatures, large objects) rather than spheres. The bounding box of each part is tested instead of a sphere.

Source: EnvCell.cs:245-309

EnvCell.find_transit_cells(numParts, parts[], cellArray):
    checkOutside = false

    for each portal in this.Portals:
        portalPoly = this.CellStructure.Polygons[portal.PolygonId]

        for each part in parts:
            if part == null: continue
            sphere = part.GfxObj.PhysicsSphere ?? part.GfxObj.DrawingSphere
            if sphere == null: continue

            // Convert sphere center to this cell's local space
            center = this.Pos.LocalToLocal(part.Pos, sphere.Center)
            rad = sphere.Radius + EPSILON

            // Broad phase: sphere-plane distance test
            dist = Dot(center, portalPoly.Plane.Normal) + portalPoly.Plane.D
            if portal.PortalSide:
                if dist < -rad: continue    // sphere fully behind portal (wrong side)
            else:
                if dist >  rad: continue    // sphere fully in front of portal (wrong side)

            // Narrow phase: AABB intersection
            bbox = part.GetBoundingBox()
            box = BBox.LocalToLocal(bbox, part.Pos, this.Pos)
            sidedness = portalPoly.Plane.intersect_box(box)
            if (sidedness == Positive AND NOT portal.PortalSide) OR
               (sidedness == Negative AND portal.PortalSide):
                continue    // box fully on wrong side, skip

            // At this point the part potentially crosses the portal
            if portal.OtherCellId == 0xFFFF:
                checkOutside = true
                break
            
            otherCell = this.GetVisible(portal.OtherCellId)
            if otherCell == null:
                cellArray.add_cell(portal.OtherCellId, null)   // load hint
                break

            cellBox = BBox.LocalToLocal(bbox, part.Pos, otherCell.Pos)
            if otherCell.CellStructure.box_intersects_cell(cellBox):
                cellArray.add_cell(otherCell.ID, otherCell)
                break

    if checkOutside:
        LandCell.add_all_outside_cells(numParts, parts, cellArray, this.ID)

LandCell.find_transit_cells (outdoor → indoor entry)

When the entity is outdoors, LandCell.find_transit_cells runs. It first calls add_all_outside_cells to include neighboring outdoor cells, then delegates to SortCell.find_transit_cells which in turn calls BuildingObj.find_building_transit_cells if a building is attached.

Source: LandCell.cs:271-281, SortCell.cs:33-43

LandCell.find_transit_cells(position, numSphere, spheres[], cellArray, path):
    add_all_outside_cells(position, numSphere, spheres, cellArray)
    // then: SortCell.find_transit_cells → BuildingObj.find_building_transit_cells

BuildingObj.find_building_transit_cells(pos, numSphere, spheres[], cellArray, path):
    for each bldPortal in this.Portals:
        otherCell = bldPortal.GetOtherCell(CurCell.ID)   // resolve EnvCell from OtherCellId
        if otherCell != null:
            otherCell.check_building_transit(bldPortal.OtherPortalId, pos, numSphere, spheres, cellArray, path)

EnvCell.check_building_transit (the entry-point test)

EnvCell.check_building_transit(portalId, pos, numSphere, spheres[], cellArray, path):
    if portalId == 0xFFFF: return   // invalid portal

    for each sphere in spheres:
        // Convert sphere center to this cell's local space
        globSphere = Sphere(this.Pos.Frame.GlobalToLocal(sphere.Center), sphere.Radius)

        // Test if the sphere is inside this cell's geometry
        if this.CellStructure.sphere_intersects_cell(globSphere) == Outside:
            continue                // sphere not inside this cell, skip

        // Sphere IS inside the cell
        if path != null: path.HitsInteriorCell = true
        cellArray.add_cell(this.ID, this)
        // Note: no recursive portal search here — this is just the entry check

LandCell.add_all_outside_cells (sphere variant)

⚠️ Correction (2026-06-09, issue #106): this section understates the math. Retail's lcoords are GLOBAL map-wide cell coordinates (0..2039), not block-relative — AdjustToOutside doesn't just "normalise to correct block", it can re-seat the cell id into a NEIGHBOUR landblock, and add_outside_cell has no same-block filter. The original acdream port read this section as single-block math and clamped to the current landblock's 8×8 grid, which froze outdoor membership at landblock boundaries (#106). The full corrected pseudocode (with the BN decomp artifacts and the ACE add_cell_block FIXME divergence) is in docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md.

Determines which outdoor landblock cells an entity's spheres overlap. Each outdoor cell is a 24×24m square. The function adds the primary cell plus up to 3 neighbors when the sphere radius reaches a boundary.

Source: LandCell.cs:82-118

add_all_outside_cells(position, numSphere, spheres[], cellArray):
    if cellArray.AddedOutside: return

    for each sphere in spheres:
        cellPoint = position.ObjCellID
        center = sphere.Center
        AdjustToOutside(ref cellPoint, ref center)  // normalise to correct block

        // Within the 24m×24m cell, compute local 2D coords
        point.X = center.X mod 24.0f
        point.Y = center.Y mod 24.0f
        minRad = sphere.Radius
        maxRad = 24.0f - minRad

        lcoord = gid_to_lcoord(cellPoint)   // world cell ID → grid coordinates
        add_outside_cell(cellArray, lcoord)
        check_add_cell_boundary(cellArray, point, lcoord, minRad, maxRad)

// check_add_cell_boundary: add neighbouring cells when sphere crosses cell edges
check_add_cell_boundary(cellArray, point, lcoord, minRad, maxRad):
    x, y = lcoord
    if point.X > maxRad:
        add_outside_cell(cellArray, x+1, y)
        if point.Y > maxRad: add_outside_cell(cellArray, x+1, y+1)
        if point.Y < minRad: add_outside_cell(cellArray, x+1, y-1)
    if point.X < minRad:
        add_outside_cell(cellArray, x-1, y)
        if point.Y > maxRad: add_outside_cell(cellArray, x-1, y+1)
        if point.Y < minRad: add_outside_cell(cellArray, x-1, y-1)
    if point.Y > maxRad: add_outside_cell(cellArray, x, y+1)
    if point.Y < minRad: add_outside_cell(cellArray, x, y-1)

PortalSide flag semantics

PortalSide is the single most confusing flag in the dat. Here's the exact semantics:

portal.PortalSide = (cp.Flags & 2) == 0 ? 1 : 0    (ACME EnvCellManager.cs:379)

In ACE EnvCell.find_transit_cells (sphere):
    dist = Dot(center_in_cell_local, portalPoly.Normal) + portalPoly.D

    if portal.OtherCellId == 0xFFFF (outdoor exit):
        // sphere straddling counts regardless of PortalSide
        trigger when: dist > -rad AND dist < rad

    if otherCell != null (known neighbor):
        // use sphere_intersects_cell in otherCell's local space
        // PortalSide is NOT checked for sphere variant — containment in other cell is the test

    if otherCell == null (load hint):
        if portalSide:    trigger when dist > -localSphere.Radius
        if NOT portalSide: trigger when dist <  localSphere.Radius

In ACE EnvCell.find_transit_cells (AABB):
    // Broad phase sphere-plane test:
    if portal.PortalSide:  skip if dist < -rad  (sphere fully behind negative half)
    if NOT PortalSide:     skip if dist >  rad  (sphere fully in positive half)

Intuition: PortalSide tells you which half-space of the portal plane is the "inside" of the cell. When PortalSide=true the cell sits on the positive side of the portal plane (dist > 0 is "in the cell"). When crossing into a neighbour cell, an entity must be transitioning from the current cell's side toward the portal, i.e. moving toward dist=0.


point_in_cell — two implementations

ACE / physics-accurate (CellStruct.CellBSP)

EnvCell.point_in_cell(worldPoint):
    localPoint = this.Pos.Frame.GlobalToLocal(worldPoint)
    return this.CellStructure.CellBSP.point_inside_cell_bsp(localPoint)

Requires the full BSP tree from the dat. Exact for any convex or near-convex cell shape.

ACME editor (AABB in cell-local space)

PointInCell(worldPoint, loadedCell):
    EPSILON = 0.01f
    localPoint = Transform(worldPoint, loadedCell.InverseWorldTransform)
    return localPoint is inside loadedCell.LocalBoundsMin..LocalBoundsMax (with EPSILON)

ACME precomputes an AABB from all CellStruct vertices. Approximate — may include false positives in highly non-rectangular cells, but works well for building interiors. The ACME editor also has a portal-plane cut test for visibility (see GetVisibleCells).


Portal visibility traversal (for rendering, not collision)

ACME's GetVisibleCells performs BFS from the camera cell through portals, testing:

  1. Portal-side plane test — camera must be on the InsideSide of the portal plane
  2. Frustum test — neighbour cell bounding box must intersect the view frustum

This is separate from collision transit detection. For Sprint 2 (indoor transitions), we need collision transit (above), not visibility traversal.


Cell ID format

Full cell ID:  0xAABBCCCC
               ^^^^        landblock X (high 8 bits of high word)
                   ^^^^    landblock Y (low 8 bits of high word)
                       ^^^^  cell index (low word)

Low word < 0x100  → outdoor landblock cell (8×8 grid, values 0x0001..0x0040)
Low word >= 0x100 → indoor EnvCell
0xXXXXFFFF        → sentinel meaning "outdoor" in CellPortal.OtherCellId context

Design for acdream Sprint 2

The existing PortalPlane.IsCrossing and CellSurface.SampleFloorZ approach in PhysicsEngine.cs is a simplified subset of the full ACE algorithm. For Sprint 2, the upgrade path is:

Current model (flat plane-crossing heuristic)

  • PortalPlane.IsCrossing = sign-change of plane-distance + centroid proximity guard
  • Works for simple convex cells with one entry portal

ACE-accurate model (sphere containment)

Replace IsCrossing with the sphere variant of find_transit_cells:

// For each portal in the current indoor cell:
//   Get the portal's polygon plane in cell-local space
//   Convert world-sphere to cell-local sphere
//   Compute dist = Dot(localCenter, planeNormal) + planeD
//   If |dist| < sphere.Radius + EPSILON → sphere straddles plane
//   Then: convert sphere to otherCell's local space
//         call CellBSP.sphere_intersects_cell → if not Outside, add to candidate set

Outdoor → indoor: the check_building_transit path is the missing piece:

  • When player is outdoor, iterate BuildingObj.Portals for the current SortCell
  • For each bldPortal, get the OtherCell (the nearest EnvCell in the building)
  • Test sphere_intersects_cell(GlobalToLocal(sphere)) in that cell's local space
  • If not Outside → mark HitsInteriorCell = true, add the cell to the active set

Key data not yet in acdream:

  • CellBSP (BSP tree from environment dat) — required for sphere_intersects_cell
  • BldPortal list per SortCell (from LandBlock.Buildings) — required for outdoor→indoor detection
  • VisibleCells dict per EnvCell — required for indoor→indoor traversal

Until the BSP is wired, the current AABB / flat-plane approach is the best available approximation. The ACME PointInCell AABB approach is a valid intermediate step that doesn't need the BSP.


Decompiled code note

The task hint asked about 0x531A00-0x531C00 in chunk_00530000.c. Inspecting that range:

  • FUN_00531a30 (0x531A30): reads a count + array of structs from a stream (CellPortal deserialization candidate)
  • FUN_00531ae0 (0x531AE0): destructor — frees an array
  • FUN_00531b30 (0x531B30): vtable swap + destructor
  • FUN_00531b70 (0x531B70): initializes an object with magic values (9, 0, 0, 0x51, 0xA2 — looks like CellStruct init with polygon/vertex counts)
  • FUN_00531bd0 (0x531BD0): BSP/normal calculation from face adjacency — the vertex normal / rendering BSP builder
  • FUN_00531e10 (0x531E10): polygon construction with vertex IDs and an "is horizontal" flag
  • FUN_00531f20 (0x531F20): CellPortal serialization — writes 9 shorts per portal (polygon vertices)

None of these contain the transit-cell intersection tests. The transit tests in the decompiled client are almost certainly in the 0x004Dxxxx0x0050xxxx range (where ObjCell, LandCell, EnvCell vtable methods live) but the symbols are not named in the Ghidra decompilation. The ACE port is the authoritative source.


Summary table

Transition type Primary test Fallback
Outdoor → outdoor add_all_outside_cells (24m grid, boundary check) N/A
Outdoor → indoor check_building_transit: sphere_intersects_cell in CellBSP AABB (ACME)
Indoor → same cell point_in_cell (CellBSP or AABB) no change
Indoor → neighbour find_transit_cells: sphere_intersects_cell in neighbour's CellBSP load hint (plane-side only)
Indoor → outdoor find_transit_cells: OtherCellId == 0xFFFF + straddle test plane sign-change