From 18fce7bb5a9819269bf489164b240b0e7e508611 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 23 Aug 2026 15:28:59 +0200 Subject: [PATCH] fix #28: port retail's sky default-script playback (aurora) and the particle facing law MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ISSUES.md | 19 +- .../retail-divergence-register.md | 3 +- .../2026-08-23-sky-default-script-port.md | 119 ++++++++++ .../Composition/FrameRootComposition.cs | 3 +- src/AcDream.App/Rendering/ParticleRenderer.cs | 136 +++++++++--- .../Rendering/SkyPesFrameController.cs | 204 ++++++++++++------ .../Rendering/WorldRenderFrameBuilder.cs | 16 +- src/AcDream.App/RuntimeOptions.cs | 2 - src/AcDream.Core/Vfx/RetailParticleFacing.cs | 118 ++++++++++ src/AcDream.Core/World/SkyDescLoader.cs | 45 ++++ .../Rendering/SkyPesFrameControllerTests.cs | 172 +++++++++++++++ .../AcDream.App.Tests/RuntimeOptionsTests.cs | 5 - .../Vfx/RetailParticleFacingTests.cs | 177 +++++++++++++++ 13 files changed, 906 insertions(+), 113 deletions(-) create mode 100644 docs/research/2026-08-23-sky-default-script-port.md create mode 100644 src/AcDream.Core/Vfx/RetailParticleFacing.cs create mode 100644 tests/AcDream.App.Tests/Rendering/SkyPesFrameControllerTests.cs create mode 100644 tests/AcDream.Core.Tests/Vfx/RetailParticleFacingTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b16f7ab2..e6487250 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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`. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 11daed32..2c5a4b12 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -66,7 +66,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 85 active rows (AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- +## 2. Adaptation (AD) — 86 active rows (AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- -to-line-break shaping, which retail's `ItemExamineUI::AddItemInfo @0x004AC050` does not do (wire text appends verbatim; the escape decode retail runs at `StringInfo` resolution now lives at our string source, `DatStringResolver` → `RetailStringEscapes`); AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate @@ -110,6 +110,7 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AD-112 | **Filed 2026-08-23 with the sky default-script port (issues #28/#2, phase C.1.5c).** Retail plays the sky carriers' aurora/lightning/thunder PES by creating real `CPhysicsObj`s in the viewer-centered sky cells (`GameSky::MakeObject @0x00506EE0` → `CPhysicsObj::makeObject @0x00513970`; the Setup's `DefaultScript` marks `state \|= 0x80000` and `CPhysicsObj::animate_static_object @0x00513DF0` ticks `ScriptManager::UpdateScripts` + the ParticleManager). acdream plays the same scripts through `PhysicsScriptRunner` synthetic owners anchored at the camera (`SkyPesFrameController`), pass-routed by `props & 1` into the existing SkyPreScene/SkyPostScene particle draws — no sky-cell physics objects exist. Slot persistence keys on (index, gfx id, properties), the `CreateDeletePhysicsObjects @0x005073C0` identity law. The heading/rotation → pose axis mapping uses `UnitY` where retail's `GameSky::CalcFrame @0x00506F80` runs `set_heading` + `grotate`; every current PES carrier authors 0°/0° so the axis is unexercised. | `src/AcDream.App/Rendering/SkyPesFrameController.cs`; `src/AcDream.Core/World/SkyDescLoader.cs` (`SkyObjectData.DefaultScriptId`) | Retail's sky cells are camera-centered, so a camera-anchored world-space owner is the same geometry; the script/emitter engines are the production ones shared with entity effects; `SkyPesFrameControllerTests` pins the identity/persistence/window lifecycle. | A sky carrier authored with nonzero heading/rotation angles would orbit around the wrong axis; a PES whose hooks depend on real part frames (part_index targeting) would find only the synthetic owner's root pose. | `GameSky::MakeObject @0x00506EE0`; `GameSky::CreateDeletePhysicsObjects @0x005073C0`; `CPhysicsObj::animate_static_object @0x00513DF0`; `docs/research/2026-08-23-sky-default-script-port.md`; `SkyPesFrameControllerTests` | | AD-111 | **Filed 2026-08-17 at the systemic escape-normalization round (commit 967b9c57).** The appraisal report's WIRE-string shaping (`ItemAppraisalTextLayout.Shape`) converts a literal two-character ` ` in server-sent fragment text (long description, use text) into a real line break. Retail does NOT: `ItemExamineUI::AddItemInfo @ 0x004AC050` hands wire text straight to `UIElement_Text::AppendTextWithFont` with no `StringTableMetaLanguage::UnescapeString` pass (that decode belongs to `StringInfo` resolution — DAT/authored strings — which the same round ported to `DatStringResolver`/`RetailStringEscapes` as the single source decode), so retail renders a wire backslash-n literally. Pre-existing behavior documented as wire-domain at the same round (it shipped inside the user-accepted Slice-3 assessment surface); the sibling inscription path (`IndicatorDetailText.Shape`) was returned to retail-verbatim in the same commit. | `src/AcDream.App/UI/Layout/ItemAppraisalReport.cs` (`ItemAppraisalTextLayout.Shape`'s domain-commented replace) | Accommodates literal " " sequences appearing in ACE database strings; server strings carrying REAL line-break characters flow through the same split either way, so the replace only ever fires on content retail would render with a visible backslash-n. | A wire string legitimately containing the two characters backslash+n (a file path, ASCII art in a description) renders with a spurious line break where retail shows it literally. | `ItemExamineUI::AddItemInfo @ 0x004AC050`; `UIElement_Text::AppendTextWithFont` (direct append, no unescape); `StringTableMetaLanguage::UnescapeString @ 0x0067BDC0` (the decode retail applies ONLY at StringInfo resolution) | diff --git a/docs/research/2026-08-23-sky-default-script-port.md b/docs/research/2026-08-23-sky-default-script-port.md new file mode 100644 index 00000000..a93eca25 --- /dev/null +++ b/docs/research/2026-08-23-sky-default-script-port.md @@ -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. diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index b8cadd25..8f5099c7 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -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 diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 5e14a30a..63036896 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -105,6 +105,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable private readonly Dictionary _particleGfxInfoByGfxObj = new(); private readonly Dictionary _particleGfxInfoByEmitter = new(); private readonly Dictionary _geometryKindByGfxObj = new(); + private readonly Dictionary _firstDegradeModeByGfxObj = new(); private readonly Dictionary _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; + } + + /// + /// The sprite's FIRST degrade entry's mode — retail's facing selector + /// (GfxObjDegradeInfo::get_degrade @0x0051E4B0 feeding + /// CPhysicsPart::calc_draw_frame @0x0050DFA0). Null when the + /// GfxObj has no degrade table. + /// + private uint? ResolveFirstDegradeMode(uint gfxObjId) + { + if (_firstDegradeModeByGfxObj.TryGetValue(gfxObjId, out uint? mode)) + return mode; + try { if (_dats?.Get(gfxObjId) is { } gfx @@ -584,7 +658,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable && gfx.DIDDegrade != 0 && _dats.Get(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(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(); } + /// + /// is the sprite GfxObj's FIRST degrade + /// entry's mode — retail's facing selector + /// (CPhysicsPart::calc_draw_frame @0x0050DFA0, see + /// ). Mode 2 sprites + /// take the 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. + /// 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); } } diff --git a/src/AcDream.App/Rendering/SkyPesFrameController.cs b/src/AcDream.App/Rendering/SkyPesFrameController.cs index 57dfd96b..ab087831 100644 --- a/src/AcDream.App/Rendering/SkyPesFrameController.cs +++ b/src/AcDream.App/Rendering/SkyPesFrameController.cs @@ -6,16 +6,47 @@ using AcDream.Core.World; namespace AcDream.App.Rendering; /// -/// 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. +/// +/// Retail mechanism (full chain with decomp cites in +/// docs/research/2026-08-23-sky-default-script-port.md): +/// GameSky::MakeObject @0x00506EE0 creates each visible sky object via +/// CPhysicsObj::makeObject @0x00513970; a Setup carrying a +/// DefaultScript marks the object state |= 0x80000 and joins the +/// static-animating list, whose per-tick +/// CPhysicsObj::animate_static_object @0x00513DF0 drives +/// ScriptManager::UpdateScripts and the object's ParticleManager. The +/// earlier April-2026 research correctly proved GameSky never reads the +/// CelestialPosition.pes_id column — the ids ride the Setup's own +/// DefaultScript instead (byte-equal in Dereth's Region DAT). +/// +/// Slot identity / persistence mirrors +/// GameSky::CreateDeletePhysicsObjects @0x005073C0: 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. +/// +/// Adaptations (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 +/// synthetic owners instead of real +/// physics objects in a dedicated sky cell. Retail's +/// LScape::weather_enabled guard on props & 4 objects is +/// moot: acdream has no weather kill-switch, matching retail's default-on +/// state (see ). Sky scripts keep +/// running while the camera is indoors — retail's outside check gates only +/// GameSky::Draw, and our sky passes are likewise skipped by +/// RenderSky without stopping simulation. /// 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 _active = []; private readonly HashSet _missing = []; + private readonly HashSet _reportedScriptMismatches = []; + private readonly HashSet _seenScratch = []; + private readonly List _stopScratch = []; + private readonly Action? _diagnostic; public SkyPesFrameController( PhysicsScriptRunner scripts, ParticleHookSink particles, EntityEffectPoseRegistry poses, - EntityEffectController? effects) + EntityEffectController? effects, + Action? 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(); - 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(), - 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(), + 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 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); } } + /// + /// The Setup's authored DefaultScript is the retail source; the + /// dead pes_id column is byte-equal in Dereth's DAT and serves + /// only as a one-time-logged cross-check for modded/foreign data. + /// + 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) diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 36aaf465..7fdc66ae 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -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); diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index be16cda0..569b551b 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -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 diff --git a/src/AcDream.Core/Vfx/RetailParticleFacing.cs b/src/AcDream.Core/Vfx/RetailParticleFacing.cs new file mode 100644 index 00000000..5b2eb0c9 --- /dev/null +++ b/src/AcDream.Core/Vfx/RetailParticleFacing.cs @@ -0,0 +1,118 @@ +using System.Numerics; + +namespace AcDream.Core.Vfx; + +/// +/// Retail's particle draw-frame facing law, ported verbatim from +/// CPhysicsPart::calc_draw_frame @0x0050DFA0: +/// +/// +/// 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) +/// +/// +/// viewer_heading is the normalized part→viewer direction +/// (CPhysicsPart::UpdateViewerDistance @0x0050E030). The mode is the +/// FIRST degrade entry's DegradeMode +/// (GfxObjDegradeInfo::get_degrade @0x0051E4B0). Retail's +/// Always2D (mode != 1) affects only cell membership, not drawing — +/// the draw path is always the authored mesh with this facing applied. +/// Research: docs/research/2026-08-23-sky-default-script-port.md. +/// +public static class RetailParticleFacing +{ + /// True when the mode orients the part toward the viewer at all + /// (retail's deg_mode != 1 && (deg_mode - 2) <= 3). + public static bool Faces(uint degradeMode) + => degradeMode >= 2u && degradeMode <= 5u; + + /// + /// 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. + /// + /// First degrade entry's mode. + /// The particle's authored world orientation. + /// Sprite-local in-plane X (unit). + /// Sprite-local in-plane Y (unit). + /// Normalized particle→viewer direction. + /// Basis used when the facing construction is + /// degenerate (viewer straight along world up) — the camera right. + /// Camera up, same degenerate fallback. + 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)); + } + + /// + /// Mode 2 — Frame::set_vector_heading: 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. + /// + 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); + } +} diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index fdfb2faf..9b08d200 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -38,6 +38,22 @@ public sealed class SkyObjectData public uint PesObjectId; public uint Properties; + /// + /// The carrier Setup's own DefaultScript PES id (zero when the + /// gfx id is not a Setup or the Setup has none). This — not + /// — is what retail plays: a Setup with a + /// default script marks its physics object state |= 0x80000 + /// (CPhysicsObj::makeObject @0x00513970 → + /// InitPartArrayObject) and the static-animating tick + /// (CPhysicsObj::animate_static_object @0x00513DF0) drives + /// ScriptManager::UpdateScripts + 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 pes_id column stays + /// parsed as a cross-check only. Full chain: + /// docs/research/2026-08-23-sky-default-script-port.md. + /// + public uint DefaultScriptId; + /// /// 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), }; + /// + /// Fetches a sky carrier Setup's DefaultScript id (the retail + /// sky-PES source — see ). + /// Non-Setup ids (the dome, star layers, cloud sheets are raw GfxObjs) + /// and missing DATs resolve to zero. + /// + private static uint ResolveSetupDefaultScript( + uint gfxObjId, + IDatObjectSource? dats) + { + if (dats is null || (gfxObjId & 0xFF000000u) != 0x02000000u) + return 0u; + + try + { + return dats.TryGet(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) diff --git a/tests/AcDream.App.Tests/Rendering/SkyPesFrameControllerTests.cs b/tests/AcDream.App.Tests/Rendering/SkyPesFrameControllerTests.cs new file mode 100644 index 00000000..8567e977 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/SkyPesFrameControllerTests.cs @@ -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; + +/// +/// Production sky default-script lifecycle per +/// GameSky::CreateDeletePhysicsObjects @0x005073C0: 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: +/// docs/research/2026-08-23-sky-default-script-port.md. +/// +public sealed class SkyPesFrameControllerTests +{ + private const uint AuroraSetup = 0x02000714u; + private const uint AuroraScript = 0x330007DBu; + + private sealed class Harness + { + public readonly List ResolvedScriptIds = []; + public readonly List 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); + } +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 9698b34c..3f5739db 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -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); } diff --git a/tests/AcDream.Core.Tests/Vfx/RetailParticleFacingTests.cs b/tests/AcDream.Core.Tests/Vfx/RetailParticleFacingTests.cs new file mode 100644 index 00000000..cfa96aa8 --- /dev/null +++ b/tests/AcDream.Core.Tests/Vfx/RetailParticleFacingTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Numerics; +using AcDream.Core.Vfx; +using Xunit; + +namespace AcDream.Core.Tests.Vfx; + +/// +/// Retail facing law from CPhysicsPart::calc_draw_frame @0x0050DFA0: +/// mode 2 faces the viewer roll-free (Frame::set_vector_heading), +/// modes 3/4/5 spin the authored frame around one local axis toward the +/// viewer (Frame::rotate_around_axis_to_vector), every other mode +/// keeps the authored orientation. +/// +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); + } +}