acdream/src/AcDream.Core.Net/ObjectTableWiring.cs
Erik 7a0f836af5 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>
2026-07-30 11:36:11 +02:00

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);
}