acdream/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs
Erik ec1bbb4f43 feat(vfx): Phase C.1 — PES particle renderer + post-review fixes
Ports retail's ParticleEmitterInfo / Particle::Init / Particle::Update
(0x005170d0..0x0051d400) and PhysicsScript runtime to a C# data-layer
plus a Silk.NET billboard renderer. Sky-PES path is debug-only behind
ACDREAM_ENABLE_SKY_PES because named-retail decomp confirms GameSky
copies SkyObject.pes_id but never reads it (CreateDeletePhysicsObjects
0x005073c0, MakeObject 0x00506ee0, UseTime 0x005075b0).

Post-review fixes folded into this commit:

H1: AttachLocal (is_parent_local=1) follows live parent each frame.
    ParticleSystem.UpdateEmitterAnchor + ParticleHookSink.UpdateEntityAnchor
    let the owning subsystem refresh AnchorPos every tick — matches
    ParticleEmitter::UpdateParticles 0x0051d2d4 which re-reads the live
    parent frame when is_parent_local != 0. Drops the renderer-side
    cameraOffset hack that only worked when the parent was the camera.

H3: Strip the long stale comment in GfxObjMesh.cs that contradicted the
    retail-faithful (1 - translucency) opacity formula. The code was
    right; the comment was a leftover from an earlier hypothesis and
    would have invited a wrong "fix".

M1: SkyRenderer tracks textures whose wrap mode it set to ClampToEdge
    and restores them to Repeat at end-of-pass, so non-sky renderers
    that share the GL handle can't silently inherit clamped wrap state.

M2: Post-scene Z-offset (-120m) only fires when the SkyObject is
    weather-flagged AND bit 0x08 is clear, matching retail
    GameSky::UpdatePosition 0x00506dd0. The old code applied it to
    every post-scene object — a no-op today (every Dereth post-scene
    entry happens to be weather-flagged) but a future post-scene-only
    sun rim would have been pushed below the camera.

M4: ParticleSystem.EmitterDied event lets ParticleHookSink prune dead
    handles from the per-entity tracking dictionaries, fixing a slow
    leak where naturally-expired emitters' handles stayed in the
    ConcurrentBag forever during long sessions.

M5: SkyPesEntityId moves the post-scene flag bit to 0x08000000 so it
    can't ever overlap the object-index range. Synthetic IDs stay in
    the reserved 0xFxxxxxxx space.

New tests (ParticleSystemTests + ParticleHookSinkTests):
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed (retail-faithful
  single-emit-per-frame behavior)
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking

dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(up from 1325).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:47:11 +02:00

170 lines
5.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.World;
public sealed class SkyDescLoaderTests
{
/// <summary>
/// Hand-build a Region with a minimal sky descriptor to feed the
/// loader without needing real dat bytes. The LoadFromRegion
/// separator exists precisely for this — keeps the parsing logic
/// testable independent of DatCollection.
/// </summary>
private static Region MakeRegion(float dirBright, byte rBgrOrder)
{
var region = new Region();
region.PartsMask = PartsMask.HasSkyInfo;
var sky = new SkyDesc
{
TickSize = 1.0,
LightTickSize = 2.0,
};
var dg = new DayGroup
{
ChanceOfOccur = 1.0f,
};
var time = new SkyTimeOfDay
{
Begin = 0.5f,
DirBright = dirBright,
DirHeading = 180f,
DirPitch = 70f,
DirColor = new ColorARGB { Blue = 0, Green = 0, Red = rBgrOrder, Alpha = 255 },
AmbBright = 0.4f,
AmbColor = new ColorARGB { Blue = 100, Green = 100, Red = 100, Alpha = 255 },
MinWorldFog = 120f,
MaxWorldFog = 400f,
WorldFogColor = new ColorARGB { Blue = 50, Green = 50, Red = 50, Alpha = 255 },
WorldFog = 1, // Linear
};
dg.SkyTime.Add(time);
sky.DayGroups.Add(dg);
region.SkyInfo = sky;
return region;
}
[Fact]
public void LoadFromRegion_ConvertsFogFields()
{
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
var loaded = SkyDescLoader.LoadFromRegion(region);
Assert.NotNull(loaded);
Assert.Equal(1.0, loaded!.TickSize);
Assert.Single(loaded.DayGroups);
var grp = loaded.DayGroups[0];
Assert.Single(grp.SkyTimes);
var kf = grp.SkyTimes[0].Keyframe;
Assert.Equal(120f, kf.FogStart);
Assert.Equal(400f, kf.FogEnd);
Assert.Equal(FogMode.Linear, kf.FogMode);
}
[Fact]
public void LoadFromRegion_CapturesSkyObjectPesId()
{
var region = MakeRegion(dirBright: 1.0f, rBgrOrder: 255);
var dg = region.SkyInfo!.DayGroups[0];
dg.SkyObjects.Add(new SkyObject
{
BeginTime = 0f,
EndTime = 1f,
DefaultGfxObjectId = 0x01004C44u,
DefaultPesObjectId = 0x3300042Cu,
Properties = 0x05,
});
var loaded = SkyDescLoader.LoadFromRegion(region);
Assert.NotNull(loaded);
var obj = Assert.Single(loaded!.DayGroups[0].SkyObjects);
Assert.Equal(0x01004C44u, obj.GfxObjId);
Assert.Equal(0x3300042Cu, obj.PesObjectId);
Assert.True(obj.IsPostScene);
}
[Fact]
public void LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude()
{
// The loader stores DirColor and DirBright RAW. The SunColor property
// composes them via |sunVec| per retail's UpdateLightsInternal at
// 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²)
// where the sun vector is built from heading/pitch/brightness with
// Y unscaled by brightness (decomp 261352).
//
// For this region: H=180°, P=70°, B=1.5
// sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70))
// = (0, 0.342, 1.410)
// |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509
// DirColor.X = 200/255 = 0.7843
// SunColor.X = 0.7843 × 1.4509 = 1.138
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
var loaded = SkyDescLoader.LoadFromRegion(region);
Assert.NotNull(loaded);
var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
Assert.InRange(kf.SunColor.X, 1.13f, 1.15f);
}
[Fact]
public void LoadFromRegion_NoSkyInfo_ReturnsNull()
{
var region = new Region { PartsMask = 0 };
Assert.Null(SkyDescLoader.LoadFromRegion(region));
}
[Fact]
public void BuildDefaultProvider_FromDatKeyframes_SupportsInterpolation()
{
var region = MakeRegion(dirBright: 1.0f, rBgrOrder: 255);
var loaded = SkyDescLoader.LoadFromRegion(region)!;
var provider = loaded.BuildDefaultProvider();
// Exactly one keyframe: interpolation at any t returns it.
var s = provider.Interpolate(0.1f);
Assert.InRange(s.SunColor.X, 0.99f, 1.01f);
}
[Fact]
public void SkyObjectData_IsVisible_HandlesWrap()
{
var obj = new SkyObjectData
{
BeginTime = 0.9f, // wraps across midnight
EndTime = 0.1f,
};
Assert.True(obj.IsVisible(0.95f)); // near end of day
Assert.True(obj.IsVisible(0.05f)); // just after midnight
Assert.False(obj.IsVisible(0.5f)); // mid-day (not visible)
}
[Fact]
public void SkyObjectData_CurrentAngle_LerpsAcrossWindow()
{
var obj = new SkyObjectData
{
BeginTime = 0.25f,
EndTime = 0.75f,
BeginAngle = 0f,
EndAngle = 180f,
};
// Middle of the window → 90°.
Assert.Equal(90f, obj.CurrentAngle(0.5f), precision: 2);
// At begin → begin angle.
Assert.Equal(0f, obj.CurrentAngle(0.25f), precision: 2);
}
}