fix #175: door BSP collision poses at the motion-table closed pose

The Facility Hub double door (Setup 0x02000C9D) embeds the player into
the visual panel from one side and blocks with a phantom slab on the
other: its Setup PLACEMENT frames pose the two panels AJAR (yaw
-150/-30 deg, 0.44 m behind the doorway plane — dat-confirmed by the
Issue175 inspection) while the rendered door poses them CLOSED from
the wire-supplied motion table via the sequencer. ShadowShapeBuilder
read placement frames, so the 1.66x0.29x2.95 m physics slabs
registered at the ajar pose. Retail tests each part's LIVE
CPhysicsPart pose — for an idle door, the motion table's default
(closed) state.

Fix: ShadowShapeBuilder.FromSetup gains partPoseOverride (BSP part
shapes only); RegisterServerEntityCollision derives it from the spawn
MotionTableId via GameWindow.MotionTableDefaultPose (default style ->
first cycle -> LowFrame part frames). Null/short poses fall back
per-part to placement frames — table-less entities and landblock
statics unchanged. One-shot snapshot vs retail's per-frame live pose
is register row AP-84 (equivalent for the door lifecycle: closed ==
default pose; open == ETHEREAL bypasses collision, #150).

Pins: FromSetup_PartPoseOverride_ReplacesPlacementFrames /
_NoOverride_KeepsPlacementFrames / _ShortOverride_FallsBackPerPart.
Suites: Core 2539 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 17:00:06 +02:00
parent 2312259a93
commit 355e389d4c
5 changed files with 160 additions and 6 deletions

View file

@ -4163,9 +4163,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
@ -4254,6 +4267,37 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// #175: the motion table's default-state pose — frame LowFrame of the
/// default style's first cycle animation — the pose an idle entity's
/// parts hold (retail: CPartArray init runs the motion table's default
/// state; collision tests the resulting live CPhysicsPart poses). Used
/// as the BSP shadow-shape part-pose override at registration. Returns
/// null (→ placement-frame fallback) when the entity has no motion
/// table, the table has no default cycle, or the frame doesn't cover
/// <paramref name="partCount"/> parts.
/// </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;
if (!mt.Cycles.TryGetValue((int)mt.DefaultStyle, out var cycle)
|| cycle.Anims.Count == 0)
return null;
var animRef = cycle.Anims[0];
var anim = _dats.Get<DatReaderWriter.DBObjs.Animation>(animRef.AnimId);
if (anim is null || anim.PartFrames.Count == 0) return null;
int idx = System.Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1);
var frames = anim.PartFrames[idx].Frames;
return frames.Count >= partCount ? frames : null;
}
/// <summary>
/// R3-W4: one-time per-remote wiring of the animation-dispatch stack —
/// the persistent <see cref="RemoteMotion.Sink"/> (ObservedOmega turn

View file

@ -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.