The two remaining flagged workarounds retired, per the BR-7 plan +
the WF1 [MEDIUM] correction (re-gate, do NOT delete the outside-add):
1. A6.P5 hasExitPortal topology widening DELETED. Outdoor cells enter the
collision cell array ONLY on the retail straddle gate - |dist| <
radius + F_EPSILON against an exterior portal plane
(CEnvCell::find_transit_cells Ghidra 0x0052c820, gate 0052c9d6,
live-binary verified) - the same flag that already gated the
membership pick (#112 rider). The widening existed so outdoor-
registered doors stayed findable from indoor cells under the old flat
registry query; with per-cell shadow lists the door is found in the
straddle-admitted outdoor cell's own list (tick-13558 pin holds).
The hasExitPortal out-param + plumbing deleted from
FindTransitCellsSphere; the AddAllOutsideCells call in
BuildCellSetAndPickContaining re-gated on exitOutsideStraddle
(once-per-walk = retail CELLARRAY.added_outside).
2. #90 ResolveCellId sphere-overlap stickiness REMOVED (the 4ca3596
workaround, deferred-to-A6.P4 in the physics digest). It was dead
code: the method's only caller is FindEnvCollisions' cache-null TEST
fallback, and the indoor branch (where the stickiness lived) required
a non-null DataCache. Production membership flows exclusively through
the collide-then-pick advance whose ordered-array hysteresis (current
cell at index 0, interior-wins-break) is the retail mechanism the
workaround approximated. ResolveCellId reduced to the bare
prefix-preserving outdoor re-derive, documented test-only.
Test updates (pins of the deleted behaviors inverted to retail):
- A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell (asserted the
topology widening verbatim) -> DeepInteriorSphere_NoStraddle_
AddsNoOutdoorCells: a deep-interior sphere admits NO outdoor cells.
- A6P5_BuildCellSetFromAlcove... -> AlcoveSphere_StraddlesExitPortal_
ReachesDoorOutdoorCell (the captured alcove position genuinely
straddles - the retail-positive half).
- Issue112MembershipTests straddle pin + the second-sphere straddle test
updated to the single-flag signature.
Suites: Core 1416/0/2, App 225, UI 420, Net 294 - green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1169 lines
56 KiB
C#
1169 lines
56 KiB
C#
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);
|
||
|
||
/// <summary>
|
||
/// Top-level physics resolver that combines <see cref="TerrainSurface"/> and
|
||
/// <see cref="CellSurface"/> to resolve entity movement with step-height
|
||
/// enforcement and outdoor/indoor cell transitions.
|
||
///
|
||
/// <para>
|
||
/// Landblocks are registered via <see cref="AddLandblock"/> with their
|
||
/// terrain, indoor cells, and world-space offsets. <see cref="Resolve"/>
|
||
/// 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.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class PhysicsEngine
|
||
{
|
||
private readonly Dictionary<uint, LandblockPhysics> _landblocks = new();
|
||
|
||
/// <summary>Number of registered landblocks (diagnostic).</summary>
|
||
public int LandblockCount => _landblocks.Count;
|
||
|
||
/// <summary>
|
||
/// Cell-based spatial index for static object collision.
|
||
/// Populated during landblock streaming; queried by the Transition system.
|
||
/// </summary>
|
||
public ShadowObjectRegistry ShadowObjects { get; } = new();
|
||
|
||
/// <summary>
|
||
/// Physics BSP cache shared with the streaming loader. Set once by the
|
||
/// host (GameWindow) immediately after construction. The Transition system
|
||
/// reads this during FindObjCollisionsInCell to perform narrow-phase BSP
|
||
/// tests. BR-7: propagated into <see cref="ShadowObjects"/> so the
|
||
/// registration-side flood (<see cref="CellTransit.BuildShadowCellSet"/>)
|
||
/// can traverse cells + buildings.
|
||
/// </summary>
|
||
public PhysicsDataCache? DataCache
|
||
{
|
||
get => _dataCache;
|
||
set { _dataCache = value; ShadowObjects.DataCache = value; }
|
||
}
|
||
private PhysicsDataCache? _dataCache;
|
||
|
||
private sealed record LandblockPhysics(
|
||
TerrainSurface Terrain,
|
||
IReadOnlyList<CellSurface> Cells,
|
||
IReadOnlyList<PortalPlane> Portals,
|
||
float WorldOffsetX,
|
||
float WorldOffsetY);
|
||
|
||
/// <summary>
|
||
/// Register a landblock with its terrain surface, indoor cells, portal
|
||
/// planes, and world-space origin offset.
|
||
/// </summary>
|
||
public void AddLandblock(uint landblockId, TerrainSurface terrain,
|
||
IReadOnlyList<CellSurface> cells, IReadOnlyList<PortalPlane> portals,
|
||
float worldOffsetX, float worldOffsetY)
|
||
{
|
||
_landblocks[landblockId] = new LandblockPhysics(terrain, cells, portals, worldOffsetX, worldOffsetY);
|
||
|
||
// UCG Stage 1: mirror terrain into the unified graph (inert this stage).
|
||
DataCache?.CellGraph.RegisterTerrain(landblockId, terrain, new Vector3(worldOffsetX, worldOffsetY, 0f));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Remove a previously registered landblock, including its shadow objects.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Find the landblock that contains the given world-space XY position and
|
||
/// return its ID plus world-space origin offsets. Returns false when no
|
||
/// registered landblock covers the position.
|
||
/// Used by Transition.FindObjCollisions to build the shadow-object query.
|
||
/// </summary>
|
||
public bool TryGetLandblockContext(float worldX, float worldY,
|
||
out uint landblockId, out float worldOffsetX, out float worldOffsetY)
|
||
{
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldX - lb.WorldOffsetX;
|
||
float localY = worldY - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
{
|
||
landblockId = kvp.Key;
|
||
worldOffsetX = lb.WorldOffsetX;
|
||
worldOffsetY = lb.WorldOffsetY;
|
||
return true;
|
||
}
|
||
}
|
||
landblockId = 0;
|
||
worldOffsetX = 0f;
|
||
worldOffsetY = 0f;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample the outdoor terrain Z at the given world-space XY position.
|
||
/// Searches all registered landblocks; returns null if no landblock covers the position.
|
||
/// Used by Transition.FindEnvCollisions for terrain collision resolution.
|
||
/// </summary>
|
||
public float? SampleTerrainZ(float worldX, float worldY)
|
||
{
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldX - lb.WorldOffsetX;
|
||
float localY = worldY - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
return lb.Terrain.SampleZ(localX, localY);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample the per-point water depth at the given world-space XY
|
||
/// (meters by which the character is allowed to sink below the
|
||
/// contact plane — 0.9 on fully-flooded water cells, 0.45 on
|
||
/// partial-water near a water corner, 0.1 on non-water corners of
|
||
/// partial-water cells, 0 on dry cells). Matches ACE
|
||
/// <c>ObjCell.get_water_depth</c>. Used by
|
||
/// <see cref="Transition"/> to visually submerge characters in water
|
||
/// without needing a separate water surface mesh.
|
||
/// </summary>
|
||
public float SampleWaterDepth(float worldX, float worldY)
|
||
{
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldX - lb.WorldOffsetX;
|
||
float localY = worldY - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
return lb.Terrain.SampleWaterDepth(localX, localY);
|
||
}
|
||
return 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample the outdoor terrain plane (Z + sloped normal) at the given
|
||
/// world-space XY position. The returned <see cref="System.Numerics.Plane"/>
|
||
/// has the true terrain-triangle normal (NOT a flat <c>(0,0,1)</c>), and
|
||
/// its <c>D</c> is set so the plane passes through the sampled point. Used
|
||
/// by <see cref="Transition"/> to build a CORRECT contact plane — a flat
|
||
/// plane breaks slope tracking because <c>AdjustOffset</c>'s projection
|
||
/// onto a flat plane cannot impart the Z component that horizontal
|
||
/// velocity needs to follow the slope.
|
||
/// </summary>
|
||
public System.Numerics.Plane? SampleTerrainPlane(float worldX, float worldY)
|
||
{
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldX - lb.WorldOffsetX;
|
||
float localY = worldY - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
{
|
||
var (z, normal) = lb.Terrain.SampleSurface(localX, localY);
|
||
// System.Numerics.Plane convention: dot(Normal, P) + D == 0
|
||
// for points P on the plane. Pick P = (worldX, worldY, z).
|
||
float d = -(normal.X * worldX + normal.Y * worldY + normal.Z * z);
|
||
return new System.Numerics.Plane(normal, d);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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
|
||
/// <see cref="SampleTerrainWalkable"/> without exposing the internal
|
||
/// <c>TerrainWalkableSample</c> shape.
|
||
/// </summary>
|
||
public Vector3? SampleTerrainNormal(float worldX, float worldY)
|
||
{
|
||
var sample = SampleTerrainWalkable(worldX, worldY);
|
||
return sample?.Plane.Normal;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample the outdoor terrain walkable triangle at the given world-space
|
||
/// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/>
|
||
/// plus world-space triangle vertices for retail precipice-slide.
|
||
/// </summary>
|
||
internal TerrainWalkableSample? SampleTerrainWalkable(float worldX, float worldY)
|
||
{
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldX - lb.WorldOffsetX;
|
||
float localY = worldY - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
{
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a
|
||
/// given world position via retail's portal-graph traversal for indoor
|
||
/// cells, or via terrain grid lookup for outdoor cells.
|
||
///
|
||
/// <para>
|
||
/// Indoor seed: delegates to <see cref="CellTransit.FindCellList"/> which
|
||
/// BFS-walks the portal graph and uses <see cref="BSPQuery.PointInsideCellBsp"/>
|
||
/// for containment. This replaces Phase D's AABB shortcut.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Outdoor seed: uses the registered landblock terrain grid to compute
|
||
/// the correct prefixed cell ID, preserving the pre-existing outdoor
|
||
/// resolution behavior (the L.2e prefix-preservation fix).
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Design: <c>docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md</c>
|
||
/// </para>
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Set the render root cell — <see cref="World.Cells.CellGraph.CurrCell"/>, which IS
|
||
/// "the PLAYER's cell" (CellGraph.cs:19) and roots the indoor render
|
||
/// (GameWindow.OnRender). Call ONLY for the local player, from
|
||
/// <c>PlayerMovementController.UpdateCellId</c> — the single player chokepoint for CellId
|
||
/// (teleport / server snap / per-frame resolver).
|
||
///
|
||
/// <para>
|
||
/// 2026-06-03: this write was previously inside the per-entity <see cref="ResolveWithTransition"/>
|
||
/// (every NPC / remote calls that). A Holtburg NPC jump-looping near the cottage doorway
|
||
/// clobbered the player's render root every tick → the render rooted at the NPC's tiny
|
||
/// connector cell (0170) instead of the player's room (0171) → only that cell's ~8-triangle
|
||
/// shell drew, the rest showing the GL clear color = the cottage doorway "blue-hole" flap.
|
||
/// Moving the write to the player-only chokepoint fixes it: NPCs no longer touch CurrCell.
|
||
/// </para>
|
||
///
|
||
/// <para>Leaves CurrCell unchanged when the id isn't resolvable in the graph yet
|
||
/// (stale beats null), matching the prior behavior. Retail anchor:
|
||
/// CObjCell::change_cell sets the object's curr_cell; only the player's drives the viewer.</para>
|
||
/// </summary>
|
||
public void UpdatePlayerCurrCell(uint cellId)
|
||
{
|
||
if (DataCache?.CellGraph is { } cg && cg.GetVisible(cellId) is { } cell)
|
||
cg.CurrCell = cell;
|
||
}
|
||
|
||
/// <summary>
|
||
/// TEST-ONLY outdoor cell re-derive. The single caller is
|
||
/// <c>Transition.FindEnvCollisions</c>'s cache-null fallback
|
||
/// (PhysicsEngineTests run engines without a <see cref="DataCache"/>,
|
||
/// so <see cref="CellTransit.FindCellSet"/> is unavailable). Production
|
||
/// membership flows exclusively through the collide-then-pick advance
|
||
/// (<c>RunCheckOtherCellsAndAdvance</c> → <c>FindCellSet</c>).
|
||
///
|
||
/// <para>
|
||
/// BR-7 / A6.P4 C4 (2026-06-11): the former indoor branch — including
|
||
/// the #90 sphere-overlap stickiness workaround (4ca3596) and the
|
||
/// building-transit promotion — was DEAD CODE on this path (it required
|
||
/// a non-null DataCache; the only caller guarantees null) and is
|
||
/// removed. #90's doorway ping-pong concern is owned by the retail
|
||
/// ordered-pick hysteresis (current cell at array index 0,
|
||
/// interior-wins-break; CellTransit.BuildCellSetAndPickContaining) —
|
||
/// the workaround is retired, closing the digest's deferred-removal
|
||
/// item.
|
||
/// </para>
|
||
///
|
||
/// <para>Preserves the L.2e prefix-preservation fix (always apply the
|
||
/// matched landblock's high-16 prefix even when
|
||
/// <paramref name="fallbackCellId"/> arrived bare-low-byte).</para>
|
||
/// </summary>
|
||
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
|
||
{
|
||
if (fallbackCellId == 0) return 0;
|
||
|
||
// Indoor fallback ids pass through unchanged — identical to the old
|
||
// dead path's `DataCache is null → return fallbackCellId` outcome.
|
||
if ((fallbackCellId & 0xFFFFu) >= 0x0100u) return fallbackCellId;
|
||
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldPos.X - lb.WorldOffsetX;
|
||
float localY = worldPos.Y - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
{
|
||
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
|
||
return (kvp.Key & 0xFFFF0000u) | lowCellId;
|
||
}
|
||
}
|
||
|
||
return fallbackCellId;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Verbatim port of <c>CPhysicsObj::AdjustPosition</c>
|
||
/// (<c>acclient_2013_pseudo_c.txt:280009</c>): resolve which cell actually
|
||
/// contains <paramref name="worldPoint"/>, given a seed cell. Indoor
|
||
/// (<c>objcell_id ≥ 0x100</c>, :280020) → <see cref="CellTransit.FindVisibleChildCell"/>
|
||
/// in stab-list mode (retail <c>arg5 = 1</c>, :280028); outdoor (:280050) →
|
||
/// snap to the landcell under the point (retail <c>LandDefs::adjust_to_outside</c>,
|
||
/// the same grid lookup <see cref="ResolveCellId"/> uses). Returns
|
||
/// <c>found = false</c> with the seed id unchanged when no cell resolves
|
||
/// (retail <c>return 0</c>, :280065).
|
||
///
|
||
/// <para>
|
||
/// <c>SmartBox::update_viewer</c> calls this to seat the camera sweep's start
|
||
/// cell at the head-pivot (:280032, indoor branch only) and again as fallback 1
|
||
/// at the sought eye (:280078). The player snap path
|
||
/// (<c>SetPositionInternal</c> :283908 → our <see cref="Resolve"/>) 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// #107 (2026-06-10) completed the previously-deferred indoor
|
||
/// <c>seen_outside → adjust_to_outside</c> sub-fallback (:280037-280046): when
|
||
/// the claimed cell is hydrated, nothing in its visible graph contains the
|
||
/// point, and the cell has outdoor-visible portals, retail demotes to the
|
||
/// landcell under the point. The corner-seal replay (`b21bb28`) shows camera
|
||
/// eyes always land inside cells/openings, so the camera path does not reach
|
||
/// this sub-branch in the gated scenarios (CameraCornerSealReplayTests stays
|
||
/// green).
|
||
/// </para>
|
||
/// </summary>
|
||
/// <summary>
|
||
/// #111: the walkable floor Z of <paramref name="cellId"/>'s PHYSICS
|
||
/// polygons under the world XY, nearest to <paramref name="referenceZ"/>.
|
||
/// Walkable = plane normal.Z ≥ <see cref="PhysicsGlobals.FloorZ"/> (retail
|
||
/// BSPTREE::find_walkable's filter) — ceilings/roof tops never qualify,
|
||
/// unlike the <see cref="CellSurface"/> triangle soup. Resolved polygons
|
||
/// are CELL-LOCAL: transform in, drop on the plane, transform out.
|
||
/// Returns null when the claim has no hydrated struct or no walkable
|
||
/// under the XY.
|
||
/// </summary>
|
||
private float? WalkableFloorZNearest(uint cellId, Vector3 worldPos, float referenceZ)
|
||
{
|
||
var cp = DataCache?.GetCellStruct(cellId);
|
||
if (cp is null) return null;
|
||
|
||
var local = Vector3.Transform(
|
||
new Vector3(worldPos.X, worldPos.Y, referenceZ), cp.InverseWorldTransform);
|
||
|
||
float? best = null;
|
||
float bestDist = float.MaxValue;
|
||
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;
|
||
}
|
||
|
||
/// <summary>Even-odd XY-projection point-in-polygon test (cell-local frame).</summary>
|
||
private static bool PointInPolygonXY(IReadOnlyList<Vector3> verts, float x, float y)
|
||
{
|
||
bool inside = false;
|
||
for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
|
||
{
|
||
var vi = verts[i]; var vj = verts[j];
|
||
if ((vi.Y > y) != (vj.Y > y)
|
||
&& x < (vj.X - vi.X) * (y - vi.Y) / (vj.Y - vi.Y) + vi.X)
|
||
inside = !inside;
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
/// <summary>
|
||
/// #107: does any loaded landblock carry a <see cref="CellSurface"/> 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).
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// #107 auto-entry hold (gate-2 extension, 2026-06-10): true when the
|
||
/// server-claimed spawn cell is ready for <see cref="AdjustPosition"/> to
|
||
/// act on. Outdoor claims need only terrain (the existing gate). Indoor
|
||
/// claims wait until the claimed cell's struct is hydrated — the async-
|
||
/// streaming equivalent of retail's synchronous cell load before
|
||
/// SetPosition.
|
||
///
|
||
/// <para>
|
||
/// ⚠️ The first version disambiguated "claim bogus" via "any cell struct
|
||
/// in the landblock present" — WRONG: interiors hydrate in id order on the
|
||
/// background worker, so the render-thread predicate can observe the
|
||
/// mid-population state (early cells present, the claim not yet) and open
|
||
/// the gate before AdjustPosition's stab search can act (the 2026-06-10
|
||
/// gate-run regression: claim 0xA9B40172 committed raw → outdoor demote on
|
||
/// first movement → transparent interior). Claims that can NEVER hydrate
|
||
/// (id outside the landblock's NumCells range) are now filtered by the
|
||
/// caller against the dat, and <see cref="Resolve"/> carries a loud
|
||
/// outdoor-demote safety net for any unhydrated indoor claim that still
|
||
/// gets through.
|
||
/// </para>
|
||
/// </summary>
|
||
public bool IsSpawnCellReady(uint cellId)
|
||
{
|
||
if ((cellId & 0xFFFFu) < 0x0100u) return true;
|
||
return DataCache?.GetCellStruct(cellId) is not null;
|
||
}
|
||
|
||
public (uint cellId, bool found) AdjustPosition(uint seedCellId, Vector3 worldPoint)
|
||
{
|
||
if (seedCellId == 0u) return (seedCellId, false);
|
||
|
||
if ((seedCellId & 0xFFFFu) >= 0x0100u)
|
||
{
|
||
// Indoor: find_visible_child_cell(this, point, arg3 = 1) (:280028).
|
||
if (DataCache is null) return (seedCellId, false);
|
||
uint child = CellTransit.FindVisibleChildCell(DataCache, seedCellId, worldPoint, useStabList: true);
|
||
if (child != 0u) return (child, true);
|
||
|
||
// Retail :280037-280046: claimed cell hydrated + seen_outside →
|
||
// Position::adjust_to_outside (fall through to the grid snap below).
|
||
// A non-hydrated or not-seen-outside claim stays (seed, false) —
|
||
// retail's lost-cell path; our callers keep their legacy fallback.
|
||
var claimed = DataCache.GetCellStruct(seedCellId);
|
||
if (claimed is null || !claimed.SeenOutside)
|
||
return (seedCellId, false);
|
||
}
|
||
|
||
// Outdoor: LandDefs::adjust_to_outside — snap to the landcell under the
|
||
// point (same grid lookup as ResolveCellId, lines 363-371). No building
|
||
// re-entry here: AdjustPosition's outdoor branch is the bare landcell snap.
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldPoint.X - lb.WorldOffsetX;
|
||
float localY = worldPoint.Y - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
{
|
||
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
|
||
return ((kvp.Key & 0xFFFF0000u) | lowCellId, true);
|
||
}
|
||
}
|
||
|
||
return (seedCellId, false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolve an entity's movement from <paramref name="currentPos"/> by
|
||
/// applying <paramref name="delta"/> (XY only) and computing the correct Z
|
||
/// from the terrain or indoor cell floor beneath the candidate position.
|
||
///
|
||
/// <para>
|
||
/// Step-height enforcement rejects horizontal movement when the upward Z
|
||
/// change exceeds <paramref name="stepUpHeight"/>. Downhill movement is
|
||
/// always accepted. Returns <see cref="ResolveResult.IsOnGround"/> false
|
||
/// when no loaded landblock covers the candidate position.
|
||
/// </para>
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolve movement using the CTransition sphere-sweep system.
|
||
/// Subdivides movement into sphere-radius steps, tests terrain collision
|
||
/// at each step, handles step-down for ground contact.
|
||
/// Falls back to the simple <see cref="Resolve"/> if the transition fails.
|
||
///
|
||
/// <para>
|
||
/// <paramref name="body"/> is optional but highly recommended for movement
|
||
/// that runs across multiple frames. When provided, the previous frame's
|
||
/// contact plane is copied INTO the transition's CollisionInfo (mirroring
|
||
/// retail's <c>PhysicsObj.get_object_info → InitContactPlane</c> at
|
||
/// <c>PhysicsObj.cs:2598-2604</c>). That seed is critical for slope
|
||
/// tracking: <c>AdjustOffset</c> projects the Euler offset onto the plane
|
||
/// so horizontal velocity acquires the correct Z component for the slope,
|
||
/// preventing the character from floating on downhill runs where the
|
||
/// per-frame descent exceeds the 4 cm step-down budget.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// On return, the plane discovered during this call is written BACK to
|
||
/// <paramref name="body"/>, so the next frame's transition starts with
|
||
/// an up-to-date plane seed. Callers without a persistent body (tests,
|
||
/// one-shot movements) can pass <c>null</c> and accept the first-frame
|
||
/// hiccup.
|
||
/// </para>
|
||
/// </summary>
|
||
public ResolveResult ResolveWithTransition(
|
||
Vector3 currentPos, Vector3 targetPos, uint cellId,
|
||
float sphereRadius, float sphereHeight,
|
||
float stepUpHeight, float stepDownHeight,
|
||
bool isOnGround,
|
||
PhysicsBody? body = null,
|
||
ObjectInfoState moverFlags = ObjectInfoState.None,
|
||
uint movingEntityId = 0)
|
||
{
|
||
// 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;
|
||
}
|
||
}
|