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:
parent
dc0468cc2b
commit
7a0f836af5
15 changed files with 704 additions and 81 deletions
|
|
@ -27,6 +27,13 @@ public sealed class GameEventWiringTests
|
|||
return result;
|
||||
}
|
||||
|
||||
private static void AppendU32(List<byte> payload, uint value)
|
||||
{
|
||||
byte[] u32 = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(u32, value);
|
||||
payload.AddRange(u32);
|
||||
}
|
||||
|
||||
private static byte[] WrapEnvelope(GameEventType type, byte[] payload)
|
||||
{
|
||||
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
||||
|
|
@ -222,6 +229,65 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(new[] { 0x1500u }, wieldConfirmed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): 0x0248
|
||||
/// House_UpdateRestrictions refreshes an already-known house object's
|
||||
/// guest/ban list. Wire shape verified against Chorizite
|
||||
/// House_UpdateRestrictions.generated.cs: byte Sequence, uint SenderId,
|
||||
/// then RestrictionDB (Version u32, Flags u32, MonarchId u32, PHashTable
|
||||
/// packed-u32-count + count × (guid u32 + value u32)).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WireAll_HouseUpdateRestrictions_UpdatesClientObjectTable()
|
||||
{
|
||||
var (d, items, _, _, _) = MakeAll();
|
||||
const uint houseGuid = 0x70200099u;
|
||||
items.AddOrUpdate(new ClientObject { ObjectId = houseGuid, WeenieClassId = 1 });
|
||||
|
||||
var payload = new List<byte>();
|
||||
payload.Add(5); // Sequence
|
||||
AppendU32(payload, houseGuid); // SenderId
|
||||
AppendU32(payload, 0x10000002u); // Version
|
||||
AppendU32(payload, 0u); // Flags (private)
|
||||
AppendU32(payload, 0x50000500u); // MonarchId
|
||||
AppendU32(payload, 1u); // PHashTable packed size (count=1)
|
||||
AppendU32(payload, 0x50000001u); // guest guid
|
||||
AppendU32(payload, 1u); // guest permission (storage)
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.HouseUpdateRestrictions, payload.ToArray()));
|
||||
d.Dispatch(env!.Value);
|
||||
|
||||
var house = items.Get(houseGuid);
|
||||
Assert.NotNull(house);
|
||||
Assert.NotNull(house!.Restrictions);
|
||||
Assert.False(house.Restrictions!.OpenToPublic);
|
||||
Assert.Equal(0x50000500u, house.Restrictions.AllegianceMonarchId);
|
||||
Assert.Single(house.Restrictions.Guests);
|
||||
Assert.Equal(1u, house.Restrictions.Guests[0x50000001u]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_HouseUpdateRestrictions_UnknownHouseObject_NoOps()
|
||||
{
|
||||
// Retail's own client would only receive this for an object it already
|
||||
// knows about; a stub half-formed ClientObject must not be invented.
|
||||
var (d, items, _, _, _) = MakeAll();
|
||||
const uint unknownHouseGuid = 0x70200100u;
|
||||
|
||||
var payload = new List<byte>();
|
||||
payload.Add(1);
|
||||
AppendU32(payload, unknownHouseGuid);
|
||||
AppendU32(payload, 0x10000002u);
|
||||
AppendU32(payload, 1u); // open
|
||||
AppendU32(payload, 0u);
|
||||
AppendU32(payload, 0u); // zero guests
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.HouseUpdateRestrictions, payload.ToArray()));
|
||||
d.Dispatch(env!.Value);
|
||||
|
||||
Assert.Null(items.Get(unknownHouseGuid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_ConfirmationRequest_UsesRetailTwoIntegerHeader()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -431,15 +431,22 @@ public sealed class CreateObjectTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_HouseRestrictionsSkipped_ThenIconOverlayCaptured()
|
||||
public void TryParse_HouseRestrictionsCaptured_ThenIconOverlayCaptured()
|
||||
{
|
||||
// Verifies that the variable-length RestrictionDB skip (weenieFlags bit
|
||||
// 0x04000000) lands the cursor at the correct position so that
|
||||
// IconOverlay (bit 0x40000000) immediately after it is still captured.
|
||||
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): HouseRestrictions
|
||||
// (weenieFlags bit 0x04000000) is now CAPTURED, not skipped. Verifies both
|
||||
// that the RestrictionDB parses correctly (zero-entry PHashTable) AND that
|
||||
// the cursor lands at the correct position so IconOverlay (bit 0x40000000)
|
||||
// immediately after it is still captured.
|
||||
//
|
||||
// Wire layout per ACE RestrictionDB (16 bytes, zero entries):
|
||||
// Version(u32) + OpenStatus(u32) + MonarchId(u32) = 12 bytes
|
||||
// count(u16) + numBuckets(u16) = 4 bytes
|
||||
// Wire layout per Chorizite RestrictionDB.generated.cs (verified against
|
||||
// protocol.xml:6270-6275; zero entries):
|
||||
// Version(u32) + Flags(u32) + MonarchId(u32) = 12 bytes
|
||||
// PHashTable header: ONE packed u32 whose low 24 bits are the entry
|
||||
// count (NOT a separate count(u16)+numBuckets(u16) pair — the fixture
|
||||
// below writes count=0/numBuckets=768 as two u16s, which happens to
|
||||
// decode identically under the packed-u32 mask since 768 (0x0300)
|
||||
// lands entirely in the discarded high byte) = 4 bytes
|
||||
// entries: count(0) × 8 = 0 bytes
|
||||
// total = 16 bytes
|
||||
//
|
||||
|
|
@ -463,10 +470,65 @@ public sealed class CreateObjectTests
|
|||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x06003333u, parsed!.Value.IconOverlayId);
|
||||
Assert.NotNull(parsed!.Value.Restrictions);
|
||||
Assert.False(parsed.Value.Restrictions!.OpenToPublic);
|
||||
Assert.Equal(0u, parsed.Value.Restrictions.AllegianceMonarchId);
|
||||
Assert.Empty(parsed.Value.Restrictions.Guests);
|
||||
Assert.Equal(0x06003333u, parsed.Value.IconOverlayId);
|
||||
Assert.Equal(0x06004444u, parsed.Value.IconUnderlayId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_HouseRestrictionsWithGuests_CapturesGuestTable()
|
||||
{
|
||||
// AP-129: a non-empty guest table (2 entries) parses correctly and the
|
||||
// cursor still reaches HookItemTypes/Monarch/HookType/IconOverlay after it.
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x5000000Fu,
|
||||
name: "HouseCottage",
|
||||
itemType: (uint)ItemType.Portal,
|
||||
weenieFlags: 0x04000000u | 0x20000000u | 0x00000040u | 0x10000000u | 0x40000000u,
|
||||
houseRestrictionOpen: false,
|
||||
houseRestrictionMonarchId: 0x50000500u,
|
||||
houseRestrictionGuests: new Dictionary<uint, uint> { [0x50000001u] = 0u, [0x50000002u] = 1u },
|
||||
hookItemTypes: 0x1u,
|
||||
hookType: 2,
|
||||
iconOverlayId: 0x5555u);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.NotNull(parsed!.Value.Restrictions);
|
||||
Assert.False(parsed.Value.Restrictions!.OpenToPublic);
|
||||
Assert.Equal(0x50000500u, parsed.Value.Restrictions.AllegianceMonarchId);
|
||||
Assert.Equal(2, parsed.Value.Restrictions.Guests.Count);
|
||||
Assert.Equal(0u, parsed.Value.Restrictions.Guests[0x50000001u]);
|
||||
Assert.Equal(1u, parsed.Value.Restrictions.Guests[0x50000002u]);
|
||||
Assert.Equal(0x1u, parsed.Value.HookItemTypes);
|
||||
Assert.Equal((uint)2, parsed.Value.HookType);
|
||||
Assert.Equal(0x06005555u, parsed.Value.IconOverlayId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_HouseOwnerAndMonarch_Captured()
|
||||
{
|
||||
// AP-129: HouseOwner (0x02000000) and Monarch (0x00000040) are now
|
||||
// captured instead of skipped.
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x50000012u,
|
||||
name: "HouseDoor",
|
||||
itemType: (uint)ItemType.Portal,
|
||||
weenieFlags: 0x02000000u | 0x00000040u,
|
||||
houseOwnerId: 0x50000042u,
|
||||
monarchId: 0x50000777u);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x50000042u, parsed!.Value.HouseOwnerId);
|
||||
Assert.Equal(0x50000777u, parsed.Value.MonarchId);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags bit 0x80) — captured
|
||||
// instead of skipped. Drives the icon's effect-overlay recolor.
|
||||
|
|
@ -768,7 +830,13 @@ public sealed class CreateObjectTests
|
|||
uint petOwnerId = 0,
|
||||
ushort spellId = 0,
|
||||
uint hookItemTypes = 0,
|
||||
ushort hookType = 0)
|
||||
ushort hookType = 0,
|
||||
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30).
|
||||
uint? houseOwnerId = null,
|
||||
uint? monarchId = null,
|
||||
bool houseRestrictionOpen = false,
|
||||
uint houseRestrictionMonarchId = 0,
|
||||
IReadOnlyDictionary<uint, uint>? houseRestrictionGuests = null)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
WriteU32(bytes, CreateObject.Opcode);
|
||||
|
|
@ -858,20 +926,25 @@ public sealed class CreateObjectTests
|
|||
}
|
||||
if ((weenieFlags & 0x00200000u) != 0) WriteU16(bytes, burden ?? 0); // Burden u16
|
||||
if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, spellId); // Spell u16
|
||||
if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, 0); // HouseOwner u32
|
||||
// HouseRestrictions (0x04000000): not parameterized (zero entries).
|
||||
// Wire: Version(u32) + OpenStatus(u32) + MonarchId(u32) + count(u16) + numBuckets(u16) + entries.
|
||||
// Zero entries → 16 bytes total.
|
||||
if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, houseOwnerId ?? 0); // HouseOwner u32
|
||||
// HouseRestrictions (0x04000000). Wire per Chorizite RestrictionDB.generated.cs:
|
||||
// Version(u32) + Flags(u32) + MonarchId(u32) + PHashTable<uint,uint> (one packed
|
||||
// u32 whose low 24 bits are the entry count, then count × (guid u32 + value u32)).
|
||||
if ((weenieFlags & 0x04000000u) != 0)
|
||||
{
|
||||
WriteU32(bytes, 0x10000002u); // Version
|
||||
WriteU32(bytes, 0u); // OpenStatus
|
||||
WriteU32(bytes, 0u); // MonarchId
|
||||
WriteU16(bytes, 0); // count
|
||||
WriteU16(bytes, 768); // numBuckets (retail constant)
|
||||
WriteU32(bytes, houseRestrictionOpen ? 1u : 0u); // Flags
|
||||
WriteU32(bytes, houseRestrictionMonarchId); // MonarchId
|
||||
var guests = houseRestrictionGuests ?? new Dictionary<uint, uint>();
|
||||
WriteU32(bytes, (uint)guests.Count); // PHashTable packed size (low 24 bits = count)
|
||||
foreach (var kvp in guests)
|
||||
{
|
||||
WriteU32(bytes, kvp.Key);
|
||||
WriteU32(bytes, kvp.Value);
|
||||
}
|
||||
}
|
||||
if ((weenieFlags & 0x20000000u) != 0) WriteU32(bytes, hookItemTypes); // HookItemTypes u32
|
||||
if ((weenieFlags & 0x00000040u) != 0) WriteU32(bytes, 0); // Monarch u32
|
||||
if ((weenieFlags & 0x00000040u) != 0) WriteU32(bytes, monarchId ?? 0); // Monarch u32
|
||||
if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, hookType); // HookType u16
|
||||
if ((weenieFlags & 0x40000000u) != 0) WritePackedDword(bytes, iconOverlayId); // IconOverlay
|
||||
if ((weenieFlags2 & 0x00000001u) != 0) WritePackedDword(bytes, iconUnderlayId); // IconUnderlay
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue