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