fix(vfx): classify hardwareless particle emitters once

This commit is contained in:
Erik 2026-07-27 00:03:31 +02:00
parent 921712f412
commit 5b3fb17775
8 changed files with 374 additions and 18 deletions

View file

@ -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

View file

@ -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`

View file

@ -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;
}
/// <summary>
/// Resolves retail <c>ParticleEmitterInfo</c> dat records
/// (<c>0x32xxxxxx</c>) into acdream runtime descriptors.
@ -20,8 +35,11 @@ namespace AcDream.Core.Vfx;
public sealed class EmitterDescRegistry
{
private readonly Func<uint, DatParticleEmitter?>? _resolver;
private readonly Func<DatParticleEmitter, float?>? _degradeDistanceResolver;
private readonly Func<DatParticleEmitter, EmitterDatResolution>?
_degradeDistanceResolver;
private readonly ConcurrentDictionary<uint, EmitterDesc> _byId = new();
private readonly ConcurrentDictionary<uint, EmitterDescResolutionFailure>
_failures = new();
public EmitterDescRegistry()
: this((Func<uint, DatParticleEmitter?>?)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<DatGfxObj>(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<DatGfxObjDegradeInfo>(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));
}
}
/// <summary>

View file

@ -40,6 +40,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
private readonly HashSet<uint> _examinationOwners = [];
private readonly HashSet<uint> _hiddenPresentationOwners = [];
private readonly HashSet<uint> _stoppingOwners = [];
private readonly HashSet<uint> _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,

View file

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

View file

@ -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<string>();
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()
{

View file

@ -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<uint, IDBObj> _objects = new();
private readonly Dictionary<uint, int> _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<T>(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<T>(uint fileId, out T value) where T : IDBObj
{
value = Get<T>(fileId);
return value is not null;
}
}
}

View file

@ -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<string>();
var representativePes = new HashSet<uint>();
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<DatParticleEmitter>(emitterId);
uint softwareGfxObjId = emitter?.GfxObjId.DataId ?? 0u;
uint hardwareGfxObjId = emitter?.HwGfxObjId.DataId ?? 0u;
bool hasSoftwareGfx = softwareGfxObjId != 0u
&& SafeGet<GfxObj>(softwareGfxObjId) is not null;
bool hasHardwareGfx = hardwareGfxObjId != 0u
&& SafeGet<GfxObj>(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<uint, (Vector3 Origin, float Radius, int Length, string Sha256)>
{
[0x02000124u] = (Vector3.Zero, 0.1f, 812, "630D19A9C79137F7BA90D01448B03E8F44D655FF77A4725792CB5660930A079E"),