Publication-throughput rework per the D2 design (docs/research/ 2026-08-02-collision-throughput-handoff/design-note.md): O1 per-prefix installed-key ledgers replacing the seal's full-map scans; O2 per- landblock delta commit (LandblockReplacementApplyCursor against the active root) replacing whole-world TransferTo; O3 empty staging root, commit-time reflood (CObjCell::init_objects 0x0052B420 -> recalc_cross_cells 0x00515A30), journal/peer-rebase machinery deleted (~1,900 lines net). Automated gates green: Runtime 999, Core physics 2,135, App 4,039/3, Headless 79, complete solution 10,812/0/4; lifecycle gate PASS (connected-world-gate-20260802-193029). Soak 194423: publication-side acceptance fully met (37 -> 4 failures, all convergence dims zero, loadedLandblocks baseline-identical, waitCue 6/9 -> 1/9). COMMITTED AS WIP ON USER DIRECTION - NOT ACCEPTED. The user feel-test FAILED on this tree: monsters still pop into existence at close range, monsters spawned mid-air far ahead, static placements visibly wrong, plus 243x "Landblock already has a full retirement receipt" InvalidOperationException catch-retry loop during origin recenter (launch-feeltest-oclone.log). The 4 remaining soak failures (pendingLandblockRetirements 131/122 at the Caul->Sawato stops) and the implementer's "exposed pre-existing" classification are under re-judgment against that loop. Dual reviews were dispatched and then stopped mid-flight on user direction; NO review has passed this commit. Full problem inventory + next-agent instructions: docs/research/2026-08-02-collision-throughput-handoff/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
305 lines
12 KiB
C#
305 lines
12 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics; // TerrainSurface
|
|
|
|
namespace AcDream.Core.World.Cells;
|
|
|
|
/// <summary>
|
|
/// The unified cell graph: the active, authoritative id->cell resolver and registry.
|
|
/// Populated from validated
|
|
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> payloads
|
|
/// (a physics root is optional; a containment root is required) and consumed across
|
|
/// the engine: <see cref="GetVisible"/> resolves any cell id, <see cref="CurrCell"/> is
|
|
/// the player render/lighting root, <see cref="FindVisibleChildCell"/> resolves the
|
|
/// 3rd-person camera cell, and <see cref="TryGetTerrainOrigin"/> supplies the block-local
|
|
/// terrain origin for the LandDefs lcoord math. Retail anchor: CObjCell::GetVisible
|
|
/// (pseudo_c:308209). Worker-thread populated; reads are concurrency-safe.
|
|
/// </summary>
|
|
public sealed class CellGraph
|
|
{
|
|
private readonly CollisionWorldStateSlot _collisionWorld;
|
|
private ConcurrentDictionary<uint, EnvCell> _envCells =>
|
|
_collisionWorld.Current.EnvCells;
|
|
private ConcurrentDictionary<uint, CellGraphTerrain> _terrain =>
|
|
_collisionWorld.Current.Terrain;
|
|
private ConcurrentDictionary<uint, ObjCell> _outdoorCells =>
|
|
_collisionWorld.Current.OutdoorCells;
|
|
|
|
public CellGraph()
|
|
: this(new CollisionWorldStateSlot())
|
|
{
|
|
}
|
|
|
|
internal CellGraph(CollisionWorldStateSlot collisionWorld)
|
|
{
|
|
_collisionWorld = collisionWorld
|
|
?? throw new ArgumentNullException(nameof(collisionWorld));
|
|
}
|
|
|
|
/// <summary>The player's current cell — the render/lighting root. Written ONLY at the
|
|
/// player chokepoint <see cref="AcDream.Core.Physics.PhysicsEngine.UpdatePlayerCurrCell"/>
|
|
/// (NPCs never touch it — a per-entity writer was the cottage-doorway "blue-hole"
|
|
/// cause); read by the renderer for the player root (GameWindow). Left unchanged when
|
|
/// the id isn't yet resolvable in the graph (stale beats null).</summary>
|
|
public ObjCell? CurrCell { get; internal set; }
|
|
|
|
public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId);
|
|
|
|
public void Add(EnvCell cell) =>
|
|
_collisionWorld.Current.TryAddEnvCell(cell.Id, cell);
|
|
|
|
/// <param name="landblockPrefix">Any id in the cell's landblock; masked to (id & 0xFFFF0000).</param>
|
|
public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin)
|
|
{
|
|
uint prefix = landblockPrefix & 0xFFFF0000u;
|
|
_terrain[prefix] = new CellGraphTerrain(terrain, worldOrigin);
|
|
for (uint low = 1u; low <= 0x40u; low++)
|
|
{
|
|
uint id = prefix | low;
|
|
int index = (int)(low - 1u);
|
|
_outdoorCells[id] = LandCell.Synthesize(
|
|
id,
|
|
terrain,
|
|
worldOrigin,
|
|
index / 8,
|
|
index % 8);
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
if (CurrCell is { } current
|
|
&& (current.Id & 0xFFFF0000u) == lb)
|
|
{
|
|
CurrCell = null;
|
|
}
|
|
_terrain.TryRemove(lb, out _);
|
|
for (uint low = 1u; low <= 0x40u; low++)
|
|
_outdoorCells.TryRemove(lb | low, out _);
|
|
RemoveEnvCellPrefixKeys(lb);
|
|
}
|
|
|
|
/// <summary>
|
|
/// O1: retire one prefix's EnvCells through the installed-key ledger —
|
|
/// O(prefix keys), never a whole-map scan. Removal tombstones the
|
|
/// captured slot list, so index iteration stays exact.
|
|
/// </summary>
|
|
private void RemoveEnvCellPrefixKeys(uint prefix)
|
|
{
|
|
CollisionWorldState world = _collisionWorld.Current;
|
|
List<uint>? slots = world.EnvCellKeys.SlotsForPrefix(prefix);
|
|
if (slots is null)
|
|
return;
|
|
int limit = slots.Count;
|
|
for (int index = 0; index < limit; index++)
|
|
{
|
|
uint id = slots[index];
|
|
if (id != 0u)
|
|
world.RemoveEnvCell(id);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove only a landblock's indoor environment cells while preserving its
|
|
/// outdoor terrain registration. Used by Near-to-Far streaming demotion.
|
|
/// </summary>
|
|
public void RemoveEnvCellsForLandblock(uint landblockPrefix)
|
|
{
|
|
uint lb = landblockPrefix & 0xFFFF0000u;
|
|
if (CurrCell is { } current
|
|
&& (current.Id & 0xFFFF0000u) == lb
|
|
&& (current.Id & 0xFFFFu) >= 0x0100u)
|
|
{
|
|
CurrCell = null;
|
|
}
|
|
RemoveEnvCellPrefixKeys(lb);
|
|
}
|
|
|
|
/// <summary>The universal id->cell resolver (retail CObjCell::GetVisible).</summary>
|
|
public ObjCell? GetVisible(uint id)
|
|
{
|
|
if (id == 0u) return null;
|
|
if ((id & 0xFFFFu) >= 0x100u)
|
|
return _envCells.TryGetValue(id, out var env) ? env : null;
|
|
|
|
uint low = id & 0xFFFFu;
|
|
if (low < 1u || low > 0x40u) return null;
|
|
return _outdoorCells.TryGetValue(id, out ObjCell? cell)
|
|
? cell
|
|
: null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve the cell on the far side of a portal — retail CCellPortal::GetOtherCell =
|
|
/// GetVisible(other_cell_id). <paramref name="cell"/> documents the traversal source and is
|
|
/// reserved for the OtherCellPtr neighbor cache (Stage 3); the lookup keys only on the portal.
|
|
/// </summary>
|
|
public ObjCell? Neighbor(ObjCell cell, in CellPortal portal) => GetVisible(portal.OtherCellId);
|
|
|
|
/// <summary>
|
|
/// Retail CEnvCell::find_visible_child_cell @ 0x0052dc50 (pseudo_c:311397).
|
|
/// Walk <paramref name="rootId"/>'s StabList + the root itself, return the first
|
|
/// EnvCell whose <see cref="ObjCell.PointInCell"/> is true for
|
|
/// <paramref name="worldPoint"/>. Used to resolve the camera cell in 3rd-person
|
|
/// from the physics cell graph rather than an independent AABB reclassification —
|
|
/// the root is the player cell, never a camera-eye AABB scan.
|
|
/// Returns null when no cell in the root's stab list (or the root itself) contains
|
|
/// the query point (caller falls back to the player cell as projection root).
|
|
/// </summary>
|
|
public EnvCell? FindVisibleChildCell(uint rootId, Vector3 worldPoint)
|
|
{
|
|
if (!_envCells.TryGetValue(rootId, out var root)) return null;
|
|
if (root.PointInCell(worldPoint)) return root;
|
|
foreach (var stabId in root.StabList)
|
|
if (_envCells.TryGetValue(stabId, out var stab) && stab.PointInCell(worldPoint))
|
|
return stab;
|
|
return null;
|
|
}
|
|
|
|
internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
|
|
CellGraph staging,
|
|
uint landblockId) => new(this, staging, landblockId);
|
|
|
|
internal sealed class LandblockReplacementBuilder : IDisposable
|
|
{
|
|
private readonly CellGraph _active;
|
|
private readonly CellGraph _staging;
|
|
private readonly uint _prefix;
|
|
private readonly List<KeyValuePair<uint, EnvCell>> _envCells = new();
|
|
private readonly HashSet<uint> _stagingIds = new();
|
|
private readonly List<uint> _removeIds = new();
|
|
private List<uint>? _keySlots;
|
|
private int _keySlotLimit;
|
|
private bool _keySlotsCaptured;
|
|
private int _cursor;
|
|
private int _phase;
|
|
|
|
internal LandblockReplacementBuilder(
|
|
CellGraph active,
|
|
CellGraph staging,
|
|
uint landblockId)
|
|
{
|
|
_active = active;
|
|
_staging = staging;
|
|
_prefix = landblockId & 0xFFFF0000u;
|
|
}
|
|
|
|
internal bool Advance()
|
|
{
|
|
if (_phase == 0)
|
|
{
|
|
// O1: enumerate the staging root's installed target-prefix
|
|
// EnvCell keys via the ledger instead of scanning the map.
|
|
if (TryTakeNextPrefixKey(
|
|
_staging._collisionWorld.Current.EnvCellKeys,
|
|
out uint stagingId))
|
|
{
|
|
if ((stagingId & 0xFFFFu) >= 0x0100u
|
|
&& _staging._envCells.TryGetValue(
|
|
stagingId,
|
|
out EnvCell? cell))
|
|
{
|
|
_envCells.Add(
|
|
new KeyValuePair<uint, EnvCell>(stagingId, cell));
|
|
_stagingIds.Add(stagingId);
|
|
}
|
|
return false;
|
|
}
|
|
_phase = 1;
|
|
return false;
|
|
}
|
|
if (_phase == 1)
|
|
{
|
|
// O1: active-side removal capture through the ledger — no
|
|
// cross-frame live enumerator over the active map.
|
|
if (TryTakeNextPrefixKey(
|
|
_active._collisionWorld.Current.EnvCellKeys,
|
|
out uint activeId))
|
|
{
|
|
if ((activeId & 0xFFFFu) >= 0x0100u
|
|
&& !_stagingIds.Contains(activeId)
|
|
&& _active._envCells.ContainsKey(activeId))
|
|
{
|
|
_removeIds.Add(activeId);
|
|
}
|
|
return false;
|
|
}
|
|
bool hasTerrain = _staging._terrain.TryGetValue(
|
|
_prefix,
|
|
out var terrain);
|
|
Prepared = new PreparedCellGraphLandblock(
|
|
_prefix,
|
|
_removeIds,
|
|
_envCells,
|
|
hasTerrain,
|
|
terrain);
|
|
_phase = 2;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private bool TryTakeNextPrefixKey(PrefixKeyIndex ledger, out uint key)
|
|
{
|
|
if (!_keySlotsCaptured)
|
|
{
|
|
_keySlots = ledger.SlotsForPrefix(_prefix);
|
|
_keySlotLimit = _keySlots?.Count ?? 0;
|
|
_keySlotsCaptured = true;
|
|
_cursor = 0;
|
|
}
|
|
while (_cursor < _keySlotLimit)
|
|
{
|
|
uint candidate = _keySlots![_cursor++];
|
|
if (candidate != 0u)
|
|
{
|
|
key = candidate;
|
|
return true;
|
|
}
|
|
}
|
|
key = 0u;
|
|
_keySlots = null;
|
|
_keySlotsCaptured = false;
|
|
return false;
|
|
}
|
|
|
|
internal PreparedCellGraphLandblock? Prepared { get; private set; }
|
|
|
|
public void Dispose()
|
|
{
|
|
_keySlots = null;
|
|
_keySlotsCaptured = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed record PreparedCellGraphLandblock(
|
|
uint LandblockPrefix,
|
|
IReadOnlyList<uint> EnvCellIdsToRemove,
|
|
IReadOnlyList<KeyValuePair<uint, EnvCell>> EnvCells,
|
|
bool HasTerrain,
|
|
CellGraphTerrain? Terrain);
|
|
|
|
internal sealed record CellGraphTerrain(
|
|
TerrainSurface Terrain,
|
|
Vector3 Origin);
|