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 + + + + + + +