Fixes #282 (plan S2). Adds register row AP-133. Retail gives a CPhysicsObj exactly ONE cell: ShouldDrawParticles @0x0050fe60 reads this->cell and calls IsInView on it, and set_cell_id @0x0050f4f0 / change_cell @0x00513390 are the only things that move it. acdream splits that into ParentCellId (render parent, deliberately null for outdoor dat stabs) and EffectCellId (the authored landcell those parentless stabs still need) - an adaptation, now recorded as AP-133. WorldEntity.EffectCellId documents itself as the stab field, with live and interior entities using ParentCellId.f24532adbegan writing it for live entities too. Because EntityEffectPoseRegistry resolved EffectCellId FIRST, that write won - and the audit shows only 3 of 14 cell writers maintain it. The other 11 do not, including the hottest paths: RemotePhysicsUpdater:239,294 and LiveEntityOrdinaryPhysicsUpdater:107 write ParentCellId every physics tick from the snapshot, and LocalPlayerProjectionController:79 writes the local player's cell every frame. So a moving entity updated its cell constantly while EffectCellId stayed frozen at whatever cell it materialized in. Its particles and lights kept being tested against that stale cell and failed IsInView the moment it crossed a boundary - effects vanishing on a monster that is plainly visible, or drawing through a wall from a room the viewer cannot see. The consumers had also drifted into disagreeing: EntityEffectPoseRegistry preferred EffectCellId while WbDrawDispatcher.TryGetEntityCell and the remote spawn seed preferred ParentCellId - two answers to "which cell is this in". - WorldEntity.VisibilityCellId (ParentCellId ?? EffectCellId) is the single accessor; all five consumer sites resolve through it, so the precedence cannot drift apart again. - LiveEntityRuntime's three live-entity EffectCellId writes are removed, restoring the field to its documented purpose. Its real writers - LandblockLoader:80,97 and LandblockBuildFactory:408 - are untouched, and the parentless-stab path is pinned by a new test. - f24532ad's actual fix is preserved: RebucketLiveEntity still installs the committed cell, just on the one field live entities use. LiveEntityLightControllerTests.Refresh_FollowsCurrentTopLevelRootAndCell is back to moving the entity by ParentCellId alone - its original pre-f24532ad form - and passes. CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell had its two EffectCellId assertions (added byf24532ad, encoding the defect) replaced with the corrected contract: ParentCellId set, EffectCellId null, VisibilityCellId resolving - a stronger assertion, not a relaxed one. Complete Release solution: 10,836 passed / 4 skipped / 0 failed. User visual check still outstanding: a monster with an active spell effect crossing a cell boundary, and a lit static object, indoors and outdoors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
14 KiB
C#
312 lines
14 KiB
C#
using System.Numerics;
|
||
|
||
namespace AcDream.Core.World;
|
||
|
||
public sealed class WorldEntity
|
||
{
|
||
private PaletteOverride? _paletteOverride;
|
||
private IReadOnlyList<PartOverride> _partOverrides = Array.Empty<PartOverride>();
|
||
|
||
public required uint Id { get; init; }
|
||
/// <summary>
|
||
/// Server-assigned GUID (from CreateObject). Zero for dat-hydrated
|
||
/// scenery/static entities that don't come from the server.
|
||
/// Used by GpuWorldState for persistent-entity rescue on landblock unload.
|
||
/// </summary>
|
||
public uint ServerGuid { get; init; }
|
||
public required uint SourceGfxObjOrSetupId { get; init; }
|
||
/// <summary>
|
||
/// World-space position. Settable so Phase 6.7 position-update events
|
||
/// can reseat an existing entity without rebuilding its meshes.
|
||
/// </summary>
|
||
public required Vector3 Position { get; set; }
|
||
/// <summary>Settable for the same reason as <see cref="Position"/>.</summary>
|
||
public required Quaternion Rotation { get; set; }
|
||
/// <summary>
|
||
/// Per-part mesh references with their root-relative transforms.
|
||
/// Mutable so the animation tick can replace it each frame for
|
||
/// entities that play a cycle (Phase 6.4); static entities set it
|
||
/// once at hydration and never touch it again.
|
||
/// </summary>
|
||
public required IReadOnlyList<MeshRef> MeshRefs { get; set; }
|
||
|
||
/// <summary>
|
||
/// Whether the root PartArray participates in mesh drawing. Live-object
|
||
/// PhysicsState NoDraw/Hidden transitions change this without destroying
|
||
/// the entity or its scripts, particles, lights, cell identity, or cached
|
||
/// mesh resources. Dat/static entities remain visible by default.
|
||
/// </summary>
|
||
public bool IsDrawVisible { get; set; } = true;
|
||
|
||
/// <summary>
|
||
/// Retained-tree visibility inherited from attached ancestors. Retail
|
||
/// draws child physics objects through their parent hierarchy; acdream
|
||
/// flattens them into independent draw entries, so this separate bit keeps
|
||
/// ancestor suppression out of the child's own PhysicsState.
|
||
/// </summary>
|
||
public bool IsAncestorDrawVisible { get; set; } = true;
|
||
|
||
/// <summary>
|
||
/// Stable Setup-part-indexed poses used by attachment and effect hooks.
|
||
/// Unlike <see cref="MeshRefs"/>, this array never compacts around a DAT
|
||
/// part whose GfxObj could not be loaded. <see cref="IndexedPartAvailable"/>
|
||
/// distinguishes a real indexed part from a retained placeholder pose.
|
||
/// </summary>
|
||
public IReadOnlyList<Matrix4x4> IndexedPartTransforms { get; private set; } =
|
||
Array.Empty<Matrix4x4>();
|
||
|
||
public IReadOnlyList<bool> IndexedPartAvailable { get; private set; } =
|
||
Array.Empty<bool>();
|
||
|
||
public void SetIndexedPartPoses(
|
||
IReadOnlyList<Matrix4x4> transforms,
|
||
IReadOnlyList<bool> available)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(transforms);
|
||
ArgumentNullException.ThrowIfNull(available);
|
||
if (transforms.Count != available.Count)
|
||
throw new ArgumentException("Indexed part pose and availability counts must match.");
|
||
IndexedPartTransforms = transforms;
|
||
IndexedPartAvailable = available;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Optional per-entity palette override (server-specified base +
|
||
/// subpalette overlays). When non-null, applies to every palette-
|
||
/// indexed texture on this entity. Used for character skin/hair
|
||
/// colors, creature recolors (e.g. stone-colored drudge statue),
|
||
/// and team colors. Non-palette-indexed textures ignore this field.
|
||
/// </summary>
|
||
public PaletteOverride? PaletteOverride
|
||
{
|
||
get => _paletteOverride;
|
||
init => _paletteOverride = value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// EnvCell or outdoor cell ID that owns this entity (room geometry, static
|
||
/// object, or live object inside/outside a cell).
|
||
/// the cell). Used by portal visibility to filter interior entities — only
|
||
/// entities whose ParentCellId appears in the visible set are rendered.
|
||
/// Null for outdoor dat scenery/building stabs or unresolved live entities.
|
||
/// </summary>
|
||
public uint? ParentCellId { get; set; }
|
||
|
||
/// <summary>
|
||
/// Owning AC cell used by entity-attached effects. This is separate from
|
||
/// <see cref="ParentCellId"/> because outdoor dat stabs deliberately keep
|
||
/// a null render parent while retail still gives their physics object an
|
||
/// outdoor landcell for <c>CObjCell::IsInView</c> particle gating.
|
||
/// Live/interior entities normally use <see cref="ParentCellId"/> instead.
|
||
/// </summary>
|
||
public uint? EffectCellId { get; set; }
|
||
|
||
/// <summary>
|
||
/// #282: the ONE cell this entity occupies, as retail models it. A
|
||
/// <c>CPhysicsObj</c> has a single <c>cell</c>; <c>ShouldDrawParticles</c>
|
||
/// @0x0050fe60 reads that same field and calls <c>IsInView</c> on it, and
|
||
/// <c>set_cell_id</c> @0x0050f4f0 / <c>change_cell</c> @0x00513390 are the
|
||
/// only things that move it. Our split into
|
||
/// <see cref="ParentCellId"/> + <see cref="EffectCellId"/> is an
|
||
/// adaptation for outdoor dat stabs, which keep a null render parent yet
|
||
/// still need a landcell for particle gating.
|
||
///
|
||
/// Resolve through here, never by reading one field or re-deriving the
|
||
/// precedence at a call site. Live entities carry
|
||
/// <see cref="ParentCellId"/>, kept current by every per-tick physics and
|
||
/// network writer; stabs and building shells carry only
|
||
/// <see cref="EffectCellId"/>. Reading <see cref="EffectCellId"/> FIRST is
|
||
/// what stranded live entities' particles and lights on their
|
||
/// materialization cell, because only 3 of 14 cell writers maintain it.
|
||
/// </summary>
|
||
public uint? VisibilityCellId => ParentCellId ?? EffectCellId;
|
||
|
||
/// <summary>
|
||
/// True when this entity originates from <c>LandBlockInfo.Buildings[]</c>
|
||
/// (the dat array that carries building shells: cottage walls, smithy walls,
|
||
/// inn walls — every solid building enclosure). False for entities from
|
||
/// <c>LandBlockInfo.Objects[]</c> (rocks, fences, lampposts, tree clusters —
|
||
/// outdoor scenery placeholders). The two arrays are conflated through
|
||
/// hydration today but the dat itself carries the distinction; retail
|
||
/// (<c>CLandBlock::init_buildings</c>) and WorldBuilder
|
||
/// (<c>SceneryInstance.IsBuilding</c>) both preserve it.
|
||
///
|
||
/// <para>
|
||
/// Read at draw time by <c>WbDrawDispatcher</c>'s <c>IndoorPass</c>
|
||
/// partition so building shells can render when the camera is inside their
|
||
/// own building (they ARE the indoor walls), not stencil-gated as outdoor
|
||
/// scenery would be.
|
||
/// </para>
|
||
/// </summary>
|
||
public bool IsBuildingShell { get; init; }
|
||
|
||
/// <summary>
|
||
/// Dat-derived EnvCell anchor for a building shell. Building shells are
|
||
/// top-level landblock stabs, so they do not have a real ParentCellId, but
|
||
/// the LandBlockInfo.Buildings[] portal list names cells owned by the same
|
||
/// building. The indoor renderer uses this anchor only for draw scoping:
|
||
/// a shell renders in IndoorPass when its anchor belongs to the camera
|
||
/// building's EnvCell set. Collision still treats the shell as an outdoor
|
||
/// stab unless ParentCellId is explicitly set.
|
||
/// </summary>
|
||
public uint? BuildingShellAnchorCellId { get; init; }
|
||
|
||
/// <summary>
|
||
/// Uniform scale applied to this entity's mesh by the scenery pipeline.
|
||
/// For scenery objects this is spawn.Scale (typically 0.8–1.3). For stabs
|
||
/// and interior static objects this is 1.0 (no scaling).
|
||
///
|
||
/// Used by the collision registration path to scale CylSphere / Sphere /
|
||
/// Setup.Radius shapes so they match the visually-scaled mesh. Without
|
||
/// this, scaled scenery has a collision cylinder that's smaller than the
|
||
/// visible trunk, producing "partial passthrough" bugs.
|
||
/// </summary>
|
||
public float Scale { get; init; } = 1.0f;
|
||
|
||
/// <summary>
|
||
/// Server-sent part-swap overrides from <c>AnimPartChange</c>. Each entry
|
||
/// replaces a Setup part's GfxObj with an alternate model (clothing, weapons,
|
||
/// helmets). Carried on the entity so <c>EntitySpawnAdapter</c> can populate
|
||
/// <c>AnimatedEntityState</c>'s override map at spawn time. Empty for atlas-
|
||
/// tier entities.
|
||
/// </summary>
|
||
public IReadOnlyList<PartOverride> PartOverrides
|
||
{
|
||
get => _partOverrides;
|
||
init => _partOverrides = value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Replaces the render-only appearance carried by an ObjDesc update while
|
||
/// preserving this entity's identity, spatial state, and runtime owners.
|
||
/// Retail performs the equivalent mutation through
|
||
/// <c>CPhysicsObj::DoObjDescChangesFromDefault</c>.
|
||
/// </summary>
|
||
public void ApplyAppearance(
|
||
IReadOnlyList<MeshRef> meshRefs,
|
||
PaletteOverride? paletteOverride,
|
||
IReadOnlyList<PartOverride> partOverrides)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(meshRefs);
|
||
ArgumentNullException.ThrowIfNull(partOverrides);
|
||
|
||
MeshRefs = meshRefs;
|
||
_paletteOverride = paletteOverride;
|
||
_partOverrides = partOverrides;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Bitmask of hidden Setup parts. Bit <c>i</c> set hides part <c>i</c> at
|
||
/// draw time. Sourced from the server's <c>CreateObject</c> record when
|
||
/// present. Zero (no parts hidden) is the default.
|
||
/// </summary>
|
||
public ulong HiddenPartsMask { get; init; }
|
||
|
||
// Per Phase A.5 spec §4.6 Change #2 — cache per-entity AABB so the
|
||
// dispatcher's frustum cull is a memory read, not a per-frame recompute.
|
||
// AabbDirty starts true so the dispatcher calls RefreshAabb on first read
|
||
// (AabbMin/AabbMax are Vector3.Zero until refreshed).
|
||
public Vector3 AabbMin { get; private set; }
|
||
public Vector3 AabbMax { get; private set; }
|
||
public bool AabbDirty { get; private set; } = true;
|
||
|
||
/// <summary>
|
||
/// Root-local geometry bounds: the union over MeshRefs of each part's dat
|
||
/// vertex AABB transformed by its part transform (see
|
||
/// <c>Meshing.LocalBoundsAccumulator</c>). Set at hydration from the same
|
||
/// vertex data that gets drawn — the every-case fix for the "#119 bounds
|
||
/// must cover the mesh" class. When absent (HasLocalBounds false), the
|
||
/// part-offset heuristic below is the fallback.
|
||
/// </summary>
|
||
public Vector3 LocalBoundMin { get; private set; }
|
||
public Vector3 LocalBoundMax { get; private set; }
|
||
public bool HasLocalBounds { get; private set; }
|
||
|
||
public void SetLocalBounds(Vector3 min, Vector3 max)
|
||
{
|
||
LocalBoundMin = min;
|
||
LocalBoundMax = max;
|
||
HasLocalBounds = true;
|
||
AabbDirty = true;
|
||
}
|
||
|
||
private const float DefaultAabbRadius = 5.0f;
|
||
|
||
public void RefreshAabb()
|
||
{
|
||
var p = Position;
|
||
|
||
// #119 follow-up (2026-06-11): the box must cover the MESH, not just the
|
||
// anchor. BOTH visibility gates derive from this box: the dispatcher's
|
||
// per-entity frustum cull (WbDrawDispatcher.WalkEntitiesInto) and the
|
||
// viewcone sphere (RetailPViewRenderer.EntitySphere = this box's
|
||
// bounding sphere). The original fixed ±5 m anchor box dropped the AAB3
|
||
// tower staircase (parts spiralling 15 m above the anchor) whenever the
|
||
// gaze left the anchor's neighborhood — stairs visible looking down,
|
||
// gone looking up.
|
||
//
|
||
// Preferred path: dat-vertex-derived root-local bounds (SetLocalBounds
|
||
// at hydration), rotated into world axes — re-boxing the 8 rotated
|
||
// corners contains the rotated contents, so this is correct for EVERY
|
||
// shape including a single tall part at identity transform (which the
|
||
// offset heuristic below cannot see). DefaultAabbRadius stays as a
|
||
// margin: it absorbs animated-pose drift (MeshRefs are swapped per
|
||
// frame for animated entities while local bounds are rest-pose) and
|
||
// keeps small objects at their historical box size.
|
||
if (HasLocalBounds)
|
||
{
|
||
Vector3 lo = LocalBoundMin, hi = LocalBoundMax;
|
||
var rot = Rotation;
|
||
Vector3 min = default, max = default;
|
||
for (int c = 0; c < 8; c++)
|
||
{
|
||
var corner = new Vector3(
|
||
(c & 1) == 0 ? lo.X : hi.X,
|
||
(c & 2) == 0 ? lo.Y : hi.Y,
|
||
(c & 4) == 0 ? lo.Z : hi.Z);
|
||
var t = Vector3.Transform(corner, rot);
|
||
if (c == 0) { min = max = t; }
|
||
else { min = Vector3.Min(min, t); max = Vector3.Max(max, t); }
|
||
}
|
||
AabbMin = p + min - new Vector3(DefaultAabbRadius);
|
||
AabbMax = p + max + new Vector3(DefaultAabbRadius);
|
||
AabbDirty = false;
|
||
return;
|
||
}
|
||
|
||
// Fallback (no hydration bounds — e.g. tests, minimal fixtures): anchor
|
||
// box expanded by the largest part-translation magnitude. Rotation-
|
||
// invariant; covers multi-part spreads but NOT a single part whose mesh
|
||
// extends >5 m from its own origin — which is why hydrated entities use
|
||
// the vertex-derived path above.
|
||
float radius = DefaultAabbRadius;
|
||
var refs = MeshRefs;
|
||
if (refs is not null)
|
||
{
|
||
float maxOffset = 0f;
|
||
for (int i = 0; i < refs.Count; i++)
|
||
{
|
||
float len = refs[i].PartTransform.Translation.Length();
|
||
if (len > maxOffset) maxOffset = len;
|
||
}
|
||
radius += maxOffset;
|
||
}
|
||
|
||
AabbMin = new Vector3(p.X - radius, p.Y - radius, p.Z - radius);
|
||
AabbMax = new Vector3(p.X + radius, p.Y + radius, p.Z + radius);
|
||
AabbDirty = false;
|
||
}
|
||
|
||
public void SetPosition(Vector3 pos)
|
||
{
|
||
Position = pos;
|
||
AabbDirty = true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Lightweight value type for a server-sent <c>AnimPartChange</c> (part index
|
||
/// → replacement GfxObj id). Decouples <c>WorldEntity</c> (Core) from the
|
||
/// network-layer <c>CreateObject.AnimPartChange</c> type.
|
||
/// </summary>
|
||
public readonly record struct PartOverride(byte PartIndex, uint GfxObjId);
|