using System; using System.Collections.Generic; using System.Numerics; namespace AcDream.Core.Physics; internal readonly record struct TerrainWalkableSample( System.Numerics.Plane Plane, Vector3[] 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, the entity's current cell ID, a movement delta, /// and a step-up height limit; it returns the validated new position, the /// updated cell ID, and whether the entity is standing on a surface. /// /// public sealed class PhysicsEngine { private readonly Dictionary _landblocks = new(); /// Number of registered landblocks (diagnostic). public int LandblockCount => _landblocks.Count; /// /// Cell-based spatial index for static object collision. /// Populated during landblock streaming; queried by the Transition system. /// public ShadowObjectRegistry ShadowObjects { get; } = new(); /// /// 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 { _dataCache = value; ShadowObjects.DataCache = value; } } private PhysicsDataCache? _dataCache; private sealed record LandblockPhysics( TerrainSurface Terrain, IReadOnlyList Cells, IReadOnlyList Portals, float WorldOffsetX, float WorldOffsetY); /// /// Register a landblock with its terrain surface, indoor cells, portal /// planes, and world-space origin offset. /// public void AddLandblock(uint landblockId, TerrainSurface terrain, IReadOnlyList cells, IReadOnlyList portals, float worldOffsetX, float worldOffsetY) { _landblocks[landblockId] = new LandblockPhysics(terrain, cells, portals, worldOffsetX, worldOffsetY); // 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. /// public void RemoveLandblock(uint landblockId) { _landblocks.Remove(landblockId); ShadowObjects.RemoveLandblock(landblockId); // UCG Stage 1: mirror removal into the unified graph (inert this stage). DataCache?.CellGraph.RemoveLandblock(landblockId); } /// /// 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; } /// /// Public surface for callers that only need the local terrain plane /// normal at a world-space XY (e.g., the grounded-remote tick path /// projecting anim root motion onto the slope to avoid the staircase /// between server position updates). Returns null when no registered /// landblock covers the point. Mirrors the plane component of /// without exposing the internal /// TerrainWalkableSample shape. /// public Vector3? SampleTerrainNormal(float worldX, float worldY) { var sample = SampleTerrainWalkable(worldX, worldY); return sample?.Plane.Normal; } /// /// 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) { var sample = lb.Terrain.SampleSurfacePolygon(localX, localY); var vertices = new Vector3[sample.Vertices.Length]; for (int i = 0; i < sample.Vertices.Length; i++) { var v = sample.Vertices[i]; vertices[i] = new Vector3( v.X + lb.WorldOffsetX, v.Y + lb.WorldOffsetY, v.Z); } var normal = sample.Normal; float d = -Vector3.Dot(normal, vertices[0]); var plane = new System.Numerics.Plane(normal, d); float waterDepth = lb.Terrain.SampleWaterDepth(localX, localY); bool isWater = waterDepth >= 0.45f; uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY); uint fullCellId = (kvp.Key & 0xFFFF0000u) | lowCellId; return new TerrainWalkableSample( plane, vertices, waterDepth, isWater, fullCellId); } } return null; } /// /// 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 single caller is /// Transition.FindEnvCollisions's cache-null fallback /// (PhysicsEngineTests run engines without a , /// so is unavailable). 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 → our ) calls it to /// validate the server-restored (cell, position) pair before any physics runs — /// the #107 indoor-login wedge was this validation missing: a poisoned save /// (cell id from one building, position inside another) was trusted verbatim, /// the player stood fake-grounded with no walkable floor, and the first movement /// demoted them outdoor mid-building → 2.4 m fall under the cottage floor. /// /// /// #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). /// /// /// /// #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; 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; } /// 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: does any loaded landblock carry a for /// this cell id? Distinguishes "partially hydrated" (floor data present, /// struct pending — the legacy floor-snap can ground the claim) from /// "completely unknown" (the Resolve safety net demotes loudly). /// private bool HasCellSurface(uint cellId) { // Masked low-word compare (house norm in this file): production // CellSurfaces carry full prefixed ids (GameWindow.cs:5923), test // fixtures bare low words. A zero-prefix (bare, pre-#106 convention) // claim matches any loaded landblock by low word — the legacy Resolve // body below treats bare claims the same way. uint low = cellId & 0xFFFFu; uint prefix = cellId & 0xFFFF0000u; foreach (var kvp in _landblocks) { if (prefix != 0u && (kvp.Key & 0xFFFF0000u) != prefix) continue; foreach (var cell in kvp.Value.Cells) if ((cell.CellId & 0xFFFFu) == low) return true; } return false; } /// /// #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, and carries a loud /// outdoor-demote safety net for any unhydrated indoor claim that still /// gets through. /// /// 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 an entity's movement from by /// applying (XY only) and computing the correct Z /// from the terrain or indoor cell floor beneath the candidate position. /// /// /// Step-height enforcement rejects horizontal movement when the upward Z /// change exceeds . Downhill movement is /// always accepted. Returns false /// when no loaded landblock covers the candidate position. /// /// public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight) { // #107 (2026-06-10): retail CPhysicsObj::SetPositionInternal (:283892) // step 1 — AdjustPosition (:283908) validates/corrects the claimed cell // from the position BEFORE any physics runs. This legacy Resolve is the // player snap path (login entry + teleport arrival — the SetPosition // shaped calls); both hand it a server-restored (cell, position) pair // that can be poisoned (the #107 capture: cell id from one building, // position inside another, 55 m apart). Retail validates at the foot- // sphere CENTER (localtoglobal of sphere_path.local_sphere, :283903); // the player's foot sphere is radius 0.48 m centred 0.48 m above the // feet (PlayerMovementController body — capture input.sphereRadius). const float FootSphereCenterLift = 0.48f; var (adjustedCellId, adjustedFound) = AdjustPosition( cellId, currentPos + new Vector3(0f, 0f, FootSphereCenterLift)); if (adjustedFound && adjustedCellId != cellId) { Console.WriteLine(System.FormattableString.Invariant( $"[spawn-adjust] claimed cell 0x{cellId:X8} does not contain ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — corrected to 0x{adjustedCellId:X8} (retail AdjustPosition :280009)")); cellId = adjustedCellId; } else if (!adjustedFound && (cellId & 0xFFFFu) >= 0x0100u && DataCache?.GetCellStruct(cellId) is null && !HasCellSurface(cellId)) { // #107 safety net (2026-06-10 gate-run regression): an indoor claim // the engine knows NOTHING about (no cell struct AND no CellSurface // floor data) cannot be validated or grounded — committing it raw // reproduces the fake-grounded wedge. Retail goes lost-cell here // (GotoLostCell, :283418); our recoverable equivalent is the // outdoor landcell under the point (documented divergence — we have // no lost-cell machinery). When only the struct is missing but the // CellSurface floor exists (partial hydration), the legacy indoor // floor-snap below handles the claim — don't demote. The auto-entry // hold should make this unreachable in practice; if the line fires, // the hold has a gap. var (outdoorCellId, outdoorFound) = AdjustPosition( (cellId & 0xFFFF0000u) | 0x0001u, currentPos + new Vector3(0f, 0f, FootSphereCenterLift)); if (outdoorFound) { Console.WriteLine(System.FormattableString.Invariant( $"[spawn-adjust] UNHYDRATED indoor claim 0x{cellId:X8} at ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — demoted to outdoor 0x{outdoorCellId:X8} (lost-cell equivalent)")); cellId = outdoorCellId; } } var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f); // #111 apparatus: one [snap] line per Resolve call (entry + teleport // arrival only — low volume, permanent). The gate-3/4/5 runs committed // ACE's restored pair VERBATIM through this method while every read // path should have changed Z or cell — this line answers which branch // actually ran. Remove or demote to env-gate once #111 closes. bool snapDiag = (delta.X == 0f && delta.Y == 0f); // Find the landblock this candidate position falls in. // #106 follow-up (2026-06-09): capture its high-16 prefix — every // computed cell id below is returned FULL (lbPrefix | low). The old // bare-low-word returns wedged the membership chain whenever a caller // committed them (the teleport-arrival snap wrote 0x0000013F: an // unresolvable indoor id → no wall BSP, #98 gate reads "indoor // primary" and kills the outdoor object sweep → no collision at all). LandblockPhysics? physics = null; uint lbPrefix = 0u; foreach (var kvp in _landblocks) { var lb = kvp.Value; float localX = candidatePos.X - lb.WorldOffsetX; float localY = candidatePos.Y - lb.WorldOffsetY; if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f) { physics = lb; lbPrefix = kvp.Key & 0xFFFF0000u; break; } } if (physics is null) { if (snapDiag) Console.WriteLine(System.FormattableString.Invariant( $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) branch=NO-LANDBLOCK (lbs={_landblocks.Count}) -> verbatim")); return new ResolveResult(candidatePos, cellId, IsOnGround: false); } float localCandX = candidatePos.X - physics.WorldOffsetX; float localCandY = candidatePos.Y - physics.WorldOffsetY; // #111 (2026-06-10): a VALIDATED indoor claim is AUTHORITATIVE for the // cell — retail SetPositionInternal commits the AdjustPosition cell and // only settles Z (CheckPositionInternal → find_valid_position, :283426); // it never re-picks the cell from floor geometry. The legacy bestCell // floor-pick below scans EVERY CellSurface in the landblock (123 at // Holtburg) and breaks same-height ties by iteration order — on a live // login it clobbered ACE's clean, validated claim 0xA9B40171 with // 0xA9B4013F (issue111-snap1.log), putting the player in a wrong cell // → outdoor demote on first movement → transparent interior (#111). // Snap shape only (zero delta): ground Z onto the validated claim's own // floor when it has one under this XY; cells without their own floor // surface here (thresholds, stair lips) fall through to the legacy path. if (snapDiag && adjustedFound && (cellId & 0xFFFFu) >= 0x0100u) { // Ground via the claim's PHYSICS WALKABLE polygons (normal.Z ≥ // PhysicsGlobals.FloorZ), NOT the CellSurface triangle soup — the // soup includes ceiling/roof TOP faces whose first-hit (99.475 // over 0x171's 94.0 floor, issue111-verify2.log) and even // nearest-to-reference (the poisoned reference SAT on the ceiling // face, issue111-verify3.log) selections both land on non-floors. // The walkable set contains only real floors (retail // BSPTREE::find_walkable's polygon filter). float? claimFloorZ = WalkableFloorZNearest(cellId, candidatePos, currentPos.Z); if (claimFloorZ is not null) { Console.WriteLine(System.FormattableString.Invariant( $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}")); return new ResolveResult( new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value), lbPrefix | (cellId & 0xFFFFu), IsOnGround: true); } } // Check if the candidate position falls on any indoor cell floor. // Pick the cell whose floor Z is closest to the entity's current Z. CellSurface? bestCell = null; float? bestCellZ = null; float bestZDist = float.MaxValue; foreach (var cell in physics.Cells) { float? floorZ = cell.SampleFloorZ(candidatePos.X, candidatePos.Y); if (floorZ is not null) { float dist = MathF.Abs(floorZ.Value - currentPos.Z); if (dist < bestZDist) { bestCell = cell; bestCellZ = floorZ; bestZDist = dist; } } } // Determine target surface Z and cell. float terrainZ = physics.Terrain.SampleZ(localCandX, localCandY); float targetZ; uint targetCellId; // Only the low 16 bits of cellId carry the cell index. Outdoor // cells are 0x0001–0x0040; indoor (EnvCell) cells are 0x0100+. // The full 32-bit cellId includes the landblock prefix in the // high 16 bits (e.g., 0xA9B40001), so we MUST mask before // comparing. Without the mask, every cell looks "indoor" because // 0xA9B40001 >= 0x0100 → the engine always takes the "stay // indoors" path and snaps Z to an EnvCell floor 28m below. bool currentlyIndoor = (cellId & 0xFFFFu) >= 0x0100; if (currentlyIndoor) { // Check whether the player crosses a portal belonging to the current cell. uint currentCellIndex = cellId & 0xFFFFu; PortalPlane? crossedPortal = null; foreach (var portal in physics.Portals) { // Only portals owned by the current cell are relevant when indoors. if ((portal.OwnerCellId & 0xFFFFu) != currentCellIndex) continue; if (portal.IsCrossing(currentPos, candidatePos)) { crossedPortal = portal; break; } } if (crossedPortal is not null) { if (crossedPortal.Value.TargetCellId == 0xFFFFu) { // Indoor → Outdoor exit. targetZ = terrainZ; targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); } else { // Indoor → Indoor (room to room). uint nextCellIndex = crossedPortal.Value.TargetCellId & 0xFFFFu; CellSurface? nextCell = null; foreach (var c in physics.Cells) { if ((c.CellId & 0xFFFFu) == nextCellIndex) { nextCell = c; break; } } float? nextFloorZ = nextCell?.SampleFloorZ(candidatePos.X, candidatePos.Y); targetZ = nextFloorZ ?? terrainZ; targetCellId = nextCellIndex; } } else if (bestCellZ is not null) { // Staying in the same indoor cell. targetZ = bestCellZ.Value; targetCellId = bestCell!.CellId & 0xFFFFu; } else { // No cell floor found and no portal crossed — fall back to outdoor. targetZ = terrainZ; targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); } } else { // Outdoor player: check for a portal crossing into an indoor cell. // Outside-facing portals have TargetCellId == 0xFFFF (they face the // outdoor world); crossing one from the outdoor side enters the OwnerCellId. PortalPlane? crossedPortal = null; foreach (var portal in physics.Portals) { if (portal.TargetCellId != 0xFFFFu) continue; // only outside-facing portals if (portal.IsCrossing(currentPos, candidatePos)) { crossedPortal = portal; break; } } if (crossedPortal is not null) { // Outdoor → Indoor: enter the OwnerCellId IF the target cell // actually contains the candidate position. Without CellBSP, // we verify by checking that SampleFloorZ returns non-null // (position is within the cell's floor polygon bounds) AND the // floor Z is close to the player's current Z (not a basement // 30m below). This prevents the wall-bounce bug where portal // planes on upper floors captured outdoor positions. uint enterCellIndex = crossedPortal.Value.OwnerCellId & 0xFFFFu; CellSurface? enterCell = null; foreach (var c in physics.Cells) { if ((c.CellId & 0xFFFFu) == enterCellIndex) { enterCell = c; break; } } float? enterFloorZ = enterCell?.SampleFloorZ(candidatePos.X, candidatePos.Y); // Validate: floor must exist AND be within step height of current Z. // This rejects transitions to basements, upper floors, and cells // whose floor polygon doesn't actually cover this position. bool validTransition = enterFloorZ is not null && MathF.Abs(enterFloorZ.Value - currentPos.Z) < stepUpHeight + 2f; if (validTransition) { targetZ = enterFloorZ!.Value; targetCellId = enterCellIndex; } else { // Portal crossed but target cell doesn't contain us — stay outdoor. targetZ = terrainZ; targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); } } else { // Stay outdoors on terrain. targetZ = terrainZ; targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); } } // Step-height enforcement: block upward movement that exceeds the limit. float zDelta = targetZ - currentPos.Z; if (snapDiag) Console.WriteLine(System.FormattableString.Invariant( $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cells={physics.Cells.Count} bestCell=0x{(bestCell?.CellId ?? 0u):X8} bestZ={(bestCellZ?.ToString("F3") ?? "none")} terrainZ={terrainZ:F3} indoor={currentlyIndoor} -> targetZ={targetZ:F3} targetCell=0x{(lbPrefix | (targetCellId & 0xFFFFu)):X8} stepReject={zDelta > stepUpHeight}")); if (zDelta > stepUpHeight) { // Too steep to step up — reject horizontal movement. return new ResolveResult(currentPos, cellId, IsOnGround: true); } return new ResolveResult( new Vector3(candidatePos.X, candidatePos.Y, targetZ), lbPrefix | (targetCellId & 0xFFFFu), IsOnGround: true); } /// /// Resolve movement using the CTransition sphere-sweep system. /// Subdivides movement into sphere-radius steps, tests terrain collision /// at each step, handles step-down for ground contact. /// Falls back to the simple if the transition fails. /// /// /// 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) { // 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.HasFlag(ObjectInfoState.IsPlayer); PhysicsBodySnapshot? bodyBeforeSnap = captureEnabled && body is not null ? PhysicsResolveCapture.Snapshot(body) : null; var transition = new Transition(); transition.ObjectInfo.StepUpHeight = stepUpHeight; transition.ObjectInfo.StepDownHeight = stepDownHeight; transition.ObjectInfo.StepDown = true; // Fix #42 (2026-05-05): the moving entity's ShadowEntry must be // skipped in FindObjCollisions or the sweep collides with self. // Default 0 keeps tests / one-shot callers (no registered entity) // working. Plumbed through ObjectInfo because retail stores the // self pointer on OBJECTINFO::object (named-retail // acclient_2013_pseudo_c.txt:274435 OBJECTINFO::init → // this->object = arg2). The skip itself is at // CObjCell::find_obj_collisions line 308931. transition.ObjectInfo.SelfEntityId = movingEntityId; // 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; if (isOnGround) transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; // K-fix7 (2026-04-26): only seed the contact plane when the body // is actually grounded. Pre-seeding while AIRBORNE caused // AdjustOffset's "Have a contact plane / Moving away from plane" // branch to fire on every jump step — which calls // Plane::snap_to_plane on the offset and ZEROES the Z component, // killing all upward jump motion. // // We KEEP the seeding when isOnGround for slope-walking + step-up // continuity (the original concern that motivated the seed). // BSP step_up needs ContactPlane on sub-step 1 to compute the // correct lift direction; removing the seed breaks stair-walking // at the last step (verified by A6.P3 slice 2 first attempt // 2026-05-22, reverted in this commit). Retail's CTransition::init // explicitly CLEARS contact_plane_valid; we deliberately diverge // for step_up correctness. // // A6.P3 slice 2 (2026-05-22) — to close issue #96 (per-tick CP-write // blowup) without breaking stair-walking, the no-op-if-unchanged // guard inside CollisionInfo.SetContactPlane (TransitionTypes.cs:259) // collapses redundant seeds (same plane every tick) to a true no-op. // The seed still fires the function call but only counts as a write // when the plane values actually change. if (isOnGround && body is not null && body.ContactPlaneValid) { transition.CollisionInfo.SetContactPlane( body.ContactPlane, body.ContactPlaneCellId, body.ContactPlaneIsWater); } // 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.HasFlag(TransientStateFlags.Sliding) && body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) { transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal); } transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight); if (isOnGround && body is not null && body.WalkablePolygonValid && body.WalkableVertices is { Length: >= 3 }) { transition.SpherePath.SetWalkable( body.WalkablePlane, body.WalkableVertices, body.WalkableUp); } 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) { if (ci.ContactPlaneValid) { body.ContactPlaneValid = true; body.ContactPlane = ci.ContactPlane; body.ContactPlaneCellId = ci.ContactPlaneCellId; body.ContactPlaneIsWater = ci.ContactPlaneIsWater; } else if (ci.LastKnownContactPlaneValid) { body.ContactPlaneValid = true; body.ContactPlane = ci.LastKnownContactPlane; body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId; body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater; } else { body.ContactPlaneValid = false; } if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null) { body.WalkablePolygonValid = true; body.WalkablePlane = sp.LastWalkablePlane; body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone(); body.WalkableUp = sp.LastWalkableUp; } else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid) { body.WalkablePolygonValid = false; body.WalkableVertices = null; } if (ci.SlidingNormalValid && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) { body.SlidingNormal = ci.SlidingNormal; body.TransientState |= TransientStateFlags.Sliding; } else { body.SlidingNormal = Vector3.Zero; body.TransientState &= ~TransientStateFlags.Sliding; } // L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity. // Phase 3's reset path sets VelocityKilled when an airborne hit // can't find a walkable surface (steep roof, wall) AND the // body had a last_known_contact_plane (i.e., was grounded // recently). Retail zeros all three velocity components so // gravity restarts cleanly next frame. // // Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0) // acclient_2013_pseudo_c.txt:274467-274475 // Called from CTransition::transitional_insert reset path: // acclient_2013_pseudo_c.txt:273237 (Phase 3) // acclient_2013_pseudo_c.txt:272567 (validate_transition) if (transition.ObjectInfo.VelocityKilled) { if (PhysicsDiagnostics.DumpSteepRoofEnabled) Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0"); body.Velocity = Vector3.Zero; } } // L.3a (2026-04-30): surface the wall normal so callers can apply // retail's velocity-reflection bounce (CPhysicsObj::handle_all_collisions // at acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs: // 2692-2697). The reflection itself is applied in // PlayerMovementController after the position commit, gated on // apply_bounce = !(prevOnWalkable && newOnWalkable) — airborne wall // hits bounce, grounded wall slides don't. bool collisionNormalValid = ci.CollisionNormalValid; Vector3 collisionNormal = ci.CollisionNormal; // #42 diagnostic (2026-05-05): trace airborne sweeps to identify the // source of the ~1m XY drift on retail-observed stationary jumps. // Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per // resolve call. deltaXY = post - target tells us how much the sweep // diverged from the requested target; for a clean stationary +Z // jump we expect (0,0). cp=valid with a tilted normal would confirm // H1 (initial-overlap depenetration → next-step AdjustOffset projects // the +Z offset along a non-+Z normal). User repros at flat plaza / // east hillside / north hillside; if drift direction tracks terrain // orientation, H1 is the cause; if it tracks actor facing, H2 / H3. if (!isOnGround && Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1") { var post = sp.CheckPos; float dx = post.X - targetPos.X; float dy = post.Y - targetPos.Y; string cpInfo = ci.ContactPlaneValid ? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})" : "none"; Console.WriteLine( $"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " + $"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " + $"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " + $"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " + $"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}"); } // L.2a slice 1 (2026-05-12): general-purpose resolver probe. // One line per call when PhysicsDiagnostics.ProbeResolveEnabled // is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the // DebugPanel checkbox flipped at runtime). Captures every // dimension L.2 cares about: input/output position, input/output // cell, ok-vs-partial, grounded-in vs contact-out, contact-plane // status, wall normal if hit, walkable polygon valid. Zero cost // when off (one static-bool read). if (PhysicsDiagnostics.ProbeResolveEnabled) { var probePost = sp.CheckPos; string probeCp = ci.ContactPlaneValid ? "valid" : (ci.LastKnownContactPlaneValid ? "lastKnown" : "none"); string probeHit; if (collisionNormalValid) { // L.2a slice 2 (2026-05-12): include the hit object's guid + // environment flag so we can tell whether the wall is a building // (CBuildingObj), a door (CC0Cxxxx range), an NPC, or terrain. // Without this we know the wall normal but not the responsible // entity — half the L.2d sub-direction call. string objPart = ci.LastCollidedObjectGuid.HasValue ? System.FormattableString.Invariant( $" obj=0x{ci.LastCollidedObjectGuid.Value:X8}") : ""; string envPart = ci.CollidedWithEnvironment ? " env" : ""; int objCount = ci.CollideObjectGuids.Count; string objCountPart = objCount > 1 ? System.FormattableString.Invariant($" nObj={objCount}") : ""; probeHit = System.FormattableString.Invariant( $"yes n=({collisionNormal.X:F2},{collisionNormal.Y:F2},{collisionNormal.Z:F2}){objPart}{envPart}{objCountPart}"); } else { probeHit = "no"; } Console.WriteLine(System.FormattableString.Invariant( $"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}")); } // Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell. // Emits before the ResolveResult is built so it shows what BOTH paths would return. // No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change. if (PhysicsDiagnostics.ProbeSweptEnabled) { Console.WriteLine(System.FormattableString.Invariant( $"[cell-swept] ent=0x{movingEntityId:X8} ok={ok} inCell=0x{cellId:X8} curCell=0x{sp.CurCellId:X8} checkCell=0x{sp.CheckCellId:X8} curPos=({sp.CurPos.X:F3},{sp.CurPos.Y:F3},{sp.CurPos.Z:F3}) checkPos=({sp.CheckPos.X:F3},{sp.CheckPos.Y:F3},{sp.CheckPos.Z:F3})")); } ResolveResult resolveResult; if (ok) { bool onGround = ci.ContactPlaneValid || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable); 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); } 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.HasFlag(ObjectInfoState.OnWalkable) || 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); // 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; } }