fix #28: port retail's sky default-script playback (aurora) and the particle facing law
The aurora was never missing data — it was a missing mechanism plus a misread. New decompile evidence closes the April-2026 contradiction: retail plays the sky carriers' PES through the Setup's own DefaultScript (GameSky::MakeObject @0x00506EE0 -> CPhysicsObj::makeObject @0x00513970 sets state|=0x80000; animate_static_object @0x00513DF0 ticks ScriptManager + ParticleManager). The pes_id column stays dead — that half of the April finding stands; the ids are byte-equal mirrors. - SkyPesFrameController is now the production owner (ACDREAM_ENABLE_SKY_PES deleted): script ids resolve from the Setup DefaultScript (SkyObjectData.DefaultScriptId; the pes_id column is a one-time-logged cross-check), slots persist by (index, gfx id, properties) per CreateDeletePhysicsObjects @0x005073C0 — a day-group swap keeping the carrier no longer restarts its emitters — and stale slots stop before replacements claim the slot-derived owner id. - RetailParticleFacing ports calc_draw_frame @0x0050DFA0: degrade mode 2 faces the viewer roll-free (set_vector_heading) instead of the camera plane; modes 3/4/5 spin the authored frame around one local axis (rotate_around_axis_to_vector) — Dereth authors 54 mode-5 emitters that previously got no facing at all; 1,583 mode-2 emitters get the exact law; authored/mode-1 paths are unchanged. - The 2026-08-23 'whole-sky tint' was the Rainy-group lightning/thunder PES playing at the debug anchor inside their 0.03-0.19 window, not the aurora: the aurora is nine faint viewer-facing glows pulsing on 6.7/15/55-minute rebirth cycles, in every day group, all day. Research: docs/research/2026-08-23-sky-default-script-port.md. Register: AD-112 filed (camera-anchored synthetic owners vs sky-cell physics objects). ISSUES #2 corrected (the playback ban is lifted by the new evidence); #28 fix landed pending the connected night gate. Tests: RetailParticleFacingTests (16), SkyPesFrameControllerTests (6); hermetic suites App 6,076/0, Core 4,905/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6f47740af0
commit
18fce7bb5a
13 changed files with 906 additions and 113 deletions
172
tests/AcDream.App.Tests/Rendering/SkyPesFrameControllerTests.cs
Normal file
172
tests/AcDream.App.Tests/Rendering/SkyPesFrameControllerTests.cs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Production sky default-script lifecycle per
|
||||
/// <c>GameSky::CreateDeletePhysicsObjects @0x005073C0</c>: a slot keeps its
|
||||
/// running script while (gfx id, properties) are unchanged — across day-group
|
||||
/// swaps included — and a mismatch or window exit stops it. Research:
|
||||
/// <c>docs/research/2026-08-23-sky-default-script-port.md</c>.
|
||||
/// </summary>
|
||||
public sealed class SkyPesFrameControllerTests
|
||||
{
|
||||
private const uint AuroraSetup = 0x02000714u;
|
||||
private const uint AuroraScript = 0x330007DBu;
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public readonly List<uint> ResolvedScriptIds = [];
|
||||
public readonly List<string> Diagnostics = [];
|
||||
public readonly PhysicsScriptRunner Runner;
|
||||
public readonly SkyPesFrameController Controller;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var registry = new EmitterDescRegistry();
|
||||
var system = new ParticleSystem(registry, new Random(42));
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
var sink = new ParticleHookSink(system, poses);
|
||||
Runner = new PhysicsScriptRunner(
|
||||
id =>
|
||||
{
|
||||
ResolvedScriptIds.Add(id);
|
||||
var script = new DatPhysicsScript();
|
||||
script.ScriptData.Add(new PhysicsScriptData
|
||||
{
|
||||
StartTime = 0.0,
|
||||
Hook = new SoundHook(),
|
||||
});
|
||||
return script;
|
||||
},
|
||||
sink);
|
||||
Controller = new SkyPesFrameController(
|
||||
Runner,
|
||||
sink,
|
||||
poses,
|
||||
effects: null,
|
||||
Diagnostics.Add);
|
||||
}
|
||||
}
|
||||
|
||||
private static SkyObjectData Carrier(
|
||||
uint gfxObjId = AuroraSetup,
|
||||
uint scriptId = AuroraScript,
|
||||
uint pesColumn = AuroraScript,
|
||||
uint properties = 0u,
|
||||
float begin = 0f,
|
||||
float end = 0f) => new()
|
||||
{
|
||||
GfxObjId = gfxObjId,
|
||||
DefaultScriptId = scriptId,
|
||||
PesObjectId = pesColumn,
|
||||
Properties = properties,
|
||||
BeginTime = begin,
|
||||
EndTime = end,
|
||||
};
|
||||
|
||||
private static DayGroupData Group(params SkyObjectData[] objects) => new()
|
||||
{
|
||||
Name = "Test",
|
||||
SkyObjects = objects,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void PlaysOncePerSlotAndPersistsAcrossEquivalentDayGroups()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
h.Controller.Update(0.1f, Group(Carrier()), Vector3.Zero);
|
||||
Assert.Equal([AuroraScript], h.ResolvedScriptIds);
|
||||
Assert.Equal(1, h.Runner.ActiveScriptCount);
|
||||
|
||||
// A different DayGroupData instance with the SAME slot identity is
|
||||
// retail's midnight day-group swap keeping the aurora carrier: the
|
||||
// running script must survive, not restart. The runner caches
|
||||
// resolved scripts, so the replay probe is ActiveScriptCount —
|
||||
// PlayDirect stacks one scheduled script per play.
|
||||
h.Controller.Update(0.9f, Group(Carrier()), Vector3.One * 10f);
|
||||
Assert.Equal(1, h.Runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlotIdentityChangeStopsTheOldScriptAndPlaysTheNew()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Controller.Update(0.1f, Group(Carrier()), Vector3.Zero);
|
||||
|
||||
h.Controller.Update(
|
||||
0.1f,
|
||||
Group(Carrier(gfxObjId: 0x02000589u, scriptId: 0x3300042Cu, pesColumn: 0x3300042Cu)),
|
||||
Vector3.Zero);
|
||||
|
||||
Assert.Equal([AuroraScript, 0x3300042Cu], h.ResolvedScriptIds);
|
||||
Assert.Equal(1, h.Runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeavingTheVisibilityWindowStopsTheScriptAndReentryReplays()
|
||||
{
|
||||
var h = new Harness();
|
||||
SkyObjectData windowed() => Carrier(begin: 0.2f, end: 0.4f);
|
||||
|
||||
h.Controller.Update(0.3f, Group(windowed()), Vector3.Zero);
|
||||
Assert.Equal(1, h.Runner.ActiveScriptCount);
|
||||
|
||||
h.Controller.Update(0.5f, Group(windowed()), Vector3.Zero);
|
||||
Assert.Equal(0, h.Runner.ActiveScriptCount);
|
||||
|
||||
// Re-entry replays (the runner's script cache absorbs the resolver
|
||||
// call; the fresh scheduled script is the observable).
|
||||
h.Controller.Update(0.25f, Group(windowed()), Vector3.Zero);
|
||||
Assert.Equal(1, h.Runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetupDefaultScriptIsTheSourceAndAMismatchedPesColumnLogsOnce()
|
||||
{
|
||||
var h = new Harness();
|
||||
var group = Group(Carrier(scriptId: AuroraScript, pesColumn: 0x33000999u));
|
||||
|
||||
h.Controller.Update(0.1f, group, Vector3.Zero);
|
||||
h.Controller.Update(0.2f, group, Vector3.Zero);
|
||||
|
||||
Assert.Equal([AuroraScript], h.ResolvedScriptIds);
|
||||
string diagnostic = Assert.Single(h.Diagnostics);
|
||||
Assert.Contains("0x330007DB", diagnostic, StringComparison.Ordinal);
|
||||
Assert.Contains("0x33000999", diagnostic, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CarriersWithoutADefaultScriptNeverPlay()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Controller.Update(
|
||||
0.1f,
|
||||
Group(Carrier(gfxObjId: 0x010015F0u, scriptId: 0u, pesColumn: 0u)),
|
||||
Vector3.Zero);
|
||||
|
||||
Assert.Empty(h.ResolvedScriptIds);
|
||||
Assert.Equal(0, h.Runner.ActiveScriptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullDayGroupStopsEverything()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Controller.Update(0.1f, Group(Carrier()), Vector3.Zero);
|
||||
Assert.Equal(1, h.Runner.ActiveScriptCount);
|
||||
|
||||
h.Controller.Update(0.1f, null, Vector3.Zero);
|
||||
Assert.Equal(0, h.Runner.ActiveScriptCount);
|
||||
}
|
||||
}
|
||||
|
|
@ -156,7 +156,6 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.False(opts.UncappedRendering);
|
||||
Assert.False(opts.DumpMoveTruth);
|
||||
Assert.False(opts.NoAudio);
|
||||
Assert.False(opts.EnableSkyPesDebug);
|
||||
Assert.Equal(-1, opts.HidePartIndex);
|
||||
// Default-on: RetailCloseDegrades is true unless explicitly disabled.
|
||||
Assert.True(opts.RetailCloseDegrades);
|
||||
|
|
@ -508,7 +507,6 @@ public sealed class RuntimeOptionsTests
|
|||
["ACDREAM_DUMP_MOVE_TRUTH"] = "1",
|
||||
["ACDREAM_DUMP_SKY"] = "1",
|
||||
["ACDREAM_NO_AUDIO"] = "1",
|
||||
["ACDREAM_ENABLE_SKY_PES"] = "1",
|
||||
["ACDREAM_DUMP_SCENERY_Z"] = "1",
|
||||
["ACDREAM_DUMP_CLOTHING"] = "1",
|
||||
}));
|
||||
|
|
@ -517,7 +515,6 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.True(allOn.DumpMoveTruth);
|
||||
Assert.True(allOn.DumpSky);
|
||||
Assert.True(allOn.NoAudio);
|
||||
Assert.True(allOn.EnableSkyPesDebug);
|
||||
Assert.True(allOn.DumpSceneryZ);
|
||||
Assert.True(allOn.DumpClothing);
|
||||
|
||||
|
|
@ -529,7 +526,6 @@ public sealed class RuntimeOptionsTests
|
|||
["ACDREAM_UNCAPPED_RENDER"] = "true",
|
||||
["ACDREAM_DUMP_MOVE_TRUTH"] = "yes",
|
||||
["ACDREAM_NO_AUDIO"] = "2",
|
||||
["ACDREAM_ENABLE_SKY_PES"] = "on",
|
||||
["ACDREAM_DUMP_SCENERY_Z"] = " 1",
|
||||
["ACDREAM_DUMP_CLOTHING"] = "true",
|
||||
}));
|
||||
|
|
@ -537,7 +533,6 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.False(anyOther.UncappedRendering);
|
||||
Assert.False(anyOther.DumpMoveTruth);
|
||||
Assert.False(anyOther.NoAudio);
|
||||
Assert.False(anyOther.EnableSkyPesDebug);
|
||||
Assert.False(anyOther.DumpSceneryZ);
|
||||
Assert.False(anyOther.DumpClothing);
|
||||
}
|
||||
|
|
|
|||
177
tests/AcDream.Core.Tests/Vfx/RetailParticleFacingTests.cs
Normal file
177
tests/AcDream.Core.Tests/Vfx/RetailParticleFacingTests.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Vfx;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Retail facing law from <c>CPhysicsPart::calc_draw_frame @0x0050DFA0</c>:
|
||||
/// mode 2 faces the viewer roll-free (<c>Frame::set_vector_heading</c>),
|
||||
/// modes 3/4/5 spin the authored frame around one local axis toward the
|
||||
/// viewer (<c>Frame::rotate_around_axis_to_vector</c>), every other mode
|
||||
/// keeps the authored orientation.
|
||||
/// </summary>
|
||||
public sealed class RetailParticleFacingTests
|
||||
{
|
||||
private const float Eps = 1e-4f;
|
||||
|
||||
private static void AssertVector(Vector3 expected, Vector3 actual)
|
||||
{
|
||||
Assert.True(
|
||||
Vector3.Distance(expected, actual) < 1e-3f,
|
||||
$"expected {expected}, got {actual}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, false)]
|
||||
[InlineData(1u, false)]
|
||||
[InlineData(2u, true)]
|
||||
[InlineData(3u, true)]
|
||||
[InlineData(4u, true)]
|
||||
[InlineData(5u, true)]
|
||||
[InlineData(6u, false)]
|
||||
public void Faces_MatchesRetailModeWindow(uint mode, bool expected)
|
||||
=> Assert.Equal(expected, RetailParticleFacing.Faces(mode));
|
||||
|
||||
[Fact]
|
||||
public void Mode2_ViewerNorth_QuadXStaysEastAndYIsWorldUp()
|
||||
{
|
||||
(Vector3 xd, Vector3 yd) = RetailParticleFacing.OrientQuad(
|
||||
2u,
|
||||
Quaternion.Identity,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitY,
|
||||
toViewerUnit: Vector3.UnitY,
|
||||
fallbackRight: Vector3.UnitX,
|
||||
fallbackUp: Vector3.UnitZ);
|
||||
|
||||
AssertVector(Vector3.UnitX, xd);
|
||||
AssertVector(Vector3.UnitZ, yd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_QuadPlaneIsPerpendicularToViewerWithNormalTowardThem()
|
||||
{
|
||||
Vector3 toViewer = Vector3.Normalize(new Vector3(0.4f, -0.7f, 0.59f));
|
||||
(Vector3 xd, Vector3 yd) = RetailParticleFacing.OrientQuad(
|
||||
2u,
|
||||
Quaternion.Identity,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitY,
|
||||
toViewer,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitZ);
|
||||
|
||||
Assert.True(MathF.Abs(Vector3.Dot(xd, toViewer)) < Eps);
|
||||
Assert.True(MathF.Abs(Vector3.Dot(yd, toViewer)) < Eps);
|
||||
// Roll-free: the X span stays horizontal.
|
||||
Assert.True(MathF.Abs(xd.Z) < Eps);
|
||||
// The quad plane is exactly perpendicular to the viewer direction;
|
||||
// the winding puts the geometric normal on the far side, which is
|
||||
// presentation-neutral because retail's sprite polys carry the same
|
||||
// surface on both faces (posSurf == negSurf, double-sided).
|
||||
Assert.True(MathF.Abs(Vector3.Dot(Vector3.Cross(xd, yd), toViewer)) > 0.99f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode2_ViewerStraightOverhead_FallsBackToCameraPlane()
|
||||
{
|
||||
var fallbackRight = Vector3.Normalize(new Vector3(1f, 1f, 0f));
|
||||
var fallbackUp = Vector3.UnitZ;
|
||||
(Vector3 xd, Vector3 yd) = RetailParticleFacing.OrientQuad(
|
||||
2u,
|
||||
Quaternion.Identity,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitY,
|
||||
toViewerUnit: Vector3.UnitZ,
|
||||
fallbackRight,
|
||||
fallbackUp);
|
||||
|
||||
AssertVector(fallbackRight, xd);
|
||||
AssertVector(fallbackUp, yd);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u)]
|
||||
[InlineData(1u)]
|
||||
[InlineData(7u)]
|
||||
public void NonFacingModes_KeepTheAuthoredOrientation(uint mode)
|
||||
{
|
||||
var orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ, MathF.PI / 2f);
|
||||
(Vector3 xd, Vector3 yd) = RetailParticleFacing.OrientQuad(
|
||||
mode,
|
||||
orientation,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitZ,
|
||||
toViewerUnit: Vector3.UnitY,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitZ);
|
||||
|
||||
AssertVector(Vector3.UnitY, xd); // +X yawed 90° -> +Y
|
||||
AssertVector(Vector3.UnitZ, yd); // spin axis unchanged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode5_SpinsAroundLocalZUntilTheNormalFacesTheViewer()
|
||||
{
|
||||
// Authored X-Z plane quad: normal = cross(+X, +Z) = -Y. Viewer east.
|
||||
(Vector3 xd, Vector3 yd) = RetailParticleFacing.OrientQuad(
|
||||
5u,
|
||||
Quaternion.Identity,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitZ,
|
||||
toViewerUnit: Vector3.UnitX,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitZ);
|
||||
|
||||
AssertVector(Vector3.UnitZ, yd); // constrained axis untouched
|
||||
AssertVector(Vector3.UnitX, Vector3.Cross(xd, yd));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode4_ViewerAlongTheConstrainedAxis_KeepsAuthoredOrientation()
|
||||
{
|
||||
// Constrained to local Y; the viewer sits along that axis, so no
|
||||
// in-plane target exists and retail leaves the frame alone.
|
||||
(Vector3 xd, Vector3 yd) = RetailParticleFacing.OrientQuad(
|
||||
4u,
|
||||
Quaternion.Identity,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitZ,
|
||||
toViewerUnit: Vector3.UnitY,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitZ);
|
||||
|
||||
AssertVector(Vector3.UnitX, xd);
|
||||
AssertVector(Vector3.UnitZ, yd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mode3_HonorsTheParticleOrientationWhenSpinning()
|
||||
{
|
||||
// Yaw the whole frame 90° about Z first; constrain to the frame's
|
||||
// local X (now world +Y). The spun normal must land in the plane
|
||||
// perpendicular to that axis, as close to the viewer as allowed.
|
||||
var orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ, MathF.PI / 2f);
|
||||
Vector3 axisWorld = Vector3.Transform(Vector3.UnitX, orientation);
|
||||
Vector3 toViewer = Vector3.Normalize(new Vector3(0.3f, 0.1f, 0.95f));
|
||||
|
||||
(Vector3 xd, Vector3 yd) = RetailParticleFacing.OrientQuad(
|
||||
3u,
|
||||
orientation,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitZ,
|
||||
toViewer,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitZ);
|
||||
|
||||
Vector3 normal = Vector3.Normalize(Vector3.Cross(xd, yd));
|
||||
Vector3 targetInPlane = Vector3.Normalize(
|
||||
toViewer - axisWorld * Vector3.Dot(toViewer, axisWorld));
|
||||
Assert.True(MathF.Abs(Vector3.Dot(normal, axisWorld)) < 1e-3f);
|
||||
Assert.True(Vector3.Dot(normal, targetInPlane) > 0.999f);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue