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