From d09e246d3a973ea828efc47acaa47e0a212a2885 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 10:54:33 +0200 Subject: [PATCH] refactor(world): own live environment state Move the DAT sky, selected day group, world clock, weather, AdminEnvirons bridge, and debug cycles into a one-shot WorldEnvironmentController while preserving GameWindow's public aliases and accepted startup/session/render order. Correct the named retail citations and register the remaining environment audio and fog/radar gaps. Co-authored-by: Codex --- AGENTS.md | 12 +- CLAUDE.md | 12 +- docs/ISSUES.md | 9 +- docs/architecture/code-structure.md | 5 +- .../retail-divergence-register.md | 9 +- docs/plans/2026-04-11-roadmap.md | 14 +- docs/plans/2026-05-12-milestones.md | 8 +- ...amewindow-slice-8-composition-lifecycle.md | 17 +- .../research/2026-04-23-daygroup-selection.md | 40 ++- .../2026-04-23-retail-memory-probe.md | 6 +- memory/project_gamewindow_decomposition.md | 22 +- src/AcDream.App/Rendering/GameWindow.cs | 295 ++---------------- .../Rendering/WorldSceneRuntimeSources.cs | 19 -- .../World/WorldEnvironmentController.cs | 249 +++++++++++++++ src/AcDream.Core/World/DerethDateTime.cs | 10 +- src/AcDream.Core/World/SkyDescLoader.cs | 23 +- src/AcDream.Core/World/WeatherState.cs | 2 +- .../GameWindowSlice8BoundaryTests.cs | 35 +++ .../World/WorldEnvironmentControllerTests.cs | 187 +++++++++++ 19 files changed, 606 insertions(+), 368 deletions(-) create mode 100644 src/AcDream.App/World/WorldEnvironmentController.cs create mode 100644 tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs diff --git a/AGENTS.md b/AGENTS.md index 6df5a163..1c2198b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,14 +127,16 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–C are also complete: dead host residue is removed, +Slice 8 checkpoints A–D are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical -session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods, -down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test +session controller. `WorldEnvironmentController` solely owns the clock, DAT +sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow` +is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from +the 15,723-line campaign baseline. The 7,419-test Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second -synchronized nine-stop soak pass. Slice 8 checkpoints D–L—world environment, -input/resize, action routing, settings, resource ownership, ordered composition, +synchronized nine-stop soak pass. Slice 8 checkpoints E–L—input/resize, action +routing, settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/CLAUDE.md b/CLAUDE.md index d78eec38..1a38cd9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,14 +125,16 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–C are also complete: dead host residue is removed, +Slice 8 checkpoints A–D are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical -session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods, -down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test +session controller. `WorldEnvironmentController` solely owns the clock, DAT +sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow` +is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from +the 15,723-line campaign baseline. The 7,419-test Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second -synchronized nine-stop soak pass. Slice 8 checkpoints D–L—world environment, -input/resize, action routing, settings, resource ownership, ordered composition, +synchronized nine-stop soak pass. Slice 8 checkpoints E–L—input/resize, action +routing, settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 331b0497..4ccdb45a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,10 +27,11 @@ What does NOT go here: ## Current queue — 2026-07-22 - **Active structural work:** `GameWindow` decomposition. Slices 1–7 are - complete; Slice 8 checkpoints A–C now own native callbacks and live-session - composition outside the shell. `GameWindow.OnUpdate` and - `GameWindow.OnRender` are typed orchestration handoffs and the class is 4,589 - lines / 196 fields / 69 methods. Remaining Slice 8 composition/shutdown + complete; Slice 8 checkpoints A–D now own native callbacks, live-session + composition, and world-environment state outside the shell. + `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration + handoffs and the class is 4,330 lines / 192 fields / 67 methods. Remaining + Slice 8 input/settings/composition/shutdown cleanup is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index e967c89d..e5e4e6b3 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -31,6 +31,7 @@ after Slice 4 10,301 lines / 267 fields / 163 method after Slice 5 closeout 8,811 lines / 247 fields / 153 methods after Slice 6 closeout 7,026 lines / 241 fields / 108 methods after Slice 7 draw-frame cutover 4,666 lines / 196 fields / 70 methods +after Slice 8 checkpoint D 4,330 lines / 192 fields / 67 methods ``` `GameWindow` is the single object that: @@ -256,6 +257,7 @@ src/AcDream.App/ │ ├── RetailInboundEventDispatcher.cs # update-thread packet/frame FIFO barrier │ ├── RetailLiveFrameCoordinator.cs # shipped: object/network/command/reconcile phase order │ ├── LiveEntityPresentationController.cs # ordered Hidden/NoDraw/effect/collision side effects +│ ├── WorldEnvironmentController.cs # one-shot clock/DAT-sky/day/weather/AdminEnvirons owner │ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain │ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations ├── Interaction/ @@ -439,13 +441,14 @@ useful ordering seam, but its ownership status is **partial**. |---|---|---| | Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. | | Network session | **Complete** | `LiveSessionController` remains the sole resolve/create/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal and current-session owner. `LiveSessionHost` owns App reset/selection/entry ordering plus create→attach route factories; `LiveSessionLifecycleHost` guards exact borrowed-session attachment. `LiveSessionEventRouter` and `LiveSessionCommandRouter` own exact inbound/outbound lifetimes, with retryable per-edge teardown before reset or replacement. `GameWindow` retains the controller, focused host, and typed domain binding factories—no mirrored session or reset plan. | +| World environment | **Complete ownership** | `WorldEnvironmentController` solely owns WorldTime, the loaded DAT sky, current day group, Weather, server synchronization, AdminEnvirons bridging, and debug time/weather cycles. `GameWindow` keeps ABI-compatible readonly aliases to the same clock/weather instances and composes one render source plus the two live-session callbacks. TS-54/TS-55 register the remaining centered UI sound and full fog/ambient/radar behavior gaps. | | Live identity/lifetime | **Complete App integration** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, and logical/spatial lifetime. `LiveEntityHydrationController`, `LiveEntityRuntimeTeardownController`, and `LiveEntityNetworkUpdateController` own DAT-backed hydration, retryable cleanup, and accepted Position/Vector/State/Movement presentation without a second GUID owner (`aa90c646`). | | Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). | | World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | | Retained gameplay UI | **Mostly complete feature ownership** | `RetailUiRuntime` and focused panel/controllers own layout and behavior. `GameWindow.OnLoad` still performs substantial service composition, which is allowed until the final composition cleanup. | | Selection/interaction | **Complete** | `WorldSelectionQuery` owns read-only picking/classification/range queries; `SelectionInteractionController` owns selection intent, Use/PickUp transport, exact-incarnation queues, and auto-walk deferral; `ItemInteractionController` owns ItemHolder policy plus the shared retail inventory-request transaction. `GameWindow` retains construction and narrow lifecycle forwarding only. | | Landblock presentation | **Complete** | `LandblockBuildFactory` owns the captured-origin DAT transaction; concrete render/physics/DAT-static publishers and `LandblockPresentationPipeline` own publication and exact retryable retirement. `StreamingOriginRecenterCoordinator` serializes old-window retirement with teleport/session origin lifetimes. `GameWindow` retains construction and one pipeline field only (`c79d0a49`, closeout `4a205a3e`). | -| Render-frame orchestration | **Not extracted** | `OnRender` plus its portal/PView/alpha/particle helpers still own the draw graph and frame-local scratch state. There is no `RenderFrameOrchestrator` yet. | +| Render-frame orchestration | **Complete** | `RenderFrameOrchestrator` owns the GPU-flight, resource, world/PView/shared-alpha, private-presentation, diagnostics, screenshot, and recovery graph. `GameWindow.OnRender` takes one logical window-size snapshot and performs one immutable handoff (`9d7df1bf`). | | Unified `GameEntity` | **Deferred target** | `LiveEntityRuntime` already supplies the critical canonical owner. Full type aggregation is a separate high-risk migration and is not required to make `GameWindow` thin. | ### 4.3 Revised extraction sequence diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3949a045..30eb2abc 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -218,7 +218,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | | AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | -## 4. Temporary stopgap (TS) — 35 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 37 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -261,10 +261,12 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order | | TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 | | TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | +| TS-54 | AdminEnvirons sound values `0x65..0x7B` are diagnosed by retail enum name but do not play audio. Retail checks that the local player physics object and UI sound table exist, then calls `SoundManager::PlaySoundFromCenter(Sound_UI_*, table)` for Roar through Thunder6. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`) | The current audio owner has no typed retail UI-sound-table binding; logging preserves the inbound evidence without inventing wave DIDs or routing the sounds through positional world audio. | Server-authored ambience/thunder packets are silent in acdream while retail plays the centered UI sound. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055E07F..0x0055E2C7`); `SoundManager::PlaySoundFromCenter @ 0x00550950` | +| TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) | --- -## 5. Unclear (UN) — 5 rows +## 5. Unclear (UN) — 4 rows These rows have a missing, contradictory, or never-argued justification. They are the highest-priority audits: each needs either a recorded @@ -273,7 +275,6 @@ equivalence argument (promote to AD/AP) or a fix. | # | Divergence | Where (file:line) | Recorded justification (deficient) | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 | -| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs | | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | | UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | @@ -294,7 +295,7 @@ WITH that phase, not before. 5. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. 6. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. 7. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger. -8. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler. +8. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together. 9. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 68735a60..fec0c3dc 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–C are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–D are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -79,12 +79,12 @@ second synchronized soak pass with graceful exits, and the six stable PNG checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse process-residency gate's diagnostic blind spot; no tolerance was loosened. -Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–C now own -the frozen shell boundary, all nine native callback edges, and live-session -reset/selection/entry/route composition. `GameWindow` is 4,589 lines / 196 -fields / 69 methods; the Checkpoint C Release suite passes 7,408 tests / 5 -skips. Environment, input, settings, startup/resource transfer, and shutdown -checkpoints D–L remain. +Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–D now own +the frozen shell boundary, all nine native callback edges, live-session +reset/selection/entry/route composition, and world clock/sky/day/weather/ +AdminEnvirons state. `GameWindow` is 4,330 lines / 192 fields / 67 methods; +the Checkpoint D Release suite passes 7,419 tests / 5 skips. Input, settings, +startup/resource transfer, and shutdown checkpoints E–L remain. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 403b3a17..1c92eb8d 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -39,10 +39,12 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized nine-destination soak pass with graceful exits, and six stable PNG checkpoints -preserve Slice 6 presentation. Final Slice 8 checkpoints A–C are complete: +preserve Slice 6 presentation. Final Slice 8 checkpoints A–D are complete: native callbacks and live-session composition now have explicit retryable -owners, and `GameWindow` is 4,589 lines / 196 fields / 69 methods. Checkpoints -D–L remain active. Issue #232 tracks process-residency variance in the soak without +owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, +day group, weather, and AdminEnvirons state. `GameWindow` is 4,330 lines / 192 +fields / 67 methods; 7,419 Release tests pass / 5 skip. Checkpoints E–L remain +active. Issue #232 tracks process-residency variance in the soak without loosening its leak threshold. Carried: diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index d6410f63..32a8979a 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -20,7 +20,7 @@ audit. define host-quiescence failure semantics. - [x] C — extract live-session host/reset/binding callbacks and verify the embedded skill formula against named retail. -- [ ] D — extract retail world-environment/day/weather behavior. +- [x] D — extract retail world-environment/day/weather behavior. - [ ] E — extract two-phase raw pointer/camera input, focus, and framebuffer resize behind symmetric named subscriptions. - [ ] F — extract the sole gameplay input-action router plus focused combat and @@ -471,6 +471,21 @@ checkpoint; the final connected lifecycle gate remains Checkpoint L. - Preserve existing named-retail citations and registered sound adaptation; diagnostics call typed commands rather than owning time/weather state. +Result: `WorldEnvironmentController` is now the sole owner of the clock, +loaded sky descriptor, selected day group, Weather state, server time sync, +AdminEnvirons bridge, and time/weather debug cycles. `GameWindow` preserves its +public readonly clock/weather aliases but only composes the owner into render +and session seams. Initialization is explicitly one-shot, and missing GameTime +restores the documented fallback origin instead of inheriting process-global +state. Named-oracle review corrected the day picker to +`SkyDesc::CalcPresentDayGroup @ 0x00500E10` and AdminEnvirons to +`CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20`; TS-54/TS-55 now register +the carried centered-audio and complete fog/ambient/radar gaps. Three +corrected-diff reviews are clean; 17 focused tests, the App suite (3,059 pass / +3 intentional skips), the warning-free Release build, and the full suite +(7,419 pass / 5 intentional skips) pass. No connected gate was required for +this behavior-preserving ownership checkpoint. + ### E — pointer, focus, and framebuffer resize - Make SilkKeyboard/Mouse sources and dispatcher links reversible, then attach diff --git a/docs/research/2026-04-23-daygroup-selection.md b/docs/research/2026-04-23-daygroup-selection.md index 97f4dd4d..9307f9fc 100644 --- a/docs/research/2026-04-23-daygroup-selection.md +++ b/docs/research/2026-04-23-daygroup-selection.md @@ -4,11 +4,16 @@ **Status:** RESEARCH COMPLETE — algorithm recovered, ready for C# port. **Companion docs:** `2026-04-23-sky-retail-verbatim.md` (full sky pipeline map). +> **Named-oracle correction (2026-07-22):** the Sept 2013 PDB identifies this +> body as `SkyDesc::CalcPresentDayGroup @ 0x00500E10`. References below to +> `FUN_00501990 @ 0x00501990` describe the older fallback binary/chunk only; +> that address is inside `SkyDesc::Pack` in the named 2013 build. + --- ## 0. TL;DR -Retail's DayGroup picker is a **simple integer LCG mixed with `(YEAR, DAY_OF_YEAR)` from the live `TimeOfDay` struct**, scaled by `DayGroupCount`. It is **not** a weighted walk over `ChanceOfOccur` — it's uniform over all DayGroups. Dereth's 20 DayGroups all carry `ChanceOfOccur = 5.0f` anyway, so uniform matches the intent. The algorithm lives in `FUN_00501990` (previously mis-labeled "deterministic PES roll" in the sky-verbatim doc; it is actually **SkyDesc::PickCurrentDayGroup**). +Retail's DayGroup picker is a **simple integer LCG mixed with `(YEAR, DAY_OF_YEAR)` from the live `TimeOfDay` struct**, scaled by `DayGroupCount`. It is **not** a weighted walk over `ChanceOfOccur` — it's uniform over all DayGroups. Dereth's 20 DayGroups all carry `ChanceOfOccur = 5.0f` anyway, so uniform matches the intent. The named algorithm is **`SkyDesc::CalcPresentDayGroup @ 0x00500E10`**; `FUN_00501990` is its older-build fallback identity. --- @@ -41,7 +46,7 @@ Note: `FUN_004ff4b0` (the OTHER mapped trampoline → `FUN_00502a10`) is called ```c // chunk_00500000.c:1274 FUN_00501990 @ 0x00501990 size=138 -// SkyDesc::PickCurrentDayGroup(this) +// Older build of SkyDesc::CalcPresentDayGroup(this) void __fastcall FUN_00501990(uint *param_1) { float fVar1; @@ -127,7 +132,7 @@ Global: `DAT_008ee9c8` (type `void *`). Populated by `FUN_005a7fd0` (TimeOfDay:: | 0x00 | double | `EpochBase` (dat-declared) | `FUN_005a7fd0:6045` | | 0x08 | int | `BaseYear` (dat-declared) | `FUN_005a7fd0:6051` | | 0x0C | float | `SecondsPerDay` | `FUN_005a7fd0:6056` | -| **0x10** | int | **`SecondsPerDay` (int copy — source of `iVar6`)** | `FUN_005a7fd0:6061` | +| **0x10** | int | **`DaysPerYear` (source of `iVar6`; live-probed as 360)** | `FUN_005a7fd0:6061`; `2026-04-23-retail-memory-probe.md` | | 0x40 | double | `SecondsPerYear = DaysPerYear × SecondsPerDay` | `FUN_005a7fd0:6068` | | 0x48 | float | `DayFraction` (0..1) | `FUN_005a7800:5497` | | 0x50 | double | CurrentDay startTick | `FUN_005a7510` (via `FUN_005a75b0`) | @@ -136,24 +141,28 @@ Global: `DAT_008ee9c8` (type `void *`). Populated by `FUN_005a7fd0` (TimeOfDay:: | **0x68** | int | **`DayOfYear`** | `FUN_005a7510:5304`: `= floor(withinYearSec / secsPerDay)` | | 0x6c | int | `SeasonIndex` | `FUN_005a7510:5313` | -The `iVar6 = *(int *)(DAT_008ee9c8 + 0x10)` read in `FUN_00501990` picks up `SecondsPerDay` as a multiplier — acting as a spread-factor to guarantee different days in different years yield different hash inputs. (If both sides of the multiplication were small — Year in [0, ~200], DayOfYear in [0, 365] — and `SecondsPerDay` were 1, the seed range would be tiny. Using the full `SecondsPerDay` integer makes the seed uniform over a ~31-bit range before the LCG step.) +The `days_per_year = GameTime::current_game_time->days_per_year` read in +`SkyDesc::CalcPresentDayGroup` is the year-to-day multiplier. The live memory +probe measured 360 at the older build's corresponding `TimeOfDay+0x10` field, +so `Year × DaysPerYear + DayOfYear` is a flat total-day index. -Retail Dereth values (from dat — confirm when we next parse a Region): `SecondsPerDay = 1800` (30 real-minutes = one Dereth-day per r12 §11, times some scale factor baked into the dat). Any value works — the algorithm is agnostic. +Retail Dereth's DAT-backed `DaysPerYear` value is 360. --- ## 5. Pseudocode (ready for C# port) ```csharp -// SkyDesc.PickCurrentDayGroup — ports acclient.exe FUN_00501990 @ 0x00501990 +// SkyDesc.CalcPresentDayGroup — named retail @ 0x00500E10 +// Older-build cross-reference: FUN_00501990 @ 0x00501990 // Decompile: docs/research/decompiled/chunk_00500000.c:1276 // Must be called every frame before SkyDesc.UpdateSkyObjectTable; value is // stable across frames within one Dereth-day so repeated calls are cheap. -public static int PickCurrentDayGroup(int year, int secondsPerDay, int dayOfYear, int dayGroupCount) +public static int CalcPresentDayGroup(int year, int daysPerYear, int dayOfYear, int dayGroupCount) { // Step 1: deterministic per-day seed. // (This mirrors retail's 3-int read from TimeOfDay+0x64/0x10/0x68.) - int seed = year * secondsPerDay + dayOfYear; + int seed = year * daysPerYear + dayOfYear; // Step 2: 32-bit signed LCG (x86 wraps silently; force it in C#). int mixed = unchecked(seed * 0x6A42FDB2 + (int)0x8ABE1652); @@ -180,14 +189,15 @@ Call from the per-frame sky render hook, **before** the keyframe-bracket interpo ```csharp // once per frame, before sky object/light interp -skyDesc.CurrentDayGroupIndex = SkyDesc.PickCurrentDayGroup( +skyDesc.CurrentDayGroupIndex = SkyDesc.CalcPresentDayGroup( year: world.TimeOfDay.Year, - secondsPerDay: world.TimeOfDay.SecondsPerDayInt, // the int copy at TimeOfDay+0x10 + daysPerYear: world.TimeOfDay.DaysPerYear, dayOfYear: world.TimeOfDay.DayOfYear, dayGroupCount: skyDesc.DayGroups.Count); ``` -If ACE and acdream agree on `(Year, SecondsPerDay, DayOfYear)` (trivially true — ACE computes these server-side from the same epoch), both clients will converge to the same index every Dereth-day. This replaces the current SplitMix64 path. +Retail and acdream converge whenever their `(Year, DaysPerYear, DayOfYear)` +inputs agree. ACE does not select a day group; each client computes it locally. --- @@ -204,12 +214,10 @@ So retail's decompile IS our only source, and we have it. ## 7. Gaps -None critical. Two minor open items: +None critical. One minor open item: 1. **`_DAT_007c6f10` exact bit value.** I inferred `1.0/2^32` from usage pattern but did not find a data-section dump that prints the literal. If the live client ever shows off-by-one drift on a boundary day, re-verify by dumping the .rdata section at 0x007c6f10. (ACDREAM_DUMP_SKY already logs picked index + inputs; we can correlate against a retail client session to confirm.) -2. **`TimeOfDay+0x10` value for live Dereth.** Retail `SecondsPerDay` has been quoted as both 1800 and 3600 in various reverse-engineering docs depending on whether "seconds" means realtime seconds or Dereth-clock seconds. The algorithm itself is insensitive — whatever integer the dat provides is what both retail and acdream must use. If our Region parser stores it in a differently-named field, make sure the call site passes the dat-raw value (the int at TimeOfDay+0x10), not a re-derived one. - --- ## 8. Summary table @@ -217,8 +225,8 @@ None critical. Two minor open items: | Question | Answer | Evidence | |---|---|---| | Caller of `FUN_00502a10` | `FUN_004ff4b0` → called from `FUN_00508010:7560` | `chunk_004F0000.c:10732`, `chunk_00500000.c:7560` | -| DayGroup selection function | `FUN_00501990` | `chunk_00500000.c:1276` | -| Hash formula | `(Year × SecondsPerDay + DayOfYear) × 0x6A42FDB2 + 0x8ABE1652` | `chunk_00500000.c:1296` | +| DayGroup selection function | `SkyDesc::CalcPresentDayGroup @ 0x00500E10` | named Sept 2013 PDB/pseudo-C; older fallback `FUN_00501990`, `chunk_00500000.c:1276` | +| Hash formula | `(Year × DaysPerYear + DayOfYear) × 0x6A42FDB2 + 0x8ABE1652` | `SkyDesc::CalcPresentDayGroup @ 0x00500E10`; older `chunk_00500000.c:1296` | | Weighted by ChanceOfOccur? | **No.** Uniform over DayGroupCount. | No CDF loop in FUN_00501990 | | Selected index stored at | `SkyDesc + 0x00` (via `*param_1` write) | `chunk_00500000.c:1307,1309` | | Read back by | `FUN_00502a10:2429`, `FUN_00501600`, `FUN_00501860` | `chunk_00500000.c:2429` | diff --git a/docs/research/2026-04-23-retail-memory-probe.md b/docs/research/2026-04-23-retail-memory-probe.md index f1c71351..c7df7323 100644 --- a/docs/research/2026-04-23-retail-memory-probe.md +++ b/docs/research/2026-04-23-retail-memory-probe.md @@ -35,7 +35,8 @@ DayOfYear = 47 ✓ The decompile agent's C trail (`docs/research/2026-04-23-sky-decompile-hunt-C.md` §1 + §4) labeled `TimeOfDay+0x10` as *"SecondsPerDay (int copy — source of iVar6)"* -for the `FUN_00501990` (`SkyDesc::PickCurrentDayGroup`) LCG seed. +for the named `SkyDesc::CalcPresentDayGroup @ 0x00500E10` LCG seed +(older-build cross-reference `FUN_00501990 @ 0x00501990`). The live probe disproves that. The value is **360** — the same as `GameTime.DaysPerYear` from the dat. @@ -43,7 +44,8 @@ The live probe disproves that. The value is **360** — the same as Implication for the retail LCG seed formula: ```c -// FUN_00501990 line 1296 of chunk_00500000.c: +// SkyDesc::CalcPresentDayGroup @ 0x00500E10 +// (older build: FUN_00501990 line 1296 of chunk_00500000.c): iVar4 = (iVar3 * iVar6 + iVar4) * 0x6a42fdb2 + -0x7541e9ae; // ^Year ^x0x10 ^DayOfYear ``` diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index a6f535d4..96e288f4 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -3,9 +3,9 @@ ## Current state The behavior-preserving App ownership campaign is complete through Slice 7 and -Slice 8 checkpoints A–C. `GameWindow.cs` moved from the 2026-07-21 baseline of -15,723 lines / 278 fields / 205 methods to 4,589 lines / 196 fields / 69 -methods: 11,134 lines (70.8%) were removed without changing accepted gameplay +Slice 8 checkpoints A–D. `GameWindow.cs` moved from the 2026-07-21 baseline of +15,723 lines / 278 fields / 205 methods to 4,330 lines / 192 fields / 67 +methods: 11,393 lines (72.5%) were removed without changing accepted gameplay or rendering behavior. | Slice | Ownership moved out | Closeout size | @@ -17,6 +17,7 @@ or rendering behavior. | 5 | landblock build, publication, retirement, and shared-origin lifetime | 8,811 / 247 / 153 | | 6 | complete update-frame orchestration | 7,026 / 241 / 108 | | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | +| 8 A–D | shell freeze, native callbacks, live-session composition, world environment | 4,330 / 192 / 67 | Slice 8 is an ordered checkpoint campaign rather than another feature-body move. A froze/pruned the shell boundary; B gave all nine native window callback @@ -25,7 +26,13 @@ reset/selection/entry/route composition while keeping `LiveSessionController` the sole session owner. C also replaced the embedded ACE shortcut with the named-retail `SkillFormula::Calculate @ 0x00591960` port. Partial route construction and individual detach edges are retained and retried; reset and a -new generation cannot pass an incompletely detached route. +new generation cannot pass an incompletely detached route. D added +`WorldEnvironmentController` as the one-shot owner of WorldTime, loaded sky, +day-group selection, Weather, server synchronization, AdminEnvirons, and debug +cycles. `GameWindow` retains only ABI-compatible readonly aliases and typed +composition edges. The named-oracle correction is +`SkyDesc::CalcPresentDayGroup @ 0x00500E10`; the carried AdminEnvirons audio and +full fog/ambient/radar gaps are TS-54/TS-55. Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. @@ -83,6 +90,9 @@ review rule live in `docs/architecture/code-structure.md`. ## Accepted verification +- Slice 8 Checkpoint D Release suite: 7,419 passed / 5 fixture or environment + skips; App 3,059/3 skips. Focused environment/boundary tests and all three + independent corrected-diff reviews are clean. - Slice 8 Checkpoint C Release suite: 7,408 passed / 5 fixture or environment skips; App 3,048/3 skips and Core.Net 548/0. All three independent corrected- diff reviews are clean. @@ -109,8 +119,8 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Slice 8 checkpoints D–L group the remaining environment, input, settings, -resource ownership, startup, and shutdown wiring and reduce Silk callbacks to +Slice 8 checkpoints E–L group the remaining input, settings, resource +ownership, startup, and shutdown wiring and reduce Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a canonical owner; it does not redesign gameplay, rendering, or resource lifetime. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3e3eb48b..3e185b01 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -101,7 +101,8 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; private AcDream.App.Rendering.RenderFrameOrchestrator? _renderFrameOrchestrator; - private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState; + private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment = + new(Console.WriteLine); private ResourceShutdownTransaction? _shutdown; private readonly DisplayFramePacingController _displayFramePacing; @@ -380,13 +381,12 @@ public sealed class GameWindow : IDisposable // DevToolsEnabled reads through typed RuntimeOptions. private bool DevToolsEnabled => _options.DevTools; - // Phase G.1-G.2 world lighting/time state. - public readonly AcDream.Core.World.WorldTimeService WorldTime = - new AcDream.Core.World.WorldTimeService( - AcDream.Core.World.SkyStateProvider.Default()); + // Phase G.1-G.2 world lighting/time state. The environment owner keeps + // the clock, selected day group, and weather transitions coherent. + public readonly AcDream.Core.World.WorldTimeService WorldTime; public readonly AcDream.Core.Lighting.LightManager Lighting = new(); - public readonly AcDream.Core.World.WeatherSystem Weather = new(); + public readonly AcDream.Core.World.WeatherSystem Weather; // Wired into the hook router in OnLoad so SetLightHook fires // from the animation pipeline flip the matching LightSource.IsLit. private AcDream.Core.Lighting.LightingHookSink? _lightingSink; @@ -407,22 +407,6 @@ public sealed class GameWindow : IDisposable // sun / ambient / fog / flash data per frame. private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo; private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer; - private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc; - - // Phase 3a — retail-faithful per-Dereth-day weather roll. The active - // DayGroup is re-picked deterministically whenever the server clock - // crosses a DayTicks boundary. long.MinValue sentinel means - // "no day rolled yet" so the first RefreshSkyForCurrentDay call - // unconditionally installs a provider. See r12 §11 for the roller - // semantics. - private long _loadedSkyDayIndex = long.MinValue; - - // F7 / F10 debug-cycle steps for time + weather. Initialized out of - // range of the real values so the first press hits index 0 of the - // cycle table cleanly. - private int _timeDebugStep = 0; - private int _weatherDebugStep = 0; - // Phase B.2: player movement mode. private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new(); private AcDream.App.Input.PlayerMovementController? _playerController @@ -587,6 +571,8 @@ public sealed class GameWindow : IDisposable AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); + WorldTime = _worldEnvironment.WorldTime; + Weather = _worldEnvironment.Weather; _retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot(); _inputCapture = new AcDream.App.Input.CompositeInputCaptureSource( new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools), @@ -685,8 +671,6 @@ public sealed class GameWindow : IDisposable private void OnLoad() { - _worldSceneSkyState ??= new AcDream.App.Rendering.WorldSceneSkyState( - WorldTime); // Task 7: wire the physics data cache into the engine so Transition can // run narrow-phase BSP tests during FindObjCollisions. _physicsEngine.DataCache = _physicsDataCache; @@ -1276,79 +1260,9 @@ public sealed class GameWindow : IDisposable if (heightTable is null || heightTable.Length < 256) throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated"); - // Phase G.1: parse the full sky descriptor (day groups, keyframes, - // celestial mesh layers) and swap WorldTime's provider over to the - // dat-backed keyframes. The stub default provider is only used if - // the Region lacks HasSkyInfo. - if (region is not null) - { - _loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region); - if (_loadedSkyDesc is not null) - { - // Phase 3d: do NOT assign WorldTime.TickSize from - // SkyDesc.TickSize. Agent C's decompile (chunk_00500000.c:6241 - // FUN_005062e0) shows SkyDesc.TickSize is the "next sky-tick - // deadline" period — a throttle — NOT a game-time - // advancement rate. ACE's server advances PortalYearTicks at - // 1.0 ticks per real-second (Timers.cs: `PortalYearTicks += - // worldTickTimer.Elapsed.TotalSeconds`). Our client - // extrapolation between TimeSyncs must match: 1.0. - // - // Previous behavior: WorldTime.TickSize = 0.8 (from the live - // SkyDesc.TickSize). Between ~20s TimeSync gaps we fell 4 - // ticks behind the server, producing a visible "acdream sky - // is behind retail" time-of-day mismatch (user-verified - // 2026-04-23). - WorldTime.TickSize = 1.0; - - // Phase 3f: adopt the dat's GameTime.ZeroTimeOfYear as the - // calendar-extraction offset. Dereth's dat value is 3600 - // (verified 2026-04-23 live dump); ACE's DerethDateTime.cs - // comment that "tick 0 = Morntide-and-Half" (3333.75 - // offset = +7/16) is WRONG by 266.25 ticks against the - // authoritative dat. The mismatch cascaded into both the - // wrong hour label AND the wrong DayOfYear at boundary - // times (different LCG seed → different DayGroup roll), - // which explained the user's observation of "acdream - // clear night, retail stormy pre-dawn" at the same - // server PortalYearTicks. - if (region.GameTime is not null) - { - AcDream.Core.World.DerethDateTime.SetOriginOffsetFromDat( - region.GameTime.ZeroTimeOfYear); - Console.WriteLine( - $"sky: GameTime ZeroTimeOfYear={region.GameTime.ZeroTimeOfYear} " + - $"(was default {AcDream.Core.World.DerethDateTime.DayFractionOriginOffsetTicks})"); - } - - Console.WriteLine( - $"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " + - $"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " + - $"LightTickSize={_loadedSkyDesc.LightTickSize}"); - - // Initial DayGroup roll using whatever WorldTime currently - // has (either the hardcoded boot seed or a pre-arrived - // server sync). RefreshSkyForCurrentDay will re-roll when - // ServerTimeUpdated delivers the real ConnectRequest tick. - RefreshSkyForCurrentDay(); - } - } - - // Seed WorldTime to noon so outdoor scenes aren't pitch-black before - // the server sends its first TimeSync packet (offline rendering in - // particular never receives one). - // - // "Noon" here means sun at zenith — dayFraction = 0.5. Because - // DerethDateTime applies a +7/16 offset (tick 0 = Morntide-and-Half, - // hour 8 of 16), we need raw ticks = 476.25 (one hour past tick 0 = - // Midsong / Hour 9, which is what retail considers noon). - // - // Using `DayTicks * 0.5 = 3810` WOULD be correct if the offset were - // zero, but with our 3333.75-tick shift it lands on dayFraction - // 0.9375 — that's Gloaming-and-Half (sunset, nearly midnight), - // producing a dim orange sky with the sun below the horizon until - // TimeSync arrives. - WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks / 16.0); // = 476.25 = Midsong (noon) + // Parse and install the DAT-backed sky, retail day-group state, and + // offline clock seed through their single environment owner. + _worldEnvironment.Initialize(region!); // N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters BEFORE // building the terrain atlas / renderer — both consume BindlessSupport @@ -2872,7 +2786,7 @@ public sealed class GameWindow : IDisposable var worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer( renderFrameResources, renderLoginState, - _worldSceneSkyState!, + _worldEnvironment, worldRenderFrameBuilder, new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState), _retailSelectionScene, @@ -3120,12 +3034,8 @@ public sealed class GameWindow : IDisposable session, _liveEntitySessionEvents.CreateSink(), new AcDream.App.Net.LiveEnvironmentSessionSink( - OnEnvironChanged, - ticks => - { - WorldTime.SyncFromServer(ticks); - RefreshSkyForCurrentDay(); - }), + _worldEnvironment.ApplyAdminEnvirons, + _worldEnvironment.SynchronizeFromServer), CreateLiveInventorySessionBindings(), CreateLiveCharacterSessionBindings(skillTable), CreateLiveSocialSessionBindings()); @@ -3251,60 +3161,6 @@ public sealed class GameWindow : IDisposable session.SendTurbineChatTo( roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - /// - /// Phase 5d — retail AdminEnvirons (0xEA60) dispatcher. - /// Routes fog presets into the weather system's sticky override - /// slot and logs the sound cues (Thunder1..6, Roar, Bell, etc) - /// for now — actual sound playback needs a lookup table from - /// EnvironChangeType → wave asset, which we don't yet - /// have dat-indexed; follow-up will wire the thunder wave ids. - /// - private void OnEnvironChanged(uint environChangeType) - { - // Fog presets — values match AcDream.Core.World.EnvironOverride - // byte-for-byte (we deliberately mirrored retail's enum). - if (environChangeType <= 0x06u) - { - Weather.Override = (AcDream.Core.World.EnvironOverride)environChangeType; - Console.WriteLine( - $"live: AdminEnvirons fog override = " + - $"{(AcDream.Core.World.EnvironOverride)environChangeType}"); - return; - } - - // Sound cues 0x65..0x7B. Log by retail name for now; audio - // binding is a separate follow-up (needs sound-table indexing - // plus a PlaySound API on OpenAlAudioEngine that takes a - // retail sound enum → wave-id mapping). - string name = environChangeType switch - { - 0x65u => "RoarSound", - 0x66u => "BellSound", - 0x67u => "Chant1Sound", - 0x68u => "Chant2Sound", - 0x69u => "DarkWhispers1Sound", - 0x6Au => "DarkWhispers2Sound", - 0x6Bu => "DarkLaughSound", - 0x6Cu => "DarkWindSound", - 0x6Du => "DarkSpeechSound", - 0x6Eu => "DrumsSound", - 0x6Fu => "GhostSpeakSound", - 0x70u => "BreathingSound", - 0x71u => "HowlSound", - 0x72u => "LostSoulsSound", - 0x75u => "SquealSound", - 0x76u => "Thunder1Sound", - 0x77u => "Thunder2Sound", - 0x78u => "Thunder3Sound", - 0x79u => "Thunder4Sound", - 0x7Au => "Thunder5Sound", - 0x7Bu => "Thunder6Sound", - _ => $"Unknown(0x{environChangeType:X2})", - }; - Console.WriteLine( - $"live: AdminEnvirons sound cue = {name} " + - $"(0x{environChangeType:X2}) — audio binding pending"); - } private void OnUpdate(double dt) { using var _updStage = _frameProfiler.BeginStage( @@ -3413,92 +3269,6 @@ public sealed class GameWindow : IDisposable // EXPECTED-DIFF: local sidestep pacing now matches how remotes have // always played (w6-cutover-map.md R3). - /// - /// Phase 3a — re-roll the active DayGroup whenever the current - /// Dereth-day index differs from what we last installed. Idempotent - /// within the same server-day. Swaps both the - /// feeding - /// (for lighting interp) and the cached - /// (for the sky-object - /// render loop). - /// - /// - /// Honors ACDREAM_DAY_GROUP=N — when set, every call picks - /// group N regardless of day index. Useful for A/B testing each - /// weather preset against retail. See - /// - /// for the roller. - /// - /// - private void RefreshSkyForCurrentDay() - { - if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0) - return; - - // Retail FUN_00501990 seeds the LCG with the triple stored in - // TimeOfDay +0x64 (Year), +0x10 (misc. int), +0x68 (DayOfYear) - // - // The decompile agent labeled +0x10 "SecondsPerDay (int copy)" - // but a live memory probe of retail's acclient.exe (2026-04-23, - // tools/RetailTimeProbe) shows the value is actually **360** — - // semantically DaysPerYear, not seconds. So the LCG seed is - // seed = Year × DaysPerYear + DayOfYear - // which is literally "total days since epoch" (a flat day index), - // confirmed against retail's Year=116, DayOfYear=47, seed=41807. - // - // Previously we passed 7620 (DayTicks), producing seed 883967 — - // a completely different LCG output → wrong DayGroup pick → - // user-observed weather mismatch (acdream clear while retail - // stormy, 2026-04-23). The live probe nailed the fix. - double ticks = WorldTime.NowTicks; - int absYear = AcDream.Core.World.DerethDateTime.AbsoluteYear(ticks); - int dayOfYear = AcDream.Core.World.DerethDateTime.DayOfYear(ticks); - int secondsPerDay = AcDream.Core.World.DerethDateTime.DaysInAMonth - * AcDream.Core.World.DerethDateTime.MonthsInAYear; // 360 - - // Composite day key for change-detection and logging only; the - // LCG seed is computed inside SelectDayGroupIndex from (absYear, - // secondsPerDay, dayOfYear). - long dayIndex = (long)absYear * 360 + dayOfYear; - - int idx = _loadedSkyDesc.SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear); - var grp = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count - ? _loadedSkyDesc.DayGroups[idx] - : null; - - bool dayChanged = dayIndex != _loadedSkyDayIndex; - bool groupChanged = !ReferenceEquals( - grp, - _worldSceneSkyState?.ActiveDayGroup); - - if (!dayChanged && !groupChanged) return; - - _loadedSkyDayIndex = dayIndex; - (_worldSceneSkyState - ??= new AcDream.App.Rendering.WorldSceneSkyState(WorldTime)) - .ActiveDayGroup = grp; - - if (grp is not null && grp.SkyTimes.Count > 0) - { - WorldTime.SetProvider( - new AcDream.Core.World.SkyStateProvider( - grp.SkyTimes.Select(s => s.Keyframe).ToList())); - - // Phase 3e: drive the atmospheric weather (rain/snow emitters, - // fog-override categories, lightning strobe) from the retail - // DayGroup name. Stops the legacy WeatherSystem.RollKind hash - // from spawning rain particles on a "Sunny" day (user-observed - // rain regression 2026-04-23 after the retail picker landed on - // DayGroup[6] "Sunny" but the internal hash picked Rain). - Weather.SetKindFromDayGroupName(grp.Name); - - Console.WriteLine( - $"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{grp.Name}\" " + - $"(Chance={grp.ChanceOfOccur:F2}, {grp.SkyObjects.Count} objects, " + - $"{grp.SkyTimes.Count} keyframes, weather={Weather.Kind})"); - } - } - // ── Phase I.2 — DebugPanel helpers ──────────────────────────────── // // The ImGui DebugPanel reads through DebugVM closures that ask @@ -3569,27 +3339,8 @@ public sealed class GameWindow : IDisposable /// private void CycleTimeOfDay() { - // none → 0.0 (midnight) → 0.25 (dawn) → 0.5 (noon) → 0.75 (dusk) → none - _timeDebugStep = (_timeDebugStep + 1) % 5; - float? pick = _timeDebugStep switch - { - 0 => (float?)null, - 1 => 0.0f, - 2 => 0.25f, - 3 => 0.5f, - 4 => 0.75f, - _ => null, - }; - if (pick.HasValue) - { - WorldTime.SetDebugTime(pick.Value); - _debugVm?.AddToast($"Time override = {pick.Value:F2}"); - } - else - { - WorldTime.ClearDebugTime(); - _debugVm?.AddToast("Time override cleared"); - } + string message = _worldEnvironment.CycleTimeOfDay(); + _debugVm?.AddToast(message); } /// @@ -3597,17 +3348,8 @@ public sealed class GameWindow : IDisposable /// private void CycleWeather() { - var kinds = new[] - { - AcDream.Core.World.WeatherKind.Clear, - AcDream.Core.World.WeatherKind.Overcast, - AcDream.Core.World.WeatherKind.Rain, - AcDream.Core.World.WeatherKind.Snow, - AcDream.Core.World.WeatherKind.Storm, - }; - _weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length; - Weather.ForceWeather(kinds[_weatherDebugStep]); - _debugVm?.AddToast($"Weather = {kinds[_weatherDebugStep]}"); + string message = _worldEnvironment.CycleWeather(); + _debugVm?.AddToast(message); } /// @@ -4380,7 +4122,6 @@ public sealed class GameWindow : IDisposable new("world frame composition", () => { _renderFrameOrchestrator = null; - _worldSceneSkyState = null; }), ]), new ResourceShutdownStage("session dependents", diff --git a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs index bd728e53..9a0ca9a8 100644 --- a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs +++ b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs @@ -13,25 +13,6 @@ internal interface IWorldSceneSkyStateSource float DayFraction { get; } } -/// -/// Holds the selected retail day group beside the world clock that provides -/// its current interpolation point. Day-group selection remains an update-time -/// concern; render owners receive this read-only seam. -/// -internal sealed class WorldSceneSkyState : IWorldSceneSkyStateSource -{ - private readonly WorldTimeService _worldTime; - - public WorldSceneSkyState(WorldTimeService worldTime) - { - _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); - } - - public DayGroupData? ActiveDayGroup { get; set; } - - public float DayFraction => (float)_worldTime.DayFraction; -} - internal interface IWorldSceneEntitySource { IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, diff --git a/src/AcDream.App/World/WorldEnvironmentController.cs b/src/AcDream.App/World/WorldEnvironmentController.cs new file mode 100644 index 00000000..c79a65ef --- /dev/null +++ b/src/AcDream.App/World/WorldEnvironmentController.cs @@ -0,0 +1,249 @@ +using AcDream.App.Rendering; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.World; + +/// +/// Owns the live Dereth clock, selected retail day group, weather state, and +/// server environment overrides as one coherent environment lifetime. +/// +internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource +{ + private static readonly WeatherKind[] DebugWeatherKinds = + [ + WeatherKind.Clear, + WeatherKind.Overcast, + WeatherKind.Rain, + WeatherKind.Snow, + WeatherKind.Storm, + ]; + + private readonly Action _log; + private LoadedSkyDesc? _loadedSkyDesc; + private long _loadedSkyDayIndex = long.MinValue; + private int _timeDebugStep; + private int _weatherDebugStep; + private bool _initializationClaimed; + + public WorldEnvironmentController(Action? log = null) + : this( + new WorldTimeService(SkyStateProvider.Default()), + new WeatherSystem(), + log) + { + } + + internal WorldEnvironmentController( + WorldTimeService worldTime, + WeatherSystem weather, + Action? log = null) + { + WorldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); + Weather = weather ?? throw new ArgumentNullException(nameof(weather)); + _log = log ?? (_ => { }); + } + + public WorldTimeService WorldTime { get; } + + public WeatherSystem Weather { get; } + + public DayGroupData? ActiveDayGroup { get; private set; } + + public float DayFraction => (float)WorldTime.DayFraction; + + /// + /// Loads the Region DAT environment and seeds the pre-session clock exactly + /// as the former GameWindow.OnLoad body did. + /// + public void Initialize(Region region) + { + ArgumentNullException.ThrowIfNull(region); + Initialize( + SkyDescLoader.LoadFromRegion(region), + region.GameTime?.ZeroTimeOfYear); + } + + internal void Initialize( + LoadedSkyDesc? loadedSkyDesc, + double? zeroTimeOfYear) + { + if (_initializationClaimed) + { + throw new InvalidOperationException( + "The world environment is a one-shot GameWindow lifetime owner."); + } + + _initializationClaimed = true; + _loadedSkyDesc = loadedSkyDesc; + _loadedSkyDayIndex = long.MinValue; + ActiveDayGroup = null; + + // Region GameTime is authoritative when present. A Region without it + // gets the documented offline fallback rather than inheriting mutable + // process-global state from an earlier window or test lifetime. + double origin = zeroTimeOfYear + ?? DerethDateTime.DayFractionOriginOffsetTicks; + DerethDateTime.SetOriginOffsetFromDat(origin); + + if (_loadedSkyDesc is not null) + { + // SkyDesc.TickSize is retail's next-sky-update throttle, not the + // rate of PortalYearTicks. ACE advances the server clock at one + // tick per real second, so client extrapolation must remain 1.0. + WorldTime.TickSize = 1.0; + + if (zeroTimeOfYear.HasValue) + { + _log( + $"sky: GameTime ZeroTimeOfYear={zeroTimeOfYear.Value} " + + $"(was default {DerethDateTime.DayFractionOriginOffsetTicks})"); + } + + _log( + $"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " + + $"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " + + $"LightTickSize={_loadedSkyDesc.LightTickSize}"); + + // The initial roll intentionally precedes the offline noon seed, + // preserving the accepted OnLoad order. The first server sync + // below will select the authoritative day. + RefreshSkyForCurrentDay(); + } + + WorldTime.SyncFromServer(DerethDateTime.DayTicks / 16.0); + } + + public void SynchronizeFromServer(double ticks) + { + WorldTime.SyncFromServer(ticks); + RefreshSkyForCurrentDay(); + } + + /// + /// Environment packet bridge researched from + /// CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20. The current + /// fog approximation and missing centered UI-sound playback remain + /// explicitly registered as TS-55 and TS-54 respectively. + /// + public void ApplyAdminEnvirons(uint environChangeType) + { + if (environChangeType <= 0x06u) + { + Weather.Override = (EnvironOverride)environChangeType; + _log( + $"live: AdminEnvirons fog override = " + + $"{(EnvironOverride)environChangeType}"); + return; + } + + string name = environChangeType switch + { + 0x65u => "RoarSound", + 0x66u => "BellSound", + 0x67u => "Chant1Sound", + 0x68u => "Chant2Sound", + 0x69u => "DarkWhispers1Sound", + 0x6Au => "DarkWhispers2Sound", + 0x6Bu => "DarkLaughSound", + 0x6Cu => "DarkWindSound", + 0x6Du => "DarkSpeechSound", + 0x6Eu => "DrumsSound", + 0x6Fu => "GhostSpeakSound", + 0x70u => "BreathingSound", + 0x71u => "HowlSound", + 0x72u => "LostSoulsSound", + 0x75u => "SquealSound", + 0x76u => "Thunder1Sound", + 0x77u => "Thunder2Sound", + 0x78u => "Thunder3Sound", + 0x79u => "Thunder4Sound", + 0x7Au => "Thunder5Sound", + 0x7Bu => "Thunder6Sound", + _ => $"Unknown(0x{environChangeType:X2})", + }; + + _log( + $"live: AdminEnvirons sound cue = {name} " + + $"(0x{environChangeType:X2}) — audio binding pending"); + } + + /// + /// Selects the active DAT day group when the authoritative Dereth day + /// changes. The picker itself is the retail-verbatim + /// SkyDesc::CalcPresentDayGroup @ 0x00500E10 port (older-build + /// cross-reference FUN_00501990). + /// + public void RefreshSkyForCurrentDay() + { + if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0) + return; + + double ticks = WorldTime.NowTicks; + int absYear = DerethDateTime.AbsoluteYear(ticks); + int dayOfYear = DerethDateTime.DayOfYear(ticks); + int daysPerYear = DerethDateTime.DaysInAMonth + * DerethDateTime.MonthsInAYear; + long dayIndex = (long)absYear * 360 + dayOfYear; + + int idx = _loadedSkyDesc.SelectDayGroupIndex( + absYear, + daysPerYear, + dayOfYear); + DayGroupData? group = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count + ? _loadedSkyDesc.DayGroups[idx] + : null; + + bool dayChanged = dayIndex != _loadedSkyDayIndex; + bool groupChanged = !ReferenceEquals(group, ActiveDayGroup); + if (!dayChanged && !groupChanged) + return; + + _loadedSkyDayIndex = dayIndex; + ActiveDayGroup = group; + + if (group is null || group.SkyTimes.Count == 0) + return; + + WorldTime.SetProvider( + new SkyStateProvider( + group.SkyTimes.Select(s => s.Keyframe).ToList())); + Weather.SetKindFromDayGroupName(group.Name); + + _log( + $"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{group.Name}\" " + + $"(Chance={group.ChanceOfOccur:F2}, {group.SkyObjects.Count} objects, " + + $"{group.SkyTimes.Count} keyframes, weather={Weather.Kind})"); + } + + public string CycleTimeOfDay() + { + _timeDebugStep = (_timeDebugStep + 1) % 5; + float? selection = _timeDebugStep switch + { + 0 => null, + 1 => 0.0f, + 2 => 0.25f, + 3 => 0.5f, + 4 => 0.75f, + _ => null, + }; + + if (selection.HasValue) + { + WorldTime.SetDebugTime(selection.Value); + return $"Time override = {selection.Value:F2}"; + } + + WorldTime.ClearDebugTime(); + return "Time override cleared"; + } + + public string CycleWeather() + { + _weatherDebugStep = (_weatherDebugStep + 1) % DebugWeatherKinds.Length; + WeatherKind kind = DebugWeatherKinds[_weatherDebugStep]; + Weather.ForceWeather(kind); + return $"Weather = {kind}"; + } +} diff --git a/src/AcDream.Core/World/DerethDateTime.cs b/src/AcDream.Core/World/DerethDateTime.cs index a2e843d0..5e65b67a 100644 --- a/src/AcDream.Core/World/DerethDateTime.cs +++ b/src/AcDream.Core/World/DerethDateTime.cs @@ -61,8 +61,9 @@ public static class DerethDateTime /// Base/anchor year for the Portal Year calendar (retail /// GameTime.ZeroYear = 10). Tick 0 corresponds to PY 10, /// Morningthaw 1, Morntide-and-Half. Retail's TimeOfDay+0x64 - /// ("absolute year") includes this offset, and SkyDesc::PickCurrentDayGroup - /// (FUN_00501990) feeds the absolute year into its LCG seed. + /// ("absolute year") includes this offset, and + /// SkyDesc::CalcPresentDayGroup @ 0x00500E10 feeds the absolute year + /// into its LCG seed (older-build cross-reference FUN_00501990). /// public const int ZeroYear = 10; @@ -244,7 +245,8 @@ public static class DerethDateTime /// Matches retail's TimeOfDay + 0x64 field /// (FUN_005a7510:5300: /// floor((worldTime+base)/secsPerYear) + baseYear). This is - /// the value the retail DayGroup picker (FUN_00501990) feeds + /// the value the retail DayGroup picker + /// (SkyDesc::CalcPresentDayGroup @ 0x00500E10) feeds /// into its LCG seed, so acdream must match for identical weather /// picks vs retail. /// @@ -256,7 +258,7 @@ public static class DerethDateTime /// (dat's ZeroTimeOfYear) to match retail's TimeOfDay + 0x68 /// field (FUN_005a7510:5304: /// floor(withinYearSec / secsPerDay)). Consumed by - /// SkyDesc.PickCurrentDayGroup as part of the per-day seed. + /// SkyDesc::CalcPresentDayGroup as part of the per-day seed. /// public static int DayOfYear(double ticks) { diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index b3380f3b..f6f61d15 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -186,8 +186,9 @@ public sealed class LoadedSkyDesc /// /// Pick a deterministically for the given /// Derethian (year, dayOfYear) pair. Retail-verbatim port of - /// SkyDesc::PickCurrentDayGroup (FUN_00501990 at - /// chunk_00500000.c:1276) — the per-frame weather roller. + /// SkyDesc::CalcPresentDayGroup @ 0x00500E10. The older-build + /// fallback is FUN_00501990 at + /// chunk_00500000.c:1276. /// /// /// Algorithm (from the retail decompile): @@ -206,14 +207,10 @@ public sealed class LoadedSkyDesc /// /// /// - /// should be the dat-declared - /// "seconds per Derethian day" integer (retail reads it from - /// TimeOfDay + 0x10). acdream's callers pass - /// as an int (7620); ACE - /// computes the same value server-side so retail and acdream - /// converge on identical picks whenever their (Year, DayOfYear) - /// agree — which they do, because both derive from the server's - /// PortalYearTicks. + /// Despite its historical parameter name, + /// is the integer at retail TimeOfDay + 0x10. A live retail probe + /// established that this is Dereth's days-per-year value (360), which + /// WorldEnvironmentController supplies from the calendar constants. /// /// /// @@ -237,7 +234,7 @@ public sealed class LoadedSkyDesc if (DayGroups.Count == 1) return 0; - // --- Retail FUN_00501990 line-by-line port --- + // --- Retail SkyDesc::CalcPresentDayGroup @ 0x00500E10 --- // Step 1: deterministic per-day seed. int seed = unchecked(year * secondsPerDay + dayOfYear); @@ -280,8 +277,8 @@ public sealed class LoadedSkyDesc int dayOfYear = DerethDateTime.DayOfYear(serverTicks); // Retail's TimeOfDay+0x10 is actually DaysPerYear (= 360 for Dereth, // live probe 2026-04-23), NOT SecondsPerDay as the decompile agent - // mis-labeled. See GameWindow.RefreshSkyForCurrentDay for the full - // citation. + // mis-labeled. See WorldEnvironmentController.RefreshSkyForCurrentDay + // for the owning call site. int secondsPerDay = DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear; // 360 int idx = SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear); return idx < DayGroups.Count ? DayGroups[idx] : null; diff --git a/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs index 51219fd9..4117ef1d 100644 --- a/src/AcDream.Core/World/WeatherState.cs +++ b/src/AcDream.Core/World/WeatherState.cs @@ -157,7 +157,7 @@ public sealed class WeatherSystem /// /// Drive the weather kind from the active retail DayGroup name - /// (see SkyDesc::PickCurrentDayGroup port at + /// (see SkyDesc::CalcPresentDayGroup @ 0x00500E10 port at /// LoadedSkyDesc.SelectDayGroupIndex). Retail has ONE source /// of truth for weather — the DayGroup roll — so this replaces the /// internal hash once the real DayGroup picker diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index e2e915b1..66428e24 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -88,6 +88,41 @@ public sealed class GameWindowSlice8BoundaryTests Assert.DoesNotContain("PrepareResources(", postStart, StringComparison.Ordinal); } + [Fact] + public void WorldEnvironment_IsOwnedAndGameWindowOnlyComposesItsTypedEdges() + { + string source = GameWindowSource(); + string load = MethodBody( + "private void OnLoad()", + "private AcDream.App.Net.LiveSessionHost"); + string sessionFactory = MethodBody( + "private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter(", + "private AcDream.App.Net.LiveInventorySessionBindings"); + + Assert.Contains( + "private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =", + source, + StringComparison.Ordinal); + Assert.Contains("_worldEnvironment.Initialize(region!);", load, StringComparison.Ordinal); + AssertAppearsInOrder( + sessionFactory, + "new AcDream.App.Net.LiveEnvironmentSessionSink(", + "_worldEnvironment.ApplyAdminEnvirons,", + "_worldEnvironment.SynchronizeFromServer)"); + Assert.DoesNotContain("_loadedSkyDesc", source, StringComparison.Ordinal); + Assert.DoesNotContain("_loadedSkyDayIndex", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void RefreshSkyForCurrentDay()", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void OnEnvironChanged(", source, StringComparison.Ordinal); + + string cycleTime = MethodBody( + "private void CycleTimeOfDay()", + "private void CycleWeather()"); + AssertAppearsInOrder( + cycleTime, + "_worldEnvironment.CycleTimeOfDay();", + "_debugVm?.AddToast(message);"); + } + [Fact] public void InputAction_PreservesRetailAcceptedPriority() { diff --git a/tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs b/tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs new file mode 100644 index 00000000..e78d9e11 --- /dev/null +++ b/tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs @@ -0,0 +1,187 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.World; + +namespace AcDream.App.Tests.World; + +[Collection(WorldEnvironmentControllerCollection.Name)] +public sealed class WorldEnvironmentControllerTests +{ + [Fact] + public void Initialize_InstallsDatDayGroupAndSeedsOfflineNoon() + { + List log = []; + var controller = CreateController(log); + LoadedSkyDesc sky = SingleGroupSky("Sunny", begin: 0f); + + controller.Initialize(sky, zeroTimeOfYear: null); + + Assert.Same(sky.DayGroups[0], controller.ActiveDayGroup); + Assert.Equal(WeatherKind.Clear, controller.Weather.Kind); + Assert.Equal(1.0, controller.WorldTime.TickSize); + Assert.InRange(controller.DayFraction, 0.499f, 0.501f); + Assert.Contains(log, line => line.Contains("loaded Region 0x13000000", StringComparison.Ordinal)); + Assert.Contains(log, line => line.Contains("DayGroup[0] \"Sunny\"", StringComparison.Ordinal)); + } + + [Fact] + public void SynchronizeFromServer_RefreshesAtMostOnceWithinSameDay() + { + List log = []; + var controller = CreateController(log); + controller.Initialize(SingleGroupSky("Cloudy", begin: 0f), zeroTimeOfYear: null); + + controller.SynchronizeFromServer(100_000.0); + int selectedAfterFirstSync = log.Count(IsDayGroupSelection); + controller.SynchronizeFromServer(100_000.0); + + Assert.Equal(selectedAfterFirstSync, log.Count(IsDayGroupSelection)); + Assert.Equal(WeatherKind.Overcast, controller.Weather.Kind); + } + + [Fact] + public void Initialize_IsOneShotAndRetainsFirstPublishedEnvironment() + { + var controller = CreateController([]); + LoadedSkyDesc first = SingleGroupSky("Sunny", begin: 0f); + controller.Initialize(first, zeroTimeOfYear: null); + + InvalidOperationException error = Assert.Throws( + () => controller.Initialize( + SingleGroupSky("Cloudy", begin: 0f), + zeroTimeOfYear: 3600.0)); + + Assert.Contains("one-shot", error.Message, StringComparison.Ordinal); + Assert.Same(first.DayGroups[0], controller.ActiveDayGroup); + Assert.Equal(WeatherKind.Clear, controller.Weather.Kind); + } + + [Fact] + public void Initialize_WithoutGameTimeRestoresDocumentedFallbackOrigin() + { + double previous = DerethDateTime.OriginOffsetTicks; + try + { + DerethDateTime.SetOriginOffsetFromDat(3600.0); + var controller = CreateController([]); + + controller.Initialize(SingleGroupSky("Sunny", begin: 0f), zeroTimeOfYear: null); + + Assert.Equal( + DerethDateTime.DayFractionOriginOffsetTicks, + DerethDateTime.OriginOffsetTicks); + Assert.InRange(controller.DayFraction, 0.499f, 0.501f); + } + finally + { + DerethDateTime.SetOriginOffsetFromDat(previous); + } + } + + [Theory] + [InlineData(0x00u, EnvironOverride.None)] + [InlineData(0x01u, EnvironOverride.RedFog)] + [InlineData(0x06u, EnvironOverride.BlackFog2)] + public void ApplyAdminEnvirons_FogValuesSetStickyOverride( + uint raw, + EnvironOverride expected) + { + List log = []; + var controller = CreateController(log); + + controller.ApplyAdminEnvirons(raw); + + Assert.Equal(expected, controller.Weather.Override); + Assert.Contains(log, line => line.Contains(expected.ToString(), StringComparison.Ordinal)); + } + + [Fact] + public void ApplyAdminEnvirons_SoundValuePreservesWeatherAndLogsRetailName() + { + List log = []; + var controller = CreateController(log); + controller.Weather.Override = EnvironOverride.GreenFog; + + controller.ApplyAdminEnvirons(0x78u); + + Assert.Equal(EnvironOverride.GreenFog, controller.Weather.Override); + Assert.Contains(log, line => line.Contains("Thunder3Sound", StringComparison.Ordinal)); + } + + [Fact] + public void CycleTimeOfDay_PreservesAcceptedFiveStepSequence() + { + var controller = CreateController([]); + + Assert.Equal("Time override = 0.00", controller.CycleTimeOfDay()); + Assert.Equal(0f, controller.DayFraction); + Assert.Equal("Time override = 0.25", controller.CycleTimeOfDay()); + Assert.Equal(0.25f, controller.DayFraction); + Assert.Equal("Time override = 0.50", controller.CycleTimeOfDay()); + Assert.Equal(0.5f, controller.DayFraction); + Assert.Equal("Time override = 0.75", controller.CycleTimeOfDay()); + Assert.Equal(0.75f, controller.DayFraction); + Assert.Equal("Time override cleared", controller.CycleTimeOfDay()); + } + + [Fact] + public void CycleWeather_PreservesAcceptedOrder() + { + var controller = CreateController([]); + + Assert.Equal("Weather = Overcast", controller.CycleWeather()); + Assert.Equal(WeatherKind.Overcast, controller.Weather.Kind); + Assert.Equal("Weather = Rain", controller.CycleWeather()); + Assert.Equal(WeatherKind.Rain, controller.Weather.Kind); + Assert.Equal("Weather = Snow", controller.CycleWeather()); + Assert.Equal(WeatherKind.Snow, controller.Weather.Kind); + Assert.Equal("Weather = Storm", controller.CycleWeather()); + Assert.Equal(WeatherKind.Storm, controller.Weather.Kind); + Assert.Equal("Weather = Clear", controller.CycleWeather()); + Assert.Equal(WeatherKind.Clear, controller.Weather.Kind); + } + + private static WorldEnvironmentController CreateController(List log) => + new( + new WorldTimeService(SkyStateProvider.Default()), + new WeatherSystem(), + log.Add); + + private static LoadedSkyDesc SingleGroupSky(string name, float begin) + { + var keyframe = new SkyKeyframe( + Begin: begin, + SunHeadingDeg: 0f, + SunPitchDeg: 90f, + DirColor: Vector3.One, + DirBright: 1f, + AmbColor: Vector3.One, + AmbBright: 1f, + FogColor: Vector3.Zero, + FogDensity: 0f); + var group = new DayGroupData + { + ChanceOfOccur = 100f, + Name = name, + SkyTimes = + [ + new DatSkyKeyframeData { Keyframe = keyframe }, + ], + }; + return new LoadedSkyDesc + { + TickSize = 0.8, + LightTickSize = 0.2, + DayGroups = [group], + }; + } + + private static bool IsDayGroupSelection(string line) => + line.Contains("→ DayGroup[", StringComparison.Ordinal); +} + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class WorldEnvironmentControllerCollection +{ + public const string Name = "World environment process-global calendar"; +}