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:
Erik 2026-06-09 23:10:59 +02:00
parent 12fb408972
commit 7078264291
11 changed files with 813 additions and 88 deletions

View file

@ -170,110 +170,112 @@ public static class CellTransit
/// <summary>
/// Outdoor neighbour expansion. Ported from
/// <c>CLandCell::add_all_outside_cells</c> (sphere variant) per the
/// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)".
/// <c>CLandCell::add_all_outside_cells</c> (sphere variant,
/// pc:317499 @0x00533630) per
/// <c>docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md</c>.
///
/// <para>
/// The 24×24m landcell grid: a landblock is 8×8 cells. Cell index
/// within a landblock is computed from local X/Y mod 24. The sphere
/// adds the primary cell plus up to 3 neighbours when the radius
/// reaches a cell boundary.
/// Retail runs this in the GLOBAL landcell grid (<see cref="LandDefs"/>
/// lcoords, 0..2039 across the whole map): <c>adjust_to_outside</c> re-seats
/// the (cell, position) pair onto the landcell actually under the sphere —
/// crossing landblock boundaries when <c>floor(local/24)</c> leaves the
/// current block's 8×8 grid — and <c>check_add_cell_boundary</c> adds up to
/// 3 neighbour cells (strict &gt;/&lt; against the sphere radius), each id
/// re-derived from its own global lcoord. Issue #106: the pre-fix port
/// clamped everything to the current landblock's grid, so the candidate set
/// emptied the moment the player stepped over a boundary and membership
/// froze on the last in-block cell.
/// </para>
///
/// <para>
/// <see cref="worldSphereCenter"/> is in the landblock-local coord
/// space the rest of the engine uses (X/Y in [0, 192]; landblock
/// world origin is at the streaming center, so all landblock-local
/// positions are also world positions for the player's landblock).
/// </para>
///
/// <para>
/// A6.P4 door fix (2026-05-24): pre-fix this function subtracted the
/// landblock's "absolute" world origin (lbX=0xA9*192=32448) from the
/// sphere position, which made sense only if sphere coords were the
/// absolute world position (32580). But production has used
/// landblock-local coords since Phase A.1 (streaming-center landblock
/// at world origin, so lbOffset for the center is (0,0); see
/// <c>GameWindow.BuildInteriorEntitiesForStreaming</c>'s lbOffset
/// formula). With landblock-local sphere coords, the old subtraction
/// produced <c>localX = 132.36 - 32448 = -32316</c> → <c>gridX = -1346</c>
/// → out-of-range → early return → ZERO outdoor cells added. For
/// indoor primary cells (where issue #98 gates the GetNearbyObjects
/// outdoor radial sweep) this meant the cottage door's outdoor cell
/// 0xA9B40029 never reached <c>portalReachableCells</c>, the door's
/// BSP was never queried, and the player walked through unimpeded —
/// the user-reported Holtburg-door walkthrough bug. The fix:
/// treat <c>worldSphereCenter</c> as landblock-local directly, no
/// landblock-world-origin subtraction. This matches retail's
/// <c>CLandCell::add_all_outside_cells</c> which uses the per-cell
/// 6-byte position struct (landblock-relative).
/// <paramref name="worldSphereCenter"/> is in the floating world frame
/// (anchor landblock at origin — the convention every physics caller uses);
/// <paramref name="currentBlockOrigin"/> is the current cell's landblock
/// world origin (SW corner; <see cref="World.Cells.CellGraph.TryGetTerrainOrigin"/>),
/// which converts it to retail's block-local frame. Pass
/// <see cref="Vector3.Zero"/> when the current block IS the anchor — the
/// pre-#106 behavior, and what the A6.P4 (2026-05-24) "landblock-local
/// coords" convention actually meant.
/// </para>
/// </summary>
public static void AddAllOutsideCells(
/// <returns>
/// False when <c>adjust_to_outside</c> rejects the position (map edge /
/// invalid cell id) — retail breaks out of the sphere loop on that.
/// </returns>
public static bool AddAllOutsideCells(
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
Vector3 currentBlockOrigin,
ICollection<uint> candidates)
{
const float CellSize = 24f;
// Retail's position is block-local to the current cell's landblock.
var center = worldSphereCenter - currentBlockOrigin;
uint lbPrefix = currentCellId & 0xFFFF0000u;
uint cellId = currentCellId;
if (!LandDefs.AdjustToOutside(ref cellId, ref center))
return false;
if (!LandDefs.GidToLcoord(cellId, out int lx, out int ly))
return false;
float localX = worldSphereCenter.X;
float localY = worldSphereCenter.Y;
AddOutsideCell(candidates, lx, ly);
float cellLocalX = localX % CellSize;
float cellLocalY = localY % CellSize;
// check_add_cell_boundary (pc:317229 @0x00533260): the point within the
// 24 m cell, from the adjust_to_outside-normalized block-local center
// (always [0, 192) post-adjust; floor-mod for safety). Strict >/< —
// a sphere exactly tangent to a boundary does NOT add the neighbour.
float pointX = center.X - MathF.Floor(center.X / LandDefs.CellLength) * LandDefs.CellLength;
float pointY = center.Y - MathF.Floor(center.Y / LandDefs.CellLength) * LandDefs.CellLength;
float minRad = sphereRadius;
float maxRad = CellSize - sphereRadius;
float maxRad = LandDefs.CellLength - sphereRadius;
int gridX = (int)(localX / CellSize);
int gridY = (int)(localY / CellSize);
if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
AddOutsideCell(candidates, lbPrefix, gridX, gridY);
if (cellLocalX > maxRad)
if (pointX > maxRad)
{
AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY);
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY + 1);
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY - 1);
AddOutsideCell(candidates, lx + 1, ly);
if (pointY > maxRad) AddOutsideCell(candidates, lx + 1, ly + 1);
if (pointY < minRad) AddOutsideCell(candidates, lx + 1, ly - 1);
}
if (cellLocalX < minRad)
if (pointX < minRad)
{
AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY);
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY + 1);
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY - 1);
AddOutsideCell(candidates, lx - 1, ly);
if (pointY > maxRad) AddOutsideCell(candidates, lx - 1, ly + 1);
if (pointY < minRad) AddOutsideCell(candidates, lx - 1, ly - 1);
}
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY + 1);
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY - 1);
if (pointY > maxRad) AddOutsideCell(candidates, lx, ly + 1);
if (pointY < minRad) AddOutsideCell(candidates, lx, ly - 1);
return true;
}
/// <summary>
/// Multi-sphere outdoor expansion. Retail's sphere variant loops every
/// path sphere and adds the outdoor landcells touched by any of them.
/// path sphere and adds the outdoor landcells touched by any of them;
/// an <c>adjust_to_outside</c> failure BREAKS the loop (pc:533699).
/// </summary>
public static void AddAllOutsideCells(
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
uint currentCellId,
Vector3 currentBlockOrigin,
ICollection<uint> candidates)
{
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
AddAllOutsideCells(sphere.Origin, sphere.Radius, currentCellId, candidates);
if (!AddAllOutsideCells(sphere.Origin, sphere.Radius, currentCellId, currentBlockOrigin, candidates))
break;
}
}
private static void AddOutsideCell(ICollection<uint> candidates, uint lbPrefix, int gridX, int gridY)
private static void AddOutsideCell(ICollection<uint> candidates, int lx, int ly)
{
if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
// Cell index within landblock: row-major (X * 8 + Y) + 1.
uint low = (uint)(gridX * 8 + gridY + 1);
candidates.Add(lbPrefix | low);
// CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check,
// then lcoord_to_gid — NO same-block filter (ACE's add_cell_block
// "FIXME!" guard is an ACE divergence, not retail). The block id is
// re-derived from the global lcoord, so neighbour-landblock cells come
// out with the neighbour's prefix.
uint gid = LandDefs.LcoordToGid(lx, ly);
if (gid != 0u) candidates.Add(gid);
}
/// <summary>
@ -504,7 +506,12 @@ public static class CellTransit
Vector3 worldSphereCenter = worldSpheres[0].Origin;
float sphereRadius = worldSpheres[0].Radius;
uint currentLow = currentCellId & 0xFFFFu;
uint lbPrefix = currentCellId & 0xFFFF0000u;
// #106: the current block's world origin converts the world-frame sphere
// coords into retail's block-local frame for the LandDefs lcoord math.
// Unregistered terrain (tests; pre-stream) falls back to Vector3.Zero —
// the legacy anchor-block assumption (world frame == block-local frame).
cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin);
if (currentLow >= 0x0100u)
{
@ -539,7 +546,7 @@ public static class CellTransit
// pick order and interior-wins is preserved.
if (exitOutside && !outdoorAdded)
{
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates);
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates);
outdoorAdded = true;
}
}
@ -549,7 +556,7 @@ public static class CellTransit
// Outdoor seed: expand neighbour landcells (added first), then check each
// for a building stab whose portals cross into an interior EnvCell.
// (Stage 2 will make building entry intrinsic and remove CheckBuildingTransit.)
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates);
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates);
var landcellSnapshot = new List<uint>(candidates.OrderedIds);
foreach (uint landcellId in landcellSnapshot)
@ -571,6 +578,21 @@ public static class CellTransit
// (Replaces the 5ca2f44 current-first pre-check, which approximated this for
// the indoor-current case only; the ordered array now delivers it for every
// seed by construction.)
//
// #106: the outdoor containing cell is the GLOBAL XY-column under the sphere
// centre (LandDefs.AdjustToOutside from the current block's frame — retail
// subtracts get_block_offset per candidate before point_in_cell, pc:308804;
// landcells are disjoint columns so identity-compare is equivalent). The
// pre-fix [0,8)-clamped, current-prefix-only computation could never match a
// neighbour-block cell, freezing membership at landblock boundaries.
uint containingOutdoorId = 0u;
{
var pickPos = worldSphereCenter - blockOrigin;
uint pickCell = currentCellId;
if (LandDefs.AdjustToOutside(ref pickCell, ref pickPos))
containingOutdoorId = pickCell;
}
uint outdoorResult = 0u;
foreach (uint candId in candidates.OrderedIds)
{
@ -583,20 +605,14 @@ public static class CellTransit
if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
return candId; // interior-wins, stop (pseudo_c:308819)
}
else if (outdoorResult == 0u)
else if (outdoorResult == 0u && containingOutdoorId != 0u)
{
// Outdoor candidate — CLandCell::point_in_cell is the XY-column the
// sphere is over (acdream landcells have no BSP point_in_cell; the
// documented adaptation). Record as the running result but DO NOT
// break — an interior cell later in the array can still win.
int gx = (int)(worldSphereCenter.X / 24f);
int gy = (int)(worldSphereCenter.Y / 24f);
if (gx >= 0 && gx < 8 && gy >= 0 && gy < 8)
{
uint outdoorId = lbPrefix | (uint)(gx * 8 + gy + 1);
if (candId == outdoorId)
outdoorResult = candId;
}
if (candId == containingOutdoorId)
outdoorResult = candId;
}
}

View file

@ -0,0 +1,147 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>LandDefs</c> outdoor-cell coordinate math (issue #106). All functions
/// operate on the GLOBAL landcell coordinate space (<c>lcoord</c>, one unit per 24 m
/// cell, range [0, 0x7F8) across the whole map) — landblock crossings are inherent
/// in the math, never special-cased. Ported per
/// <c>docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md</c>; cross-checked
/// against ACE <c>Physics/Common/LandDefs.cs</c>.
///
/// <para>Frame note: retail positions are landblock-local ([0, 192) per axis,
/// relative to the cell id's block). acdream physics positions are in the floating
/// world frame (anchor landblock at origin) — callers convert via the current
/// block's registered world origin BEFORE calling in here.</para>
/// </summary>
public static class LandDefs
{
/// <summary>24 m landcell side (retail <c>square_length</c>).</summary>
public const float CellLength = 24f;
/// <summary>192 m landblock side.</summary>
public const float BlockLength = 192f;
/// <summary>Map-wide lcoord bound: 255 blocks × 8 cells (retail <c>0x7F8</c>).</summary>
public const int LandLength = 0x7F8;
// Retail PhysicsGlobals::EPSILON as used by adjust_to_outside's coordinate
// snap (decomp :438719 shows the literal 0.000199999995f).
private const float Epsilon = 0.000199999995f;
/// <summary>
/// <c>LandDefs::in_bounds</c> (pc:68509, @0x0043d650): both lcoord axes inside
/// the map.
/// </summary>
public static bool InBounds(int lx, int ly)
=> lx >= 0 && ly >= 0 && lx < LandLength && ly < LandLength;
/// <summary>
/// <c>LandDefs::blockid_to_lcoord</c> (pc:68520, @0x0043d680): the lcoord of a
/// landblock's (0,0) cell. Block bytes are ZERO-extended — the decomp's
/// <c>int8_t</c> cast on block_y is a Binary Ninja mis-render (ACE
/// LandDefs.cs:169 confirms plain masks).
/// </summary>
public static bool BlockIdToLcoord(uint cellId, out int lx, out int ly)
{
if (cellId == 0u) { lx = 0; ly = 0; return false; }
lx = (int)((cellId >> 24) & 0xFFu) << 3;
ly = (int)((cellId >> 16) & 0xFFu) << 3;
return InBounds(lx, ly);
}
/// <summary>
/// <c>LandDefs::inbound_valid_cellid</c> (pc:163438, @0x004979a0): low word in
/// a valid range (landcell 1..0x40, envcell 0x100..0xFFFD, or the 0xFFFF block
/// sentinel) and the block lcoord inside the map. Retail checks BOTH axes (ACE
/// checks only X — an ACE divergence, not copied).
/// </summary>
public static bool InboundValidCellId(uint cellId)
{
if (!CellLowInRange(cellId & 0xFFFFu)) return false;
int lx = (int)((cellId >> 24) & 0xFFu) << 3;
int ly = (int)((cellId >> 16) & 0xFFu) << 3;
return InBounds(lx, ly);
}
/// <summary>
/// <c>LandDefs::gid_to_lcoord</c> (pc:163500, @0x00497a90): a full OUTDOOR cell
/// id to its global lcoord. Fails for indoor ids (low ≥ 0x100).
/// </summary>
public static bool GidToLcoord(uint cellId, out int lx, out int ly)
{
lx = 0; ly = 0;
if (!InboundValidCellId(cellId)) return false;
uint low = cellId & 0xFFFFu;
if (low >= 0x100u) return false; // outdoor only
lx = ((int)((cellId >> 24) & 0xFFu) << 3) + (int)((low - 1u) >> 3);
ly = ((int)((cellId >> 16) & 0xFFu) << 3) + (int)((low - 1u) & 7u);
return InBounds(lx, ly);
}
/// <summary>
/// <c>LandDefs::lcoord_to_gid</c> (pc:171859, @0x004a19a0): a global lcoord to
/// the full outdoor cell id — the block id is RE-DERIVED from the lcoord's
/// upper bits, so a neighbour-block lcoord yields the neighbour's cell id.
/// Returns 0 when out of map bounds (retail behavior).
/// </summary>
public static uint LcoordToGid(int lx, int ly)
{
if (!InBounds(lx, ly)) return 0u;
uint low = (uint)((ly & 7) + ((lx & 7) << 3) + 1);
uint block = (uint)(((lx >> 3) << 8) | (ly >> 3));
return (block << 16) | low;
}
/// <summary>
/// <c>LandDefs::get_outside_lcoord</c> (pc:438690, @0x005a9b00): global lcoord
/// of the landcell under <paramref name="blockLocalPos"/>, expressed relative to
/// <paramref name="cellId"/>'s block. <c>floor(pos/24)</c> may be negative or
/// ≥ 8 — that IS the landblock crossing; the only rejection is the map edge.
/// </summary>
public static bool GetOutsideLcoord(uint cellId, Vector3 blockLocalPos, out int lx, out int ly)
{
lx = 0; ly = 0;
if (!CellLowInRange(cellId & 0xFFFFu)) return false;
BlockIdToLcoord(cellId, out lx, out ly);
lx += (int)MathF.Floor(blockLocalPos.X / CellLength);
ly += (int)MathF.Floor(blockLocalPos.Y / CellLength);
return InBounds(lx, ly);
}
/// <summary>
/// <c>LandDefs::adjust_to_outside</c> (pc:438719, @0x005a9bc0): re-seat
/// (<paramref name="cellId"/>, <paramref name="blockLocalPos"/>) onto the outdoor
/// landcell actually under the point. On success the cell id may belong to a
/// NEIGHBOUR landblock and the position is re-based into that block's local
/// frame (<c>pos -= floor(pos/192)·192</c>; the decomp's <c>0f</c> divisor is a
/// BN artifact — ACE LandDefs.cs:140 confirms BlockLength). On failure the cell
/// id is zeroed (retail behavior).
/// </summary>
public static bool AdjustToOutside(ref uint cellId, ref Vector3 blockLocalPos)
{
if (CellLowInRange(cellId & 0xFFFFu))
{
if (MathF.Abs(blockLocalPos.X) < Epsilon) blockLocalPos.X = 0f;
if (MathF.Abs(blockLocalPos.Y) < Epsilon) blockLocalPos.Y = 0f;
if (GetOutsideLcoord(cellId, blockLocalPos, out int lx, out int ly))
{
cellId = LcoordToGid(lx, ly);
blockLocalPos.X -= MathF.Floor(blockLocalPos.X / BlockLength) * BlockLength;
blockLocalPos.Y -= MathF.Floor(blockLocalPos.Y / BlockLength) * BlockLength;
return true;
}
}
cellId = 0u;
return false;
}
// Retail cell_in_range: landcell, envcell, or the block sentinel.
private static bool CellLowInRange(uint low)
=> low is (>= 1u and <= 0x40u) or (>= 0x100u and <= 0xFFFDu) or 0xFFFFu;
}

View file

@ -416,7 +416,7 @@ public sealed class ShadowObjectRegistry
/// outdoor cell ids. <see cref="CellTransit.FindCellSet"/> already adds
/// outdoor cells to the candidate set when the sphere straddles an
/// indoor cell's exit portal (<c>OtherCellId=0xFFFF</c>) via
/// <see cref="CellTransit.AddAllOutsideCells(System.Numerics.Vector3, float, uint, System.Collections.Generic.HashSet{uint})"/>.
/// <see cref="CellTransit.AddAllOutsideCells(System.Numerics.Vector3, float, uint, System.Numerics.Vector3, System.Collections.Generic.ICollection{uint})"/>.
/// Pre-slice-1, the explicit
/// "skip outdoor ids" filter combined with #98's indoor-primary gate
/// meant doors registered at outdoor cells (default <c>cellScope=0</c>

View file

@ -27,6 +27,25 @@ public sealed class CellGraph
public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin)
=> _terrain[landblockPrefix & 0xFFFF0000u] = (terrain, worldOrigin);
/// <summary>
/// World origin (SW corner) of the landblock containing <paramref name="id"/>,
/// as registered by <see cref="RegisterTerrain"/>. Issue #106: converts the
/// floating-world-frame sphere coords into retail's block-local frame for the
/// <see cref="LandDefs"/> lcoord math. False (origin zero) when the landblock
/// has no registered terrain — callers fall back to the legacy anchor-block
/// assumption (world frame == block-local frame).
/// </summary>
public bool TryGetTerrainOrigin(uint id, out Vector3 origin)
{
if (_terrain.TryGetValue(id & 0xFFFF0000u, out var t))
{
origin = t.Origin;
return true;
}
origin = Vector3.Zero;
return false;
}
public void RemoveLandblock(uint landblockPrefix)
{
uint lb = landblockPrefix & 0xFFFF0000u;