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>
197 lines
8.5 KiB
C#
197 lines
8.5 KiB
C#
using AcDream.Core.Items;
|
|
using AcDream.Core.Player;
|
|
|
|
namespace AcDream.Core.Net;
|
|
|
|
/// <summary>
|
|
/// Wires WorldSession quality/property events into the client object table:
|
|
/// PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt
|
|
/// (0x02CD) = player int (burden), PrivateUpdatePropertyInt64 (0x02CF) = player
|
|
/// 64-bit qualities (experience), SetStackSize (0x0197) = stack count,
|
|
/// InventoryRemoveObject (0x0024) = inventory-view removal.
|
|
/// CreateObject/DeleteObject application remains exposed as pure helpers so
|
|
/// the Runtime entity/object lifetime owner can freshness-gate the canonical
|
|
/// incarnation before either graphical or retained-object projections mutate.
|
|
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
|
|
/// </summary>
|
|
public static class ObjectTableWiring
|
|
{
|
|
/// <summary>
|
|
/// Subscribe <paramref name="table"/> to quality, inventory, and property
|
|
/// updates whose freshness is independent of the physics timestamp pack.
|
|
/// </summary>
|
|
public static IDisposable Wire(
|
|
WorldSession session,
|
|
ClientObjectTable table,
|
|
Func<uint>? playerGuid = null,
|
|
LocalPlayerState? localPlayer = null,
|
|
Func<bool>? accepting = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(session);
|
|
ArgumentNullException.ThrowIfNull(table);
|
|
var subscriptions = new SubscriptionSet();
|
|
|
|
// Create/Delete/Pickup are deliberately not subscribed here. Their
|
|
// shared live-object timestamps must be accepted before either the
|
|
// retained weenie projection or the render projection mutates.
|
|
|
|
// B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just
|
|
// UiEffects — the server is the authority on object properties. UpdateIntProperty
|
|
// stores it in the bundle and still mirrors UiEffects → the typed Effects field.
|
|
Action<WorldSession.ObjectIntPropertyUpdate> objectIntUpdated = u =>
|
|
{
|
|
if (accepting?.Invoke() == false) return;
|
|
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
|
|
};
|
|
session.ObjectIntPropertyUpdated += objectIntUpdated;
|
|
subscriptions.Add(() => session.ObjectIntPropertyUpdated -= objectIntUpdated);
|
|
|
|
// B-Wire: PrivateUpdatePropertyInt (0x02CD) carries no guid — it targets the
|
|
// local player. Route it to the player object so live EncumbranceVal updates the
|
|
// burden bar. The player ClientObject is created at login by the PD UpsertProperties
|
|
// call (which precedes any live 0x02CD), so UpdateIntProperty finds it. If it somehow
|
|
// hasn't yet, this no-ops (UpdateIntProperty returns false on an unknown guid) rather
|
|
// than creating a phantom — the next PD / CreateObject seeds it.
|
|
Action<WorldSession.PlayerIntPropertyUpdate> playerIntUpdated = u =>
|
|
{
|
|
if (accepting?.Invoke() == false) return;
|
|
if (playerGuid is not null)
|
|
table.UpdateIntProperty(playerGuid(), u.Property, u.Value);
|
|
};
|
|
session.PlayerIntPropertyUpdated += playerIntUpdated;
|
|
subscriptions.Add(() => session.PlayerIntPropertyUpdated -= playerIntUpdated);
|
|
|
|
// Retail's qualities system owns one local-player value and notifies every
|
|
// registered panel. acdream currently exposes that value through two projections:
|
|
// ClientObjectTable (retained UI) and LocalPlayerState (Core consumers/fallback).
|
|
// Apply the authoritative 0x02CF update to both in this one wiring owner so they
|
|
// cannot drift after the login PlayerDescription snapshot.
|
|
Action<WorldSession.PlayerInt64PropertyUpdate> playerInt64Updated = u =>
|
|
{
|
|
if (accepting?.Invoke() == false) return;
|
|
ApplyPlayerInt64PropertyUpdate(
|
|
table, localPlayer, playerGuid?.Invoke() ?? 0u, u);
|
|
};
|
|
session.PlayerInt64PropertyUpdated += playerInt64Updated;
|
|
subscriptions.Add(() => session.PlayerInt64PropertyUpdated -= playerInt64Updated);
|
|
|
|
// B-Wire: SetStackSize (0x0197) — update the object's stack count + value.
|
|
Action<WorldSession.StackSizeUpdate> stackSizeUpdated = u =>
|
|
{
|
|
if (accepting?.Invoke() == false) return;
|
|
table.UpdateStackSize(u.Guid, u.StackSize, u.Value);
|
|
};
|
|
session.StackSizeUpdated += stackSizeUpdated;
|
|
subscriptions.Add(() => session.StackSizeUpdated -= stackSizeUpdated);
|
|
|
|
// B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory
|
|
// view; drop it from the table (retail ClientUISystem removes it from maintenance).
|
|
Action<uint> inventoryObjectRemoved = guid =>
|
|
{
|
|
if (accepting?.Invoke() == false) return;
|
|
table.Remove(guid);
|
|
};
|
|
session.InventoryObjectRemoved += inventoryObjectRemoved;
|
|
subscriptions.Add(() => session.InventoryObjectRemoved -= inventoryObjectRemoved);
|
|
|
|
return subscriptions;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies one CreateObject to the retained object projection after the
|
|
/// owning runtime accepts its physics generation. Internal for a
|
|
/// socket-free conformance test of the subscription body.
|
|
/// </summary>
|
|
public static bool ApplyEntitySpawn(
|
|
ClientObjectTable table,
|
|
WorldSession.EntitySpawn spawn,
|
|
bool replaceGeneration = false,
|
|
Func<bool>? accepting = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(table);
|
|
if (accepting?.Invoke() == false)
|
|
return false;
|
|
|
|
WeenieData data = ToWeenieData(spawn);
|
|
if (replaceGeneration)
|
|
{
|
|
return table.ReplaceGeneration(
|
|
data,
|
|
spawn.InstanceSequence,
|
|
accepting) is not null;
|
|
}
|
|
else
|
|
table.Ingest(data);
|
|
|
|
// Ingest publishes ObjectAdded/ObjectUpdated synchronously. A nested
|
|
// replacement wins; report the invalidated outer application so its
|
|
// remaining CreateObject tail cannot run.
|
|
return accepting?.Invoke() != false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a true DeleteObject only after the owning runtime accepts the
|
|
/// exact object incarnation. Pickup still removes only the 3-D projection.
|
|
/// </summary>
|
|
public static void ApplyEntityDelete(
|
|
ClientObjectTable table,
|
|
Messages.DeleteObject.Parsed delete)
|
|
{
|
|
table.RemoveLogicalGeneration(delete.Guid, delete.InstanceSequence);
|
|
}
|
|
|
|
/// <summary>Shared application step kept internal for byte-to-state conformance tests.</summary>
|
|
internal static void ApplyPlayerInt64PropertyUpdate(
|
|
ClientObjectTable table,
|
|
LocalPlayerState? localPlayer,
|
|
uint playerGuid,
|
|
WorldSession.PlayerInt64PropertyUpdate update)
|
|
{
|
|
if (playerGuid != 0u)
|
|
table.UpdateInt64Property(playerGuid, update.Property, update.Value);
|
|
localPlayer?.OnInt64PropertyUpdate(update.Property, update.Value);
|
|
}
|
|
|
|
/// <summary>Translate the wire spawn into the table's merge patch.</summary>
|
|
public static WeenieData ToWeenieData(WorldSession.EntitySpawn s) => new(
|
|
Guid: s.Guid,
|
|
Name: s.Name,
|
|
Type: s.ItemType is { } it ? (ItemType)it : (ItemType?)null,
|
|
WeenieClassId: s.WeenieClassId,
|
|
IconId: s.IconId,
|
|
IconOverlayId: s.IconOverlayId,
|
|
IconUnderlayId: s.IconUnderlayId,
|
|
Effects: s.UiEffects,
|
|
Value: s.Value,
|
|
StackSize: s.StackSize,
|
|
StackSizeMax: s.StackSizeMax,
|
|
Burden: s.Burden,
|
|
ContainerId: s.ContainerId,
|
|
WielderId: s.WielderId,
|
|
ValidLocations: s.ValidLocations,
|
|
CurrentWieldedLocation: s.CurrentWieldedLocation,
|
|
Priority: s.Priority,
|
|
ItemsCapacity: s.ItemsCapacity,
|
|
ContainersCapacity: s.ContainersCapacity,
|
|
HookItemTypes: s.HookItemTypes,
|
|
HookType: s.HookType,
|
|
Structure: s.Structure,
|
|
MaxStructure: s.MaxStructure,
|
|
Workmanship: s.Workmanship,
|
|
Useability: s.Useability,
|
|
TargetType: s.TargetType,
|
|
RadarBlipColor: s.RadarBlipColor,
|
|
RadarBehavior: s.RadarBehavior,
|
|
PublicWeenieBitfield: s.ObjectDescriptionFlags,
|
|
CombatUse: s.CombatUse,
|
|
PluralName: s.PluralName,
|
|
PetOwnerId: s.PetOwnerId,
|
|
AmmoType: s.AmmoType,
|
|
SpellId: s.SpellId,
|
|
CooldownId: s.CooldownId,
|
|
CooldownDuration: s.CooldownDuration,
|
|
MaterialType: s.MaterialType,
|
|
HouseOwnerId: s.HouseOwnerId,
|
|
MonarchId: s.MonarchId,
|
|
Restrictions: s.Restrictions);
|
|
}
|