acdream/src/AcDream.Core/Physics/PhysicsEngine.cs
Erik 332045c7ad
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
fix(physics): split set_contact_plane from init_contact_plane (#32 local edge-slide)
Measured live at Rithwic 2026-08-06 with ACDREAM_DUMP_EDGE_SLIDE=1.
Six branch2/steep-cliffslide events, every one reporting
curN=(-0.954,0.000,0.301) lastN=(-0.954,0.000,0.301) angle=0.0000
apply=False, outcome degenerate-cross/last-known. That is decision-table
row 1 of the research doc, verbatim.

CTransition::cliff_slide @0x0050a6d0 takes its slide direction from
cross(steep contact normal, last_known_contact_plane.N) — it needs the
surface the mover was STANDING ON as the second vector. acdream's
CollisionInfo.SetContactPlane latched the last-known group on every
call, so by the time cliff_slide ran, last-known had already been
overwritten with the steep face itself: the cross product of a vector
with itself, which is zero. Degenerate direction, no slide, walk off
the cliff.

Retail's COLLISIONINFO::set_contact_plane @0x00509d80 is 22 bytes and
writes the CONTACT group only; the last-known group has four writers,
none of them that function. So the four writes are DELETED and a new
InitContactPlane mirrors CTransition::init_contact_plane @0x0050e850,
writing both — the start-of-transition seed, where there is no earlier
surface to remember. Only check_contact's SUCCESS branch calls it. The
other eleven call sites keep the narrowed setter. This is a port, not a
suppression: no guard, no grace period, no flag.

The user's own A/B was the discriminator: Neftet's block plateaus hold
(188 branch3/precipice-slide events, all before the teleport) while
Rithwic's terrain cliff fails (6 branch2 events, all after). I had
predicted the opposite — that terrain would be the flat-normal case —
and position plus timeline corrected me, not reasoning.

NEW DISCRIMINATING TEST, because the suite had none. It was green both
before and after the production change, so nothing in it defended this
behaviour. Issue32LastKnownContactPlaneTests seeds a walkable plane,
asserts a steep mid-transition contact leaves it intact, and asserts the
resulting cross product is non-degenerate. Sabotage-verified: restore
the four writes and both discriminating rows fail while the
InitContactPlane control keeps passing — the pair separates 'the latch
is gone' from 'nothing writes last-known at all'.

Two existing tests corrected rather than deleted.
PhysicsSetPositionTests.FailedCheck_MapsCollisionHandlerResultToRetailError
passed BECAUSE of the latch (the file the research named); its hook now
populates both groups explicitly, since it asserts report plumbing, not
setter semantics. RetailEdgeResponseOrderingTests.TransitionalInsert_
DegenerateCliffSlideOk_ContinuesOuterRetry was predicted to fail and did
not — it now passes for a DIFFERENT reason (last-known absent rather
than clobbered, which retail also answers with OK_TS). Its comment
described the deleted behaviour and is corrected to say so, and to say
it does not discriminate this fix.

Also repairs the #338 probe. Its first placement in
PlayerMovementController printed nothing across 11,523 live log lines —
the wrong one of two resolve call sites — so it moves to
PhysicsEngine.ResolveWithTransition where every caller passes through,
filtered to the player. The dead site is removed rather than left in
place; a probe that never fires is worse than none. The flag test now
precedes the interpolated string: building it eagerly cost 128 B per
resolve with the probe OFF, which Slice I1's zero-allocation gate caught.

Suite 11,234 passed / 4 skipped / 0 failed.

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

2496 lines
114 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.
// 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)
{
PhysicsDiagnostics.LogStepHeights(
"resolve", stepUpHeight, stepDownHeight,
$"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);
}
}
}