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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue