feat(vfx): decode retail hooks and typed tables
Replace the incomplete package path with one DatCollection-backed compatibility seam for PhysicsScripts and Animations. Preserve CreateBlockingParticle's inherited payload and following cursor, route every production and audit consumer through the corrected loaders, and apply retail's post-UnPack StartTime ordering. Add exact stored-order PhysicsScriptTable upper-threshold resolution, high-byte DID and embedded-ID validation, plus live effect profiles with Setup-to-PhysicsDesc precedence across top-level and attached entity lifetimes. Keep blocking execution deferred and narrow TS-11 accordingly. Pin synthetic malformed/cursor/order fixtures, installed-DAT blocking and recall audits, high-index IDs, IEEE boundaries, profile teardown, and ordinary decoder parity; synchronize architecture, inventory, milestones, roadmap, research, and memory. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
8dd996053d
commit
363e046112
31 changed files with 1102 additions and 73 deletions
242
tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs
Normal file
242
tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
using System.Numerics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using AcDream.Content;
|
||||
using AcDream.Content.Vfx;
|
||||
using AcDream.Core.Vfx;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Lib.IO;
|
||||
using DatReaderWriter.Options;
|
||||
using DatReaderWriter.Types;
|
||||
using DatAnimation = DatReaderWriter.DBObjs.Animation;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.Content.Tests.Vfx;
|
||||
|
||||
public sealed class RetailDatLoaderTests
|
||||
{
|
||||
private sealed class RawDatabase : IDatDatabase
|
||||
{
|
||||
private readonly Dictionary<uint, byte[]> _entries = new();
|
||||
|
||||
public DatDatabase Db => null!;
|
||||
public int Iteration => 0;
|
||||
public void Add(uint id, byte[] bytes) => _entries[id] = bytes;
|
||||
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => _entries.Keys;
|
||||
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) =>
|
||||
_entries.TryGetValue(fileId, out value);
|
||||
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead)
|
||||
{
|
||||
if (!_entries.TryGetValue(fileId, out byte[]? value))
|
||||
{
|
||||
bytesRead = 0;
|
||||
return false;
|
||||
}
|
||||
if (bytes.Length < value.Length)
|
||||
bytes = new byte[value.Length];
|
||||
value.CopyTo(bytes, 0);
|
||||
bytesRead = value.Length;
|
||||
return true;
|
||||
}
|
||||
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj => false;
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhysicsScript_BlockingHookRetainsPayloadAndFollowingCursor()
|
||||
{
|
||||
DatPhysicsScript script = RetailPhysicsScriptLoader.Parse(
|
||||
ProjectileVfxDatFixtures.PhysicsScriptCreateBlockingThenAnimationDone);
|
||||
|
||||
Assert.Equal(0x3300F001u, script.Id);
|
||||
Assert.Equal(2, script.ScriptData.Count);
|
||||
Assert.Equal(0.0, script.ScriptData[0].StartTime);
|
||||
var blocking = Assert.IsType<RetailCreateBlockingParticleHook>(
|
||||
script.ScriptData[0].Hook);
|
||||
Assert.Equal(0x3200F001u, blocking.EmitterInfoId.DataId);
|
||||
Assert.Equal(0xFFFFFFFFu, blocking.PartIndex);
|
||||
Assert.Equal(new Vector3(1f, 2f, 3f), blocking.Offset.Origin);
|
||||
Assert.Equal(new Quaternion(0.5f, -0.5f, -0.5f, 0.5f),
|
||||
blocking.Offset.Orientation);
|
||||
Assert.Equal(7u, blocking.EmitterId);
|
||||
Assert.Equal(1.25, script.ScriptData[1].StartTime);
|
||||
Assert.IsType<AnimationDoneHook>(script.ScriptData[1].Hook);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Animation_BlockingHookRetainsPayloadAndFollowingCursor()
|
||||
{
|
||||
DatAnimation animation = RetailAnimationLoader.Parse(
|
||||
ProjectileVfxDatFixtures.AnimationCreateBlockingThenAnimationDone);
|
||||
|
||||
Assert.Equal(0x0300F001u, animation.Id);
|
||||
AnimationFrame frame = Assert.Single(animation.PartFrames);
|
||||
Assert.Equal(2, frame.Hooks.Count);
|
||||
var blocking = Assert.IsType<RetailCreateBlockingParticleHook>(frame.Hooks[0]);
|
||||
Assert.Equal(0x3200F002u, blocking.EmitterInfoId.DataId);
|
||||
Assert.Equal(0u, blocking.PartIndex);
|
||||
Assert.Equal(new Vector3(-1f, 4f, 0.25f), blocking.Offset.Origin);
|
||||
Assert.Equal(Quaternion.Identity, blocking.Offset.Orientation);
|
||||
Assert.Equal(9u, blocking.EmitterId);
|
||||
Assert.IsType<AnimationDoneHook>(frame.Hooks[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrdinaryPhysicsScript_MatchesPackageDecoder()
|
||||
{
|
||||
byte[] bytes = ProjectileVfxDatFixtures.OrdinaryPhysicsScript;
|
||||
DatPhysicsScript retail = RetailPhysicsScriptLoader.Parse(bytes);
|
||||
var package = new DatPhysicsScript();
|
||||
var reader = new DatBinReader(bytes);
|
||||
Assert.True(package.Unpack(reader));
|
||||
|
||||
Assert.Equal(reader.Length, reader.Offset);
|
||||
Assert.Equal(package.Id, retail.Id);
|
||||
Assert.Equal(package.ScriptData.Count, retail.ScriptData.Count);
|
||||
Assert.Equal(package.ScriptData[0].StartTime, retail.ScriptData[0].StartTime);
|
||||
Assert.Equal(package.ScriptData[0].Hook.HookType, retail.ScriptData[0].Hook.HookType);
|
||||
Assert.Equal(package.ScriptData[0].Hook.Direction, retail.ScriptData[0].Hook.Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrdinaryAnimation_MatchesPackageDecoder()
|
||||
{
|
||||
byte[] bytes = ProjectileVfxDatFixtures.OrdinaryAnimation;
|
||||
DatAnimation retail = RetailAnimationLoader.Parse(bytes);
|
||||
var package = new DatAnimation();
|
||||
var reader = new DatBinReader(bytes);
|
||||
Assert.True(package.Unpack(reader));
|
||||
|
||||
Assert.Equal(reader.Length, reader.Offset);
|
||||
Assert.Equal(package.Id, retail.Id);
|
||||
Assert.Equal(package.Flags, retail.Flags);
|
||||
Assert.Equal(package.NumParts, retail.NumParts);
|
||||
Assert.Equal(package.PartFrames.Count, retail.PartFrames.Count);
|
||||
Assert.Equal(package.PartFrames[0].Hooks[0].HookType,
|
||||
retail.PartFrames[0].Hooks[0].HookType);
|
||||
Assert.Equal(package.PartFrames[0].Hooks[0].Direction,
|
||||
retail.PartFrames[0].Hooks[0].Direction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhysicsScript_SortsHooksByRetailStartTimeAfterDecode()
|
||||
{
|
||||
byte[] bytes = ProjectileVfxDatFixtures.PhysicsScriptCreateBlockingThenAnimationDone.ToArray();
|
||||
// The fixture stores starts 0.0 then 1.25. Make the first later than
|
||||
// the second without changing either hook payload or cursor.
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteDoubleLittleEndian(
|
||||
bytes.AsSpan(8), 2.0);
|
||||
|
||||
DatPhysicsScript script = RetailPhysicsScriptLoader.Parse(bytes);
|
||||
|
||||
Assert.Equal(1.25, script.ScriptData[0].StartTime);
|
||||
Assert.IsType<AnimationDoneHook>(script.ScriptData[0].Hook);
|
||||
Assert.Equal(2.0, script.ScriptData[1].StartTime);
|
||||
Assert.IsType<RetailCreateBlockingParticleHook>(script.ScriptData[1].Hook);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnimationDidValidation_UsesRetailHighByte()
|
||||
{
|
||||
Assert.True(RetailAnimationLoader.IsAnimationDid(0x03010000u));
|
||||
Assert.True(RetailAnimationLoader.IsAnimationDid(0x03FFFFFFu));
|
||||
Assert.False(RetailAnimationLoader.IsAnimationDid(0x04010000u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loaders_AcceptHighIndexesCacheIdentityAndRejectEmbeddedIdMismatch()
|
||||
{
|
||||
const uint scriptDid = 0x33010000u;
|
||||
const uint animationDid = 0x03010000u;
|
||||
var portal = new RawDatabase();
|
||||
byte[] scriptBytes = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
byte[] animationBytes = ProjectileVfxDatFixtures.OrdinaryAnimation.ToArray();
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(scriptBytes, scriptDid);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(animationBytes, animationDid);
|
||||
portal.Add(scriptDid, scriptBytes);
|
||||
portal.Add(animationDid, animationBytes);
|
||||
|
||||
var scripts = new RetailPhysicsScriptLoader(portal);
|
||||
var animations = new RetailAnimationLoader(portal);
|
||||
DatPhysicsScript script = Assert.IsType<DatPhysicsScript>(scripts.LoadPhysicsScript(scriptDid));
|
||||
DatAnimation animation = Assert.IsType<DatAnimation>(animations.LoadAnimation(animationDid));
|
||||
Assert.Same(script, scripts.LoadPhysicsScript(scriptDid));
|
||||
Assert.Same(animation, animations.LoadAnimation(animationDid));
|
||||
|
||||
const uint wrongScriptKey = 0x33010001u;
|
||||
const uint wrongAnimationKey = 0x03010001u;
|
||||
portal.Add(wrongScriptKey, scriptBytes);
|
||||
portal.Add(wrongAnimationKey, animationBytes);
|
||||
Assert.Throws<InvalidDataException>(() => scripts.LoadPhysicsScript(wrongScriptKey));
|
||||
Assert.Throws<InvalidDataException>(() => animations.LoadAnimation(wrongAnimationKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsers_RejectTrailingOrTruncatedEntries()
|
||||
{
|
||||
byte[] script = ProjectileVfxDatFixtures.PhysicsScriptCreateBlockingThenAnimationDone;
|
||||
byte[] animation = ProjectileVfxDatFixtures.AnimationCreateBlockingThenAnimationDone;
|
||||
|
||||
Assert.ThrowsAny<Exception>(() =>
|
||||
RetailPhysicsScriptLoader.Parse(script.AsMemory(0, script.Length - 1)));
|
||||
Assert.ThrowsAny<Exception>(() =>
|
||||
RetailAnimationLoader.Parse(animation.AsMemory(0, animation.Length - 1)));
|
||||
Assert.Throws<InvalidDataException>(() =>
|
||||
RetailPhysicsScriptLoader.Parse(script.Concat(new byte[] { 0 }).ToArray()));
|
||||
Assert.Throws<InvalidDataException>(() =>
|
||||
RetailAnimationLoader.Parse(animation.Concat(new byte[] { 0 }).ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsers_RejectUnsupportedHookTypesWithoutReturningPartialObjects()
|
||||
{
|
||||
byte[] script = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
byte[] animation = ProjectileVfxDatFixtures.OrdinaryAnimation.ToArray();
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
script.AsSpan(16), 0xDEADBEEFu);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
animation.AsSpan(20), 0xDEADBEEFu);
|
||||
|
||||
Assert.ThrowsAny<Exception>(() => RetailPhysicsScriptLoader.Parse(script));
|
||||
Assert.ThrowsAny<Exception>(() => RetailAnimationLoader.Parse(animation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstalledBlockingScripts_ParseThroughRetailShape_WhenDatsAvailable()
|
||||
{
|
||||
string? datDir = ResolveDatDirectory();
|
||||
if (datDir is null)
|
||||
return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var loader = new RetailPhysicsScriptLoader(dats);
|
||||
|
||||
foreach (uint id in new[] { 0x33000AEAu, 0x33000AF8u })
|
||||
{
|
||||
DatPhysicsScript script = Assert.IsType<DatPhysicsScript>(
|
||||
loader.LoadPhysicsScript(id));
|
||||
Assert.Contains(script.ScriptData,
|
||||
item => item.Hook is RetailCreateBlockingParticleHook);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveDatDirectory()
|
||||
{
|
||||
string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(configured)
|
||||
&& File.Exists(Path.Combine(configured, "client_portal.dat")))
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
const string installed = @"C:\Turbine\Asheron's Call";
|
||||
return File.Exists(Path.Combine(installed, "client_portal.dat"))
|
||||
? installed
|
||||
: null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue