fix(physics): AP-71 - port check_entry_restrictions at the head of indoor FindEnvCollisions
Campaign P Slice P4 item 1. Ports retail's CObjCell::check_entry_restrictions (pc:308873-308912, 0x0052b6d0), called FIRST by CEnvCell::find_env_collisions (pc:309576) before any BSP work, as ObjectInfo.CheckEntryRestrictions wired at the top of the indoor branch of Transition.FindEnvCollisions. Resolves the research doc's open question on restriction_obj's source: the ACE cross-check (references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs:32, 66-67) plus an independent reflection probe of Chorizite.DatReaderWriter 2.1.7's own EnvCell.RestrictionObj field confirm it is a plain DAT-baked uint32 gated by EnvCellFlags.HasRestrictionObj (0x8) - not a live wire override. The BN pseudo-C's "count for an array alloc" read at the same UnPack offset was the mis-attributed field-name collision feedback_bn_decomp_field_names warned about. CellPhysics.RestrictionObj is wired from envCell.RestrictionObj in BOTH the dev/graph-fixture path (CacheCellStruct) and the production/prepared path (CachePreparedCellStruct) - the latter already receives a live parsed envCell for Position/EnvironmentId, so no bake-format change was needed. The mover's own CanBypassMoveRestrictions (BF_ADMIN 0x100000 AND BF_IMMUNE_CELL_RESTRICTIONS 0x400000, acclient.h:6452-6454) is decoded via the same PWD-bitfield pipeline TS-23 established for PK/PKLite/Impenetrable (EntityCollisionFlags -> ToMoverState -> ObjectInfoState moverFlags). Remaining gap (filed as AP-129, replacing the retired AP-71 row): CanMoveInto (house owner IID + guest/ban list) is unmodeled, so a genuinely restricted cell fails CLOSED for everyone, not just intruders - matching retail's own fallback when the restriction weenie can't be resolved (pc:704-716). Outdoor CLandCell restriction (LandblockInfo.RestrictionTables, a separate DAT structure) is explicitly out of scope for this gate. Conformance: Ap71EntryRestrictionGateTests covers the pure gate logic (NPC bypass, admin bypass, fail-closed, ordinary-cell no-op), the PWD-bitfield two-bit AND decode, and three end-to-end Transition.FindEnvCollisions scenarios proving zero behavior change for ordinary cells. AcDream.Core.Tests: 4026 passed, 2 skipped, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
eddad9cb38
commit
d6c3f8657a
5 changed files with 380 additions and 2 deletions
|
|
@ -0,0 +1,249 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests for AP-71 (Campaign P Slice P4, 2026-07-30) — retail
|
||||
/// <c>CObjCell::check_entry_restrictions</c> (named-retail pc:308873-308912,
|
||||
/// 0x0052b6d0), ported as <see cref="ObjectInfo.CheckEntryRestrictions"/> and
|
||||
/// wired at the head of the indoor branch of
|
||||
/// <c>Transition.FindEnvCollisions</c> (<c>src/AcDream.Core/Physics/
|
||||
/// TransitionTypes.cs</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail gate order: NPCs/props (not a player) bypass entirely; a mover
|
||||
/// whose PWD bitfield grants <c>CanBypassMoveRestrictions</c> (BF_ADMIN &
|
||||
/// BF_IMMUNE_CELL_RESTRICTIONS) bypasses; an ordinary cell (no
|
||||
/// <c>restriction_obj</c> authored) is a no-op; otherwise retail resolves
|
||||
/// the restriction weenie and asks <c>CanMoveInto</c> — acdream has no
|
||||
/// owner/guest-list model for that yet, so it fails CLOSED (Collided),
|
||||
/// matching retail's own fallback when the restriction object can't be
|
||||
/// resolved (pc:704-716).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class Ap71EntryRestrictionGateTests
|
||||
{
|
||||
private const uint RestrictionObjGuid = 0x80001234u;
|
||||
|
||||
[Fact]
|
||||
public void OrdinaryCell_NoRestrictionObj_IsNoOp_ForPlayer()
|
||||
{
|
||||
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer };
|
||||
|
||||
Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrdinaryCell_NoRestrictionObj_IsNoOp_ForNonPlayer()
|
||||
{
|
||||
// NPCs/props aren't players; confirms the gate is a genuine no-op for
|
||||
// the overwhelmingly common (non-house) content regardless of mover kind.
|
||||
var mover = new ObjectInfo { State = ObjectInfoState.None };
|
||||
|
||||
Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestrictedCell_NonPlayerMover_Bypasses()
|
||||
{
|
||||
// Retail's (state & 0x100) == 0 -> OK early return: NPCs/props never
|
||||
// gated by house restrictions regardless of the cell's restriction_obj.
|
||||
var mover = new ObjectInfo { State = ObjectInfoState.None };
|
||||
|
||||
Assert.Equal(
|
||||
TransitionState.OK,
|
||||
mover.CheckEntryRestrictions(RestrictionObjGuid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestrictedCell_PlayerMover_CannotBypass_FailsClosed()
|
||||
{
|
||||
// A restricted cell, a player mover, no CanBypassMoveRestrictions bit:
|
||||
// acdream cannot resolve CanMoveInto (no guest-list model) -> Collided,
|
||||
// matching retail's own fallback when the restriction weenie can't be
|
||||
// resolved (pc:704-716 fallthrough to COLLIDED_TS).
|
||||
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer };
|
||||
|
||||
Assert.Equal(
|
||||
TransitionState.Collided,
|
||||
mover.CheckEntryRestrictions(RestrictionObjGuid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestrictedCell_PlayerMover_CanBypassMoveRestrictions_PassesThrough()
|
||||
{
|
||||
// BF_ADMIN & BF_IMMUNE_CELL_RESTRICTIONS both set on the mover's own
|
||||
// PWD bitfield (via EntityCollisionFlagsExt.ToMoverState) -> OK.
|
||||
var mover = new ObjectInfo
|
||||
{
|
||||
State = ObjectInfoState.IsPlayer | ObjectInfoState.CanBypassMoveRestrictions,
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
TransitionState.OK,
|
||||
mover.CheckEntryRestrictions(RestrictionObjGuid));
|
||||
}
|
||||
|
||||
// ── PWD-bitfield decode (mirrors the existing EntityCollisionFlagsTests
|
||||
// pattern for IsPK/IsPKLite/IsImpenetrable) ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FromPwdBitfield_OnlyAdminBit_DoesNotGrantBypass()
|
||||
{
|
||||
// BF_ADMIN alone (0x100000) is not sufficient — retail ANDs both bits.
|
||||
var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x100000u);
|
||||
Assert.False(flags.HasFlag(EntityCollisionFlags.CanBypassMoveRestrictions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromPwdBitfield_OnlyImmuneCellRestrictionsBit_DoesNotGrantBypass()
|
||||
{
|
||||
// BF_IMMUNE_CELL_RESTRICTIONS alone (0x400000) is not sufficient either.
|
||||
var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x400000u);
|
||||
Assert.False(flags.HasFlag(EntityCollisionFlags.CanBypassMoveRestrictions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromPwdBitfield_BothAdminAndImmuneCellRestrictionsBits_GrantsBypass()
|
||||
{
|
||||
uint bitfield = 0x100000u | 0x400000u;
|
||||
var flags = EntityCollisionFlagsExt.FromPwdBitfield(bitfield);
|
||||
Assert.True(flags.HasFlag(EntityCollisionFlags.CanBypassMoveRestrictions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToMoverState_CanBypassMoveRestrictions_TranslatesIndependently()
|
||||
{
|
||||
Assert.Equal(
|
||||
ObjectInfoState.CanBypassMoveRestrictions,
|
||||
EntityCollisionFlags.CanBypassMoveRestrictions.ToMoverState());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromPwdBitfield_ThenToMoverState_EndToEnd_AdminBypassesRestriction()
|
||||
{
|
||||
// Full pipeline: a wire bitfield with BF_PLAYER | BF_ADMIN |
|
||||
// BF_IMMUNE_CELL_RESTRICTIONS decodes through FromPwdBitfield ->
|
||||
// ToMoverState -> ORs into moverFlags -> ObjectInfo.State ->
|
||||
// CheckEntryRestrictions passes through a restricted cell.
|
||||
uint bitfield = 0x8u | 0x100000u | 0x400000u;
|
||||
ObjectInfoState moverState =
|
||||
ObjectInfoState.IsPlayer | EntityCollisionFlagsExt.FromPwdBitfield(bitfield).ToMoverState();
|
||||
|
||||
var mover = new ObjectInfo { State = moverState };
|
||||
|
||||
Assert.Equal(
|
||||
TransitionState.OK,
|
||||
mover.CheckEntryRestrictions(RestrictionObjGuid));
|
||||
}
|
||||
|
||||
// ── CellPhysics.RestrictionObj plumbing (ordinary-cell no-op proof) ────
|
||||
|
||||
[Fact]
|
||||
public void CellPhysics_DefaultRestrictionObj_IsZero()
|
||||
{
|
||||
// Default-constructed CellPhysics (the shape every existing test
|
||||
// fixture builds) carries RestrictionObj == 0 — the exact "ordinary
|
||||
// cell" no-op input CheckEntryRestrictions expects. Proves the new
|
||||
// field cannot silently flip any existing fixture's behavior.
|
||||
var cellPhysics = new CellPhysics
|
||||
{
|
||||
WorldTransform = System.Numerics.Matrix4x4.Identity,
|
||||
InverseWorldTransform = System.Numerics.Matrix4x4.Identity,
|
||||
Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
|
||||
Assert.Equal(0u, cellPhysics.RestrictionObj);
|
||||
}
|
||||
|
||||
// ── End-to-end: the gate wired into Transition.FindEnvCollisions ──────
|
||||
// A single indoor cell with an EMPTY physics BSP (no walls of its own) —
|
||||
// the ONLY thing that can stop the sphere here is the entry-restriction
|
||||
// gate. Proves the wiring at the top of FindEnvCollisions's indoor
|
||||
// branch, not just the pure CheckEntryRestrictions logic above.
|
||||
|
||||
private const uint CellId = 0xA9B40157u;
|
||||
|
||||
private static CellPhysics MakeIndoorCell(uint restrictionObj) => new()
|
||||
{
|
||||
BSP = new PhysicsBSPTree
|
||||
{
|
||||
Root = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f },
|
||||
},
|
||||
},
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } },
|
||||
RestrictionObj = restrictionObj,
|
||||
};
|
||||
|
||||
private static PhysicsEngine MakeEngine(CellPhysics cellPhysics)
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
engine.DataCache = new PhysicsDataCache();
|
||||
engine.DataCache.RegisterCellStructForTest(CellId, cellPhysics);
|
||||
return engine;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndToEnd_RestrictedCell_PlayerCannotBypass_TransitionHaltsAtOrigin()
|
||||
{
|
||||
var engine = MakeEngine(MakeIndoorCell(restrictionObj: 0xABCDu));
|
||||
|
||||
var from = new Vector3(0.1f, 0f, 0.2f);
|
||||
var to = new Vector3(0.7f, 0f, 0.2f);
|
||||
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, cellId: CellId);
|
||||
t.ObjectInfo.State |= ObjectInfoState.IsPlayer;
|
||||
|
||||
t.FindTransitionalPosition(engine);
|
||||
|
||||
Assert.True(
|
||||
System.MathF.Abs(t.SpherePath.CurPos.X - from.X) < 1e-4f,
|
||||
$"Restricted cell must halt the player at the origin; CurPos.X={t.SpherePath.CurPos.X:F4}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndToEnd_RestrictedCell_PlayerCanBypass_TransitionReachesTarget()
|
||||
{
|
||||
var engine = MakeEngine(MakeIndoorCell(restrictionObj: 0xABCDu));
|
||||
|
||||
var from = new Vector3(0.1f, 0f, 0.2f);
|
||||
var to = new Vector3(0.7f, 0f, 0.2f);
|
||||
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, cellId: CellId);
|
||||
t.ObjectInfo.State |= ObjectInfoState.IsPlayer | ObjectInfoState.CanBypassMoveRestrictions;
|
||||
|
||||
t.FindTransitionalPosition(engine);
|
||||
|
||||
Assert.True(
|
||||
System.MathF.Abs(t.SpherePath.CurPos.X - to.X) < 1e-4f,
|
||||
$"An admin/bypass-flagged player must pass through unimpeded; CurPos.X={t.SpherePath.CurPos.X:F4}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndToEnd_OrdinaryCell_NoRestrictionObj_PlayerUnaffected()
|
||||
{
|
||||
// Zero-behavior-change proof: an ordinary (non-house) cell, empty BSP,
|
||||
// reaches the target exactly like it did before AP-71 landed.
|
||||
var engine = MakeEngine(MakeIndoorCell(restrictionObj: 0u));
|
||||
|
||||
var from = new Vector3(0.1f, 0f, 0.2f);
|
||||
var to = new Vector3(0.7f, 0f, 0.2f);
|
||||
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, cellId: CellId);
|
||||
t.ObjectInfo.State |= ObjectInfoState.IsPlayer;
|
||||
|
||||
t.FindTransitionalPosition(engine);
|
||||
|
||||
Assert.True(
|
||||
System.MathF.Abs(t.SpherePath.CurPos.X - to.X) < 1e-4f,
|
||||
$"Ordinary cell must be a complete no-op; CurPos.X={t.SpherePath.CurPos.X:F4}");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue