acdream/src/AcDream.Core/World/Cells/CellGraph.cs
Erik 71604331cf wip(physics): collision O(changed) delta-commit (O1-O3) - ON HOLD, feel-test failed
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>
2026-08-02 20:06:59 +02:00

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-&gt;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 &amp; 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-&gt;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);