From e2e285b855021643539d0485ca28838d07aaf6c6 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 8 Jul 2026 20:10:45 +0200 Subject: [PATCH] =?UTF-8?q?fix(#187):=20drop=20the=20"Name=20=3D=3D=20Door?= =?UTF-8?q?"=20special-case=20=E2=80=94=20register=20any=20entity=20with?= =?UTF-8?q?=20a=20resolvable=20MotionTableId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ISSUES.md | 107 ++++++++---- src/AcDream.App/Rendering/GameWindow.cs | 53 +++--- ...188FadingDoorMotionTableInspectionTests.cs | 158 ++++++++++++++++++ 3 files changed, 271 insertions(+), 47 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/Issue188FadingDoorMotionTableInspectionTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 380dec77..a5827702 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,42 +46,93 @@ Copy this block when adding a new issue: --- -## #187 — Non-hinged doors (sliding doors, "fading wall" gates) don't play their open animation +## #187 — [DONE 2026-07-08] Non-hinged doors (sliding doors, gates) don't play their open animation -**Status:** OPEN — under investigation +**Status:** CLOSED 2026-07-08 — user-confirmed live gate: "sliding doors now work." **Severity:** MEDIUM (visual correctness; collision + interaction already work — #137) **Filed:** 2026-07-08 **Component:** render — entity animation registration (`GameWindow.cs` live-spawn dispatch) -**Description (user):** door COLLISION is confirmed fixed (#137) — all doors can be clicked -and passed through correctly. But not all door TYPES play their open animation: normal -hinged doors swing open, but sliding doors and "fading wall" style dungeon gates do not -animate — the player passes through as if the door/wall just isn't there. +**Root cause (CONFIRMED via retail decomp + a live weenie-data survey):** +`GameWindow.cs:3897` registered the reactive-motion-table rescue sequencer only when +`spawn.Name == "Door"` (an exact display-name string match). Retail's own client-side +motion dispatch chain (`ACCObjectMaint::CreateObject` → `CPhysicsObj::set_description` → +`SetMotionTableID` → `CPartArray::SetMotionTableID` 0x005186e0 → `MotionTableManager:: +PerformMovement`) is unconditionally data-driven — the only gate anywhere in that chain +is "does this object have a non-zero MotionTableId" (`if (ebx != 0)`); there is no +`CDoor` class and no name/type 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 — so the +name-string gate silently dropped every door-like object not literally named "Door". -**Suspected root cause (pre-investigation read, NOT yet decomp-verified):** -`GameWindow.cs:3897` registers the door-swing sequencer only when -`IsDoorSpawn(spawn)` (`GameWindow.cs:3128`: `spawn.Name == "Door"`, an exact -display-name string match). The branch above it (`:3818`) already handles ANY entity -generically via its resolved idle cycle (multi-frame, framerate != 0) — no name check. -Doors need the special branch only because their REST pose is a static single frame -(fails the generic gate) while their MotionTable still carries reactive On/Off cycles -ACE drives via `UpdateMotion`. That "static rest + reactive motion-table cycle" shape -is not unique to hinged doors — any sliding/lever/gate prop with the same shape would -need the identical rescue, but only literal name `"Door"` gets it. Retail's own decomp -has ZERO `CDoor`-class or door-specific dispatch (only `ObjCollisionProfile::SetDoor`, -a collision-only concept) — suggesting retail's client-side animation dispatch is -entity-type-agnostic (MotionTable-driven), and the name-string gate is an acdream-only -divergence, not a port of anything retail does. +**Fix:** `GameWindow.cs:3897` — dropped the name check; the branch's existing +`mtableId != 0` test (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. Full regression green (App 741 / Core 2631). +Live-verified: sliding doors now animate open/closed correctly. -**Open question for the investigation:** are "fading wall" gates even MotionTable/ -skeletal objects, or a structurally different mechanism (an ObjectDescription / -translucency-toggle triggered by a script, no motion involved)? Needs dat/weenie-data -survey before designing the fix — must not just add more name strings (same class of -bug, just deferred). +**Scope note:** the investigation also surfaced a SEPARATE, deeper gap — some +door-family objects (confirmed: "Pedestal Weak Spot", a fading-wall secret passage) +don't use ordinary part-transform motion at all; their open cycle is a translucency-fade ++ ethereal-toggle effect that acdream has no rendering sink for. That is NOT a +registration problem (this fix's dispatch reaches it correctly) — filed separately as +**#188**. -**Acceptance:** sliding doors + fading-wall gates play their open/close transition -matching the door's actual open/closed wire state, same as hinged doors today. Hinged -doors keep working (no regression). Suites green. +--- + +## #188 — "Fading wall" secret passages don't visually fade (missing TransparentHook/EtherealHook render sink) + +**Status:** OPEN +**Severity:** MEDIUM (a real but narrow class of dungeon secret-door objects; collision +already correct via a separate wire channel) +**Filed:** 2026-07-08 (surfaced during #187's live gate) +**Component:** render — animation hook dispatch (`IAnimationHookSink` / render-state sinks) + +**Description (user):** a "fading wall" style secret-passage door ("Pedestal Weak Spot") +can be used and passed through, but never visibly changes — no fade, no motion, nothing. +Confirmed distinct from #187 (registration/dispatch already reaches this entity +correctly). + +**Root cause (CONFIRMED via a live dat decode of the actual entity, not inference):** +loaded the real MotionTable (`0x090000F9`) for the live-identified "Pedestal Weak Spot" +(guid `0x7C95B03B`) and dumped its open cycle's animation hooks directly — the cycle +(`anim 0x03000919`, 10 frames) carries **`EtherealHook`, `TransparentPartHook`, +`SoundTableHook`** — i.e. this door type's "open" isn't skeletal motion at all, it's a +per-part translucency fade + a collision-passthrough toggle + a sound cue, fired as +keyframe hooks during the animation. + +`src/AcDream.Core/Physics/IAnimationHookSink.cs`'s own doc comment documents +`TransparentHook`/`NoDrawHook`/`ScaleHook`/`ReplaceObjectHook`/etc. as intended to route +to "GfxObjMesh / renderer state mutations on the target entity" — but only three sinks +are ever registered (`_particleSink`, `_lightingSink`, `_audioSink`; `GameWindow.cs` +~1414-1441). No sink anywhere in the codebase pattern-matches `TransparentHook` / +`TransparentPartHook` / `EtherealHook` / `NoDrawHook` / `ScaleHook` / +`ReplaceObjectHook` (verified by a full-repo grep) — the sequencer correctly parses and +fires these hooks every tick, the router fans them out, and all three registered sinks +silently ignore them. Nothing crashes (the router swallows exceptions per-sink); nothing +renders differently either. + +**Confirmed NOT a collision bug:** the object's ethereal/collision-passthrough state is +applied correctly via a SEPARATE server-authoritative wire message (`SetState` → +`OnLiveStateUpdated`, `GameWindow.cs:5517`) — independent of the animation-hook +mechanism. `EtherealHook` firing client-side during the animation is very likely a +redundant/cosmetic signal in retail's own design; the real remaining gap is purely +**`TransparentPartHook` → no visible fade**. + +**Scope (not yet designed):** implementing this touches the render pipeline (a per-part +runtime alpha under the mandatory N.5 bindless/MDI pipeline — see +`memory/reference_modern_rendering_pipeline.md` for the existing SSBO layout +constraints) — this is feature-shaped work, not a one-line fix. Needs its own design +pass (grep retail's `TransparentHook::Execute`/`SetTranslucency2`/`SetPartTranslucency` +decomp for the exact interpolation semantics) before implementation. + +**Apparatus (kept):** `tests/AcDream.Core.Tests/Physics/Issue187FadingDoorMotionTableInspectionTests.cs` +— reflects the real `DatReaderWriter` hook-type shapes + decodes a live MotionTable's +hook contents directly (no guessing). Reusable for any future "why doesn't this animate" +question — just swap the MotionTableId. + +**Acceptance:** the Pedestal Weak Spot (and similar fading-wall objects) visibly fades +out/in when triggered, matching retail. No regression to #187's fix or any other door type. --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index cadccc24..ca08901a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3119,17 +3119,18 @@ public sealed class GameWindow : IDisposable } /// - /// 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. /// 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) { diff --git a/tests/AcDream.Core.Tests/Physics/Issue188FadingDoorMotionTableInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue188FadingDoorMotionTableInspectionTests.cs new file mode 100644 index 00000000..c04e3a25 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue188FadingDoorMotionTableInspectionTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using Xunit; +using Xunit.Abstractions; +using Env = System.Environment; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #188 root-cause evidence (report-only): decode the REAL dat MotionTable +/// for the "Pedestal Weak Spot" fading-wall entity identified live +/// (guid 0x7C95B03B, MotionTableId 0x090000F9, Setup 0x02000D55). CONFIRMED: +/// its open cycle carries EtherealHook + TransparentPartHook + SoundTableHook +/// — a translucency-fade effect, not part-transform motion — and acdream has +/// no registered that consumes Transparent/ +/// NoDraw/Ethereal/ReplaceObject/Scale hooks (only Particle/Lighting/Audio are +/// wired). Kept as a reusable decoder for any future "why doesn't this +/// animate" question — swap the MotionTableId. +/// +public class Issue188FadingDoorMotionTableInspectionTests +{ + private readonly ITestOutputHelper _out; + public Issue188FadingDoorMotionTableInspectionTests(ITestOutputHelper output) => _out = output; + + private static string? DatDir() + { + var d = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); + return Directory.Exists(d) ? d : null; + } + + [Fact] + public void Reflect_DatReaderWriter_HookTypes() + { + // Dump the DatReaderWriter assembly's hook-related type shapes so the + // real data-walk test below uses correct field names (no guessing). + var asm = typeof(MotionTable).Assembly; + _out.WriteLine($"assembly: {asm.FullName}"); + + foreach (var t in asm.GetTypes().Where(t => t.Name.Contains("Hook", StringComparison.OrdinalIgnoreCase))) + { + _out.WriteLine($"=== type {t.FullName} (base={t.BaseType?.Name}) ==="); + foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + _out.WriteLine($" {p.PropertyType.Name} {p.Name}"); + } + + void DumpMembers(Type t, string label) + { + _out.WriteLine($"=== {label} ({t.FullName}) ==="); + foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + _out.WriteLine($" [prop] {p.PropertyType} {p.Name}"); + foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance)) + _out.WriteLine($" [field] {f.FieldType} {f.Name}"); + } + + DumpMembers(typeof(MotionData), "MotionData"); + DumpMembers(asm.GetTypes().First(t => t.Name == "AnimData"), "AnimData"); + DumpMembers(typeof(Animation), "Animation"); + var frameType = asm.GetTypes().FirstOrDefault(t => t.Name is "AnimationFrame" or "AnimFrame"); + if (frameType is not null) DumpMembers(frameType, frameType.Name); + var qdiType = asm.GetTypes().FirstOrDefault(t => t.Name.StartsWith("QualifiedDataId")); + if (qdiType is not null) DumpMembers(qdiType, qdiType.Name); + } + + [Fact] + public void Dump_PedestalWeakSpot_MotionTable_HookContents() + { + var datDir = DatDir(); + if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + const uint MotionTableId = 0x090000F9u; // live-captured from the Pedestal Weak Spot entity + var mtable = dats.Get(MotionTableId); + if (mtable is null) { _out.WriteLine($"MotionTable 0x{MotionTableId:X8} NOT FOUND"); return; } + + _out.WriteLine($"MotionTable 0x{MotionTableId:X8}: DefaultStyle=0x{(uint)mtable.DefaultStyle:X8}"); + + var allMotionData = new List<(string source, MotionData md)>(); + foreach (var kv in mtable.Cycles) allMotionData.Add(($"Cycles[{kv.Key:X}]", kv.Value)); + foreach (var kv in mtable.Modifiers) allMotionData.Add(($"Modifiers[{kv.Key:X}]", kv.Value)); + foreach (var linkKv in mtable.Links) + foreach (var innerKv in linkKv.Value.MotionData) + allMotionData.Add(($"Links[{linkKv.Key:X}][{innerKv.Key:X}]", innerKv.Value)); + + _out.WriteLine($"total MotionData entries: {allMotionData.Count}"); + + var animIdsSeen = new HashSet(); + var hookTypesSeen = new SortedSet(); + + foreach (var (source, md) in allMotionData) + { + if (md?.Anims is null) continue; + foreach (var animData in md.Anims) + { + uint animId = GetAnimId(animData); + if (animId == 0 || !animIdsSeen.Add(animId)) continue; + + var anim = dats.Get(animId); + if (anim is null) { _out.WriteLine($" [{source}] anim 0x{animId:X8} NOT FOUND"); continue; } + + int frameCount = 0, hookCount = 0; + foreach (var frame in GetFrames(anim)) + { + frameCount++; + foreach (var hook in GetHooks(frame)) + { + hookCount++; + hookTypesSeen.Add(hook.GetType().Name); + } + } + _out.WriteLine($" [{source}] anim 0x{animId:X8}: frames={frameCount} hooks={hookCount}"); + } + } + + _out.WriteLine($"=== DISTINCT HOOK TYPES ACROSS ENTIRE TABLE: {(hookTypesSeen.Count == 0 ? "(none)" : string.Join(", ", hookTypesSeen))} ==="); + } + + private static object? GetMember(object obj, string name) + { + var t = obj.GetType(); + var prop = t.GetProperty(name, BindingFlags.Public | BindingFlags.Instance); + if (prop is not null) return prop.GetValue(obj); + var field = t.GetField(name, BindingFlags.Public | BindingFlags.Instance); + return field?.GetValue(obj); + } + + private static uint GetAnimId(object animData) + { + var val = GetMember(animData, "AnimId") ?? GetMember(animData, "Id"); + if (val is null) return 0; + var dataId = GetMember(val, "DataId") ?? GetMember(val, "Id"); + return dataId switch { uint u => u, int i => (uint)i, ulong ul => (uint)ul, _ => 0 }; + } + + private static IEnumerable GetFrames(Animation anim) + { + var frames = (GetMember(anim, "PartFrames") ?? GetMember(anim, "Frames")) as IEnumerable; + if (frames is null) yield break; + foreach (var f in frames) + if (f is not null) yield return f; + } + + private static IEnumerable GetHooks(object frame) + { + var hooks = GetMember(frame, "Hooks") as IEnumerable; + if (hooks is null) yield break; + foreach (var h in hooks) + if (h is not null) yield return h; + } +}