diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 76a3e3d4..22d6d523 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -48,7 +48,16 @@ Copy this block when adding a new issue:
## #175 β Door collision registers the Setup PLACEMENT pose, not the motion-table CLOSED pose (phantom slab behind the visual door)
-**Status:** OPEN β mechanism dat-confirmed (`Issue175HubDoorPoseInspectionTests`); fix not started
+**Status:** π‘ FIX SHIPPED 2026-07-05 (same session) β pending user gate (Facility Hub double door: closed blocks AT the visual panels from both sides, no embed, no phantom wall; Holtburg cottage door unregressed).
+**FIX:** `ShadowShapeBuilder.FromSetup` gains a `partPoseOverride` (BSP part
+shapes only; CylSphere/Sphere unchanged); `RegisterServerEntityCollision`
+derives it via `GameWindow.MotionTableDefaultPose` β the wire MotionTableId's
+default style, first cycle, LowFrame part frames (the closed/idle pose retail's
+live CPhysicsPart holds). Null / short poses fall back per-part to placement
+frames (table-less entities + landblock statics unchanged). Register row
+AP-84 (one-shot registration snapshot vs retail's per-frame live pose β
+equivalent for the door lifecycle since open = ETHEREAL). Pins: the three
+`FromSetup_*` tests in `Issue175HubDoorPoseInspectionTests`.
**Severity:** MEDIUM-HIGH (embed into doors from one side; phantom wall on the other β can push the player out of use radius)
**Filed:** 2026-07-05
**Component:** physics β server-entity collision registration (door part poses)
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 1d4be70e..09d3f274 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -179,6 +179,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone β without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions β a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 β which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speedΓquantum < \|dist\|`, `dist < 0`), acdream applies `delta = β(speedΓquantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there β a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDistβ0 β "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum β₯1/30 β threshold ~1 m; render-rate quanta β ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
+| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors β `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case β doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits β the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path β SetCollisionNormal + Collided β is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp β retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` Β§7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail β clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
## 4. Temporary stopgap (TS) β 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 β sticky seams bound to the ported PositionManager/StickyManager, radii threaded)
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index cd90737a..bdf00131 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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
}
}
+ ///
+ /// #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
+ /// parts.
+ ///
+ private IReadOnlyList? MotionTableDefaultPose(
+ uint motionTableId, int partCount)
+ {
+ if (motionTableId == 0u || partCount == 0 || _dats is null) return null;
+
+ var mt = _dats.Get(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(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;
+ }
+
///
/// R3-W4: one-time per-remote wiring of the animation-dispatch stack β
/// the persistent (ObservedOmega turn
diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
index 5d8d8ee6..8cb30123 100644
--- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
+++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
@@ -38,10 +38,22 @@ public static class ShadowShapeBuilder
/// every radius, height, and local offset.
/// Predicate: does the GfxObj with this id
/// have a non-null PhysicsBSP? Production: id => cache.GetGfxObj(id)?.BSP?.Root is not null.
+ /// #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
+ /// CPhysicsPart 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).
public static IReadOnlyList FromSetup(
Setup setup,
float entScale,
- Func hasPhysicsBsp)
+ Func hasPhysicsBsp,
+ IReadOnlyList? 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.
diff --git a/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs
index 6c023e7c..2ec6134f 100644
--- a/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs
@@ -1,12 +1,16 @@
using System;
using System.IO;
using System.Linq;
+using System.Numerics;
+using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
+using DatReaderWriter.Types;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
+using Placement = DatReaderWriter.Enums.Placement;
namespace AcDream.Core.Tests.Physics;
@@ -145,4 +149,82 @@ public class Issue175HubDoorPoseInspectionTests
_out.WriteLine("=== no DefaultMotionTable on the setup ===");
}
}
+
+ // ββ #175 fix pins: ShadowShapeBuilder partPoseOverride ββββββββββββββ
+
+ private static Setup MakeTwoPartSetup()
+ {
+ var setup = new Setup();
+ setup.Parts.Add(0x01000001u);
+ setup.Parts.Add(0x01000002u);
+ var placement = new AnimationFrame(2);
+ placement.Frames.Clear();
+ placement.Frames.Add(new Frame { Origin = new Vector3(0.88f, -0.44f, 1.37f),
+ Orientation = new Quaternion(0f, 0f, -0.966f, 0.259f) });
+ placement.Frames.Add(new Frame { Origin = new Vector3(-0.88f, -0.44f, 1.37f),
+ Orientation = new Quaternion(0f, 0f, -0.259f, 0.966f) });
+ setup.PlacementFrames[Placement.Default] = placement;
+ return setup;
+ }
+
+ ///
+ /// With a motion-table pose override, the BSP part shapes must use it β
+ /// the closed pose, not the ajar placement pose (the #175 offset).
+ ///
+ [Fact]
+ public void FromSetup_PartPoseOverride_ReplacesPlacementFrames()
+ {
+ var setup = MakeTwoPartSetup();
+ var closed = new[]
+ {
+ new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
+ new Frame { Origin = new Vector3(-0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
+ };
+
+ var shapes = ShadowShapeBuilder.FromSetup(
+ setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: closed);
+
+ Assert.Equal(2, shapes.Count);
+ Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition);
+ Assert.Equal(Quaternion.Identity, shapes[0].LocalRotation);
+ Assert.Equal(new Vector3(-0.85f, 0f, 1.37f), shapes[1].LocalPosition);
+ }
+
+ ///
+ /// Null override (no motion table) keeps the pre-#175 placement-frame
+ /// behavior β landblock statics and table-less entities unchanged.
+ ///
+ [Fact]
+ public void FromSetup_NoOverride_KeepsPlacementFrames()
+ {
+ var setup = MakeTwoPartSetup();
+
+ var shapes = ShadowShapeBuilder.FromSetup(
+ setup, entScale: 1f, hasPhysicsBsp: _ => true);
+
+ Assert.Equal(2, shapes.Count);
+ Assert.Equal(new Vector3(0.88f, -0.44f, 1.37f), shapes[0].LocalPosition);
+ Assert.Equal(new Quaternion(0f, 0f, -0.966f, 0.259f), shapes[0].LocalRotation);
+ }
+
+ ///
+ /// A short override (fewer frames than parts) falls back to placement
+ /// frames β a mismatched motion table must not misplace collision.
+ ///
+ [Fact]
+ public void FromSetup_ShortOverride_FallsBackPerPart()
+ {
+ var setup = MakeTwoPartSetup();
+ var shortOverride = new[]
+ {
+ new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
+ };
+
+ var shapes = ShadowShapeBuilder.FromSetup(
+ setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: shortOverride);
+
+ Assert.Equal(2, shapes.Count);
+ Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition); // override
+ Assert.Equal(new Vector3(-0.88f, -0.44f, 1.37f), shapes[1].LocalPosition); // placement fallback
+ }
}