fix(vfx): classify hardwareless particle emitters once
This commit is contained in:
parent
921712f412
commit
5b3fb17775
8 changed files with 374 additions and 18 deletions
|
|
@ -207,6 +207,15 @@ anchors: `CPhysicsPart::GetMaxDegradeDistance` `0x0050D510`,
|
||||||
`CPhysicsObj::ShouldDrawParticles` `0x0050FE60`, and
|
`CPhysicsObj::ShouldDrawParticles` `0x0050FE60`, and
|
||||||
`ParticleEmitter::UpdateParticles` `0x0051D180`.
|
`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
|
**Retail shared world-alpha seam (2026-07-18).** WorldBuilder's editor
|
||||||
renderers classify translucent mesh batches correctly, but they have no live
|
renderers classify translucent mesh batches correctly, but they have no live
|
||||||
retail `CPartCell`/`CShadowPart` list and render particles in a separate
|
retail `CPartCell`/`CShadowPart` list and render particles in a separate
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -13,6 +13,21 @@ using DatGfxObjFlags = DatReaderWriter.Enums.GfxObjFlags;
|
||||||
|
|
||||||
namespace AcDream.Core.Vfx;
|
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>
|
/// <summary>
|
||||||
/// Resolves retail <c>ParticleEmitterInfo</c> dat records
|
/// Resolves retail <c>ParticleEmitterInfo</c> dat records
|
||||||
/// (<c>0x32xxxxxx</c>) into acdream runtime descriptors.
|
/// (<c>0x32xxxxxx</c>) into acdream runtime descriptors.
|
||||||
|
|
@ -20,8 +35,11 @@ namespace AcDream.Core.Vfx;
|
||||||
public sealed class EmitterDescRegistry
|
public sealed class EmitterDescRegistry
|
||||||
{
|
{
|
||||||
private readonly Func<uint, DatParticleEmitter?>? _resolver;
|
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, EmitterDesc> _byId = new();
|
||||||
|
private readonly ConcurrentDictionary<uint, EmitterDescResolutionFailure>
|
||||||
|
_failures = new();
|
||||||
|
|
||||||
public EmitterDescRegistry()
|
public EmitterDescRegistry()
|
||||||
: this((Func<uint, DatParticleEmitter?>?)null)
|
: this((Func<uint, DatParticleEmitter?>?)null)
|
||||||
|
|
@ -44,6 +62,7 @@ public sealed class EmitterDescRegistry
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(desc);
|
ArgumentNullException.ThrowIfNull(desc);
|
||||||
_byId[desc.DatId] = desc;
|
_byId[desc.DatId] = desc;
|
||||||
|
_failures.TryRemove(desc.DatId, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
public EmitterDesc Get(uint emitterId)
|
public EmitterDesc Get(uint emitterId)
|
||||||
|
|
@ -55,35 +74,59 @@ public sealed class EmitterDescRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryGet(uint emitterId, out EmitterDesc desc)
|
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!))
|
if (_byId.TryGetValue(emitterId, out desc!))
|
||||||
|
{
|
||||||
|
failure = EmitterDescResolutionFailure.None;
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
if (_failures.TryGetValue(emitterId, out failure))
|
||||||
|
{
|
||||||
|
desc = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (_resolver is not null)
|
if (_resolver is not null)
|
||||||
{
|
{
|
||||||
var dat = _resolver(emitterId);
|
var dat = _resolver(emitterId);
|
||||||
if (dat is not null)
|
if (dat is not null)
|
||||||
{
|
{
|
||||||
float? resolvedDistance = _degradeDistanceResolver?.Invoke(dat);
|
EmitterDatResolution resolved = _degradeDistanceResolver?.Invoke(dat)
|
||||||
if (_degradeDistanceResolver is not null && !resolvedDistance.HasValue)
|
?? EmitterDatResolution.Success(
|
||||||
|
RetailParticleDegradeDistance.DefaultDistance);
|
||||||
|
if (!resolved.Succeeded)
|
||||||
{
|
{
|
||||||
// ParticleEmitter::SetInfo (0x0051CE90) fails when the
|
// ParticleEmitter::SetInfo (0x0051CE90) fails when the
|
||||||
// authored hardware GfxObj cannot be loaded. It never
|
// authored hardware GfxObj cannot be loaded. It never
|
||||||
// substitutes the software GfxObj.
|
// substitutes the software GfxObj.
|
||||||
|
failure = resolved.Failure;
|
||||||
|
_failures.TryAdd(emitterId, failure);
|
||||||
desc = null!;
|
desc = null!;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
float maxDegradeDistance = resolvedDistance
|
desc = FromDat(emitterId, dat, resolved.MaxDegradeDistance);
|
||||||
?? RetailParticleDegradeDistance.DefaultDistance;
|
|
||||||
desc = FromDat(emitterId, dat, maxDegradeDistance);
|
|
||||||
_byId[emitterId] = desc;
|
_byId[emitterId] = desc;
|
||||||
|
failure = EmitterDescResolutionFailure.None;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
failure = new EmitterDescResolutionFailure(
|
||||||
|
EmitterDescResolutionFailureKind.MissingEmitterInfo,
|
||||||
|
emitterId);
|
||||||
|
_failures.TryAdd(emitterId, failure);
|
||||||
desc = null!;
|
desc = null!;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Count => _byId.Count;
|
public int Count => _byId.Count;
|
||||||
|
public int FailureCount => _failures.Count;
|
||||||
|
|
||||||
public static EmitterDesc FromDat(
|
public static EmitterDesc FromDat(
|
||||||
uint emitterId,
|
uint emitterId,
|
||||||
|
|
@ -155,25 +198,34 @@ public sealed class EmitterDescRegistry
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float? ResolveMaxDegradeDistance(
|
private static EmitterDatResolution ResolveMaxDegradeDistance(
|
||||||
IDatObjectSource dats,
|
IDatObjectSource dats,
|
||||||
DatParticleEmitter emitter)
|
DatParticleEmitter emitter)
|
||||||
{
|
{
|
||||||
uint gfxObjId = GetRetailHardwareGfxObjId(emitter);
|
uint gfxObjId = GetRetailHardwareGfxObjId(emitter);
|
||||||
if (gfxObjId == 0)
|
if (gfxObjId == 0)
|
||||||
return null;
|
{
|
||||||
|
return EmitterDatResolution.Fail(
|
||||||
|
EmitterDescResolutionFailureKind.InvalidHardwareGfxObjId);
|
||||||
|
}
|
||||||
|
|
||||||
DatGfxObj? gfx = SafeGet<DatGfxObj>(dats, gfxObjId);
|
DatGfxObj? gfx = SafeGet<DatGfxObj>(dats, gfxObjId);
|
||||||
if (gfx is null)
|
if (gfx is null)
|
||||||
return null;
|
{
|
||||||
|
return EmitterDatResolution.Fail(
|
||||||
|
EmitterDescResolutionFailureKind.MissingHardwareGfxObj,
|
||||||
|
gfxObjId);
|
||||||
|
}
|
||||||
if (!gfx.Flags.HasFlag(DatGfxObjFlags.HasDIDDegrade)
|
if (!gfx.Flags.HasFlag(DatGfxObjFlags.HasDIDDegrade)
|
||||||
|| gfx.DIDDegrade == 0)
|
|| gfx.DIDDegrade == 0)
|
||||||
{
|
{
|
||||||
return RetailParticleDegradeDistance.DefaultDistance;
|
return EmitterDatResolution.Success(
|
||||||
|
RetailParticleDegradeDistance.DefaultDistance);
|
||||||
}
|
}
|
||||||
|
|
||||||
DatGfxObjDegradeInfo? degrade = SafeGet<DatGfxObjDegradeInfo>(dats, gfx.DIDDegrade);
|
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)
|
internal static uint GetRetailHardwareGfxObjId(DatParticleEmitter emitter)
|
||||||
|
|
@ -216,6 +268,26 @@ public sealed class EmitterDescRegistry
|
||||||
DatParticleType.GlobalVelocity => ParticleType.GlobalVelocity,
|
DatParticleType.GlobalVelocity => ParticleType.GlobalVelocity,
|
||||||
_ => ParticleType.Unknown,
|
_ => 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>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
||||||
private readonly HashSet<uint> _examinationOwners = [];
|
private readonly HashSet<uint> _examinationOwners = [];
|
||||||
private readonly HashSet<uint> _hiddenPresentationOwners = [];
|
private readonly HashSet<uint> _hiddenPresentationOwners = [];
|
||||||
private readonly HashSet<uint> _stoppingOwners = [];
|
private readonly HashSet<uint> _stoppingOwners = [];
|
||||||
|
private readonly HashSet<uint> _reportedEmitterResolutionFailures = [];
|
||||||
|
|
||||||
// Production pose registries publish actual root/part/cell changes. Only
|
// Production pose registries publish actual root/part/cell changes. Only
|
||||||
// those owners are recomposed; static world emitters do no refresh work.
|
// those owners are recomposed; static world emitters do no refresh work.
|
||||||
|
|
@ -417,11 +418,16 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
||||||
partIndex,
|
partIndex,
|
||||||
renderPass,
|
renderPass,
|
||||||
visibilityPolicy,
|
visibilityPolicy,
|
||||||
out int handle))
|
out int handle,
|
||||||
|
out EmitterDescResolutionFailure failure))
|
||||||
{
|
{
|
||||||
DiagnosticSink?.Invoke(
|
if (_reportedEmitterResolutionFailures.Add(emitterInfoId))
|
||||||
$"ParticleEmitterInfo 0x{emitterInfoId:X8} for owner " +
|
{
|
||||||
$"0x{ownerLocalId:X8} was not found; no fallback was created.");
|
DiagnosticSink?.Invoke(FormatEmitterResolutionDiagnostic(
|
||||||
|
emitterInfoId,
|
||||||
|
ownerLocalId,
|
||||||
|
failure));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_system.UpdateEmitterOwnerPosition(handle, ownerPosition);
|
_system.UpdateEmitterOwnerPosition(handle, ownerPosition);
|
||||||
|
|
@ -534,6 +540,26 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
||||||
return true;
|
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(
|
private readonly record struct EmitterBinding(
|
||||||
uint OwnerLocalId,
|
uint OwnerLocalId,
|
||||||
int PartIndex,
|
int PartIndex,
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,29 @@ public sealed class ParticleSystem : IParticleSystem
|
||||||
ParticleRenderPass renderPass,
|
ParticleRenderPass renderPass,
|
||||||
ParticleVisibilityPolicy visibilityPolicy,
|
ParticleVisibilityPolicy visibilityPolicy,
|
||||||
out int handle)
|
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;
|
handle = 0;
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -191,6 +212,7 @@ public sealed class ParticleSystem : IParticleSystem
|
||||||
attachedPartIndex,
|
attachedPartIndex,
|
||||||
renderPass,
|
renderPass,
|
||||||
visibilityPolicy);
|
visibilityPolicy);
|
||||||
|
failure = EmitterDescResolutionFailure.None;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,29 @@ public sealed class ParticleHookSinkTests
|
||||||
Assert.Contains($"0x{Owner:X8}", diagnostic, StringComparison.Ordinal);
|
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]
|
[Fact]
|
||||||
public void MissingLivePose_HidesPresentationWithoutEndingEmitterLifetime()
|
public void MissingLivePose_HidesPresentationWithoutEndingEmitterLifetime()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Content;
|
||||||
using AcDream.Core.Vfx;
|
using AcDream.Core.Vfx;
|
||||||
|
using DatReaderWriter.Lib.IO;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|
@ -267,6 +269,35 @@ public sealed class ParticleSystemTests
|
||||||
Assert.Same(desc, reg.Get(0x32001234u));
|
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]
|
[Fact]
|
||||||
public void LocalVelocity_TransformsABySpawnRotation()
|
public void LocalVelocity_TransformsABySpawnRotation()
|
||||||
{
|
{
|
||||||
|
|
@ -398,6 +429,31 @@ public sealed class ParticleSystemTests
|
||||||
Assert.Equal(0u, EmitterDescRegistry.GetRetailHardwareGfxObjId(dat));
|
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]
|
[Fact]
|
||||||
public void RetailParticleDegradeDistance_MatchesRetailEntrySelection()
|
public void RetailParticleDegradeDistance_MatchesRetailEntrySelection()
|
||||||
{
|
{
|
||||||
|
|
@ -1100,4 +1156,31 @@ public sealed class ParticleSystemTests
|
||||||
sys.Tick(0.01f);
|
sys.Tick(0.01f);
|
||||||
Assert.Equal(2, sys.ActiveParticleCount);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,57 @@ using DatReaderWriter.Lib.IO;
|
||||||
using DatReaderWriter.Options;
|
using DatReaderWriter.Options;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
using DatAnimation = DatReaderWriter.DBObjs.Animation;
|
using DatAnimation = DatReaderWriter.DBObjs.Animation;
|
||||||
|
using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter;
|
||||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||||
|
|
||||||
string datDir = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
string datDir = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
?? @"C:\Turbine\Asheron's Call";
|
?? @"C:\Turbine\Asheron's Call";
|
||||||
|
|
||||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
var physicsScripts = new RetailPhysicsScriptLoader(dats);
|
using var boundedDats = new AcDream.Content.DatCollectionAdapter(dats);
|
||||||
var animations = new RetailAnimationLoader(dats);
|
var physicsScripts = new RetailPhysicsScriptLoader(boundedDats);
|
||||||
|
var animations = new RetailAnimationLoader(boundedDats);
|
||||||
Console.WriteLine($"DAT metadata audit: {datDir}");
|
Console.WriteLine($"DAT metadata audit: {datDir}");
|
||||||
var failures = new List<string>();
|
var failures = new List<string>();
|
||||||
var representativePes = new HashSet<uint>();
|
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)>
|
var expectedSetups = new Dictionary<uint, (Vector3 Origin, float Radius, int Length, string Sha256)>
|
||||||
{
|
{
|
||||||
[0x02000124u] = (Vector3.Zero, 0.1f, 812, "630D19A9C79137F7BA90D01448B03E8F44D655FF77A4725792CB5660930A079E"),
|
[0x02000124u] = (Vector3.Zero, 0.1f, 812, "630D19A9C79137F7BA90D01448B03E8F44D655FF77A4725792CB5660930A079E"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue