diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index e414f8d0..c034005a 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -207,6 +207,15 @@ anchors: `CPhysicsPart::GetMaxDegradeDistance` `0x0050D510`, `CPhysicsObj::ShouldDrawParticles` `0x0050FE60`, and `ParticleEmitter::UpdateParticles` `0x0051D180`. +Hardwareless ParticleEmitterInfo records are also retained exactly. Retail +`ParticleEmitter::SetInfo @ 0x0051CE90` returns false when +`hw_gfxobj_id == INVALID_DID`; it does not substitute the software GfxObj. +`EmitterDescRegistry` negative-caches that authored outcome and the hook sink +reports it once per DAT ID rather than once per owner. This is classification +and bounded diagnostics around the existing `DatCollection`, not a fallback +reader or invented VFX. Evidence: +`docs/research/2026-07-26-retail-hardwareless-particle-emitter-diagnostics.md`. + **Retail shared world-alpha seam (2026-07-18).** WorldBuilder's editor renderers classify translucent mesh batches correctly, but they have no live retail `CPartCell`/`CShadowPart` list and render particles in a separate diff --git a/docs/research/2026-07-26-retail-hardwareless-particle-emitter-diagnostics.md b/docs/research/2026-07-26-retail-hardwareless-particle-emitter-diagnostics.md new file mode 100644 index 00000000..acf0871b --- /dev/null +++ b/docs/research/2026-07-26-retail-hardwareless-particle-emitter-diagnostics.md @@ -0,0 +1,82 @@ +# Retail hardwareless particle-emitter resolution + +Date: 2026-07-26 +Oracle: matching Sept-2013 `acclient.exe` + `acclient.pdb` + +## Symptom + +Dense scenes repeatedly logged that ParticleEmitterInfo records such as +`0x320002D6` and `0x320003B6`–`0x320003B9` were “not found” for many different +owners. The records were present in the installed DAT, so the message was +misleading and each owner repeated the same failed resolution work. + +## Named-retail behavior + +`ParticleEmitter::SetInfo @ 0x0051CE90` begins with: + +```text +Destroy() +info = suppliedInfo + +if info.hw_gfxobj_id == INVALID_DID: + Destroy() + return false + +particleObject = makeParticleObject(info.max_particles, info.sorting_sphere) +... +``` + +`ParticleEmitter::SetInfo(DataID) @ 0x0051D3C0` first loads the +ParticleEmitterInfo and delegates to the overload above. +`ParticleManager::CreateParticleEmitter @ 0x0051B6C0` destroys the attempted +emitter when either `SetInfo` or parenting fails. Retail does not substitute +the software `gfxobj_id` for an absent hardware GfxObj. + +## Installed-DAT audit + +The production `DatCollection` and `EmitterDescRegistry` were exercised +through `ProjectileVfxAudit --emitters`: + +| Emitter | Record | Software GfxObj | Hardware GfxObj | Retail outcome | +|---|---|---:|---:|---| +| `0x320002D6` | present/parsed | present | `0` | skip | +| `0x320003B6` | present/parsed | present | `0` | skip | +| `0x320003B7` | present/parsed | present | `0` | skip | +| `0x320003B8` | present/parsed | present | `0` | skip | +| `0x320003B9` | present/parsed | present | `0` | skip | + +These are authored hardwareless records, not missing DAT files. + +## Cross-reference + +- ACViewer `ParticleEmitter.SetInfo` returns false when `HWGfxObjID == 0`. + Its render initialization explicitly comments that this case is expected and + skips the absent emitter. +- WorldBuilder contains no contradictory live-client hardware-emitter + fallback. Its particle code is an editor/rendering reference, while the + retail client remains the behavior oracle. + +## Runtime contract + +`EmitterDescRegistry` now stores either one resolved descriptor or one +classified negative result per DAT ID: + +```text +MissingEmitterInfo +InvalidHardwareGfxObjId +MissingHardwareGfxObj +``` + +The particle hook sink reports the first failure for each emitter DAT ID, not +one line per owner. Hardwareless records say that retail skipped them rather +than claiming the ParticleEmitterInfo was absent. No fallback asset or +particle is fabricated. + +Registration of an explicit runtime descriptor clears a cached negative +result, preserving test/tool injection and future hot-reload behavior. + +## Conformance coverage + +- `EmitterDescRegistry_NegativeCachesMissingDatAndRegisterClearsFailure` +- `EmitterDescRegistry_ClassifiesAndCachesAuthoredHardwarelessEmitter` +- `MissingEmitterAsset_ReportsOnlyOnceAcrossManyOwners` diff --git a/src/AcDream.Core/Vfx/EmitterDescLoader.cs b/src/AcDream.Core/Vfx/EmitterDescLoader.cs index 484f730a..320c5350 100644 --- a/src/AcDream.Core/Vfx/EmitterDescLoader.cs +++ b/src/AcDream.Core/Vfx/EmitterDescLoader.cs @@ -13,6 +13,21 @@ using DatGfxObjFlags = DatReaderWriter.Enums.GfxObjFlags; namespace AcDream.Core.Vfx; +public enum EmitterDescResolutionFailureKind +{ + None, + MissingEmitterInfo, + InvalidHardwareGfxObjId, + MissingHardwareGfxObj, +} + +public readonly record struct EmitterDescResolutionFailure( + EmitterDescResolutionFailureKind Kind, + uint RelatedDatId = 0u) +{ + public static EmitterDescResolutionFailure None => default; +} + /// /// Resolves retail ParticleEmitterInfo dat records /// (0x32xxxxxx) into acdream runtime descriptors. @@ -20,8 +35,11 @@ namespace AcDream.Core.Vfx; public sealed class EmitterDescRegistry { private readonly Func? _resolver; - private readonly Func? _degradeDistanceResolver; + private readonly Func? + _degradeDistanceResolver; private readonly ConcurrentDictionary _byId = new(); + private readonly ConcurrentDictionary + _failures = new(); public EmitterDescRegistry() : this((Func?)null) @@ -44,6 +62,7 @@ public sealed class EmitterDescRegistry { ArgumentNullException.ThrowIfNull(desc); _byId[desc.DatId] = desc; + _failures.TryRemove(desc.DatId, out _); } public EmitterDesc Get(uint emitterId) @@ -55,35 +74,59 @@ public sealed class EmitterDescRegistry } public bool TryGet(uint emitterId, out EmitterDesc desc) + => TryGet(emitterId, out desc, out _); + + public bool TryGet( + uint emitterId, + out EmitterDesc desc, + out EmitterDescResolutionFailure failure) { if (_byId.TryGetValue(emitterId, out desc!)) + { + failure = EmitterDescResolutionFailure.None; return true; + } + if (_failures.TryGetValue(emitterId, out failure)) + { + desc = null!; + return false; + } + if (_resolver is not null) { var dat = _resolver(emitterId); if (dat is not null) { - float? resolvedDistance = _degradeDistanceResolver?.Invoke(dat); - if (_degradeDistanceResolver is not null && !resolvedDistance.HasValue) + EmitterDatResolution resolved = _degradeDistanceResolver?.Invoke(dat) + ?? EmitterDatResolution.Success( + RetailParticleDegradeDistance.DefaultDistance); + if (!resolved.Succeeded) { // ParticleEmitter::SetInfo (0x0051CE90) fails when the // authored hardware GfxObj cannot be loaded. It never // substitutes the software GfxObj. + failure = resolved.Failure; + _failures.TryAdd(emitterId, failure); desc = null!; return false; } - float maxDegradeDistance = resolvedDistance - ?? RetailParticleDegradeDistance.DefaultDistance; - desc = FromDat(emitterId, dat, maxDegradeDistance); + desc = FromDat(emitterId, dat, resolved.MaxDegradeDistance); _byId[emitterId] = desc; + failure = EmitterDescResolutionFailure.None; return true; } } + + failure = new EmitterDescResolutionFailure( + EmitterDescResolutionFailureKind.MissingEmitterInfo, + emitterId); + _failures.TryAdd(emitterId, failure); desc = null!; return false; } public int Count => _byId.Count; + public int FailureCount => _failures.Count; public static EmitterDesc FromDat( uint emitterId, @@ -155,25 +198,34 @@ public sealed class EmitterDescRegistry }; } - private static float? ResolveMaxDegradeDistance( + private static EmitterDatResolution ResolveMaxDegradeDistance( IDatObjectSource dats, DatParticleEmitter emitter) { uint gfxObjId = GetRetailHardwareGfxObjId(emitter); if (gfxObjId == 0) - return null; + { + return EmitterDatResolution.Fail( + EmitterDescResolutionFailureKind.InvalidHardwareGfxObjId); + } DatGfxObj? gfx = SafeGet(dats, gfxObjId); if (gfx is null) - return null; + { + return EmitterDatResolution.Fail( + EmitterDescResolutionFailureKind.MissingHardwareGfxObj, + gfxObjId); + } if (!gfx.Flags.HasFlag(DatGfxObjFlags.HasDIDDegrade) || gfx.DIDDegrade == 0) { - return RetailParticleDegradeDistance.DefaultDistance; + return EmitterDatResolution.Success( + RetailParticleDegradeDistance.DefaultDistance); } DatGfxObjDegradeInfo? degrade = SafeGet(dats, gfx.DIDDegrade); - return RetailParticleDegradeDistance.FromEntries(degrade?.Degrades); + return EmitterDatResolution.Success( + RetailParticleDegradeDistance.FromEntries(degrade?.Degrades)); } internal static uint GetRetailHardwareGfxObjId(DatParticleEmitter emitter) @@ -216,6 +268,26 @@ public sealed class EmitterDescRegistry DatParticleType.GlobalVelocity => ParticleType.GlobalVelocity, _ => ParticleType.Unknown, }; + + private readonly record struct EmitterDatResolution( + bool Succeeded, + float MaxDegradeDistance, + EmitterDescResolutionFailure Failure) + { + public static EmitterDatResolution Success(float maxDegradeDistance) => + new( + true, + maxDegradeDistance, + EmitterDescResolutionFailure.None); + + public static EmitterDatResolution Fail( + EmitterDescResolutionFailureKind kind, + uint relatedDatId = 0u) => + new( + false, + 0f, + new EmitterDescResolutionFailure(kind, relatedDatId)); + } } /// diff --git a/src/AcDream.Core/Vfx/ParticleHookSink.cs b/src/AcDream.Core/Vfx/ParticleHookSink.cs index ad2a5998..c6077ae9 100644 --- a/src/AcDream.Core/Vfx/ParticleHookSink.cs +++ b/src/AcDream.Core/Vfx/ParticleHookSink.cs @@ -40,6 +40,7 @@ public sealed class ParticleHookSink : IAnimationHookSink private readonly HashSet _examinationOwners = []; private readonly HashSet _hiddenPresentationOwners = []; private readonly HashSet _stoppingOwners = []; + private readonly HashSet _reportedEmitterResolutionFailures = []; // Production pose registries publish actual root/part/cell changes. Only // those owners are recomposed; static world emitters do no refresh work. @@ -417,11 +418,16 @@ public sealed class ParticleHookSink : IAnimationHookSink partIndex, renderPass, visibilityPolicy, - out int handle)) + out int handle, + out EmitterDescResolutionFailure failure)) { - DiagnosticSink?.Invoke( - $"ParticleEmitterInfo 0x{emitterInfoId:X8} for owner " + - $"0x{ownerLocalId:X8} was not found; no fallback was created."); + if (_reportedEmitterResolutionFailures.Add(emitterInfoId)) + { + DiagnosticSink?.Invoke(FormatEmitterResolutionDiagnostic( + emitterInfoId, + ownerLocalId, + failure)); + } return; } _system.UpdateEmitterOwnerPosition(handle, ownerPosition); @@ -534,6 +540,26 @@ public sealed class ParticleHookSink : IAnimationHookSink return true; } + private static string FormatEmitterResolutionDiagnostic( + uint emitterInfoId, + uint ownerLocalId, + EmitterDescResolutionFailure failure) => + failure.Kind switch + { + EmitterDescResolutionFailureKind.InvalidHardwareGfxObjId => + $"ParticleEmitterInfo 0x{emitterInfoId:X8} has no retail " + + $"hardware GfxObj; ParticleEmitter::SetInfo skipped it " + + $"(first owner 0x{ownerLocalId:X8}).", + EmitterDescResolutionFailureKind.MissingHardwareGfxObj => + $"ParticleEmitterInfo 0x{emitterInfoId:X8} references missing " + + $"retail hardware GfxObj 0x{failure.RelatedDatId:X8}; " + + $"ParticleEmitter::SetInfo skipped it " + + $"(first owner 0x{ownerLocalId:X8}).", + _ => + $"ParticleEmitterInfo 0x{emitterInfoId:X8} for owner " + + $"0x{ownerLocalId:X8} was not found; no fallback was created.", + }; + private readonly record struct EmitterBinding( uint OwnerLocalId, int PartIndex, diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs index 4e067102..4345c6f3 100644 --- a/src/AcDream.Core/Vfx/ParticleSystem.cs +++ b/src/AcDream.Core/Vfx/ParticleSystem.cs @@ -176,8 +176,29 @@ public sealed class ParticleSystem : IParticleSystem ParticleRenderPass renderPass, ParticleVisibilityPolicy visibilityPolicy, out int handle) + => TrySpawnEmitterById( + emitterId, + anchor, + rot, + attachedObjectId, + attachedPartIndex, + renderPass, + visibilityPolicy, + out handle, + out _); + + public bool TrySpawnEmitterById( + uint emitterId, + Vector3 anchor, + Quaternion? rot, + uint attachedObjectId, + int attachedPartIndex, + ParticleRenderPass renderPass, + ParticleVisibilityPolicy visibilityPolicy, + out int handle, + out EmitterDescResolutionFailure failure) { - if (!_registry.TryGet(emitterId, out EmitterDesc? desc)) + if (!_registry.TryGet(emitterId, out EmitterDesc? desc, out failure)) { handle = 0; return false; @@ -191,6 +212,7 @@ public sealed class ParticleSystem : IParticleSystem attachedPartIndex, renderPass, visibilityPolicy); + failure = EmitterDescResolutionFailure.None; return true; } diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs index 126d8ef5..2d7ed3d5 100644 --- a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs @@ -264,6 +264,29 @@ public sealed class ParticleHookSinkTests Assert.Contains($"0x{Owner:X8}", diagnostic, StringComparison.Ordinal); } + [Fact] + public void MissingEmitterAsset_ReportsOnlyOnceAcrossManyOwners() + { + const uint missingId = 0x32DEAD00u; + const uint secondOwner = Owner + 1u; + var registry = new EmitterDescRegistry(); + var system = new ParticleSystem(registry, new Random(42)); + var poses = new MutablePoseSource(); + poses.Publish(Owner, Matrix4x4.Identity, Matrix4x4.Identity); + poses.Publish(secondOwner, Matrix4x4.Identity, Matrix4x4.Identity); + var sink = new ParticleHookSink(system, poses); + var diagnostics = new List(); + sink.DiagnosticSink = diagnostics.Add; + + sink.OnHook(Owner, Vector3.Zero, Create(missingId, logicalId: 0u)); + sink.OnHook(secondOwner, Vector3.Zero, Create(missingId, logicalId: 0u)); + sink.OnHook(Owner, Vector3.Zero, Create(missingId, logicalId: 0u)); + + Assert.Empty(system.EnumerateEmitters()); + Assert.Single(diagnostics); + Assert.Contains($"0x{missingId:X8}", diagnostics[0], StringComparison.Ordinal); + } + [Fact] public void MissingLivePose_HidesPresentationWithoutEndingEmitterLifetime() { diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs index 171b35cb..0d6f4a52 100644 --- a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs @@ -1,6 +1,8 @@ using System.Linq; using System.Numerics; +using AcDream.Core.Content; using AcDream.Core.Vfx; +using DatReaderWriter.Lib.IO; using DatReaderWriter.Types; using Xunit; @@ -267,6 +269,35 @@ public sealed class ParticleSystemTests Assert.Same(desc, reg.Get(0x32001234u)); } + [Fact] + public void EmitterDescRegistry_NegativeCachesMissingDatAndRegisterClearsFailure() + { + int calls = 0; + var reg = new EmitterDescRegistry(_ => + { + calls++; + return null; + }); + const uint emitterId = 0x3200DEADu; + + Assert.False(reg.TryGet(emitterId, out _, out var first)); + Assert.False(reg.TryGet(emitterId, out _, out var second)); + + Assert.Equal(1, calls); + Assert.Equal( + EmitterDescResolutionFailureKind.MissingEmitterInfo, + first.Kind); + Assert.Equal(first, second); + Assert.Equal(1, reg.FailureCount); + + var desc = new EmitterDesc { DatId = emitterId }; + reg.Register(desc); + Assert.True(reg.TryGet(emitterId, out var resolved, out var failure)); + Assert.Same(desc, resolved); + Assert.Equal(EmitterDescResolutionFailureKind.None, failure.Kind); + Assert.Equal(0, reg.FailureCount); + } + [Fact] public void LocalVelocity_TransformsABySpawnRotation() { @@ -398,6 +429,31 @@ public sealed class ParticleSystemTests Assert.Equal(0u, EmitterDescRegistry.GetRetailHardwareGfxObjId(dat)); } + [Fact] + public void EmitterDescRegistry_ClassifiesAndCachesAuthoredHardwarelessEmitter() + { + const uint emitterId = 0x320002D6u; + var dats = new FakeDatObjectSource(); + dats.Add( + emitterId, + new DatReaderWriter.DBObjs.ParticleEmitter + { + GfxObjId = 0x010016C9u, + HwGfxObjId = 0u, + }); + var reg = new EmitterDescRegistry(dats); + + Assert.False(reg.TryGet(emitterId, out _, out var first)); + Assert.False(reg.TryGet(emitterId, out _, out var second)); + + Assert.Equal( + EmitterDescResolutionFailureKind.InvalidHardwareGfxObjId, + first.Kind); + Assert.Equal(first, second); + Assert.Equal(1, dats.GetCallCount(emitterId)); + Assert.Equal(1, reg.FailureCount); + } + [Fact] public void RetailParticleDegradeDistance_MatchesRetailEntrySelection() { @@ -1100,4 +1156,31 @@ public sealed class ParticleSystemTests sys.Tick(0.01f); Assert.Equal(2, sys.ActiveParticleCount); } + + private sealed class FakeDatObjectSource : IDatObjectSource + { + private readonly Dictionary _objects = new(); + private readonly Dictionary _calls = new(); + + public void Add(uint id, IDBObj value) => _objects[id] = value; + + public int GetCallCount(uint id) => + _calls.TryGetValue(id, out int count) ? count : 0; + + public T Get(uint fileId) where T : IDBObj + { + _calls.TryGetValue(fileId, out int count); + _calls[fileId] = count + 1; + return _objects.TryGetValue(fileId, out IDBObj? value) + && value is T typed + ? typed + : default!; + } + + public bool TryGet(uint fileId, out T value) where T : IDBObj + { + value = Get(fileId); + return value is not null; + } + } } diff --git a/tools/ProjectileVfxAudit/Program.cs b/tools/ProjectileVfxAudit/Program.cs index 5073b880..204288d4 100644 --- a/tools/ProjectileVfxAudit/Program.cs +++ b/tools/ProjectileVfxAudit/Program.cs @@ -11,18 +11,57 @@ using DatReaderWriter.Lib.IO; using DatReaderWriter.Options; using DatReaderWriter.Types; using DatAnimation = DatReaderWriter.DBObjs.Animation; +using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter; using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; string datDir = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? @"C:\Turbine\Asheron's Call"; using var dats = new DatCollection(datDir, DatAccessType.Read); -var physicsScripts = new RetailPhysicsScriptLoader(dats); -var animations = new RetailAnimationLoader(dats); +using var boundedDats = new AcDream.Content.DatCollectionAdapter(dats); +var physicsScripts = new RetailPhysicsScriptLoader(boundedDats); +var animations = new RetailAnimationLoader(boundedDats); Console.WriteLine($"DAT metadata audit: {datDir}"); var failures = new List(); var representativePes = new HashSet(); +if (args.Contains("--emitters", StringComparer.OrdinalIgnoreCase)) +{ + uint[] emitterIds = + [ + 0x320002D6u, + 0x320003B6u, + 0x320003B7u, + 0x320003B8u, + 0x320003B9u, + ]; + var registry = new EmitterDescRegistry(boundedDats); + foreach (uint emitterId in emitterIds) + { + bool hasRaw = dats.Portal.Tree.TryGetFile(emitterId, out _); + DatParticleEmitter? emitter = SafeGet(emitterId); + uint softwareGfxObjId = emitter?.GfxObjId.DataId ?? 0u; + uint hardwareGfxObjId = emitter?.HwGfxObjId.DataId ?? 0u; + bool hasSoftwareGfx = softwareGfxObjId != 0u + && SafeGet(softwareGfxObjId) is not null; + bool hasHardwareGfx = hardwareGfxObjId != 0u + && SafeGet(hardwareGfxObjId) is not null; + bool resolved = registry.TryGet( + emitterId, + out EmitterDesc? descriptor, + out EmitterDescResolutionFailure failure); + Console.WriteLine( + $"0x{emitterId:X8}: raw={hasRaw}, parsed={emitter is not null}, " + + $"gfx=0x{softwareGfxObjId:X8}, gfxParsed={hasSoftwareGfx}, " + + $"hwGfx=0x{hardwareGfxObjId:X8}, hwParsed={hasHardwareGfx}, " + + $"registry={resolved}, maxDist={descriptor?.MaxDegradeDistance:R}, " + + $"failure={failure.Kind}, related=0x{failure.RelatedDatId:X8}, " + + $"type={emitter?.EmitterType}/{emitter?.ParticleType}, " + + $"initial={emitter?.InitialParticles}, max={emitter?.MaxParticles}"); + } + return; +} + var expectedSetups = new Dictionary { [0x02000124u] = (Vector3.Zero, 0.1f, 812, "630D19A9C79137F7BA90D01448B03E8F44D655FF77A4725792CB5660930A079E"),