acdream/tests/AcDream.Core.Tests/Vfx/RetailParticleFacingTests.cs
Erik 18fce7bb5a 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>
2026-08-23 15:28:59 +02:00

177 lines
6 KiB
C#

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