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";
+}