Merge branch 'main' into claude/eloquent-hugle-42119e
# Conflicts: # .gitignore
This commit is contained in:
commit
093cdb6d57
38 changed files with 5852 additions and 216 deletions
|
|
@ -882,8 +882,17 @@ public sealed class PlayerMovementController
|
|||
// Falls back to simple Z-snap if transition fails.
|
||||
var resolveResult = _physics.ResolveWithTransition(
|
||||
preIntegratePos, postIntegratePos, CellId,
|
||||
sphereRadius: 0.48f, // human player radius from Setup
|
||||
sphereHeight: 1.2f, // human player height from Setup
|
||||
sphereRadius: 0.48f, // human Setup 0x02000001 sphere radius (dat: 0.480)
|
||||
// #137 window climb (2026-07-06): sphereHeight is the CAPSULE TOP
|
||||
// (InitPath places the head sphere center at height − radius). The
|
||||
// dat human Setup 0x02000001 has Spheres[1].Origin.Z = 1.350
|
||||
// (top 1.830) and Height = 1.835 — retail collides with that
|
||||
// sphere list verbatim (CPhysicsObj::transition 0x00512dc0 →
|
||||
// init_sphere(GetNumSphere, GetSphere, scale)). The old 1.2f put
|
||||
// the head sphere center at 0.72 — the top 0.63 m of the
|
||||
// character had NO collision, letting the player climb into a
|
||||
// 1.3 m window alcove head-through-lintel. Register TS-46.
|
||||
sphereHeight: 1.835f,
|
||||
stepUpHeight: StepUpHeight,
|
||||
stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight
|
||||
isOnGround: _body.OnWalkable,
|
||||
|
|
|
|||
|
|
@ -3714,7 +3714,8 @@ public sealed class GameWindow : IDisposable
|
|||
ownerId: entity.Id,
|
||||
entityPosition: entity.Position,
|
||||
entityRotation: entity.Rotation,
|
||||
isDynamic: true); // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5)
|
||||
isDynamic: true, // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5)
|
||||
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
|
||||
foreach (var ls in loaded)
|
||||
_lightingSink.RegisterOwnedLight(ls);
|
||||
}
|
||||
|
|
@ -4193,9 +4194,22 @@ public sealed class GameWindow : IDisposable
|
|||
// span the doorway gap, so the player could walk through. With
|
||||
// this change the door also registers the part-0 BSP slab
|
||||
// (1.9 × 0.26 × 2.5 m) that retail uses for the real block.
|
||||
// #175 (2026-07-05): BSP part shapes must pose at the motion table's
|
||||
// DEFAULT-STATE frame (the closed pose — what the sequencer renders
|
||||
// for an idle entity and what retail's live CPhysicsPart pose is),
|
||||
// not the Setup's placement frame. The Facility Hub double door
|
||||
// (Setup 0x02000C9D) places its panels AJAR in the placement frame
|
||||
// (yaw −150°/−30°, −0.44 m behind the doorway) while rendering
|
||||
// closed — the user embedded into the visual door on one side and
|
||||
// hit a phantom slab on the other. Null (no motion table / no
|
||||
// cycle / part-count mismatch) falls back to placement frames.
|
||||
var closedPose = MotionTableDefaultPose(
|
||||
spawn.MotionTableId ?? 0u, setup.Parts.Count);
|
||||
|
||||
var raw = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup(
|
||||
setup, entScale,
|
||||
id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null);
|
||||
id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null,
|
||||
partPoseOverride: closedPose);
|
||||
|
||||
// Substitute the real bounding-sphere radius for BSP shapes —
|
||||
// the pure builder's 2.0 placeholder works for typical doors
|
||||
|
|
@ -4284,6 +4298,37 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #175: the motion table's default-state pose (the closed pose for
|
||||
/// doors) — the derivation lives in
|
||||
/// <see cref="AcDream.Core.Physics.Motion.MotionTablePose"/> (Core,
|
||||
/// dat-conformance-tested; the first cut here used a bare-style cycle
|
||||
/// key, always missed, and silently no-oped — the "175 is not fixed"
|
||||
/// report). Returns null → placement-frame fallback.
|
||||
/// </summary>
|
||||
private IReadOnlyList<DatReaderWriter.Types.Frame>? MotionTableDefaultPose(
|
||||
uint motionTableId, int partCount)
|
||||
{
|
||||
if (motionTableId == 0u || partCount == 0 || _dats is null) return null;
|
||||
|
||||
var mt = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(motionTableId);
|
||||
if (mt is null) return null;
|
||||
|
||||
var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames(
|
||||
mt, id => _dats.Get<DatReaderWriter.DBObjs.Animation>(id));
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
string desc = pose is null ? "null->placement-fallback"
|
||||
: System.FormattableString.Invariant(
|
||||
$"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})");
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {desc}"));
|
||||
}
|
||||
|
||||
return pose;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3-W4: one-time per-remote wiring of the animation-dispatch stack —
|
||||
/// the persistent <see cref="RemoteMotion.Sink"/> (ObservedOmega turn
|
||||
|
|
@ -4318,7 +4363,20 @@ public sealed class GameWindow : IDisposable
|
|||
rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
|
||||
};
|
||||
rm.Motion.DefaultSink = rm.Sink;
|
||||
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations;
|
||||
// #174 (2026-07-05): the RemoveLinkAnimations seam is retail
|
||||
// CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to
|
||||
// CPartArray::HandleEnterWorld 0x00517d70 →
|
||||
// MotionTableManager::HandleEnterWorld 0x0051bdd0: strip the
|
||||
// sequence's link animations AND drain pending_animations
|
||||
// completely (each pop fires MotionDone → CMotionInterp pops its
|
||||
// pending_motions node in lockstep). The previous binding was the
|
||||
// bare sequence strip — every LeaveGround (jump) stripped the
|
||||
// animations that queued manager nodes were counting down on,
|
||||
// orphaning them (NumAnims>0, anims gone) and permanently damming
|
||||
// BOTH queues: MotionsPending() never drained again, so every
|
||||
// armed moveto (ACE's walk-to-door mt-6, the close-range use turn)
|
||||
// starved — the "door only works on a fresh session" bug.
|
||||
rm.Motion.RemoveLinkAnimations = () => ae.Sequencer.Manager.HandleEnterWorld();
|
||||
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
|
||||
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
|
||||
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
|
||||
|
|
@ -7670,7 +7728,8 @@ public sealed class GameWindow : IDisposable
|
|||
datSetup,
|
||||
ownerId: entity.Id,
|
||||
entityPosition: entity.Position,
|
||||
entityRotation: entity.Rotation);
|
||||
entityRotation: entity.Rotation,
|
||||
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
|
||||
foreach (var ls in loaded)
|
||||
_lightingSink.RegisterOwnedLight(ls);
|
||||
}
|
||||
|
|
@ -9350,6 +9409,11 @@ public sealed class GameWindow : IDisposable
|
|||
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
||||
Camera = camera,
|
||||
CameraWorldPosition = camPos,
|
||||
// A7 #176/#177: once DrawInside has resolved the visible-cell set,
|
||||
// rebuild the point-light pool from ONLY those cells' lights (retail's
|
||||
// per-frame add_*_lights over visible_cell_table). The renderers hold a
|
||||
// reference to the same PointSnapshot list, rebuilt in place here.
|
||||
RebuildScopedLights = visible => Lighting.BuildPointLightSnapshot(camPos, visible),
|
||||
Frustum = frustum,
|
||||
PlayerLandblockId = playerLb,
|
||||
AnimatedEntityIds = animatedIds,
|
||||
|
|
@ -10461,14 +10525,16 @@ public sealed class GameWindow : IDisposable
|
|||
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
|
||||
{
|
||||
// Sphere dims match local-player defaults (human Setup
|
||||
// bounds — ~0.48m radius, ~1.2m height). Good enough for
|
||||
// 0x02000001: sphere radius 0.480, capsule top 1.835 =
|
||||
// Setup.Height; see the #137 TS-46 note at the
|
||||
// PlayerMovementController call). Good enough for
|
||||
// grounded humanoid remotes; can be setup-derived later
|
||||
// if creatures of wildly different sizes need different
|
||||
// collision profiles.
|
||||
var resolveResult = _physicsEngine.ResolveWithTransition(
|
||||
preIntegratePos, postIntegratePos, rm.CellId,
|
||||
sphereRadius: 0.48f,
|
||||
sphereHeight: 1.2f,
|
||||
sphereHeight: 1.835f,
|
||||
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
|
||||
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
|
||||
// K-fix9 (2026-04-26): mirror the K-fix7 gate —
|
||||
|
|
@ -10496,6 +10562,55 @@ public sealed class GameWindow : IDisposable
|
|||
if (resolveResult.CellId != 0)
|
||||
rm.CellId = resolveResult.CellId;
|
||||
|
||||
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
|
||||
// (pc:282699-282715) runs after EVERY SetPositionInternal —
|
||||
// remote objects included; a VectorUpdate-launched jump arc
|
||||
// is ordinary object physics in retail. acdream ported the
|
||||
// velocity reflection for the LOCAL player only (L.3a,
|
||||
// PlayerMovementController ~:940), so a remote jumping into
|
||||
// a dungeon ceiling had its POSITION pinned by the sweep
|
||||
// while its +Z velocity kept integrating — the char hovered
|
||||
// at the roof until gravity burned the arc off, landing
|
||||
// late (user report, 0x0007 dungeon). Mirror the local
|
||||
// site exactly:
|
||||
// v_new = v − (1 + elasticity)·dot(v, n)·n
|
||||
// with the AD-25 suppression (bounce only when airborne
|
||||
// before AND after — corridor slides and landings don't
|
||||
// reflect; the landing snap below keeps its
|
||||
// `Velocity.Z <= 0` gate intact). Inelastic movers
|
||||
// (missiles, later) zero out instead.
|
||||
if (resolveResult.CollisionNormalValid)
|
||||
{
|
||||
bool prevOnWalkable = rm.Body.OnWalkable;
|
||||
bool nowOnWalkable = resolveResult.IsOnGround;
|
||||
bool applyBounce = rm.Body.State.HasFlag(
|
||||
AcDream.Core.Physics.PhysicsStateFlags.Sledding)
|
||||
? !(prevOnWalkable && nowOnWalkable)
|
||||
: (!prevOnWalkable && !nowOnWalkable);
|
||||
if (applyBounce)
|
||||
{
|
||||
if (rm.Body.State.HasFlag(
|
||||
AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
|
||||
{
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
var vRem = rm.Body.Velocity;
|
||||
var nRem = resolveResult.CollisionNormal;
|
||||
float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
|
||||
if (dotVN < 0f)
|
||||
{
|
||||
rm.Body.Velocity =
|
||||
vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
Console.WriteLine(
|
||||
$"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// K-fix15 (2026-04-26): post-resolve landing
|
||||
// detection for airborne remotes. Mirrors
|
||||
// PlayerMovementController's local-player landing
|
||||
|
|
@ -13699,7 +13814,14 @@ public sealed class GameWindow : IDisposable
|
|||
// R3-W4: bind the player interp's retail seams to the player
|
||||
// sequencer — LeaveGround/HitGround strip transition links here
|
||||
// (the K-fix18 replacement).
|
||||
_playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations;
|
||||
// #174 (2026-07-05): the seam is HandleEnterWorld (strip + FULL
|
||||
// queue drain), not the bare sequence strip — see the remote
|
||||
// bind site's comment (retail 0x0050fe20 → 0x00517d70 →
|
||||
// 0x0051bdd0). The bare strip orphaned every pending manager
|
||||
// node on each jump's LeaveGround; the local player's
|
||||
// pending_motions then never drained and every armed moveto
|
||||
// starved (#174: doors unusable after the first jump/run).
|
||||
_playerController.Motion.RemoveLinkAnimations = () => playerSeq.Manager.HandleEnterWorld();
|
||||
_playerController.Motion.InitializeMotionTables =
|
||||
() => playerSeq.Manager.InitializeState();
|
||||
_playerController.Motion.CheckForCompletedMotions =
|
||||
|
|
|
|||
|
|
@ -146,6 +146,12 @@ public sealed class RetailPViewRenderer
|
|||
prepareCells = _lookInPrepareScratch;
|
||||
}
|
||||
|
||||
// A7 #176/#177: scope this frame's point-light pool to the cells actually being
|
||||
// drawn, NOW that the flood has resolved the visible set (retail collects lights
|
||||
// per-frame over visible_cell_table). Must run before the cell/entity draws below
|
||||
// that select from LightManager.PointSnapshot.
|
||||
ctx.RebuildScopedLights?.Invoke(prepareCells);
|
||||
|
||||
_envCells.PrepareRenderBatches(
|
||||
ctx.ViewProjection,
|
||||
ctx.CameraWorldPosition,
|
||||
|
|
@ -1102,6 +1108,16 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
|
|||
public Action? DrawUnattachedSceneParticles { get; init; }
|
||||
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
|
||||
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
||||
|
||||
/// <summary>A7 #176/#177: rebuild the point-light snapshot scoped to the cells
|
||||
/// this frame actually draws — invoked AFTER the portal flood resolves the visible
|
||||
/// set and BEFORE any cell/entity draw (the faithful port of retail's per-frame
|
||||
/// light collection: <c>CObjCell::add_*_to_global_lights</c> walked over
|
||||
/// <c>CEnvCell::visible_cell_table</c>). The argument is every cell drawn this frame
|
||||
/// (main flood + interior-root look-ins). A cell-less light (viewer fill) is kept
|
||||
/// regardless. Null-safe: outdoor/no-flood callers leave it unset and keep the
|
||||
/// legacy full-pool snapshot.</summary>
|
||||
public Action<IReadOnlySet<uint>>? RebuildScopedLights { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RetailPViewFrameResult
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ public static class LightInfoLoader
|
|||
uint ownerId,
|
||||
Vector3 entityPosition,
|
||||
Quaternion entityRotation,
|
||||
bool isDynamic = false)
|
||||
bool isDynamic = false,
|
||||
uint cellId = 0)
|
||||
{
|
||||
var results = new List<LightSource>();
|
||||
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
|
||||
|
|
@ -89,6 +90,7 @@ public static class LightInfoLoader
|
|||
Range = info.Falloff * (isDynamic ? 1.5f : 1.3f),
|
||||
ConeAngle = info.ConeAngle,
|
||||
OwnerId = ownerId,
|
||||
CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177)
|
||||
IsLit = true,
|
||||
IsDynamic = isDynamic,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -176,8 +176,27 @@ public sealed class LightManager
|
|||
public const int MaxLightsPerObject = 8;
|
||||
|
||||
/// <summary>Hard cap on the per-frame global point-light snapshot the shader
|
||||
/// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128
|
||||
/// is generous. If exceeded, the nearest-to-camera are kept (cold path).</summary>
|
||||
/// indexes. ⚠️ LOAD-BEARING STOPGAP — read before touching (#176/#177,
|
||||
/// 2026-07-06): this cap BITES in the Facility Hub (366 registered fixtures →
|
||||
/// 238 camera-distance evictions/frame), and the eviction is the CONFIRMED
|
||||
/// mechanism of the #176 purple seam flash + the #177 stair-room light
|
||||
/// pop-in — an in-range torch of a visible cell that ranks past the cap
|
||||
/// drops out of that cell's 8-set, so per-cell Gouraud lighting pops as the
|
||||
/// camera moves. Retail's <c>minimize_object_lighting</c> (0x0054d480) has
|
||||
/// NO global camera-nearest cap. HOWEVER: raising the cap to 1024 was
|
||||
/// live-tested 2026-07-06 and REVERTED — with the full pool active, three
|
||||
/// unported retail lighting semantics dominate the frame: (a) lights reach
|
||||
/// THROUGH solid floors/walls (retail registers lights per-CELL via
|
||||
/// <c>insert_light</c> 0x0054d1b0 — a portal's purple light below never
|
||||
/// touches the corridor above; our flat sphere-overlap selection has no
|
||||
/// reach/occlusion notion), (b) stationary weenie fixtures ride the DYNAMIC
|
||||
/// 1/d falloff (~9× stronger at 3 m than retail's static 1/d³ bake curve),
|
||||
/// (c) an unexplained striped z-fight-like artifact on lit floor regions
|
||||
/// (user screenshot, launch-176-texflush session). The proper fix is the
|
||||
/// A7 dungeon-lighting arc: per-cell light registration + the static curve
|
||||
/// for fixtures + the stripe hunt, THEN uncap. Register row AP-85; desired
|
||||
/// end-state pin (currently Skip):
|
||||
/// LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant.</summary>
|
||||
public const int MaxGlobalLights = 128;
|
||||
|
||||
private readonly List<LightSource> _pointSnapshot = new();
|
||||
|
|
@ -199,11 +218,33 @@ public sealed class LightManager
|
|||
/// per-object selection.
|
||||
/// </summary>
|
||||
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
|
||||
=> BuildPointLightSnapshot(cameraWorldPos, visibleCells: null);
|
||||
|
||||
/// <summary>
|
||||
/// Visible-cell-scoped snapshot build — the faithful port of retail's per-frame
|
||||
/// light collection (<c>CObjCell::add_*_to_global_lights</c> 0x0052b350/0x0052b390
|
||||
/// walked over <c>CEnvCell::visible_cell_table</c> 0x0052d410). When
|
||||
/// <paramref name="visibleCells"/> is non-null (an indoor root with a portal
|
||||
/// flood), a cell-tagged light is a candidate ONLY when its <see cref="LightSource.CellId"/>
|
||||
/// is in the visible set — a cell-less light (<c>CellId == 0</c>: the viewer fill,
|
||||
/// global lights) is always included. This is what (a) stops an under-room light
|
||||
/// washing THROUGH a solid floor (its cell isn't visibly flooded → excluded) and
|
||||
/// (b) bounds the pool to the handful of visible cells so <see cref="MaxGlobalLights"/>
|
||||
/// never evicts a visible cell's in-range light (the #176/#177 mechanism). When
|
||||
/// <paramref name="visibleCells"/> is null (outdoor root / no flood) the behaviour
|
||||
/// is unchanged from the legacy full-pool path.
|
||||
/// </summary>
|
||||
public void BuildPointLightSnapshot(Vector3 cameraWorldPos, IReadOnlySet<uint>? visibleCells)
|
||||
{
|
||||
_pointSnapshot.Clear();
|
||||
foreach (var light in _all)
|
||||
{
|
||||
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
||||
// Visible-cell scoping (retail add_*_lights over visible_cell_table). A
|
||||
// cell-less light (CellId == 0: viewer fill / global) is always a candidate;
|
||||
// a cell-tagged light joins the pool ONLY when its cell is visibly flooded.
|
||||
if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId))
|
||||
continue;
|
||||
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
|
||||
_pointSnapshot.Add(light);
|
||||
}
|
||||
|
|
@ -212,6 +253,11 @@ public sealed class LightManager
|
|||
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
|
||||
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
|
||||
}
|
||||
|
||||
// A7.L1 SET-COMPOSITION probe — only meaningful on the scoped (indoor) path.
|
||||
// Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1; the flag check keeps it zero-cost off.
|
||||
if (visibleCells is not null && AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(visibleCells.Count, _all, _pointSnapshot);
|
||||
}
|
||||
|
||||
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ public sealed class LightSource
|
|||
public float Range = 10f; // metres, hard cutoff
|
||||
public float ConeAngle = 0f; // radians, Spot only
|
||||
public uint OwnerId; // attached entity id; 0 = world-global
|
||||
public uint CellId; // owning cell id (0xLLLLNNNN); 0 = cell-less/global (viewer fill, sun).
|
||||
// Retail carries this on the RenderLight (insert_light arg6, +0x6c) so the
|
||||
// per-frame pool can be built from only the VISIBLE cells' lights
|
||||
// (CObjCell::add_*_to_global_lights over CEnvCell::visible_cell_table).
|
||||
// acdream uses it to scope BuildPointLightSnapshot — a cell-tagged light is
|
||||
// only a candidate when its cell is visibly flooded (#176/#177 A7 fix).
|
||||
public bool IsLit = true; // SetLightHook latch
|
||||
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
|
||||
// false = static dat-baked bake (1/d³, range×1.3)
|
||||
|
|
|
|||
|
|
@ -1399,26 +1399,35 @@ public static class BSPQuery
|
|||
|
||||
// -------------------------------------------------------------------------
|
||||
// slide_sphere — BSPTree level
|
||||
// ACE: BSPTree.cs slide_sphere
|
||||
// Retail: BSPTREE::slide_sphere (find_collisions Contact head-hit dispatch
|
||||
// at 0x0053a697). ACE: BSPTree.cs:310-316.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// BSPTree.slide_sphere — apply sliding collision response.
|
||||
/// BSPTree.slide_sphere — dispatch the real sphere-level slide
|
||||
/// (<c>CSphere::slide_sphere</c> 0x00537440, ported as
|
||||
/// <see cref="Transition.SlideSphereInternal"/>): slide IN-FRAME along the
|
||||
/// crease between the collision normal and the contact plane, applied to
|
||||
/// sphere 0's check position (ACE BSPTree.cs:315 —
|
||||
/// <c>GlobalSphere[0].SlideSphere(..., GlobalCurrCenter[0].Center)</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Sets the sliding normal on CollisionInfo so the outer transition loop
|
||||
/// applies a wall-slide projection.
|
||||
/// #137 mechanism 2 (2026-07-06): this was a stub that set
|
||||
/// <c>CollisionInfo.SlidingNormal</c> and returned Slid. Retail's BSP layer
|
||||
/// never writes the sliding normal — its only in-transition writer is
|
||||
/// <c>CTransition::validate_transition</c> (0x0050ac21) — so the stub's
|
||||
/// leaked normal survived to the body writeback and absorbed the next
|
||||
/// frame's exactly-anti-parallel offset: the Facility Hub corridor
|
||||
/// phantom's dead-stop half (ISSUES #137).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>ACE: BSPTree.cs slide_sphere — calls GlobalSphere[0].SlideSphere.</para>
|
||||
/// </summary>
|
||||
/// <param name="worldNormal">Collision normal already in world space (the
|
||||
/// call sites apply L2W; ACE globalizes inside slide_sphere instead).</param>
|
||||
private static TransitionState SlideSphere(
|
||||
Transition transition,
|
||||
Vector3 collisionNormal)
|
||||
{
|
||||
transition.CollisionInfo.SetSlidingNormal(collisionNormal);
|
||||
return TransitionState.Slid;
|
||||
}
|
||||
Vector3 worldNormal)
|
||||
=> transition.SlideSphereInternal(
|
||||
worldNormal, transition.SpherePath.GlobalCurrCenter[0].Origin);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// collide_with_pt — BSPTree level
|
||||
|
|
@ -1894,11 +1903,14 @@ public static class BSPQuery
|
|||
if (engine is not null && !path.StepUp && !path.StepDown)
|
||||
return StepSphereUp(transition, worldNormal, engine);
|
||||
|
||||
// No engine OR step-up/step-down already in progress — fall
|
||||
// back to wall-slide.
|
||||
collisions.SetCollisionNormal(worldNormal);
|
||||
collisions.SetSlidingNormal(worldNormal);
|
||||
return TransitionState.Slid;
|
||||
// No engine OR step-up/step-down already in progress — the
|
||||
// real slide response. Retail: a blocked step-up funnels to
|
||||
// SPHEREPATH::step_up_slide → CSphere::slide_sphere (ACE
|
||||
// SpherePath.cs:316); the slide records the collision normal
|
||||
// itself and never writes the sliding normal (#137 mechanism
|
||||
// 2 — the old SetSlidingNormal stub here leaked a normal that
|
||||
// wedged the next frame's forward offset).
|
||||
return SlideSphere(transition, worldNormal);
|
||||
}
|
||||
|
||||
// Sphere 0 didn't fully hit. Per retail, the head-sphere test AND
|
||||
|
|
@ -1937,9 +1949,10 @@ public static class BSPQuery
|
|||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||
|
||||
var worldNormal = L2W(hitPoly1!.Plane.Normal);
|
||||
collisions.SetCollisionNormal(worldNormal);
|
||||
collisions.SetSlidingNormal(worldNormal);
|
||||
return TransitionState.Slid;
|
||||
// Retail head-sphere full hit → BSPTREE::slide_sphere
|
||||
// (0x0053a697; ACE BSPTree.cs:202) — the real in-frame
|
||||
// slide, no sliding-normal write (#137 mechanism 2).
|
||||
return SlideSphere(transition, worldNormal);
|
||||
}
|
||||
|
||||
// Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide.
|
||||
|
|
|
|||
65
src/AcDream.Core/Physics/Motion/MotionTablePose.cs
Normal file
65
src/AcDream.Core/Physics/Motion/MotionTablePose.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// #175: the motion table's DEFAULT-STATE part pose — the pose an idle
|
||||
/// entity's parts hold (retail: <c>CMotionTable::SetDefaultState</c>
|
||||
/// 0x005230a0 installs <c>StyleDefaults[DefaultStyle]</c>'s cycle; the parts
|
||||
/// then sit at that animation's frames — the live <c>CPhysicsPart</c> pose
|
||||
/// collision tests against). Used as the BSP shadow-shape part-pose override
|
||||
/// at server-entity registration (doors: the CLOSED pose).
|
||||
///
|
||||
/// <para>
|
||||
/// Cycle key arithmetic mirrors <see cref="CMotionTable"/>'s
|
||||
/// <c>LookupCycle</c> (CMotionTable.cs:683): <c>(style << 16) |
|
||||
/// (substate & 0xFFFFFF)</c> — the raw dat <c>Cycles</c> dictionary is
|
||||
/// keyed by the COMBINED word, not the bare style (the first cut of this
|
||||
/// helper looked up the bare style, always missed, and silently fell back
|
||||
/// to placement frames — the #175 "not fixed" report).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MotionTablePose
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve the default-state pose frames. Returns null (callers fall
|
||||
/// back to placement frames) when the table has no default-style
|
||||
/// substate, no matching cycle, or no animation. A pose covering FEWER
|
||||
/// parts than the Setup is returned as-is —
|
||||
/// <see cref="ShadowShapeBuilder"/> falls back to the placement frame
|
||||
/// PER PART beyond the override's length (e.g. a door anim that poses
|
||||
/// only the panel parts, not the BSP-less frame header).
|
||||
/// </summary>
|
||||
/// <param name="mt">The raw dat motion table (wire MotionTableId).</param>
|
||||
/// <param name="loadAnimation">Animation loader (production:
|
||||
/// <c>id => dats.Get<Animation>(id)</c>).</param>
|
||||
public static IReadOnlyList<Frame>? DefaultStatePartFrames(
|
||||
MotionTable mt,
|
||||
Func<uint, Animation?> loadAnimation)
|
||||
{
|
||||
if (mt is null) return null;
|
||||
|
||||
// SetDefaultState: StyleDefaults[DefaultStyle] → the default substate.
|
||||
if (!mt.StyleDefaults.TryGetValue(mt.DefaultStyle, out var defaultSubstateCmd))
|
||||
return null;
|
||||
|
||||
// LookupCycle key (CMotionTable.cs:683 — same wrap semantics).
|
||||
uint style = (uint)mt.DefaultStyle;
|
||||
uint substate = (uint)defaultSubstateCmd;
|
||||
int key = (int)((style << 16) | (substate & 0xFFFFFFu));
|
||||
|
||||
if (!mt.Cycles.TryGetValue(key, out var cycle) || cycle.Anims.Count == 0)
|
||||
return null;
|
||||
|
||||
var animRef = cycle.Anims[0];
|
||||
var anim = loadAnimation(animRef.AnimId);
|
||||
if (anim is null || anim.PartFrames.Count == 0) return null;
|
||||
|
||||
int idx = Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1);
|
||||
var frames = anim.PartFrames[idx].Frames;
|
||||
return frames.Count > 0 ? frames : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1085,16 +1085,27 @@ public sealed class PhysicsEngine
|
|||
body.WalkableVertices = null;
|
||||
}
|
||||
|
||||
if (ci.SlidingNormalValid
|
||||
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||
// Retail persists sliding state to the body ONLY on transition
|
||||
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
|
||||
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
|
||||
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
|
||||
// is unreachable when find_valid_position fails (the transition is
|
||||
// discarded whole; the body keeps its prior state). #137 mechanism
|
||||
// 2: an unconditional writeback here could persist a normal retail
|
||||
// would discard.
|
||||
if (ok)
|
||||
{
|
||||
body.SlidingNormal = ci.SlidingNormal;
|
||||
body.TransientState |= TransientStateFlags.Sliding;
|
||||
}
|
||||
else
|
||||
{
|
||||
body.SlidingNormal = Vector3.Zero;
|
||||
body.TransientState &= ~TransientStateFlags.Sliding;
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -38,10 +38,22 @@ public static class ShadowShapeBuilder
|
|||
/// every radius, height, and local offset.</param>
|
||||
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
|
||||
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
|
||||
/// <param name="partPoseOverride">#175: per-part pose override for the
|
||||
/// BSP part shapes — the entity's motion-table DEFAULT-STATE pose (the
|
||||
/// closed pose for doors). Retail collision tests each part's LIVE
|
||||
/// <c>CPhysicsPart</c> pose, which for an idle entity is the motion
|
||||
/// table's default state, NOT the Setup's placement frame — the two
|
||||
/// differ on e.g. the Facility Hub double door (Setup 0x02000C9D:
|
||||
/// placement poses the panels AJAR at yaw −150°/−30°, y −0.44 m; the
|
||||
/// closed pose is straight). Null / short lists fall back to the
|
||||
/// placement frame per part (entities with no motion table, and the
|
||||
/// CylSphere/Sphere shapes, are unaffected — retail poses those from
|
||||
/// the setup too).</param>
|
||||
public static IReadOnlyList<ShadowShape> FromSetup(
|
||||
Setup setup,
|
||||
float entScale,
|
||||
Func<uint, bool> hasPhysicsBsp)
|
||||
Func<uint, bool> hasPhysicsBsp,
|
||||
IReadOnlyList<Frame>? partPoseOverride = null)
|
||||
{
|
||||
if (setup is null) throw new ArgumentNullException(nameof(setup));
|
||||
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
||||
|
|
@ -84,15 +96,21 @@ public static class ShadowShapeBuilder
|
|||
}
|
||||
|
||||
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
|
||||
// Pose priority per part: partPoseOverride (the motion-table
|
||||
// default-state pose, #175) → placement frame → identity.
|
||||
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
|
||||
for (int i = 0; i < setup.Parts.Count; i++)
|
||||
{
|
||||
uint gfxId = (uint)setup.Parts[i];
|
||||
if (!hasPhysicsBsp(gfxId)) continue;
|
||||
|
||||
Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count
|
||||
? placementFrame.Frames[i]
|
||||
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
||||
Frame partFrame;
|
||||
if (partPoseOverride is not null && i < partPoseOverride.Count)
|
||||
partFrame = partPoseOverride[i];
|
||||
else if (placementFrame is not null && i < placementFrame.Frames.Count)
|
||||
partFrame = placementFrame.Frames[i];
|
||||
else
|
||||
partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
||||
|
||||
// BSP radius default; caller substitutes the real BoundingSphere.Radius
|
||||
// at registration time when available. Loose-but-safe broadphase value.
|
||||
|
|
|
|||
|
|
@ -1804,6 +1804,21 @@ public sealed class Transition
|
|||
{
|
||||
if (cellId == sp.CheckCellId) continue;
|
||||
|
||||
// #137 seam shake (2026-07-06): a mid-loop query can MOVE the
|
||||
// sphere — a Path-5 full hit dispatches step_sphere_up, and a
|
||||
// successful climb lifts the foot (the 0.6 mm ramp-slab lift at
|
||||
// the Facility Hub boundaries) yet returns OK, so the loop
|
||||
// continues. Retail's check_other_cells reads the LIVE
|
||||
// sphere_path.global_sphere for every cell (each cell's
|
||||
// find_collisions receives the transition itself, pc:272717+);
|
||||
// querying the remaining cells with the pre-climb center re-tests
|
||||
// the boundary ~0.4 mm inside the floor slab and grazes the
|
||||
// under-room's CEILING (the slab underside) — the neg-poly
|
||||
// step-up-with-a-downward-normal chain that shook the player at
|
||||
// every corridor seam. Same lesson as the P2 cellar-lip fix
|
||||
// (RunCheckOtherCellsAndAdvance's entry refresh), one loop deeper.
|
||||
footCenter = sp.GlobalSphere[0].Origin;
|
||||
|
||||
if ((cellId & 0xFFFFu) < 0x0100u)
|
||||
{
|
||||
var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
|
||||
|
|
@ -2811,9 +2826,12 @@ public sealed class Transition
|
|||
// Effect (pc:276973-276977):
|
||||
// state_3 = OK_TS ← force passable
|
||||
// collision_normal_valid = 0 ← clear stale slide normal
|
||||
// Note: Cylinder and Sphere shapes already return OK from their own
|
||||
// obstruction_ethereal early-out, so this clause only fires in practice
|
||||
// for the BSP branch, but is written unconditionally as retail does.
|
||||
// Note: the BSP branch AND (since the 2026-07-05 CCylSphere family
|
||||
// port) the Cylinder branch rely on this clause for ethereal
|
||||
// passability — CylinderCollision's branch 1 returns Collided on
|
||||
// overlap like retail, and THIS override clears it for non-static
|
||||
// targets. Only the Sphere branch still early-outs on
|
||||
// obstruction_ethereal (consume site 1).
|
||||
if (result != TransitionState.OK
|
||||
&& sp.ObstructionEthereal
|
||||
&& !sp.StepDown
|
||||
|
|
@ -3171,168 +3189,453 @@ public sealed class Transition
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs,
|
||||
/// door foot-colliders). For Contact-grounded movers, attempts to step over short
|
||||
/// cylinders (retail-faithful CCylSphere::step_sphere_up). For airborne movers,
|
||||
/// movers already stepping, or cylinders too tall to step over, applies a
|
||||
/// horizontal wall-slide response.
|
||||
/// Retail <c>CCylSphere::intersects_sphere</c> dispatcher — verbatim port
|
||||
/// of <c>0x0053b440</c> (acclient_2013_pseudo_c.txt:324558). Full family:
|
||||
/// <c>collides_with_sphere</c> 0x0053a880, <c>normal_of_collision</c>
|
||||
/// 0x0053ab50, <c>collide_with_point</c> 0x0053acb0, <c>slide_sphere</c>
|
||||
/// 0x0053b2a0, <c>step_sphere_up</c> 0x0053b310, <c>land_on_cylinder</c>
|
||||
/// 0x0053b3d0, <c>step_sphere_down</c> 0x0053a9b0. Pseudocode + settled
|
||||
/// BN ambiguities + ACE cross-reference notes:
|
||||
/// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md.
|
||||
///
|
||||
/// <para>
|
||||
/// A6.P6 (2026-05-25): the step-over path matches retail's
|
||||
/// <c>CCylSphere::step_sphere_up</c> at
|
||||
/// <c>acclient_2013_pseudo_c.txt:324516-324538</c>. The door's foot
|
||||
/// cylinder (h=0.20m, r=0.10m) is too tall for the static slide to
|
||||
/// produce smooth sliding along the slab — the radial push-out
|
||||
/// fires as a "phantom collision" at the door's center when the
|
||||
/// sphere is touching the slab face and the cyl is just within reach.
|
||||
/// Retail steps the sphere over the cyl (succeeds when
|
||||
/// <c>step_up_height >= sphere.radius + cyl.height - offset.z</c>),
|
||||
/// which lets the sphere walk past the cyl without the radial push.
|
||||
/// On step-up failure (cyl too tall, no walkable surface beyond),
|
||||
/// falls back to <c>step_up_slide</c> — the same crease-projection
|
||||
/// slide the BSP path uses, which produces smoother behavior than
|
||||
/// the radial push.
|
||||
/// Replaces the pre-2026-07-05 hand-rolled approximation (A6.P6 step-up
|
||||
/// gate + radial wall-slide) which had NO cylinder-TOP support: a
|
||||
/// grounded mover stepping up onto a WIDE cylinder (the Holtburg
|
||||
/// town-network portal platform, Setup 0x020019E3, r=2.597 m h=0.256 m)
|
||||
/// could never validate a landing — the step-up's internal step-down
|
||||
/// probe needs branch 2 (step_sphere_down → contact plane ON the flat
|
||||
/// top) — so DoStepUp failed into StepUpSlide and the player orbited the
|
||||
/// rim forever. Airborne landings on tops (land_on_cylinder + the
|
||||
/// collide-flag exact-TOI branch) were likewise missing.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The <see cref="ShadowEntry"/> already carries the wrapper overload's
|
||||
/// (0x0053b8f0) work: Position = globalized low_pt (entity frame applied
|
||||
/// at registration), Radius/CylHeight pre-scaled; the cylinder axis stays
|
||||
/// world-Z. Ethereal targets: branch 1 returns Collided on overlap and
|
||||
/// the caller's Layer-2 override (pc:276961-276989) clears it for
|
||||
/// non-static targets — the retail passability mechanism (#150); the
|
||||
/// step-down pass never reaches here for ethereal targets (pc:276799
|
||||
/// skip).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine)
|
||||
{
|
||||
// Consume site 2 — CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573).
|
||||
// When obstruction_ethereal is set (target is ETHEREAL-alone, state & 0x4),
|
||||
// the retail function is void and all paths in the ethereal branch return
|
||||
// without producing a COLLIDED/Slid result — the player is fully passable.
|
||||
// We mirror this by returning OK immediately, skipping all blocking paths.
|
||||
// Retail ref: acclient_2013_pseudo_c.txt:324573.
|
||||
if (sp.ObstructionEthereal)
|
||||
return TransitionState.OK;
|
||||
// Degenerate dat heights: registration sites apply the same fallback;
|
||||
// kept for entries registered before it (pre-dates this port).
|
||||
float cylHeight = obj.CylHeight > 0f ? obj.CylHeight : obj.Radius * 4f;
|
||||
|
||||
var s0 = sp.GlobalSphere[0];
|
||||
Vector3 disp0 = s0.Origin - obj.Position;
|
||||
// radsum: ε shaved ONCE in the dispatcher preamble (0x0053b48e) —
|
||||
// this is what lets a sphere REST exactly on the top without
|
||||
// re-colliding every frame.
|
||||
float radsum = obj.Radius - PhysicsGlobals.EPSILON + s0.Radius;
|
||||
|
||||
bool hasHead = sp.NumSphere > 1;
|
||||
Vector3 disp1 = default;
|
||||
float headRadius = 0f;
|
||||
if (hasHead)
|
||||
{
|
||||
disp1 = sp.GlobalSphere[1].Origin - obj.Position;
|
||||
headRadius = sp.GlobalSphere[1].Radius;
|
||||
}
|
||||
|
||||
// ── Branch 1 (0x0053b4a0): placement / ethereal — detection only. ──
|
||||
if (sp.InsertType == InsertType.Placement || sp.ObstructionEthereal)
|
||||
{
|
||||
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
|
||||
return TransitionState.Collided;
|
||||
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
|
||||
return TransitionState.Collided;
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
// ── Branch 2 (0x0053b4c0): step-down probe — land on the top. ──
|
||||
if (sp.StepDown)
|
||||
return CylStepSphereDown(obj, sp, cylHeight, disp0, radsum);
|
||||
|
||||
// ── Branch 3 (0x0053b4d7): walkable probe — occupancy blocks. ──
|
||||
if (sp.CheckWalkable)
|
||||
{
|
||||
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
|
||||
return TransitionState.Collided;
|
||||
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
|
||||
return TransitionState.Collided;
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
var ci = CollisionInfo;
|
||||
var oi = ObjectInfo;
|
||||
Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;
|
||||
Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin;
|
||||
float sphRadius = sp.GlobalSphere[0].Radius;
|
||||
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
|
||||
|
||||
// Vertical check: does sphere reach the cylinder's height range at all?
|
||||
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
|
||||
float checkZ = sphereCheckPos.Z;
|
||||
if (checkZ - sphRadius > obj.Position.Z + cylTop ||
|
||||
checkZ + sphRadius < obj.Position.Z)
|
||||
if (!sp.Collide)
|
||||
{
|
||||
// ── Branch 4 (0x0053b701): normal transition sweep. ──
|
||||
if ((oi.State & (ObjectInfoState.Contact | ObjectInfoState.OnWalkable)) != 0)
|
||||
{
|
||||
// Grounded mover: foot hit → step over / onto; head hit → slide.
|
||||
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
|
||||
return CylStepSphereUp(obj, sp, engine, cylHeight, disp0, radsum);
|
||||
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
|
||||
// Retail 0x0053b843 passes the HEAD disp (its x_2); ACE's
|
||||
// port passes the foot disp here — retail wins (pseudocode
|
||||
// doc §8.2).
|
||||
return CylSlideSphere(obj, sp, cylHeight, disp1, radsum, 1);
|
||||
}
|
||||
else if ((oi.State & ObjectInfoState.PathClipped) != 0)
|
||||
{
|
||||
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
|
||||
return CylCollideWithPoint(obj, sp, cylHeight, s0, disp0, radsum, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Airborne: foot hit → land on the top; head hit → point hit.
|
||||
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
|
||||
return CylLandOnCylinder(obj, sp, cylHeight, disp0, radsum);
|
||||
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
|
||||
return CylCollideWithPoint(obj, sp, cylHeight, sp.GlobalSphere[1], disp1, radsum, 1);
|
||||
}
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
// ── Branch 5 (0x0053b525): collide-flag re-test — exact-TOI cap landing. ──
|
||||
// Runs on the attempt after land_on_cylinder set sp.Collide: rewinds
|
||||
// the sphere along the REVERSE movement to rest exactly on the top,
|
||||
// sets the contact plane, and consumes walk_interp.
|
||||
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius)
|
||||
|| (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius)))
|
||||
{
|
||||
// movement = curr − check. Retail subtracts the cur→check
|
||||
// landblock offset (get_curr_pos_check_pos_block_offset); our
|
||||
// physics frame is continuous world-space → zero (same standing
|
||||
// adaptation as SlideSphere's gDelta).
|
||||
Vector3 movement = sp.GlobalCurrCenter[0].Origin - s0.Origin;
|
||||
if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
|
||||
return TransitionState.Collided;
|
||||
|
||||
float timecheck = (cylHeight + s0.Radius - disp0.Z) / movement.Z;
|
||||
Vector3 offset = movement * timecheck;
|
||||
|
||||
Vector3 sum = offset + disp0;
|
||||
if (radsum * radsum < sum.X * sum.X + sum.Y * sum.Y)
|
||||
return TransitionState.OK; // rewound point is off the cap — not a top landing
|
||||
|
||||
float t = (1f - timecheck) * sp.WalkInterp;
|
||||
if (t >= sp.WalkInterp || t < -0.1f)
|
||||
return TransitionState.Collided;
|
||||
|
||||
Vector3 pt = s0.Origin + offset;
|
||||
pt.Z -= s0.Radius;
|
||||
// is_water=1 is verbatim retail (0x0053b6b9 → set_contact_plane
|
||||
// arg3 = contact_plane_is_water, 0x00509db1). Do not "fix".
|
||||
var contactPlane = new Plane(Vector3.UnitZ, -pt.Z);
|
||||
CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true);
|
||||
sp.WalkInterp = t;
|
||||
sp.AddOffsetToCheckPos(offset);
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CCylSphere::collides_with_sphere</c> (0x0053a880,
|
||||
/// pc:323943): XY overlap against radsum, then Z-band overlap against the
|
||||
/// cylinder's [0, height] extent. <paramref name="disp"/> = sphere center
|
||||
/// − cylinder base point.
|
||||
/// </summary>
|
||||
private static bool CylCollidesWithSphere(Vector3 disp, float radsum, float cylHeight, float sphereRadius)
|
||||
{
|
||||
if (disp.X * disp.X + disp.Y * disp.Y <= radsum * radsum)
|
||||
{
|
||||
float halfH = cylHeight * 0.5f;
|
||||
if (sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - disp.Z))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CCylSphere::normal_of_collision</c> (0x0053ab50, pc:324102).
|
||||
/// Discriminates on where the sphere was at the START of the step
|
||||
/// (GlobalCurrCenter): XY-outside the combined radius → radial side
|
||||
/// normal; XY-inside the footprint → cap normal (descending → top +Z,
|
||||
/// ascending → underside −Z; polarity settled by ACE + geometry — BN's
|
||||
/// x87 branch rendering is untrustworthy here). Returns "definite":
|
||||
/// false when a radial contact could actually be a diagonal cap hit
|
||||
/// (curr was outside the Z band AND there is vertical movement) —
|
||||
/// consumed only by <see cref="CylCollideWithPoint"/>.
|
||||
/// </summary>
|
||||
private bool CylNormalOfCollision(ShadowEntry obj, SpherePath sp, float cylHeight,
|
||||
Vector3 dispCheck, float radsum, float sphereRadius, int sphereNum, out Vector3 normal)
|
||||
{
|
||||
Vector3 dispCurr = sp.GlobalCurrCenter[sphereNum].Origin - obj.Position;
|
||||
if (radsum * radsum < dispCurr.X * dispCurr.X + dispCurr.Y * dispCurr.Y)
|
||||
{
|
||||
normal = new Vector3(dispCurr.X, dispCurr.Y, 0f);
|
||||
float halfH = cylHeight * 0.5f;
|
||||
bool zBandOverlapAtCurr =
|
||||
sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - dispCurr.Z);
|
||||
bool noZMovement = MathF.Abs(dispCurr.Z - dispCheck.Z) <= PhysicsGlobals.EPSILON;
|
||||
return zBandOverlapAtCurr || noZMovement;
|
||||
}
|
||||
normal = new Vector3(0f, 0f, dispCheck.Z - dispCurr.Z <= 0f ? 1f : -1f);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>AC1Legacy::Vector3::normalize_check_small</c>: returns true
|
||||
/// (degenerate — caller yields Collided) when |v| < F_EPSILON, else
|
||||
/// normalizes in place.
|
||||
/// </summary>
|
||||
private static bool NormalizeCheckSmall(ref Vector3 v)
|
||||
{
|
||||
float mag = v.Length();
|
||||
if (mag < PhysicsGlobals.EPSILON)
|
||||
return true;
|
||||
v /= mag;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CCylSphere::step_sphere_up</c> (0x0053b310, pc:324516).
|
||||
/// Too-tall cylinders slide; otherwise the generic step-up
|
||||
/// (<see cref="DoStepUp"/> = CTransition::step_up) runs — its internal
|
||||
/// step-down probe lands on the cylinder top via
|
||||
/// <see cref="CylStepSphereDown"/> (branch 2), which is what makes
|
||||
/// stepping ONTO a wide cylinder possible.
|
||||
/// </summary>
|
||||
private TransitionState CylStepSphereUp(ShadowEntry obj, SpherePath sp, PhysicsEngine engine,
|
||||
float cylHeight, Vector3 disp0, float radsum)
|
||||
{
|
||||
var s0 = sp.GlobalSphere[0];
|
||||
|
||||
// step_up_height must clear (sphere.radius + height − disp.z) — the
|
||||
// lift needed so the sphere bottom rests on the top (0x0053b323).
|
||||
if (ObjectInfo.StepUpHeight < s0.Radius + cylHeight - disp0.Z)
|
||||
return CylSlideSphere(obj, sp, cylHeight, disp0, radsum, 0);
|
||||
|
||||
// Retail computes the normal and ignores the definite flag here.
|
||||
CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum, s0.Radius, 0, out var n);
|
||||
if (NormalizeCheckSmall(ref n))
|
||||
return TransitionState.Collided;
|
||||
|
||||
// Retail rotates the normal by the target OBJECT's frame
|
||||
// (localtoglobalvec via the wrapper's cached localspace_pos,
|
||||
// 0x0053b38d) before CTransition::step_up. Yaw-only AC frames leave
|
||||
// vertical normals unchanged; radial normals pick up the yaw.
|
||||
var nWorld = Vector3.Transform(n, obj.Rotation);
|
||||
|
||||
// engine==null only in bare unit-test transitions — no step-up
|
||||
// machinery available; the retail-faithful fallback is the slide.
|
||||
if (engine is not null && DoStepUp(nWorld, engine))
|
||||
return TransitionState.OK;
|
||||
|
||||
// XY distance from sphere check position to cylinder axis.
|
||||
float dxCheck = sphereCheckPos.X - obj.Position.X;
|
||||
float dyCheck = sphereCheckPos.Y - obj.Position.Y;
|
||||
float distSqCheck = dxCheck * dxCheck + dyCheck * dyCheck;
|
||||
float combinedR = sphRadius + obj.Radius;
|
||||
float combinedRSq = combinedR * combinedR;
|
||||
return sp.StepUpSlide(this);
|
||||
}
|
||||
|
||||
if (distSqCheck >= combinedRSq)
|
||||
return TransitionState.OK; // not overlapping at check position
|
||||
/// <summary>
|
||||
/// Retail <c>CCylSphere::step_sphere_down</c> (0x0053a9b0, pc:324032):
|
||||
/// during a step-down probe, land the foot sphere ON the cylinder's flat
|
||||
/// top — contact plane (0,0,1) through the top, walk_interp consumed,
|
||||
/// CheckPos lifted so the sphere bottom rests exactly on it. THE piece
|
||||
/// whose absence made every step-up onto a wide cylinder fail (the
|
||||
/// portal-platform rim orbit).
|
||||
/// </summary>
|
||||
private TransitionState CylStepSphereDown(ShadowEntry obj, SpherePath sp,
|
||||
float cylHeight, Vector3 disp0, float radsum)
|
||||
{
|
||||
var s0 = sp.GlobalSphere[0];
|
||||
|
||||
// ─── Overlap detected ─────────────────────────────────────
|
||||
// Horizontal outward normal from the cylinder axis to the sphere
|
||||
// check position. For the degenerate case where the sphere center
|
||||
// is exactly on the axis, use the movement direction as a fallback
|
||||
// (pushes the sphere back out along the way it came in).
|
||||
float distCheck = MathF.Sqrt(distSqCheck);
|
||||
Vector3 collisionNormal;
|
||||
if (distCheck < PhysicsGlobals.EPSILON)
|
||||
bool hit = CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius);
|
||||
if (!hit && sp.NumSphere > 1)
|
||||
{
|
||||
// Sphere center on cylinder axis — push along reverse movement.
|
||||
float mxy = MathF.Sqrt(sphMovement.X * sphMovement.X + sphMovement.Y * sphMovement.Y);
|
||||
if (mxy > PhysicsGlobals.EPSILON)
|
||||
collisionNormal = new Vector3(-sphMovement.X / mxy, -sphMovement.Y / mxy, 0f);
|
||||
else
|
||||
collisionNormal = Vector3.UnitX;
|
||||
Vector3 disp1 = sp.GlobalSphere[1].Origin - obj.Position;
|
||||
hit = CylCollidesWithSphere(disp1, radsum, cylHeight, sp.GlobalSphere[1].Radius);
|
||||
}
|
||||
else
|
||||
if (!hit)
|
||||
return TransitionState.OK;
|
||||
|
||||
float stepScale = sp.StepDownAmt * sp.WalkInterp;
|
||||
if (MathF.Abs(stepScale) < PhysicsGlobals.EPSILON)
|
||||
return TransitionState.Collided;
|
||||
|
||||
// Lift so the foot sphere's bottom rests on the top disc. The
|
||||
// (1 − deltaZ/stepScale) divisor is stepScale — BN garbled it;
|
||||
// settled via ACE CylSphere.StepSphereDown (pseudocode doc §4).
|
||||
float deltaZ = cylHeight + s0.Radius - disp0.Z;
|
||||
float interp = (1f - deltaZ / stepScale) * sp.WalkInterp;
|
||||
if (interp >= sp.WalkInterp || interp < -0.1f)
|
||||
return TransitionState.Collided;
|
||||
|
||||
float topZ = s0.Origin.Z + deltaZ - s0.Radius;
|
||||
// is_water=1 verbatim retail (0x0053aae2). Do not "fix".
|
||||
var contactPlane = new Plane(Vector3.UnitZ, -topZ);
|
||||
CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true);
|
||||
sp.WalkInterp = interp;
|
||||
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, deltaZ));
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CCylSphere::slide_sphere</c> (0x0053b2a0, pc:324502):
|
||||
/// normal_of_collision → CSphere::slide_sphere (our
|
||||
/// <see cref="SlideSphere"/>) with the sliding sphere's own curr center.
|
||||
/// </summary>
|
||||
private TransitionState CylSlideSphere(ShadowEntry obj, SpherePath sp,
|
||||
float cylHeight, Vector3 disp, float radsum, int sphereNum)
|
||||
{
|
||||
CylNormalOfCollision(obj, sp, cylHeight, disp, radsum,
|
||||
sp.GlobalSphere[sphereNum].Radius, sphereNum, out var n);
|
||||
if (NormalizeCheckSmall(ref n))
|
||||
return TransitionState.Collided;
|
||||
|
||||
return SlideSphere(n, sp.GlobalCurrCenter[sphereNum].Origin, sphereNum);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CCylSphere::land_on_cylinder</c> (0x0053b3d0, pc:324542):
|
||||
/// airborne foot hit — arm the Collide re-test (backup + flag) and relax
|
||||
/// the walkable allowance to LandingZ. The NEXT attempt's branch 5 then
|
||||
/// rests the sphere on the top with the exact time-of-impact.
|
||||
/// </summary>
|
||||
private TransitionState CylLandOnCylinder(ShadowEntry obj, SpherePath sp,
|
||||
float cylHeight, Vector3 disp0, float radsum)
|
||||
{
|
||||
CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum,
|
||||
sp.GlobalSphere[0].Radius, 0, out var n);
|
||||
if (NormalizeCheckSmall(ref n))
|
||||
return TransitionState.Collided;
|
||||
|
||||
sp.SetCollide(n);
|
||||
sp.WalkableAllowance = PhysicsGlobals.LandingZ; // 0.0871557 (0x0053b41f)
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CCylSphere::collide_with_point</c> (0x0053acb0, pc:324173):
|
||||
/// PathClipped movers + airborne head-sphere hits. Non-PerfectClip movers
|
||||
/// record the collision normal and hard-stop; PerfectClip movers get the
|
||||
/// exact time-of-impact reposition. TOI sub-branches ported per ACE
|
||||
/// CylSphere.CollideWithPoint (BN mush too heavy in 0x0053adb6+); no
|
||||
/// PerfectClip mover exists in M1.5 (players never set it), so only the
|
||||
/// Collided path is load-bearing today — revisit against Ghidra if
|
||||
/// missiles ever arm PerfectClip (pseudocode doc §7).
|
||||
/// </summary>
|
||||
private TransitionState CylCollideWithPoint(ShadowEntry obj, SpherePath sp,
|
||||
float cylHeight, Sphere checkSphere, Vector3 disp, float radsum, int sphereNum)
|
||||
{
|
||||
bool definite = CylNormalOfCollision(obj, sp, cylHeight, disp, radsum,
|
||||
checkSphere.Radius, sphereNum, out var n);
|
||||
if (NormalizeCheckSmall(ref n))
|
||||
return TransitionState.Collided;
|
||||
|
||||
if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip))
|
||||
{
|
||||
collisionNormal = new Vector3(dxCheck / distCheck, dyCheck / distCheck, 0f);
|
||||
CollisionInfo.SetCollisionNormal(n);
|
||||
return TransitionState.Collided;
|
||||
}
|
||||
|
||||
// A6.P6 (2026-05-25): retail-faithful CCylSphere::step_sphere_up for
|
||||
// Contact-grounded movers. acclient_2013_pseudo_c.txt:324516-324538.
|
||||
//
|
||||
// Retail check: step_up_height must clear (sphere.radius + cyl.height
|
||||
// - offset.z) where offset.z is sphere center Z minus cyl low_pt Z.
|
||||
// Geometrically: the height we need to lift the sphere to clear the
|
||||
// cyl's top, less the sphere center's current height above the cyl
|
||||
// base, equals cyl top minus sphere bottom (positive when sphere
|
||||
// currently below cyl top).
|
||||
//
|
||||
// For the door's foot cyl (h=0.20m, sphere radius 0.48m, step_up 0.60m)
|
||||
// at standing height (offset.z ~0.38m): cyl_clearance =
|
||||
// 0.48 + 0.20 - 0.38 = 0.30m, step_up_height = 0.60m → step over OK.
|
||||
if (oi.Contact && !sp.StepUp && !sp.StepDown && engine is not null)
|
||||
{
|
||||
float offsetZ = sphereCheckPos.Z - obj.Position.Z;
|
||||
float cylClearance = sphRadius + cylTop - offsetZ;
|
||||
// Retail reads global_curr_center[0] even for the head hit
|
||||
// (0x0053ad26; ACE agrees) — verbatim.
|
||||
Vector3 globCenter = sp.GlobalCurrCenter[0].Origin;
|
||||
// Block offset = 0 (continuous world frame; see branch 5 note).
|
||||
Vector3 movement = checkSphere.Origin - globCenter;
|
||||
Vector3 oldDisp = globCenter - obj.Position;
|
||||
float radsumEps = radsum + PhysicsGlobals.EPSILON;
|
||||
|
||||
if (oi.StepUpHeight >= cylClearance)
|
||||
float xyMoveLenSq = movement.X * movement.X + movement.Y * movement.Y;
|
||||
float dot2d = movement.X * oldDisp.X + movement.Y * oldDisp.Y;
|
||||
float xyDiff = -dot2d;
|
||||
float oldDispXYSq = oldDisp.X * oldDisp.X + oldDisp.Y * oldDisp.Y;
|
||||
float diffSq = xyDiff * xyDiff - (oldDispXYSq - radsumEps * radsumEps) * xyMoveLenSq;
|
||||
|
||||
float time;
|
||||
Vector3 scaledMovement;
|
||||
|
||||
if (!definite)
|
||||
{
|
||||
if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
|
||||
return TransitionState.Collided;
|
||||
if (movement.Z > 0f)
|
||||
{
|
||||
// Try step-up over the cyl (DoStepUp probes upward by
|
||||
// step_up_height, then step-down for walkable surface).
|
||||
// On success: sphere is repositioned past/over the cyl,
|
||||
// ContactPlane updated, returns OK.
|
||||
if (DoStepUp(collisionNormal, engine))
|
||||
return TransitionState.OK;
|
||||
|
||||
// Step-up failed — sphere couldn't find a walkable surface
|
||||
// beyond the cyl (e.g., a wall behind it). Fall back to
|
||||
// step_up_slide which uses the SlideSphereInternal crease
|
||||
// projection — smoother than the radial push-out below
|
||||
// because it follows the contact-plane / cyl-normal crease
|
||||
// direction.
|
||||
return sp.StepUpSlide(this);
|
||||
}
|
||||
// else: cyl too tall to step over — fall through to radial slide
|
||||
}
|
||||
|
||||
// ─── Fallback: airborne / non-Contact / cyl-too-tall — wall-slide ───
|
||||
|
||||
// Wall-slide position (in world space):
|
||||
// curr = sphereCurrPos (pre-step)
|
||||
// movement = sphMovement
|
||||
// projected = movement - (movement · normal) * normal
|
||||
// slidPos = curr + projected
|
||||
// Then push outward if still inside the cylinder radius.
|
||||
Vector3 horizMovement = new Vector3(sphMovement.X, sphMovement.Y, 0f);
|
||||
float movementIntoWall = Vector3.Dot(horizMovement, collisionNormal);
|
||||
Vector3 projectedMovement = horizMovement - collisionNormal * movementIntoWall;
|
||||
// Preserve vertical movement component (jumping/falling).
|
||||
projectedMovement.Z = sphMovement.Z;
|
||||
|
||||
Vector3 slidPos = sphereCurrPos + projectedMovement;
|
||||
|
||||
// Ensure slid position is outside the cylinder radius horizontally.
|
||||
float sdx = slidPos.X - obj.Position.X;
|
||||
float sdy = slidPos.Y - obj.Position.Y;
|
||||
float sDistSq = sdx * sdx + sdy * sdy;
|
||||
float minDist = combinedR + 0.01f;
|
||||
if (sDistSq < minDist * minDist)
|
||||
{
|
||||
float sDist = MathF.Sqrt(sDistSq);
|
||||
if (sDist < PhysicsGlobals.EPSILON)
|
||||
{
|
||||
// Degenerate: push out along collisionNormal
|
||||
slidPos.X = obj.Position.X + collisionNormal.X * minDist;
|
||||
slidPos.Y = obj.Position.Y + collisionNormal.Y * minDist;
|
||||
n = new Vector3(0f, 0f, -1f);
|
||||
time = (movement.Z + checkSphere.Radius) / movement.Z * -1f;
|
||||
}
|
||||
else
|
||||
{
|
||||
float pushDist = (minDist - sDist);
|
||||
slidPos.X += (sdx / sDist) * pushDist;
|
||||
slidPos.Y += (sdy / sDist) * pushDist;
|
||||
n = new Vector3(0f, 0f, 1f);
|
||||
time = (checkSphere.Radius + cylHeight - movement.Z) / movement.Z;
|
||||
}
|
||||
scaledMovement = movement * time;
|
||||
|
||||
Vector3 landed = scaledMovement + oldDisp;
|
||||
if (landed.X * landed.X + landed.Y * landed.Y >= radsumEps * radsumEps)
|
||||
{
|
||||
if (MathF.Abs(xyMoveLenSq) < PhysicsGlobals.EPSILON)
|
||||
return TransitionState.Collided;
|
||||
if (diffSq >= 0f && xyMoveLenSq > PhysicsGlobals.EPSILON)
|
||||
{
|
||||
float diff = MathF.Sqrt(diffSq);
|
||||
time = xyDiff - diff < 0f
|
||||
? (diff - dot2d) / xyMoveLenSq
|
||||
: (xyDiff - diff) / xyMoveLenSq;
|
||||
scaledMovement = movement * time;
|
||||
}
|
||||
n = (scaledMovement + globCenter - obj.Position) / radsumEps;
|
||||
n.Z = 0f;
|
||||
}
|
||||
|
||||
if (time < 0f || time > 1f)
|
||||
return TransitionState.Collided;
|
||||
|
||||
Vector3 offsetOut = globCenter - scaledMovement - checkSphere.Origin;
|
||||
sp.AddOffsetToCheckPos(offsetOut);
|
||||
CollisionInfo.SetCollisionNormal(n);
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
||||
// Apply the offset (difference between slid and current CheckPos)
|
||||
Vector3 delta = slidPos - sphereCheckPos;
|
||||
sp.AddOffsetToCheckPos(delta);
|
||||
if (n.Z != 0f)
|
||||
{
|
||||
if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
|
||||
return TransitionState.Collided;
|
||||
|
||||
ci.SetCollisionNormal(collisionNormal);
|
||||
ci.SetSlidingNormal(collisionNormal);
|
||||
return TransitionState.Slid;
|
||||
time = movement.Z > 0f
|
||||
? -((oldDisp.Z + checkSphere.Radius) / movement.Z)
|
||||
: (checkSphere.Radius + cylHeight - oldDisp.Z) / movement.Z;
|
||||
scaledMovement = movement * time;
|
||||
|
||||
if (time < 0f || time > 1f)
|
||||
return TransitionState.Collided;
|
||||
|
||||
Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin;
|
||||
sp.AddOffsetToCheckPos(offsetOut);
|
||||
CollisionInfo.SetCollisionNormal(n);
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
||||
if (diffSq < 0f || xyMoveLenSq < PhysicsGlobals.EPSILON)
|
||||
return TransitionState.Collided;
|
||||
|
||||
{
|
||||
float diff = MathF.Sqrt(diffSq);
|
||||
time = xyDiff - diff < 0f
|
||||
? (diff - dot2d) / xyMoveLenSq
|
||||
: (xyDiff - diff) / xyMoveLenSq;
|
||||
scaledMovement = movement * time;
|
||||
|
||||
if (time < 0f || time > 1f)
|
||||
return TransitionState.Collided;
|
||||
|
||||
n = (scaledMovement + globCenter - obj.Position) / radsumEps;
|
||||
n.Z = 0f;
|
||||
|
||||
Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin;
|
||||
sp.AddOffsetToCheckPos(offsetOut);
|
||||
CollisionInfo.SetCollisionNormal(n);
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -3356,7 +3659,11 @@ public sealed class Transition
|
|||
internal TransitionState SlideSphereInternal(Vector3 collisionNormal, Vector3 currPos)
|
||||
=> SlideSphere(collisionNormal, currPos);
|
||||
|
||||
private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos)
|
||||
/// <param name="sphereNum">Which path sphere is sliding (retail
|
||||
/// CSphere::slide_sphere's <c>this</c> is the sphere instance — the head
|
||||
/// sphere slides by its OWN displacement, 0x0053b843 passes
|
||||
/// global_sphere[1]). Default 0 preserves every existing call site.</param>
|
||||
private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos, int sphereNum = 0)
|
||||
{
|
||||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
|
|
@ -3364,7 +3671,7 @@ public sealed class Transition
|
|||
// Degenerate case: zero collision normal — nudge halfway.
|
||||
if (collisionNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
|
||||
{
|
||||
Vector3 halfOffset = (currPos - sp.GlobalSphere[0].Origin) * 0.5f;
|
||||
Vector3 halfOffset = (currPos - sp.GlobalSphere[sphereNum].Origin) * 0.5f;
|
||||
sp.AddOffsetToCheckPos(halfOffset);
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
|
@ -3374,7 +3681,7 @@ public sealed class Transition
|
|||
// gDelta: displacement from currPos to the current check sphere center.
|
||||
// In the retail code this includes a block offset for cross-landblock
|
||||
// transitions; for outdoor single-landblock movement this is zero.
|
||||
Vector3 gDelta = sp.GlobalSphere[0].Origin - currPos;
|
||||
Vector3 gDelta = sp.GlobalSphere[sphereNum].Origin - currPos;
|
||||
|
||||
// Get the contact plane (prefer current, fall back to last known).
|
||||
System.Numerics.Plane contactPlane;
|
||||
|
|
@ -3438,15 +3745,25 @@ public sealed class Transition
|
|||
return TransitionState.Slid;
|
||||
}
|
||||
|
||||
// Opposing normals: give up, reverse direction.
|
||||
// Retail returns OK here to allow retry with the reversed normal.
|
||||
// Opposing normals (collision normal anti-parallel to the contact
|
||||
// plane, e.g. a ceiling-facing normal while grounded): record the
|
||||
// REVERSED displacement as the collision normal and return COLLIDED.
|
||||
// Retail CSphere::slide_sphere 0x00537440 @0x005375d7-0x0053762c:
|
||||
// `*normal = -gDelta; normalize_check_small; set_collision_normal;
|
||||
// return 2 (COLLIDED_TS)`. #137 (2026-07-06): this previously
|
||||
// returned OK ("to allow retry with the reversed normal" — a decomp
|
||||
// misread), which let the step complete as-is carrying a SYNTHETIC
|
||||
// reversed-movement collision normal — the live corridor hit's
|
||||
// `n=(-1.00,0.03,-0.03)` (= the negated run direction) matched no
|
||||
// dat polygon; validate's epilogue then turned it into a persisted
|
||||
// sliding normal and wedged all forward motion.
|
||||
Vector3 reversed = -gDelta;
|
||||
if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||
{
|
||||
reversed = Vector3.Normalize(reversed);
|
||||
ci.SetCollisionNormal(reversed);
|
||||
}
|
||||
return TransitionState.OK;
|
||||
return TransitionState.Collided;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -271,6 +271,33 @@ public static class RenderingDiagnostics
|
|||
public static bool ProbeLightEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// A7.L1 (2026-07-06) per-cell light SET-COMPOSITION probe — the apparatus the
|
||||
/// <c>[light]</c> counts could not provide (the #176/#177 discriminator: the bug
|
||||
/// lived in set MEMBERSHIP, not counts). When true, the scoped
|
||||
/// <c>LightManager.BuildPointLightSnapshot</c> emits ONE rate-limited
|
||||
/// <c>[indoor-light]</c> line describing the visible-cell-scoped point-light pool
|
||||
/// (see <see cref="EmitIndoorLight"/>):
|
||||
/// <code>
|
||||
/// [indoor-light] visibleCells=<N> pool=<M> cellLess=<K> registered=<R>
|
||||
/// droppedNonVisible=<R-M> byCell=[0x<id>:<count>,...]
|
||||
/// </code>
|
||||
/// This validates the A7 fix's load-bearing assumption end-to-end:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>cellLess==pool</c> (every pool light is CellId 0) ⇒
|
||||
/// cell tagging FAILED (ParentCellId not flowing) — scoping is a silent no-op.</description></item>
|
||||
/// <item><description><c>pool==cellLess</c> while <c>registered</c> is large in a
|
||||
/// LIT room ⇒ tagged CellIds never match the visible set (wrong id form) — the
|
||||
/// room would go dark.</description></item>
|
||||
/// <item><description><c>droppedNonVisible>0</c> with <c>byCell</c> tracking the
|
||||
/// visible rooms ⇒ scoping WORKING (the under-room/through-floor lights are the
|
||||
/// dropped ones).</description></item>
|
||||
/// </list>
|
||||
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorLightEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1";
|
||||
|
||||
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
|
||||
// so launch.log stays readable under motion (the per-frame call is a no-op
|
||||
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
|
||||
|
|
@ -451,6 +478,66 @@ public static class RenderingDiagnostics
|
|||
}
|
||||
}
|
||||
|
||||
// Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval).
|
||||
private static long _lastIndoorLightEmitTicks;
|
||||
|
||||
/// <summary>
|
||||
/// A7.L1 — emit ONE rate-limited <c>[indoor-light]</c> line describing the
|
||||
/// visible-cell-scoped point-light pool: the SET COMPOSITION the <c>[light]</c>
|
||||
/// counts can't show. Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is
|
||||
/// false; otherwise fires at most once per second. Called from the scoped
|
||||
/// <c>LightManager.BuildPointLightSnapshot</c> (visibleCells != null path).
|
||||
/// </summary>
|
||||
/// <param name="visibleCellCount">Size of the portal-flood visible-cell set this frame.</param>
|
||||
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
|
||||
/// <param name="scopedSnapshot">The visible-cell-scoped point-light pool just built.</param>
|
||||
public static void EmitIndoorLight(int visibleCellCount,
|
||||
IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered,
|
||||
IReadOnlyList<AcDream.Core.Lighting.LightSource> scopedSnapshot)
|
||||
{
|
||||
if (!ProbeIndoorLightEnabled) return;
|
||||
|
||||
long now = DateTime.UtcNow.Ticks;
|
||||
if (_lastIndoorLightEmitTicks != 0 && (now - _lastIndoorLightEmitTicks) < LightEmitIntervalTicks)
|
||||
return;
|
||||
_lastIndoorLightEmitTicks = now;
|
||||
|
||||
int registeredLitPoints = 0;
|
||||
foreach (var l in allRegistered)
|
||||
if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++;
|
||||
|
||||
int pool = scopedSnapshot.Count;
|
||||
int cellLess = 0;
|
||||
var hist = new Dictionary<uint, int>();
|
||||
foreach (var l in scopedSnapshot)
|
||||
{
|
||||
if (l.CellId == 0) cellLess++;
|
||||
hist.TryGetValue(l.CellId, out var c);
|
||||
hist[l.CellId] = c + 1;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(220);
|
||||
sb.Append("[indoor-light] visibleCells=").Append(visibleCellCount);
|
||||
sb.Append(" pool=").Append(pool);
|
||||
sb.Append(" cellLess=").Append(cellLess);
|
||||
sb.Append(" registered=").Append(registeredLitPoints);
|
||||
// Lights excluded by visibility scoping (retail: cells not in visible_cell_table
|
||||
// contribute nothing) — the through-floor/under-room lights kept out of the pool.
|
||||
sb.Append(" droppedNonVisible=").Append(registeredLitPoints - pool);
|
||||
sb.Append(" byCell=[");
|
||||
const int MaxCells = 12;
|
||||
int shown = 0;
|
||||
foreach (var kv in hist)
|
||||
{
|
||||
if (shown >= MaxCells) { sb.Append(",..."); break; }
|
||||
if (shown > 0) sb.Append(',');
|
||||
sb.Append("0x").Append(kv.Key.ToString("X8")).Append(':').Append(kv.Value);
|
||||
shown++;
|
||||
}
|
||||
sb.Append(']');
|
||||
Console.WriteLine(sb.ToString());
|
||||
}
|
||||
|
||||
private static bool _probeEnvCellEnabled =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue