acdream/src/AcDream.Core/Physics/PhysicsEngine.cs

3068 lines
139 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Items;
using AcDream.Core.World.Cells;
namespace AcDream.Core.Physics;
internal readonly record struct TerrainWalkableSample(
System.Numerics.Plane Plane,
TerrainTriangleVertices Vertices,
float WaterDepth,
bool IsWater,
uint CellId);
/// <summary>
/// Top-level physics resolver that combines <see cref="TerrainSurface"/> and
/// <see cref="CellSurface"/> to resolve entity movement with step-height
/// enforcement and outdoor/indoor cell transitions.
///
/// <para>
/// Landblocks are registered via <see cref="AddLandblock"/> with their
/// terrain, indoor cells, and world-space offsets. <see cref="Resolve"/>
/// takes a current position, the entity's current cell ID, a movement delta,
/// and a step-up height limit; it returns the validated new position, the
/// updated cell ID, and whether the entity is standing on a surface.
/// </para>
/// </summary>
public sealed class PhysicsEngine
{
private CollisionWorldStateSlot _collisionWorld;
private Dictionary<uint, LandblockPhysics> _landblocks =>
_collisionWorld.Current.Landblocks;
private List<uint> _landblockSlots =>
_collisionWorld.Current.LandblockSlots;
private Dictionary<uint, int> _landblockIndices =>
_collisionWorld.Current.LandblockIndices;
private Stack<int> _landblockFreeSlots =>
_collisionWorld.Current.LandblockFreeSlots;
private readonly TransitionScratchArena? _transitionScratch;
public PhysicsEngine()
: this(reuseTransitionScratch: true)
{
}
/// <summary>
/// Test seam for fresh-versus-reused transition differential evidence.
/// Production always uses the public constructor and owns one retail-shaped
/// scratch arena.
/// </summary>
internal PhysicsEngine(bool reuseTransitionScratch)
{
_collisionWorld = new CollisionWorldStateSlot();
ShadowObjects = new ShadowObjectRegistry(_collisionWorld);
_transitionScratch = reuseTransitionScratch
? new TransitionScratchArena()
: null;
}
private Transition RentTransition()
=> _transitionScratch?.Rent() ?? new Transition();
private void ReturnTransition(Transition transition)
=> _transitionScratch?.Return(transition);
/// <summary>Number of registered landblocks (diagnostic).</summary>
public int LandblockCount => _landblocks.Count;
internal CollisionWorldStateSlot CollisionWorld => _collisionWorld;
/// <summary>
/// Optional high-volume collision trace sink. Production leaves this
/// unset; focused diagnostic gates may opt in explicitly.
/// </summary>
public Action<string>? DiagnosticLog { get; set; }
/// <summary>
/// Deterministic test seam for the retail per-cell dispatcher. Production
/// leaves this null. Tests may observe a completed phase and substitute its
/// returned state to prove retry/order semantics without geometry-specific
/// response coupling.
/// </summary>
internal Func<
Transition,
TransitionCellCollisionPhase,
uint,
TransitionState,
TransitionState>? TransitionCellCollisionTestHook { get; set; }
/// <summary>
/// Deterministic seam for retail <c>Random::RollDice(-1, 1)</c> used by
/// SetPosition scatter. Values are expected in [0,1); production uses the
/// process RNG and tests inject a fixed sequence.
/// </summary>
internal Func<double> SetPositionRandomUnit { get; set; } =
Random.Shared.NextDouble;
/// <summary>
/// True once the landblock covering <paramref name="cellOrLandblockId"/> has had its
/// terrain + cells registered via <see cref="AddLandblock"/>. Accepts a canonical
/// (0xFFFF) id, a cell-resolved id, or a bare landblock id — compares on the high 16
/// bits. This is the teleport "worldReady" gate (the destination is grounded).
/// </summary>
public bool IsLandblockTerrainResident(uint cellOrLandblockId)
{
uint prefix = cellOrLandblockId & 0xFFFF0000u;
foreach ((uint key, _) in _landblocks)
if ((key & 0xFFFF0000u) == prefix) return true;
return false;
}
/// <summary>
/// True once EVERY in-bounds landblock within Chebyshev <paramref name="radius"/> of the
/// landblock covering <paramref name="cellOrLandblockId"/> has had its terrain registered.
/// This is the teleport "surroundings are loaded" gate: holding the fade until the player's
/// own landblock AND its immediate neighbours are resident means they arrive standing on a
/// loaded, collidable world (their cell-walk can root into neighbour cells) instead of a
/// single landblock floating in the void. Off-map neighbours (coords outside 0..254) are
/// skipped — they never load, so requiring them would hang the hold until the wall-clock
/// timeout. radius 0 is equivalent to <see cref="IsLandblockTerrainResident"/>.
/// </summary>
public bool IsNeighborhoodTerrainResident(uint cellOrLandblockId, int radius)
{
var resident = new HashSet<uint>();
foreach ((uint key, _) in _landblocks)
resident.Add(key & 0xFFFF0000u);
int cx = (int)((cellOrLandblockId >> 24) & 0xFFu);
int cy = (int)((cellOrLandblockId >> 16) & 0xFFu);
for (int dx = -radius; dx <= radius; dx++)
for (int dy = -radius; dy <= radius; dy++)
{
int nx = cx + dx, ny = cy + dy;
if (nx < 0 || nx > 254 || ny < 0 || ny > 254) continue; // off-map: skip
uint prefix = ((uint)nx << 24) | ((uint)ny << 16);
if (!resident.Contains(prefix)) return false;
}
return true;
}
/// <summary>
/// Cell-based spatial index for static object collision.
/// Populated during landblock streaming; queried by the Transition system.
/// </summary>
public ShadowObjectRegistry ShadowObjects { get; }
/// <summary>
/// Physics BSP cache shared with the streaming loader. Set once by the
/// host (GameWindow) immediately after construction. The Transition system
/// reads this during FindObjCollisionsInCell to perform narrow-phase BSP
/// tests. BR-7: propagated into <see cref="ShadowObjects"/> so the
/// registration-side flood (<see cref="CellTransit.BuildShadowCellSet"/>)
/// can traverse cells + buildings.
/// </summary>
public PhysicsDataCache? DataCache
{
get => _dataCache;
set
{
if (value is not null
&& !ReferenceEquals(_collisionWorld, value.CollisionWorld))
{
if (ShadowObjects.TotalRegistered != 0)
{
throw new InvalidOperationException(
"A populated physics engine cannot change collision roots.");
}
// Legacy/test construction commonly installs terrain before
// attaching its first cache. Preserve that one-time ordering
// without permitting a live populated root swap: move only
// the engine-owned landblock index into the still-private
// cache root, then bind the stable facades.
if (_dataCache is null && _landblocks.Count != 0)
CopyDetachedLandblocksTo(value.CollisionWorld);
else if (_landblocks.Count != 0)
throw new InvalidOperationException(
"A populated physics engine cannot change collision roots.");
_collisionWorld = value.CollisionWorld;
ShadowObjects.AttachCollisionWorld(_collisionWorld);
}
_dataCache = value;
ShadowObjects.DataCache = value;
}
}
private PhysicsDataCache? _dataCache;
private void CopyDetachedLandblocksTo(
CollisionWorldStateSlot destinationSlot)
{
CollisionWorldState source = _collisionWorld.Current;
CollisionWorldState destination = destinationSlot.Current;
if (destination.Landblocks.Count != 0
|| destination.LandblockSlots.Count != 0
|| destination.LandblockIndices.Count != 0
|| destination.LandblockFreeSlots.Count != 0)
{
throw new InvalidOperationException(
"A cache collision root already owns engine landblocks.");
}
foreach ((uint landblockId, LandblockPhysics landblock) in
source.Landblocks)
{
destination.Landblocks.Add(landblockId, landblock);
}
destination.LandblockSlots.AddRange(source.LandblockSlots);
foreach ((uint landblockId, int slot) in source.LandblockIndices)
destination.LandblockIndices.Add(landblockId, slot);
int[] freeSlots = source.LandblockFreeSlots.ToArray();
for (int index = freeSlots.Length - 1; index >= 0; index--)
destination.LandblockFreeSlots.Push(freeSlots[index]);
}
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): optional live
/// weenie-object table, consulted ONLY by
/// <see cref="Transition.FindEnvCollisions"/>'s entry-restriction gate to
/// resolve a cell's <c>RestrictionObj</c> into its owner/guest-list data
/// (retail <c>ACCWeenieObject::CanMoveInto</c>). Mirrors the
/// <see cref="DataCache"/> pattern: nullable, settable, defaults null so
/// every existing test/one-shot caller is unaffected. An unset table
/// makes a restricted cell fail CLOSED — exactly retail's own fallback
/// when the restriction weenie can't be resolved — so production MUST
/// wire this to the live table for the fix to actually admit anyone.
/// </summary>
public ClientObjectTable? Objects { get; set; }
internal sealed record LandblockPhysics(
TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells,
IReadOnlyList<PortalPlane> Portals,
float WorldOffsetX,
float WorldOffsetY);
/// <summary>
/// Creates an off-side collision world from the last complete generation.
/// Streaming modifies this copy only; the active engine and its borrowed
/// cache/registry identities remain stable until Runtime commits.
/// </summary>
internal CollisionStagingBuilder CreateCollisionStagingBuilder(
uint targetLandblockId)
{
PhysicsDataCache activeCache = DataCache
?? throw new InvalidOperationException(
"Active collision engine has no data cache.");
return new CollisionStagingBuilder(
this,
activeCache,
targetLandblockId & 0xFFFF0000u);
}
internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
PhysicsEngine staging,
uint landblockId,
uint[] gfxObjectIds,
uint[] setupIds,
IReadOnlyList<uint> expectedRetainedOwners)
{
ArgumentNullException.ThrowIfNull(staging);
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!staging._landblocks.TryGetValue(
canonical,
out LandblockPhysics? landblock))
{
throw new InvalidOperationException(
$"Staging collision generation has no landblock 0x{canonical:X8}.");
}
PhysicsDataCache stagingCache = staging.DataCache
?? throw new InvalidOperationException(
"Staging collision engine has no data cache.");
return new LandblockReplacementBuilder(
canonical,
landblock,
staging,
(DataCache ?? throw new InvalidOperationException(
"Active collision engine has no data cache."))
.CreateLandblockReplacementBuilder(
stagingCache,
canonical,
gfxObjectIds,
setupIds),
ShadowObjects.CreateLandblockReplacementBuilder(
staging.ShadowObjects,
canonical,
expectedRetainedOwners));
}
internal void CommitLandblockReplacement(
PreparedPhysicsEngineLandblock replacement)
{
PhysicsDataCache activeCache = DataCache
?? throw new InvalidOperationException(
"Active collision engine has no data cache.");
PhysicsDataCache stagingCache = replacement.Staging.DataCache
?? throw new InvalidOperationException(
"Staging collision engine has no data cache.");
uint activeCurrentCellId = activeCache.CellGraph.CurrCell?.Id ?? 0u;
stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld);
if ((activeCurrentCellId & 0xFFFF0000u)
== (replacement.LandblockId & 0xFFFF0000u))
{
activeCache.CellGraph.CurrCell =
activeCache.CellGraph.GetVisible(activeCurrentCellId);
}
}
internal LandblockReplacementApplyCursor
CreateLandblockReplacementApplyCursor(
PreparedPhysicsEngineLandblock replacement) =>
new(this, replacement);
internal readonly record struct LandblockReplacementApplyStep(
bool Completed,
bool Worked,
bool HasOwner,
uint OwnerId);
internal LandblockRetirementCursor CreateLandblockRetirementCursor(
PhysicsEngine authoritative,
uint landblockId,
bool withdraw) => new(
this,
authoritative,
landblockId,
withdraw);
/// <summary>
/// Applies one demotion/withdrawal to an off-side root without a whole-
/// world synchronous scan. Every advance inspects or mutates at most one
/// stable owner slot, dictionary leaf, or authored outdoor cell.
/// </summary>
internal sealed class LandblockRetirementCursor : IDisposable
{
private readonly PhysicsEngine _destinationEngine;
private readonly PhysicsDataCache _destinationCache;
private readonly CollisionWorldState _destination;
private readonly CollisionWorldState _authoritative;
private readonly uint _canonical;
private readonly uint _prefix;
private readonly bool _withdraw;
private readonly List<uint> _ownerSlots;
private readonly int _ownerSlotLimit;
private readonly LandblockPhysics? _demotedLandblock;
private readonly CellGraphTerrain? _demotedTerrain;
private IEnumerator<KeyValuePair<uint, CellPhysics>>? _cells;
private IEnumerator<KeyValuePair<uint, FlatCellStructureCollisionAsset>>?
_flatCells;
private IEnumerator<KeyValuePair<uint, FlatEnvCellTopology>>? _flatEnvCells;
private IEnumerator<KeyValuePair<uint, BuildingPhysics>>? _buildings;
private IEnumerator<KeyValuePair<uint, EnvCell>>? _envCells;
private int _ownerIndex;
private int _outdoorIndex;
private int _phase;
internal LandblockRetirementCursor(
PhysicsEngine destination,
PhysicsEngine authoritative,
uint landblockId,
bool withdraw)
{
_destinationEngine = destination;
_destinationCache = destination.DataCache
?? throw new InvalidOperationException(
"Collision engine has no data cache.");
_destination = destination._collisionWorld.Capture();
_authoritative = authoritative._collisionWorld.Capture();
_canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
_prefix = landblockId & 0xFFFF0000u;
_withdraw = withdraw;
_ownerSlots = _destination.ShadowOwnerSlots;
_ownerSlotLimit = _ownerSlots.Count;
if (!withdraw)
{
_authoritative.Landblocks.TryGetValue(
_canonical,
out _demotedLandblock);
_authoritative.Terrain.TryGetValue(
_prefix,
out _demotedTerrain);
}
}
internal uint LandblockId => _canonical;
internal LandblockRetirementStep Advance()
{
while (true)
{
switch (_phase)
{
case 0:
if (_ownerIndex < _ownerSlotLimit)
{
uint ownerId = _ownerSlots[_ownerIndex++];
if (ownerId != 0u)
{
_destinationEngine.ShadowObjects
.RetireOwnerFromLandblock(
ownerId,
_canonical);
}
return Worked();
}
_phase++;
continue;
case 1:
_cells ??= _destination.CellStruct.GetEnumerator();
if (RemoveOneInPrefix(_cells, _destination.CellStruct))
return Worked();
DisposeEnumerator(ref _cells);
_phase++;
continue;
case 2:
_flatCells ??= _destination.FlatCellStruct.GetEnumerator();
if (RemoveOneInPrefix(
_flatCells,
_destination.FlatCellStruct))
return Worked();
DisposeEnumerator(ref _flatCells);
_phase++;
continue;
case 3:
_flatEnvCells ??= _destination.FlatEnvCell.GetEnumerator();
if (RemoveOneInPrefix(
_flatEnvCells,
_destination.FlatEnvCell))
return Worked();
DisposeEnumerator(ref _flatEnvCells);
_phase++;
continue;
case 4:
_buildings ??= _destination.Buildings.GetEnumerator();
if (RemoveOneInPrefix(
_buildings,
_destination.Buildings))
return Worked();
DisposeEnumerator(ref _buildings);
_phase++;
continue;
case 5:
_envCells ??= _destination.EnvCells.GetEnumerator();
if (RemoveOneInPrefix(_envCells, _destination.EnvCells))
return Worked();
DisposeEnumerator(ref _envCells);
_phase++;
continue;
case 6:
if (_outdoorIndex < 0x40)
{
uint id = _prefix | (uint)++_outdoorIndex;
_destination.ShadowCells.Remove(id);
if (_withdraw)
{
_destination.OutdoorCells.TryRemove(id, out _);
}
else if (_authoritative.OutdoorCells.TryGetValue(
id,
out ObjCell? outdoor))
{
_destination.OutdoorCells[id] = outdoor;
}
return Worked();
}
_phase++;
continue;
case 7:
if (_withdraw)
{
_destinationEngine._landblocks.Remove(_canonical);
_destinationEngine.RemoveLandblockSlot(_canonical);
_destination.Terrain.TryRemove(_prefix, out _);
}
else
{
if (_demotedLandblock is not null)
{
_destinationEngine._landblocks[_canonical] =
_demotedLandblock;
_destinationEngine.EnsureLandblockSlot(_canonical);
}
if (_demotedTerrain is not null)
_destination.Terrain[_prefix] = _demotedTerrain;
}
_phase++;
return Worked();
case 8:
uint currentCellId =
_destinationCache.CellGraph.CurrCell?.Id ?? 0u;
if ((currentCellId & 0xFFFF0000u) == _prefix
&& (_withdraw
|| (currentCellId & 0xFFFFu) >= 0x0100u))
{
_destinationCache.CellGraph.CurrCell = null;
}
_phase++;
return new LandblockRetirementStep(
Completed: true,
Worked: false);
default:
return new LandblockRetirementStep(
Completed: true,
Worked: false);
}
}
}
private LandblockRetirementStep Worked() => new(
Completed: false,
Worked: true);
private bool RemoveOneInPrefix<T>(
IEnumerator<KeyValuePair<uint, T>> source,
IDictionary<uint, T> destination)
{
if (!source.MoveNext())
return false;
uint id = source.Current.Key;
if ((id & 0xFFFF0000u) == _prefix)
{
destination.Remove(id);
_destination.ShadowCells.Remove(id);
}
return true;
}
private static void DisposeEnumerator<T>(
ref IEnumerator<KeyValuePair<uint, T>>? enumerator)
{
enumerator?.Dispose();
enumerator = null;
}
public void Dispose()
{
DisposeEnumerator(ref _cells);
DisposeEnumerator(ref _flatCells);
DisposeEnumerator(ref _flatEnvCells);
DisposeEnumerator(ref _buildings);
DisposeEnumerator(ref _envCells);
}
}
internal readonly record struct LandblockRetirementStep(
bool Completed,
bool Worked);
/// <summary>
/// Applies one already-committed landblock delta to a later off-side root.
/// Each advance mutates at most one dictionary leaf, one synthesized
/// outdoor cell, or one logical shadow owner.
/// </summary>
internal sealed class LandblockReplacementApplyCursor : IDisposable
{
private readonly PhysicsEngine _destinationEngine;
private readonly PhysicsDataCache _destinationCache;
private readonly CollisionWorldState _destination;
private readonly PreparedPhysicsEngineLandblock _replacement;
private int _phase;
private int _index;
internal LandblockReplacementApplyCursor(
PhysicsEngine destination,
PreparedPhysicsEngineLandblock replacement)
{
_destinationEngine = destination;
_destinationCache = destination.DataCache
?? throw new InvalidOperationException(
"Collision engine has no data cache.");
_destination = _destinationCache.CollisionWorld.Capture();
_replacement = replacement;
}
internal uint LandblockId => _replacement.LandblockId;
internal LandblockReplacementApplyStep Advance()
{
PreparedPhysicsDataCacheLandblock data = _replacement.DataCache;
PreparedCellGraphLandblock graph = data.CellGraph;
while (true)
{
switch (_phase)
{
case 0:
if (RemoveOne(_destination.CellStruct, data.CellIdsToRemove))
return Worked();
NextPhase();
continue;
case 1:
if (InstallOne(_destination.CellStruct, data.Cells))
return Worked();
NextPhase();
continue;
case 2:
if (RemoveOne(_destination.FlatCellStruct, data.FlatCellIdsToRemove))
return Worked();
NextPhase();
continue;
case 3:
if (InstallOne(_destination.FlatCellStruct, data.FlatCells))
return Worked();
NextPhase();
continue;
case 4:
if (RemoveOne(_destination.FlatEnvCell, data.FlatEnvCellIdsToRemove))
return Worked();
NextPhase();
continue;
case 5:
if (InstallOne(_destination.FlatEnvCell, data.FlatEnvCells))
return Worked();
NextPhase();
continue;
case 6:
if (RemoveOne(_destination.Buildings, data.BuildingIdsToRemove))
return Worked();
NextPhase();
continue;
case 7:
if (InstallOne(_destination.Buildings, data.Buildings))
return Worked();
NextPhase();
continue;
case 8:
if (RemoveOne(_destination.EnvCells, graph.EnvCellIdsToRemove))
return Worked();
NextPhase();
continue;
case 9:
if (graph.HasTerrain)
{
if (_index == 0)
{
_destination.Terrain[graph.LandblockPrefix] =
graph.Terrain!;
_index++;
return Worked();
}
if (_index <= 0x40)
{
uint low = (uint)_index++;
CellGraphTerrain terrain = graph.Terrain!;
int cellIndex = (int)(low - 1u);
uint id = graph.LandblockPrefix | low;
_destination.OutdoorCells[id] =
LandCell.Synthesize(
id,
terrain.Terrain,
terrain.Origin,
cellIndex / 8,
cellIndex % 8);
return Worked();
}
}
else
{
if (_index == 0)
{
_destination.Terrain.TryRemove(
graph.LandblockPrefix,
out _);
_index++;
return Worked();
}
if (_index <= 0x40)
{
uint low = (uint)_index++;
_destination.OutdoorCells.TryRemove(
graph.LandblockPrefix | low,
out _);
return Worked();
}
}
NextPhase();
continue;
case 10:
if (InstallOne(_destination.EnvCells, graph.EnvCells))
return Worked();
NextPhase();
continue;
case 11:
_destinationEngine.InstallLandblockClone(
_replacement.LandblockId,
_replacement.Landblock);
NextPhase();
return Worked();
case 12:
if (_index < _replacement.Shadows.OwnerIds.Count)
{
uint ownerId =
_replacement.Shadows.OwnerIds[_index++];
return new LandblockReplacementApplyStep(
Completed: false,
Worked: true,
HasOwner: true,
ownerId);
}
NextPhase();
continue;
case 13:
uint currentCellId =
_destinationCache.CellGraph.CurrCell?.Id ?? 0u;
if ((currentCellId & 0xFFFF0000u)
== graph.LandblockPrefix)
{
_destinationCache.CellGraph.CurrCell =
_destinationCache.CellGraph.GetVisible(
currentCellId);
}
_phase++;
return new LandblockReplacementApplyStep(
Completed: true,
Worked: false,
HasOwner: false,
OwnerId: 0u);
default:
return new LandblockReplacementApplyStep(
Completed: true,
Worked: false,
HasOwner: false,
OwnerId: 0u);
}
}
}
private LandblockReplacementApplyStep Worked() => new(
Completed: false,
Worked: true,
HasOwner: false,
OwnerId: 0u);
private void NextPhase()
{
_phase++;
_index = 0;
}
private bool RemoveOne<T>(
IDictionary<uint, T> destination,
IReadOnlyList<uint> ids)
{
if (_index >= ids.Count)
return false;
destination.Remove(ids[_index++]);
return true;
}
private bool InstallOne<T>(
IDictionary<uint, T> destination,
IReadOnlyList<KeyValuePair<uint, T>> entries)
{
if (_index >= entries.Count)
return false;
KeyValuePair<uint, T> pair = entries[_index++];
destination[pair.Key] = pair.Value;
return true;
}
public void Dispose()
{
}
}
/// <summary>
/// Retained one-leaf-at-a-time materializer for an off-side collision
/// generation. Construction captures the current root reference only; no
/// resident dictionary is copied until <see cref="Advance"/>. Runtime's
/// owner journal reconciles mutations that occur while this cursor walks.
/// </summary>
internal sealed class CollisionStagingBuilder : IDisposable
{
private readonly PhysicsEngine _active;
private readonly CollisionWorldState _source;
private readonly CollisionWorldState _destination;
private readonly ShadowObjectRegistry _sourceShadows;
private readonly uint _targetPrefix;
private readonly HashSet<uint> _suppressedPrefixes = new();
private readonly int _landblockSlotLimit;
private readonly int _ownerSlotLimit;
private IEnumerator<KeyValuePair<uint, CellPhysics>>? _cells;
private IEnumerator<KeyValuePair<uint, FlatCellStructureCollisionAsset>>? _flatCells;
private IEnumerator<KeyValuePair<uint, FlatEnvCellTopology>>? _flatEnvCells;
private IEnumerator<KeyValuePair<uint, BuildingPhysics>>? _buildings;
private IEnumerator<KeyValuePair<uint, EnvCell>>? _envCells;
private IEnumerator<KeyValuePair<uint, CellGraphTerrain>>? _terrain;
private IEnumerator<KeyValuePair<uint, ObjCell>>? _outdoorCells;
private int _landblockIndex;
private int _ownerIndex;
private int _phase;
internal CollisionStagingBuilder(
PhysicsEngine active,
PhysicsDataCache activeCache,
uint targetPrefix)
{
_active = active;
_targetPrefix = targetPrefix;
_source = active._collisionWorld.Capture();
var stagingSlot = new CollisionWorldStateSlot();
StagingCache = activeCache.CreateEmptyCollisionStaging(stagingSlot);
StagingEngine = new PhysicsEngine
{
DataCache = StagingCache,
Objects = active.Objects,
};
_destination = stagingSlot.Capture();
_sourceShadows = new ShadowObjectRegistry(
new CollisionWorldStateSlot(_source));
_landblockSlotLimit = _source.LandblockSlots.Count;
_ownerSlotLimit = _source.ShadowOwnerSlots.Count;
}
internal PhysicsDataCache StagingCache { get; }
internal PhysicsEngine StagingEngine { get; }
internal int WorkUnits { get; private set; }
internal bool Completed => _phase == 9;
/// <summary>
/// Prevent a landblock retired after this cursor captured its source
/// root from being copied back into the draft by a later phase.
/// Already-copied leaves are retired by the caller before cloning
/// resumes; this tombstone covers every leaf not visited yet.
/// </summary>
internal void SuppressLandblock(uint landblockId) =>
_suppressedPrefixes.Add(landblockId & 0xFFFF0000u);
internal bool Advance()
{
switch (_phase)
{
case 0:
if (_landblockIndex < _landblockSlotLimit)
{
uint id = _source.LandblockSlots[_landblockIndex++];
if (id != 0u
&& (id & 0xFFFF0000u) != _targetPrefix
&& !_suppressedPrefixes.Contains(
id & 0xFFFF0000u)
&& _source.Landblocks.TryGetValue(
id,
out LandblockPhysics? landblock))
{
StagingEngine.InstallLandblockClone(id, landblock);
}
WorkUnits++;
return false;
}
_phase++;
return false;
case 1:
_cells ??= _source.CellStruct.GetEnumerator();
if (CopyOneOutsideTarget(_cells, _destination.CellStruct))
return CountOne();
DisposeEnumerator(ref _cells);
_phase++;
return false;
case 2:
_flatCells ??= _source.FlatCellStruct.GetEnumerator();
if (CopyOneOutsideTarget(_flatCells, _destination.FlatCellStruct))
return CountOne();
DisposeEnumerator(ref _flatCells);
_phase++;
return false;
case 3:
_flatEnvCells ??= _source.FlatEnvCell.GetEnumerator();
if (CopyOneOutsideTarget(_flatEnvCells, _destination.FlatEnvCell))
return CountOne();
DisposeEnumerator(ref _flatEnvCells);
_phase++;
return false;
case 4:
_buildings ??= _source.Buildings.GetEnumerator();
if (CopyOneOutsideTarget(_buildings, _destination.Buildings))
return CountOne();
DisposeEnumerator(ref _buildings);
_phase++;
return false;
case 5:
_envCells ??= _source.EnvCells.GetEnumerator();
if (CopyOneOutsideTarget(_envCells, _destination.EnvCells))
return CountOne();
DisposeEnumerator(ref _envCells);
_phase++;
return false;
case 6:
_terrain ??= _source.Terrain.GetEnumerator();
if (CopyOneOutsideTarget(_terrain, _destination.Terrain))
return CountOne();
DisposeEnumerator(ref _terrain);
_phase++;
return false;
case 7:
_outdoorCells ??= _source.OutdoorCells.GetEnumerator();
if (CopyOneOutsideTarget(_outdoorCells, _destination.OutdoorCells))
return CountOne();
DisposeEnumerator(ref _outdoorCells);
_phase++;
return false;
case 8:
if (_ownerIndex < _ownerSlotLimit)
{
uint ownerId = _source.ShadowOwnerSlots[_ownerIndex++];
if (ownerId != 0u
&& !_sourceShadows.IsStaticOwnerRootedIn(
ownerId,
_targetPrefix)
&& !IsSuppressedStaticOwner(ownerId)
&& !StagingEngine.ShadowObjects.HasLogicalOwner(
ownerId))
{
StagingEngine.ShadowObjects.MirrorOwnerFrom(
_sourceShadows,
ownerId);
}
WorkUnits++;
return false;
}
uint currentCellId = _active.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
StagingCache.CellGraph.CurrCell =
StagingCache.CellGraph.GetVisible(currentCellId);
_phase++;
return true;
default:
return true;
}
}
private bool CountOne()
{
WorkUnits++;
return false;
}
private bool CopyOneOutsideTarget<T>(
IEnumerator<KeyValuePair<uint, T>> source,
IDictionary<uint, T> destination)
{
if (!source.MoveNext())
return false;
KeyValuePair<uint, T> pair = source.Current;
uint prefix = pair.Key & 0xFFFF0000u;
if (prefix != _targetPrefix
&& !_suppressedPrefixes.Contains(prefix))
destination[pair.Key] = pair.Value;
return true;
}
private bool IsSuppressedStaticOwner(uint ownerId)
=> _sourceShadows.TryGetStaticOwnerRootPrefix(
ownerId,
out uint prefix)
&& _suppressedPrefixes.Contains(prefix);
private static void DisposeEnumerator<T>(
ref IEnumerator<KeyValuePair<uint, T>>? enumerator)
{
enumerator?.Dispose();
enumerator = null;
}
public void Dispose()
{
DisposeEnumerator(ref _cells);
DisposeEnumerator(ref _flatCells);
DisposeEnumerator(ref _flatEnvCells);
DisposeEnumerator(ref _buildings);
DisposeEnumerator(ref _envCells);
DisposeEnumerator(ref _terrain);
DisposeEnumerator(ref _outdoorCells);
}
}
private void InstallLandblockClone(
uint landblockId,
LandblockPhysics landblock)
{
_landblocks[landblockId] = landblock;
EnsureLandblockSlot(landblockId);
}
private void EnsureLandblockSlot(uint landblockId)
{
if (_landblockIndices.ContainsKey(landblockId))
return;
if (_landblockFreeSlots.TryPop(out int freeIndex))
{
_landblockSlots[freeIndex] = landblockId;
_landblockIndices[landblockId] = freeIndex;
return;
}
_landblockIndices[landblockId] = _landblockSlots.Count;
_landblockSlots.Add(landblockId);
}
private void RemoveLandblockSlot(uint landblockId)
{
if (!_landblockIndices.Remove(landblockId, out int slotIndex))
return;
_landblockSlots[slotIndex] = 0u;
_landblockFreeSlots.Push(slotIndex);
}
internal sealed class PreparedPhysicsEngineLandblock
{
internal PreparedPhysicsEngineLandblock(
uint landblockId,
LandblockPhysics landblock,
PhysicsEngine staging,
PreparedPhysicsDataCacheLandblock dataCache,
ShadowObjectRegistry.PreparedLandblockShadowReplacement shadows)
{
LandblockId = landblockId;
Landblock = landblock;
Staging = staging;
DataCache = dataCache;
Shadows = shadows;
}
internal uint LandblockId { get; }
internal LandblockPhysics Landblock { get; }
internal PhysicsEngine Staging { get; }
internal PreparedPhysicsDataCacheLandblock DataCache { get; }
internal ShadowObjectRegistry.PreparedLandblockShadowReplacement Shadows { get; }
}
internal sealed class LandblockReplacementBuilder : IDisposable
{
private readonly uint _landblockId;
private readonly LandblockPhysics _landblock;
private readonly PhysicsEngine _staging;
private readonly PhysicsDataCache.LandblockReplacementBuilder _data;
private readonly ShadowObjectRegistry.LandblockReplacementBuilder _shadows;
private int _phase;
internal LandblockReplacementBuilder(
uint landblockId,
LandblockPhysics landblock,
PhysicsEngine staging,
PhysicsDataCache.LandblockReplacementBuilder data,
ShadowObjectRegistry.LandblockReplacementBuilder shadows)
{
_landblockId = landblockId;
_landblock = landblock;
_staging = staging;
_data = data;
_shadows = shadows;
}
internal int WorkUnits => _data.WorkUnits + _shadows.WorkUnits;
internal PreparedPhysicsEngineLandblock? Prepared { get; private set; }
internal void RefreshRetainedOwner(uint ownerId) =>
_shadows.RefreshOwner(ownerId);
internal bool Advance()
{
if (_phase == 0)
{
if (!_data.Advance())
return false;
_phase++;
return false;
}
if (_phase == 1)
{
if (!_shadows.Advance())
return false;
if (_data.Prepared is not null
&& _shadows.Prepared is not null)
{
Prepared = new PreparedPhysicsEngineLandblock(
_landblockId,
_landblock,
_staging,
_data.Prepared,
_shadows.Prepared);
}
_phase++;
}
return true;
}
public void Dispose()
{
_data.Dispose();
_shadows.Dispose();
}
}
/// <summary>
/// Register a landblock with its terrain surface, indoor cells, portal
/// planes, and world-space origin offset.
/// </summary>
public void AddLandblock(uint landblockId, TerrainSurface terrain,
IReadOnlyList<CellSurface> cells, IReadOnlyList<PortalPlane> portals,
float worldOffsetX, float worldOffsetY)
{
_landblocks[landblockId] = new LandblockPhysics(terrain, cells, portals, worldOffsetX, worldOffsetY);
EnsureLandblockSlot(landblockId);
// UCG Stage 1: mirror terrain into the unified graph (inert this stage).
DataCache?.CellGraph.RegisterTerrain(landblockId, terrain, new Vector3(worldOffsetX, worldOffsetY, 0f));
}
/// <summary>
/// Remove a previously registered landblock, including its shadow objects.
/// </summary>
public void RemoveLandblock(uint landblockId)
{
_landblocks.Remove(landblockId);
RemoveLandblockSlot(landblockId);
ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId);
ShadowObjects.RemoveLandblock(landblockId);
DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply
DataCache?.RemoveBuildingsForLandblock(landblockId);
// #145: if the player's current cell belonged to the landblock being removed (a teleport
// retires the stale source through StreamingOriginRecenterCoordinator and the presentation
// pipeline), clear it. Otherwise CurrCell
// dangles on an orphaned cell and the dungeon-streaming gate — keyed on CurrCell — keeps
// streaming collapsed onto the gone landblock, so the destination never streams in and
// only the skybox renders. Clearing it lets the gate read "not in a dungeon" → the
// controller ExitDungeonExpands to the destination, and CurrCell re-acquires once the
// destination landblock hydrates and the per-frame resolve roots into it.
if (DataCache?.CellGraph is { } cg && cg.CurrCell is { } cur
&& (cur.Id & 0xFFFF0000u) == (landblockId & 0xFFFF0000u))
{
cg.CurrCell = null;
}
// UCG Stage 1: mirror removal into the unified graph (inert this stage).
DataCache?.CellGraph.RemoveLandblock(landblockId);
}
/// <summary>
/// Releases every collision landblock and retained shadow registration
/// owned by this engine. Runtime calls this only at terminal disposal;
/// ordinary streaming still uses the typed per-landblock retirement path.
/// </summary>
public void Clear()
{
if (_landblocks.Count != 0)
{
var landblocks = new uint[_landblocks.Count];
_landblocks.Keys.CopyTo(landblocks, 0);
foreach (uint landblockId in landblocks)
RemoveLandblock(landblockId);
}
// Dynamic live registrations can remain intentionally suspended with
// no cell rows after their last landblock retires. Terminal engine
// ownership must retire those logical registrations as well.
ShadowObjects.Clear();
}
/// <summary>
/// Retire a Near landblock's indoor-cell and static-object collision layer
/// while preserving its terrain surface and world offset for Far-tier use.
/// The corresponding render-side demotion preserves the terrain slot too.
/// </summary>
public void DemoteLandblockToTerrain(uint landblockId)
{
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (_landblocks.TryGetValue(canonical, out var landblock))
{
_landblocks[canonical] = landblock with
{
Cells = Array.Empty<CellSurface>(),
Portals = Array.Empty<PortalPlane>(),
};
}
// Static footprints can flood into adjacent landblocks. Retire them by
// logical owner before removing rows by cell prefix, otherwise a mesh
// demoted out of view can remain as invisible collision across a seam.
ShadowObjects.DeregisterStaticOwnersForLandblock(canonical);
ShadowObjects.RemoveLandblock(canonical);
DataCache?.RemoveCellsForLandblock(canonical);
DataCache?.RemoveBuildingsForLandblock(canonical);
DataCache?.CellGraph.RemoveEnvCellsForLandblock(canonical);
if (DataCache?.CellGraph is { } graph
&& graph.CurrCell is { } current
&& (current.Id & 0xFFFF0000u) == (canonical & 0xFFFF0000u)
&& (current.Id & 0xFFFFu) >= 0x0100u)
{
graph.CurrCell = null;
}
}
/// <summary>
/// Find the landblock that contains the given world-space XY position and
/// return its ID plus world-space origin offsets. Returns false when no
/// registered landblock covers the position.
/// Used by Transition.FindObjCollisions to build the shadow-object query.
/// </summary>
public bool TryGetLandblockContext(float worldX, float worldY,
out uint landblockId, out float worldOffsetX, out float worldOffsetY)
{
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
landblockId = kvp.Key;
worldOffsetX = lb.WorldOffsetX;
worldOffsetY = lb.WorldOffsetY;
return true;
}
}
landblockId = 0;
worldOffsetX = 0f;
worldOffsetY = 0f;
return false;
}
/// <summary>
/// Sample the outdoor terrain Z at the given world-space XY position.
/// Searches all registered landblocks; returns null if no landblock covers the position.
/// Used by Transition.FindEnvCollisions for terrain collision resolution.
/// </summary>
public float? SampleTerrainZ(float worldX, float worldY)
{
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
return lb.Terrain.SampleZ(localX, localY);
}
return null;
}
/// <summary>
/// Sample the per-point water depth at the given world-space XY
/// (meters by which the character is allowed to sink below the
/// contact plane — 0.9 on fully-flooded water cells, 0.45 on
/// partial-water near a water corner, 0.1 on non-water corners of
/// partial-water cells, 0 on dry cells). Matches ACE
/// <c>ObjCell.get_water_depth</c>. Used by
/// <see cref="Transition"/> to visually submerge characters in water
/// without needing a separate water surface mesh.
/// </summary>
public float SampleWaterDepth(float worldX, float worldY)
{
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
return lb.Terrain.SampleWaterDepth(localX, localY);
}
return 0f;
}
/// <summary>
/// Sample the outdoor terrain plane (Z + sloped normal) at the given
/// world-space XY position. The returned <see cref="System.Numerics.Plane"/>
/// has the true terrain-triangle normal (NOT a flat <c>(0,0,1)</c>), and
/// its <c>D</c> is set so the plane passes through the sampled point. Used
/// by <see cref="Transition"/> to build a CORRECT contact plane — a flat
/// plane breaks slope tracking because <c>AdjustOffset</c>'s projection
/// onto a flat plane cannot impart the Z component that horizontal
/// velocity needs to follow the slope.
/// </summary>
public System.Numerics.Plane? SampleTerrainPlane(float worldX, float worldY)
{
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
var (z, normal) = lb.Terrain.SampleSurface(localX, localY);
// System.Numerics.Plane convention: dot(Normal, P) + D == 0
// for points P on the plane. Pick P = (worldX, worldY, z).
float d = -(normal.X * worldX + normal.Y * worldY + normal.Z * z);
return new System.Numerics.Plane(normal, d);
}
}
return null;
}
/// <summary>
/// Public surface for callers that only need the local terrain plane
/// normal at a world-space XY (e.g., the grounded-remote tick path
/// projecting anim root motion onto the slope to avoid the staircase
/// between server position updates). Returns null when no registered
/// landblock covers the point. Mirrors the plane component of
/// <see cref="SampleTerrainWalkable"/> without exposing the internal
/// <c>TerrainWalkableSample</c> shape.
/// </summary>
public Vector3? SampleTerrainNormal(float worldX, float worldY)
{
var sample = SampleTerrainWalkable(worldX, worldY);
return sample?.Plane.Normal;
}
/// <summary>
/// Sample the outdoor terrain walkable triangle at the given world-space
/// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/>
/// plus world-space triangle vertices for retail precipice-slide.
/// </summary>
internal TerrainWalkableSample? SampleTerrainWalkable(float worldX, float worldY)
{
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
return BuildTerrainWalkableSample(kvp.Key, lb, localX, localY);
}
return null;
}
/// <summary>
/// Samples only the fixed outdoor cell supplied to retail
/// <c>CTransition::insert_into_cell</c>. The target point may move into a
/// neighboring cell during a retry, but retail continues dispatching the
/// captured <c>CObjCell*</c> until that inner call returns.
/// </summary>
internal TerrainWalkableSample? SampleTerrainWalkableInCell(
uint cellId,
float worldX,
float worldY)
{
uint lowCellId = cellId & 0xFFFFu;
if (lowCellId is < 1u or > 0x40u)
return null;
foreach (var kvp in _landblocks)
{
uint requestedPrefix = cellId & 0xFFFF0000u;
if (requestedPrefix != 0u &&
(kvp.Key & 0xFFFF0000u) != requestedPrefix)
continue;
LandblockPhysics lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (requestedPrefix == 0u &&
(localX < 0f || localX >= 192f ||
localY < 0f || localY >= 192f))
{
continue;
}
int cellIndex = (int)lowCellId - 1;
int cellX = cellIndex / TerrainSurface.CellsPerSide;
int cellY = cellIndex % TerrainSurface.CellsPerSide;
float minX = cellX * TerrainSurface.CellSize;
float minY = cellY * TerrainSurface.CellSize;
float maxX = minX + TerrainSurface.CellSize;
float maxY = minY + TerrainSurface.CellSize;
if (localX < minX || localX >= maxX ||
localY < minY || localY >= maxY)
{
return null;
}
return BuildTerrainWalkableSample(
kvp.Key,
lb,
localX,
localY);
}
return null;
}
private static TerrainWalkableSample BuildTerrainWalkableSample(
uint landblockId,
LandblockPhysics landblock,
float localX,
float localY)
{
TerrainSurfacePolygon sample = landblock.Terrain.SampleSurfacePolygon(
localX,
localY);
var vertices = new TerrainTriangleVertices(
OffsetTerrainVertex(sample.Vertices.V0, landblock),
OffsetTerrainVertex(sample.Vertices.V1, landblock),
OffsetTerrainVertex(sample.Vertices.V2, landblock));
Vector3 normal = sample.Normal;
float d = -Vector3.Dot(normal, vertices[0]);
var plane = new System.Numerics.Plane(normal, d);
float waterDepth = landblock.Terrain.SampleWaterDepth(localX, localY);
bool isWater = waterDepth >= 0.45f;
uint lowCellId = landblock.Terrain.ComputeOutdoorCellId(localX, localY);
uint fullCellId = (landblockId & 0xFFFF0000u) | lowCellId;
return new TerrainWalkableSample(
plane,
vertices,
waterDepth,
isWater,
fullCellId);
}
private static Vector3 OffsetTerrainVertex(Vector3 vertex, LandblockPhysics landblock)
=> new(
vertex.X + landblock.WorldOffsetX,
vertex.Y + landblock.WorldOffsetY,
vertex.Z);
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a
/// given world position via retail's portal-graph traversal for indoor
/// cells, or via terrain grid lookup for outdoor cells.
///
/// <para>
/// Indoor seed: delegates to <see cref="CellTransit.FindCellList"/> which
/// BFS-walks the portal graph and uses <see cref="BSPQuery.PointInsideCellBsp"/>
/// for containment. This replaces Phase D's AABB shortcut.
/// </para>
///
/// <para>
/// Outdoor seed: uses the registered landblock terrain grid to compute
/// the correct prefixed cell ID, preserving the pre-existing outdoor
/// resolution behavior (the L.2e prefix-preservation fix).
/// </para>
///
/// <para>
/// Design: <c>docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md</c>
/// </para>
/// </summary>
/// <summary>
/// Set the render root cell — <see cref="World.Cells.CellGraph.CurrCell"/>, which IS
/// "the PLAYER's cell" (CellGraph.cs:19) and roots the indoor render
/// (GameWindow.OnRender). Call ONLY for the local player, from
/// <c>PlayerMovementController.UpdateCellId</c> — the single player chokepoint for CellId
/// (teleport / server snap / per-frame resolver).
///
/// <para>
/// 2026-06-03: this write was previously inside the per-entity <see cref="ResolveWithTransition"/>
/// (every NPC / remote calls that). A Holtburg NPC jump-looping near the cottage doorway
/// clobbered the player's render root every tick → the render rooted at the NPC's tiny
/// connector cell (0170) instead of the player's room (0171) → only that cell's ~8-triangle
/// shell drew, the rest showing the GL clear color = the cottage doorway "blue-hole" flap.
/// Moving the write to the player-only chokepoint fixes it: NPCs no longer touch CurrCell.
/// </para>
///
/// <para>Leaves CurrCell unchanged when the id isn't resolvable in the graph yet
/// (stale beats null), matching the prior behavior. Retail anchor:
/// CObjCell::change_cell sets the object's curr_cell; only the player's drives the viewer.</para>
/// </summary>
public void UpdatePlayerCurrCell(uint cellId)
{
if (DataCache?.CellGraph is { } cg && cg.GetVisible(cellId) is { } cell)
cg.CurrCell = cell;
}
/// <summary>
/// TEST-ONLY outdoor cell re-derive. The sole caller is
/// <c>Transition.RunCheckOtherCellsAndAdvance</c>'s cache-null fallback
/// (PhysicsEngineTests run engines without a <see cref="DataCache"/>,
/// so <see cref="CellTransit.FindCellSet"/> is unavailable). Normal
/// production membership flows exclusively through the collide-then-pick advance
/// (<c>RunCheckOtherCellsAndAdvance</c> → <c>FindCellSet</c>).
///
/// <para>
/// BR-7 / A6.P4 C4 (2026-06-11): the former indoor branch — including
/// the #90 sphere-overlap stickiness workaround (4ca3596) and the
/// building-transit promotion — was DEAD CODE on this path (it required
/// a non-null DataCache; the only caller guarantees null) and is
/// removed. #90's doorway ping-pong concern is owned by the retail
/// ordered-pick hysteresis (current cell at array index 0,
/// interior-wins-break; CellTransit.BuildCellSetAndPickContaining) —
/// the workaround is retired, closing the digest's deferred-removal
/// item.
/// </para>
///
/// <para>Preserves the L.2e prefix-preservation fix (always apply the
/// matched landblock's high-16 prefix even when
/// <paramref name="fallbackCellId"/> arrived bare-low-byte).</para>
/// </summary>
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
{
if (fallbackCellId == 0) return 0;
// Indoor fallback ids pass through unchanged — identical to the old
// dead path's `DataCache is null → return fallbackCellId` outcome.
if ((fallbackCellId & 0xFFFFu) >= 0x0100u) return fallbackCellId;
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldPos.X - lb.WorldOffsetX;
float localY = worldPos.Y - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
return (kvp.Key & 0xFFFF0000u) | lowCellId;
}
}
return fallbackCellId;
}
/// <summary>
/// Verbatim port of <c>CPhysicsObj::AdjustPosition</c>
/// (<c>acclient_2013_pseudo_c.txt:280009</c>): resolve which cell actually
/// contains <paramref name="worldPoint"/>, given a seed cell. Indoor
/// (<c>objcell_id ≥ 0x100</c>, :280020) → <see cref="CellTransit.FindVisibleChildCell"/>
/// in stab-list mode (retail <c>arg5 = 1</c>, :280028); outdoor (:280050) →
/// snap to the landcell under the point (retail <c>LandDefs::adjust_to_outside</c>,
/// the same grid lookup <see cref="ResolveCellId"/> uses). Returns
/// <c>found = false</c> with the seed id unchanged when no cell resolves
/// (retail <c>return 0</c>, :280065).
///
/// <para>
/// <c>SmartBox::update_viewer</c> calls this to seat the camera sweep's start
/// cell at the head-pivot (:280032, indoor branch only) and again as fallback 1
/// at the sought eye (:280078). The player snap path
/// (<c>SetPositionInternal</c> :283908 → our <see cref="Resolve"/>) calls it to
/// validate the server-restored (cell, position) pair before any physics runs —
/// the #107 indoor-login wedge was this validation missing: a poisoned save
/// (cell id from one building, position inside another) was trusted verbatim,
/// the player stood fake-grounded with no walkable floor, and the first movement
/// demoted them outdoor mid-building → 2.4 m fall under the cottage floor.
/// </para>
/// <para>
/// #107 (2026-06-10) completed the previously-deferred indoor
/// <c>seen_outside → adjust_to_outside</c> sub-fallback (:280037-280046): when
/// the claimed cell is hydrated, nothing in its visible graph contains the
/// point, and the cell has outdoor-visible portals, retail demotes to the
/// landcell under the point. The corner-seal replay (`b21bb28`) shows camera
/// eyes always land inside cells/openings, so the camera path does not reach
/// this sub-branch in the gated scenarios (CameraCornerSealReplayTests stays
/// green).
/// </para>
/// </summary>
private readonly record struct AdjustedSetPosition(
uint CellId,
Vector3 CellLocalPosition,
bool Resident);
/// <summary>
/// SetPosition's exact AdjustPosition input/output shape. Unlike the
/// camera helper above, this carries retail's block-local frame and can
/// therefore run outdoor <c>LandDefs::adjust_to_outside</c> without
/// consulting the resident-landblock registry. A valid adjusted id with
/// no visible cell is retained for the lost-cell path.
/// </summary>
private AdjustedSetPosition AdjustSetPosition(
uint seedCellId,
Vector3 cellLocalPosition,
Vector3 firstWorldSphereCenter)
{
uint low = seedCellId & 0xFFFFu;
bool lowInRange = low is (>= 1u and <= 0x40u)
or (>= 0x0100u and <= 0xFFFDu)
or 0xFFFFu;
if (!lowInRange)
return new AdjustedSetPosition(
seedCellId,
cellLocalPosition,
Resident: false);
uint adjustedCell = seedCellId;
Vector3 adjustedLocal = cellLocalPosition;
if (low >= 0x0100u)
{
PhysicsDataCache? cache = DataCache;
if (cache is null || cache.GetCellStruct(seedCellId) is null)
{
return new AdjustedSetPosition(
seedCellId,
cellLocalPosition,
Resident: false);
}
uint child = CellTransit.FindVisibleChildCell(
cache,
seedCellId,
firstWorldSphereCenter,
useStabList: true);
if (child != 0u)
{
return new AdjustedSetPosition(
child,
adjustedLocal,
Resident: cache.GetCellStruct(child) is not null);
}
CellPhysics? claimed = cache.GetCellStruct(seedCellId);
if (claimed is null || !claimed.SeenOutside)
{
return new AdjustedSetPosition(
seedCellId,
adjustedLocal,
Resident: false);
}
}
// Outdoor adjustment is pure cell-relative LandDefs math. Residency
// is observed only after the id/frame have been mutated.
bool adjusted = LandDefs.AdjustToOutside(
ref adjustedCell,
ref adjustedLocal);
bool resident = adjusted
&& IsLandblockTerrainResident(adjustedCell);
return new AdjustedSetPosition(
adjustedCell,
adjustedLocal,
resident);
}
/// Canonical retail placement transaction:
/// <c>CPhysicsObj::SetPosition</c> (0x005160C0) -&gt;
/// <c>SetPositionInternal</c> (0x00515BD0) -&gt;
/// <c>AdjustPosition</c> (0x00511D80) -&gt;
/// <c>CheckPositionInternal</c> (0x00511E90) -&gt;
/// <c>CTransition::find_valid_position</c> (0x0050C310).
/// A destination whose cell is not resident returns DeferredCell; callers
/// must retain the authoritative frame rather than demoting it outdoors.
/// </summary>
internal PhysicsSetPositionResult SetPosition(
in PhysicsSetPositionRequest request,
Func<PhysicsSetPositionCollisionReport, bool>? handleCollisions = null)
{
if (_transitionScratch?.ActiveDepth >= TransitionScratchArena.Capacity)
{
return ErrorResult(
request,
PhysicsSetPositionError.GeneralFailure);
}
Transition transition = RentTransition();
try
{
InitializeSetPositionTransition(transition, request);
bool randomOnly = request.Flags.HasFlag(
PhysicsSetPositionFlags.RandomScatter);
if (randomOnly)
{
return SetScatterPositionInternal(
transition,
request,
handleCollisions);
}
PhysicsSetPositionResult result =
SetPositionInternal(transition, request, handleCollisions);
if (result.Error != PhysicsSetPositionError.Ok
&& request.Flags.HasFlag(PhysicsSetPositionFlags.Scatter))
{
return SetScatterPositionInternal(
transition,
request,
handleCollisions);
}
return result;
}
finally
{
ReturnTransition(transition);
}
}
private void InitializeSetPositionTransition(
Transition transition,
in PhysicsSetPositionRequest request)
{
transition.ObjectInfo.StepUpHeight = request.StepUpHeight;
transition.ObjectInfo.StepDownHeight = request.StepDownHeight;
transition.ObjectInfo.StepDown =
!request.MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile);
transition.ObjectInfo.MoverPhysicsState = request.MoverPhysicsState;
transition.ObjectInfo.SelfEntityId = request.MovingEntityId;
transition.ObjectInfo.State = request.MoverFlags;
transition.ObjectInfo.Ethereal = request.MoverPhysicsState.HasFlag(
PhysicsStateFlags.Ethereal);
transition.SpherePath.PlacementAllowsSliding =
request.Flags.HasFlag(PhysicsSetPositionFlags.Slide);
}
private PhysicsSetPositionResult SetScatterPositionInternal(
Transition transition,
in PhysicsSetPositionRequest request,
Func<PhysicsSetPositionCollisionReport, bool>? handleCollisions)
{
PhysicsSetPositionResult result = ErrorResult(
request,
PhysicsSetPositionError.GeneralFailure);
for (uint attempt = 0u; attempt < request.ScatterAttempts; attempt++)
{
float dx = ((float)((SetPositionRandomUnit() * 2d) - 1d))
* request.ScatterRadiusX;
float dy = ((float)((SetPositionRandomUnit() * 2d) - 1d))
* request.ScatterRadiusY;
var scattered = request with
{
Position = request.Position + new Vector3(dx, dy, 0f),
CellLocalPosition = request.CellLocalPosition
+ new Vector3(dx, dy, 0f),
};
result = SetPositionInternal(
transition,
scattered,
handleCollisions);
if (result.Error == PhysicsSetPositionError.Ok)
break;
}
return result;
}
private PhysicsSetPositionResult SetPositionInternal(
Transition transition,
in PhysicsSetPositionRequest request,
Func<PhysicsSetPositionCollisionReport, bool>? handleCollisions)
{
transition.SpherePath.CellCandidates.Clear();
transition.SpherePath.ClearWalkable();
ImmutableArray<FlatCollisionSphere> spheres = request.Spheres;
float sphereScale = spheres.IsDefaultOrEmpty ? 1f : request.Scale;
Vector3 firstLocalCenter = spheres.IsDefaultOrEmpty
? new Vector3(0f, 0f, PhysicsGlobals.DummySphereRadius)
: spheres[0].Origin * sphereScale;
Vector3 firstWorldCenter =
Vector3.Transform(firstLocalCenter, request.Orientation)
+ request.Position;
AdjustedSetPosition adjusted = AdjustSetPosition(
request.CellId,
request.CellLocalPosition,
firstWorldCenter);
if (!adjusted.Resident)
{
return new PhysicsSetPositionResult(
PhysicsSetPositionError.Ok,
PhysicsResidenceDisposition.DeferredCell,
request.Position,
request.Orientation,
adjusted.CellId,
adjusted.CellLocalPosition,
CrossCellIds: ImmutableArray<uint>.Empty,
CollidedObjectIds: ImmutableArray<uint>.Empty);
}
bool forceIntoCell = request.PlacementClass is
PhysicsPlacementClass.Hook
or PhysicsPlacementClass.Storage
or PhysicsPlacementClass.Corpse;
if (forceIntoCell)
{
if (adjusted.CellId == 0u)
{
return ErrorResult(
request,
PhysicsSetPositionError.NoCell);
}
bool changedCell = request.CurrentCellId is null
|| request.CurrentCellId.Value != adjusted.CellId;
return new PhysicsSetPositionResult(
PhysicsSetPositionError.Ok,
PhysicsResidenceDisposition.Committed,
request.Position,
request.Orientation,
adjusted.CellId,
adjusted.CellLocalPosition,
CellChanged: changedCell,
ShadowAction: changedCell
? PhysicsShadowCommitAction.Recalculate
: PhysicsShadowCommitAction.None,
CrossCellIds: ImmutableArray<uint>.Empty,
CollidedObjectIds: ImmutableArray<uint>.Empty);
}
transition.SpherePath.InitPath(
request.Position,
request.Position,
adjusted.CellId,
spheres,
sphereScale,
request.Orientation,
request.Orientation);
transition.SpherePath.InsertType = InsertType.Placement;
transition.SpherePath.PlacementAllowsSliding =
request.Flags.HasFlag(PhysicsSetPositionFlags.Slide);
bool valid = transition.FindValidPosition(this);
SpherePath spherePath = transition.SpherePath;
if (valid
&& !request.Flags.HasFlag(PhysicsSetPositionFlags.Slide))
{
valid = AcceptNoSlidePlacement(
spherePath.CurPos,
request.Position,
spherePath.CurCellId,
adjusted.CellId);
}
CollisionInfo collision = transition.CollisionInfo;
var collisionReport = new PhysicsSetPositionCollisionReport(
collision.ContactPlaneValid,
collision.ContactPlane,
collision.ContactPlaneCellId,
collision.ContactPlaneIsWater,
collision.LastKnownContactPlaneValid,
collision.LastKnownContactPlane,
collision.LastKnownContactPlaneCellId,
collision.LastKnownContactPlaneIsWater,
collision.SlidingNormalValid,
collision.SlidingNormal,
collision.CollisionNormalValid,
collision.CollisionNormal,
collision.CollidedWithEnvironment,
collision.FramesStationaryFall,
collision.AdjustOffset,
collision.LastCollidedObjectGuid,
collision.CollideObjectGuids.ToImmutableArray());
bool collisionHandlerResult = !valid
&& handleCollisions?.Invoke(collisionReport) == true;
if (!valid)
{
return new PhysicsSetPositionResult(
collisionHandlerResult
? PhysicsSetPositionError.Collided
: PhysicsSetPositionError.NoValidPosition,
PhysicsResidenceDisposition.Unchanged,
request.Position,
request.Orientation,
request.CellId,
request.CellLocalPosition,
InContact: collision.ContactPlaneValid,
OnWalkable: PhysicsObjUpdate.IsWalkableContact(
collision.ContactPlaneValid,
collision.ContactPlane.Normal),
ContactPlane: collision.ContactPlane,
ContactPlaneCellId: collision.ContactPlaneCellId,
ContactPlaneIsWater: collision.ContactPlaneIsWater,
SlidingNormalValid: collision.SlidingNormalValid,
SlidingNormal: collision.SlidingNormal,
CollisionNormalValid: collision.CollisionNormalValid,
CollisionNormal: collision.CollisionNormal,
FramesStationaryFall: collision.FramesStationaryFall,
CollisionHandlerResult: collisionHandlerResult,
CollidedWithEnvironment: collision.CollidedWithEnvironment,
CrossCellIds: ImmutableArray<uint>.Empty,
CollidedObjectIds: collisionReport.CollidedObjectIds);
}
if (spherePath.CurCellId == 0u)
{
return ErrorResult(request, PhysicsSetPositionError.NoCell);
}
bool inContact = collision.ContactPlaneValid;
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact,
collision.ContactPlane.Normal);
Vector3 resultLocal = adjusted.CellLocalPosition
+ (spherePath.CurPos - request.Position)
- LandDefs.GetBlockOffset(adjusted.CellId, spherePath.CurCellId);
bool hasPhysicsBsp = request.MoverPhysicsState.HasFlag(
PhysicsStateFlags.HasPhysicsBsp);
ImmutableArray<uint> transitionCells =
spherePath.CellCandidates.OrderedIds.ToImmutableArray();
PhysicsShadowCommitAction shadowAction = hasPhysicsBsp
? PhysicsShadowCommitAction.Recalculate
: transitionCells.Length != 0
? PhysicsShadowCommitAction.Replace
: PhysicsShadowCommitAction.Preserve;
return new PhysicsSetPositionResult(
PhysicsSetPositionError.Ok,
PhysicsResidenceDisposition.Committed,
spherePath.CurPos,
// CheckPositionInternal mutates only origin in no-slide mode;
// SetPosition retains the requested frame orientation.
request.Orientation,
spherePath.CurCellId,
resultLocal,
inContact,
onWalkable,
collision.ContactPlane,
collision.ContactPlaneCellId,
collision.ContactPlaneIsWater,
collision.SlidingNormalValid,
collision.SlidingNormal,
collision.CollisionNormalValid,
collision.CollisionNormal,
collision.FramesStationaryFall,
collision.CollidedWithEnvironment,
collisionHandlerResult,
CellChanged: request.CurrentCellId is null
|| request.CurrentCellId.Value != spherePath.CurCellId,
ShadowAction: shadowAction,
CrossCellIds: shadowAction == PhysicsShadowCommitAction.Replace
? transitionCells
: ImmutableArray<uint>.Empty,
CollidedObjectIds:
collision.CollideObjectGuids.ToImmutableArray());
}
private static PhysicsSetPositionResult ErrorResult(
in PhysicsSetPositionRequest request,
PhysicsSetPositionError error) => new(
error,
PhysicsResidenceDisposition.Unchanged,
request.Position,
request.Orientation,
request.CellId,
request.CellLocalPosition,
CrossCellIds: ImmutableArray<uint>.Empty,
CollidedObjectIds: ImmutableArray<uint>.Empty);
internal static bool AcceptNoSlidePlacement(
Vector3 resolvedPosition,
Vector3 requestedPosition,
uint resolvedCellId,
uint adjustedCellId)
{
Vector3 displacement = resolvedPosition - requestedPosition;
return displacement.X <= 0.0500000007f
&& displacement.Y <= 0.0500000007f
&& resolvedCellId == adjustedCellId;
}
/// <summary>
/// #111: the walkable floor Z of <paramref name="cellId"/>'s PHYSICS
/// polygons under the world XY, nearest to <paramref name="referenceZ"/>.
/// Walkable = plane normal.Z ≥ <see cref="PhysicsGlobals.FloorZ"/> (retail
/// BSPTREE::find_walkable's filter) — ceilings/roof tops never qualify,
/// unlike the <see cref="CellSurface"/> triangle soup. Resolved polygons
/// are CELL-LOCAL: transform in, drop on the plane, transform out.
/// Returns null when the claim has no hydrated struct or no walkable
/// under the XY.
/// </summary>
private float? WalkableFloorZNearest(uint cellId, Vector3 worldPos, float referenceZ)
{
var cp = DataCache?.GetCellStruct(cellId);
if (cp is null) return null;
var local = Vector3.Transform(
new Vector3(worldPos.X, worldPos.Y, referenceZ), cp.InverseWorldTransform);
float? best = null;
float bestDist = float.MaxValue;
FlatPhysicsBsp? flat = cp.FlatPhysicsBsp;
if (flat is not null)
{
FlatPolygonTable table = flat.PolygonTable;
for (int i = 0; i < table.Polygons.Length; i++)
{
FlatCollisionPolygon poly = table.Polygons[i];
Vector3 n = poly.Plane.Normal;
if (n.Z < PhysicsGlobals.FloorZ) continue;
if (!PointInPolygonXY(table, poly.VertexRange, local.X, local.Y))
continue;
float lz =
-(n.X * local.X + n.Y * local.Y + poly.Plane.D) / n.Z;
float wz = Vector3.Transform(
new Vector3(local.X, local.Y, lz),
cp.WorldTransform).Z;
float dist = MathF.Abs(wz - referenceZ);
if (dist < bestDist)
{
bestDist = dist;
best = wz;
}
}
return best;
}
if (DataCache!.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
throw new InvalidOperationException(
$"Production CellStruct 0x{cellId:X8} has no prepared physics BSP.");
}
// Explicit graph-oracle fixture path. Production returns above.
foreach (var kv in cp.Resolved)
{
var poly = kv.Value;
var n = poly.Plane.Normal;
if (n.Z < PhysicsGlobals.FloorZ) continue;
if (!PointInPolygonXY(poly.Vertices, local.X, local.Y)) continue;
// plane: n·p + d = 0 => z = -(n.x*x + n.y*y + d)/n.z
float lz = -(n.X * local.X + n.Y * local.Y + poly.Plane.D) / n.Z;
float wz = Vector3.Transform(new Vector3(local.X, local.Y, lz), cp.WorldTransform).Z;
float dist = MathF.Abs(wz - referenceZ);
if (dist < bestDist) { bestDist = dist; best = wz; }
}
return best;
}
private static bool PointInPolygonXY(
FlatPolygonTable table,
FlatIndexRange range,
float x,
float y)
{
bool inside = false;
int end = range.EndExclusive;
for (int i = range.Start, j = end - 1; i < end; j = i++)
{
Vector3 vi = table.Vertices[i];
Vector3 vj = table.Vertices[j];
if ((vi.Y > y) != (vj.Y > y)
&& x < (vj.X - vi.X) * (y - vi.Y) /
(vj.Y - vi.Y) + vi.X)
{
inside = !inside;
}
}
return inside;
}
/// <summary>Even-odd XY-projection point-in-polygon test (cell-local frame).</summary>
private static bool PointInPolygonXY(IReadOnlyList<Vector3> verts, float x, float y)
{
bool inside = false;
for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
{
var vi = verts[i]; var vj = verts[j];
if ((vi.Y > y) != (vj.Y > y)
&& x < (vj.X - vi.X) * (y - vi.Y) / (vj.Y - vi.Y) + vi.X)
inside = !inside;
}
return inside;
}
/// <summary>
/// #107: does any loaded landblock carry a <see cref="CellSurface"/> for
/// this cell id? Distinguishes "partially hydrated" (floor data present,
/// struct pending — the legacy floor-snap can ground the claim) from
/// "completely unknown" (the Resolve safety net demotes loudly).
/// </summary>
private bool HasCellSurface(uint cellId)
{
// Masked low-word compare (house norm in this file): production
// CellSurfaces carry full prefixed ids (GameWindow.cs:5923), test
// fixtures bare low words. A zero-prefix (bare, pre-#106 convention)
// claim matches any loaded landblock by low word — the legacy Resolve
// body below treats bare claims the same way.
uint low = cellId & 0xFFFFu;
uint prefix = cellId & 0xFFFF0000u;
foreach (var kvp in _landblocks)
{
if (prefix != 0u && (kvp.Key & 0xFFFF0000u) != prefix) continue;
foreach (var cell in kvp.Value.Cells)
if ((cell.CellId & 0xFFFFu) == low) return true;
}
return false;
}
/// <summary>
/// #107 auto-entry hold (gate-2 extension, 2026-06-10): true when the
/// server-claimed spawn cell is ready for <see cref="AdjustPosition"/> to
/// act on. Outdoor claims need only terrain (the existing gate). Indoor
/// claims wait until the claimed cell's struct is hydrated — the async-
/// streaming equivalent of retail's synchronous cell load before
/// SetPosition.
///
/// <para>
/// ⚠️ The first version disambiguated "claim bogus" via "any cell struct
/// in the landblock present" — WRONG: interiors hydrate in id order on the
/// background worker, so the render-thread predicate can observe the
/// mid-population state (early cells present, the claim not yet) and open
/// the gate before AdjustPosition's stab search can act (the 2026-06-10
/// gate-run regression: claim 0xA9B40172 committed raw → outdoor demote on
/// first movement → transparent interior). Claims that can NEVER hydrate
/// (id outside the landblock's NumCells range) are now filtered by the
/// caller against the dat, and <see cref="Resolve"/> carries a loud
/// outdoor-demote safety net for any unhydrated indoor claim that still
/// gets through.
/// </para>
/// </summary>
public bool IsSpawnCellReady(uint cellId)
{
if ((cellId & 0xFFFFu) < 0x0100u) return true;
return DataCache?.GetCellStruct(cellId) is not null;
}
public (uint cellId, bool found) AdjustPosition(uint seedCellId, Vector3 worldPoint)
{
if (seedCellId == 0u) return (seedCellId, false);
if ((seedCellId & 0xFFFFu) >= 0x0100u)
{
// Indoor: find_visible_child_cell(this, point, arg3 = 1) (:280028).
if (DataCache is null) return (seedCellId, false);
uint child = CellTransit.FindVisibleChildCell(DataCache, seedCellId, worldPoint, useStabList: true);
if (child != 0u) return (child, true);
// Retail :280037-280046: claimed cell hydrated + seen_outside →
// Position::adjust_to_outside (fall through to the grid snap below).
// A non-hydrated or not-seen-outside claim stays (seed, false) —
// retail's lost-cell path; our callers keep their legacy fallback.
var claimed = DataCache.GetCellStruct(seedCellId);
if (claimed is null || !claimed.SeenOutside)
return (seedCellId, false);
}
// Outdoor: LandDefs::adjust_to_outside — snap to the landcell under the
// point (same grid lookup as ResolveCellId, lines 363-371). No building
// re-entry here: AdjustPosition's outdoor branch is the bare landcell snap.
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldPoint.X - lb.WorldOffsetX;
float localY = worldPoint.Y - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
return ((kvp.Key & 0xFFFF0000u) | lowCellId, true);
}
}
return (seedCellId, false);
}
/// <summary>
/// Resolve an entity's movement from <paramref name="currentPos"/> by
/// applying <paramref name="delta"/> (XY only) and computing the correct Z
/// from the terrain or indoor cell floor beneath the candidate position.
///
/// <para>
/// Step-height enforcement rejects horizontal movement when the upward Z
/// change exceeds <paramref name="stepUpHeight"/>. Downhill movement is
/// always accepted. Returns <see cref="ResolveResult.IsOnGround"/> false
/// when no loaded landblock covers the candidate position.
/// </para>
/// </summary>
public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight)
{
// #107 (2026-06-10): retail CPhysicsObj::SetPositionInternal (:283892)
// step 1 — AdjustPosition (:283908) validates/corrects the claimed cell
// from the position BEFORE any physics runs. This legacy Resolve is the
// player snap path (login entry + teleport arrival — the SetPosition
// shaped calls); both hand it a server-restored (cell, position) pair
// that can be poisoned (the #107 capture: cell id from one building,
// position inside another, 55 m apart). Retail validates at the foot-
// sphere CENTER (localtoglobal of sphere_path.local_sphere, :283903);
// the player's foot sphere is radius 0.48 m centred 0.48 m above the
// feet (PlayerMovementController body — capture input.sphereRadius).
const float FootSphereCenterLift = 0.48f;
var (adjustedCellId, adjustedFound) = AdjustPosition(
cellId, currentPos + new Vector3(0f, 0f, FootSphereCenterLift));
if (adjustedFound && adjustedCellId != cellId)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[spawn-adjust] claimed cell 0x{cellId:X8} does not contain ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — corrected to 0x{adjustedCellId:X8} (retail AdjustPosition :280009)"));
cellId = adjustedCellId;
}
else if (!adjustedFound
&& (cellId & 0xFFFFu) >= 0x0100u
&& DataCache?.GetCellStruct(cellId) is null
&& !HasCellSurface(cellId))
{
// #107 safety net (2026-06-10 gate-run regression): an indoor claim
// the engine knows NOTHING about (no cell struct AND no CellSurface
// floor data) cannot be validated or grounded — committing it raw
// reproduces the fake-grounded wedge. Retail goes lost-cell here
// (GotoLostCell, :283418); our recoverable equivalent is the
// outdoor landcell under the point (documented divergence — we have
// no lost-cell machinery). When only the struct is missing but the
// CellSurface floor exists (partial hydration), the legacy indoor
// floor-snap below handles the claim — don't demote. The auto-entry
// hold should make this unreachable in practice; if the line fires,
// the hold has a gap.
var (outdoorCellId, outdoorFound) = AdjustPosition(
(cellId & 0xFFFF0000u) | 0x0001u,
currentPos + new Vector3(0f, 0f, FootSphereCenterLift));
if (outdoorFound)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[spawn-adjust] UNHYDRATED indoor claim 0x{cellId:X8} at ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — demoted to outdoor 0x{outdoorCellId:X8} (lost-cell equivalent)"));
cellId = outdoorCellId;
}
}
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
// #111 apparatus: one [snap] line per Resolve call (entry + teleport
// arrival only — low volume, permanent). The gate-3/4/5 runs committed
// ACE's restored pair VERBATIM through this method while every read
// path should have changed Z or cell — this line answers which branch
// actually ran. Remove or demote to env-gate once #111 closes.
bool snapDiag = (delta.X == 0f && delta.Y == 0f);
// Find the landblock this candidate position falls in.
// #106 follow-up (2026-06-09): capture its high-16 prefix — every
// computed cell id below is returned FULL (lbPrefix | low). The old
// bare-low-word returns wedged the membership chain whenever a caller
// committed them (the teleport-arrival snap wrote 0x0000013F: an
// unresolvable indoor id → no wall BSP, #98 gate reads "indoor
// primary" and kills the outdoor object sweep → no collision at all).
LandblockPhysics? physics = null;
uint lbPrefix = 0u;
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = candidatePos.X - lb.WorldOffsetX;
float localY = candidatePos.Y - lb.WorldOffsetY;
if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f)
{
physics = lb;
lbPrefix = kvp.Key & 0xFFFF0000u;
break;
}
}
if (physics is null)
{
if (snapDiag && DiagnosticLog is { } noLandblockLog)
noLandblockLog(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) branch=NO-LANDBLOCK (lbs={_landblocks.Count}) -> verbatim"));
return new ResolveResult(candidatePos, cellId, IsOnGround: false);
}
float localCandX = candidatePos.X - physics.WorldOffsetX;
float localCandY = candidatePos.Y - physics.WorldOffsetY;
// #111 (2026-06-10): a VALIDATED indoor claim is AUTHORITATIVE for the
// cell — retail SetPositionInternal commits the AdjustPosition cell and
// only settles Z (CheckPositionInternal → find_valid_position, :283426);
// it never re-picks the cell from floor geometry. The legacy bestCell
// floor-pick below scans EVERY CellSurface in the landblock (123 at
// Holtburg) and breaks same-height ties by iteration order — on a live
// login it clobbered ACE's clean, validated claim 0xA9B40171 with
// 0xA9B4013F (issue111-snap1.log), putting the player in a wrong cell
// → outdoor demote on first movement → transparent interior (#111).
// Snap shape only (zero delta): ground Z onto the validated claim's own
// floor when it has one under this XY; cells without their own floor
// surface here (thresholds, stair lips) fall through to the legacy path.
if (snapDiag && adjustedFound && (cellId & 0xFFFFu) >= 0x0100u)
{
// Ground via the claim's PHYSICS WALKABLE polygons (normal.Z ≥
// PhysicsGlobals.FloorZ), NOT the CellSurface triangle soup — the
// soup includes ceiling/roof TOP faces whose first-hit (99.475
// over 0x171's 94.0 floor, issue111-verify2.log) and even
// nearest-to-reference (the poisoned reference SAT on the ceiling
// face, issue111-verify3.log) selections both land on non-floors.
// The walkable set contains only real floors (retail
// BSPTREE::find_walkable's polygon filter).
float? claimFloorZ = WalkableFloorZNearest(cellId, candidatePos, currentPos.Z);
if (claimFloorZ is not null)
{
if (DiagnosticLog is { } validatedLog)
{
validatedLog(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}"));
}
// #133 (2026-06-13): return the VALIDATED claim's OWN full cell id,
// NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning
// resident landblocks for one whose [0,192) local bounds contain
// the candidate XY — but a dungeon EnvCell's local Y can be NEGATIVE
// (server teleport to 0x00070143 at local (70,-60,0.01)). The dungeon
// landblock fails the localY>=0 bounds test, so the loop matches a
// neighbouring still-resident block (e.g. Holtburg 0xA9B3), re-stamping
// the validated claim 0x00070143 -> 0xA9B30143. The client then
// mis-resolves the player into the wrong landblock and spams ACE with
// rejected moves. The validated claim's prefix is AUTHORITATIVE; a
// position falling in a neighbouring resident landblock must not
// re-stamp it. Byte-identical for the login case (the position lies in
// the claim's own landblock, so lbPrefix == cellId & 0xFFFF0000);
// diverges only — and correctly — in the far-teleport dungeon case.
return new ResolveResult(
new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value),
cellId,
IsOnGround: true);
}
}
// Check if the candidate position falls on any indoor cell floor.
// Pick the cell whose floor Z is closest to the entity's current Z.
CellSurface? bestCell = null;
float? bestCellZ = null;
float bestZDist = float.MaxValue;
foreach (var cell in physics.Cells)
{
float? floorZ = cell.SampleFloorZ(candidatePos.X, candidatePos.Y);
if (floorZ is not null)
{
float dist = MathF.Abs(floorZ.Value - currentPos.Z);
if (dist < bestZDist)
{
bestCell = cell;
bestCellZ = floorZ;
bestZDist = dist;
}
}
}
// Determine target surface Z and cell.
float terrainZ = physics.Terrain.SampleZ(localCandX, localCandY);
float targetZ;
uint targetCellId;
// Only the low 16 bits of cellId carry the cell index. Outdoor
// cells are 0x00010x0040; indoor (EnvCell) cells are 0x0100+.
// The full 32-bit cellId includes the landblock prefix in the
// high 16 bits (e.g., 0xA9B40001), so we MUST mask before
// comparing. Without the mask, every cell looks "indoor" because
// 0xA9B40001 >= 0x0100 → the engine always takes the "stay
// indoors" path and snaps Z to an EnvCell floor 28m below.
bool currentlyIndoor = (cellId & 0xFFFFu) >= 0x0100;
if (currentlyIndoor)
{
// Check whether the player crosses a portal belonging to the current cell.
uint currentCellIndex = cellId & 0xFFFFu;
PortalPlane? crossedPortal = null;
foreach (var portal in physics.Portals)
{
// Only portals owned by the current cell are relevant when indoors.
if ((portal.OwnerCellId & 0xFFFFu) != currentCellIndex) continue;
if (portal.IsCrossing(currentPos, candidatePos))
{
crossedPortal = portal;
break;
}
}
if (crossedPortal is not null)
{
if (crossedPortal.Value.TargetCellId == 0xFFFFu)
{
// Indoor → Outdoor exit.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
else
{
// Indoor → Indoor (room to room).
uint nextCellIndex = crossedPortal.Value.TargetCellId & 0xFFFFu;
CellSurface? nextCell = null;
foreach (var c in physics.Cells)
{
if ((c.CellId & 0xFFFFu) == nextCellIndex) { nextCell = c; break; }
}
float? nextFloorZ = nextCell?.SampleFloorZ(candidatePos.X, candidatePos.Y);
targetZ = nextFloorZ ?? terrainZ;
targetCellId = nextCellIndex;
}
}
else if (bestCellZ is not null)
{
// Staying in the same indoor cell.
targetZ = bestCellZ.Value;
targetCellId = bestCell!.CellId & 0xFFFFu;
}
else
{
// No cell floor found and no portal crossed — fall back to outdoor.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
}
else
{
// Outdoor player: check for a portal crossing into an indoor cell.
// Outside-facing portals have TargetCellId == 0xFFFF (they face the
// outdoor world); crossing one from the outdoor side enters the OwnerCellId.
PortalPlane? crossedPortal = null;
foreach (var portal in physics.Portals)
{
if (portal.TargetCellId != 0xFFFFu) continue; // only outside-facing portals
if (portal.IsCrossing(currentPos, candidatePos))
{
crossedPortal = portal;
break;
}
}
if (crossedPortal is not null)
{
// Outdoor → Indoor: enter the OwnerCellId IF the target cell
// actually contains the candidate position. Without CellBSP,
// we verify by checking that SampleFloorZ returns non-null
// (position is within the cell's floor polygon bounds) AND the
// floor Z is close to the player's current Z (not a basement
// 30m below). This prevents the wall-bounce bug where portal
// planes on upper floors captured outdoor positions.
uint enterCellIndex = crossedPortal.Value.OwnerCellId & 0xFFFFu;
CellSurface? enterCell = null;
foreach (var c in physics.Cells)
{
if ((c.CellId & 0xFFFFu) == enterCellIndex) { enterCell = c; break; }
}
float? enterFloorZ = enterCell?.SampleFloorZ(candidatePos.X, candidatePos.Y);
// Validate: floor must exist AND be within step height of current Z.
// This rejects transitions to basements, upper floors, and cells
// whose floor polygon doesn't actually cover this position.
bool validTransition = enterFloorZ is not null
&& MathF.Abs(enterFloorZ.Value - currentPos.Z) < stepUpHeight + 2f;
if (validTransition)
{
targetZ = enterFloorZ!.Value;
targetCellId = enterCellIndex;
}
else
{
// Portal crossed but target cell doesn't contain us — stay outdoor.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
}
else
{
// Stay outdoors on terrain.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
// #126 (2026-06-11, RETAIL-CORRECTED same day): a zero-delta
// RESTORE commits the server's position — it does NOT
// re-derive Z. Retail CPhysicsObj::SetPositionInternal
// (0x00515bd0, pc:283892-283945) treats the supplied Position
// as INPUT: AdjustPosition resolves which cell CONTAINS it,
// CheckPositionInternal/find_valid_position VALIDATES it
// through the collision transition, and failure goes
// store_position + GotoLostCell — there is NO terrain/surface
// re-grounding anywhere in the restore path. Our previous
// shapes both diverged: grounding to terrainZ warped a
// roof-deck logout (ACE's authoritative z=127.2 on the AAB3
// tower) THROUGH the roof into the building volume → the
// transparent-interior spawn; the cell-walkable scan that
// replaced it missed shell-geometry decks entirely (no
// EnvCell owns the surface) and failed silently. Trust the
// claim's Z; the first physics tick validates/settles against
// the REAL collision world (BR-7 building channel included).
// max(terrain, z) stays as the under-terrain sanity bound —
// our recoverable stand-in for retail's lost-cell machinery
// (documented divergence, same as the #107 demote).
if (snapDiag
&& currentPos.Z > terrainZ)
{
if (DiagnosticLog is { } outdoorLog)
{
outdoorLog(System.FormattableString.Invariant(
$"[snap] OUTDOOR claim 0x{cellId:X8} z={currentPos.Z:F3} above terrain {terrainZ:F3} — committing the server Z (retail SetPositionInternal shape; physics settles on tick 1)"));
}
targetZ = currentPos.Z;
}
}
}
// Step-height enforcement: block upward movement that exceeds the limit.
float zDelta = targetZ - currentPos.Z;
if (snapDiag && DiagnosticLog is { } resultLog)
resultLog(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cells={physics.Cells.Count} bestCell=0x{(bestCell?.CellId ?? 0u):X8} bestZ={(bestCellZ?.ToString("F3") ?? "none")} terrainZ={terrainZ:F3} indoor={currentlyIndoor} -> targetZ={targetZ:F3} targetCell=0x{(lbPrefix | (targetCellId & 0xFFFFu)):X8} stepReject={zDelta > stepUpHeight}"));
if (zDelta > stepUpHeight)
{
// Too steep to step up — reject horizontal movement.
return new ResolveResult(currentPos, cellId, IsOnGround: true);
}
return new ResolveResult(
new Vector3(candidatePos.X, candidatePos.Y, targetZ),
lbPrefix | (targetCellId & 0xFFFFu),
IsOnGround: true);
}
/// <summary>
/// Resolve movement using the CTransition sphere-sweep system.
/// Subdivides movement into sphere-radius steps, tests terrain collision
/// at each step, handles step-down for ground contact.
/// Falls back to the simple <see cref="Resolve"/> if the transition fails.
///
/// <para>
/// <paramref name="body"/> is optional but highly recommended for movement
/// that runs across multiple frames. When provided, the previous frame's
/// contact plane is copied INTO the transition's CollisionInfo (mirroring
/// retail's <c>PhysicsObj.get_object_info → InitContactPlane</c> at
/// <c>PhysicsObj.cs:2598-2604</c>). That seed is critical for slope
/// tracking: <c>AdjustOffset</c> projects the Euler offset onto the plane
/// so horizontal velocity acquires the correct Z component for the slope,
/// preventing the character from floating on downhill runs where the
/// per-frame descent exceeds the 4 cm step-down budget.
/// </para>
///
/// <para>
/// On return, the plane discovered during this call is written BACK to
/// <paramref name="body"/>, so the next frame's transition starts with
/// an up-to-date plane seed. Callers without a persistent body (tests,
/// one-shot movements) can pass <c>null</c> and accept the first-frame
/// hiccup.
/// </para>
/// </summary>
public ResolveResult ResolveWithTransition(
Vector3 currentPos, Vector3 targetPos, uint cellId,
float sphereRadius, float sphereHeight,
float stepUpHeight, float stepDownHeight,
bool isOnGround,
PhysicsBody? body = null,
ObjectInfoState moverFlags = ObjectInfoState.None,
uint movingEntityId = 0,
Vector3? localSphereOrigin = null,
Quaternion? beginOrientation = null,
Quaternion? endOrientation = null,
uint designatedTargetId = 0,
// TS-46 (2026-07-30): the mover's own Setup ≤2-sphere list (retail
// CPhysicsObj::transition 0x00512dc0 → SPHEREPATH::init_sphere
// 0x0050c670), scaled by sphereScale (the object's own m_scale /
// wire ObjScale) exactly as init_sphere applies it per-sphere.
// Default/empty preserves the legacy sphereRadius/sphereHeight
// two-scalar reconstruction below — every pre-existing caller
// (camera probe, projectiles, captured-fixture replays) that omits
// this parameter is byte-for-byte unaffected.
ImmutableArray<FlatCollisionSphere> sphereList = default,
float sphereScale = 1f)
{
// A6.P3 #98 (2026-05-23) live capture. Filtered to IsPlayer so NPC /
// remote ResolveWithTransition calls don't pollute the capture. Snapshot
// the body BEFORE the engine mutates it so the replay test can seed its
// PhysicsBody with the exact pre-call state. See PhysicsResolveCapture.cs.
bool captureEnabled = PhysicsResolveCapture.IsEnabled
&& (moverFlags & ObjectInfoState.IsPlayer) != 0;
PhysicsBodySnapshot? bodyBeforeSnap =
captureEnabled && body is not null
? PhysicsResolveCapture.Snapshot(body)
: null;
var transition = RentTransition();
try
{
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true;
// Fix #42 (2026-05-05): the moving entity's ShadowEntry must be
// skipped in FindObjCollisions or the sweep collides with self.
// Default 0 keeps tests / one-shot callers (no registered entity)
// working. Plumbed through ObjectInfo because retail stores the
// self pointer on OBJECTINFO::object (named-retail
// acclient_2013_pseudo_c.txt:274435 OBJECTINFO::init →
// this->object = arg2). The skip itself is at
// CObjCell::find_obj_collisions line 308931.
transition.ObjectInfo.SelfEntityId = movingEntityId;
transition.ObjectInfo.MoverPhysicsState = body?.State ?? PhysicsStateFlags.None;
transition.ObjectInfo.TargetId = designatedTargetId;
// Commit C 2026-04-29 — caller-supplied mover flags drive the
// retail PvP exemption block in FindObjCollisions. The local
// player passes IsPlayer (and PK/PKLite/Impenetrable when known
// from PlayerDescription); remote dead-reckoning passes None
// (matches non-player movement, all targets collide).
transition.ObjectInfo.State |= moverFlags;
// CPhysicsObj::get_object_info 0x00511CC0: Missile contributes
// PathClipped only. PerfectClip is deliberately not inferred.
if ((transition.ObjectInfo.MoverPhysicsState & PhysicsStateFlags.Missile) != 0)
transition.ObjectInfo.State |= ObjectInfoState.PathClipped;
// frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit
// (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder
// in ValidateTransition runs for gravity movers (the player) and not floating props.
transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false;
// Landing-bounce family (#265, 2026-07-30,
// docs/research/2026-07-30-landing-bounce-family.md): the retail
// seed is CPhysicsObj::get_object_info (0x00511cc0) — a body in
// transient CONTACT is re-checked per transition by
// CPhysicsObj::check_contact (0x0050f5b0): contact HOLDS only while
// v · contact_plane.N <= ε (0.0002), i.e. the mover is not moving
// AWAY from its plane. A jump launch fails the check instantly, so
// the transition runs contact-free (no step-down glue, ballistic
// ascent, no contact plane found → SetPositionInternal clears
// CONTACT naturally). The failed-check branch seeds only the
// LAST-KNOWN contact plane (init_last_known_contact_plane) — plane
// context without contact state. This replaces the former
// isOnGround-driven seed (the "resolver reports ground during an
// ascending jump" divergence that forced the AD-25 landing gate).
//
// K-fix7 lineage: pre-seeding a full contact plane while airborne
// made AdjustOffset's snap-to-plane zero jump Z — check_contact is
// retail's own version of that guard. Grounded walking (v·n ≈ 0)
// keeps the plane seed for slope/step-up continuity exactly as
// before (A6.P3 slice 2: SetContactPlane's no-op-if-unchanged guard
// still collapses redundant per-tick seeds).
// A contact WITHOUT a stored plane is unrepresentable in retail
// (init_contact_plane always accompanies the CONTACT seed), so the
// plane requirement here is strict: a body flagged Contact but with
// no committed plane (e.g. the tick after a placement that never
// swept) seeds nothing — its first moving resolve re-derives
// contact from the geometry it actually touches.
if (body is not null && body.InContact && body.ContactPlaneValid)
{
// retail ε 0.000199999995f == PhysicsGlobals.EPSILON (0.0002f).
float awayRate = Vector3.Dot(body.Velocity, body.ContactPlane.Normal);
if (awayRate <= PhysicsGlobals.EPSILON)
{
transition.ObjectInfo.State |= ObjectInfoState.Contact;
if (body.OnWalkable)
transition.ObjectInfo.State |= ObjectInfoState.OnWalkable;
transition.CollisionInfo.SetContactPlane(
body.ContactPlane,
body.ContactPlaneCellId,
body.ContactPlaneIsWater);
}
else
{
// retail get_object_info failed-check branch:
// CTransition::init_last_known_contact_plane.
transition.CollisionInfo.LastKnownContactPlaneValid = true;
transition.CollisionInfo.LastKnownContactPlane = body.ContactPlane;
transition.CollisionInfo.LastKnownContactPlaneCellId = body.ContactPlaneCellId;
transition.CollisionInfo.LastKnownContactPlaneIsWater = body.ContactPlaneIsWater;
}
}
else if (body is null && isOnGround)
{
// Body-less callers (one-shot probes, tests) keep the legacy
// grounded seed — they have no velocity/plane to run
// check_contact against.
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
}
// Retail CPhysicsObj::get_object_info also seeds SlidingNormal when
// transient_state has bit 2 set. This matters for one-step/frame hits:
// a wall collision at the end of one transition must project the next
// frame's movement along the wall instead of hard-stopping again.
if (body is not null
&& (body.TransientState & TransientStateFlags.Sliding) != 0
&& body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
}
if (!sphereList.IsDefaultOrEmpty)
{
// TS-46: the Setup's verbatim sphere list, not the two-scalar
// capsule reconstruction. localSphereOrigin has no meaning
// here — every sphere already carries its own dat-authored
// origin.
transition.SpherePath.InitPath(
currentPos,
targetPos,
cellId,
sphereList,
sphereScale,
beginOrientation,
endOrientation);
}
else
{
transition.SpherePath.InitPath(
currentPos,
targetPos,
cellId,
sphereRadius,
sphereHeight,
localSphereOrigin,
beginOrientation,
endOrientation);
}
// #145: supply the carried cell-relative frame anchor to the outdoor
// membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE
// landblock world origin, correct even for an UNSTREAMED neighbour — replacing
// the terrain-registry origin that returns (0,0) and marches the cell id one
// landblock per tick (the #145 far-town cascade). Engaged only for a SEEDED
// OUTDOOR body whose carried landblock matches the resolve cell (the controller
// passes body.CellPosition.ObjCellId for the outdoor case, so they agree);
// null otherwise → legacy TryGetTerrainOrigin for NPCs/tests/indoor.
transition.SpherePath.CarriedBlockOrigin =
body is not null
&& (cellId & 0xFFFFu) is >= 1u and <= 0x40u // resolve cell is an outdoor landcell
&& (body.CellPosition.ObjCellId & 0xFFFFu) is >= 1u and <= 0x40u // carried cell is outdoor (seeded)
&& (cellId >> 16) == (body.CellPosition.ObjCellId >> 16) // same landblock → anchor consistent
? body.Position - body.CellPosition.Frame.Origin
: null;
if (isOnGround && body is not null
&& body.WalkablePolygonValid
&& body.WalkableVertices is { Length: >= 3 })
{
transition.SpherePath.SetWalkable(
body.WalkablePlane,
body.WalkableVertices,
body.WalkableUp);
}
// Seed collision_info.frames_stationary_fall from the body's carried Stationary*
// transient bits — retail transition() 0x00512dc0 seeds fsf from transient_state
// 0x40/0x20/0x10 AFTER init_path and immediately BEFORE find_valid_position
// (pc:280939-949). Placed here (post-InitPath) so InitPath's CollisionInfo reset
// doesn't wipe the seed.
if (body is not null)
{
transition.CollisionInfo.FramesStationaryFall =
(body.TransientState & TransientStateFlags.StationaryStuck) != 0 ? 3 :
(body.TransientState & TransientStateFlags.StationaryStop) != 0 ? 2 :
(body.TransientState & TransientStateFlags.StationaryFall) != 0 ? 1 : 0;
}
bool ok = transition.FindTransitionalPosition(this);
var sp = transition.SpherePath;
var ci = transition.CollisionInfo;
// Persist the resulting contact plane state back to the body so the
// next frame's transition can seed from it. Uses LastKnownContactPlane
// when current is invalid (e.g., airborne this frame), matching retail.
if (body is not null)
{
// CPhysicsObj::transition 0x00512DC0 discards its CTransition when
// find_valid_position fails. Only SetPositionInternal 0x00515330
// publishes contact/fsf/walkable/sliding state, and that function
// is unreachable on the UpdateObjectInternal failure branch.
if (ok)
{
if (ci.ContactPlaneValid)
{
body.ContactPlaneValid = true;
body.ContactPlane = ci.ContactPlane;
body.ContactPlaneCellId = ci.ContactPlaneCellId;
body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
// #265/#166 (2026-07-30): retail CPhysicsObj::calc_friction
// (0x0050ee70) reads `this->contact_plane.Normal` directly off
// the object (see PhysicsBody.calc_friction's doc comment).
// acdream models that same field as the separate GroundNormal
// property so isolated unit tests can drive calc_friction
// without a full resolve, but nothing wrote it from a live
// resolve before now -- calc_friction always saw the Vector3.UnitZ
// default, i.e. every slope behaved like flat ground. Sync it
// here, at the SAME commit point that already publishes
// ContactPlane, so every caller (player, remote, ordinary,
// projectile) gets a real slope normal for free.
body.GroundNormal = ci.ContactPlane.Normal;
}
else if (ci.LastKnownContactPlaneValid)
{
body.ContactPlaneValid = true;
body.ContactPlane = ci.LastKnownContactPlane;
body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
body.GroundNormal = ci.LastKnownContactPlane.Normal;
}
else
{
body.ContactPlaneValid = false;
// GroundNormal left unchanged/stale -- matches ContactPlane's
// own stale-retention pattern in this branch (comment above).
// calc_friction only reads it while OnWalkable, and OnWalkable
// cannot be true without a valid contact plane, so a stale
// value here is never observed.
}
// AP-10 (Campaign P Slice P4, 2026-07-30): retail SetPositionInternal
// (0x005153e5-0051545f) writes WATER_CONTACT_TS in the same statement
// block as CONTACT_TS, immediately after — this is acdream's equivalent
// per-resolve commit point. Mirrors whatever body.ContactPlaneIsWater was
// just set to above (unchanged/stale in the no-valid-contact branch,
// matching that branch's existing ContactPlaneIsWater behavior).
if (body.ContactPlaneIsWater)
body.TransientState |= TransientStateFlags.WaterContact;
else
body.TransientState &= ~TransientStateFlags.WaterContact;
// Publish frames_stationary_fall + carry it to the next frame via the Stationary*
// transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
// acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
// register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core.
// handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall.
body.FramesStationaryFall = ci.FramesStationaryFall;
body.TransientState &= ~(TransientStateFlags.StationaryFall
| TransientStateFlags.StationaryStop
| TransientStateFlags.StationaryStuck);
body.TransientState |= ci.FramesStationaryFall switch
{
1 => TransientStateFlags.StationaryFall,
2 => TransientStateFlags.StationaryStop,
3 => TransientStateFlags.StationaryStuck,
_ => TransientStateFlags.None,
};
if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
{
body.WalkablePolygonValid = true;
body.WalkablePlane = sp.LastWalkablePlane;
body.SetWalkableVerticesExact(sp.LastWalkableVertices);
body.WalkableUp = sp.LastWalkableUp;
}
else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
{
body.WalkablePolygonValid = false;
body.WalkableVertices = null;
}
// Retail persists sliding state to the body ONLY on transition
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
// is unreachable when find_valid_position fails (the transition is
// discarded whole; the body keeps its prior state). #137 mechanism
// 2: an unconditional writeback here could persist a normal retail
// would discard.
if (ci.SlidingNormalValid
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
body.SlidingNormal = ci.SlidingNormal;
body.TransientState |= TransientStateFlags.Sliding;
}
else
{
body.SlidingNormal = Vector3.Zero;
body.TransientState &= ~TransientStateFlags.Sliding;
}
}
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
// Phase 3's reset path sets VelocityKilled when an airborne hit
// can't find a walkable surface (steep roof, wall) AND the
// body had a last_known_contact_plane (i.e., was grounded
// recently). Retail zeros all three velocity components so
// gravity restarts cleanly next frame.
//
// Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)
// acclient_2013_pseudo_c.txt:274467-274475
// Called from CTransition::transitional_insert reset path:
// acclient_2013_pseudo_c.txt:273237 (Phase 3)
// acclient_2013_pseudo_c.txt:272567 (validate_transition)
if (transition.ObjectInfo.VelocityKilled)
{
if (PhysicsDiagnostics.DumpSteepRoofEnabled)
Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0");
body.Velocity = Vector3.Zero;
}
}
// L.3a (2026-04-30): surface the wall normal so callers can apply
// retail's velocity-reflection bounce (CPhysicsObj::handle_all_collisions
// at acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:
// 2692-2697). The reflection itself is applied in
// PlayerMovementController after the position commit, gated on
// apply_bounce = !(prevOnWalkable && newOnWalkable) — airborne wall
// hits bounce, grounded wall slides don't.
bool collisionNormalValid = ci.CollisionNormalValid;
Vector3 collisionNormal = ci.CollisionNormal;
// #42 diagnostic (2026-05-05): trace airborne sweeps to identify the
// source of the ~1m XY drift on retail-observed stationary jumps.
// Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per
// resolve call. deltaXY = post - target tells us how much the sweep
// diverged from the requested target; for a clean stationary +Z
// jump we expect (0,0). cp=valid with a tilted normal would confirm
// H1 (initial-overlap depenetration → next-step AdjustOffset projects
// the +Z offset along a non-+Z normal). User repros at flat plaza /
// east hillside / north hillside; if drift direction tracks terrain
// orientation, H1 is the cause; if it tracks actor facing, H2 / H3.
if (!isOnGround
&& Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1")
{
var post = sp.CheckPos;
float dx = post.X - targetPos.X;
float dy = post.Y - targetPos.Y;
string cpInfo = ci.ContactPlaneValid
? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})"
: "none";
Console.WriteLine(
$"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " +
$"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " +
$"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " +
$"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " +
$"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}");
}
// L.2a slice 1 (2026-05-12): general-purpose resolver probe.
// One line per call when PhysicsDiagnostics.ProbeResolveEnabled
// is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the
// DebugPanel checkbox flipped at runtime). Captures every
// dimension L.2 cares about: input/output position, input/output
// cell, ok-vs-partial, grounded-in vs contact-out, contact-plane
// status, wall normal if hit, walkable polygon valid. Zero cost
// when off (one static-bool read).
if (PhysicsDiagnostics.ProbeResolveEnabled)
{
var probePost = sp.CheckPos;
string probeCp = ci.ContactPlaneValid
? "valid"
: (ci.LastKnownContactPlaneValid ? "lastKnown" : "none");
string probeHit;
if (collisionNormalValid)
{
// L.2a slice 2 (2026-05-12): include the hit object's guid +
// environment flag so we can tell whether the wall is a building
// (CBuildingObj), a door (CC0Cxxxx range), an NPC, or terrain.
// Without this we know the wall normal but not the responsible
// entity — half the L.2d sub-direction call.
string objPart = ci.LastCollidedObjectGuid.HasValue
? System.FormattableString.Invariant(
$" obj=0x{ci.LastCollidedObjectGuid.Value:X8}")
: "";
string envPart = ci.CollidedWithEnvironment ? " env" : "";
int objCount = ci.CollideObjectGuids.Count;
string objCountPart = objCount > 1
? System.FormattableString.Invariant($" nObj={objCount}")
: "";
probeHit = System.FormattableString.Invariant(
$"yes n=({collisionNormal.X:F2},{collisionNormal.Y:F2},{collisionNormal.Z:F2}){objPart}{envPart}{objCountPart}");
}
else
{
probeHit = "no";
}
Console.WriteLine(System.FormattableString.Invariant(
$"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
}
// Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell.
// Emits before the ResolveResult is built so it shows what BOTH paths would return.
// No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change.
if (PhysicsDiagnostics.ProbeSweptEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-swept] ent=0x{movingEntityId:X8} ok={ok} inCell=0x{cellId:X8} curCell=0x{sp.CurCellId:X8} checkCell=0x{sp.CheckCellId:X8} curPos=({sp.CurPos.X:F3},{sp.CurPos.Y:F3},{sp.CurPos.Z:F3}) checkPos=({sp.CheckPos.X:F3},{sp.CheckPos.Y:F3},{sp.CheckPos.Z:F3})"));
}
ResolveResult resolveResult;
if (ok)
{
bool inContact = ci.ContactPlaneValid;
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact,
ci.ContactPlane.Normal);
bool onGround = inContact
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
resolveResult = new ResolveResult(
sp.CheckPos,
// Phase W Stage 1: return the transition's SWEPT cell (retail SetPositionInternal
// reads sphere_path.curr_cell), not a static re-derive from the resting origin.
// ValidateTransition advances sp.CurCellId only on accepted moves / reverts on
// blocks, so push-back or standing still cannot flip it. The render root
// (CellGraph.CurrCell) is NOT written here — this runs for EVERY entity; it is set
// from this id only by the player's UpdateCellId (see UpdatePlayerCurrCell).
sp.CurCellId,
onGround,
collisionNormalValid,
collisionNormal,
Orientation: sp.CurOrientation,
InContact: inContact,
OnWalkable: onWalkable);
}
else
{
// Transition failed (e.g., stuck in corner, too many steps).
// Use whatever position the transition reached (partial movement)
// instead of falling back to the no-collision Resolve.
// If CheckPos hasn't moved from CurPos, the player stays put —
// this is correct behavior when completely blocked.
bool partialOnGround = ci.ContactPlaneValid
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0
|| isOnGround;
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
resolveResult = new ResolveResult(
sp.CheckPos,
// Phase W Stage 1: prefer the swept cell; fall back to partialCellId only when
// sp.CurCellId is zero (transition never advanced — teleport or physics reset).
// (Render root set by the player's UpdateCellId, not here — see UpdatePlayerCurrCell.)
sp.CurCellId != 0 ? sp.CurCellId : partialCellId,
partialOnGround,
collisionNormalValid,
collisionNormal,
Ok: false,
Orientation: sp.CurOrientation); // Render Residual A — the sweep failed (find_valid_position == 0)
}
// A6.P3 #98 capture: emit one JSON Lines record per player call,
// with bodyBefore snapshot (taken at method entry, before any
// engine mutation) + bodyAfter snapshot (taken now, after the
// engine wrote back the contact plane / walkable / sliding state
// to the body). Loaded by CellarUpTrajectoryReplayTests.cs.
if (captureEnabled)
{
PhysicsResolveCapture.LogCall(
new ResolveCallInputs(
CurrentPos: currentPos,
TargetPos: targetPos,
CellId: cellId,
SphereRadius: sphereRadius,
SphereHeight: sphereHeight,
StepUpHeight: stepUpHeight,
StepDownHeight: stepDownHeight,
IsOnGround: isOnGround,
MoverFlags: (uint)moverFlags,
MovingEntityId: movingEntityId),
bodyBeforeSnap,
new ResolveCallResult(
Position: resolveResult.Position,
CellId: resolveResult.CellId,
IsOnGround: resolveResult.IsOnGround,
CollisionNormalValid: resolveResult.CollisionNormalValid,
CollisionNormal: resolveResult.CollisionNormal),
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
}
return resolveResult;
}
finally
{
ReturnTransition(transition);
}
}
/// <summary>
/// Runs retail's radius-aware placement-ring search after the host has
/// validated the server's cell and grounded the initial position. This is
/// the <c>CTransition::find_placement_pos</c> half of enter-world
/// <c>SetPosition</c>; unlike an ordinary zero-distance transition it tests
/// object occupancy and can seat a relogging player beside a creature that
/// now occupies the saved location.
/// </summary>
public ResolveResult ResolvePlacement(
Vector3 position,
uint cellId,
float sphereRadius,
float sphereHeight,
float stepUpHeight,
float stepDownHeight,
ObjectInfoState moverFlags = ObjectInfoState.None,
uint movingEntityId = 0)
{
var transition = RentTransition();
try
{
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true;
transition.ObjectInfo.SelfEntityId = movingEntityId;
transition.ObjectInfo.State = moverFlags;
transition.SpherePath.InitPath(
position, position, cellId, sphereRadius, sphereHeight);
transition.SpherePath.InsertType = InsertType.Placement;
bool ok = transition.FindPlacementPos(this);
var sp = transition.SpherePath;
var ci = transition.CollisionInfo;
bool inContact = ci.ContactPlaneValid;
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact,
ci.ContactPlane.Normal);
bool onGround = inContact
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
return new ResolveResult(
sp.CurPos,
sp.CurCellId != 0 ? sp.CurCellId : cellId,
onGround,
ci.CollisionNormalValid,
ci.CollisionNormal,
ok,
Orientation: sp.CurOrientation,
InContact: inContact,
OnWalkable: onWalkable,
ContactPlane: ci.ContactPlane,
ContactPlaneCellId: ci.ContactPlaneCellId,
ContactPlaneIsWater: ci.ContactPlaneIsWater);
}
finally
{
ReturnTransition(transition);
}
}
}