fix(#187): drop the "Name == Door" special-case — register any entity with a resolvable MotionTableId

The door-swing animation rescue (GameWindow.cs:3897, for entities whose rest pose
is a static single frame but which still carry a reactive MotionTable) was gated on
an exact display-name string match. Sliding doors, gates, portcullises, and disguised
secret-passage props ("Magic Wall") all fail that check because their in-game Name
isn't literally "Door" -- so ACE's UpdateMotion for them was silently dropped forever
by the _animatedEntities.TryGetValue bail-out in OnLiveUpdateMotion.

Retail's own dispatch chain (ACCObjectMaint::CreateObject -> CPhysicsObj::
set_description -> SetMotionTableID -> CPartArray::SetMotionTableID 0x005186e0 ->
MotionTableManager::PerformMovement) is unconditionally data-driven: the only gate
for creating a motion dispatcher anywhere in that chain is "motion table id != 0" --
no CDoor class, no WeenieType switch, no name check. Production weenie data confirms
Sliding Door / Portcullis / Gate / Magic Wall all carry the identical WeenieType=Door
+ non-zero-MotionTableId shape as a plain "Door", differing only in display name.

Fix: the branch's existing `mtableId != 0` check (already computed one line later)
is now the entire gate, matching retail exactly. IsDoorSpawn deleted (dead code);
IsDoorName kept only for an unrelated diagnostic log-label filter.

Live-verified: sliding doors now animate open/closed correctly. Full regression
green (App 741 / Core 2631).

docs(#188): file the fading-wall render gap surfaced during #187's live gate

A "Pedestal Weak Spot" secret-passage door dispatches correctly (proving #187's fix
reaches it) but never visibly changes. Decoded its actual dat MotionTable directly
(0x090000F9): its open cycle carries EtherealHook + TransparentPartHook +
SoundTableHook -- a translucency-fade effect, not part-transform motion. acdream's
IAnimationHookSink documents these hook types as intended for "GfxObjMesh / renderer
state mutations" but no sink anywhere consumes them (only Particle/Lighting/Audio are
wired) -- confirmed via full-repo grep. Collision already works correctly via a
separate server-authoritative SetState wire message, independent of the animation
hook. This is feature-shaped rendering work (a per-part runtime alpha under the
mandatory N.5 bindless pipeline), not a quick fix -- filed for its own design pass.

Kept Issue188FadingDoorMotionTableInspectionTests.cs as a reusable MotionTable/hook
decoder for future "why doesn't this animate" questions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-08 20:10:45 +02:00
parent 0efa7ed2a1
commit e2e285b855
3 changed files with 271 additions and 47 deletions

View file

@ -3119,17 +3119,18 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// Door detection by server-sent name. Doors fail the generic
/// multi-frame-idle gate at line 2692 (no idle cycle), so we register
/// them via a sibling branch with a state-seeded sequencer. Shared
/// with the [door-cycle] UM dispatch diagnostic — both sites must
/// agree on the name predicate.
/// Diagnostic-only label filter for the [door-cycle] UM dispatch trail
/// (below). #187: this is NOT the functional registration gate — that
/// was an exact-name match (`spawn.Name == "Door"`) which silently
/// dropped every door-like object whose display name isn't literally
/// "Door" (Sliding Door, Portcullis, Gate, "Magic Wall"). The real
/// registration gate is now data-driven (MotionTableId != 0, matching
/// retail's CPartArray::SetMotionTableID 0x005186e0) — see the rescue
/// branch below. Kept only to scope the debug print to door-ish
/// entities so the log doesn't fill with every reactive-motion prop.
/// </summary>
private static bool IsDoorName(string? name) => name == "Door";
private static bool IsDoorSpawn(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
=> IsDoorName(spawn.Name);
private void OnLiveEntitySpawnedLocked(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
{
_liveSpawnReceived++;
@ -3894,20 +3895,34 @@ public sealed class GameWindow : IDisposable
_entitySoundTables.Set(entity.Id, soundTableId);
}
}
else if (IsDoorSpawn(spawn) && _animLoader is not null)
else if (_animLoader is not null)
{
// Phase B.4c — Door swing animation. Doors fail the
// multi-frame-idle gate above (no idle cycle) but DO have a
// MotionTable with On/Off cycles that ACE drives via
// UpdateMotion. Register with a seeded sequencer so the
// per-frame tick has frames to advance from frame 1 (without
// the seed, Sequencer.Advance(dt) returns no frames and the
// MeshRefs rebuild at line 7691 collapses the door to origin).
// Phase B.4c / #187 — reactive motion-table rescue. An entity
// whose REST pose is a static single frame fails the generic
// multi-frame-idle gate above, but may still carry a MotionTable
// with On/Off (or other) cycles the server drives reactively via
// UpdateMotion. Hinged doors, sliding doors, gates/portcullises,
// and disguised secret-passage props ("Magic Wall") all share
// this exact shape. #187: this branch used to be gated on
// `spawn.Name == "Door"`, which silently dropped every door-like
// object whose display name isn't literally "Door" — the
// production weenie data confirms Sliding Door / Portcullis /
// Gate / Magic Wall all carry the SAME WeenieType=Door +
// non-zero MotionTableId shape as a plain Door, differing only
// in name. Retail's own gate for creating a motion dispatcher is
// purely data-driven on the motion table id
// (CPartArray::SetMotionTableID 0x005186e0: `if (ebx != 0)`) — no
// WeenieType switch, no name check — so the `mtableId != 0` test
// just below is now the ENTIRE gate, matching retail exactly.
// Register with a seeded sequencer so the per-frame tick has
// frames to advance from frame 1 (without the seed,
// Sequencer.Advance(dt) returns no frames and the MeshRefs
// rebuild at line 7691 collapses the entity to origin).
//
// Initial cycle mirrors ACE's Door.cs:43
// (CurrentMotionState = motionClosed): Off when the door is
// closed at spawn, On when the spawn PhysicsState carries the
// ETHEREAL bit (door was already open in ACE's DB).
// (CurrentMotionState = motionClosed): Off when closed at spawn,
// On when the spawn PhysicsState carries the ETHEREAL bit
// (already open in ACE's DB).
uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{