fix(physics): AP-129 review fix - port CanMoveInto/IsAllowedIn, stop failing closed

Campaign P Slice P4 Opus review verdict: FIX-FIRST. RestrictionObjPrevalenceInspectionTests
(commit 3b5e0992) found 103,766 of 729,888 installed EnvCells (1,293 landblocks -
the whole housing estate) carry a baked RestrictionObj. The AP-71 gate's
unconditional fail-closed default (CanMoveInto unmodeled) would have locked
every apartment/cottage/villa interior for every player, including its own
owner - a live regression, not the "inert in dev content" the original
register row assumed.

Ports ACCWeenieObject::CanMoveInto (0x0058da40, pc:407982-408056) and
RestrictionDB::IsAllowedIn (0x005ae8f0, pc:444493-444516) verbatim into
ObjectInfo.CheckEntryRestrictions:
- owner_iid == 0 or == mover's own guid -> admit (open/owner)
- no RestrictionDB (retail _db == 0, i.e. never authored or not yet
  received) -> admit
- present RestrictionDB -> IsAllowedIn: open-to-public flag, OR mover
  shares the house's allegiance monarch, OR mover's own guid is a
  guest-table member
- unresolved restriction object -> fails CLOSED, exactly retail's own
  fallback when GetObjectA can't resolve it (pc:704-716)

Wire feed (Core.Net):
- CreateObject.cs: HouseOwner (WeenieHeaderFlag 0x02000000), HouseRestrictions
  (0x04000000), and Monarch (0x40) PWD-tail fields were parsed-and-skipped;
  now captured. Also fixes the HouseRestrictions PHashTable header
  misconception: the wire is ONE packed u32 (low 24 bits = entry count),
  not a separate count(u16)+numBuckets(u16) pair - verified against
  Chorizite's RestrictionDB.generated.cs. The old skip's byte-count
  happened to match for realistic guest-list sizes, but a future
  numBuckets value >255 would have corrupted the parse; now correct
  regardless.
- GameEvents.cs/GameEventWiring.cs: new House_UpdateRestrictions (0x0248)
  parser + wiring - retail's live guest-list refresh, whole-unit replace.
  No-ops if the house object hasn't arrived via CreateObject yet.
- ClientObject/WeenieData/ClientObjectTable: HouseOwnerId, MonarchId,
  Restrictions (new HouseRestrictionRecord) fields + merge-preserving
  Ingest + targeted UpdateHouseRestrictions.

Physics wiring:
- PhysicsEngine gains an Objects (ClientObjectTable?) property, mirroring
  the existing DataCache pattern - acdream's GetObjectA equivalent, used
  ONLY by the entry-restriction gate.
- RuntimeEntityObjectLifetime wires Physics.Engine.Objects = Objects in
  all three constructors, right alongside the table's own construction -
  the same canonical table every other subsystem borrows from, never a
  second one. This is the production fix: without it the gate still fails
  closed on every restricted cell (unresolvable object), so the wiring is
  load-bearing, not cosmetic.

Register: AP-129 narrowed (not retired) to the genuine remaining residual -
House_UpdateRestrictions' Sequence byte isn't used for staleness/reordering
rejection (low-probability, self-correcting), and outdoor CLandCell
restriction (a separate DAT structure) remains unported and unaffected by
this fix.

Tests: 15 new/updated in Ap71EntryRestrictionGateTests.cs (resolved-unowned
admits, owner admits, present-list-excluded blocks, present-list-included
admits, open-to-public admits, shared-allegiance-monarch admits, unresolved
blocks via null and via an empty table, plus two new end-to-end
PhysicsEngine.Objects-wired scenarios); 2 new CreateObject parser tests +
2 new GameEventWiring tests for the wire feed.

AcDream.Core.Tests: 4049 passed, 2 skipped, 0 failed.
AcDream.Core.Net.Tests: 761 passed, 0 skipped, 0 failed.
Complete solution suite: 9,961 total, 9,956 passed, 5 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 11:36:11 +02:00
parent dc0468cc2b
commit 7a0f836af5
15 changed files with 704 additions and 81 deletions

View file

@ -362,6 +362,19 @@ public static class GameEventWiring
containerTypeHint: p.Value.ContainerType);
});
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): House_UpdateRestrictions
// (0x0248) — live refresh of a house object's guest/ban list. Feeds
// ObjectInfo.CheckEntryRestrictions' CanMoveInto port. No-ops if the house
// object hasn't arrived via CreateObject yet (UpdateHouseRestrictions
// returns false), matching every other targeted property update here.
registrar.Register(GameEventType.HouseUpdateRestrictions, e =>
{
var p = GameEvents.ParseHouseUpdateRestrictions(e.Payload.Span);
if (p is null) return;
items.UpdateHouseRestrictions(p.Value.SenderId, p.Value.Restrictions);
});
// ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you
// opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without
// inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so

View file

@ -1,6 +1,7 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Items;
namespace AcDream.Core.Net.Messages;
@ -227,7 +228,14 @@ public static class CreateObject
// PublicWeenieDesc._material_type, gated by PWD_Packed_MaterialType.
// ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) uses this live
// spawn field to prefix the authored material name.
uint? MaterialType = null);
uint? MaterialType = null,
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): house-restriction
// PWD tail fields (WeenieHeaderFlag.Owner 0x02000000, .HouseRestrictions
// 0x04000000, .Monarch 0x40). Feeds ObjectInfo.CheckEntryRestrictions'
// CanMoveInto port.
uint? HouseOwnerId = null,
uint? MonarchId = null,
HouseRestrictionRecord? Restrictions = null);
/// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> /
@ -861,10 +869,10 @@ public static class CreateObject
// 0x01000000 Workmanship f32 (skip)
// 0x00200000 Burden u16 (skip)
// 0x00400000 Spell u16 (skip)
// 0x02000000 HouseOwner u32 (skip)
// 0x04000000 HouseRestrictions RestrictionDB (skip, variable-length)
// 0x02000000 HouseOwner u32 CAPTURE (AP-129)
// 0x04000000 HouseRestrictions RestrictionDB CAPTURE (AP-129, variable-length)
// 0x20000000 HookItemTypes u32 CAPTURE
// 0x00000040 Monarch u32 (skip)
// 0x00000040 Monarch u32 CAPTURE (AP-129)
// 0x10000000 HookType u16 CAPTURE
// 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE
// weenieFlags2 bit 0x01:
@ -897,6 +905,13 @@ public static class CreateObject
uint? cooldownId = null;
double? cooldownDuration = null;
uint? materialType = null;
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): house-restriction
// PWD tail fields, previously skipped. HouseOwner/Monarch are plain u32
// ids; HouseRestrictions is the full RestrictionDB (open flag + allegiance
// monarch + guest table) — see AcDream.Core.Items.HouseRestrictionRecord.
uint? houseOwnerId = null;
uint? monarchId = null;
HouseRestrictionRecord? restrictions = null;
try
{
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
@ -1051,37 +1066,50 @@ public static class CreateObject
spellId = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32 ← CAPTURE (AP-129)
{
if (body.Length - pos < 4) throw new FormatException("trunc HouseOwner");
pos += 4;
houseOwnerId = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x04000000u) != 0) // HouseRestrictions (RestrictionDB)
if ((weenieFlags & 0x04000000u) != 0) // HouseRestrictions (RestrictionDB) ← CAPTURE (AP-129)
{
// Wire layout per ACE RestrictionDB + RestrictionDBExtensions.Write:
// u32 Version, u32 OpenStatus, u32 MonarchId,
// u16 count, u16 numBuckets, then count × (u32 guid + u32 value).
// Fixed header = 12 bytes; PackableHashTable header = 4 bytes.
// Total = 16 + count * 8.
if (body.Length - pos < 16) throw new FormatException("trunc RestrictionDB header");
// Version(4) + OpenStatus(4) + MonarchId(4) = 12 bytes
pos += 12;
ushort tableCount = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2; // count u16
pos += 2; // numBuckets u16
int entryBytes = tableCount * 8; // each entry: u32 guid + u32 value
// Wire layout per Chorizite RestrictionDB.generated.cs (verified against
// protocol.xml:6270-6275): u32 Version, u32 Flags, u32 MonarchId, then a
// PHashTable<uint,uint> (NOT the u16-count/u16-size PackableHashTable shape
// this skip previously assumed — the byte total happened to match for any
// realistic guest-list size, but the count extraction was reading the wrong
// half of the packed dword). PHashTable's own header is ONE packed u32 whose
// low 24 bits are the entry count.
if (body.Length - pos < 12) throw new FormatException("trunc RestrictionDB header");
pos += 4; // Version — not consulted
uint flags = ReadU32(body, ref pos); // 0 = private, 1 = open
uint restrictionMonarchId = ReadU32(body, ref pos);
if (body.Length - pos < 4) throw new FormatException("trunc RestrictionDB PHashTable header");
uint packedSize = ReadU32(body, ref pos);
uint entryCount = packedSize & 0xFFFFFFu;
long entryBytes = (long)entryCount * 8; // each entry: u32 guid + u32 value
if (body.Length - pos < entryBytes) throw new FormatException("trunc RestrictionDB entries");
pos += entryBytes;
var guests = new Dictionary<uint, uint>((int)entryCount);
for (uint i = 0; i < entryCount; i++)
{
uint guestId = ReadU32(body, ref pos);
uint permission = ReadU32(body, ref pos);
guests[guestId] = permission;
}
restrictions = new HouseRestrictionRecord(
OpenToPublic: flags != 0,
AllegianceMonarchId: restrictionMonarchId,
Guests: guests);
}
if ((weenieFlags & 0x20000000u) != 0) // HookItemTypes u32
{
if (body.Length - pos < 4) throw new FormatException("trunc HookItemTypes");
wHookItemTypes = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000040u) != 0) // Monarch u32
if ((weenieFlags & 0x00000040u) != 0) // Monarch u32 ← CAPTURE (AP-129)
{
if (body.Length - pos < 4) throw new FormatException("trunc Monarch");
pos += 4;
monarchId = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x10000000u) != 0) // HookType u16
{
@ -1158,7 +1186,10 @@ public static class CreateObject
CooldownId: cooldownId,
CooldownDuration: cooldownDuration,
Physics: physics,
MaterialType: materialType);
MaterialType: materialType,
HouseOwnerId: houseOwnerId,
MonarchId: monarchId,
Restrictions: restrictions);
}
catch
{

View file

@ -2,6 +2,7 @@ using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using AcDream.Core.Items;
namespace AcDream.Core.Net.Messages;
@ -538,6 +539,57 @@ public static class GameEvents
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
// ── House ────────────────────────────────────────────────────────────────
/// <summary>
/// 0x0248 House_UpdateRestrictions: retail's live refresh of a house
/// object's guest/ban list (whole-unit replace, not a delta). Wire shape
/// confirmed verbatim against <c>references/Chorizite.ACProtocol
/// /Chorizite.ACProtocol/Messages/S2C/Events/House_UpdateRestrictions
/// .generated.cs</c>: <c>byte Sequence, uint SenderId, RestrictionDB
/// Restrictions</c> — Sequence is a single unpadded byte, immediately
/// followed by the 4-byte SenderId (the house object whose restrictions
/// changed).
/// </summary>
public readonly record struct HouseUpdateRestrictions(
byte Sequence,
uint SenderId,
HouseRestrictionRecord Restrictions);
public static HouseUpdateRestrictions? ParseHouseUpdateRestrictions(ReadOnlySpan<byte> payload)
{
// Sequence(1) + SenderId(4) + RestrictionDB{Version(4)+Flags(4)+MonarchId(4)+PHashTable-header(4)} = 21
if (payload.Length < 21) return null;
int pos = 0;
byte sequence = payload[pos]; pos += 1;
uint senderId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
pos += 4; // Version — not consulted
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint allegianceMonarchId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint packedSize = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint entryCount = packedSize & 0xFFFFFFu;
long entryBytes = (long)entryCount * 8;
if (payload.Length - pos < entryBytes) return null;
var guests = new Dictionary<uint, uint>((int)entryCount);
for (uint i = 0; i < entryCount; i++)
{
uint guestId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint permission = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
guests[guestId] = permission;
}
return new HouseUpdateRestrictions(
sequence,
senderId,
new HouseRestrictionRecord(
OpenToPublic: flags != 0,
AllegianceMonarchId: allegianceMonarchId,
Guests: guests));
}
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)

View file

@ -190,5 +190,8 @@ public static class ObjectTableWiring
SpellId: s.SpellId,
CooldownId: s.CooldownId,
CooldownDuration: s.CooldownDuration,
MaterialType: s.MaterialType);
MaterialType: s.MaterialType,
HouseOwnerId: s.HouseOwnerId,
MonarchId: s.MonarchId,
Restrictions: s.Restrictions);
}

View file

@ -179,7 +179,11 @@ public sealed class WorldSession : IDisposable
PhysicsSpawnData? Physics = null,
uint? HookItemTypes = null,
uint? HookType = null,
uint? MaterialType = null);
uint? MaterialType = null,
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30).
uint? HouseOwnerId = null,
uint? MonarchId = null,
HouseRestrictionRecord? Restrictions = null);
/// <summary>
/// Projects the wire-level CreateObject result into the stable session
@ -245,7 +249,10 @@ public sealed class WorldSession : IDisposable
Physics: parsed.Physics,
HookItemTypes: parsed.HookItemTypes,
HookType: parsed.HookType,
MaterialType: parsed.MaterialType);
MaterialType: parsed.MaterialType,
HouseOwnerId: parsed.HouseOwnerId,
MonarchId: parsed.MonarchId,
Restrictions: parsed.Restrictions);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;

View file

@ -267,6 +267,31 @@ public sealed class ClientObject
/// the base object name.
/// </summary>
public uint? MaterialType { get; set; }
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._house_owner_iid</c>. Present only on a house/dwelling restriction
/// object (wire <c>WeenieHeaderFlag.Owner</c>, 0x02000000). Zero or a
/// match against the mover's own id admits regardless of the guest list.
/// </summary>
public uint? HouseOwnerId { get; set; }
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._monarch_iid</c> (wire <c>WeenieHeaderFlag.Monarch</c>, 0x40) — this
/// object's OWN allegiance monarch. Present on any weenie, not just house
/// objects; a player's own value is what <c>RestrictionDB::IsAllowedIn</c>
/// compares against a house's <see cref="HouseRestrictionRecord.AllegianceMonarchId"/>.
/// </summary>
public uint? MonarchId { get; set; }
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._db</c> (<c>RestrictionDB*</c>) — the house's own guest/ban list.
/// Null means retail's <c>_db == 0</c> ("no list", i.e. open) OR simply
/// that neither a CreateObject HouseRestrictions field nor a live
/// <c>House_UpdateRestrictions (0x0248)</c> has arrived yet for this
/// object — acdream cannot distinguish the two, and both resolve to
/// the same retail-faithful "allow" default.
/// </summary>
public HouseRestrictionRecord? Restrictions { get; set; }
public PropertyBundle Properties { get; } = new();
/// <summary>
@ -332,7 +357,14 @@ public readonly record struct WeenieData(
double? CooldownDuration = null,
uint? HookItemTypes = null,
uint? HookType = null,
uint? MaterialType = null);
uint? MaterialType = null,
// AP-129 (Campaign P Slice P4 review fix): house-restriction PWD tail
// fields. Restrictions is null-preserving (a CreateObject that doesn't
// carry HouseRestrictions this time must not clobber a value fed
// separately by a live House_UpdateRestrictions event).
uint? HouseOwnerId = null,
uint? MonarchId = null,
HouseRestrictionRecord? Restrictions = null);
/// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).

View file

@ -771,6 +771,23 @@ public sealed class ClientObjectTable
return true;
}
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix): live <c>House_UpdateRestrictions
/// (0x0248)</c> refresh of a house object's guest/ban list — retail resends
/// the whole <c>RestrictionDB</c> (open flag + allegiance monarch + guest
/// table) as one unit rather than a delta. No-ops (returns false) if the
/// house object hasn't arrived via CreateObject yet, matching every other
/// targeted property update in this table.
/// </summary>
public bool UpdateHouseRestrictions(uint guid, HouseRestrictionRecord restrictions)
{
ArgumentNullException.ThrowIfNull(restrictions);
if (!_objects.TryGetValue(guid, out var item)) return false;
item.Restrictions = restrictions;
ObjectUpdated?.Invoke(item);
return true;
}
/// <summary>
/// Canonical CreateObject ingestion: create-if-absent, else patch the
/// wire-carried fields in place (retail SetWeenieDesc). Preserves the
@ -828,6 +845,9 @@ public sealed class ClientObjectTable
if (d.MaxStructure is { } ms) obj.MaxStructure = ms;
if (d.Workmanship is { } wm) obj.Workmanship = wm;
if (d.MaterialType is { } materialType) obj.MaterialType = materialType;
if (d.HouseOwnerId is { } houseOwnerId) obj.HouseOwnerId = houseOwnerId;
if (d.MonarchId is { } monarchId) obj.MonarchId = monarchId;
if (d.Restrictions is { } restrictions) obj.Restrictions = restrictions;
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
obj.ObjectId,

View file

@ -0,0 +1,44 @@
using System.Collections.Generic;
namespace AcDream.Core.Items;
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix, 2026-07-30). Retail
/// <c>RestrictionDB</c> — the access-control list carried on a house/dwelling
/// object's <c>PublicWeenieDesc</c> (<c>WeenieHeaderFlag.HouseRestrictions</c>,
/// 0x04000000) and refreshed live via <c>House_UpdateRestrictions (0x0248)</c>.
/// Wire shape confirmed verbatim against
/// <c>references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/RestrictionDB.generated.cs</c>
/// and <c>protocol.xml:6270-6275</c>: <c>uint Version, uint Flags (0=private,
/// 1=open), ObjectId MonarchId, PHashTable&lt;ObjectId,uint&gt; Permissions</c>.
/// </summary>
/// <param name="OpenToPublic">Wire <c>Flags != 0</c> — retail's <c>_bitmask
/// &amp; 1</c>. When true, every mover is admitted regardless of the guest
/// list.</param>
/// <param name="AllegianceMonarchId">Wire <c>MonarchId</c> — retail
/// <c>_monarch_iid</c>. A mover whose own monarch matches this id is admitted
/// (allegiance-wide access), independent of the guest list.</param>
/// <param name="Guests">Wire <c>Permissions</c> — guid to permission value
/// (0 = dwelling access only, 1 = storage access also). Retail's
/// <c>IsAllowedIn</c> only consults key membership for entry; the permission
/// value is preserved for wire fidelity but not consulted here.</param>
public sealed record HouseRestrictionRecord(
bool OpenToPublic,
uint AllegianceMonarchId,
IReadOnlyDictionary<uint, uint> Guests)
{
/// <summary>
/// Verbatim port of retail <c>RestrictionDB::IsAllowedIn</c>
/// (named-retail pc:444493-444516, 0x005ae8f0):
/// <c>if ((_bitmask &amp; 1) == 0) { if (_monarch_iid == 0 || arg3 !=
/// _monarch_iid) { if (arg2 == 0) return 0; if (!table.find(arg2)) return
/// 0; } } return 1;</c> — open bit set, OR mover shares the house's
/// allegiance monarch, OR mover's own guid is a table member.
/// </summary>
public bool IsAllowedIn(uint moverId, uint moverMonarchId)
{
if (OpenToPublic) return true;
if (AllegianceMonarchId != 0 && moverMonarchId == AllegianceMonarchId) return true;
return moverId != 0 && Guests.ContainsKey(moverId);
}
}

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Items;
namespace AcDream.Core.Physics;
@ -125,6 +126,20 @@ public sealed class PhysicsEngine
}
private PhysicsDataCache? _dataCache;
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): optional live
/// weenie-object table, consulted ONLY by
/// <see cref="Transition.FindEnvCollisions"/>'s entry-restriction gate to
/// resolve a cell's <c>RestrictionObj</c> into its owner/guest-list data
/// (retail <c>ACCWeenieObject::CanMoveInto</c>). Mirrors the
/// <see cref="DataCache"/> pattern: nullable, settable, defaults null so
/// every existing test/one-shot caller is unaffected. An unset table
/// makes a restricted cell fail CLOSED — exactly retail's own fallback
/// when the restriction weenie can't be resolved — so production MUST
/// wire this to the live table for the fix to actually admit anyone.
/// </summary>
public ClientObjectTable? Objects { get; set; }
private sealed record LandblockPhysics(
TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells,

View file

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Items;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
@ -169,22 +170,51 @@ public sealed class ObjectInfo
/// cannot bypass): retail resolves the live restriction weenie
/// (<c>CPhysicsObj::GetObjectA</c>) and asks <c>CanMoveInto</c> (house
/// owner IID + guest/ban list, <c>ACCWeenieObject::CanMoveInto</c>
/// 0x0058da40). acdream has no restriction-weenie resolution or
/// guest-list wire model yet (see the AP-71 register history for the
/// narrower unfed-input row this leaves), so it cannot correctly answer
/// <c>CanMoveInto</c> — it fails CLOSED (<see
/// cref="TransitionState.Collided"/>), exactly matching retail's own
/// fallback when the restriction object can't be resolved (pc:704-716
/// falls through to <c>COLLIDED_TS</c>).
/// 0x0058da40, named-retail pc:407982-408056). AP-129 (Campaign P Slice
/// P4 review fix, 2026-07-30) ports this verbatim via
/// <paramref name="objects"/> (retail's <c>GetObjectA</c> equivalent —
/// the house object is an ordinary <c>CreateObject</c> entity in range):
/// </para>
/// <list type="bullet">
/// <item>Unresolved restriction object (<paramref name="objects"/> is
/// null, or has no row for <paramref name="cellRestrictionObj"/>) —
/// fails CLOSED (<see cref="TransitionState.Collided"/>), exactly
/// retail's own fallback when <c>rObj == 0</c> (pc:704-716 falls
/// through to <c>COLLIDED_TS</c>).</item>
/// <item>Resolved, <c>HouseOwnerId</c> zero or equal to the mover's own
/// id — OK (owner always in, or no owner set = open).</item>
/// <item>Resolved, no <c>Restrictions</c> (retail <c>_db == 0</c>) — OK
/// (an un-received or never-authored guest list is open, same as a
/// retail client that hasn't been sent restrictions yet).</item>
/// <item>Resolved with a <c>Restrictions</c> list — delegates to
/// <see cref="HouseRestrictionRecord.IsAllowedIn"/> (retail
/// <c>RestrictionDB::IsAllowedIn</c> 0x005ae8f0): open-to-public,
/// OR mover shares the house's allegiance monarch, OR the mover's
/// own guid is a guest-table member.</item>
/// </list>
/// </summary>
public TransitionState CheckEntryRestrictions(uint cellRestrictionObj)
public TransitionState CheckEntryRestrictions(
uint cellRestrictionObj,
ClientObjectTable? objects)
{
if (!IsPlayer) return TransitionState.OK; // NPCs/props bypass entirely
if (CanBypassMoveRestrictions) return TransitionState.OK;
if (cellRestrictionObj == 0) return TransitionState.OK; // ordinary cell — no-op
return TransitionState.Collided;
ClientObject? restrictionObject = objects?.Get(cellRestrictionObj);
if (restrictionObject is null) return TransitionState.Collided; // retail: rObj==0 -> COLLIDED
uint moverId = SelfEntityId;
uint houseOwnerId = restrictionObject.HouseOwnerId ?? 0;
if (houseOwnerId == 0 || houseOwnerId == moverId) return TransitionState.OK;
HouseRestrictionRecord? restrictions = restrictionObject.Restrictions;
if (restrictions is null) return TransitionState.OK; // retail: _db==0 -> open
uint moverMonarchId = objects?.Get(moverId)?.MonarchId ?? 0;
return restrictions.IsAllowedIn(moverId, moverMonarchId)
? TransitionState.OK
: TransitionState.Collided;
}
/// <summary>
@ -3033,8 +3063,12 @@ public sealed class Transition
// find_env_collisions (pc:309576) calls check_entry_restrictions
// as its FIRST statement — before any HasPhysics/BSP dispatch.
// cellPhysics is null-safe: an uncached/BSP-less cell has no
// authored restriction data either.
var restrictionState = ObjectInfo.CheckEntryRestrictions(cellPhysics?.RestrictionObj ?? 0);
// authored restriction data either. AP-129 (review fix): pass
// engine.Objects so the gate can resolve CanMoveInto instead of
// failing closed on every restricted cell (see PhysicsEngine.Objects).
var restrictionState = ObjectInfo.CheckEntryRestrictions(
cellPhysics?.RestrictionObj ?? 0,
engine.Objects);
if (restrictionState != TransitionState.OK)
{
if ((ObjectInfo.State & ObjectInfoState.Contact) == 0)

View file

@ -100,6 +100,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
Physics = new RuntimePhysicsState(Entities, timeProvider: timeProvider);
Objects = new ClientObjectTable();
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): the physics
// entry-restriction gate (ObjectInfo.CheckEntryRestrictions) resolves
// a restricted cell's owner/guest list through the SAME live
// ClientObjectTable every other subsystem borrows from this owner —
// never a second table. Without this, every restricted cell fails
// closed for everyone (see PhysicsEngine.Objects).
Physics.Engine.Objects = Objects;
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
InventoryView = views.Inventory;
@ -118,6 +125,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
physicsDataCache,
timeProvider);
Objects = new ClientObjectTable();
Physics.Engine.Objects = Objects;
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
InventoryView = views.Inventory;
@ -136,6 +144,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
physicsEngine,
timeProvider);
Objects = new ClientObjectTable();
Physics.Engine.Objects = Objects;
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
InventoryView = views.Inventory;