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
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>
2509 lines
115 KiB
C#
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) ->
|
|
/// <c>SetPositionInternal</c> (0x00515BD0) ->
|
|
/// <c>AdjustPosition</c> (0x00511D80) ->
|
|
/// <c>CheckPositionInternal</c> (0x00511E90) ->
|
|
/// <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);
|
|
}
|
|
}
|
|
}
|