acdream/src/AcDream.Core/Physics/PhysicsEngine.cs
Erik 8c97084289
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
docs: close #338 — headline refuted by full-capture statistics; AD-68 files the real residual
The three-site probe answered it in one run: prepare and publish carry
the authored 0.600/1.500 to the publication candidate, and resolve
receives exactly those values for the entire session after one early
0.400 reading. Re-reading the ORIGINAL 337-support.log with statistics
instead of an eyeball: authored pair 111,248 lines, 0.400 pair 358. The
filing was built on an early line of a 255k-line capture; the alleged
mechanism (values never wired to the mover) does not exist.

The 358 are AD-68, now registered: GetSetupMoverShape's placeholder
(empty spheres -> legacy capsule, 0.4/0.4 steps) during an entity's
async Setup-residency window, plus the local player's own seconds-long
window between controller construction and publication-candidate
adoption. Retail loads synchronously and has no such window. Left as-is
deliberately: shrinking it is streaming work.

The filing still paid for itself: three false doc-comment claims
corrected in PlayerMovementController (retail '~0.4 m' twice, and an
ApplyStepHeights writer that never existed anywhere in the tree —
replaced with the real writer chain), retail's actual fallback pinned at
0.04 (CTransition::step_up @0x0050b655), and the resolve probe now
prints the mover id, because the early 0.400 was most plausibly a
REMOTE player — remotes also carry IsPlayer — and the guid rule
(feedback_probe_identity_attribution) exists precisely to stop that
misread.

No production behaviour changed; nothing for the morning gate. AD
section 50 -> 51. Suite 11,234 / 4 / 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:44:42 +02:00

2509 lines
115 KiB
C#

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="ResolveWithTransition"/> takes a current position, a target
/// position, the entity's current cell ID, and the mover's sphere/step
/// parameters; it returns the validated new position, the updated cell ID,
/// and whether the entity is standing on a surface. C5a (2026-08-05): the
/// legacy zero-delta <c>Resolve</c> snap this paragraph used to cite is
/// gone — canonical placement now flows through <c>SetPosition</c> (Core)
/// / <c>PreparePositionForCommit</c> + <c>ArmConstraintLeashAtCommittedPlacement</c>
/// (Runtime), both of which settle through this same sphere-sweep resolver.
/// </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;
// #280 (D6): reusable landblock-prefix scratch for
// IsNeighborhoodTerrainResident. Physics is single-threaded per engine and
// this method is a leaf, so one instance-owned set is safe and keeps the
// per-frame reveal gate allocation-free.
private readonly HashSet<uint> _terrainResidencyScratch = new();
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)
{
// #280 (D6): this runs every frame for the whole duration of a reveal
// hold, and the hold's radius is now the streaming Far radius (12 at
// the shipped High preset = 625 ring members) instead of 1. Building a
// fresh HashSet per call would allocate on every frame of every hold,
// against Slice I1's 0 B standard. The scratch set is owned by this
// engine, cleared and refilled in place, so a warmed call allocates
// nothing; the prefix-masked membership semantics are unchanged
// (callers register landblocks under canonical, cell-resolved, or bare
// ids and this gate has always compared on the high 16 bits).
HashSet<uint> resident = _terrainResidencyScratch;
resident.Clear();
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>
private ClientObjectTable? _objects;
private ulong _objectsBindingRevision;
public ClientObjectTable? Objects
{
get => _objects;
set
{
if (ReferenceEquals(_objects, value))
return;
_objects = value;
_objectsBindingRevision = checked(_objectsBindingRevision + 1UL);
}
}
internal ulong ObjectsBindingRevision => _objectsBindingRevision;
internal sealed record LandblockPhysics(
TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells,
IReadOnlyList<PortalPlane> Portals,
float WorldOffsetX,
float WorldOffsetY);
/// <summary>
/// Creates the empty off-side staging root for one landblock collision
/// generation. O3 (2026-08-02): admission no longer materializes a clone
/// of the resident world — the staging root holds ONLY the target
/// landblock's authored content and the commit installs it into the
/// active root as a per-landblock delta whose owner refloods run against
/// the live world (retail <c>CObjCell::init_objects</c> 0x0052B420 →
/// <c>CPhysicsObj::recalc_cross_cells</c> 0x00515A30).
/// </summary>
internal CollisionStagingBuilder CreateCollisionStagingBuilder(
uint targetLandblockId)
{
_ = targetLandblockId;
PhysicsDataCache activeCache = DataCache
?? throw new InvalidOperationException(
"Active collision engine has no data cache.");
return new CollisionStagingBuilder(this, activeCache);
}
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));
}
/// <summary>
/// Publishes one sealed landblock replacement into the ACTIVE collision
/// root as a per-landblock delta drained in this one synchronous
/// update-thread call — the O2 (2026-08-02) restoration of be94bc9b's
/// O(changed) commit, replacing the whole-root
/// <c>CollisionWorldStateSlot.TransferTo</c> swap. Retail hydrates one
/// cell synchronously and refloods the objects associated with it
/// (<c>CObjCell::init_objects</c> 0x0052B420 →
/// <c>CPhysicsObj::recalc_cross_cells</c> 0x00515A30); the streaming
/// analogue is one landblock delta applied atomically with respect to
/// every reader (the runtime is single-threaded and the caller holds the
/// prefix quiescence permission). Owner rows install from the sealed
/// staging registry, which the seal keeps exactly current.
/// </summary>
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.");
ShadowObjectRegistry stagingShadows = replacement.Staging.ShadowObjects;
CollisionWorldState active = activeCache.CollisionWorld.Current;
// Rows in retired target cells belong exclusively to owners in the
// sealed owner list (every owner with target-prefix rows is captured
// there), so dropping the retired cells' row lists first can never
// discard a row the owner installs below.
PreparedPhysicsDataCacheLandblock data = replacement.DataCache;
for (int index = 0; index < data.CellIdsToRemove.Count; index++)
active.ShadowCells.Remove(data.CellIdsToRemove[index]);
IReadOnlyList<uint> envCellRemovals =
data.CellGraph.EnvCellIdsToRemove;
for (int index = 0; index < envCellRemovals.Count; index++)
active.ShadowCells.Remove(envCellRemovals[index]);
using (LandblockReplacementApplyCursor cursor =
CreateLandblockReplacementApplyCursor(replacement))
{
while (true)
{
LandblockReplacementApplyStep step = cursor.Advance();
if (step.HasOwner)
{
// O3 (2026-08-02): retail's per-cell hydration suffix —
// adopt each staged owner and recalculate its cross-cells
// against the live post-delta world, retire the outgoing
// generation's authored statics, and re-run the flood for
// retained live owners touching the replaced landblock
// (CObjCell::init_objects 0x0052B420 →
// CPhysicsObj::recalc_cross_cells 0x00515A30).
ShadowObjects.ApplyCommittedOwnerReplacement(
stagingShadows,
step.OwnerId,
replacement.LandblockId);
}
if (step.Completed)
break;
}
}
// Retail init_objects refloods every object associated with the
// hydrated cell at hydration time. Owners that became associated with
// the target after the sealed capture (a mover entering the prefix
// mid-publication) are in the live prefix-owner slots but not the
// sealed list; recalculate their cross-cells here too.
ShadowObjects.RefloodPrefixOwnersAfterReplacement(
replacement.LandblockId,
replacement.Shadows.OwnerIds);
// The staging root no longer becomes the active root, but a committed
// preparation must still lose its private world exactly as TransferTo
// revoked it.
stagingCache.CollisionWorld.Revoke();
}
internal LandblockReplacementApplyCursor
CreateLandblockReplacementApplyCursor(
PreparedPhysicsEngineLandblock replacement) =>
new(this, replacement);
internal readonly record struct LandblockReplacementApplyStep(
bool Completed,
bool Worked,
bool HasOwner,
uint OwnerId);
/// <summary>
/// Applies one sealed landblock delta to the active collision root. Each
/// advance mutates at most one dictionary leaf, one synthesized outdoor
/// cell, or yields one logical shadow owner to the committing caller.
/// <see cref="CommitLandblockReplacement"/> drains it in one synchronous
/// update-thread call.
/// </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 (_index < data.CellIdsToRemove.Count)
{
_destination.RemoveCellStruct(
data.CellIdsToRemove[_index++]);
return Worked();
}
NextPhase();
continue;
case 1:
if (_index < data.Cells.Count)
{
KeyValuePair<uint, CellPhysics> pair =
data.Cells[_index++];
_destination.SetCellStruct(pair.Key, pair.Value);
return Worked();
}
NextPhase();
continue;
case 2:
if (_index < data.FlatCellIdsToRemove.Count)
{
_destination.RemoveFlatCellStruct(
data.FlatCellIdsToRemove[_index++]);
return Worked();
}
NextPhase();
continue;
case 3:
if (_index < data.FlatCells.Count)
{
KeyValuePair<uint, FlatCellStructureCollisionAsset>
pair = data.FlatCells[_index++];
_destination.SetFlatCellStruct(pair.Key, pair.Value);
return Worked();
}
NextPhase();
continue;
case 4:
if (_index < data.FlatEnvCellIdsToRemove.Count)
{
_destination.RemoveFlatEnvCell(
data.FlatEnvCellIdsToRemove[_index++]);
return Worked();
}
NextPhase();
continue;
case 5:
if (_index < data.FlatEnvCells.Count)
{
KeyValuePair<uint, FlatEnvCellTopology> pair =
data.FlatEnvCells[_index++];
_destination.SetFlatEnvCell(pair.Key, pair.Value);
return Worked();
}
NextPhase();
continue;
case 6:
if (_index < data.BuildingIdsToRemove.Count)
{
_destination.RemoveBuilding(
data.BuildingIdsToRemove[_index++]);
return Worked();
}
NextPhase();
continue;
case 7:
if (_index < data.Buildings.Count)
{
KeyValuePair<uint, BuildingPhysics> pair =
data.Buildings[_index++];
_destination.SetBuilding(pair.Key, pair.Value);
return Worked();
}
NextPhase();
continue;
case 8:
if (_index < graph.EnvCellIdsToRemove.Count)
{
_destination.RemoveEnvCell(
graph.EnvCellIdsToRemove[_index++]);
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 (_index < graph.EnvCells.Count)
{
KeyValuePair<uint, EnvCell> pair =
graph.EnvCells[_index++];
_destination.SetEnvCell(pair.Key, pair.Value);
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;
}
public void Dispose()
{
}
}
/// <summary>
/// O3 (2026-08-02): the empty off-side staging root for one landblock
/// collision generation. The pre-O3 builder materialized a whole-world
/// clone one leaf per host step; that clone existed only so the old
/// whole-root activation swap and the staged retained-owner refloods had
/// a complete world to stand on. With the per-landblock delta commit and
/// commit-time refloods against the live world (retail
/// <c>CObjCell::init_objects</c> 0x0052B420 →
/// <c>CPhysicsObj::recalc_cross_cells</c> 0x00515A30), admission is O(1)
/// and the staging root holds only the target landblock's authored
/// content. Immutable GfxObj/Setup catalogs still read through to the
/// active cache via the staging cache's read fallback.
/// </summary>
internal sealed class CollisionStagingBuilder : IDisposable
{
internal CollisionStagingBuilder(
PhysicsEngine active,
PhysicsDataCache activeCache)
{
var stagingSlot = new CollisionWorldStateSlot();
StagingCache = activeCache.CreateEmptyCollisionStaging(stagingSlot);
StagingEngine = new PhysicsEngine
{
DataCache = StagingCache,
Objects = active.Objects,
};
}
internal PhysicsDataCache StagingCache { get; }
internal PhysicsEngine StagingEngine { get; }
public void Dispose()
{
}
}
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>
internal 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>
internal 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>
internal 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>
internal 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;
}
// AD-10 (retired 2026-08-06): SampleTerrainNormal(worldX, worldY) lived
// here. Its only caller was the remote tick's pre-sweep slope projection,
// which was itself an extra copy of retail's in-sweep
// CTransition::adjust_offset (0x0050a370). The lookup was XY-only and
// Z-blind, so it answered with terrain even for a body on a bridge, in a
// dungeon or on a roof. Nothing needs a bare terrain normal now; callers
// that need a contact surface read the body's own committed ContactPlane,
// which the resolve below publishes.
/// <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) originally called it through the
/// legacy <c>Resolve</c> wrapper (deleted C5a, zero production callers)
/// to validate the server-restored (cell, position) pair before any
/// physics runs; the sole production caller today is
/// <c>PhysicsCameraCollisionProbe</c> (camera collision cell resolve) —
/// the #107 indoor-login wedge this method fixed was the validation
/// missing from the (now-deleted) player snap path:
/// 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,
CellArray queryFootprint)
{
queryFootprint.Add(seedCellId);
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,
queryFootprint);
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);
queryFootprint.Add(adjustedCell);
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();
CellArray queryFootprint =
transition.SpherePath.SetPositionQueryFootprint;
try
{
queryFootprint.Clear();
transition.SpherePath.CellCandidates.UnionTarget = queryFootprint;
InitializeSetPositionTransition(transition, request);
bool randomOnly = request.Flags.HasFlag(
PhysicsSetPositionFlags.RandomScatter);
PhysicsSetPositionResult result;
if (randomOnly)
{
result = SetScatterPositionInternal(
transition,
request,
handleCollisions,
queryFootprint);
}
else
{
result = SetPositionInternal(
transition,
request,
handleCollisions,
queryFootprint);
if (result.Error != PhysicsSetPositionError.Ok
&& request.Flags.HasFlag(PhysicsSetPositionFlags.Scatter))
{
result = SetScatterPositionInternal(
transition,
request,
handleCollisions,
queryFootprint);
}
}
// Scatter retains one append-only query union across every inner
// attempt. Materialize it exactly once at the public transaction
// boundary; copying it per attempt is quadratic at retail's
// maximum retry count.
return result with
{
QueriedCellIds = queryFootprint.OrderedIds.ToImmutableArray(),
};
}
finally
{
transition.SpherePath.CellCandidates.UnionTarget = null;
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,
CellArray queryFootprint)
{
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,
queryFootprint);
if (result.Error == PhysicsSetPositionError.Ok)
break;
}
return result;
}
private PhysicsSetPositionResult SetPositionInternal(
Transition transition,
in PhysicsSetPositionRequest request,
Func<PhysicsSetPositionCollisionReport, bool>? handleCollisions,
CellArray queryFootprint)
{
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,
queryFootprint);
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 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. C5a (2026-08-05): the loud outdoor-demote
/// safety net this paragraph used to cite lived in the legacy
/// <c>Resolve</c>/<c>HasCellSurface</c> pair, deleted with zero production
/// callers — canonical <c>SetPosition</c> has no equivalent unhydrated-
/// claim demote, so a claim that fails this gate simply stays un-adjusted
/// until the streaming worker catches up, the same as any other caller
/// of <see cref="AdjustPosition"/> that gets <c>found = false</c>.
/// </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 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. C5a (2026-08-05):
/// the legacy simple-snap fallback this method used to describe (the
/// zero-delta <c>Resolve</c> player-snap path) is gone — deleted with
/// zero production callers. This is now the sole movement-resolution
/// entry point; a failed transition returns its own
/// <see cref="ResolveResult.Ok"/> false rather than falling back to
/// anything.
///
/// <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;
// #337 (2026-08-06 — TEMPORARY): arm the [support] probe's
// contact-plane provenance latch for this resolve, ahead of everything
// including the carried-plane seed below. The seed is itself one of
// the ten sites that assert a plane, so it stamps its own name and a
// capture can read `cpSrc=ResolveWithTransition:<line>` as "carried
// from the body, nothing re-derived it this resolve" without needing a
// sentinel value for that case. No-op when the probe is off.
PhysicsDiagnostics.BeginContactPlaneAttribution();
var transition = RentTransition();
try
{
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
// #338 (TEMPORARY): the resolver's own reading, taken where the
// values actually land rather than at one of two candidate call
// sites. The first attempt probed PlayerMovementController and
// printed NOTHING across 11,523 live log lines — the wrong one of
// its two resolve calls. A silent probe proves nothing, so this
// one sits where every caller must pass through. Filtered to the
// player so remotes cannot drown it.
// #338 self-report, once per process, UNCONDITIONAL. The probe has
// now been silent through two placements, and "no output" cannot
// distinguish "this site is never reached" from "the flag is
// false". This line answers both directly instead of a third round
// of inference. One Interlocked per process; strip with the probe.
PhysicsDiagnostics.AnnounceStepHeightProbeOnce(
(moverFlags & ObjectInfoState.IsPlayer) != 0);
// The flag test MUST precede the interpolated string: this site runs
// per resolve, and building the detail eagerly cost 128 B/resolve
// with the probe OFF — caught by Slice I1's zero-allocation gate,
// which is exactly what that gate is for.
if (PhysicsDiagnostics.ProbeStepHeightsEnabled
&& (moverFlags & ObjectInfoState.IsPlayer) != 0)
{
// The mover id is REQUIRED here (feedback_probe_identity_attribution):
// remote players also carry IsPlayer, and the one early
// 0.400 reading this probe caught was nearly misattributed to
// the local player for exactly that reason — it was a remote
// in its Setup-residency window (AD-68).
PhysicsDiagnostics.LogStepHeights(
"resolve", stepUpHeight, stepDownHeight,
$"mover=0x{movingEntityId:X8} onGround={isOnGround} hasBody={body is not null}");
}
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;
// #32 (2026-08-07): InitContactPlane, not SetContactPlane.
// This is retail's check_contact SUCCESS branch — the
// start-of-transition seed, where both groups are meant to
// be written because there is no earlier surface to
// remember. Every OTHER call site keeps the narrowed
// setter, so a steep face met mid-transition can no longer
// overwrite the walkable surface cliff_slide needs as its
// second cross-product vector.
transition.CollisionInfo.InitContactPlane(
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}"));
}
// #337 [support] probe (2026-08-06 — TEMPORARY, strip with the
// physics-probe family). Runs for EVERY body, not just the player:
// a corpse sinking through geometry is a plain physics body with
// no player-specific logic, so it is the cheapest possible control
// on whether the movement code or the geometry is at fault, and it
// is invisible to any player-filtered probe.
//
// The terrain sample below is INDEPENDENT of whatever the sweep
// decided — it asks the landblock directly what the ground height
// is under the body's own out-XY. Pairing that with the contact
// plane's height at the same XY is what separates "terrain is
// holding this body up" from "some object surface is". Read-only:
// SampleTerrainWalkable takes no locks, mutates nothing, and is
// not on the resolve's committed path.
if (PhysicsDiagnostics.ProbeSupportEnabled)
{
Vector3 outPos = sp.CheckPos;
TerrainWalkableSample? terrain =
SampleTerrainWalkable(outPos.X, outPos.Y);
bool terrainSampled = terrain.HasValue
&& PhysicsDiagnostics.TryPlaneZAt(
terrain.Value.Plane, outPos.X, outPos.Y, out _);
float terrainZ = float.NaN;
if (terrainSampled)
{
PhysicsDiagnostics.TryPlaneZAt(
terrain!.Value.Plane, outPos.X, outPos.Y, out terrainZ);
}
PhysicsDiagnostics.LogSupport(
moverId: movingEntityId,
isPlayer: (moverFlags & ObjectInfoState.IsPlayer) != 0,
inPos: currentPos,
inCell: cellId,
targetPos: targetPos,
outPos: outPos,
outCell: sp.CheckCellId,
ok: ok,
groundedIn: isOnGround,
contact: transition.ObjectInfo.Contact,
onWalkable: transition.ObjectInfo.OnWalkable,
contactPlaneValid: ci.ContactPlaneValid,
contactPlane: ci.ContactPlane,
contactPlaneCellId: ci.ContactPlaneCellId,
contactPlaneIsWater: ci.ContactPlaneIsWater,
contactPlaneSource: PhysicsDiagnostics.ContactPlaneSource,
lastKnownValid: ci.LastKnownContactPlaneValid,
lastKnownPlane: ci.LastKnownContactPlane,
terrainSampled: terrainSampled,
terrainZ: terrainZ,
terrainNormal: terrain?.Plane.Normal ?? Vector3.Zero,
terrainCellId: terrain?.CellId ?? 0u,
terrainIsWater: terrain?.IsWater ?? false,
walkablePolygon: sp.HasWalkablePolygon,
lastWalkablePolygon: sp.HasLastWalkablePolygon,
stepUpHeight: stepUpHeight,
stepDownHeight: stepDownHeight,
velocity: body?.Velocity ?? Vector3.Zero);
}
// 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);
}
}
}