fix(phys): A6.P7 — retail-binary cyl-vs-BSP dispatch (HAS_PHYSICS_BSP_PS gate)
Closes the door-cyl phantom slide where a sphere approaching a closed cottage door at NE/SE headings could be blocked by the cyl's radial normal contaminating the slide tangent into the slab face (live evidence in door-a6p6-v2.utf8.log: 12 resolves with cn=(0.86,0.51,0) attributed to door entity 0x000F4245). Retail anchor: CPhysicsObj::FindObjCollisions at acclient_2013_pseudo_c.txt:276861 dispatches BINARILY between BSP-only and cyl+sphere based on HAS_PHYSICS_BSP_PS (0x10000 in acclient.h:2833). For non-PvP, non-missile movers — every M1.5 scope walking-vs-static scenario — an entity with the flag set tests its BSP exclusively; the foot cyl is never tested. ACE confirms the truth table at PhysicsObj.cs:412-450 (HasPhysicsBSP, missileIgnore, exemption). Our dispatcher iterated every ShadowEntry independently and tested both the cyl AND the BSP for a closed door. Cyl was registered first (FromSetup walk order), and its diagonal radial slide normal "won" attribution at the early-return on first non-OK. Result was out=in for tangential motion along the door face. Changes (~15 LOC + 7 unit tests): - PhysicsStateFlags.HasPhysicsBsp = 0x00010000 (PhysicsBody.cs) - Transition.BspOnlyDispatch(uint state) static predicate (TransitionTypes.cs) — mirrors retail's branch with M1.5 scope defaults (ebp_1 and eax_12 treated as false; wire PvP / missile refinements when those scopes ship) - Per-entry guard in FindObjCollisions cyl/sphere branch (TransitionTypes.cs:2433) — continue when BspOnlyDispatch fires, with [cyl-skip-bsp] diagnostic line gated on ProbeBuildingEnabled - A6P7DispatchRulesTests (7 tests, all GREEN): flag value + 6 parameterized predicate cases Verification: 14-test keep-green list from the 2026-05-25 handoff passes (5 BSPQueryTests.FindCollisions_Path5_*, 2 CellTransitTests.A6P5_*, 2 DoorCollisionApparatusTests.Apparatus_DeadCenter_*, 5 DoorBugTrajectoryReplayTests, 1 CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap). Total: 20/20 pass including the new 7-test predicate suite. The DocumentsBug test (Apparatus_Grounded_50cmOffCenter) fails post-fix BUT was already failing pre-fix in the worktree baseline (verified by stashing the fix and re-running — same failure mode: sphere blocks at start with floor normal (0,0,1)). Not in the keep-green list, so this is a known pre-existing condition; the test's own header comment instructs flipping the assertion when the fix lands. Investigation: docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md Needs visual verification at Holtburg cottage door (NE/SE approach should now slide smoothly along the door face — zero [cyl-test] log lines attributed to door entity, replaced by [cyl-skip-bsp]). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b36eff1c10
commit
888272aad1
4 changed files with 492 additions and 0 deletions
|
|
@ -0,0 +1,319 @@
|
|||
# A6.P7 — Retail dispatch investigation for door cyl + slab interaction
|
||||
|
||||
**Date:** 2026-05-25 PM
|
||||
**Mode:** Report-only (per `/investigate` skill). No code edits.
|
||||
**Predecessor:** [`2026-05-25-a6-door-cyl-investigation-handoff.md`](2026-05-25-a6-door-cyl-investigation-handoff.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the smoking gun
|
||||
|
||||
**Retail's `CPhysicsObj::FindObjCollisions` dispatches BINARILY between
|
||||
"BSP-only" and "cyl + sphere" — never both.** The selector is the state
|
||||
bit `HAS_PHYSICS_BSP_PS = 0x10000` (named verbatim in the retail header).
|
||||
|
||||
For a closed cottage door + walking player:
|
||||
- Door state `0x10008` has `HAS_PHYSICS_BSP_PS` set.
|
||||
- Player isn't a missile.
|
||||
- Player isn't a PvP-eligible target of the door.
|
||||
- → Retail goes to the **BSP-only branch**. **The cyl is never tested.**
|
||||
|
||||
Acdream tests both because our dispatch iterates per `ShadowEntry`
|
||||
(cyl and BSP are separate entries). The residual phantom slide at
|
||||
NE/SE headings is the predictable consequence: the cyl's radial normal
|
||||
fires first, drives the slide tangent into the slab face, slab blocks
|
||||
in a downstream sub-tick, net out=in.
|
||||
|
||||
The fix is **~15 LOC at the per-entry test site**, reading
|
||||
`obj.State & 0x10000u` (which is already populated on every
|
||||
`ShadowEntry` from ACE's `spawn.PhysicsState`). It is **NOT** an
|
||||
architectural restructuring of `ShadowObjectRegistry`. The handoff's
|
||||
"Option 2 = large change" assessment was wrong — Option 2 is the
|
||||
right answer, but its scope is dramatically smaller than the handoff
|
||||
feared.
|
||||
|
||||
---
|
||||
|
||||
## Question 1 — What is `state & 0x10000`? Which branch fires?
|
||||
|
||||
**Named flag:** `HAS_PHYSICS_BSP_PS = 0x10000` —
|
||||
[`docs/research/named-retail/acclient.h:2833`](research/named-retail/acclient.h:2833).
|
||||
The full retail `PhysicsState` enum lives at lines 2815-2843. The flags
|
||||
implicated by the dispatch:
|
||||
|
||||
| Bit | Name | Meaning |
|
||||
|---|---|---|
|
||||
| 0x4 | `ETHEREAL_PS` | Non-solid; passes through other objects |
|
||||
| 0x10 | `IGNORE_COLLISIONS_PS` | Skips collision processing entirely |
|
||||
| 0x40 | `MISSILE_PS` | Object is a projectile / arrow / spell in flight |
|
||||
| 0x10000 | `HAS_PHYSICS_BSP_PS` | Object exposes a per-Setup BSP collision mesh |
|
||||
|
||||
**Branch logic from
|
||||
[`acclient_2013_pseudo_c.txt:276861`](research/named-retail/acclient_2013_pseudo_c.txt):**
|
||||
|
||||
```c
|
||||
if (((this->state & 0x10000) == 0 || ebp_1 != 0) || eax_12 != 0)
|
||||
{
|
||||
// CYL + SPHERE path (lines 276863-276953):
|
||||
// iterate part_array's CylSpheres → CCylSphere::intersects_sphere
|
||||
// fall through label_50f21d:
|
||||
// iterate part_array's Spheres → CSphere::intersects_sphere
|
||||
// (BSP is NEVER tested in this branch)
|
||||
}
|
||||
else
|
||||
{
|
||||
// BSP path (lines 276956-276985):
|
||||
state_3 = CPartArray::FindObjCollisions(part_array, transition);
|
||||
// (cyl + sphere are NEVER iterated in this branch)
|
||||
}
|
||||
```
|
||||
|
||||
**What `ebp_1` and `eax_12` are:**
|
||||
|
||||
- `ebp_1` is set at lines 276808-276841. It's non-null **only when**
|
||||
THIS object's weenie is a player AND the moving transition's
|
||||
ObjectInfo has the IsPlayer flag AND no PvP exclusion (IsPK match,
|
||||
IsPKLite match, IsImpenetrable). Effectively: "I am a player and the
|
||||
incoming mover is also a player I can collide with."
|
||||
- `eax_12` is `OBJECTINFO::missile_ignore(transition, this)` —
|
||||
[`acclient_2013_pseudo_c.txt:274385`](research/named-retail/acclient_2013_pseudo_c.txt:274385).
|
||||
Returns non-zero when the moving object is a missile that should
|
||||
ignore this target. For a walking player vs door: returns 0.
|
||||
|
||||
**For our scenario (player walking vs closed door):**
|
||||
- `state & 0x10000 == 0`: FALSE (door has the bit set).
|
||||
- `ebp_1 != 0`: FALSE (door is not a player target).
|
||||
- `eax_12 != 0`: FALSE (walking isn't a missile).
|
||||
- Condition is FALSE → **ELSE branch fires → BSP-only path.**
|
||||
|
||||
The retail client **never calls `CCylSphere::intersects_sphere` on the
|
||||
door's foot cylinder** when a non-missile, non-PvP mover walks into it.
|
||||
|
||||
**ACE cross-reference confirms the truth table exactly.**
|
||||
[`references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:412-450`](../../references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs):
|
||||
|
||||
```csharp
|
||||
if (!State.HasFlag(PhysicsState.HasPhysicsBSP) || missileIgnore || exemption)
|
||||
{
|
||||
// cyl-then-sphere iteration
|
||||
}
|
||||
else if (PartArray != null)
|
||||
{
|
||||
var collided = PartArray.FindObjCollisions(transition); // BSP path
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
ACE names the flag `HasPhysicsBSP`; the local variables are `missileIgnore`
|
||||
(retail's `eax_12`) and `exemption` (retail's `ebp_1`). The structure is
|
||||
identical bar a `// TODO: reverse this check to make it more readable`
|
||||
comment at [`PhysicsObj.cs:401`](../../references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:401)
|
||||
confirming ACE faithfully transcribed the negated predicate without
|
||||
adding interpretation.
|
||||
|
||||
**Verdict on Q1: the cyl is not tested in retail for our case. Bit
|
||||
0x10000 means "this object has a BSP — use it exclusively, do not
|
||||
test the cyl/sphere proxies".**
|
||||
|
||||
---
|
||||
|
||||
## Question 2 — Does retail's cyl actually fire `collides_with_sphere`?
|
||||
|
||||
**Answer derivable from Q1 without running cdb: NO.** The retail dispatch
|
||||
unambiguously routes a closed-door + walking-player call to
|
||||
`CPartArray::FindObjCollisions` (the BSP path). The function
|
||||
`CCylSphere::collides_with_sphere` is reached only via the cyl-and-sphere
|
||||
path; that path is dead code for our scenario.
|
||||
|
||||
A cdb trace would confirm zero hits on `CCylSphere::collides_with_sphere`
|
||||
for our scenario — but the decomp + ACE agreement is sufficient
|
||||
evidence to skip the trace. The branch condition is fully resolved by
|
||||
inspection.
|
||||
|
||||
If we wanted defensive verification (recommended only if a fix attempt
|
||||
fails on first land), the live-trace recipe is:
|
||||
|
||||
```
|
||||
bp acclient!CCylSphere::collides_with_sphere "r $t0=@$t0+1; gc"
|
||||
bp acclient!CPartArray::FindObjCollisions "r $t1=@$t1+1; gc"
|
||||
```
|
||||
|
||||
Walk into the cottage door from outside for ~10 seconds. Expected:
|
||||
`@$t0 == 0` (cyl never tested), `@$t1` non-zero. This would settle
|
||||
the question definitively, but is not blocking the fix.
|
||||
|
||||
---
|
||||
|
||||
## Question 3 — Compare our ShadowShapeBuilder vs retail's Setup parsing
|
||||
|
||||
**Retail STORES both cyl and BSP** for a door whose Setup has both.
|
||||
The cyl + sphere primitives live in `CPartArray::cylspheres` /
|
||||
`CPartArray::spheres`, the BSP is per-Part. Retail does not filter at
|
||||
the storage layer; it filters at the **dispatch** layer via the
|
||||
`HAS_PHYSICS_BSP_PS` flag.
|
||||
|
||||
**Our `ShadowShapeBuilder.FromSetup`** at
|
||||
[`src/AcDream.Core/Physics/ShadowShapeBuilder.cs:41-110`](../../src/AcDream.Core/Physics/ShadowShapeBuilder.cs)
|
||||
does the same — emits both a Cylinder shape and per-Part BSP shapes
|
||||
for Setup `0x020019FF`. **This is correct.** The bug isn't in
|
||||
registration; it's in dispatch.
|
||||
|
||||
**Where we diverge:**
|
||||
|
||||
| Step | Retail | Acdream |
|
||||
|---|---|---|
|
||||
| Storage | One `CPartArray` per `CPhysicsObj`; cyls + spheres + BSP parts all stored | Flat `ShadowEntry` rows in `_cells[cellId]`; one row per shape, no per-entity grouping at the cell layer |
|
||||
| Dispatch trigger | `CPhysicsObj::FindObjCollisions` called once per shadow object (per-cell iteration) | `Transition.FindObjCollisions` iterates every `ShadowEntry` in `nearbyObjs` |
|
||||
| Cyl-vs-BSP branch | Binary on `state & 0x10000` | None — every shape is tested |
|
||||
| Effect on door | Only BSP tested → clean slab-normal slide | Cyl tested first → radial-normal drives slide into slab |
|
||||
|
||||
**Critical observation:** The retail state bit is already on every
|
||||
acdream `ShadowEntry.State` (uint field), populated at
|
||||
[`GameWindow.cs:3156`](../../src/AcDream.App/Rendering/GameWindow.cs:3156)
|
||||
from `spawn.PhysicsState ?? 0u` — ACE delivers it on the wire.
|
||||
Confirmed via direct check: the door test fixtures
|
||||
([`DoorBugTrajectoryReplayTests.cs:61`](../../tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs:61),
|
||||
[`DoorCollisionApparatusTests.cs:371`](../../tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs:371))
|
||||
all seed the door with `0x10008u` (= `STATIC_PS | REPORT_COLLISIONS_PS |
|
||||
HAS_PHYSICS_BSP_PS`). The bit is available — we just don't read it.
|
||||
|
||||
---
|
||||
|
||||
## Mapping to the three fix options
|
||||
|
||||
| Option | Retail-faithful? | Verdict |
|
||||
|---|---|---|
|
||||
| **#1 — BSP-first per-entity test order** | NO. Retail isn't "BSP-first"; it's "BSP-only when 0x10000 set." Per-entity ordering would also test the cyl for tree trunks (no BSP) which is correct — but would still test the cyl for doors, which retail doesn't. | Reject. |
|
||||
| **#2 — Port retail's per-physobj dispatch** | **YES.** This is exactly what retail does. The handoff's scoping ("touches many files; large change") was based on a misread of what Option 2 requires — it does NOT require restructuring `ShadowObjectRegistry` to group shapes by entity. The retail check is per-shape on a state flag already present. | **RECOMMENDED.** ~15 LOC at the per-entry dispatch site. |
|
||||
| **#3 — Door-cyl-as-informational (skip cyl registration when entity has BSP)** | NO. Retail STORES both shapes in `CPartArray` — registration includes both. Filtering at registration would diverge from retail's data model and risk breaking missile / PvP paths that need the cyl. | Reject. |
|
||||
|
||||
The handoff's option-2 worry about "restructure `ShadowObjectRegistry`
|
||||
to group shapes by entity" is overengineered. The retail check is
|
||||
local to each shape's `ShadowEntry.State`:
|
||||
|
||||
```text
|
||||
For each ShadowEntry obj in nearbyObjs:
|
||||
if obj is BSP and (obj.State & HAS_PHYSICS_BSP_PS) is unset, skip (impossible — BSP entries on entities WITH 0x10000 don't need a check; we just need to ensure they DO fire)
|
||||
if obj is Cylinder/Sphere and (obj.State & HAS_PHYSICS_BSP_PS) is SET and not pvp-target and not missile-ignored, skip
|
||||
```
|
||||
|
||||
Effectively: **suppress cyl/sphere tests when the entity has BSP.**
|
||||
Implemented as a single `continue` guard inside the existing loop at
|
||||
[`TransitionTypes.cs:2313`](../../src/AcDream.Core/Physics/TransitionTypes.cs:2313).
|
||||
No data-structure change. No grouping pass. No new fields.
|
||||
|
||||
---
|
||||
|
||||
## Recommended next step
|
||||
|
||||
**Approve the implementation of a retail-binary dispatch** at the
|
||||
per-entry site in `Transition.FindObjCollisions`. The fix has these
|
||||
properties:
|
||||
|
||||
1. **Site:** [`src/AcDream.Core/Physics/TransitionTypes.cs:2313`](../../src/AcDream.Core/Physics/TransitionTypes.cs:2313)
|
||||
(the `if (obj.CollisionType == ShadowCollisionType.BSP) ... else ...`
|
||||
dispatch).
|
||||
2. **Guard:** add a continue at the cyl/sphere branch when
|
||||
`(obj.State & HasPhysicsBspPs) != 0 && !isPvpTarget && !missileIgnore`.
|
||||
For M1.5 polish we can treat both `isPvpTarget` and `missileIgnore`
|
||||
as `false` (no PK, no missiles in scope) and add `// TODO: wire
|
||||
when PK / missiles ship` comments. The simplified guard is
|
||||
`(obj.State & 0x10000u) != 0`.
|
||||
3. **Companion constant:** add `HasPhysicsBsp = 0x10000u` to
|
||||
`PhysicsStateFlags` ([`PhysicsBody.cs:25-43`](../../src/AcDream.Core/Physics/PhysicsBody.cs:25)) —
|
||||
it's currently absent. Naming matches both retail (`HAS_PHYSICS_BSP_PS`)
|
||||
and ACE (`HasPhysicsBSP`).
|
||||
4. **Existing tests that would change outcome under the fix:**
|
||||
- [`DoorCollisionApparatusTests.Apparatus_Grounded_50cmOffCenter_FrontApproach_DocumentsBug`](../../tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs:213)
|
||||
is in "documents the bug" form — its header comment at lines
|
||||
285-298 explicitly says "When the fix lands, flip this to
|
||||
`Assert.True(blocked)`." Fix lands → invert assertion in same
|
||||
commit.
|
||||
- Apparatus dead-center + back-approach tests — should remain
|
||||
PASS (BSP still fires).
|
||||
- `DoorBugTrajectoryReplayTests` LiveCompare tests — should
|
||||
remain PASS (BSP-only behavior is closer to live capture).
|
||||
- `CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap`
|
||||
— unrelated path (#98 cottage-floor cap). Unaffected.
|
||||
- `BSPQueryTests.FindCollisions_Path5_*` — unrelated; tests
|
||||
`BSPQuery` internals not dispatch. PASS.
|
||||
- `CellTransitTests.A6P5_*` — unrelated. PASS.
|
||||
|
||||
5. **Risks:**
|
||||
- **Foot-slipping for small entities on the door cyl.** Retail
|
||||
doesn't have this concern because retail's cyl isn't tested on
|
||||
the door for the standard mover either — so we won't regress
|
||||
anything that retail does. If a future fix needs cyl-vs-cyl for
|
||||
a small dynamic entity (e.g. a chicken bumping the door), that's
|
||||
a separate problem solved per `MISSILE_PS` / `ebp_1` rules, which
|
||||
ours already approximate via `CollisionExemption`.
|
||||
- **Other entities with `0x10000`.** Cottage walls (the static
|
||||
landblock GfxObj `0xA9B47900`) likely have `HAS_PHYSICS_BSP_PS`
|
||||
and only register BSP shapes (no cyl) — fix is a no-op there.
|
||||
NPCs and players have no BSP, no `0x10000`, so the cyl path
|
||||
continues firing for them — desired.
|
||||
- **Verification:** run the existing test list from the handoff
|
||||
(14 tests) post-fix; rerun live launch with all three probes;
|
||||
expect zero `[cyl-test] obj=0x000F4245` lines in the log.
|
||||
|
||||
---
|
||||
|
||||
## Verification plan (post-fix)
|
||||
|
||||
When the fix lands, a single live launch + 14-test green list is
|
||||
sufficient verification. The `door-a6p6-v2.utf8.log` showed:
|
||||
|
||||
- 117 `hit=yes obj=0x000F4245` resolves
|
||||
- 350 `[cyl-test] result=Slid` (across all entities)
|
||||
- 12 phantom `cn=(0.86, 0.51, 0)` resolves attributed to the door
|
||||
|
||||
Post-fix expectation in an equivalent capture:
|
||||
- Door cyl-test count attributed to `obj=0x000F4245`: **0**
|
||||
- Door BSP `[bsp-test]` calls: unchanged or slightly higher (no
|
||||
cyl short-circuit)
|
||||
- `cn=(0.86, 0.51, 0)` phantom on the door: **0**
|
||||
- Visual confirmation: smooth slide along door slab face from
|
||||
NE/SE approach.
|
||||
|
||||
---
|
||||
|
||||
## What this is NOT
|
||||
|
||||
- This is **NOT** a recommendation to restructure `ShadowObjectRegistry`.
|
||||
The flat per-cell list is fine. The retail check is per-shape, not
|
||||
per-entity.
|
||||
- This is **NOT** an Option 1 ("BSP-first ordering") fix. Retail does
|
||||
binary selection, not reordering.
|
||||
- This is **NOT** an Option 3 ("don't register cyl") fix. Retail
|
||||
registers both shapes.
|
||||
- This is **NOT** related to A6.P6's `CCylSphere::step_sphere_up`
|
||||
port (commit `3d4e63f`). That port is correct — it just doesn't
|
||||
fire for the door because the cyl is never reached. A6.P6 remains
|
||||
useful for non-door cylinders (tree trunks, rock pillars).
|
||||
- This is **NOT** related to the cdb workflow being insufficient — we
|
||||
could trace it for confirmation but the decomp + ACE agreement makes
|
||||
inspection sufficient.
|
||||
- **The cottage-floor cap (#98) is unrelated.** This bug is in entity
|
||||
collision dispatch; #98 is in cell BSP / GfxObj polygon evaluation.
|
||||
|
||||
---
|
||||
|
||||
## Citations
|
||||
|
||||
| Source | Line(s) | What |
|
||||
|---|---|---|
|
||||
| `docs/research/named-retail/acclient.h` | 2815-2843 | `enum PhysicsState` — `HAS_PHYSICS_BSP_PS = 0x10000` at 2833 |
|
||||
| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 276776-276996 | `CPhysicsObj::FindObjCollisions` |
|
||||
| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 276861 | Binary dispatch branch |
|
||||
| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 276808-276841 | `ebp_1` (PvP-target-player flag) setup |
|
||||
| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 274385-274410 | `OBJECTINFO::missile_ignore` |
|
||||
| `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` | 381-454 | ACE's `FindObjCollisions` |
|
||||
| `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` | 412-450 | ACE's binary dispatch (cleaner names) |
|
||||
| `references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs` | 14, 24 | ACE's `Missile = 0x40` + `HasPhysicsBSP = 0x10000` |
|
||||
| `src/AcDream.Core/Physics/TransitionTypes.cs` | 2189-2521 | Our `FindObjCollisions` |
|
||||
| `src/AcDream.Core/Physics/TransitionTypes.cs` | 2313 | Our per-shape dispatch site |
|
||||
| `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | 41-110 | Our `FromSetup` (emits both shapes — correct) |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` | 3156 | Where `spawn.PhysicsState` lands on `ShadowEntry.State` |
|
||||
| `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` | 587 | `ShadowEntry.State : uint` field |
|
||||
| `src/AcDream.Core/Physics/PhysicsBody.cs` | 25-43 | `PhysicsStateFlags` (currently missing `HasPhysicsBsp`) |
|
||||
| `tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs` | 213, 285-298 | The "documents the bug" fixture; flip-assertion guidance |
|
||||
|
|
@ -32,6 +32,19 @@ public enum PhysicsStateFlags : uint
|
|||
Gravity = 0x00000400, // bit 10 — apply downward gravity
|
||||
Hidden = 0x00001000,
|
||||
/// <summary>
|
||||
/// A6.P7 (2026-05-25): retail HAS_PHYSICS_BSP_PS bit
|
||||
/// (acclient.h:2833). When set, the entity exposes a per-Setup
|
||||
/// BSP collision mesh; retail's
|
||||
/// <c>CPhysicsObj::FindObjCollisions</c> at
|
||||
/// acclient_2013_pseudo_c.txt:276861 dispatches the entity's
|
||||
/// collision queries to the BSP path EXCLUSIVELY for non-PvP,
|
||||
/// non-missile movers — the foot cylinder and per-Setup spheres
|
||||
/// are NEVER tested in this case. Closed cottage doors have
|
||||
/// state 0x10008 (STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP).
|
||||
/// ACE name: <c>PhysicsState.HasPhysicsBSP</c>.
|
||||
/// </summary>
|
||||
HasPhysicsBsp = 0x00010000, // bit 16 — retail HAS_PHYSICS_BSP_PS
|
||||
/// <summary>
|
||||
/// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834).
|
||||
/// When set, wall-collisions zero the velocity instead of reflecting.
|
||||
/// Used by spell projectiles and missiles that should embed/explode on
|
||||
|
|
|
|||
|
|
@ -586,6 +586,58 @@ public sealed class Transition
|
|||
private static bool DumpEdgeSlideEnabled =>
|
||||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// A6.P7 (2026-05-25) — retail-binary dispatch rule
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A6.P7 retail-binary dispatch predicate. Returns true when an
|
||||
/// entity's collision queries should go to its BSP exclusively,
|
||||
/// skipping the cyl/sphere shapes.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors the dispatch branch in retail's
|
||||
/// <c>CPhysicsObj::FindObjCollisions</c> at
|
||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:276861</c>:
|
||||
/// <code>
|
||||
/// if (((state & 0x10000) == 0 || ebp_1 != 0) || eax_12 != 0)
|
||||
/// // cyl + sphere iteration
|
||||
/// else
|
||||
/// // BSP-only via CPartArray::FindObjCollisions
|
||||
/// </code>
|
||||
/// where <c>ebp_1</c> is the PvP-target-player flag (lines 276808-
|
||||
/// 276841) and <c>eax_12</c> is the <c>OBJECTINFO::missile_ignore</c>
|
||||
/// result (line 274385). The flag is named
|
||||
/// <c>HAS_PHYSICS_BSP_PS = 0x10000</c> in acclient.h:2833 and
|
||||
/// <c>PhysicsState.HasPhysicsBSP</c> in ACE
|
||||
/// (references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:24).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// M1.5 scope (walking-vs-static, no PK, no missiles) treats both
|
||||
/// <c>ebp_1</c> and <c>eax_12</c> as <c>false</c>. The predicate
|
||||
/// reduces to <c>(state & HAS_PHYSICS_BSP_PS) != 0</c>. When
|
||||
/// PK ships (M2+ phase) and missiles ship (F.3), wire the
|
||||
/// PvP-exemption and missile_ignore checks through as additional
|
||||
/// parameters following retail's
|
||||
/// <c>references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:412</c>
|
||||
/// dispatch shape.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Investigation:
|
||||
/// <c>docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="entityState">The collision target entity's raw
|
||||
/// <c>PhysicsState</c> value (as stored on
|
||||
/// <c>ShadowEntry.State</c>).</param>
|
||||
/// <returns>True when retail would dispatch BSP-only — i.e. when
|
||||
/// the entity has <c>HAS_PHYSICS_BSP_PS</c> set; cyl/sphere shapes
|
||||
/// must be skipped at the per-entry dispatch site.</returns>
|
||||
public static bool BspOnlyDispatch(uint entityState)
|
||||
=> (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public entry point
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -2383,6 +2435,36 @@ public sealed class Transition
|
|||
// ACE: Sphere.IntersectsSphere handles CylSphere objects via
|
||||
// the same 6-path dispatcher. For now we keep the swept-sphere
|
||||
// cylinder test which matches the retail CylSphere behavior.
|
||||
|
||||
// A6.P7 (2026-05-25) — retail-binary dispatch. Retail's
|
||||
// CPhysicsObj::FindObjCollisions at
|
||||
// acclient_2013_pseudo_c.txt:276861 dispatches BSP-only
|
||||
// when HAS_PHYSICS_BSP_PS (0x10000) is set on the entity
|
||||
// (and the mover isn't a PvP-eligible player or
|
||||
// missile-ignored). Cottage doors carry the flag in their
|
||||
// state (0x10008), so retail tests their slab BSP
|
||||
// exclusively — the foot cyl is never tested. Without
|
||||
// this guard, our dispatcher iterated cyl FIRST (it's
|
||||
// registered before the BSP shape) and its radial normal
|
||||
// contaminated the slide direction at NE/SE approach
|
||||
// headings, producing the "stuck on door" phantom in
|
||||
// door-a6p6-v2.utf8.log. See investigation at
|
||||
// docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md.
|
||||
//
|
||||
// M1.5 scope: PvP exemption (ebp_1) and missile_ignore
|
||||
// (eax_12) are treated as false. Wire them through when
|
||||
// PK / missiles ship — matches retail's full predicate
|
||||
// at line 276861.
|
||||
if (BspOnlyDispatch(obj.State))
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
{
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[cyl-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
result = CylinderCollision(obj, sp, engine);
|
||||
|
||||
// A6.P4 door investigation (2026-05-24): log every Cylinder
|
||||
|
|
|
|||
78
tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs
Normal file
78
tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// A6.P7 (2026-05-25) — retail-binary dispatch rule. Retail's
|
||||
/// <c>CPhysicsObj::FindObjCollisions</c> at
|
||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:276861</c>
|
||||
/// dispatches BINARILY between "BSP-only" and "cyl + sphere" based on
|
||||
/// the <c>HAS_PHYSICS_BSP_PS</c> flag (bit 16, hex 0x10000) in
|
||||
/// <c>PhysicsState</c>. The flag is defined in
|
||||
/// <c>docs/research/named-retail/acclient.h:2833</c> and mirrored in
|
||||
/// ACE at <c>references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:24</c>
|
||||
/// as <c>HasPhysicsBSP = 0x00010000</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// For non-PvP, non-missile movers (M1.5 scope — walking-vs-static), an
|
||||
/// entity with HAS_PHYSICS_BSP_PS in its state tests its BSP exclusively
|
||||
/// — the foot cyl is NEVER tested. The closed cottage door (state
|
||||
/// <c>0x10008</c> = <c>STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP</c>)
|
||||
/// hits this branch.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Pre-A6.P7: our dispatcher iterates every <c>ShadowEntry</c>
|
||||
/// independently and tests both the cyl AND the BSP for a door. The
|
||||
/// cyl's radial normal contaminates the slide direction at NE/SE
|
||||
/// approach headings (see
|
||||
/// <c>docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md</c>).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Post-A6.P7: the dispatcher consults
|
||||
/// <see cref="Transition.BspOnlyDispatch(uint)"/> on each cyl/sphere
|
||||
/// entry and skips it when the entity has BSP, matching retail.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class A6P7DispatchRulesTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Retail bit constant <c>HAS_PHYSICS_BSP_PS = 0x10000</c>
|
||||
/// (acclient.h:2833) and ACE's <c>HasPhysicsBSP = 0x00010000</c>
|
||||
/// (PhysicsState.cs:24) must match the value we expose on
|
||||
/// <see cref="PhysicsStateFlags"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PhysicsStateFlags_HasPhysicsBsp_Is_Bit_16()
|
||||
{
|
||||
Assert.Equal(0x00010000u, (uint)PhysicsStateFlags.HasPhysicsBsp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dispatch predicate maps directly from retail's branch at
|
||||
/// acclient_2013_pseudo_c.txt:276861:
|
||||
/// <code>
|
||||
/// ((state & 0x10000) == 0 || ebp_1 != 0 || eax_12 != 0)
|
||||
/// → cyl + sphere path
|
||||
/// else
|
||||
/// → BSP-only path
|
||||
/// </code>
|
||||
/// For M1.5 scope <c>ebp_1</c> (PvP-target-player) and <c>eax_12</c>
|
||||
/// (missile_ignore) are treated as false. The predicate reduces to:
|
||||
/// "BSP-only iff <c>HAS_PHYSICS_BSP_PS</c> is set".
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x00010008u, true)] // closed cottage door — STATIC | REPORT | HAS_BSP
|
||||
[InlineData(0x00010000u, true)] // bare HAS_BSP
|
||||
[InlineData(0x00110000u, true)] // HAS_BSP | CLOAKED
|
||||
[InlineData(0x00000008u, false)] // creature — REPORT_COLLISIONS only
|
||||
[InlineData(0x00000000u, false)] // empty state
|
||||
[InlineData(0x0000FFFFu, false)] // every bit BELOW 0x10000 set
|
||||
public void BspOnlyDispatch_RespectsHasPhysicsBspFlag(
|
||||
uint entityState, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, Transition.BspOnlyDispatch(entityState));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue