acdream/tests/AcDream.Core.Tests/Physics/Issue188FadingDoorMotionTableInspectionTests.cs
Erik 3284dd0aed feat(#188): fading-wall + sliding-door translucency; hold open past animation settle
Lands the fading-secret-door feature and fixes the door "flip-back" that
surfaced while testing it.

#188 — fading-wall doors (e.g. "Pedestal Weak Spot") fade their wall part
out via TransparentPartHook instead of swinging:
  - TranslucencyHookSink consumes TransparentPartHook -> TranslucencyFadeManager
    (per-(entity,part) linear translucency ramp; holds at End frame).
  - WbDrawDispatcher: new per-instance alpha SSBO (binding 7); ClassifyBatches
    takes opacityMultiplier (1 - translucency, per CMaterial::SetTranslucencySimple
    0x005396f0) forcing AlphaBlend; fully-invisible parts skipped.
  - mesh_modern.vert/.frag: binding-7 InstanceAlphaBuf -> vOpacityMultiplier ->
    FragColor.a *= vOpacityMultiplier.
  - Register AP-89: the fade multiplies sampled texture alpha, not a separate
    D3D9 material alpha channel (observably identical for texture-alpha==1 surfaces).

Door flip-back fix (affected BOTH #188 fading walls AND #187 sliding doors): a
door/wall that finished opening holds a single unchanging frame, so the
uncommitted IsEntityCurrentlyMoving cache-bypass narrowing dropped it onto the
Tier-1 static cache -- which only remembers the REST pose + opacity 1.0 --
snapping it visually shut/opaque while physics stayed open. Reverted that
narrowing: every Sequencer entity stays on the per-frame path (live pose + live
fade opacity), the known-good pre-optimization behavior. The per-frame CPU cost
that narrowing chased was a Debug-build artifact -- Release is GPU-bound
(~200 fps in Sawato, measured), so the unconditional add is free where it
matters. Left a code comment barring re-introduction.

Tests: full Core suite green (2649 passed, 2 skipped). Live visual gate PASSED --
both fading-wall and sliding doors hold open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:17:50 +02:00

168 lines
7.6 KiB
C#

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;
/// <summary>
/// #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 <see cref="IAnimationHookSink"/> 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.
/// </summary>
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 | BindingFlags.DeclaredOnly))
_out.WriteLine($" [prop] {p.PropertyType.Name} {p.Name}");
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
_out.WriteLine($" [field] {f.FieldType.Name} {f.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<MotionTable>(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<uint>();
var hookTypesSeen = new SortedSet<string>();
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<Animation>(animId);
if (anim is null) { _out.WriteLine($" [{source}] anim 0x{animId:X8} NOT FOUND"); continue; }
int frameCount = 0, hookCount = 0;
int frameIdx = 0;
foreach (var frame in GetFrames(anim))
{
frameCount++;
foreach (var hook in GetHooks(frame))
{
hookCount++;
hookTypesSeen.Add(hook.GetType().Name);
// Dump every field's actual VALUE for this specific hook instance
// (not just the type) -- what alpha/duration retail authored.
var fieldDump = string.Join(" ", hook.GetType()
.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Select(f => $"{f.Name}={f.GetValue(hook)}"));
_out.WriteLine($" frame[{frameIdx}] {hook.GetType().Name}: {fieldDump}");
}
frameIdx++;
}
_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<object> 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<object> 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;
}
}