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

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