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
|
|
@ -16095,7 +16095,7 @@ additional flash-shader work).
|
|||
|
||||
**Description:** Lightning/storm sky visuals still do not match retail. A 2026-04-28 named-retail recheck disproved the prior assumption that `SkyObject.PesObjectId` drives sky-render flash particles: `SkyDesc::GetSky` copies the field into `CelestialPosition.pes_id`, but `GameSky::CreateDeletePhysicsObjects`, `GameSky::MakeObject`, and `GameSky::UseTime` never read it.
|
||||
|
||||
**Root cause / status:** Open again. The sky-PES path is non-retail and must stay disabled for normal rendering. The remaining mismatch likely lives in the sky/weather mesh material path, the lightning/fog flash path, or another weather subsystem outside `GameSky`; do not reintroduce per-SkyObject PES playback without new decompile evidence.
|
||||
**Root cause / status:** CORRECTED 2026-08-23 — the "must stay disabled" ban is lifted by new decompile evidence: retail DOES play the sky carriers' PES, via the Setup `DefaultScript` → `CPhysicsObj::makeObject` default-script path (never via the `pes_id` column, which remains dead — that half of the April finding stands). Sky default-script playback is production as of the C.1.5c port (`SkyPesFrameController`, research `2026-08-23-sky-default-script-port.md`). The lightning-flash carriers (`0x02000BA6` → `0x33000453`, windows 0.03–0.19 / 0.40–0.50 / 0.91–0.98 on Rainy groups) now fire through it; whether the flash PRESENTATION matches retail (sky-wide crossfade vs a sprite) still needs its own storm-window gate.
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — sky/weather mesh draw, material state, pre/post split
|
||||
|
|
@ -16142,11 +16142,26 @@ additional flash-shader work).
|
|||
|
||||
## #28 — Aurora ("northern lights") effect not rendered
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** FIX LANDED 2026-08-23 — awaiting the connected visual gate
|
||||
**Severity:** LOW (aesthetic feature-parity)
|
||||
**Filed:** 2026-04-26
|
||||
**Component:** sky / vfx
|
||||
|
||||
**2026-08-23 resolution (the C.1.5c mechanism, found):** the sky PES ids ride
|
||||
the carrier Setups' own `DefaultScript` (byte-equal to the dead `pes_id`
|
||||
column) and retail plays them through the ordinary default-script machinery —
|
||||
`GameSky::MakeObject @0x00506EE0` → `CPhysicsObj::makeObject @0x00513970`
|
||||
(`state |= 0x80000`) → `CPhysicsObj::animate_static_object @0x00513DF0`. The
|
||||
aurora carrier (`0x02000714` → PES `0x330007DB`) is present in ALL 20 day
|
||||
groups around the clock; visibility is purely "faint additive glows over a
|
||||
dark sky", pulsing on the emitters' 6.7/15/55-min rebirth cycles. The April
|
||||
"colored wash" experiment was run inside a Rainy day group's lightning window
|
||||
— the flash/thunder PES at the camera anchor, not the aurora, made the wash.
|
||||
`SkyPesFrameController` is now the production owner (no env flag), with
|
||||
retail's slot-identity persistence and the `calc_draw_frame @0x0050DFA0`
|
||||
facing law in the particle renderer. Full chain:
|
||||
`docs/research/2026-08-23-sky-default-script-port.md`; register AD-112.
|
||||
|
||||
**Description:** Retail renders a dynamic colored "light play" effect in the sky during certain Rainy/Cloudy DayGroup time windows. The user describes it as aurora-borealis-style. acdream renders no comparable effect.
|
||||
|
||||
**Root cause / status:** Open again. The prior root cause was wrong: `CelestialPosition.pes_id` exists in the retail header and is populated by `SkyDesc::GetSky`, but named retail `GameSky` code does not read it during sky object creation, update, or draw. A 2026-04-28 C.1 experiment that played those PES ids produced colored blobs/wash that did not match retail's broad aurora-like rays, and the path is now debug-only behind `ACDREAM_ENABLE_SKY_PES=1`.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
119
docs/research/2026-08-23-sky-default-script-port.md
Normal file
119
docs/research/2026-08-23-sky-default-script-port.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Sky default-script (aurora/lightning/thunder) — the retail mechanism, proven
|
||||
|
||||
**Date:** 2026-08-23 · **Issues:** #28 (aurora), #2 (lightning, partial), #29 (clouds — not addressed here) · **Phase:** C.1.5c
|
||||
|
||||
## The April contradiction, resolved
|
||||
|
||||
Two prior research passes (`2026-04-23-sky-pes-wiring.md`, `2026-04-28-pes-pseudocode.md`)
|
||||
correctly proved `GameSky` never **reads** `CelestialPosition.pes_id`
|
||||
(`SkyDesc::GetSky @0x00501EC0` writes it at 0x00501FC9; no reader exists), and
|
||||
issue #2 therefore banned per-SkyObject PES playback "without new decompile
|
||||
evidence". This document is that evidence. Both were right and both missed the
|
||||
actual route:
|
||||
|
||||
**The sky PES ids ride the sky Setups' own `DefaultScript`, and retail plays
|
||||
them through the ordinary object default-script machinery — the `pes_id`
|
||||
column is a dead mirror of the same ids.**
|
||||
|
||||
Verified in the installed Dereth DAT: Setup `0x02000714` (aurora carrier,
|
||||
parts `0x010001EC`) has `DefaultScript = 0x330007DB` — byte-equal to its
|
||||
SkyObject's `PesObjectId`. Same holds for `0x02000589→0x3300042C` (thunder
|
||||
ping-pong), `0x02000588→0x33000428` (thunder variant), `0x02000BA6→0x33000453`
|
||||
(lightning flash).
|
||||
|
||||
## The retail chain (named decomp, every link read this session)
|
||||
|
||||
1. `GameSky::UseTime @0x005075B0` (30 Hz) → `CRegionDesc::GetSky` →
|
||||
`SkyDesc::GetSky @0x00501EC0` rebuilds the `CelestialPosition` list.
|
||||
Per-object visibility: **if `begin_time == end_time` the object is always
|
||||
included**; otherwise `begin <= t <= end` gates the gfx id (out-of-window →
|
||||
`INVALID_DID`). The aurora object has `begin=end=0.00` in **all 20 day
|
||||
groups** → always present, all day.
|
||||
2. `GameSky::CreateDeletePhysicsObjects @0x005073C0`: an existing sky object
|
||||
is **kept** when its current DataID equals the wanted gfx id AND the
|
||||
properties word is unchanged AND (`props & 4` → `LScape::weather_enabled`
|
||||
did not flip). Only a mismatch destroys/recreates. ⇒ the aurora object —
|
||||
same id in every group — **persists across day-group changes**, and its
|
||||
emitters keep their particle population.
|
||||
3. `GameSky::MakeObject @0x00506EE0`: `props & 4` objects are only created
|
||||
when `LScape::weather_enabled != 0`; `props & 1` selects `after_sky_cell`
|
||||
(post-scene) vs `before_sky_cell`. Creation is
|
||||
`CPhysicsObj::makeObject(gfx_id, 0, 0)`.
|
||||
4. `CPhysicsObj::makeObject @0x00513970` → `InitPartArrayObject`: a Setup
|
||||
with `default_script_id != 0` sets `state |= 0x80000` and registers via
|
||||
`CPhysics::AddStaticAnimatingObject @0x00509AF0`.
|
||||
5. `CPhysicsObj::animate_static_object @0x00513DF0` (per tick): state 0x80000
|
||||
→ `ScriptManager::UpdateScripts` (starts/advances the default script — the
|
||||
PES) and updates the object's `ParticleManager`. This is the ~150/min
|
||||
`CallPES` churn the 2026-04-30 live trace counted (the thunder PES
|
||||
`0x3300042C` ping-pongs via `CallPES` chains).
|
||||
|
||||
## The particle laws confirmed against our port
|
||||
|
||||
- `ParticleEmitterInfo::GetRandomOffset @0x005174A0`: random vector, **minus
|
||||
its projection onto `offset_dir`**, normalized, × rand[min,max] — a disk
|
||||
PERPENDICULAR to the dir (a shell when dir is zero). Our
|
||||
`ParticleSystem.RandomOffset` is byte-faithful. The aurora's 450–700 m
|
||||
"Z-dir" offsets are therefore a horizontal RING around the sky-object
|
||||
origin, not a column above it.
|
||||
- `Particle::Update @0x0051C290` writes **only the part origin** per type
|
||||
formula (our `ComputePosition` matches, incl. Swarm cos/sin); only the
|
||||
GR/LR parabolic variants rotate.
|
||||
- `ParticleEmitter::SetInfo @0x0051CE90`: parts are ordinary
|
||||
`CPhysicsPart::makePhysicsPart(hw_gfxobj_id)`; there is no special "2D
|
||||
particle" draw. `CPhysicsPart::Draw @0x0050D7A0` always feeds
|
||||
`DrawMesh(gfxobj[deg_level], &draw_pos)`.
|
||||
- **The facing law** — `CPhysicsPart::calc_draw_frame @0x0050DFA0`, driven by
|
||||
the FIRST degrade entry's mode (`GfxObjDegradeInfo::get_degrade
|
||||
@0x0051E4B0`; `viewer_heading` = normalized part→viewer from
|
||||
`UpdateViewerDistance @0x0050E030`):
|
||||
|
||||
```
|
||||
draw = pos
|
||||
switch deg_mode:
|
||||
2: Frame::set_vector_heading(draw, viewer_heading) // face viewer, roll-free
|
||||
3/4/5: Frame::rotate_around_axis_to_vector(draw, X/Y/Z, vh) // cylindrical, one free axis
|
||||
else: authored orientation (mode 1, mode 0, out of range)
|
||||
```
|
||||
|
||||
Our renderer camera-plane-aligned every "billboard" particle
|
||||
(`cameraRight`/`cameraUp`); retail faces each part toward the viewer
|
||||
per-part and honors constrained modes. `CPhysicsPart::Always2D @0x0050D8A0`
|
||||
(mode != 1) is only consulted for **cell membership**
|
||||
(`CLandCell::add_all_outside_cells @0x00533360`), not drawing.
|
||||
|
||||
- The aurora emitters (`0x32000455/56/57`): BirthratePerSec 10, max 3,
|
||||
initial 3, lifespans 3300/900/400 s, StartTrans 0.8 → FinalTrans 1.0
|
||||
(≤20 % opacity fading to nothing), StartScale 7–8, sprites
|
||||
`0x01001A61..63` = single ±137.5 m quads whose 64×64 additive textures are
|
||||
soft glow blobs (row/col profiled — no banding; peak RGB 36–71/255). First
|
||||
degrade mode = **2** on all three. The visible aurora is therefore nine
|
||||
huge, faint, viewer-facing glows in a slow Swarm drift — a pulse that
|
||||
re-brightens when a cohort is reborn (~6.7/15/55 min cycles), not a steady
|
||||
fixture.
|
||||
|
||||
## Why the 2026-08-23 experiment looked like "whole-sky tint"
|
||||
|
||||
The debug controller (`ACDREAM_ENABLE_SKY_PES=1`) ran under day group 16
|
||||
(Rainy) at t=0.125 — inside the lightning window (0.03–0.19) — so the
|
||||
**lightning-flash and thunder PES** played at the camera anchor alongside the
|
||||
aurora. A flash sprite at the anchor IS a full-screen additive wash. The
|
||||
aurora itself was drowned under a faithful-but-wrong-conditions storm.
|
||||
|
||||
## Port deltas (this change)
|
||||
|
||||
1. Sky default-script playback becomes **production** (no env flag): every
|
||||
visible sky object whose Setup carries a `DefaultScript` plays it through
|
||||
`PhysicsScriptRunner`, anchored at the camera (retail sky-cell space is
|
||||
viewer-centered), pass-routed by `props & 1`.
|
||||
2. **Persistence contract**: script/emitter state is keyed by
|
||||
(gfx id, properties) per `CreateDeletePhysicsObjects` — a day-group flip
|
||||
that keeps the same carrier Setup must NOT restart its emitters.
|
||||
3. Weather gating: `props & 4` objects follow the weather-enabled state
|
||||
(acdream's weather system), matching `MakeObject`'s guard.
|
||||
4. `calc_draw_frame` facing law in the particle renderer: per-sprite degrade
|
||||
mode picks face-viewer (2), axis-constrained (3/4/5), or authored (else)
|
||||
orientation — replacing the blanket camera-plane alignment.
|
||||
|
||||
Script ids resolve from the **Setup's DefaultScript** (the retail source);
|
||||
the `PesObjectId` column is only a cross-check.
|
||||
|
|
@ -329,7 +329,8 @@ internal sealed class FrameRootCompositionPhase
|
|||
content.ScriptRunner,
|
||||
content.ParticleSink,
|
||||
d.EffectPoses,
|
||||
live.EntityEffects);
|
||||
live.EntityEffects,
|
||||
d.Log);
|
||||
IWorldSceneFramePhase? worldSceneRenderer = null;
|
||||
CurrentRenderSceneOracle? currentRenderSceneOracle =
|
||||
interaction.RetainedUi?.Screenshots is not null
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
private readonly Dictionary<uint, ParticleGfxInfo> _particleGfxInfoByGfxObj = new();
|
||||
private readonly Dictionary<int, ParticleGfxInfo> _particleGfxInfoByEmitter = new();
|
||||
private readonly Dictionary<uint, RetailParticleGeometryKind> _geometryKindByGfxObj = new();
|
||||
private readonly Dictionary<uint, uint?> _firstDegradeModeByGfxObj = new();
|
||||
private readonly Dictionary<uint, TranslucencyKind> _meshBlendBySurface = new();
|
||||
private readonly ParticleMeshReferenceTracker? _meshReferences;
|
||||
private readonly ParticleEmitterRetirementTracker _emitterRetirements;
|
||||
|
|
@ -400,18 +401,75 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
var key = new BatchKey(additive);
|
||||
Vector3 axisX;
|
||||
Vector3 axisY;
|
||||
Vector3 toViewer = cameraWorldPos - pos;
|
||||
float toViewerLength = toViewer.Length();
|
||||
if (gfxInfo.IsBillboard)
|
||||
{
|
||||
pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size);
|
||||
axisX = cameraRight * (gfxInfo.Size.X * p.Size);
|
||||
axisY = cameraUp * (gfxInfo.Size.Y * p.Size);
|
||||
// Degrade mode 2 — face the viewer roll-free
|
||||
// (CPhysicsPart::calc_draw_frame @0x0050DFA0 via
|
||||
// Frame::set_vector_heading), not the camera plane: the two
|
||||
// agree at screen centre and diverge toward the edges and
|
||||
// overhead, where retail's sprites tilt toward the viewer.
|
||||
Vector3 xd;
|
||||
Vector3 yd;
|
||||
if (toViewerLength > 1e-3f)
|
||||
{
|
||||
(xd, yd) = RetailParticleFacing.OrientQuad(
|
||||
2u,
|
||||
Quaternion.Identity,
|
||||
Vector3.UnitX,
|
||||
Vector3.UnitY,
|
||||
toViewer / toViewerLength,
|
||||
cameraRight,
|
||||
cameraUp);
|
||||
}
|
||||
else
|
||||
{
|
||||
(xd, yd) = (cameraRight, cameraUp);
|
||||
}
|
||||
|
||||
// The sprite's authored (X, Z) plane rides the quad axes; the
|
||||
// out-of-plane component (authored Y, ~0 on flat sprites) is
|
||||
// dropped rather than pushed along the view direction.
|
||||
pos += (xd * gfxInfo.CenterOffset.X
|
||||
+ yd * gfxInfo.CenterOffset.Z) * p.Size;
|
||||
axisX = xd * (gfxInfo.Size.X * p.Size);
|
||||
axisY = yd * (gfxInfo.Size.Y * p.Size);
|
||||
}
|
||||
else
|
||||
{
|
||||
Quaternion orientation = ParticleOrientation(em, p);
|
||||
pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation);
|
||||
axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size);
|
||||
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
|
||||
if (RetailParticleFacing.Faces(gfxInfo.DegradeMode)
|
||||
&& toViewerLength > 1e-3f)
|
||||
{
|
||||
// Modes 3/4/5 — authored geometry spun around one local
|
||||
// axis toward the viewer
|
||||
// (Frame::rotate_around_axis_to_vector).
|
||||
(Vector3 xd, Vector3 yd) = RetailParticleFacing.OrientQuad(
|
||||
gfxInfo.DegradeMode,
|
||||
orientation,
|
||||
gfxInfo.AxisX,
|
||||
gfxInfo.AxisY,
|
||||
toViewer / toViewerLength,
|
||||
cameraRight,
|
||||
cameraUp);
|
||||
Vector3 localNormal = Vector3.Cross(gfxInfo.AxisX, gfxInfo.AxisY);
|
||||
Vector3 spunNormal = Vector3.Cross(xd, yd);
|
||||
if (spunNormal.LengthSquared() > 1e-10f)
|
||||
spunNormal = Vector3.Normalize(spunNormal);
|
||||
Vector3 c = gfxInfo.CenterOffset;
|
||||
pos += (xd * Vector3.Dot(c, gfxInfo.AxisX)
|
||||
+ yd * Vector3.Dot(c, gfxInfo.AxisY)
|
||||
+ spunNormal * Vector3.Dot(c, localNormal)) * p.Size;
|
||||
axisX = xd * (gfxInfo.Size.X * p.Size);
|
||||
axisY = yd * (gfxInfo.Size.Y * p.Size);
|
||||
}
|
||||
else
|
||||
{
|
||||
pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation);
|
||||
axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size);
|
||||
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
|
||||
}
|
||||
}
|
||||
|
||||
float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
|
||||
|
|
@ -576,7 +634,23 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
if (_geometryKindByGfxObj.TryGetValue(gfxObjId, out RetailParticleGeometryKind kind))
|
||||
return kind;
|
||||
|
||||
uint? firstDegradeMode = null;
|
||||
kind = RetailParticleGeometryClassifier.Classify(
|
||||
ResolveFirstDegradeMode(gfxObjId));
|
||||
_geometryKindByGfxObj[gfxObjId] = kind;
|
||||
return kind;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sprite's FIRST degrade entry's mode — retail's facing selector
|
||||
/// (<c>GfxObjDegradeInfo::get_degrade @0x0051E4B0</c> feeding
|
||||
/// <c>CPhysicsPart::calc_draw_frame @0x0050DFA0</c>). Null when the
|
||||
/// GfxObj has no degrade table.
|
||||
/// </summary>
|
||||
private uint? ResolveFirstDegradeMode(uint gfxObjId)
|
||||
{
|
||||
if (_firstDegradeModeByGfxObj.TryGetValue(gfxObjId, out uint? mode))
|
||||
return mode;
|
||||
|
||||
try
|
||||
{
|
||||
if (_dats?.Get<GfxObj>(gfxObjId) is { } gfx
|
||||
|
|
@ -584,7 +658,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
&& gfx.DIDDegrade != 0
|
||||
&& _dats.Get<GfxObjDegradeInfo>(gfx.DIDDegrade) is { Degrades.Count: > 0 } degrade)
|
||||
{
|
||||
firstDegradeMode = degrade.Degrades[0].DegradeMode;
|
||||
mode = degrade.Degrades[0].DegradeMode;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -596,9 +670,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
$"[particle-geometry] Failed to decode GfxObj 0x{gfxObjId:X8} degrade metadata: {ex.Message}");
|
||||
}
|
||||
|
||||
kind = RetailParticleGeometryClassifier.Classify(firstDegradeMode);
|
||||
_geometryKindByGfxObj[gfxObjId] = kind;
|
||||
return kind;
|
||||
_firstDegradeModeByGfxObj[gfxObjId] = mode;
|
||||
return mode;
|
||||
}
|
||||
|
||||
private void OnEmitterDied(int handle)
|
||||
|
|
@ -672,7 +745,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
texture: AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned,
|
||||
additive,
|
||||
hasMaterial: surfaceId != 0,
|
||||
surfaceId: surfaceId);
|
||||
surfaceId: surfaceId,
|
||||
degradeMode: ResolveFirstDegradeMode(gfxObjId) ?? 0u);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -685,7 +759,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
AcDream.App.Rendering.Gpu.GpuTextureSlot texture,
|
||||
bool additive,
|
||||
bool hasMaterial,
|
||||
uint surfaceId)
|
||||
uint surfaceId,
|
||||
uint degradeMode)
|
||||
{
|
||||
if (gfx.VertexArray.Vertices.Count == 0)
|
||||
return ParticleGfxInfo.Billboard(
|
||||
|
|
@ -779,24 +854,12 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
false,
|
||||
additive,
|
||||
hasMaterial,
|
||||
surfaceId);
|
||||
surfaceId,
|
||||
degradeMode);
|
||||
}
|
||||
|
||||
private bool IsPointSprite(GfxObj gfx)
|
||||
{
|
||||
if (!gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || gfx.DIDDegrade == 0 || _dats is null)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var degrade = _dats.Get<GfxObjDegradeInfo>(gfx.DIDDegrade);
|
||||
return degrade?.Degrades.Count > 0 && degrade.Degrades[0].DegradeMode == 2;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
=> ResolveFirstDegradeMode(gfx.Id) == 2u;
|
||||
|
||||
private static float FallbackParticleExtent(float value)
|
||||
=> value > 1e-4f ? Math.Clamp(value, 1e-4f, 10_000f) : 1f;
|
||||
|
|
@ -886,10 +949,21 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
_particleGfxInfoByEmitter.Clear();
|
||||
_particleGfxInfoByGfxObj.Clear();
|
||||
_geometryKindByGfxObj.Clear();
|
||||
_firstDegradeModeByGfxObj.Clear();
|
||||
_meshBlendBySurface.Clear();
|
||||
_deferredAlpha.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="DegradeMode"/> is the sprite GfxObj's FIRST degrade
|
||||
/// entry's mode — retail's facing selector
|
||||
/// (<c>CPhysicsPart::calc_draw_frame @0x0050DFA0</c>, see
|
||||
/// <see cref="AcDream.Core.Vfx.RetailParticleFacing"/>). Mode 2 sprites
|
||||
/// take the <paramref name="IsBillboard"/> quad path (face viewer,
|
||||
/// roll-free); modes 3–5 keep authored geometry but spin around one
|
||||
/// local axis toward the viewer; every other mode draws authored.
|
||||
/// Synthetic texture-only billboards carry mode 2 by construction.
|
||||
/// </summary>
|
||||
private readonly record struct ParticleGfxInfo(
|
||||
AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot,
|
||||
Vector2 Size,
|
||||
|
|
@ -899,7 +973,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
bool IsBillboard,
|
||||
bool Additive,
|
||||
bool HasMaterial,
|
||||
uint SurfaceId)
|
||||
uint SurfaceId,
|
||||
uint DegradeMode)
|
||||
{
|
||||
public static ParticleGfxInfo Default { get; } =
|
||||
Billboard(
|
||||
|
|
@ -926,6 +1001,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
true,
|
||||
additive,
|
||||
hasMaterial,
|
||||
surfaceId);
|
||||
surfaceId,
|
||||
DegradeMode: 2u);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,16 +6,47 @@ using AcDream.Core.World;
|
|||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the optional DAT-archaeology sky-PES experiment. Named retail shows
|
||||
/// GameSky does not consume SkyObject.PesObjectId, so production invokes this
|
||||
/// owner only when the explicit startup diagnostic is enabled.
|
||||
/// Production owner of retail's sky default-script playback — the aurora,
|
||||
/// lightning-flash, and thunder effects authored on the sky carrier Setups.
|
||||
///
|
||||
/// <para><b>Retail mechanism</b> (full chain with decomp cites in
|
||||
/// <c>docs/research/2026-08-23-sky-default-script-port.md</c>):
|
||||
/// <c>GameSky::MakeObject @0x00506EE0</c> creates each visible sky object via
|
||||
/// <c>CPhysicsObj::makeObject @0x00513970</c>; a Setup carrying a
|
||||
/// <c>DefaultScript</c> marks the object <c>state |= 0x80000</c> and joins the
|
||||
/// static-animating list, whose per-tick
|
||||
/// <c>CPhysicsObj::animate_static_object @0x00513DF0</c> drives
|
||||
/// <c>ScriptManager::UpdateScripts</c> and the object's ParticleManager. The
|
||||
/// earlier April-2026 research correctly proved GameSky never reads the
|
||||
/// <c>CelestialPosition.pes_id</c> column — the ids ride the Setup's own
|
||||
/// DefaultScript instead (byte-equal in Dereth's Region DAT).</para>
|
||||
///
|
||||
/// <para><b>Slot identity / persistence</b> mirrors
|
||||
/// <c>GameSky::CreateDeletePhysicsObjects @0x005073C0</c>: a slot keeps its
|
||||
/// object (and therefore its running script and particle population) while
|
||||
/// the slot's gfx id and properties word are unchanged; a mismatch — or the
|
||||
/// object leaving its begin/end window, which retail expresses as the slot's
|
||||
/// gfx id becoming INVALID — destroys and recreates it. Keys here are
|
||||
/// (slot index, gfx id, properties) for exactly that contract.</para>
|
||||
///
|
||||
/// <para><b>Adaptations</b> (register row AD-112): the script anchors at the
|
||||
/// camera (retail's sky-cell space is viewer-centered, so world-space camera
|
||||
/// anchoring is the same geometry), and playback goes through
|
||||
/// <see cref="PhysicsScriptRunner"/> synthetic owners instead of real
|
||||
/// physics objects in a dedicated sky cell. Retail's
|
||||
/// <c>LScape::weather_enabled</c> guard on <c>props & 4</c> objects is
|
||||
/// moot: acdream has no weather kill-switch, matching retail's default-on
|
||||
/// state (see <see cref="SkyObjectData.IsWeather"/>). Sky scripts keep
|
||||
/// running while the camera is indoors — retail's outside check gates only
|
||||
/// <c>GameSky::Draw</c>, and our sky passes are likewise skipped by
|
||||
/// <c>RenderSky</c> without stopping simulation.</para>
|
||||
/// </summary>
|
||||
internal sealed class SkyPesFrameController
|
||||
{
|
||||
private readonly record struct SkyPesKey(
|
||||
int ObjectIndex,
|
||||
uint PesObjectId,
|
||||
bool PostScene);
|
||||
uint GfxObjId,
|
||||
uint Properties);
|
||||
|
||||
private readonly PhysicsScriptRunner _scripts;
|
||||
private readonly ParticleHookSink _particles;
|
||||
|
|
@ -23,108 +54,153 @@ internal sealed class SkyPesFrameController
|
|||
private readonly EntityEffectController? _effects;
|
||||
private readonly HashSet<SkyPesKey> _active = [];
|
||||
private readonly HashSet<SkyPesKey> _missing = [];
|
||||
private readonly HashSet<uint> _reportedScriptMismatches = [];
|
||||
private readonly HashSet<SkyPesKey> _seenScratch = [];
|
||||
private readonly List<SkyPesKey> _stopScratch = [];
|
||||
private readonly Action<string>? _diagnostic;
|
||||
|
||||
public SkyPesFrameController(
|
||||
PhysicsScriptRunner scripts,
|
||||
ParticleHookSink particles,
|
||||
EntityEffectPoseRegistry poses,
|
||||
EntityEffectController? effects)
|
||||
EntityEffectController? effects,
|
||||
Action<string>? diagnostic = null)
|
||||
{
|
||||
_scripts = scripts ?? throw new ArgumentNullException(nameof(scripts));
|
||||
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
|
||||
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
|
||||
_effects = effects;
|
||||
_diagnostic = diagnostic;
|
||||
}
|
||||
|
||||
public void Update(
|
||||
float dayFraction,
|
||||
DayGroupData? dayGroup,
|
||||
Vector3 cameraWorldPosition,
|
||||
bool suppressSky)
|
||||
Vector3 cameraWorldPosition)
|
||||
{
|
||||
var seen = new HashSet<SkyPesKey>();
|
||||
if (!suppressSky && dayGroup is not null)
|
||||
_seenScratch.Clear();
|
||||
if (dayGroup is not null)
|
||||
{
|
||||
for (int index = 0; index < dayGroup.SkyObjects.Count; index++)
|
||||
{
|
||||
SkyObjectData skyObject = dayGroup.SkyObjects[index];
|
||||
if (skyObject.PesObjectId == 0 || !skyObject.IsVisible(dayFraction))
|
||||
if (ResolveScriptId(skyObject) == 0
|
||||
|| !skyObject.IsVisible(dayFraction))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = new SkyPesKey(
|
||||
_seenScratch.Add(new SkyPesKey(
|
||||
index,
|
||||
skyObject.PesObjectId,
|
||||
skyObject.IsPostScene);
|
||||
seen.Add(key);
|
||||
uint ownerId = EntityId(key);
|
||||
ParticleRenderPass renderPass = skyObject.IsPostScene
|
||||
? ParticleRenderPass.SkyPostScene
|
||||
: ParticleRenderPass.SkyPreScene;
|
||||
_particles.SetEntityRenderPass(ownerId, renderPass);
|
||||
Vector3 anchor = Anchor(skyObject, cameraWorldPosition);
|
||||
Quaternion rotation = Rotation(skyObject, dayFraction);
|
||||
_poses.Publish(
|
||||
ownerId,
|
||||
Matrix4x4.CreateFromQuaternion(rotation)
|
||||
* Matrix4x4.CreateTranslation(anchor),
|
||||
Array.Empty<Matrix4x4>(),
|
||||
cellId: 0u);
|
||||
|
||||
if (_active.Contains(key) || _missing.Contains(key))
|
||||
continue;
|
||||
|
||||
_effects?.RegisterSyntheticOwner(ownerId);
|
||||
if (_scripts.Play(skyObject.PesObjectId, ownerId, anchor))
|
||||
{
|
||||
_active.Add(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_missing.Add(key);
|
||||
_effects?.UnregisterSyntheticOwner(ownerId);
|
||||
_particles.ClearEntityRenderPass(ownerId);
|
||||
_poses.Remove(ownerId);
|
||||
}
|
||||
skyObject.GfxObjId,
|
||||
skyObject.Properties));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (SkyPesKey key in _active.ToArray())
|
||||
// Stop stale slots BEFORE starting replacements: EntityId is
|
||||
// slot-derived, so a slot whose identity changed this frame must
|
||||
// release its owner before the new identity claims it.
|
||||
StopUnseen(_active, stopScripts: true);
|
||||
StopUnseen(_missing, stopScripts: false);
|
||||
|
||||
if (dayGroup is null)
|
||||
return;
|
||||
|
||||
for (int index = 0; index < dayGroup.SkyObjects.Count; index++)
|
||||
{
|
||||
if (seen.Contains(key))
|
||||
SkyObjectData skyObject = dayGroup.SkyObjects[index];
|
||||
uint scriptId = ResolveScriptId(skyObject);
|
||||
if (scriptId == 0 || !skyObject.IsVisible(dayFraction))
|
||||
continue;
|
||||
|
||||
var key = new SkyPesKey(
|
||||
index,
|
||||
skyObject.GfxObjId,
|
||||
skyObject.Properties);
|
||||
uint ownerId = EntityId(key);
|
||||
_scripts.StopAllForEntity(ownerId);
|
||||
_effects?.UnregisterSyntheticOwner(ownerId);
|
||||
_particles.StopAllForEntity(ownerId, fadeOut: true);
|
||||
_poses.Remove(ownerId);
|
||||
_active.Remove(key);
|
||||
ParticleRenderPass renderPass = skyObject.IsPostScene
|
||||
? ParticleRenderPass.SkyPostScene
|
||||
: ParticleRenderPass.SkyPreScene;
|
||||
_particles.SetEntityRenderPass(ownerId, renderPass);
|
||||
Quaternion rotation = Rotation(skyObject, dayFraction);
|
||||
_poses.Publish(
|
||||
ownerId,
|
||||
Matrix4x4.CreateFromQuaternion(rotation)
|
||||
* Matrix4x4.CreateTranslation(cameraWorldPosition),
|
||||
Array.Empty<Matrix4x4>(),
|
||||
cellId: 0u);
|
||||
|
||||
if (_active.Contains(key) || _missing.Contains(key))
|
||||
continue;
|
||||
|
||||
_effects?.RegisterSyntheticOwner(ownerId);
|
||||
if (_scripts.Play(scriptId, ownerId, cameraWorldPosition))
|
||||
{
|
||||
_active.Add(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_missing.Add(key);
|
||||
_effects?.UnregisterSyntheticOwner(ownerId);
|
||||
_particles.ClearEntityRenderPass(ownerId);
|
||||
_poses.Remove(ownerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StopUnseen(HashSet<SkyPesKey> set, bool stopScripts)
|
||||
{
|
||||
_stopScratch.Clear();
|
||||
foreach (SkyPesKey key in set)
|
||||
{
|
||||
if (!_seenScratch.Contains(key))
|
||||
_stopScratch.Add(key);
|
||||
}
|
||||
|
||||
foreach (SkyPesKey key in _missing.ToArray())
|
||||
foreach (SkyPesKey key in _stopScratch)
|
||||
{
|
||||
if (!seen.Contains(key))
|
||||
_missing.Remove(key);
|
||||
if (stopScripts)
|
||||
{
|
||||
uint ownerId = EntityId(key);
|
||||
_scripts.StopAllForEntity(ownerId);
|
||||
_effects?.UnregisterSyntheticOwner(ownerId);
|
||||
_particles.StopAllForEntity(ownerId, fadeOut: true);
|
||||
_poses.Remove(ownerId);
|
||||
}
|
||||
|
||||
set.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Setup's authored <c>DefaultScript</c> is the retail source; the
|
||||
/// dead <c>pes_id</c> column is byte-equal in Dereth's DAT and serves
|
||||
/// only as a one-time-logged cross-check for modded/foreign data.
|
||||
/// </summary>
|
||||
private uint ResolveScriptId(SkyObjectData skyObject)
|
||||
{
|
||||
uint scriptId = skyObject.DefaultScriptId;
|
||||
if (scriptId != skyObject.PesObjectId
|
||||
&& skyObject.GfxObjId != 0
|
||||
&& _reportedScriptMismatches.Add(skyObject.GfxObjId))
|
||||
{
|
||||
_diagnostic?.Invoke(
|
||||
$"[sky-pes] carrier 0x{skyObject.GfxObjId:X8}: Setup DefaultScript " +
|
||||
$"0x{scriptId:X8} != SkyObject pes_id 0x{skyObject.PesObjectId:X8}; " +
|
||||
"playing the DefaultScript (retail's source).");
|
||||
}
|
||||
|
||||
return scriptId;
|
||||
}
|
||||
|
||||
private static uint EntityId(SkyPesKey key)
|
||||
{
|
||||
uint postScene = key.PostScene ? 0x08000000u : 0u;
|
||||
uint postScene = (key.Properties & 0x01u) != 0u ? 0x08000000u : 0u;
|
||||
return 0xF0000000u
|
||||
| postScene
|
||||
| ((uint)key.ObjectIndex & 0x07FFFFFFu);
|
||||
}
|
||||
|
||||
private static Vector3 Anchor(
|
||||
SkyObjectData skyObject,
|
||||
Vector3 cameraWorldPosition)
|
||||
{
|
||||
if (skyObject.IsWeather && (skyObject.Properties & 0x08u) == 0u)
|
||||
return cameraWorldPosition + new Vector3(0f, 0f, -120f);
|
||||
return cameraWorldPosition;
|
||||
}
|
||||
|
||||
private static Quaternion Rotation(
|
||||
SkyObjectData skyObject,
|
||||
float dayFraction)
|
||||
|
|
|
|||
|
|
@ -476,14 +476,14 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
|
|||
in RenderFrameFoundation foundation,
|
||||
DayGroupData? activeDayGroup)
|
||||
{
|
||||
if (_options.EnableSkyPesDebug)
|
||||
{
|
||||
_skyPes?.Update(
|
||||
(float)_worldTime.DayFraction,
|
||||
activeDayGroup,
|
||||
camera.Position,
|
||||
roots.CameraInsideCell);
|
||||
}
|
||||
// Retail's sky default scripts (aurora/lightning/thunder) run
|
||||
// unconditionally — GameSky::UseTime/CreateDeletePhysicsObjects tick
|
||||
// every frame regardless of the outside check, which gates only the
|
||||
// draw. See docs/research/2026-08-23-sky-default-script-port.md.
|
||||
_skyPes?.Update(
|
||||
(float)_worldTime.DayFraction,
|
||||
activeDayGroup,
|
||||
camera.Position);
|
||||
|
||||
UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);
|
||||
_lighting.UpdateViewerLight(roots.PlayerViewPosition);
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ public sealed record RuntimeOptions(
|
|||
bool DumpMoveTruth,
|
||||
bool DumpSky,
|
||||
bool NoAudio,
|
||||
bool EnableSkyPesDebug,
|
||||
int HidePartIndex,
|
||||
bool RetailCloseDegrades,
|
||||
bool DumpSceneryZ,
|
||||
|
|
@ -152,7 +151,6 @@ public sealed record RuntimeOptions(
|
|||
DumpMoveTruth: IsExactlyOne(env("ACDREAM_DUMP_MOVE_TRUTH")),
|
||||
DumpSky: IsExactlyOne(env("ACDREAM_DUMP_SKY")),
|
||||
NoAudio: IsExactlyOne(env("ACDREAM_NO_AUDIO")),
|
||||
EnableSkyPesDebug: IsExactlyOne(env("ACDREAM_ENABLE_SKY_PES")),
|
||||
HidePartIndex: TryParseInt(env("ACDREAM_HIDE_PART")) ?? -1,
|
||||
// Default-on: any value other than the literal string "0" enables
|
||||
// retail close-detail degrades. Set ACDREAM_RETAIL_CLOSE_DEGRADES=0
|
||||
|
|
|
|||
118
src/AcDream.Core/Vfx/RetailParticleFacing.cs
Normal file
118
src/AcDream.Core/Vfx/RetailParticleFacing.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's particle draw-frame facing law, ported verbatim from
|
||||
/// <c>CPhysicsPart::calc_draw_frame @0x0050DFA0</c>:
|
||||
///
|
||||
/// <code>
|
||||
/// draw = pos
|
||||
/// switch (deg_mode):
|
||||
/// 2: Frame::set_vector_heading(draw, viewer_heading) // face viewer, roll-free
|
||||
/// 3/4/5: Frame::rotate_around_axis_to_vector(draw, X/Y/Z) // one free axis
|
||||
/// else: authored orientation (mode 1, mode 0, out of range)
|
||||
/// </code>
|
||||
///
|
||||
/// <c>viewer_heading</c> is the normalized part→viewer direction
|
||||
/// (<c>CPhysicsPart::UpdateViewerDistance @0x0050E030</c>). The mode is the
|
||||
/// FIRST degrade entry's <c>DegradeMode</c>
|
||||
/// (<c>GfxObjDegradeInfo::get_degrade @0x0051E4B0</c>). Retail's
|
||||
/// <c>Always2D</c> (mode != 1) affects only cell membership, not drawing —
|
||||
/// the draw path is always the authored mesh with this facing applied.
|
||||
/// Research: <c>docs/research/2026-08-23-sky-default-script-port.md</c>.
|
||||
/// </summary>
|
||||
public static class RetailParticleFacing
|
||||
{
|
||||
/// <summary>True when the mode orients the part toward the viewer at all
|
||||
/// (retail's <c>deg_mode != 1 && (deg_mode - 2) <= 3</c>).</summary>
|
||||
public static bool Faces(uint degradeMode)
|
||||
=> degradeMode >= 2u && degradeMode <= 5u;
|
||||
|
||||
/// <summary>
|
||||
/// Orients a particle quad per the retail law. Inputs are the particle's
|
||||
/// authored orientation plus the local in-plane axes the renderer chose
|
||||
/// for the sprite (unit vectors in sprite-local space); output is the
|
||||
/// world-space direction pair for the quad's X/Y spans.
|
||||
/// </summary>
|
||||
/// <param name="degradeMode">First degrade entry's mode.</param>
|
||||
/// <param name="orientation">The particle's authored world orientation.</param>
|
||||
/// <param name="localAxisX">Sprite-local in-plane X (unit).</param>
|
||||
/// <param name="localAxisY">Sprite-local in-plane Y (unit).</param>
|
||||
/// <param name="toViewerUnit">Normalized particle→viewer direction.</param>
|
||||
/// <param name="fallbackRight">Basis used when the facing construction is
|
||||
/// degenerate (viewer straight along world up) — the camera right.</param>
|
||||
/// <param name="fallbackUp">Camera up, same degenerate fallback.</param>
|
||||
public static (Vector3 XDir, Vector3 YDir) OrientQuad(
|
||||
uint degradeMode,
|
||||
Quaternion orientation,
|
||||
Vector3 localAxisX,
|
||||
Vector3 localAxisY,
|
||||
Vector3 toViewerUnit,
|
||||
Vector3 fallbackRight,
|
||||
Vector3 fallbackUp)
|
||||
{
|
||||
if (degradeMode == 2u)
|
||||
return FaceViewerRollFree(toViewerUnit, fallbackRight, fallbackUp);
|
||||
|
||||
Vector3 worldX = Vector3.Transform(localAxisX, orientation);
|
||||
Vector3 worldY = Vector3.Transform(localAxisY, orientation);
|
||||
if (degradeMode < 3u || degradeMode > 5u)
|
||||
return (worldX, worldY);
|
||||
|
||||
// Modes 3/4/5 spin the authored frame around its own local X/Y/Z so
|
||||
// the sprite's face normal points at the viewer as far as the
|
||||
// constraint allows (Frame::rotate_around_axis_to_vector).
|
||||
Vector3 localAxis = degradeMode switch
|
||||
{
|
||||
3u => Vector3.UnitX,
|
||||
4u => Vector3.UnitY,
|
||||
_ => Vector3.UnitZ,
|
||||
};
|
||||
Vector3 axis = Vector3.Transform(localAxis, orientation);
|
||||
Vector3 normal = Vector3.Cross(worldX, worldY);
|
||||
if (normal.LengthSquared() < 1e-10f)
|
||||
return (worldX, worldY);
|
||||
normal = Vector3.Normalize(normal);
|
||||
|
||||
Vector3 targetInPlane = toViewerUnit - axis * Vector3.Dot(toViewerUnit, axis);
|
||||
Vector3 normalInPlane = normal - axis * Vector3.Dot(normal, axis);
|
||||
if (targetInPlane.LengthSquared() < 1e-8f
|
||||
|| normalInPlane.LengthSquared() < 1e-8f)
|
||||
{
|
||||
return (worldX, worldY);
|
||||
}
|
||||
|
||||
targetInPlane = Vector3.Normalize(targetInPlane);
|
||||
normalInPlane = Vector3.Normalize(normalInPlane);
|
||||
float cos = Math.Clamp(Vector3.Dot(normalInPlane, targetInPlane), -1f, 1f);
|
||||
float sin = Vector3.Dot(Vector3.Cross(normalInPlane, targetInPlane), axis);
|
||||
float angle = MathF.Atan2(sin, cos);
|
||||
var spin = Quaternion.CreateFromAxisAngle(axis, angle);
|
||||
return (Vector3.Transform(worldX, spin), Vector3.Transform(worldY, spin));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mode 2 — <c>Frame::set_vector_heading</c>: the quad's face normal
|
||||
/// points at the viewer with zero roll against world up (+Z). The quad's
|
||||
/// X span stays horizontal; its Y span becomes the in-plane up.
|
||||
/// </summary>
|
||||
private static (Vector3 XDir, Vector3 YDir) FaceViewerRollFree(
|
||||
Vector3 toViewerUnit,
|
||||
Vector3 fallbackRight,
|
||||
Vector3 fallbackUp)
|
||||
{
|
||||
Vector3 right = Vector3.Cross(toViewerUnit, Vector3.UnitZ);
|
||||
if (right.LengthSquared() < 1e-8f)
|
||||
{
|
||||
// Viewer straight above/below the part: heading is undefined —
|
||||
// hold the camera plane, which retail's next frame resolves the
|
||||
// same way once the direction tilts.
|
||||
return (fallbackRight, fallbackUp);
|
||||
}
|
||||
|
||||
right = Vector3.Normalize(right);
|
||||
Vector3 up = Vector3.Cross(right, toViewerUnit);
|
||||
return (right, up);
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,22 @@ public sealed class SkyObjectData
|
|||
public uint PesObjectId;
|
||||
public uint Properties;
|
||||
|
||||
/// <summary>
|
||||
/// The carrier Setup's own <c>DefaultScript</c> PES id (zero when the
|
||||
/// gfx id is not a Setup or the Setup has none). This — not
|
||||
/// <see cref="PesObjectId"/> — is what retail plays: a Setup with a
|
||||
/// default script marks its physics object <c>state |= 0x80000</c>
|
||||
/// (<c>CPhysicsObj::makeObject @0x00513970</c> →
|
||||
/// <c>InitPartArrayObject</c>) and the static-animating tick
|
||||
/// (<c>CPhysicsObj::animate_static_object @0x00513DF0</c>) drives
|
||||
/// <c>ScriptManager::UpdateScripts</c> + the object's ParticleManager.
|
||||
/// In Dereth's Region DAT the two ids are byte-equal for every sky
|
||||
/// carrier (verified 2026-08-23); the dead <c>pes_id</c> column stays
|
||||
/// parsed as a cross-check only. Full chain:
|
||||
/// <c>docs/research/2026-08-23-sky-default-script-port.md</c>.
|
||||
/// </summary>
|
||||
public uint DefaultScriptId;
|
||||
|
||||
/// <summary>
|
||||
/// Source GfxObj sort centre. Celestial billboards are authored at their
|
||||
/// apparent direction from the camera, so transforming and normalizing
|
||||
|
|
@ -525,11 +541,40 @@ public static class SkyDescLoader
|
|||
GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u,
|
||||
PesObjectId = s.DefaultPesObjectId?.DataId ?? 0u,
|
||||
Properties = s.Properties,
|
||||
DefaultScriptId = ResolveSetupDefaultScript(
|
||||
s.DefaultGfxObjectId?.DataId ?? 0u,
|
||||
dats),
|
||||
AuthoredSortCenter = ResolveSortCenter(
|
||||
s.DefaultGfxObjectId?.DataId ?? 0u,
|
||||
dats),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a sky carrier Setup's <c>DefaultScript</c> id (the retail
|
||||
/// sky-PES source — see <see cref="SkyObjectData.DefaultScriptId"/>).
|
||||
/// Non-Setup ids (the dome, star layers, cloud sheets are raw GfxObjs)
|
||||
/// and missing DATs resolve to zero.
|
||||
/// </summary>
|
||||
private static uint ResolveSetupDefaultScript(
|
||||
uint gfxObjId,
|
||||
IDatObjectSource? dats)
|
||||
{
|
||||
if (dats is null || (gfxObjId & 0xFF000000u) != 0x02000000u)
|
||||
return 0u;
|
||||
|
||||
try
|
||||
{
|
||||
return dats.TryGet<Setup>(gfxObjId, out var setup) && setup is not null
|
||||
? setup.DefaultScript.DataId
|
||||
: 0u;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Enhancement metadata cannot make authoritative sky loading fail.
|
||||
return 0u;
|
||||
}
|
||||
}
|
||||
|
||||
private static DatSkyKeyframeData ConvertTimeOfDay(
|
||||
SkyTimeOfDay s,
|
||||
IDatObjectSource? dats)
|
||||
|
|
|
|||
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