From e6a87679b7c075ff6dcd64d8929c86ba967fbc4d Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 22:26:45 +0200 Subject: [PATCH] fix(render): read TransparentPartHook opacity by the real part ordinal, not 0 The user reported crystal shards hovering in the air above every Bind Stone on Coldeve (setup 0x020010AC) that do not exist in the retail client. The DAT truth, extracted with the new tools/SetupInspect probe: the model authors SEVEN parts - pedestal, spinning column, inner crystal, and four shard meshes parked in a static ring at Z=3.0 in the placement frame and every frame of the idle cycle - and frame 0 of that idle cycle fires four TransparentPartHooks (parts 3-6, start=end=1.0) each loop. Retail hides the shards through those hooks; the model simply ships with permanently-hooked-invisible parts. acdream's hook chain was intact end to end - the static-animating workset captures the hooks (RetailStaticAnimatingObjectScheduler -> AnimationHookFrameQueue -> TranslucencyHookSink), and TranslucencyFadeManager committed translucency 1.0 for parts 3-6 - but BOTH dispatchers' bare-GfxObj branch read the fade with a hard-coded part index 0 under a false #188-era assumption ("a bare GfxObj entity has exactly one part"). Every live server object is a FLATTENED multi-part entity in exactly that branch: SetupMesh.Flatten emits one bare-GfxObj MeshRef per Setup.Parts[i], order preserved, AnimPartChanges replacing in place - so the MeshRef ordinal IS the retail CPartArray ordinal TransparentPartHook.PartIndex addresses. The committed invisibility for parts 3-6 was never consulted and the shards drew forever. Proof the ordinal was trustworthy all along: click-selection in the same loops already publishes it as the part identity (Slice 4 picking runs on it in production). Fix: both the legacy classifier and the packed oracle now pass the per-part ordinal (partIdx / packedPart.PartIndex) to the translucency lookup. Single-part objects still read index 0; the #188 door fades are unchanged; the Setup-expanded branch already indexed correctly. Any other object hiding authored parts via idle-loop hooks gets its retail appearance from the same change. tools/SetupInspect is the new reusable DAT probe that cracked this: dumps a Setup's parts, parent indices, GfxObj vertex bounds, placement frames, motion-table default cycle, sampled animation frames, and all animation hooks. Closes task #32's code side; the connected visual gate (shards gone at the Bind Stone, base crystals and spin retained) is the acceptance. App Release suite 3,968 / 3 skips. Co-Authored-By: Claude Fable 5 --- .../Wb/WbDrawDispatcher.PackedOracle.cs | 9 +- .../Rendering/Wb/WbDrawDispatcher.cs | 15 +- tools/SetupInspect/Program.cs | 226 ++++++++++++++++++ tools/SetupInspect/SetupInspect.csproj | 15 ++ 4 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 tools/SetupInspect/Program.cs create mode 100644 tools/SetupInspect/SetupInspect.csproj diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs index 985a8183..6b4a4868 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs @@ -499,9 +499,16 @@ public sealed unsafe partial class WbDrawDispatcher } else { + // #188/#32: the packed part ordinal IS the retail CPartArray + // part ordinal TransparentPartHook.PartIndex addresses (one + // bare-GfxObj MeshRef per Setup.Parts[i] for flattened live + // entities; trivially 0 for single-part objects). The previous + // constant 0 mirrored the legacy dispatcher's false + // one-part assumption and kept the Bind Stone's four + // hook-hidden shard parts visible. float opacity = PackedPartOpacity( entity.LocalEntityId, - 0u); + (uint)partIndex); if (opacity < 1f) reusableAcrossFrames = false; if (opacity <= 0f) diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index e6d1d6e2..7cacabdd 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -1899,12 +1899,19 @@ public sealed partial class WbDrawDispatcher : IDisposable } else { - // #188: a bare (non-Setup) GfxObj entity has exactly one part — - // retail's CPartArray for such an object is a single-entry array, - // so TransparentPartHook.PartIndex for it is always 0. + // #188/#32: this MeshRef's ordinal IS the retail CPartArray part + // ordinal TransparentPartHook.PartIndex addresses. A single-part + // object trivially reads index 0; a FLATTENED multi-part live + // entity (SetupMesh.Flatten emits one bare-GfxObj MeshRef per + // Setup.Parts[i], order preserved, AnimPartChanges replace + // in place) keeps the same equality per part. The previous + // constant 0 silently ignored per-part hooks on every flattened + // entity — the Bind Stone's idle cycle hides its four authored + // shard parts (3-6) with TransparentPartHook start=end=1.0 + // every loop, and they stayed visible. float opacityMultiplier = 1.0f; bool fullyInvisible = false; - if (_translucencyFades.TryGetCurrentValue(entity.Id, 0u, out float translucencyValue)) + if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)partIdx, out float translucencyValue)) { if (translucencyValue >= 1.0f) fullyInvisible = true; else opacityMultiplier = 1f - translucencyValue; diff --git a/tools/SetupInspect/Program.cs b/tools/SetupInspect/Program.cs new file mode 100644 index 00000000..c08c1225 --- /dev/null +++ b/tools/SetupInspect/Program.cs @@ -0,0 +1,226 @@ +// SetupInspect — dump a Setup's part/pose truth for placement bugs +// (built for the Bind stone floating-crystal investigation, task #32). +// +// dotnet run --project tools\SetupInspect -- 0x02000xyz +// +// Prints: parts + gfx ids, parent indices, default scales, every placement +// frame's per-part origin/orientation, DefaultAnimation id, and — when set — +// the animation's frame count plus per-part origins for the first, middle, +// and last frames. Comparing placement-frame origins against animation-frame +// origins tells you which pose the client is actually showing. +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using SysEnv = System.Environment; + +if (args.Length < 1) +{ + Console.Error.WriteLine("usage: SetupInspect [animId hex override]"); + return 2; +} + +static uint ParseHex(string s) + => uint.Parse(s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? s[2..] : s, + NumberStyles.HexNumber, CultureInfo.InvariantCulture); + +uint setupId = ParseHex(args[0]); + +string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + +Console.WriteLine($"datDir = {datDir}"); +using var dats = new DatCollection(datDir, DatAccessType.Read); + +if (!dats.TryGet(setupId, out var setup) || setup is null) +{ + Console.Error.WriteLine($"ERROR: Setup 0x{setupId:X8} not found"); + return 1; +} + +Console.WriteLine($"=== Setup 0x{setupId:X8} ==="); +Console.WriteLine($"Flags = 0x{(uint)setup.Flags:X8}"); +Console.WriteLine($"Parts = {setup.Parts.Count}"); +for (int i = 0; i < setup.Parts.Count; i++) +{ + string parent = i < setup.ParentIndex.Count + ? setup.ParentIndex[i].ToString(CultureInfo.InvariantCulture) + : "-"; + string scale = i < setup.DefaultScale.Count + ? $"({setup.DefaultScale[i].X:F3},{setup.DefaultScale[i].Y:F3},{setup.DefaultScale[i].Z:F3})" + : "-"; + Console.WriteLine($" part[{i}] gfx=0x{(uint)setup.Parts[i]:X8} parent={parent} scale={scale}"); +} + +Console.WriteLine("Part GfxObj vertex bounds (object-local before frames):"); +foreach (uint gfxId in setup.Parts.Select(p => (uint)p).Distinct()) +{ + if (!dats.TryGet(gfxId, out var gfx) || gfx is null) + { + Console.WriteLine($" gfx=0x{gfxId:X8} NOT FOUND"); + continue; + } + float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue; + float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue; + int count = 0; + foreach (var vert in gfx.VertexArray.Vertices.Values) + { + minX = Math.Min(minX, vert.Origin.X); maxX = Math.Max(maxX, vert.Origin.X); + minY = Math.Min(minY, vert.Origin.Y); maxY = Math.Max(maxY, vert.Origin.Y); + minZ = Math.Min(minZ, vert.Origin.Z); maxZ = Math.Max(maxZ, vert.Origin.Z); + count++; + } + Console.WriteLine( + $" gfx=0x{gfxId:X8} verts={count} " + + $"x[{minX:F2},{maxX:F2}] y[{minY:F2},{maxY:F2}] z[{minZ:F2},{maxZ:F2}] " + + $"sortCenter=({gfx.SortCenter.X:F2},{gfx.SortCenter.Y:F2},{gfx.SortCenter.Z:F2})"); +} + +Console.WriteLine($"DefaultAnimation = 0x{(uint)setup.DefaultAnimation:X8}"); +Console.WriteLine($"DefaultScript = 0x{(uint)setup.DefaultScript:X8}"); +Console.WriteLine($"DefaultMotionTable = 0x{(uint)setup.DefaultMotionTable:X8}"); +static void DumpHooks(AnimationFrame frame, string indent) +{ + if (frame.Hooks.Count == 0) return; + foreach (AnimationHook hook in frame.Hooks) + { + string detail = hook switch + { + TransparentPartHook tp => + $"part={tp.PartIndex} start={tp.Start:F3} end={tp.End:F3} time={tp.Time:F3}", + TransparentHook t => $"start={t.Start:F3} end={t.End:F3} time={t.Time:F3}", + ScaleHook s => $"end={s.End:F3} time={s.Time:F3}", + _ => "", + }; + Console.WriteLine($"{indent}HOOK {hook.GetType().Name} type={hook.HookType} dir={hook.Direction} {detail}"); + } +} + +Console.WriteLine($"PlacementFrames = {setup.PlacementFrames.Count}"); +foreach (var kvp in setup.PlacementFrames) +{ + Console.WriteLine($" [placement {kvp.Key} (0x{(uint)kvp.Key:X})] frames={kvp.Value.Frames.Count} hooks={kvp.Value.Hooks.Count}"); + for (int i = 0; i < kvp.Value.Frames.Count; i++) + { + Frame f = kvp.Value.Frames[i]; + Console.WriteLine( + $" part[{i}] origin=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " + + $"quat=({f.Orientation.W:F3},{f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3})"); + } + DumpHooks(kvp.Value, " "); +} + +void DumpAnimation(uint id, string label) +{ + if (!dats.TryGet(id, out var anim) || anim is null) + { + Console.Error.WriteLine($"ERROR: Animation 0x{id:X8} not found"); + return; + } + + Console.WriteLine(); + Console.WriteLine($"=== Animation 0x{id:X8} ({label}) ==="); + Console.WriteLine($"NumParts = {anim.NumParts}"); + Console.WriteLine($"PartFrames = {anim.PartFrames.Count}"); + if (SysEnv.GetEnvironmentVariable("SETUPINSPECT_TRACK_PART") is { Length: > 0 } trackRaw + && int.TryParse(trackRaw, out int trackPart)) + { + Console.WriteLine($"-- part[{trackPart}] across ALL frames --"); + for (int frameIdx = 0; frameIdx < anim.PartFrames.Count; frameIdx++) + { + var frames = anim.PartFrames[frameIdx].Frames; + if (trackPart >= frames.Count) continue; + Frame f = frames[trackPart]; + Console.WriteLine( + $" f[{frameIdx:D2}] origin=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " + + $"quat=({f.Orientation.W:F3},{f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3})"); + } + } + int[] sample = new[] { 0, anim.PartFrames.Count / 2, anim.PartFrames.Count - 1 } + .Distinct().Where(i => i >= 0 && i < anim.PartFrames.Count).ToArray(); + foreach (int frameIdx in sample) + { + AnimationFrame frame = anim.PartFrames[frameIdx]; + Console.WriteLine($" frame[{frameIdx}] parts={frame.Frames.Count} hooks={frame.Hooks.Count}"); + for (int i = 0; i < frame.Frames.Count; i++) + { + Frame f = frame.Frames[i]; + Console.WriteLine( + $" part[{i}] origin=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " + + $"quat=({f.Orientation.W:F3},{f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3})"); + } + DumpHooks(frame, " "); + } + + Console.WriteLine("-- hooks on ALL frames --"); + for (int frameIdx = 0; frameIdx < anim.PartFrames.Count; frameIdx++) + { + AnimationFrame frame = anim.PartFrames[frameIdx]; + if (frame.Hooks.Count == 0) continue; + Console.WriteLine($" frame[{frameIdx}]:"); + DumpHooks(frame, " "); + } +} + +if ((uint)setup.DefaultAnimation != 0 || args.Length > 1) +{ + uint animId = args.Length > 1 ? ParseHex(args[1]) : (uint)setup.DefaultAnimation; + DumpAnimation(animId, "DefaultAnimation/override"); +} + +// Walk the motion table the way MotionResolver.ResolveIdleCycleInternal does: +// DefaultStyle -> StyleDefaults[DefaultStyle] -> Cycles[(style << 16) | substate] +// -> Anims[0] -> Animation. +if ((uint)setup.DefaultMotionTable != 0) +{ + uint mtId = (uint)setup.DefaultMotionTable; + if (!dats.TryGet(mtId, out var mtable) || mtable is null) + { + Console.Error.WriteLine($"ERROR: MotionTable 0x{mtId:X8} not found"); + return 1; + } + + Console.WriteLine(); + Console.WriteLine($"=== MotionTable 0x{mtId:X8} ==="); + Console.WriteLine($"DefaultStyle = 0x{(uint)mtable.DefaultStyle:X} ({mtable.DefaultStyle})"); + Console.WriteLine($"StyleDefaults = {mtable.StyleDefaults.Count}"); + foreach (var kvp in mtable.StyleDefaults) + Console.WriteLine($" style 0x{(uint)kvp.Key:X} ({kvp.Key}) -> substate 0x{(uint)kvp.Value:X} ({kvp.Value})"); + Console.WriteLine($"Cycles = {mtable.Cycles.Count}"); + foreach (var kvp in mtable.Cycles) + { + var anims = kvp.Value.Anims; + string animsDesc = string.Join(", ", anims.Select(a => + $"anim=0x{(uint)a.AnimId:X8} lo={a.LowFrame} hi={a.HighFrame} fps={a.Framerate:F2}")); + Console.WriteLine($" cycle key 0x{(uint)kvp.Key:X8}: [{animsDesc}]"); + } + + if (mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var defSub)) + { + int cycleKey = (int)(((uint)mtable.DefaultStyle << 16) | ((uint)defSub & 0xFFFFFF)); + Console.WriteLine($"Default cycle key = 0x{(uint)cycleKey:X8}"); + if (mtable.Cycles.TryGetValue(cycleKey, out var motionData) + && motionData is not null + && motionData.Anims.Count > 0) + { + DumpAnimation((uint)motionData.Anims[0].AnimId, "default idle cycle"); + } + else + { + Console.WriteLine("Default cycle NOT FOUND in Cycles — MotionResolver returns null here " + + "and the client falls back to PlacementFrames."); + } + } + else + { + Console.WriteLine("DefaultStyle has no StyleDefaults entry — MotionResolver returns null here " + + "and the client falls back to PlacementFrames."); + } +} + +return 0; diff --git a/tools/SetupInspect/SetupInspect.csproj b/tools/SetupInspect/SetupInspect.csproj new file mode 100644 index 00000000..396c926c --- /dev/null +++ b/tools/SetupInspect/SetupInspect.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + SetupInspect + + + + + + +