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:
parent
557eb7ef6b
commit
d09e246d3a
19 changed files with 606 additions and 368 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -127,14 +127,16 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation,
|
||||||
update-frame orchestration, and render-frame orchestration all have typed
|
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 A–C are also complete: dead host residue is removed,
|
Slice 8 checkpoints A–D are also complete: dead host residue is removed,
|
||||||
native-window callback intake has reversible lifetime ownership, and
|
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 D–L—world environment,
|
synchronized nine-stop soak pass. Slice 8 checkpoints E–L—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,
|
||||||
|
|
|
||||||
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -125,14 +125,16 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation,
|
||||||
update-frame orchestration, and render-frame orchestration all have typed
|
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 A–C are also complete: dead host residue is removed,
|
Slice 8 checkpoints A–D are also complete: dead host residue is removed,
|
||||||
native-window callback intake has reversible lifetime ownership, and
|
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 D–L—world environment,
|
synchronized nine-stop soak pass. Slice 8 checkpoints E–L—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,
|
||||||
|
|
|
||||||
|
|
@ -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 1–7 are
|
- **Active structural work:** `GameWindow` decomposition. Slices 1–7 are
|
||||||
complete; Slice 8 checkpoints A–C now own native callbacks and live-session
|
complete; Slice 8 checkpoints A–D 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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison |
|
| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison |
|
||||||
|
| TS-54 | AdminEnvirons sound values `0x65..0x7B` are diagnosed by retail enum name but do not play audio. Retail checks that the local player physics object and UI sound table exist, then calls `SoundManager::PlaySoundFromCenter(Sound_UI_*, table)` for Roar through Thunder6. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`) | The current audio owner has no typed retail UI-sound-table binding; logging preserves the inbound evidence without inventing wave DIDs or routing the sounds through positional world audio. | Server-authored ambience/thunder packets are silent in acdream while retail plays the centered UI sound. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055E07F..0x0055E2C7`); `SoundManager::PlaySoundFromCenter @ 0x00550950` |
|
||||||
|
| TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Unclear (UN) — 5 rows
|
## 5. Unclear (UN) — 4 rows
|
||||||
|
|
||||||
These rows have a missing, contradictory, or never-argued justification.
|
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):**
|
||||||
|
|
|
||||||
|
|
@ -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 1–7 and Slice 8 checkpoints A–C are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
|
**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–D are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
|
||||||
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
|
**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 A–C now own
|
Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–D 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 D–L remain.
|
startup/resource transfer, and shutdown checkpoints E–L 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
|
||||||
|
|
|
||||||
|
|
@ -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 A–C are complete:
|
preserve Slice 6 presentation. Final Slice 8 checkpoints A–D are complete:
|
||||||
native callbacks and live-session composition now have explicit retryable
|
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,
|
||||||
D–L 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 E–L remain
|
||||||
|
active. Issue #232 tracks process-residency variance in the soak without
|
||||||
loosening its leak threshold.
|
loosening its leak threshold.
|
||||||
|
|
||||||
Carried:
|
Carried:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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 A–C. `GameWindow.cs` moved from the 2026-07-21 baseline of
|
Slice 8 checkpoints A–D. `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 A–D | shell freeze, native callbacks, live-session composition, world environment | 4,330 / 192 / 67 |
|
||||||
|
|
||||||
Slice 8 is an ordered checkpoint campaign rather than another feature-body
|
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 D–L group the remaining environment, input, settings,
|
Slice 8 checkpoints E–L 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 1–7 already established a
|
It removes leftover feature state only where Slices 1–7 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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
249
src/AcDream.App/World/WorldEnvironmentController.cs
Normal file
249
src/AcDream.App/World/WorldEnvironmentController.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
187
tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs
Normal file
187
tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs
Normal 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";
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue