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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue