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); /// /// Top-level physics resolver that combines and /// to resolve entity movement with step-height /// enforcement and outdoor/indoor cell transitions. /// /// /// Landblocks are registered via with their /// terrain, indoor cells, and world-space offsets. /// 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 Resolve snap this paragraph used to cite is /// gone — canonical placement now flows through SetPosition (Core) /// / PreparePositionForCommit + ArmConstraintLeashAtCommittedPlacement /// (Runtime), both of which settle through this same sphere-sweep resolver. /// /// public sealed class PhysicsEngine { private CollisionWorldStateSlot _collisionWorld; private Dictionary _landblocks => _collisionWorld.Current.Landblocks; private List _landblockSlots => _collisionWorld.Current.LandblockSlots; private Dictionary _landblockIndices => _collisionWorld.Current.LandblockIndices; private Stack _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 _terrainResidencyScratch = new(); public PhysicsEngine() : this(reuseTransitionScratch: true) { } /// /// Test seam for fresh-versus-reused transition differential evidence. /// Production always uses the public constructor and owns one retail-shaped /// scratch arena. /// 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); /// Number of registered landblocks (diagnostic). public int LandblockCount => _landblocks.Count; internal CollisionWorldStateSlot CollisionWorld => _collisionWorld; /// /// Optional high-volume collision trace sink. Production leaves this /// unset; focused diagnostic gates may opt in explicitly. /// public Action? DiagnosticLog { get; set; } /// /// 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. /// internal Func< Transition, TransitionCellCollisionPhase, uint, TransitionState, TransitionState>? TransitionCellCollisionTestHook { get; set; } /// /// Deterministic seam for retail Random::RollDice(-1, 1) used by /// SetPosition scatter. Values are expected in [0,1); production uses the /// process RNG and tests inject a fixed sequence. /// internal Func SetPositionRandomUnit { get; set; } = Random.Shared.NextDouble; /// /// True once the landblock covering has had its /// terrain + cells registered via . 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). /// public bool IsLandblockTerrainResident(uint cellOrLandblockId) { uint prefix = cellOrLandblockId & 0xFFFF0000u; foreach ((uint key, _) in _landblocks) if ((key & 0xFFFF0000u) == prefix) return true; return false; } /// /// True once EVERY in-bounds landblock within Chebyshev of the /// landblock covering 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 . /// 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 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; } /// /// Cell-based spatial index for static object collision. /// Populated during landblock streaming; queried by the Transition system. /// public ShadowObjectRegistry ShadowObjects { get; } /// /// 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 so the /// registration-side flood () /// can traverse cells + buildings. /// 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]); } /// /// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): optional live /// weenie-object table, consulted ONLY by /// 's entry-restriction gate to /// resolve a cell's RestrictionObj into its owner/guest-list data /// (retail ACCWeenieObject::CanMoveInto). Mirrors the /// 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. /// 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 Cells, IReadOnlyList Portals, float WorldOffsetX, float WorldOffsetY); /// /// 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 CObjCell::init_objects 0x0052B420 → /// CPhysicsObj::recalc_cross_cells 0x00515A30). /// 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 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)); } /// /// 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 /// CollisionWorldStateSlot.TransferTo swap. Retail hydrates one /// cell synchronously and refloods the objects associated with it /// (CObjCell::init_objects 0x0052B420 → /// CPhysicsObj::recalc_cross_cells 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. /// 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 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); /// /// 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. /// drains it in one synchronous /// update-thread call. /// 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 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 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 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 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 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() { } } /// /// 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 /// CObjCell::init_objects 0x0052B420 → /// CPhysicsObj::recalc_cross_cells 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. /// 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(); } } /// /// Register a landblock with its terrain surface, indoor cells, portal /// planes, and world-space origin offset. /// internal void AddLandblock(uint landblockId, TerrainSurface terrain, IReadOnlyList cells, IReadOnlyList 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)); } /// /// Remove a previously registered landblock, including its shadow objects. /// 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); } /// /// 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. /// 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(); } /// /// 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. /// internal void DemoteLandblockToTerrain(uint landblockId) { uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (_landblocks.TryGetValue(canonical, out var landblock)) { _landblocks[canonical] = landblock with { Cells = Array.Empty(), Portals = Array.Empty(), }; } // 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; } } /// /// 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. /// 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; } /// /// 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. /// 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; } /// /// 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 /// ObjCell.get_water_depth. Used by /// to visually submerge characters in water /// without needing a separate water surface mesh. /// 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; } /// /// Sample the outdoor terrain plane (Z + sloped normal) at the given /// world-space XY position. The returned /// has the true terrain-triangle normal (NOT a flat (0,0,1)), and /// its D is set so the plane passes through the sampled point. Used /// by to build a CORRECT contact plane — a flat /// plane breaks slope tracking because AdjustOffset's projection /// onto a flat plane cannot impart the Z component that horizontal /// velocity needs to follow the slope. /// 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. /// /// Sample the outdoor terrain walkable triangle at the given world-space /// XY position. This carries the same plane as /// plus world-space triangle vertices for retail precipice-slide. /// 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; } /// /// Samples only the fixed outdoor cell supplied to retail /// CTransition::insert_into_cell. The target point may move into a /// neighboring cell during a retry, but retail continues dispatching the /// captured CObjCell* until that inner call returns. /// 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); /// /// 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. /// /// /// Indoor seed: delegates to which /// BFS-walks the portal graph and uses /// for containment. This replaces Phase D's AABB shortcut. /// /// /// /// 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). /// /// /// /// Design: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md /// /// /// /// Set the render root cell — , which IS /// "the PLAYER's cell" (CellGraph.cs:19) and roots the indoor render /// (GameWindow.OnRender). Call ONLY for the local player, from /// PlayerMovementController.UpdateCellId — the single player chokepoint for CellId /// (teleport / server snap / per-frame resolver). /// /// /// 2026-06-03: this write was previously inside the per-entity /// (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. /// /// /// 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. /// public void UpdatePlayerCurrCell(uint cellId) { if (DataCache?.CellGraph is { } cg && cg.GetVisible(cellId) is { } cell) cg.CurrCell = cell; } /// /// TEST-ONLY outdoor cell re-derive. The sole caller is /// Transition.RunCheckOtherCellsAndAdvance's cache-null fallback /// (PhysicsEngineTests run engines without a , /// so is unavailable). Normal /// production membership flows exclusively through the collide-then-pick advance /// (RunCheckOtherCellsAndAdvanceFindCellSet). /// /// /// 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. /// /// /// Preserves the L.2e prefix-preservation fix (always apply the /// matched landblock's high-16 prefix even when /// arrived bare-low-byte). /// 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; } /// /// Verbatim port of CPhysicsObj::AdjustPosition /// (acclient_2013_pseudo_c.txt:280009): resolve which cell actually /// contains , given a seed cell. Indoor /// (objcell_id ≥ 0x100, :280020) → /// in stab-list mode (retail arg5 = 1, :280028); outdoor (:280050) → /// snap to the landcell under the point (retail LandDefs::adjust_to_outside, /// the same grid lookup uses). Returns /// found = false with the seed id unchanged when no cell resolves /// (retail return 0, :280065). /// /// /// SmartBox::update_viewer 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 /// (SetPositionInternal :283908) originally called it through the /// legacy Resolve wrapper (deleted C5a, zero production callers) /// to validate the server-restored (cell, position) pair before any /// physics runs; the sole production caller today is /// PhysicsCameraCollisionProbe (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. /// /// /// #107 (2026-06-10) completed the previously-deferred indoor /// seen_outside → adjust_to_outside 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). /// /// private readonly record struct AdjustedSetPosition( uint CellId, Vector3 CellLocalPosition, bool Resident); /// /// SetPosition's exact AdjustPosition input/output shape. Unlike the /// camera helper above, this carries retail's block-local frame and can /// therefore run outdoor LandDefs::adjust_to_outside without /// consulting the resident-landblock registry. A valid adjusted id with /// no visible cell is retained for the lost-cell path. /// 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: /// CPhysicsObj::SetPosition (0x005160C0) -> /// SetPositionInternal (0x00515BD0) -> /// AdjustPosition (0x00511D80) -> /// CheckPositionInternal (0x00511E90) -> /// CTransition::find_valid_position (0x0050C310). /// A destination whose cell is not resident returns DeferredCell; callers /// must retain the authoritative frame rather than demoting it outdoors. /// internal PhysicsSetPositionResult SetPosition( in PhysicsSetPositionRequest request, Func? 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? 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? handleCollisions, CellArray queryFootprint) { transition.SpherePath.CellCandidates.Clear(); transition.SpherePath.ClearWalkable(); ImmutableArray 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.Empty, CollidedObjectIds: ImmutableArray.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.Empty, CollidedObjectIds: ImmutableArray.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.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 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.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.Empty, CollidedObjectIds: ImmutableArray.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; } /// /// #111: the walkable floor Z of 's PHYSICS /// polygons under the world XY, nearest to . /// Walkable = plane normal.Z ≥ (retail /// BSPTREE::find_walkable's filter) — ceilings/roof tops never qualify, /// unlike the 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. /// 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; } /// Even-odd XY-projection point-in-polygon test (cell-local frame). private static bool PointInPolygonXY(IReadOnlyList 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; } /// /// #107 auto-entry hold (gate-2 extension, 2026-06-10): true when the /// server-claimed spawn cell is ready for 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. /// /// /// ⚠️ 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 /// Resolve/HasCellSurface pair, deleted with zero production /// callers — canonical SetPosition 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 that gets found = false. /// /// 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); } /// /// 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 Resolve 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 /// false rather than falling back to /// anything. /// /// /// 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 PhysicsObj.get_object_info → InitContactPlane at /// PhysicsObj.cs:2598-2604). That seed is critical for slope /// tracking: AdjustOffset 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. /// /// /// /// On return, the plane discovered during this call is written BACK to /// , 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 null and accept the first-frame /// hiccup. /// /// 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 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:` 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(); // #345 probe (2026-08-08): reset the per-tick transition-phase trace // buffer. Scoped to exactly one resolve/tick — see // PhysicsDiagnostics.BeginTransitFailTrace. No-op when the probe is // off. PhysicsDiagnostics.BeginTransitFailTrace(); 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) } // #345 probe (2026-08-08): the self-selecting stuck-tick // predicate — fires only when this tick requested a real XY // move and delivered none, flushing the buffered // transition-phase trace. No-op when the probe is off. PhysicsDiagnostics.EmitTransitFailIfStuck( movingEntityId, currentPos, targetPos, resolveResult.Position); // 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); } } }