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:
Erik 2026-07-14 08:17:44 +02:00
parent 8dd996053d
commit 363e046112
31 changed files with 1102 additions and 73 deletions

View file

@ -0,0 +1,83 @@
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Net.Messages;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Rendering.Vfx;
public sealed class EntityEffectProfileTests
{
[Fact]
public void DatStatic_RetainsSetupDirectScriptAndTypedTable()
{
Setup setup = BuildSetup();
EntityEffectProfile profile = EntityEffectProfile.CreateDatStatic(setup);
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
Assert.Equal(0x340000BBu, profile.CurrentPhysicsScriptTableDid);
Assert.False(profile.HasNetworkDescription);
}
[Fact]
public void LivePhysicsDescriptionWithoutPeTableClearsSetupTable()
{
EntityEffectProfile profile = EntityEffectProfile.CreateLive(
BuildSetup(),
default);
Assert.Equal(0x330000AAu, profile.SetupDefaultScriptDid);
Assert.Null(profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0u, profile.RawDefaultScriptType);
Assert.Equal(0f, profile.DefaultScriptIntensity);
Assert.True(profile.HasNetworkDescription);
}
[Fact]
public void LivePhysicsDescriptionReplacesTableAndDrivesTypedDefault()
{
PhysicsSpawnData physics = default(PhysicsSpawnData) with
{
PhysicsScriptTableId = 0x340000CCu,
DefaultScriptType = 0xA5A5A5A5u,
DefaultScriptIntensity = 0.75f,
};
EntityEffectProfile profile = EntityEffectProfile.CreateLive(
BuildSetup(),
physics);
Assert.Equal(0x340000CCu, profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0xA5A5A5A5u, profile.RawDefaultScriptType);
Assert.Equal(0.75f, profile.DefaultScriptIntensity);
}
[Fact]
public void LaterNetworkDescriptionWithPresentZeroDoesNotRestoreSetup()
{
EntityEffectProfile profile = EntityEffectProfile.CreateLive(
BuildSetup(),
default(PhysicsSpawnData) with
{
PhysicsScriptTableId = 0x340000CCu,
DefaultScriptType = 7u,
DefaultScriptIntensity = 1f,
});
profile.ApplyNetworkDescription(default(PhysicsSpawnData) with
{
PhysicsScriptTableId = 0u,
DefaultScriptType = 0u,
DefaultScriptIntensity = 0f,
});
Assert.Null(profile.CurrentPhysicsScriptTableDid);
Assert.Equal(0u, profile.RawDefaultScriptType);
Assert.Equal(0f, profile.DefaultScriptIntensity);
}
private static Setup BuildSetup() => new()
{
DefaultScript = 0x330000AAu,
DefaultScriptTable = 0x340000BBu,
};
}

View file

@ -27,6 +27,8 @@ public sealed class LiveEntityRuntimeTests
public WorldEntity Entity { get; } = entity;
}
private sealed class EffectProfile : ILiveEntityEffectProfile { }
private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle
{
public int RegisterCount { get; private set; }
@ -90,6 +92,33 @@ public sealed class LiveEntityRuntimeTests
Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _));
}
[Fact]
public void EffectProfile_SurvivesRebucketAndClearsWithLogicalTeardown()
{
const uint guid = 0x70000030u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
WorldSession.EntitySpawn spawn = Spawn(guid, 6, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
var profile = new EffectProfile();
runtime.SetEffectProfile(guid, profile);
runtime.MaterializeLiveEntity(
guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, guid));
Assert.True(runtime.RebucketLiveEntity(guid, 0x01020001u));
Assert.True(runtime.TryGetEffectProfile(guid, out var retained));
Assert.Same(profile, retained);
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, spawn.InstanceSequence),
isLocalPlayer: false));
Assert.False(runtime.TryGetEffectProfile(guid, out _));
}
[Fact]
public void InitialChildCreate_PreservesParentEventQueuedForMissingChild()
{

View file

@ -11,6 +11,8 @@ internal static class ProjectileVfxDatFixtures
{
internal static readonly byte[] PhysicsScriptCreateBlockingThenAnimationDone = BuildPhysicsScript();
internal static readonly byte[] AnimationCreateBlockingThenAnimationDone = BuildAnimation();
internal static readonly byte[] OrdinaryPhysicsScript = BuildOrdinaryPhysicsScript();
internal static readonly byte[] OrdinaryAnimation = BuildOrdinaryAnimation();
private static byte[] BuildPhysicsScript()
{
@ -95,6 +97,30 @@ internal static class ProjectileVfxDatFixtures
pos += 4;
}
}
private static byte[] BuildOrdinaryPhysicsScript()
{
var bytes = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x3300F002u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4), 1u);
BinaryPrimitives.WriteDoubleLittleEndian(bytes.AsSpan(8), 2.5);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16), 0x04u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20), 1u);
return bytes;
}
private static byte[] BuildOrdinaryAnimation()
{
var bytes = new byte[28];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x0300F002u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(12), 1u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16), 1u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20), 0x04u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(24), 1u);
return bytes;
}
}
public sealed class ProjectileVfxDatFixtureTests

View 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;
}
}

View file

@ -0,0 +1,117 @@
using AcDream.Core.Vfx;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Vfx;
public sealed class PhysicsScriptTableResolverTests
{
private const uint TableDid = 0x34000005u;
private const uint RawType = 0x00000016u;
[Fact]
public void Resolve_UsesFirstStoredUpperThresholdWithoutSorting()
{
PhysicsScriptTable table = BuildTable(
(1.0f, 0x33000011u),
(0.0f, 0x33000022u));
var resolver = new PhysicsScriptTableResolver(
id => id == TableDid ? table : null);
Assert.Equal(0x33000011u, resolver.Resolve(TableDid, RawType, -0.25f));
Assert.Equal(0x33000011u, resolver.Resolve(TableDid, RawType, 0.0f));
}
[Fact]
public void Resolve_EqualityAndDuplicateThresholdChooseFirst()
{
PhysicsScriptTable table = BuildTable(
(0.5f, 0x33000031u),
(0.5f, 0x33000032u),
(1.0f, 0x33000033u));
var resolver = new PhysicsScriptTableResolver(_ => table);
Assert.Equal(0x33000031u, resolver.Resolve(TableDid, RawType, 0.5f));
Assert.Equal(0x33000033u, resolver.Resolve(TableDid, RawType, 0.75f));
}
[Fact]
public void Resolve_PreservesRetailIeeeComparisonBoundaries()
{
PhysicsScriptTable table = BuildTable(
(0.0f, 0x33000041u),
(1.0f, 0x33000042u));
var resolver = new PhysicsScriptTableResolver(_ => table);
Assert.Equal(0x33000041u,
resolver.Resolve(TableDid, RawType, float.NegativeInfinity));
Assert.Null(resolver.Resolve(TableDid, RawType, float.PositiveInfinity));
Assert.Null(resolver.Resolve(TableDid, RawType, float.NaN));
Assert.Null(resolver.Resolve(TableDid, RawType, 1.0001f));
}
[Fact]
public void Resolve_MissingOrInvalidInputsReturnNoScript()
{
PhysicsScriptTable invalidScript = BuildTable((1.0f, 0x32000001u));
var resolver = new PhysicsScriptTableResolver(
id => id == TableDid ? invalidScript : null);
Assert.Null(resolver.Resolve(0u, RawType, 0f));
Assert.Null(resolver.Resolve(0x35000001u, RawType, 0f));
Assert.Null(resolver.Resolve(TableDid, 0xA5A5A5A5u, 0f));
Assert.Null(resolver.Resolve(TableDid, RawType, 0f));
}
[Fact]
public void DidValidation_UsesRetailHighByteAndAcceptsLargeIndexes()
{
Assert.True(PhysicsScriptTableResolver.IsPhysicsScriptDid(0x33010000u));
Assert.True(PhysicsScriptTableResolver.IsPhysicsScriptDid(0x33FFFFFFu));
Assert.True(PhysicsScriptTableResolver.IsPhysicsScriptTableDid(0x34010000u));
Assert.True(PhysicsScriptTableResolver.IsPhysicsScriptTableDid(0x34FFFFFFu));
Assert.False(PhysicsScriptTableResolver.IsPhysicsScriptDid(0x34010000u));
Assert.False(PhysicsScriptTableResolver.IsPhysicsScriptTableDid(0x33010000u));
}
[Fact]
public void Resolve_AcceptsHighIndexTableAndScriptIds()
{
const uint highTableDid = 0x34010000u;
PhysicsScriptTable table = BuildTable((1.0f, 0x33010000u));
table.Id = highTableDid;
var resolver = new PhysicsScriptTableResolver(
id => id == highTableDid ? table : null);
Assert.Equal(0x33010000u, resolver.Resolve(highTableDid, RawType, 0f));
}
[Fact]
public void Resolve_RejectsMismatchedEmbeddedTableId()
{
PhysicsScriptTable mismatched = BuildTable((1.0f, 0x33000051u));
mismatched.Id = 0x34000006u;
var resolver = new PhysicsScriptTableResolver(_ => mismatched);
Assert.Null(resolver.Resolve(TableDid, RawType, 0f));
}
private static PhysicsScriptTable BuildTable(
params (float Mod, uint ScriptDid)[] entries)
{
var data = new PhysicsScriptTableData();
foreach ((float mod, uint scriptDid) in entries)
{
data.Scripts.Add(new ScriptAndModData
{
Mod = mod,
ScriptId = scriptDid,
});
}
var table = new PhysicsScriptTable { Id = TableDid };
table.ScriptTable.Add(unchecked((PlayScript)RawType), data);
return table;
}
}