refactor(world): extract live projection mechanics

Move appearance rebinding, collision construction, default-pose resolution, local shadow ownership, and exact leave-world presentation into focused owners. Preserve retail parent ordering with staged validation, committed recovery, recursive attached-subtree withdrawal, and retryable exact teardown across parent, pickup, position, unwield, and pose-loss edges.

Co-Authored-By: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-21 16:17:03 +02:00
parent c87bac31a0
commit 69a2ca0c6d
26 changed files with 4172 additions and 430 deletions

View file

@ -216,6 +216,92 @@ public sealed class ShadowObjectRegistry
CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f);
}
/// <summary>
/// Replaces an existing live PartArray collision payload in its current
/// shadow-cell membership. Retail <c>CPartArray::SetPart</c> changes the
/// part read by later collision tests; it does not recalculate cross-cells.
/// A suspended owner updates only its retained payload. A newly shaped
/// owner may perform its first flood, then immediately suspend when its
/// canonical projection is Hidden, attached, or cell-less.
/// </summary>
public void ReplaceMultiPartPayload(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes,
uint state,
EntityCollisionFlags flags,
float worldOffsetX,
float worldOffsetY,
uint landblockId,
uint seedCellId = 0u,
bool isStatic = false,
bool suspendIfNew = false)
{
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior)
|| !prior.IsMultiPart)
{
if (shapes.Count == 0)
return;
RegisterMultiPart(
entityId,
entityWorldPos,
entityWorldRot,
shapes,
state,
flags,
worldOffsetX,
worldOffsetY,
landblockId,
seedCellId,
isStatic);
if (suspendIfNew)
Suspend(entityId);
return;
}
bool suspended = _suspendedEntities.Contains(entityId);
_entityShapes[entityId] = shapes;
_entityReg[entityId] = prior with
{
EntityWorldPos = entityWorldPos,
EntityWorldRot = entityWorldRot,
State = state,
Flags = flags,
};
if (suspended || !_entityToCells.TryGetValue(entityId, out List<uint>? cells))
return;
foreach (uint cellId in cells)
{
if (_cells.TryGetValue(cellId, out List<ShadowEntry>? entries))
entries.RemoveAll(entry => entry.EntityId == entityId);
}
foreach (ShadowShape shape in shapes)
{
Vector3 partWorldPos = entityWorldPos
+ Vector3.Transform(shape.LocalPosition, entityWorldRot);
Quaternion partWorldRot = entityWorldRot * shape.LocalRotation;
var entry = new ShadowEntry(
EntityId: entityId,
GfxObjId: shape.GfxObjId,
Position: partWorldPos,
Rotation: partWorldRot,
Radius: shape.Radius,
CollisionType: shape.CollisionType,
CylHeight: shape.CylHeight,
Scale: shape.Scale,
State: state,
Flags: flags,
LocalPosition: shape.LocalPosition,
LocalRotation: shape.LocalRotation);
foreach (uint cellId in cells)
AddEntryToCell(entry, cellId);
}
}
/// <summary>
/// Retail flood-sphere rule (CylSphere overload, Ghidra 0x0052b9f0):
/// when the object has cylinder shapes, each contributes one sphere at

View file

@ -50,11 +50,16 @@ public static class ShadowShapeBuilder
/// placement frame per part (entities with no motion table, and the
/// CylSphere/Sphere shapes, are unaffected — retail poses those from
/// the setup too).</param>
/// <param name="effectivePartGfxObjIds">Current part identities after
/// retail <c>AnimPartChanged</c> processing. Collision keeps each Setup
/// index and pose, but reads PhysicsBSP from the installed replacement.
/// Null or short lists fall back to the Setup identity.</param>
public static IReadOnlyList<ShadowShape> FromSetup(
Setup setup,
float entScale,
Func<uint, bool> hasPhysicsBsp,
IReadOnlyList<Frame>? partPoseOverride = null)
IReadOnlyList<Frame>? partPoseOverride = null,
IReadOnlyList<uint>? effectivePartGfxObjIds = null)
{
if (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
@ -102,7 +107,14 @@ public static class ShadowShapeBuilder
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
for (int i = 0; i < setup.Parts.Count; i++)
{
uint gfxId = (uint)setup.Parts[i];
// Retail CPhysicsPart::SetPart installs AnimPartChanged's current
// degrade array before CPartArray::FindObjCollisions reads it.
// Keep the stable Setup part index/pose, but source collision
// identity from that effective part when one was supplied.
uint gfxId = effectivePartGfxObjIds is not null
&& i < effectivePartGfxObjIds.Count
? effectivePartGfxObjIds[i]
: (uint)setup.Parts[i];
if (!hasPhysicsBsp(gfxId)) continue;
Frame partFrame;

View file

@ -3505,7 +3505,7 @@ public sealed class Transition
/// PathClipped movers + airborne head-sphere hits. Non-PerfectClip records the
/// center-to-center collision normal and hard-stops (the M1.5 load-bearing
/// path — players never set PerfectClip). PerfectClip gets the exact
/// time-of-impact reposition (missiles only — AP-84, dead in M1.5, ported per
/// time-of-impact reposition (missiles only — AP-91, dead in M1.5, ported per
/// ACE Sphere.cs:175-210; re-verify vs Ghidra before missiles ship).
/// </summary>
private TransitionState SphereCollideWithPoint(ShadowEntry obj, SpherePath sp,
@ -3521,7 +3521,7 @@ public sealed class Transition
return TransitionState.Collided;
}
// PerfectClip exact time-of-impact (AP-84 — dead in M1.5). Block offset = 0.
// PerfectClip exact time-of-impact (AP-91 — dead in M1.5). Block offset = 0.
Vector3 checkOffset = checkSphere.Origin - gCenter;
double toi = FindSphereTimeOfCollision(checkOffset, globalOffset, radsum + PhysicsGlobals.EPSILON);
if (toi < PhysicsGlobals.EPSILON || toi > 1.0)