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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 10:54:33 +02:00
parent 557eb7ef6b
commit d09e246d3a
19 changed files with 606 additions and 368 deletions

View file

@ -127,14 +127,16 @@ second GUID dictionary. Slices 57 are complete: landblock presentation,
update-frame orchestration, and render-frame orchestration all have typed update-frame orchestration, and render-frame orchestration all have typed
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
the frame owners have no direct window or anonymous callback-bag ownership. the frame owners have no direct window or anonymous callback-bag ownership.
Slice 8 checkpoints AC are also complete: dead host residue is removed, Slice 8 checkpoints AD are also complete: dead host residue is removed,
native-window callback intake has reversible lifetime ownership, and native-window callback intake has reversible lifetime ownership, and
`LiveSessionHost` owns reset/binding composition over the sole canonical `LiveSessionHost` owns reset/binding composition over the sole canonical
session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods, session controller. `WorldEnvironmentController` solely owns the clock, DAT
down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test 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 Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second
synchronized nine-stop soak pass. Slice 8 checkpoints DL—world environment, synchronized nine-stop soak pass. Slice 8 checkpoints EL—input/resize, action
input/resize, action routing, settings, resource ownership, ordered composition, routing, settings, resource ownership, ordered composition,
retryable shutdown, soak snapshots, and final connected gates—remain active. retryable shutdown, soak snapshots, and final connected gates—remain active.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #232, #153, `docs/architecture/code-structure.md`. **Carried:** #232, #153,

View file

@ -125,14 +125,16 @@ second GUID dictionary. Slices 57 are complete: landblock presentation,
update-frame orchestration, and render-frame orchestration all have typed update-frame orchestration, and render-frame orchestration all have typed
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
the frame owners have no direct window or anonymous callback-bag ownership. the frame owners have no direct window or anonymous callback-bag ownership.
Slice 8 checkpoints AC are also complete: dead host residue is removed, Slice 8 checkpoints AD are also complete: dead host residue is removed,
native-window callback intake has reversible lifetime ownership, and native-window callback intake has reversible lifetime ownership, and
`LiveSessionHost` owns reset/binding composition over the sole canonical `LiveSessionHost` owns reset/binding composition over the sole canonical
session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods, session controller. `WorldEnvironmentController` solely owns the clock, DAT
down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test 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 Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second
synchronized nine-stop soak pass. Slice 8 checkpoints DL—world environment, synchronized nine-stop soak pass. Slice 8 checkpoints EL—input/resize, action
input/resize, action routing, settings, resource ownership, ordered composition, routing, settings, resource ownership, ordered composition,
retryable shutdown, soak snapshots, and final connected gates—remain active. retryable shutdown, soak snapshots, and final connected gates—remain active.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #232, #153, `docs/architecture/code-structure.md`. **Carried:** #232, #153,

View file

@ -27,10 +27,11 @@ What does NOT go here:
## Current queue — 2026-07-22 ## Current queue — 2026-07-22
- **Active structural work:** `GameWindow` decomposition. Slices 17 are - **Active structural work:** `GameWindow` decomposition. Slices 17 are
complete; Slice 8 checkpoints AC now own native callbacks and live-session complete; Slice 8 checkpoints AD now own native callbacks, live-session
composition outside the shell. `GameWindow.OnUpdate` and composition, and world-environment state outside the shell.
`GameWindow.OnRender` are typed orchestration handoffs and the class is 4,589 `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration
lines / 196 fields / 69 methods. Remaining Slice 8 composition/shutdown handoffs and the class is 4,330 lines / 192 fields / 67 methods. Remaining
Slice 8 input/settings/composition/shutdown
cleanup is active in cleanup is active in
[`docs/architecture/code-structure.md`](architecture/code-structure.md). [`docs/architecture/code-structure.md`](architecture/code-structure.md).
This is the behavior-preserving prerequisite before new M4 feature bodies. This is the behavior-preserving prerequisite before new M4 feature bodies.

View file

@ -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 5 closeout 8,811 lines / 247 fields / 153 methods
after Slice 6 closeout 7,026 lines / 241 fields / 108 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 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: `GameWindow` is the single object that:
@ -256,6 +257,7 @@ src/AcDream.App/
│ ├── RetailInboundEventDispatcher.cs # update-thread packet/frame FIFO barrier │ ├── RetailInboundEventDispatcher.cs # update-thread packet/frame FIFO barrier
│ ├── RetailLiveFrameCoordinator.cs # shipped: object/network/command/reconcile phase order │ ├── RetailLiveFrameCoordinator.cs # shipped: object/network/command/reconcile phase order
│ ├── LiveEntityPresentationController.cs # ordered Hidden/NoDraw/effect/collision side effects │ ├── 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 │ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain
│ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations │ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations
├── Interaction/ ├── 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. | | 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. | | 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`). | | 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`). | | 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. | | 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. | | 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. | | 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`). | | 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. | | 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 ### 4.3 Revised extraction sequence

View file

@ -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-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` | | 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 | | # | 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-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-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 67 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-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 67 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. These rows have a missing, contradictory, or never-argued justification.
They are the highest-priority audits: each needs either a recorded 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 | | # | 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-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-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-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 | | 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. 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. 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. 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. 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):** **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap # 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 17 and Slice 8 checkpoints AC 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 17 and Slice 8 checkpoints AD 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. **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 checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse
process-residency gate's diagnostic blind spot; no tolerance was loosened. process-residency gate's diagnostic blind spot; no tolerance was loosened.
Final Slice 8 composition/shutdown cleanup is active. Checkpoints AC now own Final Slice 8 composition/shutdown cleanup is active. Checkpoints AD now own
the frozen shell boundary, all nine native callback edges, and live-session the frozen shell boundary, all nine native callback edges, live-session
reset/selection/entry/route composition. `GameWindow` is 4,589 lines / 196 reset/selection/entry/route composition, and world clock/sky/day/weather/
fields / 69 methods; the Checkpoint C Release suite passes 7,408 tests / 5 AdminEnvirons state. `GameWindow` is 4,330 lines / 192 fields / 67 methods;
skips. Environment, input, settings, startup/resource transfer, and shutdown the Checkpoint D Release suite passes 7,419 tests / 5 skips. Input, settings,
checkpoints DL remain. startup/resource transfer, and shutdown checkpoints EL remain.
This is a behavior-preserving structural program. Severe regressions still get This is a behavior-preserving structural program. Severe regressions still get
root-cause fixes in separate commits; ordinary feature work resumes with M4 root-cause fixes in separate commits; ordinary feature work resumes with M4

View file

@ -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 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 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized
nine-destination soak pass with graceful exits, and six stable PNG checkpoints nine-destination soak pass with graceful exits, and six stable PNG checkpoints
preserve Slice 6 presentation. Final Slice 8 checkpoints AC are complete: preserve Slice 6 presentation. Final Slice 8 checkpoints AD are complete:
native callbacks and live-session composition now have explicit retryable native callbacks and live-session composition now have explicit retryable
owners, and `GameWindow` is 4,589 lines / 196 fields / 69 methods. Checkpoints owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky,
DL remain active. Issue #232 tracks process-residency variance in the soak without day group, weather, and AdminEnvirons state. `GameWindow` is 4,330 lines / 192
fields / 67 methods; 7,419 Release tests pass / 5 skip. Checkpoints EL remain
active. Issue #232 tracks process-residency variance in the soak without
loosening its leak threshold. loosening its leak threshold.
Carried: Carried:

View file

@ -20,7 +20,7 @@ audit.
define host-quiescence failure semantics. define host-quiescence failure semantics.
- [x] C — extract live-session host/reset/binding callbacks and verify the - [x] C — extract live-session host/reset/binding callbacks and verify the
embedded skill formula against named retail. 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 - [ ] E — extract two-phase raw pointer/camera input, focus, and framebuffer
resize behind symmetric named subscriptions. resize behind symmetric named subscriptions.
- [ ] F — extract the sole gameplay input-action router plus focused combat and - [ ] 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; - Preserve existing named-retail citations and registered sound adaptation;
diagnostics call typed commands rather than owning time/weather state. 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 ### E — pointer, focus, and framebuffer resize
- Make SilkKeyboard/Mouse sources and dispatcher links reversible, then attach - Make SilkKeyboard/Mouse sources and dispatcher links reversible, then attach

View file

@ -4,11 +4,16 @@
**Status:** RESEARCH COMPLETE — algorithm recovered, ready for C# port. **Status:** RESEARCH COMPLETE — algorithm recovered, ready for C# port.
**Companion docs:** `2026-04-23-sky-retail-verbatim.md` (full sky pipeline map). **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 ## 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 ```c
// chunk_00500000.c:1274 FUN_00501990 @ 0x00501990 size=138 // 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) void __fastcall FUN_00501990(uint *param_1)
{ {
float fVar1; float fVar1;
@ -127,7 +132,7 @@ Global: `DAT_008ee9c8` (type `void *`). Populated by `FUN_005a7fd0` (TimeOfDay::
| 0x00 | double | `EpochBase` (dat-declared) | `FUN_005a7fd0:6045` | | 0x00 | double | `EpochBase` (dat-declared) | `FUN_005a7fd0:6045` |
| 0x08 | int | `BaseYear` (dat-declared) | `FUN_005a7fd0:6051` | | 0x08 | int | `BaseYear` (dat-declared) | `FUN_005a7fd0:6051` |
| 0x0C | float | `SecondsPerDay` | `FUN_005a7fd0:6056` | | 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` | | 0x40 | double | `SecondsPerYear = DaysPerYear × SecondsPerDay` | `FUN_005a7fd0:6068` |
| 0x48 | float | `DayFraction` (0..1) | `FUN_005a7800:5497` | | 0x48 | float | `DayFraction` (0..1) | `FUN_005a7800:5497` |
| 0x50 | double | CurrentDay startTick | `FUN_005a7510` (via `FUN_005a75b0`) | | 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)` | | **0x68** | int | **`DayOfYear`** | `FUN_005a7510:5304`: `= floor(withinYearSec / secsPerDay)` |
| 0x6c | int | `SeasonIndex` | `FUN_005a7510:5313` | | 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) ## 5. Pseudocode (ready for C# port)
```csharp ```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 // Decompile: docs/research/decompiled/chunk_00500000.c:1276
// Must be called every frame before SkyDesc.UpdateSkyObjectTable; value is // Must be called every frame before SkyDesc.UpdateSkyObjectTable; value is
// stable across frames within one Dereth-day so repeated calls are cheap. // 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. // Step 1: deterministic per-day seed.
// (This mirrors retail's 3-int read from TimeOfDay+0x64/0x10/0x68.) // (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#). // Step 2: 32-bit signed LCG (x86 wraps silently; force it in C#).
int mixed = unchecked(seed * 0x6A42FDB2 + (int)0x8ABE1652); 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 ```csharp
// once per frame, before sky object/light interp // once per frame, before sky object/light interp
skyDesc.CurrentDayGroupIndex = SkyDesc.PickCurrentDayGroup( skyDesc.CurrentDayGroupIndex = SkyDesc.CalcPresentDayGroup(
year: world.TimeOfDay.Year, year: world.TimeOfDay.Year,
secondsPerDay: world.TimeOfDay.SecondsPerDayInt, // the int copy at TimeOfDay+0x10 daysPerYear: world.TimeOfDay.DaysPerYear,
dayOfYear: world.TimeOfDay.DayOfYear, dayOfYear: world.TimeOfDay.DayOfYear,
dayGroupCount: skyDesc.DayGroups.Count); 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 ## 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.) 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 ## 8. Summary table
@ -217,8 +225,8 @@ None critical. Two minor open items:
| Question | Answer | Evidence | | Question | Answer | Evidence |
|---|---|---| |---|---|---|
| Caller of `FUN_00502a10` | `FUN_004ff4b0` → called from `FUN_00508010:7560` | `chunk_004F0000.c:10732`, `chunk_00500000.c:7560` | | 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` | | DayGroup selection function | `SkyDesc::CalcPresentDayGroup @ 0x00500E10` | named Sept 2013 PDB/pseudo-C; older fallback `FUN_00501990`, `chunk_00500000.c:1276` |
| Hash formula | `(Year × SecondsPerDay + DayOfYear) × 0x6A42FDB2 + 0x8ABE1652` | `chunk_00500000.c:1296` | | 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 | | 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` | | 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` | | Read back by | `FUN_00502a10:2429`, `FUN_00501600`, `FUN_00501860` | `chunk_00500000.c:2429` |

View file

@ -35,7 +35,8 @@ DayOfYear = 47 ✓
The decompile agent's C trail The decompile agent's C trail
(`docs/research/2026-04-23-sky-decompile-hunt-C.md` §1 + §4) labeled (`docs/research/2026-04-23-sky-decompile-hunt-C.md` §1 + §4) labeled
`TimeOfDay+0x10` as *"SecondsPerDay (int copy — source of iVar6)"* `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 The live probe disproves that. The value is **360** — the same as
`GameTime.DaysPerYear` from the dat. `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: Implication for the retail LCG seed formula:
```c ```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; iVar4 = (iVar3 * iVar6 + iVar4) * 0x6a42fdb2 + -0x7541e9ae;
// ^Year ^x0x10 ^DayOfYear // ^Year ^x0x10 ^DayOfYear
``` ```

View file

@ -3,9 +3,9 @@
## Current state ## Current state
The behavior-preserving App ownership campaign is complete through Slice 7 and The behavior-preserving App ownership campaign is complete through Slice 7 and
Slice 8 checkpoints AC. `GameWindow.cs` moved from the 2026-07-21 baseline of Slice 8 checkpoints AD. `GameWindow.cs` moved from the 2026-07-21 baseline of
15,723 lines / 278 fields / 205 methods to 4,589 lines / 196 fields / 69 15,723 lines / 278 fields / 205 methods to 4,330 lines / 192 fields / 67
methods: 11,134 lines (70.8%) were removed without changing accepted gameplay methods: 11,393 lines (72.5%) were removed without changing accepted gameplay
or rendering behavior. or rendering behavior.
| Slice | Ownership moved out | Closeout size | | 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 | | 5 | landblock build, publication, retirement, and shared-origin lifetime | 8,811 / 247 / 153 |
| 6 | complete update-frame orchestration | 7,026 / 241 / 108 | | 6 | complete update-frame orchestration | 7,026 / 241 / 108 |
| 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 |
| 8 AD | 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 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 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 the sole session owner. C also replaced the embedded ACE shortcut with the
named-retail `SkillFormula::Calculate @ 0x00591960` port. Partial route named-retail `SkillFormula::Calculate @ 0x00591960` port. Partial route
construction and individual detach edges are retained and retried; reset and a 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`, Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`,
`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`.
@ -83,6 +90,9 @@ review rule live in `docs/architecture/code-structure.md`.
## Accepted verification ## 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 - 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- skips; App 3,048/3 skips and Core.Net 548/0. All three independent corrected-
diff reviews are clean. diff reviews are clean.
@ -109,8 +119,8 @@ review rule live in `docs/architecture/code-structure.md`.
## Next work ## Next work
Slice 8 checkpoints DL group the remaining environment, input, settings, Slice 8 checkpoints EL group the remaining input, settings, resource
resource ownership, startup, and shutdown wiring and reduce Silk callbacks to ownership, startup, and shutdown wiring and reduce Silk callbacks to
narrow calls into input, update, render, resize, focus, and shutdown owners. narrow calls into input, update, render, resize, focus, and shutdown owners.
It removes leftover feature state only where Slices 17 already established a It removes leftover feature state only where Slices 17 already established a
canonical owner; it does not redesign gameplay, rendering, or resource lifetime. canonical owner; it does not redesign gameplay, rendering, or resource lifetime.

View file

@ -101,7 +101,8 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
private AcDream.App.Rendering.RenderFrameOrchestrator? private AcDream.App.Rendering.RenderFrameOrchestrator?
_renderFrameOrchestrator; _renderFrameOrchestrator;
private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState; private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =
new(Console.WriteLine);
private ResourceShutdownTransaction? _shutdown; private ResourceShutdownTransaction? _shutdown;
private readonly DisplayFramePacingController _displayFramePacing; private readonly DisplayFramePacingController _displayFramePacing;
@ -380,13 +381,12 @@ public sealed class GameWindow : IDisposable
// DevToolsEnabled reads through typed RuntimeOptions. // DevToolsEnabled reads through typed RuntimeOptions.
private bool DevToolsEnabled => _options.DevTools; private bool DevToolsEnabled => _options.DevTools;
// Phase G.1-G.2 world lighting/time state. // Phase G.1-G.2 world lighting/time state. The environment owner keeps
public readonly AcDream.Core.World.WorldTimeService WorldTime = // the clock, selected day group, and weather transitions coherent.
new AcDream.Core.World.WorldTimeService( public readonly AcDream.Core.World.WorldTimeService WorldTime;
AcDream.Core.World.SkyStateProvider.Default());
public readonly AcDream.Core.Lighting.LightManager Lighting = new(); 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 // Wired into the hook router in OnLoad so SetLightHook fires
// from the animation pipeline flip the matching LightSource.IsLit. // from the animation pipeline flip the matching LightSource.IsLit.
private AcDream.Core.Lighting.LightingHookSink? _lightingSink; private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
@ -407,22 +407,6 @@ public sealed class GameWindow : IDisposable
// sun / ambient / fog / flash data per frame. // sun / ambient / fog / flash data per frame.
private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo; private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo;
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer; 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. <c>long.MinValue</c> 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. // Phase B.2: player movement mode.
private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new(); private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new();
private AcDream.App.Input.PlayerMovementController? _playerController private AcDream.App.Input.PlayerMovementController? _playerController
@ -587,6 +571,8 @@ public sealed class GameWindow : IDisposable
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{ {
_options = options ?? throw new System.ArgumentNullException(nameof(options)); _options = options ?? throw new System.ArgumentNullException(nameof(options));
WorldTime = _worldEnvironment.WorldTime;
Weather = _worldEnvironment.Weather;
_retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot(); _retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot();
_inputCapture = new AcDream.App.Input.CompositeInputCaptureSource( _inputCapture = new AcDream.App.Input.CompositeInputCaptureSource(
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools), new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
@ -685,8 +671,6 @@ public sealed class GameWindow : IDisposable
private void OnLoad() private void OnLoad()
{ {
_worldSceneSkyState ??= new AcDream.App.Rendering.WorldSceneSkyState(
WorldTime);
// Task 7: wire the physics data cache into the engine so Transition can // Task 7: wire the physics data cache into the engine so Transition can
// run narrow-phase BSP tests during FindObjCollisions. // run narrow-phase BSP tests during FindObjCollisions.
_physicsEngine.DataCache = _physicsDataCache; _physicsEngine.DataCache = _physicsDataCache;
@ -1276,79 +1260,9 @@ public sealed class GameWindow : IDisposable
if (heightTable is null || heightTable.Length < 256) if (heightTable is null || heightTable.Length < 256)
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated"); throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
// Phase G.1: parse the full sky descriptor (day groups, keyframes, // Parse and install the DAT-backed sky, retail day-group state, and
// celestial mesh layers) and swap WorldTime's provider over to the // offline clock seed through their single environment owner.
// dat-backed keyframes. The stub default provider is only used if _worldEnvironment.Initialize(region!);
// 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)
// N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters BEFORE // N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters BEFORE
// building the terrain atlas / renderer — both consume BindlessSupport // building the terrain atlas / renderer — both consume BindlessSupport
@ -2872,7 +2786,7 @@ public sealed class GameWindow : IDisposable
var worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer( var worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer(
renderFrameResources, renderFrameResources,
renderLoginState, renderLoginState,
_worldSceneSkyState!, _worldEnvironment,
worldRenderFrameBuilder, worldRenderFrameBuilder,
new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState), new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState),
_retailSelectionScene, _retailSelectionScene,
@ -3120,12 +3034,8 @@ public sealed class GameWindow : IDisposable
session, session,
_liveEntitySessionEvents.CreateSink(), _liveEntitySessionEvents.CreateSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink( new AcDream.App.Net.LiveEnvironmentSessionSink(
OnEnvironChanged, _worldEnvironment.ApplyAdminEnvirons,
ticks => _worldEnvironment.SynchronizeFromServer),
{
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
}),
CreateLiveInventorySessionBindings(), CreateLiveInventorySessionBindings(),
CreateLiveCharacterSessionBindings(skillTable), CreateLiveCharacterSessionBindings(skillTable),
CreateLiveSocialSessionBindings()); CreateLiveSocialSessionBindings());
@ -3251,60 +3161,6 @@ public sealed class GameWindow : IDisposable
session.SendTurbineChatTo( session.SendTurbineChatTo(
roomId, chatType, dispatchType, senderGuid, text, cookie), roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine); Log: Console.WriteLine);
/// <summary>
/// Phase 5d — retail <c>AdminEnvirons</c> (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
/// <c>EnvironChangeType</c> → wave asset, which we don't yet
/// have dat-indexed; follow-up will wire the thunder wave ids.
/// </summary>
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) private void OnUpdate(double dt)
{ {
using var _updStage = _frameProfiler.BeginStage( using var _updStage = _frameProfiler.BeginStage(
@ -3413,92 +3269,6 @@ public sealed class GameWindow : IDisposable
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have // EXPECTED-DIFF: local sidestep pacing now matches how remotes have
// always played (w6-cutover-map.md R3). // always played (w6-cutover-map.md R3).
/// <summary>
/// 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
/// <see cref="AcDream.Core.World.SkyStateProvider"/> feeding
/// <see cref="WorldTime"/> (for lighting interp) and the cached
/// <see cref="WorldSceneSkyState.ActiveDayGroup"/> (for the sky-object
/// render loop).
///
/// <para>
/// Honors <c>ACDREAM_DAY_GROUP=N</c> — when set, every call picks
/// group N regardless of day index. Useful for A/B testing each
/// weather preset against retail. See
/// <see cref="AcDream.Core.World.LoadedSkyDesc.SelectDayGroupIndex"/>
/// for the roller.
/// </para>
/// </summary>
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 ──────────────────────────────── // ── Phase I.2 — DebugPanel helpers ────────────────────────────────
// //
// The ImGui DebugPanel reads through DebugVM closures that ask // The ImGui DebugPanel reads through DebugVM closures that ask
@ -3569,27 +3339,8 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void CycleTimeOfDay() private void CycleTimeOfDay()
{ {
// none → 0.0 (midnight) → 0.25 (dawn) → 0.5 (noon) → 0.75 (dusk) → none string message = _worldEnvironment.CycleTimeOfDay();
_timeDebugStep = (_timeDebugStep + 1) % 5; _debugVm?.AddToast(message);
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");
}
} }
/// <summary> /// <summary>
@ -3597,17 +3348,8 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void CycleWeather() private void CycleWeather()
{ {
var kinds = new[] string message = _worldEnvironment.CycleWeather();
{ _debugVm?.AddToast(message);
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]}");
} }
/// <summary> /// <summary>
@ -4380,7 +4122,6 @@ public sealed class GameWindow : IDisposable
new("world frame composition", () => new("world frame composition", () =>
{ {
_renderFrameOrchestrator = null; _renderFrameOrchestrator = null;
_worldSceneSkyState = null;
}), }),
]), ]),
new ResourceShutdownStage("session dependents", new ResourceShutdownStage("session dependents",

View file

@ -13,25 +13,6 @@ internal interface IWorldSceneSkyStateSource
float DayFraction { get; } float DayFraction { get; }
} }
/// <summary>
/// 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.
/// </summary>
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 internal interface IWorldSceneEntitySource
{ {
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,

View file

@ -0,0 +1,249 @@
using AcDream.App.Rendering;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.World;
/// <summary>
/// Owns the live Dereth clock, selected retail day group, weather state, and
/// server environment overrides as one coherent environment lifetime.
/// </summary>
internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource
{
private static readonly WeatherKind[] DebugWeatherKinds =
[
WeatherKind.Clear,
WeatherKind.Overcast,
WeatherKind.Rain,
WeatherKind.Snow,
WeatherKind.Storm,
];
private readonly Action<string> _log;
private LoadedSkyDesc? _loadedSkyDesc;
private long _loadedSkyDayIndex = long.MinValue;
private int _timeDebugStep;
private int _weatherDebugStep;
private bool _initializationClaimed;
public WorldEnvironmentController(Action<string>? log = null)
: this(
new WorldTimeService(SkyStateProvider.Default()),
new WeatherSystem(),
log)
{
}
internal WorldEnvironmentController(
WorldTimeService worldTime,
WeatherSystem weather,
Action<string>? 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;
/// <summary>
/// Loads the Region DAT environment and seeds the pre-session clock exactly
/// as the former <c>GameWindow.OnLoad</c> body did.
/// </summary>
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();
}
/// <summary>
/// Environment packet bridge researched from
/// <c>CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20</c>. The current
/// fog approximation and missing centered UI-sound playback remain
/// explicitly registered as TS-55 and TS-54 respectively.
/// </summary>
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");
}
/// <summary>
/// Selects the active DAT day group when the authoritative Dereth day
/// changes. The picker itself is the retail-verbatim
/// <c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</c> port (older-build
/// cross-reference <c>FUN_00501990</c>).
/// </summary>
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}";
}
}

View file

@ -61,8 +61,9 @@ public static class DerethDateTime
/// Base/anchor year for the Portal Year calendar (retail /// Base/anchor year for the Portal Year calendar (retail
/// <c>GameTime.ZeroYear</c> = 10). Tick 0 corresponds to PY 10, /// <c>GameTime.ZeroYear</c> = 10). Tick 0 corresponds to PY 10,
/// Morningthaw 1, Morntide-and-Half. Retail's <c>TimeOfDay+0x64</c> /// Morningthaw 1, Morntide-and-Half. Retail's <c>TimeOfDay+0x64</c>
/// ("absolute year") includes this offset, and <c>SkyDesc::PickCurrentDayGroup</c> /// ("absolute year") includes this offset, and
/// (<c>FUN_00501990</c>) feeds the absolute year into its LCG seed. /// <c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</c> feeds the absolute year
/// into its LCG seed (older-build cross-reference <c>FUN_00501990</c>).
/// </summary> /// </summary>
public const int ZeroYear = 10; public const int ZeroYear = 10;
@ -244,7 +245,8 @@ public static class DerethDateTime
/// Matches retail's <c>TimeOfDay + 0x64</c> field /// Matches retail's <c>TimeOfDay + 0x64</c> field
/// (<c>FUN_005a7510:5300</c>: /// (<c>FUN_005a7510:5300</c>:
/// <c>floor((worldTime+base)/secsPerYear) + baseYear</c>). This is /// <c>floor((worldTime+base)/secsPerYear) + baseYear</c>). This is
/// the value the retail DayGroup picker (<c>FUN_00501990</c>) feeds /// the value the retail DayGroup picker
/// (<c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</c>) feeds
/// into its LCG seed, so acdream must match for identical weather /// into its LCG seed, so acdream must match for identical weather
/// picks vs retail. /// picks vs retail.
/// </summary> /// </summary>
@ -256,7 +258,7 @@ public static class DerethDateTime
/// (dat's ZeroTimeOfYear) to match retail's <c>TimeOfDay + 0x68</c> /// (dat's ZeroTimeOfYear) to match retail's <c>TimeOfDay + 0x68</c>
/// field (<c>FUN_005a7510:5304</c>: /// field (<c>FUN_005a7510:5304</c>:
/// <c>floor(withinYearSec / secsPerDay)</c>). Consumed by /// <c>floor(withinYearSec / secsPerDay)</c>). Consumed by
/// <c>SkyDesc.PickCurrentDayGroup</c> as part of the per-day seed. /// <c>SkyDesc::CalcPresentDayGroup</c> as part of the per-day seed.
/// </summary> /// </summary>
public static int DayOfYear(double ticks) public static int DayOfYear(double ticks)
{ {

View file

@ -186,8 +186,9 @@ public sealed class LoadedSkyDesc
/// <summary> /// <summary>
/// Pick a <see cref="DayGroupData"/> deterministically for the given /// Pick a <see cref="DayGroupData"/> deterministically for the given
/// Derethian (year, dayOfYear) pair. Retail-verbatim port of /// Derethian (year, dayOfYear) pair. Retail-verbatim port of
/// <c>SkyDesc::PickCurrentDayGroup</c> (<c>FUN_00501990</c> at /// <c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</c>. The older-build
/// <c>chunk_00500000.c:1276</c>) — the per-frame weather roller. /// fallback is <c>FUN_00501990</c> at
/// <c>chunk_00500000.c:1276</c>.
/// ///
/// <para> /// <para>
/// <b>Algorithm</b> (from the retail decompile): /// <b>Algorithm</b> (from the retail decompile):
@ -206,14 +207,10 @@ public sealed class LoadedSkyDesc
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <paramref name="secondsPerDay"/> should be the dat-declared /// Despite its historical parameter name, <paramref name="secondsPerDay"/>
/// "seconds per Derethian day" integer (retail reads it from /// is the integer at retail <c>TimeOfDay + 0x10</c>. A live retail probe
/// <c>TimeOfDay + 0x10</c>). acdream's callers pass /// established that this is Dereth's days-per-year value (360), which
/// <see cref="DerethDateTime.DayTicks"/> as an int (7620); ACE /// <c>WorldEnvironmentController</c> supplies from the calendar constants.
/// computes the same value server-side so retail and acdream
/// converge on identical picks whenever their <c>(Year, DayOfYear)</c>
/// agree — which they do, because both derive from the server's
/// <c>PortalYearTicks</c>.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -237,7 +234,7 @@ public sealed class LoadedSkyDesc
if (DayGroups.Count == 1) return 0; if (DayGroups.Count == 1) return 0;
// --- Retail FUN_00501990 line-by-line port --- // --- Retail SkyDesc::CalcPresentDayGroup @ 0x00500E10 ---
// Step 1: deterministic per-day seed. // Step 1: deterministic per-day seed.
int seed = unchecked(year * secondsPerDay + dayOfYear); int seed = unchecked(year * secondsPerDay + dayOfYear);
@ -280,8 +277,8 @@ public sealed class LoadedSkyDesc
int dayOfYear = DerethDateTime.DayOfYear(serverTicks); int dayOfYear = DerethDateTime.DayOfYear(serverTicks);
// Retail's TimeOfDay+0x10 is actually DaysPerYear (= 360 for Dereth, // Retail's TimeOfDay+0x10 is actually DaysPerYear (= 360 for Dereth,
// live probe 2026-04-23), NOT SecondsPerDay as the decompile agent // live probe 2026-04-23), NOT SecondsPerDay as the decompile agent
// mis-labeled. See GameWindow.RefreshSkyForCurrentDay for the full // mis-labeled. See WorldEnvironmentController.RefreshSkyForCurrentDay
// citation. // for the owning call site.
int secondsPerDay = DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear; // 360 int secondsPerDay = DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear; // 360
int idx = SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear); int idx = SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear);
return idx < DayGroups.Count ? DayGroups[idx] : null; return idx < DayGroups.Count ? DayGroups[idx] : null;

View file

@ -157,7 +157,7 @@ public sealed class WeatherSystem
/// <summary> /// <summary>
/// Drive the weather kind from the active retail DayGroup name /// Drive the weather kind from the active retail DayGroup name
/// (see <c>SkyDesc::PickCurrentDayGroup</c> port at /// (see <c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</c> port at
/// <c>LoadedSkyDesc.SelectDayGroupIndex</c>). Retail has ONE source /// <c>LoadedSkyDesc.SelectDayGroupIndex</c>). Retail has ONE source
/// of truth for weather — the DayGroup roll — so this replaces the /// of truth for weather — the DayGroup roll — so this replaces the
/// internal <see cref="RollKind"/> hash once the real DayGroup picker /// internal <see cref="RollKind"/> hash once the real DayGroup picker

View file

@ -88,6 +88,41 @@ public sealed class GameWindowSlice8BoundaryTests
Assert.DoesNotContain("PrepareResources(", postStart, StringComparison.Ordinal); 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] [Fact]
public void InputAction_PreservesRetailAcceptedPriority() public void InputAction_PreservesRetailAcceptedPriority()
{ {

View file

@ -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<string> 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<string> 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<InvalidOperationException>(
() => 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<string> 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<string> 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<string> 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";
}