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>
This commit is contained in:
parent
12fb408972
commit
7078264291
11 changed files with 813 additions and 88 deletions
|
|
@ -46,11 +46,26 @@ Copy this block when adding a new issue:
|
|||
|
||||
## #106 — Outdoor player-cell membership FREEZES at landblock boundaries (whole interiors unenterable)
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** FIX LANDED 2026-06-09 — pending live boundary-walk gate (acceptance below)
|
||||
**Severity:** HIGH
|
||||
**Filed:** 2026-06-09
|
||||
**Component:** physics, membership
|
||||
|
||||
**Fix (2026-06-09):** retail `LandDefs` global-lcoord math ported as
|
||||
`AcDream.Core.Physics.LandDefs` (`blockid_to_lcoord` / `gid_to_lcoord` /
|
||||
`lcoord_to_gid` / `get_outside_lcoord` / `adjust_to_outside`, decomp-cited);
|
||||
`CellTransit.AddAllOutsideCells` rewritten as the faithful sphere-variant
|
||||
(pc:317499) — candidates and the `find_cell_list` pick now run in the GLOBAL
|
||||
landcell grid, so landblock crossings are inherent (the pre-fix port clamped
|
||||
both to the current block's 8×8 grid → zero candidates one step over the line →
|
||||
frozen). World→block-local conversion via the landblock origin registered in
|
||||
`CellGraph` (`TryGetTerrainOrigin`). The `b3ce505` gate was investigated FIRST
|
||||
and definitively exonerated (collision-only, fires only for indoor primaries —
|
||||
no membership path touches it). 31 new conformance tests incl. the capture
|
||||
geometry goldens (0xA9B40031 → 0xA9B30038/0xA9B30034) and a non-anchor-frame
|
||||
northbound return. Pseudocode + decomp-artifact notes:
|
||||
`docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md`.
|
||||
|
||||
**Description:** Walking outdoors across a landblock boundary does NOT update the player's
|
||||
outdoor cell: `playerCell` stays pinned to the last cell of the previous landblock,
|
||||
indefinitely. Every downstream consumer degrades: entering any building in the new landblock
|
||||
|
|
|
|||
183
docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md
Normal file
183
docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# LandDefs + add_all_outside_cells — retail pseudocode (issue #106)
|
||||
|
||||
**Date:** 2026-06-09. **Purpose:** ground the #106 fix (outdoor membership freezes at
|
||||
landblock boundaries) in the retail decomp. The pre-fix acdream port of
|
||||
`AddAllOutsideCells` clamped candidates to the current landblock's 8×8 grid; retail
|
||||
has NO such clamp — its cell math runs in a **global** landcell coordinate space
|
||||
(`lcoord`, 0..2039) spanning the entire map, so landblock crossings are inherent.
|
||||
|
||||
**Sources:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (primary);
|
||||
`references/ACE/Source/ACE.Server/Physics/Common/LandDefs.cs` + `LandCell.cs`
|
||||
(cross-check, in the MAIN repo checkout — this worktree only vendors WorldBuilder).
|
||||
|
||||
---
|
||||
|
||||
## 1. The coordinate model
|
||||
|
||||
- A **landblock id** packs the map grid into the high word of a cell id:
|
||||
bits 31–24 = `block_x` (east–west), bits 23–16 = `block_y` (north–south).
|
||||
- An **outdoor cell** low word is `1 + cell_x*8 + cell_y` (`cell_x`,`cell_y` ∈ 0..7,
|
||||
24 m cells, row-major with X outer).
|
||||
- A **global lcoord** is `(block_x*8 + cell_x, block_y*8 + cell_y)` — a flat map-wide
|
||||
cell grid, valid range `[0, 0x7F8)` = `[0, 2040)` (255 blocks × 8).
|
||||
- Positions in retail `Position` structs are **block-local** (`[0,192)` per axis).
|
||||
|
||||
acdream divergence to bridge: physics positions are in a **floating world frame**
|
||||
anchored at the login/teleport landblock (origin = that block's SW corner;
|
||||
`GameWindow.ApplyLoadedTerrainLocked` registers each block's origin as
|
||||
`((lbX − centerX)·192, (lbY − centerY)·192)`). Convert world → current-block-local by
|
||||
subtracting the current block's registered origin (`CellGraph._terrain` stores it),
|
||||
then retail's math applies verbatim.
|
||||
|
||||
## 2. LandDefs functions (verbatim-equivalent pseudocode)
|
||||
|
||||
### in_bounds (pc:68509, @0x0043d650)
|
||||
```
|
||||
in_bounds(x, y) := x >= 0 && y >= 0 && x < 0x7F8 && y < 0x7F8
|
||||
```
|
||||
|
||||
### blockid_to_lcoord (pc:68520, @0x0043d680)
|
||||
```
|
||||
blockid_to_lcoord(cellId, out lx, out ly):
|
||||
if cellId == 0: return false
|
||||
lx = ((cellId >> 24) & 0xFF) * 8 // decomp form: (cellId >> 21) & 0x7F8
|
||||
ly = ((cellId >> 16) & 0xFF) * 8 // decomp form: (cellId >> 13) & 0x7F8
|
||||
return in_bounds(lx, ly)
|
||||
```
|
||||
⚠️ **BN artifact:** the decomp renders the Y extraction as `(int8_t)(cellId >> 16) << 3`
|
||||
— a signed cast that would explode for block_y ≥ 0x80 (e.g. 0xB4). ACE's
|
||||
`blockid_to_lcoord` (LandDefs.cs:169) confirms plain zero-extended masks. Use unsigned.
|
||||
|
||||
### inbound_valid_cellid (pc:163438, @0x004979a0)
|
||||
```
|
||||
inbound_valid_cellid(cellId):
|
||||
low = cellId & 0xFFFF
|
||||
if !(low in [1,0x40] || low in [0x100,0xFFFD] || low == 0xFFFF): return false
|
||||
lx = (cellId >> 21) & 0x7F8; ly = (cellId >> 13) & 0x7F8
|
||||
return in_bounds(lx, ly)
|
||||
```
|
||||
⚠️ **ACE divergence:** ACE's `inbound_valid_cellid` (LandDefs.cs:241) checks only
|
||||
`block_x`. Retail checks both axes. Port retail.
|
||||
|
||||
### gid_to_lcoord (pc:163500, @0x00497a90) — outdoor cells only
|
||||
```
|
||||
gid_to_lcoord(cellId, out lx, out ly):
|
||||
if !inbound_valid_cellid(cellId): return false
|
||||
if (cellId & 0xFFFF) >= 0x100: return false // outdoor only
|
||||
lx = block_x*8 + ((low−1) >> 3)
|
||||
ly = block_y*8 + ((low−1) & 7)
|
||||
return in_bounds(lx, ly)
|
||||
```
|
||||
|
||||
### lcoord_to_gid (pc:171859, @0x004a19a0)
|
||||
```
|
||||
lcoord_to_gid(lx, ly):
|
||||
if !in_bounds(lx, ly): return 0
|
||||
low = (ly & 7) + (lx & 7)*8 + 1
|
||||
block = ((lx >> 3) << 8) | (ly >> 3) // decomp: ((lx & ~7) << 5) | (ly >> 3)
|
||||
return (block << 16) | low
|
||||
```
|
||||
Cross-block is transparent: `lx = 1439` → `block_y = 179 (0xB3)` even when the input
|
||||
block was 0xB4.
|
||||
|
||||
### get_outside_lcoord (pc:438690, @0x005a9b00)
|
||||
```
|
||||
get_outside_lcoord(cellId, blockLocalPos, out lx, out ly):
|
||||
if cellId low16 not in {1..0x40, 0x100..0xFFFD, 0xFFFF}: return false
|
||||
blockid_to_lcoord(cellId, out lx, out ly)
|
||||
lx += floor(pos.x / 24); ly += floor(pos.y / 24) // floor() — negative-safe
|
||||
return in_bounds(lx, ly)
|
||||
```
|
||||
`floor(pos/24)` may be negative or ≥ 8 — **this is the landblock crossing**. No clamp.
|
||||
|
||||
### adjust_to_outside (pc:438719, @0x005a9bc0)
|
||||
```
|
||||
adjust_to_outside(ref cellId, ref blockLocalPos):
|
||||
low = cellId & 0xFFFF
|
||||
if low in valid ranges (as above):
|
||||
if |pos.x| < 0.000199999995: pos.x = 0 // retail EPSILON snap
|
||||
if |pos.y| < 0.000199999995: pos.y = 0
|
||||
if get_outside_lcoord(cellId, pos, out lx, out ly):
|
||||
cellId = lcoord_to_gid(lx, ly)
|
||||
pos.x -= floor(pos.x / 192) * 192 // re-normalize to NEW block
|
||||
pos.y -= floor(pos.y / 192) * 192
|
||||
return true
|
||||
cellId = 0
|
||||
return false
|
||||
```
|
||||
⚠️ **BN artifact:** the decomp shows `floor(pos.x / 0f) * 0f` — the constant was
|
||||
dropped. ACE (LandDefs.cs:140-141) confirms `BlockLength = 192`.
|
||||
|
||||
## 3. CLandCell::add_all_outside_cells — sphere variant (pc:317499, @0x00533630)
|
||||
|
||||
```
|
||||
add_all_outside_cells(position, numSphere, spheres[], cellArray):
|
||||
if cellArray.added_outside: return
|
||||
cellArray.added_outside = 1
|
||||
if numSphere == 0:
|
||||
cellId = position.objcell_id; pos = position.frame.origin
|
||||
if adjust_to_outside(ref cellId, ref pos) && gid_to_lcoord(cellId, out lx, ly):
|
||||
add_outside_cell(cellArray, lx, ly)
|
||||
return
|
||||
for each sphere (center copied):
|
||||
cellId = position.objcell_id
|
||||
if !adjust_to_outside(ref cellId, ref center): BREAK // break, not continue
|
||||
if gid_to_lcoord(cellId, out lx, out ly):
|
||||
add_outside_cell(cellArray, lx, ly)
|
||||
point.x = center.x mod 24; point.y = center.y mod 24 // center is [0,192) post-adjust
|
||||
check_add_cell_boundary(cellArray, point, lx, ly, minRad=radius, maxRad=24−radius)
|
||||
```
|
||||
|
||||
### add_outside_cell (pc:317056, @0x00532ec0)
|
||||
```
|
||||
add_outside_cell(cellArray, lx, ly):
|
||||
if in_bounds(lx, ly):
|
||||
cellArray.add_cell(lcoord_to_gid(lx, ly), LScape::get_landcell(...))
|
||||
```
|
||||
**NO same-block filter.** ⚠️ ACE's `add_cell_block` (LandCell.cs:198) inserts
|
||||
`if (id >> 16 != cellID >> 16) continue;` annotated `// FIXME!` — an ACE divergence
|
||||
that drops cross-landblock cells. Do NOT copy it.
|
||||
|
||||
### check_add_cell_boundary (pc:317229, @0x00533260)
|
||||
```
|
||||
check_add_cell_boundary(cellArray, point, lx, ly, minRad, maxRad): // strict > / <
|
||||
if point.x > maxRad:
|
||||
add_outside_cell(lx+1, ly)
|
||||
if point.y > maxRad: add_outside_cell(lx+1, ly+1)
|
||||
if point.y < minRad: add_outside_cell(lx+1, ly−1)
|
||||
if point.x < minRad:
|
||||
add_outside_cell(lx−1, ly)
|
||||
if point.y > maxRad: add_outside_cell(lx−1, ly+1)
|
||||
if point.y < minRad: add_outside_cell(lx−1, ly−1)
|
||||
if point.y > maxRad: add_outside_cell(lx, ly+1)
|
||||
if point.y < minRad: add_outside_cell(lx, ly−1)
|
||||
```
|
||||
A sphere exactly tangent to a 24 m boundary does NOT add the neighbour (strict
|
||||
comparisons — matches ACE).
|
||||
|
||||
## 4. The containing-cell pick (CObjCell::find_cell_list, pc:308742-308869, @0x0052b4e0)
|
||||
|
||||
After the transit set is built, retail iterates candidates IN ORDER; for each it
|
||||
subtracts `LandDefs::get_block_offset(position.block, candidate.block)` from the
|
||||
sphere center (cross-block-aware) and calls the cell's `point_in_cell` (vtable
|
||||
+0x84). Any containing cell sets the running result; an INTERIOR containing cell
|
||||
breaks immediately (interior-wins); outdoor cells are disjoint columns so at most one
|
||||
contains the point. acdream adaptation (landcells have no BSP `point_in_cell`): the
|
||||
containing outdoor cell is `adjust_to_outside(currentCellId, blockLocal(center))` —
|
||||
the global XY-column id — compared by identity against candidates. Pre-#106 this was
|
||||
computed with a `[0,8)` grid clamp under the current block's prefix, which is what
|
||||
froze membership at boundaries.
|
||||
|
||||
## 5. Worked goldens (from the #106 capture geometry)
|
||||
|
||||
Anchor A9B4 at world origin; A9B3 is one block SOUTH (origin (0, −192)).
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `blockid_to_lcoord(0xA9B40031)` | (1352, 1440) |
|
||||
| `gid_to_lcoord(0xA9B40031)` (low 0x31 = 49 → cell 6,0) | (1358, 1440) |
|
||||
| `lcoord_to_gid(1358, 1440)` | 0xA9B40031 (roundtrip) |
|
||||
| `lcoord_to_gid(1358, 1439)` | **0xA9B30038** (block_y 179, cell 6,7) |
|
||||
| `adjust_to_outside(0xA9B40031, (150, −1))` | cellId **0xA9B30038**, pos (150, **191**) |
|
||||
| `adjust_to_outside(0xA9B40031, (150, −109.65))` | cellId **0xA9B30034** (floor(−109.65/24) = −5 → ly 1435), pos (150, 82.35) |
|
||||
| `adjust_to_outside(0xA9B30038, (150, 193))` from A9B3 frame | cellId **0xA9B40031**, pos (150, 1) — northbound return |
|
||||
|
|
@ -241,6 +241,17 @@ EnvCell.check_building_transit(portalId, pos, numSphere, spheres[], cellArray, p
|
|||
|
||||
## 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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue