Some checks failed
Headless portability / portable-headless (ubuntu-latest) (push) Has been cancelled
Headless portability / portable-headless (windows-latest) (push) Has been cancelled
Headless portability / linux-graphical (push) Has been cancelled
Headless portability / linux-vulkan (push) Has been cancelled
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 <noreply@anthropic.com>
226 lines
9.2 KiB
C#
226 lines
9.2 KiB
C#
// 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 <setupId hex, e.g. 0x02000123> [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<Setup>(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<GfxObj>(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<Animation>(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<MotionTable>(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;
|