From 6d6e5b5fa53b79ea99736f5e56cb7d49cd64a026 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 05:54:00 +0200 Subject: [PATCH] refactor(render): extract world frame preparation Move camera/root resolution, live settings and listener preview, sky/lighting/fog preparation, animated classification, and building visibility scratch behind a typed WorldRenderFrameBuilder while preserving retail frame order and borrowed lifetimes. Co-authored-by: Codex --- .../retail-divergence-register.md | 8 +- ...ndow-slice-7-render-frame-orchestration.md | 17 +- src/AcDream.App/Rendering/GameWindow.cs | 684 +++--------------- .../Rendering/SkyPesFrameController.cs | 135 ++++ .../Rendering/WorldRenderFrameBuilder.cs | 645 +++++++++++++++++ src/AcDream.App/RuntimeOptions.cs | 13 +- .../Rendering/WorldRenderFrameBuilderTests.cs | 522 +++++++++++++ .../AcDream.App.Tests/RuntimeOptionsTests.cs | 22 + 8 files changed, 1463 insertions(+), 583 deletions(-) create mode 100644 src/AcDream.App/Rendering/SkyPesFrameController.cs create mode 100644 src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs create mode 100644 tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index e9b658d1..df6dddc3 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -77,12 +77,12 @@ accepted-divergence entries (#96, #49, #50). | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | | AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB | | AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 | -| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 | +| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameBuildingSource.Gather`) | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 | | AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices | | AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) | | AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw` → `DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 | | AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 | -| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 | +| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameRootSource`, `WorldRenderFrame.ClipRoot`); `src/AcDream.App/Rendering/GameWindow.cs` (null-root safety draw) | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 | | AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams | | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | @@ -145,7 +145,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 | -| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | +| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.UpdateSunFromSky`) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | | AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | | AP-37 | LayoutDesc meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures | | AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | @@ -183,7 +183,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | | AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (VectorUpdate jump handler); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (landing blocks) | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | -| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) | +| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` back to `WorldRenderFrameBuilder` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.ObserveDrawableCells`, `ClearDrawableCells`, `Prepare`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) | | AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | | AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | | AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` | diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md index 1cf8ede2..c99749cf 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -18,10 +18,10 @@ port, or resource-lifetime redesign. introduce data-only frame contracts plus deterministic order/failure tests. - [x] B — extract paperdoll, panel-layout/devtools, and render-diagnostics leaf owners while `GameWindow` still orders the frame. -- [ ] C — extract frame-resource begin/upload/publication and live display, +- [x] C — extract frame-resource begin/upload/publication and live display, and weather/display foundations that do not depend on camera/root classification. -- [ ] D — extract frame-data classification and reusable world scratch without +- [x] D — extract frame-data classification and reusable world scratch without duplicating live identity, spatial, visibility, or resource ownership; then run the dependent audio, sky, lighting, and listener preparation. - [ ] E — replace `RetailPViewDrawContext`'s callback bag with a typed @@ -404,6 +404,19 @@ corrected-diff reviews are clean. fallback/null-root, sealed/SeenOutside, and borrowed scratch reset; - preserve allocation-free warmed frames and hysteretic scratch retention. +Completed 2026-07-22. `WorldRenderFrameBuilder` now owns the ordered camera, +visibility, live settings/listener, player-lighting versus collided-viewer root, +sky/lighting/fog, animated-ID, and building-candidate preparation. Reusable +animated/building/light-cell collections retain their explicit one-frame +borrowed lifetime; PView drawable-cell feedback returns through the builder and +is copied before the renderer reuses its own scratch. The diagnostic sky-PES +experiment moved to `SkyPesFrameController`, and fog startup overrides now enter +through typed `RuntimeOptions`. The frame graph is withdrawn before its borrowed +equipped/effect/audio owners during shutdown. `GameWindow.cs` is 5,715 raw +lines, 63.6% below the 15,723-line campaign baseline. Twenty-nine focused tests, +the Release build, and the complete suite pass (7,281 passed / 5 skipped). +Retail, architecture, and adversarial corrected-diff reviews are clean. + ### E — typed PView pass executor - replace `Action`/`Func` draw callbacks in `RetailPViewDrawContext`; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 884af989..e3bc6f5c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -16,8 +16,6 @@ public sealed class GameWindow : IDisposable System.Diagnostics.Stopwatch.GetTimestamp() / (double)System.Diagnostics.Stopwatch.Frequency; - private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene); - private readonly AcDream.App.RuntimeOptions _options; private readonly AnimationPresentationDiagnostics _animationDiagnostics; private readonly string _datDir; @@ -108,6 +106,9 @@ public sealed class GameWindow : IDisposable _renderFrameLivePreparation; private AcDream.App.Rendering.RenderWeatherFrameController? _renderWeatherFrame; + private AcDream.App.Rendering.WorldRenderFrameBuilder? + _worldRenderFrameBuilder; + private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame; private ResourceShutdownTransaction? _shutdown; private readonly DisplayFramePacingController _displayFramePacing; @@ -126,8 +127,18 @@ public sealed class GameWindow : IDisposable private AcDream.App.Streaming.LocalPlayerTeleportController? _localPlayerTeleport; private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) - private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9) - private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25) + private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange = + new(nearRadius: 4, farRadius: 12); + private int _nearRadius + { + get => _renderRange.NearRadius; + set => _renderRange.NearRadius = value; + } + private int _farRadius + { + get => _renderRange.FarRadius; + set => _renderRange.FarRadius = value; + } // A.5 T22.5: resolved quality settings (preset + env-var overrides). // Set once in OnLoad after LoadAndApplyPersistedSettings(); re-set on // ReapplyQualityPreset(). Default matches QualityPreset.High so the field @@ -226,12 +237,8 @@ public sealed class GameWindow : IDisposable // U.4 replaces the NoClip() frame with one built from the portal-visibility result. private ClipFrame? _clipFrame; - // Phase 3 (render unification, 2026-06-07): the synthetic outdoor cell node — the outdoor - // world as a flood-graph cell (spec 2026-06-07-render-unification-outdoor-as-cell). Rebuilt - // each outdoor frame from nearby building-entrance portals. ADDITIVE for now (built but not - // yet rooted; the clipRoot flip + OutsideView terrain integration is the cutover step). - private LoadedCell? _outdoorNode; - private readonly List _outdoorNodeBuildingCells = new(); + // PView pass-local particle classifications move with the pass executor in + // Checkpoint E; GameWindow still owns them until that cutover. private readonly HashSet _outdoorSceneParticleEntityIds = new(); private readonly HashSet _visibleSceneParticleEntityIds = new(); private readonly HashSet _noSceneParticleEntityIds = new(); @@ -248,13 +255,6 @@ public sealed class GameWindow : IDisposable private readonly LiveEntityAnimationRuntimeView _animatedEntities; private readonly AcDream.App.World.LiveEntityRuntimeSlot _liveEntityRuntimeSlot = new(); - // MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys. - // WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically - // (`animatedEntityIds is null || animatedEntityIds.Count == 0`), so this can - // always be a live (possibly empty) HashSet instead of `new`ing one every - // frame or passing null. - private readonly HashSet _animatedIdsScratch = new(); - /// /// Tier 1 cache (#53): per-entity classification results for static /// entities (those NOT in ). Conceptually @@ -298,35 +298,6 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new(); - // Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but - // never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime. - // Keep the experimental path available for DAT archaeology only. - // Backed by ACDREAM_ENABLE_SKY_PES via RuntimeOptions.EnableSkyPesDebug. - - // Diagnostic: hide a specific humanoid part (>=10 parts) at render. - // Backed by ACDREAM_HIDE_PART via RuntimeOptions.HidePartIndex. - - // Issue #47 — use retail's close-detail GfxObj selection on - // humanoid setups. When enabled, every per-part GfxObj id (after - // server AnimPartChanges are applied) is replaced with Degrades[0] - // from its DIDDegrade table when present. See GfxObjDegradeResolver - // for the full retail-decomp citation. Default-on after visual - // confirmation; set ACDREAM_RETAIL_CLOSE_DEGRADES=0 only for - // diagnostic before/after comparisons. - // Backed by ACDREAM_RETAIL_CLOSE_DEGRADES via RuntimeOptions.RetailCloseDegrades. - - // Issue #48 diagnostic — dump per-scenery-spawn placement evidence - // (rendered gfx id, sample source physics-vs-bilinear, ground/baseLoc/finalZ, - // mesh vertex Z range, DIDDegrade slot 0). One log line per spawn lets - // the user identify a floating tree by its world coordinates and tell - // whether the cause is BaseLoc.Z addition (H1), bilinear-fallback drift - // (H2), or DIDDegrade selection (H3). Diagnostic-first per CLAUDE.md. - // Backed by ACDREAM_DUMP_SCENERY_Z via RuntimeOptions.DumpSceneryZ. - - /// - private readonly HashSet _activeSkyPes = new(); - private readonly HashSet _missingSkyPes = new(); - // Remote-entity motion inference: tracks when each remote entity last // moved meaningfully. Used in TickAnimations to swap to Ready when // position has stalled for >StopIdleMs — retail observer pattern per @@ -467,21 +438,6 @@ public sealed class GameWindow : IDisposable AcDream.Core.World.SkyStateProvider.Default()); public readonly AcDream.Core.Lighting.LightManager Lighting = new(); - // A7.L1 (2026-07-09) — last frame's rendered visible-cell set, fed into next - // frame's Lighting.BuildPointLightSnapshot scoping. One frame of latency - // (~16 ms) instead of a mid-DrawInside callback: DrawableCells only becomes - // known partway through DrawInside (after the flood + look-in merge), and the - // #176 correction already showed that rebuilding the light pool FROM a - // freshly-recomputed frame flood (the deleted RebuildScopedLights callback, - // c500912b) is a flicker mechanism. Reusing last frame's already-drawn set - // (MP-Alloc: reused HashSet, no per-frame alloc) decouples the light-scoping - // change from DrawInside's internals entirely. _lightPoolVisibleCellsValid is - // false until the first indoor frame completes, and is cleared on any - // outdoor-only frame so scoping fails open (unscoped) for one frame on - // re-entry rather than filtering by a stale dungeon's cell ids. - private readonly HashSet _lightPoolVisibleCells = new(); - private bool _lightPoolVisibleCellsValid; - public readonly AcDream.Core.World.WeatherSystem Weather = new(); // Wired into the hook router in OnLoad so SetLightHook fires // from the animation pipeline flip the matching LightSource.IsLit. @@ -2890,6 +2846,52 @@ public sealed class GameWindow : IDisposable new AcDream.App.Rendering.RenderWeatherFrameController( WorldTime, Weather); + _skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController( + _scriptRunner!, + _particleSink!, + _effectPoses, + _entityEffects); + var worldFrameEnvironment = + new AcDream.App.Rendering.RuntimeWorldFrameEnvironmentPreparation( + _options, + WorldTime, + Lighting, + _wbDrawDispatcher, + _envCellRenderer, + _sceneLightingUbo, + _renderRange, + _skyPesFrame); + _worldRenderFrameBuilder = + new AcDream.App.Rendering.WorldRenderFrameBuilder( + new AcDream.App.Rendering.RuntimeWorldFrameCameraSource( + _cameraController!, + _localPlayerTeleport), + new AcDream.App.Rendering.RuntimeWorldFrameVisibilityPreparation( + _retailSelectionScene, + _particleVisibility, + _terrain, + _worldReveal, + _envCellFrustum), + new AcDream.App.Rendering.RuntimeWorldFrameSettingsPreview( + _settingsVm, + _audioEngine, + _cameraController!, + _displayFramePacing), + new AcDream.App.Rendering.RuntimeWorldFrameRootSource( + _physicsEngine, + _cellVisibility, + _localPlayerMode, + _chaseCameraInput, + _playerControllerSlot, + _liveWorldOrigin), + worldFrameEnvironment, + new AcDream.App.Rendering.RuntimeWorldFrameAnimatedEntitySource( + _animatedEntities, + _staticAnimationScheduler, + _equippedChildRenderer), + new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource( + _landblockPresentationPipeline!, + _cellVisibility)); var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( liveObjectFrame, _worldState, @@ -3268,115 +3270,8 @@ public sealed class GameWindow : IDisposable session.SendTurbineChatTo( roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - private void UpdateSkyPes( - float dayFraction, - AcDream.Core.World.DayGroupData? dayGroup, - System.Numerics.Vector3 cameraWorldPos, - bool suppressSky) - { - if (_scriptRunner is null || _particleSink is null) - return; - - var seen = new HashSet(); - if (!suppressSky && dayGroup is not null) - { - for (int i = 0; i < dayGroup.SkyObjects.Count; i++) - { - var obj = dayGroup.SkyObjects[i]; - if (obj.PesObjectId == 0 || !obj.IsVisible(dayFraction)) - continue; - - var key = new SkyPesKey(i, obj.PesObjectId, obj.IsPostScene); - seen.Add(key); - uint skyEntityId = SkyPesEntityId(key); - var renderPass = obj.IsPostScene - ? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene - : AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene; - _particleSink.SetEntityRenderPass(skyEntityId, renderPass); - var anchor = SkyPesAnchor(obj, cameraWorldPos); - var rotation = SkyPesRotation(obj, dayFraction); - _effectPoses.Publish( - skyEntityId, - System.Numerics.Matrix4x4.CreateFromQuaternion(rotation) - * System.Numerics.Matrix4x4.CreateTranslation(anchor), - System.Array.Empty(), - cellId: 0u); - - if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key)) - continue; - - _entityEffects?.RegisterSyntheticOwner(skyEntityId); - - if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor)) - { - _activeSkyPes.Add(key); - } - else - { - _missingSkyPes.Add(key); - _entityEffects?.UnregisterSyntheticOwner(skyEntityId); - _particleSink.ClearEntityRenderPass(skyEntityId); - _effectPoses.Remove(skyEntityId); - } - } - } - - foreach (var key in _activeSkyPes.ToArray()) - { - if (seen.Contains(key)) - continue; - - uint skyEntityId = SkyPesEntityId(key); - _scriptRunner.StopAllForEntity(skyEntityId); - _entityEffects?.UnregisterSyntheticOwner(skyEntityId); - _particleSink.StopAllForEntity(skyEntityId, fadeOut: true); - _effectPoses.Remove(skyEntityId); - _activeSkyPes.Remove(key); - } - - foreach (var key in _missingSkyPes.ToArray()) - { - if (!seen.Contains(key)) - _missingSkyPes.Remove(key); - } - } - - private static uint SkyPesEntityId(SkyPesKey key) - { - // 0xF0000000 prefix marks synthetic sky-PES entityIds (no real - // server GUID lives in the 0xFxxxxxxx space). Reserve bit - // 0x08000000 for the pre/post-scene flag and the lower 27 bits - // for the object index — keeps the post-scene flag from sliding - // into the index range if a future DayGroup ever ships >65k sky - // objects (current Dereth max is 18, but the constraint is free). - uint postBit = key.PostScene ? 0x08000000u : 0u; - return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu); - } - internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) => entity.Id; - - - private static System.Numerics.Vector3 SkyPesAnchor( - AcDream.Core.World.SkyObjectData obj, - System.Numerics.Vector3 cameraWorldPos) - { - if (obj.IsWeather && (obj.Properties & 0x08u) == 0u) - return cameraWorldPos + new System.Numerics.Vector3(0f, 0f, -120f); - - return cameraWorldPos; - } - - private static System.Numerics.Quaternion SkyPesRotation( - AcDream.Core.World.SkyObjectData obj, - float dayFraction) - { - float rotationRad = obj.CurrentAngle(dayFraction) * (MathF.PI / 180f); - return System.Numerics.Quaternion.CreateFromAxisAngle( - System.Numerics.Vector3.UnitY, - -rotationRad); - } - /// /// Phase 5d — retail AdminEnvirons (0xEA60) dispatcher. /// Routes fog presets into the weather system's sticky override @@ -3522,280 +3417,34 @@ public sealed class GameWindow : IDisposable if (_cameraController is not null && !portalViewportVisible) { _retailAlphaQueue.BeginFrame(); - var activeCamera = _cameraController.Active; - var camera = _localPlayerTeleport?.ApplyViewPlane(activeCamera) - ?? activeCamera; - var worldProjection = camera.Projection; - var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection); - _retailSelectionScene?.SetViewFrustum(frustum); - - // Extract camera world position from the inverse of the view - // matrix — needed by the scene-lighting UBO (for fog distance) - // and by the sky renderer (for the camera-centered sky dome). - System.Numerics.Matrix4x4.Invert(camera.View, out var invView); - var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43); - _particleVisibility.BeginFrame(camPos); - _terrain?.BeginVisibilityFrame(); - if (!IsLiveModeWaitingForLogin) - { - _particleVisibility.UseWorldView(); - _worldReveal?.ObserveWorldViewportVisible(); - _worldReveal?.Complete(); - } - - // L.0 Audio tab: push the SettingsVM's live AudioDraft into the - // engine each frame, so volume sliders preview audibly while - // the user drags. Cancel reverts the draft and the engine - // catches up on the very next frame; Save persists to - // settings.json without changing engine state (already - // applied). Cheap enough to run unconditionally on every - // tick — four float assignments. - if (_audioEngine is not null && _audioEngine.IsAvailable && _settingsVm is not null) - { - var a = _settingsVm.AudioDraft; - _audioEngine.MasterVolume = a.Master; - _audioEngine.MusicVolume = a.Music; - _audioEngine.SfxVolume = a.Sfx; - _audioEngine.AmbientVolume = a.Ambient; - } - - // L.0 Display tab: push the live DisplayDraft into the - // active rendering surfaces each frame. FOV is the live- - // preview slider per the brainstorm — dragging it changes - // camera FovY immediately. VSync change-detected to avoid - // spamming the window. Resolution + Fullscreen apply on - // Save (handled by ApplyDisplayWindowState — too jarring - // to live-preview a resize). - if (_settingsVm is not null && _cameraController is not null) - { - var d = _settingsVm.DisplayDraft; - float fovYRad = d.FieldOfView * (MathF.PI / 180f); - _cameraController.Orbit.FovY = fovYRad; - _cameraController.Fly.FovY = fovYRad; - if (_cameraController.Chase is not null) - _cameraController.Chase.FovY = fovYRad; - _displayFramePacing.ApplyPreference(d.VSync); - } - - // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate - // correctly relative to where we're looking. - if (_audioEngine is not null && _audioEngine.IsAvailable) - { - var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33); - var up = new System.Numerics.Vector3( invView.M21, invView.M22, invView.M23); - _audioEngine.SetListener( - camPos.X, camPos.Y, camPos.Z, - fwd.X, fwd.Y, fwd.Z, - up.X, up.Y, up.Z); - } - - // Step 4: portal visibility — compute BEFORE the UBO upload so - // the indoor flag drives the sun's intensity to zero for - // dungeons (r13 §13.7). - // Phase W single-viewpoint V1 (2026-06-03): the render keys on ONE viewpoint — the - // collided camera ("viewer") — exactly like retail (RenderNormalMode @ 0x453aa0 → - // DrawInside(viewer_cell) pc:92675; InitCell side-test vs viewer.viewpoint pc:432991). - // The viewer cell is the camera-collision sweep's swept cell - // (RetailChaseCamera.ViewerCellId = retail viewer_cell = sphere_path.curr_cell): - // graph-tracked, deterministic, NO AABB / NO grace frames — so the U.4c flap source - // (stale FindCameraCell over grace frames) is gone WITHOUT splitting viewpoints. - // SEPARATELY, lighting / seen_outside key on the PLAYER cell (CurrCell), matching retail - // CellManager::ChangePosition @ 0x4559B0 — the player's cell, not the camera's, decides - // whether the sun dies (sealed interior). retail player->cell (physics/lighting) vs - // SmartBox->viewer_cell (render); the old per-render player-root + eye-projection split is gone. - - // ── Lighting root: the PLAYER cell (CurrCell). ── - LoadedCell? playerRoot = null; - if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell playerCellObj - && _cellVisibility.TryGetCell(playerCellObj.Id, out var playerRegCell)) - playerRoot = playerRegCell; - bool playerSeenOutside = playerRoot?.SeenOutside ?? true; - - // ── Render root: the VIEWER (collided camera) cell + eye. ── - // Default (player mode + retail chase cam): the sweep's viewer cell. Fallback for the - // non-default legacy/debug camera paths: the player's registered cell (or none). - uint viewerCellId = - (_playerMode && _retailChaseCamera is not null - && AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera) - ? _retailChaseCamera.ViewerCellId - : (playerRoot?.CellId ?? 0u); - var viewerEyePos = camPos; // the collided eye drives the projection - var playerViewPos = _playerController?.RenderPosition - ?? _playerController?.Position - ?? camPos; - LoadedCell? viewerRoot = null; - if (viewerCellId != 0u && _cellVisibility.TryGetCell(viewerCellId, out var viewerRegCell)) - viewerRoot = viewerRegCell; - // T4 (BR-6): the per-frame ACME BFS (ComputeVisibilityFromRoot) is - // DELETED from the frame — it ran a full second visibility - // computation whose only production consumer was this boolean, - // which is exactly "the viewer root resolved to a loaded interior - // cell" (TryGetCell above already proves cells are loaded). The - // PView flood is the ONE visibility gate (feedback_render_one_gate). - bool cameraInsideCell = viewerRoot is not null; - - // Retail render routing is owned by the collided camera/viewer cell. - // The player cell still owns lighting state, but it must not force an - // indoor draw while the camera is outside; that drops the outdoor pass - // and leaves clear color around a floating doorway slice. - bool rootSeenOutside = viewerRoot?.SeenOutside ?? true; - - // Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified - // gated-draw block (after envCellViewProj exists) where it can report - // the real PortalVisibilityFrame — OutsideView polygon/plane counts and - // per-cell slot plane counts — via RenderingDiagnostics.EmitVis, instead - // of the old camera-state-only spike. See the U.4 ClipFrame assembly - // below (gated on ACDREAM_PROBE_VIS=1, cell-change-throttled). - - // Stage 3 (2026-06-02): replace the IsInsideAnyCell AABB scan with the - // seen_outside-derived predicate. Retail CellManager::ChangePosition (0x004559B0) - // gates sun/lighting off seen_outside on the player's current cell, NOT off an - // independent AABB containment scan. playerInsideCell = true (kill sunlight) only - // when the player is inside a SEALED interior (seen_outside=false = dungeon). - // Building interiors with seen_outside=true keep the sun (sky visible through door). - // V1 (2026-06-03): keyed on the PLAYER cell (playerRoot/playerSeenOutside), independent - // of the camera's viewer cell — retail kills the sun off the player's cell, not the eye. - bool playerInsideCell = playerRoot is not null && !playerSeenOutside; - - // Phase C.1: tick retail PhysicsScript particle hooks. Named - // retail decomp confirms SkyObject.PesObjectId is copied by - // SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is - // debug-only and disabled for normal retail rendering. - if (_options.EnableSkyPesDebug) - UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell); - // Phase G.1/G.2: feed the sun, tick LightManager, build + upload - // the scene-lighting UBO once per frame. Every shader that - // consumes binding=1 reads the same data for the rest of the - // frame — terrain, static mesh, instanced mesh, sky. - UpdateSunFromSky(kf, playerInsideCell); - // A7 indoor lighting: position retail's viewer fill light at the player - // (SmartBox::set_viewer 0x00452c40) before the snapshot is built. It is - // the primary interior fill (no sun indoors) and is indoor-only via the - // AP-43 gate. playerViewPos is the player render position (or camPos when - // there is no player), matching retail's player/viewer branch. - Lighting.UpdateViewerLight(playerViewPos); - Lighting.Tick(camPos); - - // Fix B (A7 #3): build this frame's point-light snapshot and hand it to - // the entity dispatcher for per-OBJECT light selection - // (minimize_object_lighting). Replaces the single global nearest-8-to- - // camera UBO set for point/spot lights so a wall's torches stay tied to - // the wall as the camera moves. The SUN + ambient still flow through the - // SceneLighting UBO built below (binding=1) — terrain/sky read those. - // #176 root cause: the pool is anchored at the PLAYER (retail - // Render::insert_light sorts by Render::player_pos, 0x0054d1b0) and - // collected from ALL registered (=resident-cell) lights — it is a - // function of player position only, so camera rotation cannot change - // it. playerViewPos carries retail's no-player fallback (camPos). - // A7.L1: candidacy is additionally scoped to last frame's rendered - // visible-cell set (see _lightPoolVisibleCells) when available — the - // Town Network starvation fix. Unscoped (null) until the first indoor - // frame completes or after any outdoor-only frame. - Lighting.BuildPointLightSnapshot( - playerViewPos, - _lightPoolVisibleCellsValid ? _lightPoolVisibleCells : null); - _wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot); - _envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2) - - var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( - Lighting, in atmo, camPos, (float)WorldTime.DayFraction); - - // A.5 T22: override fog ramp with N₁/N₂-derived distances so the - // horizon fog masks the N₁ scenery boundary. Sky keyframe fog is - // retail-accurate at normal view distances but far too short for - // the extended N₂=12 (25×25 LB) streaming window. - // FogStart = N₁ × 192m × 0.7 ≈ 538m at defaults (4/12). - // FogEnd = N₂ × 192m × 0.95 ≈ 2188m at defaults. - // Multipliers exposed as env vars for fast iteration at visual gate. - { - const float LandblockSize = 192.0f; - float startMult = ParseEnvFloat("ACDREAM_FOG_START_MULT", 0.7f); - float endMult = ParseEnvFloat("ACDREAM_FOG_END_MULT", 0.95f); - float fogStart = _nearRadius * LandblockSize * startMult; - float fogEnd = _farRadius * LandblockSize * endMult; - // Preserve fog color (xyz), lightning flash (z), and mode (w). - ubo.FogParams = new System.Numerics.Vector4( - fogStart, - fogEnd, - ubo.FogParams.Z, // lightning flash — unchanged - ubo.FogParams.W); // fog mode — unchanged - } - - _sceneLightingUbo?.Upload(ubo); - - // #133 A7 (2026-06-13): objective dungeon-lighting probe. One - // rate-limited [light] line — insideCell / ambient / sun / - // registered-point-lights / active-slot-count / player cell — so - // the dungeon-dim question is self-verifiable from launch.log - // without a screenshot. RegisteredCount is point/spot lights only - // (the sun lives in LightManager.Sun, never in the _all list); - // ubo.CellAmbient.W is the shader active-slot count, which counts - // the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1. - AcDream.Core.Rendering.RenderingDiagnostics.EmitLight( - insideCell: playerInsideCell, - ambientR: Lighting.CurrentAmbient.AmbientColor.X, - ambientG: Lighting.CurrentAmbient.AmbientColor.Y, - ambientB: Lighting.CurrentAmbient.AmbientColor.Z, - sunIntensity: Lighting.Sun?.Intensity ?? 0f, - registeredLights: Lighting.RegisteredCount, - activeLights: (int)ubo.CellAmbient.W, - playerCellId: playerRoot?.CellId ?? 0u, - lights: Lighting); + AcDream.App.Rendering.WorldRenderFrame worldFrame = + _worldRenderFrameBuilder!.Build( + in foundation, + IsLiveModeWaitingForLogin, + _activeDayGroup); + AcDream.App.Rendering.WorldCameraFrame cameraFrame = worldFrame.Camera; + AcDream.App.Rendering.WorldRootFrame rootFrame = worldFrame.Roots; + var camera = cameraFrame.Camera; + var worldProjection = cameraFrame.Projection; + var frustum = cameraFrame.Frustum; + var camPos = cameraFrame.Position; + // The frame builder resolves retail's player-lighting root and + // collided-viewer render root before any draw decision is made. + LoadedCell? playerRoot = rootFrame.PlayerRoot; + bool playerSeenOutside = rootFrame.PlayerSeenOutside; + uint viewerCellId = rootFrame.ViewerCellId; + var viewerEyePos = rootFrame.ViewerEyePosition; + var playerViewPos = rootFrame.PlayerViewPosition; + LoadedCell? viewerRoot = rootFrame.ViewerRoot; + bool cameraInsideCell = rootFrame.CameraInsideCell; // Never cull the landblock the player is currently on. - uint? playerLb = null; - if (_playerMode && _playerController is not null) - { - var pp = _playerController.Position; - int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); - int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); - playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); - } + uint? playerLb = rootFrame.PlayerLandblockId; + int renderCenterLbX = rootFrame.RenderCenterLandblockX; + int renderCenterLbY = rootFrame.RenderCenterLandblockY; - int renderCenterLbX = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f); - int renderCenterLbY = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); - - // Phase A8: update EnvCellRenderer's frustum. The per-frame shell snapshot - // is prepared after the portal-visible cell filter is known. - var envCellViewProj = camera.View * worldProjection; - _envCellFrustum?.Update(envCellViewProj); - - // MP-Alloc: reuse _animatedIdsScratch instead of `new`ing a - // HashSet every frame. Downstream consumers (WbDrawDispatcher. - // WalkEntitiesInto) treat null and an empty set identically, so an - // always-non-null (possibly empty) set is behaviorally the same as - // the old null-when-nothing-animated local. - // - // Every entity in _animatedEntities (i.e. every entity with a - // Sequencer) is added UNCONDITIONALLY: membership here is the - // "re-classify me every frame, don't use the Tier-1 static cache" - // flag, and the Tier-1 cache captures an entity's REST pose + - // opacity-1.0 exactly once. Any entity whose rendered state can - // depart that rest — a door held at its final OPEN frame, a wall - // held FADED-OUT by a TransparentPartHook — MUST stay on the - // per-frame path so the live sequencer pose + TranslucencyFadeManager - // opacity are read; otherwise it flips back to the stale cached - // closed/opaque state the instant its animation settles. - // - // DO NOT re-narrow this to "only if the current cycle is multi-frame" - // (the reverted IsEntityCurrentlyMoving gate, 2026-07-09): a settled - // one-shot hold IS pose-stable frame-to-frame, but stable at the - // HELD pose the cache does NOT contain — that is the door/fade - // flip-back. The per-frame re-classification cost that narrowing - // saved was a DEBUG-build artifact; Release is GPU-bound (the CPU - // work hides under GPU time), so the unconditional add is free where - // it matters. If a real Release CPU cost from static-prop - // re-classification is ever measured, gate on "entity is at its - // captured rest state" (default motion AND no active fade), never on - // "is mid-cycle". - _animatedEntities.CopySpatialIdsTo(_animatedIdsScratch); - _staticAnimationScheduler?.CopyAnimatedEntityIdsTo(_animatedIdsScratch); - if (_equippedChildRenderer is not null) - { - foreach (uint id in _equippedChildRenderer.AttachedEntityIds) - _animatedIdsScratch.Add(id); - } - HashSet? animatedIds = _animatedIdsScratch; + var envCellViewProj = cameraFrame.ViewProjection; + HashSet animatedIds = worldFrame.AnimatedEntityIds; // Phase G.1: sky renderer — draws the far-plane-infinity // celestial meshes FIRST so the rest of the scene z-tests @@ -3814,7 +3463,7 @@ public sealed class GameWindow : IDisposable // Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — clipped // to the doorway via the OutsideView (Stage 4, below). // Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky. - bool renderSky = viewerRoot is null || rootSeenOutside; + bool renderSky = rootFrame.RenderSky; // Phase W Stage 4 (2026-06-02): the sky/weather DRAW moved DOWN to its retail LScape // position — AFTER the portal-visibility ClipFrame is assembled — so it can be clipped to // the doorway (OutsideView) by sky.vert's gl_ClipDistance. See the "[Stage 4] sky @@ -3841,72 +3490,20 @@ public sealed class GameWindow : IDisposable // GPU-fenced frame slot; each renderer re-binds binding=2 defensively. _clipFrame ??= ClipFrame.NoClip(); - // Phase 3 (render unification, additive): build the synthetic outdoor cell node when - // the eye is outdoors (no interior viewerRoot). Stored in _outdoorNode but NOT yet - // rooted — behaviour is unchanged this commit. The nearby-building enumeration mirrors - // the look-in candidate gather in the OUTDOOR branch below (Chebyshev <=1 landblocks); - // OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip + - // OutsideView terrain integration that consumes this is the next (cutover) step. - _outdoorNode = null; - _outdoorNodeBuildingCells.Clear(); - if (viewerRoot is not null || viewerCellId != 0u) - { - // T2 (BR-4): draw-driven flood gating. Retail floods a building's - // interior exactly when its shell DRAWS and an aperture survives - // the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck - // → portal-BSP walk → ConstructView's GetClip; NO distance - // constant anywhere on the chain). Port: a per-BUILDING frustum - // pre-gate on the aperture bounds (Building.PortalBounds — the - // tight equivalent of the shell viewconeCheck for FLOOD purposes), - // replacing the old Chebyshev≤1 landblock cell-sweep; the 48 m - // seed cap dies with it (RetailPViewRenderer seeds at ∞). The - // per-portal admission stays BuildFromExterior's screen clip - // (empty clip = no seed) — retail's GetClip-vs-view gate. - // Per-building iteration is also the FPS fix the 2026-06-07 - // Chebyshev hack approximated: dozens of AABB tests instead of an - // O(all loaded cells) portal sweep. - // #124: the gather now runs for INTERIOR roots too — retail's - // look-in executes inside LScape::draw for ANY root with a - // non-empty outside view (DrawCells pc:432719). The renderer - // routes interior-root look-ins to its landscape-stage sub-pass - // (DrawBuildingLookIns); the root's own building self-excludes - // via the seed eye-side test. - foreach (var registry in _landblockPresentationPipeline?.BuildingRegistries - ?? Array.Empty()) - { - foreach (var b in registry.All()) - { - if (b.HasPortalBounds - && !AcDream.App.Rendering.FrustumCuller.IsAabbVisible( - frustum, b.PortalBounds.Min, b.PortalBounds.Max)) - continue; - - foreach (uint cid in b.EnvCellIds) - if (_cellVisibility.TryGetCell(cid, out var bc) && bc is not null) - _outdoorNodeBuildingCells.Add(bc); - } - } - if (viewerRoot is null) - _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId); - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)")); - } - - uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; - bool playerIndoorGate = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor( - playerCellId, - playerRoot is not null); + IReadOnlyList nearbyBuildingCells = + worldFrame.Buildings.NearbyBuildingCells; + bool playerIndoorGate = rootFrame.PlayerIndoorGate; + uint playerCellId = rootFrame.PlayerCellId; // Render unification (outdoor-as-cell, 2026-06-07 cutover): ONE render path rooted at the // VIEWER cell. Eye indoors -> its interior EnvCell (viewerRoot); eye outdoors -> the - // synthetic outdoor node (_outdoorNode, built above from nearby building entrances). The + // frame builder's synthetic outdoor node, built from nearby building entrances. The // result is null ONLY when neither exists (pre-spawn / login / legacy non-chase camera) -> // the outdoor LScape block below still runs as the safety path (and login still shows the // live sky). There is no inside/outside branch to TOGGLE as the chase eye crosses the // doorway boundary, so the indoor FLAP dies by construction. playerIndoorGate stays // computed for the [render-sig] probe but no longer selects the path (handoff // docs/research/2026-06-07-render-unification-cutover-flip-handoff.md section 4 Step B). - var clipRoot = viewerRoot ?? _outdoorNode; + var clipRoot = worldFrame.ClipRoot; string renderBranch = clipRoot is null ? "OutdoorRoot" : "RetailPViewInside"; @@ -4026,7 +3623,7 @@ public sealed class GameWindow : IDisposable // R-A2: outdoor root floods each nearby building per-building (not via the root). // #124: interior roots get the gather too — the renderer routes them to the // landscape-stage look-in sub-pass instead of the merge. - NearbyBuildingCells = _outdoorNodeBuildingCells, + NearbyBuildingCells = nearbyBuildingCells, ViewerEyePos = viewerEyePos, ViewProjection = envCellViewProj, CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, @@ -4141,9 +3738,7 @@ public sealed class GameWindow : IDisposable // pool scoping. DrawableCells is the renderer's own reused scratch set // (cleared/rebuilt every DrawInside call) — copy it, don't hold a // reference to it. - _lightPoolVisibleCells.Clear(); - _lightPoolVisibleCells.UnionWith(pviewResult.DrawableCells); - _lightPoolVisibleCellsValid = true; + _worldRenderFrameBuilder!.ObserveDrawableCells(pviewResult.DrawableCells); // Flap root-cause apparatus (2026-06-07): per-frame, the EXACT Build inputs at 6 dp + // the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player @@ -4212,7 +3807,7 @@ public sealed class GameWindow : IDisposable // A7.L1: no indoor draw this frame — fail open (unscoped) rather than // scoping next frame's light pool by a stale dungeon's cell ids. - _lightPoolVisibleCellsValid = false; + _worldRenderFrameBuilder!.ClearDrawableCells(); } // Phase U.3: close the world-geometry clip bracket opened above. From here down the @@ -4993,67 +4588,6 @@ public sealed class GameWindow : IDisposable return true; } - /// - /// Derive the current sun (directional light, slot 0 of the UBO) - /// from the interpolated , - /// plus the cell ambient. Indoor cells force the sun intensity to - /// zero and substitute a flat 0.2 white ambient — exact retail - /// behavior per CellManager::ChangePosition @ 0x004559B0, - /// which calls SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF) - /// when the player's CObjCell::seen_outside flag is 0. - /// Indoor brightness then comes from per-cell point lights - /// (Setup.Lights on the cell's static objects, registered through - /// ). - /// The trigger is the PLAYER's cell, not the camera's — third-person - /// chase camera enters interiors before the player body does, and - /// retail keys lighting off the player position. - /// - private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool playerInsideCell) - { - // Sun direction: points FROM the sun TOWARDS the world. Our - // shader does dot(N, -forward) so a positive N·L means the - // surface faces the sun. - var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf); - - if (playerInsideCell) - { - // Indoor default — retail's flat 0.2 neutral ambient, sun - // zeroed. See xref to retail decomp in the doc comment above. - Lighting.Sun = new AcDream.Core.Lighting.LightSource - { - Kind = AcDream.Core.Lighting.LightKind.Directional, - WorldForward = sunToWorld, - ColorLinear = System.Numerics.Vector3.Zero, - Intensity = 0f, - Range = 1f, - }; - Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( - AmbientColor: new System.Numerics.Vector3(0.20f, 0.20f, 0.20f), - SunColor: System.Numerics.Vector3.Zero, - SunDirection: sunToWorld); - } - else - { - // Outdoor: full keyframe sun + ambient. The SkyKeyframe stores - // raw DirColor + DirBright (and AmbColor + AmbBright) for - // retail-faithful per-channel keyframe interpolation; the - // computed `kf.SunColor` / `kf.AmbientColor` properties return - // the post-multiplied product the shader expects. - Lighting.Sun = new AcDream.Core.Lighting.LightSource - { - Kind = AcDream.Core.Lighting.LightKind.Directional, - WorldForward = sunToWorld, - ColorLinear = kf.SunColor, - Intensity = 1f, - Range = 1f, - }; - Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( - AmbientColor: kf.AmbientColor, - SunColor: kf.SunColor, - SunDirection: sunToWorld); - } - } - // ── Phase I.2 — DebugPanel helpers ──────────────────────────────── // // The ImGui DebugPanel reads through DebugVM closures that ask @@ -5934,17 +5468,6 @@ public sealed class GameWindow : IDisposable $"resident={_worldState.Entities.Count} walked={walked}"); } - /// A.5 T22: parse a float environment variable, returning - /// when the variable is absent or unparseable. - private static float ParseEnvFloat(string name, float defaultValue) - { - var s = System.Environment.GetEnvironmentVariable(name); - if (s is not null && float.TryParse(s, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out var v)) - return v; - return defaultValue; - } - private void OnClosing() => CompleteShutdown(); @@ -5989,6 +5512,17 @@ public sealed class GameWindow : IDisposable _liveSessionController = null; }), ]), + // Frame composition borrows equipped, effect, audio, and render + // owners. Withdraw the graph before the first borrowed owner closes; + // the later render-frontend stage still disposes the GL owners. + new ResourceShutdownStage("frame borrowers", + [ + new("world frame composition", () => + { + _worldRenderFrameBuilder = null; + _skyPesFrame = null; + }), + ]), new ResourceShutdownStage("session dependents", [ new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()), diff --git a/src/AcDream.App/Rendering/SkyPesFrameController.cs b/src/AcDream.App/Rendering/SkyPesFrameController.cs new file mode 100644 index 00000000..57dfd96b --- /dev/null +++ b/src/AcDream.App/Rendering/SkyPesFrameController.cs @@ -0,0 +1,135 @@ +using System.Numerics; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Vfx; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Owns the optional DAT-archaeology sky-PES experiment. Named retail shows +/// GameSky does not consume SkyObject.PesObjectId, so production invokes this +/// owner only when the explicit startup diagnostic is enabled. +/// +internal sealed class SkyPesFrameController +{ + private readonly record struct SkyPesKey( + int ObjectIndex, + uint PesObjectId, + bool PostScene); + + private readonly PhysicsScriptRunner _scripts; + private readonly ParticleHookSink _particles; + private readonly EntityEffectPoseRegistry _poses; + private readonly EntityEffectController? _effects; + private readonly HashSet _active = []; + private readonly HashSet _missing = []; + + public SkyPesFrameController( + PhysicsScriptRunner scripts, + ParticleHookSink particles, + EntityEffectPoseRegistry poses, + EntityEffectController? effects) + { + _scripts = scripts ?? throw new ArgumentNullException(nameof(scripts)); + _particles = particles ?? throw new ArgumentNullException(nameof(particles)); + _poses = poses ?? throw new ArgumentNullException(nameof(poses)); + _effects = effects; + } + + public void Update( + float dayFraction, + DayGroupData? dayGroup, + Vector3 cameraWorldPosition, + bool suppressSky) + { + var seen = new HashSet(); + if (!suppressSky && dayGroup is not null) + { + for (int index = 0; index < dayGroup.SkyObjects.Count; index++) + { + SkyObjectData skyObject = dayGroup.SkyObjects[index]; + if (skyObject.PesObjectId == 0 || !skyObject.IsVisible(dayFraction)) + continue; + + var key = new SkyPesKey( + index, + skyObject.PesObjectId, + skyObject.IsPostScene); + seen.Add(key); + uint ownerId = EntityId(key); + ParticleRenderPass renderPass = skyObject.IsPostScene + ? ParticleRenderPass.SkyPostScene + : ParticleRenderPass.SkyPreScene; + _particles.SetEntityRenderPass(ownerId, renderPass); + Vector3 anchor = Anchor(skyObject, cameraWorldPosition); + Quaternion rotation = Rotation(skyObject, dayFraction); + _poses.Publish( + ownerId, + Matrix4x4.CreateFromQuaternion(rotation) + * Matrix4x4.CreateTranslation(anchor), + Array.Empty(), + cellId: 0u); + + if (_active.Contains(key) || _missing.Contains(key)) + continue; + + _effects?.RegisterSyntheticOwner(ownerId); + if (_scripts.Play(skyObject.PesObjectId, ownerId, anchor)) + { + _active.Add(key); + } + else + { + _missing.Add(key); + _effects?.UnregisterSyntheticOwner(ownerId); + _particles.ClearEntityRenderPass(ownerId); + _poses.Remove(ownerId); + } + } + } + + foreach (SkyPesKey key in _active.ToArray()) + { + if (seen.Contains(key)) + continue; + + uint ownerId = EntityId(key); + _scripts.StopAllForEntity(ownerId); + _effects?.UnregisterSyntheticOwner(ownerId); + _particles.StopAllForEntity(ownerId, fadeOut: true); + _poses.Remove(ownerId); + _active.Remove(key); + } + + foreach (SkyPesKey key in _missing.ToArray()) + { + if (!seen.Contains(key)) + _missing.Remove(key); + } + } + + private static uint EntityId(SkyPesKey key) + { + uint postScene = key.PostScene ? 0x08000000u : 0u; + return 0xF0000000u + | postScene + | ((uint)key.ObjectIndex & 0x07FFFFFFu); + } + + private static Vector3 Anchor( + SkyObjectData skyObject, + Vector3 cameraWorldPosition) + { + if (skyObject.IsWeather && (skyObject.Properties & 0x08u) == 0u) + return cameraWorldPosition + new Vector3(0f, 0f, -120f); + return cameraWorldPosition; + } + + private static Quaternion Rotation( + SkyObjectData skyObject, + float dayFraction) + { + float radians = skyObject.CurrentAngle(dayFraction) * (MathF.PI / 180f); + return Quaternion.CreateFromAxisAngle(Vector3.UnitY, -radians); + } +} diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs new file mode 100644 index 00000000..9e575b07 --- /dev/null +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -0,0 +1,645 @@ +using System.Numerics; +using AcDream.App.Audio; +using AcDream.App.Input; +using AcDream.App.Rendering.Selection; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Lighting; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; +using AcDream.Core.World; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Rendering; + +/// Camera facts borrowed for one world render preparation. +internal readonly record struct WorldCameraFrame( + ICamera Camera, + Matrix4x4 Projection, + Matrix4x4 ViewProjection, + FrustumPlanes Frustum, + Matrix4x4 InverseView, + Vector3 Position); + +/// Player-lighting and collided-viewer roots for one frame. +internal readonly record struct WorldRootFrame( + LoadedCell? PlayerRoot, + bool PlayerSeenOutside, + uint ViewerCellId, + Vector3 ViewerEyePosition, + Vector3 PlayerViewPosition, + LoadedCell? ViewerRoot, + bool CameraInsideCell, + bool RootSeenOutside, + bool PlayerInsideCell, + uint? PlayerLandblockId, + int RenderCenterLandblockX, + int RenderCenterLandblockY, + uint PlayerCellId, + bool PlayerIndoorGate) +{ + public bool RenderSky => ViewerRoot is null || RootSeenOutside; +} + +/// Borrowed building scratch, valid only until the next build. +internal readonly record struct WorldBuildingFrame( + LoadedCell? OutdoorNode, + IReadOnlyList NearbyBuildingCells); + +/// +/// Immutable one-frame world facts. Collection members borrow reusable owner +/// scratch and must not be retained beyond the next . +/// +internal readonly record struct WorldRenderFrame( + WorldCameraFrame Camera, + WorldRootFrame Roots, + WorldBuildingFrame Buildings, + HashSet AnimatedEntityIds) +{ + public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode; +} + +internal interface IWorldFrameCameraSource +{ + WorldCameraFrame Resolve(); +} + +internal interface IWorldFrameRootSource +{ + WorldRootFrame Resolve(in WorldCameraFrame camera); +} + +internal interface IWorldFrameVisibilityPreparation +{ + void Begin(in WorldCameraFrame camera, bool waitingForLogin); + + void PublishViewProjection(in WorldCameraFrame camera); +} + +internal interface IWorldFrameSettingsPreview +{ + void Apply(in WorldCameraFrame camera); +} + +internal interface IWorldFrameEnvironmentPreparation +{ + void Prepare( + in WorldCameraFrame camera, + in WorldRootFrame roots, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup); + + void ObserveDrawableCells(IReadOnlySet drawableCells); + + void ClearDrawableCells(); +} + +internal interface IWorldFrameAnimatedEntitySource +{ + HashSet Capture(); +} + +internal interface IWorldFrameBuildingSource +{ + WorldBuildingFrame Gather( + LoadedCell? viewerRoot, + uint viewerCellId, + in FrustumPlanes frustum); +} + +/// +/// Orders typed world-frame fact sources without owning their borrowed render +/// resources. It makes no draw decision and retains no result from a prior call. +/// +internal sealed class WorldRenderFrameBuilder +{ + private readonly IWorldFrameCameraSource _camera; + private readonly IWorldFrameVisibilityPreparation _visibility; + private readonly IWorldFrameSettingsPreview _settings; + private readonly IWorldFrameRootSource _roots; + private readonly IWorldFrameEnvironmentPreparation _environment; + private readonly IWorldFrameAnimatedEntitySource _animated; + private readonly IWorldFrameBuildingSource _buildings; + + public WorldRenderFrameBuilder( + IWorldFrameCameraSource camera, + IWorldFrameVisibilityPreparation visibility, + IWorldFrameSettingsPreview settings, + IWorldFrameRootSource roots, + IWorldFrameEnvironmentPreparation environment, + IWorldFrameAnimatedEntitySource animated, + IWorldFrameBuildingSource buildings) + { + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _visibility = visibility ?? throw new ArgumentNullException(nameof(visibility)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _roots = roots ?? throw new ArgumentNullException(nameof(roots)); + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _animated = animated ?? throw new ArgumentNullException(nameof(animated)); + _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); + } + + public WorldRenderFrame Build( + in RenderFrameFoundation foundation, + bool waitingForLogin, + DayGroupData? activeDayGroup) + { + WorldCameraFrame camera = _camera.Resolve(); + _visibility.Begin(in camera, waitingForLogin); + _settings.Apply(in camera); + WorldRootFrame roots = _roots.Resolve(in camera); + _environment.Prepare(in camera, in roots, in foundation, activeDayGroup); + _visibility.PublishViewProjection(in camera); + HashSet animated = _animated.Capture(); + FrustumPlanes frustum = camera.Frustum; + WorldBuildingFrame buildings = _buildings.Gather( + roots.ViewerRoot, + roots.ViewerCellId, + in frustum); + return new WorldRenderFrame(camera, roots, buildings, animated); + } + + public void ObserveDrawableCells(IReadOnlySet drawableCells) => + _environment.ObserveDrawableCells(drawableCells); + + public void ClearDrawableCells() => _environment.ClearDrawableCells(); +} + +internal sealed class RuntimeWorldFrameCameraSource : IWorldFrameCameraSource +{ + private readonly CameraController _cameras; + private readonly LocalPlayerTeleportController _teleport; + + public RuntimeWorldFrameCameraSource( + CameraController cameras, + LocalPlayerTeleportController teleport) + { + _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras)); + _teleport = teleport ?? throw new ArgumentNullException(nameof(teleport)); + } + + public WorldCameraFrame Resolve() + { + ICamera camera = _teleport.ApplyViewPlane(_cameras.Active); + Matrix4x4 projection = camera.Projection; + Matrix4x4 viewProjection = camera.View * projection; + FrustumPlanes frustum = FrustumPlanes.FromViewProjection(viewProjection); + Matrix4x4.Invert(camera.View, out Matrix4x4 inverseView); + var position = new Vector3(inverseView.M41, inverseView.M42, inverseView.M43); + return new WorldCameraFrame( + camera, + projection, + viewProjection, + frustum, + inverseView, + position); + } +} + +internal sealed class RuntimeWorldFrameRootSource : IWorldFrameRootSource +{ + private const float LandblockSize = 192f; + + private readonly PhysicsEngine _physics; + private readonly CellVisibility _cells; + private readonly ILocalPlayerModeSource _mode; + private readonly IChaseCameraSource _chase; + private readonly ILocalPlayerControllerSource _player; + private readonly LiveWorldOriginState _origin; + + public RuntimeWorldFrameRootSource( + PhysicsEngine physics, + CellVisibility cells, + ILocalPlayerModeSource mode, + IChaseCameraSource chase, + ILocalPlayerControllerSource player, + LiveWorldOriginState origin) + { + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _cells = cells ?? throw new ArgumentNullException(nameof(cells)); + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + _chase = chase ?? throw new ArgumentNullException(nameof(chase)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + } + + public WorldRootFrame Resolve(in WorldCameraFrame camera) + { + // Retail keeps these as two related but distinct positions: + // CellManager::ChangePosition @ 0x004559B0 uses the player's current + // cell for seen_outside/lighting, while RenderNormalMode @ 0x00453AA0 + // passes the collided camera's viewer_cell to DrawInside. + LoadedCell? playerRoot = null; + if (_physics.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell playerCell + && _cells.TryGetCell(playerCell.Id, out LoadedCell? registeredPlayer)) + { + playerRoot = registeredPlayer; + } + + bool playerSeenOutside = playerRoot?.SeenOutside ?? true; + uint viewerCellId = _mode.IsPlayerMode + && _chase.Retail is { } retailChase + && CameraDiagnostics.UseRetailChaseCamera + ? retailChase.ViewerCellId + : playerRoot?.CellId ?? 0u; + LoadedCell? viewerRoot = null; + if (viewerCellId != 0u + && _cells.TryGetCell(viewerCellId, out LoadedCell? registeredViewer)) + { + viewerRoot = registeredViewer; + } + + var player = _player.Controller; + Vector3 playerViewPosition = player?.RenderPosition + ?? player?.Position + ?? camera.Position; + bool cameraInsideCell = viewerRoot is not null; + bool rootSeenOutside = viewerRoot?.SeenOutside ?? true; + bool playerInsideCell = playerRoot is not null && !playerSeenOutside; + + uint? playerLandblockId = null; + if (_mode.IsPlayerMode && player is not null) + { + int playerX = _origin.CenterX + (int)Math.Floor(player.Position.X / LandblockSize); + int playerY = _origin.CenterY + (int)Math.Floor(player.Position.Y / LandblockSize); + playerLandblockId = (uint)((playerX << 24) | (playerY << 16) | 0xFFFF); + } + + int renderCenterX = _origin.CenterX + + (int)Math.Floor(camera.Position.X / LandblockSize); + int renderCenterY = _origin.CenterY + + (int)Math.Floor(camera.Position.Y / LandblockSize); + uint playerCellId = _physics.DataCache?.CellGraph.CurrCell?.Id ?? 0u; + bool playerIndoorGate = RenderingDiagnostics.ShouldRenderIndoor( + playerCellId, + playerRoot is not null); + + return new WorldRootFrame( + playerRoot, + playerSeenOutside, + viewerCellId, + camera.Position, + playerViewPosition, + viewerRoot, + cameraInsideCell, + rootSeenOutside, + playerInsideCell, + playerLandblockId, + renderCenterX, + renderCenterY, + playerCellId, + playerIndoorGate); + } +} + +internal sealed class RuntimeWorldFrameVisibilityPreparation + : IWorldFrameVisibilityPreparation +{ + private readonly RetailSelectionScene? _selection; + private readonly ParticleVisibilityController _particles; + private readonly TerrainModernRenderer? _terrain; + private readonly WorldRevealCoordinator? _reveal; + private readonly WbFrustum? _environmentFrustum; + + public RuntimeWorldFrameVisibilityPreparation( + RetailSelectionScene? selection, + ParticleVisibilityController particles, + TerrainModernRenderer? terrain, + WorldRevealCoordinator? reveal, + WbFrustum? environmentFrustum) + { + _selection = selection; + _particles = particles ?? throw new ArgumentNullException(nameof(particles)); + _terrain = terrain; + _reveal = reveal; + _environmentFrustum = environmentFrustum; + } + + public void Begin(in WorldCameraFrame camera, bool waitingForLogin) + { + _selection?.SetViewFrustum(camera.Frustum); + _particles.BeginFrame(camera.Position); + _terrain?.BeginVisibilityFrame(); + if (waitingForLogin) + return; + + _particles.UseWorldView(); + _reveal?.ObserveWorldViewportVisible(); + _reveal?.Complete(); + } + + public void PublishViewProjection(in WorldCameraFrame camera) => + _environmentFrustum?.Update(camera.ViewProjection); +} + +internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPreview +{ + private readonly SettingsVM? _settings; + private readonly OpenAlAudioEngine? _audio; + private readonly CameraController _cameras; + private readonly DisplayFramePacingController _pacing; + + public RuntimeWorldFrameSettingsPreview( + SettingsVM? settings, + OpenAlAudioEngine? audio, + CameraController cameras, + DisplayFramePacingController pacing) + { + _settings = settings; + _audio = audio; + _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras)); + _pacing = pacing ?? throw new ArgumentNullException(nameof(pacing)); + } + + public void Apply(in WorldCameraFrame camera) + { + if (_audio is { IsAvailable: true } && _settings is not null) + { + var audio = _settings.AudioDraft; + _audio.MasterVolume = audio.Master; + _audio.MusicVolume = audio.Music; + _audio.SfxVolume = audio.Sfx; + _audio.AmbientVolume = audio.Ambient; + } + + if (_settings is not null) + { + var display = _settings.DisplayDraft; + float fieldOfView = display.FieldOfView * (MathF.PI / 180f); + _cameras.Orbit.FovY = fieldOfView; + _cameras.Fly.FovY = fieldOfView; + if (_cameras.Chase is not null) + _cameras.Chase.FovY = fieldOfView; + _pacing.ApplyPreference(display.VSync); + } + + if (_audio is not { IsAvailable: true }) + return; + + Matrix4x4 inverse = camera.InverseView; + var forward = new Vector3(-inverse.M31, -inverse.M32, -inverse.M33); + var up = new Vector3(inverse.M21, inverse.M22, inverse.M23); + Vector3 position = camera.Position; + _audio.SetListener( + position.X, position.Y, position.Z, + forward.X, forward.Y, forward.Z, + up.X, up.Y, up.Z); + } +} + +internal interface IWorldRenderRangeSource +{ + int NearRadius { get; } + + int FarRadius { get; } +} + +internal sealed class WorldRenderRangeState : IWorldRenderRangeSource +{ + public WorldRenderRangeState(int nearRadius, int farRadius) + { + NearRadius = nearRadius; + FarRadius = farRadius; + } + + public int NearRadius { get; set; } + + public int FarRadius { get; set; } +} + +internal sealed class RuntimeWorldFrameEnvironmentPreparation + : IWorldFrameEnvironmentPreparation +{ + private const float LandblockSize = 192f; + + private readonly RuntimeOptions _options; + private readonly WorldTimeService _worldTime; + private readonly LightManager _lighting; + private readonly WbDrawDispatcher? _dispatcher; + private readonly EnvCellRenderer? _environmentCells; + private readonly SceneLightingUboBinding? _lightingUbo; + private readonly IWorldRenderRangeSource _ranges; + private readonly SkyPesFrameController? _skyPes; + private readonly HashSet _visibleCells = []; + private bool _visibleCellsValid; + + public RuntimeWorldFrameEnvironmentPreparation( + RuntimeOptions options, + WorldTimeService worldTime, + LightManager lighting, + WbDrawDispatcher? dispatcher, + EnvCellRenderer? environmentCells, + SceneLightingUboBinding? lightingUbo, + IWorldRenderRangeSource ranges, + SkyPesFrameController? skyPes) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); + _lighting = lighting ?? throw new ArgumentNullException(nameof(lighting)); + _dispatcher = dispatcher; + _environmentCells = environmentCells; + _lightingUbo = lightingUbo; + _ranges = ranges ?? throw new ArgumentNullException(nameof(ranges)); + _skyPes = skyPes; + } + + public void Prepare( + in WorldCameraFrame camera, + in WorldRootFrame roots, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup) + { + if (_options.EnableSkyPesDebug) + { + _skyPes?.Update( + (float)_worldTime.DayFraction, + activeDayGroup, + camera.Position, + roots.CameraInsideCell); + } + + UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell); + _lighting.UpdateViewerLight(roots.PlayerViewPosition); + _lighting.Tick(camera.Position); + _lighting.BuildPointLightSnapshot( + roots.PlayerViewPosition, + _visibleCellsValid ? _visibleCells : null); + _dispatcher?.SetSceneLights(_lighting.PointSnapshot); + _environmentCells?.SetPointSnapshot(_lighting.PointSnapshot); + + AtmosphereSnapshot atmosphere = foundation.Atmosphere; + SceneLightingUbo ubo = SceneLightingUbo.Build( + _lighting, + in atmosphere, + camera.Position, + (float)_worldTime.DayFraction); + float fogStart = _ranges.NearRadius + * LandblockSize + * _options.FogStartMultiplier; + float fogEnd = _ranges.FarRadius + * LandblockSize + * _options.FogEndMultiplier; + ubo.FogParams = new Vector4( + fogStart, + fogEnd, + ubo.FogParams.Z, + ubo.FogParams.W); + _lightingUbo?.Upload(ubo); + + RenderingDiagnostics.EmitLight( + insideCell: roots.PlayerInsideCell, + ambientR: _lighting.CurrentAmbient.AmbientColor.X, + ambientG: _lighting.CurrentAmbient.AmbientColor.Y, + ambientB: _lighting.CurrentAmbient.AmbientColor.Z, + sunIntensity: _lighting.Sun?.Intensity ?? 0f, + registeredLights: _lighting.RegisteredCount, + activeLights: (int)ubo.CellAmbient.W, + playerCellId: roots.PlayerRoot?.CellId ?? 0u, + lights: _lighting); + } + + public void ObserveDrawableCells(IReadOnlySet drawableCells) + { + ArgumentNullException.ThrowIfNull(drawableCells); + _visibleCells.Clear(); + _visibleCells.UnionWith(drawableCells); + _visibleCellsValid = true; + } + + public void ClearDrawableCells() => _visibleCellsValid = false; + + private void UpdateSunFromSky(SkyKeyframe keyframe, bool playerInsideCell) + { + // CellManager::ChangePosition @ 0x004559B0 keys this on the player + // cell's seen_outside flag and installs neutral 0.2 ambient indoors. + Vector3 sunToWorld = -SkyStateProvider.SunDirectionFromKeyframe(keyframe); + if (playerInsideCell) + { + _lighting.Sun = new LightSource + { + Kind = LightKind.Directional, + WorldForward = sunToWorld, + ColorLinear = Vector3.Zero, + Intensity = 0f, + Range = 1f, + }; + _lighting.CurrentAmbient = new CellAmbientState( + new Vector3(0.20f, 0.20f, 0.20f), + Vector3.Zero, + sunToWorld); + return; + } + + _lighting.Sun = new LightSource + { + Kind = LightKind.Directional, + WorldForward = sunToWorld, + ColorLinear = keyframe.SunColor, + Intensity = 1f, + Range = 1f, + }; + _lighting.CurrentAmbient = new CellAmbientState( + keyframe.AmbientColor, + keyframe.SunColor, + sunToWorld); + } +} + +internal sealed class RuntimeWorldFrameAnimatedEntitySource + : IWorldFrameAnimatedEntitySource +{ + private readonly LiveEntityAnimationRuntimeView _live; + private readonly RetailStaticAnimatingObjectScheduler? _statics; + private readonly EquippedChildRenderController? _equipped; + private readonly HashSet _scratch = []; + + public RuntimeWorldFrameAnimatedEntitySource( + LiveEntityAnimationRuntimeView live, + RetailStaticAnimatingObjectScheduler? statics, + EquippedChildRenderController? equipped) + { + _live = live ?? throw new ArgumentNullException(nameof(live)); + _statics = statics; + _equipped = equipped; + } + + public HashSet Capture() + { + // Membership means "classify from the current pose this frame," not + // merely "animation is advancing." A settled door or translucency + // hook can differ permanently from its cached rest pose, so every + // sequenced/attached entity remains on the live path. + _live.CopySpatialIdsTo(_scratch); + _statics?.CopyAnimatedEntityIdsTo(_scratch); + if (_equipped is not null) + { + foreach (uint entityId in _equipped.AttachedEntityIds) + _scratch.Add(entityId); + } + + return _scratch; + } +} + +internal sealed class RuntimeWorldFrameBuildingSource : IWorldFrameBuildingSource +{ + private readonly LandblockPresentationPipeline _presentation; + private readonly CellVisibility _cells; + private readonly List _scratch = []; + + public RuntimeWorldFrameBuildingSource( + LandblockPresentationPipeline presentation, + CellVisibility cells) + { + _presentation = presentation + ?? throw new ArgumentNullException(nameof(presentation)); + _cells = cells ?? throw new ArgumentNullException(nameof(cells)); + } + + public WorldBuildingFrame Gather( + LoadedCell? viewerRoot, + uint viewerCellId, + in FrustumPlanes frustum) + { + _scratch.Clear(); + LoadedCell? outdoorNode = null; + if (viewerRoot is null && viewerCellId == 0u) + return new WorldBuildingFrame(null, _scratch); + + // DrawBuilding @ 0x0059F2A0 gates each building by its visible aperture + // before walking its portal BSP. The portal-bounds frustum test is the + // corresponding coarse admission gate; there is no distance cutoff. + foreach (BuildingRegistry registry in _presentation.BuildingRegistries) + { + foreach (Building building in registry.All()) + { + if (building.HasPortalBounds + && !FrustumCuller.IsAabbVisible( + frustum, + building.PortalBounds.Min, + building.PortalBounds.Max)) + { + continue; + } + + foreach (uint cellId in building.EnvCellIds) + { + if (_cells.TryGetCell(cellId, out LoadedCell? cell) && cell is not null) + _scratch.Add(cell); + } + } + } + + if (viewerRoot is null) + outdoorNode = OutdoorCellNode.Build(viewerCellId); + if (RenderingDiagnostics.ProbeFlapEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_scratch.Count} (T2 frustum-gated per-building floods)")); + } + + return new WorldBuildingFrame(outdoorNode, _scratch); + } +} diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 82e3e3a5..32e502eb 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -46,7 +46,9 @@ public sealed record RuntimeOptions( string? AcDir, bool UiProbeDump, string? UiProbeScript, - string? AutomationArtifactDirectory) + string? AutomationArtifactDirectory, + float FogStartMultiplier, + float FogEndMultiplier) { /// /// Build options from the process environment. Used by @@ -99,7 +101,9 @@ public sealed record RuntimeOptions( UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")), UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")), AutomationArtifactDirectory: - NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR"))); + NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")), + FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f, + FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f); } /// True iff live-mode credentials are present and valid for connecting. @@ -120,4 +124,9 @@ public sealed record RuntimeOptions( private static int? TryParseNonNegativeInt(string? s) => TryParseInt(s) is { } v && v >= 0 ? v : null; + + private static float? TryParseFloat(string? s) + => float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float value) + ? value + : null; } diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs new file mode 100644 index 00000000..498422ff --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -0,0 +1,522 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Lighting; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; +using AcDream.Core.World; +using AcDream.Core.World.Cells; + +namespace AcDream.App.Tests.Rendering; + +public sealed class WorldRenderFrameBuilderTests +{ + [Fact] + public void Build_orders_world_preparation_and_combines_borrowed_results() + { + List calls = []; + var camera = new FlyCamera { Position = new Vector3(11f, 22f, 33f) }; + var cameraFrame = new WorldCameraFrame( + camera, + camera.Projection, + camera.View * camera.Projection, + default, + Matrix4x4.Identity, + camera.Position); + WorldRootFrame roots = default; + var animated = new HashSet { 41u, 42u }; + var buildings = new List(); + var foundation = new RenderFrameFoundation( + PortalViewportVisible: false, + Sky: default, + Atmosphere: default); + var visibility = new RecordingVisibility(calls); + var environment = new RecordingEnvironment(calls); + var builder = new WorldRenderFrameBuilder( + new RecordingCamera(calls, cameraFrame), + visibility, + new RecordingSettings(calls), + new RecordingRoots(calls, roots), + environment, + new RecordingAnimated(calls, animated), + new RecordingBuildings(calls, new WorldBuildingFrame(null, buildings))); + + WorldRenderFrame result = builder.Build( + in foundation, + waitingForLogin: true, + activeDayGroup: null); + var drawableCells = new HashSet { 0x01010001u }; + builder.ObserveDrawableCells(drawableCells); + builder.ClearDrawableCells(); + + Assert.Equal( + [ + "camera", + "visibility:begin", + "settings", + "roots", + "environment", + "visibility:projection", + "animated", + "buildings", + ], + calls); + Assert.Same(camera, result.Camera.Camera); + Assert.Same(animated, result.AnimatedEntityIds); + Assert.Same(buildings, result.Buildings.NearbyBuildingCells); + Assert.Null(result.ClipRoot); + Assert.True(visibility.WaitingForLogin); + Assert.Equal(foundation, environment.Foundation); + Assert.Same(drawableCells, environment.DrawableCells); + Assert.Equal(1, environment.ClearCount); + } + + [Fact] + public void Required_sources_fail_fast() + { + var camera = new RecordingCamera([], default); + var visibility = new RecordingVisibility([]); + var settings = new RecordingSettings([]); + var roots = new RecordingRoots([], default); + var environment = new RecordingEnvironment([]); + var animated = new RecordingAnimated([], []); + var buildings = new RecordingBuildings([], default); + + Assert.Throws(() => + new WorldRenderFrameBuilder(null!, visibility, settings, roots, environment, animated, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, null!, settings, roots, environment, animated, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, visibility, null!, roots, environment, animated, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, visibility, settings, null!, environment, animated, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, visibility, settings, roots, null!, animated, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, null!, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, animated, null!)); + } + + [Fact] + public void Render_range_state_updates_both_tiers_without_replacing_the_source() + { + IWorldRenderRangeSource source = new WorldRenderRangeState(4, 12); + var mutable = Assert.IsType(source); + + mutable.NearRadius = 7; + mutable.FarRadius = 19; + + Assert.Equal(7, source.NearRadius); + Assert.Equal(19, source.FarRadius); + } + + [Fact] + public void Runtime_animated_source_reuses_scratch_and_removes_stale_ids() + { + var slot = new LiveEntityRuntimeSlot(); + var live = new LiveEntityAnimationRuntimeView(slot); + var source = new RuntimeWorldFrameAnimatedEntitySource( + live, + statics: null, + equipped: null); + + HashSet first = source.Capture(); + first.Add(0xDEADBEEFu); + HashSet second = source.Capture(); + + Assert.Same(first, second); + Assert.Empty(second); + } + + [Fact] + public void Runtime_building_source_reuses_scratch_and_rebuilds_outdoor_root() + { + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => { }, + state: new GpuWorldState()); + var source = new RuntimeWorldFrameBuildingSource( + pipeline, + new CellVisibility()); + FrustumPlanes frustum = default; + + WorldBuildingFrame first = source.Gather( + viewerRoot: null, + viewerCellId: 0x01010001u, + in frustum); + var scratch = Assert.IsType>(first.NearbyBuildingCells); + scratch.Add(new LoadedCell { CellId = 0x01010100u }); + WorldBuildingFrame second = source.Gather( + viewerRoot: null, + viewerCellId: 0x02020001u, + in frustum); + + Assert.Same(scratch, second.NearbyBuildingCells); + Assert.Empty(second.NearbyBuildingCells); + Assert.Equal(0x01010001u, first.OutdoorNode?.CellId); + Assert.Equal(0x02020001u, second.OutdoorNode?.CellId); + } + + [Fact] + public void Runtime_root_source_keeps_player_lighting_and_viewer_render_cells_distinct() + { + const uint playerCellId = 0x01010100u; + const uint viewerCellId = 0x01010101u; + var physics = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + physics.DataCache.CellGraph.Add(CoreCell(playerCellId, seenOutside: false)); + physics.UpdatePlayerCurrCell(playerCellId); + var cells = new CellVisibility(); + var playerCell = new LoadedCell { CellId = playerCellId, SeenOutside = false }; + var viewerCell = new LoadedCell { CellId = viewerCellId, SeenOutside = true }; + cells.AddCell(playerCell); + cells.AddCell(viewerCell); + var retailChase = new RetailChaseCamera(); + retailChase.Update( + playerPosition: Vector3.Zero, + playerYaw: 0f, + playerVelocity: Vector3.Zero, + isOnGround: true, + contactPlaneNormal: Vector3.UnitZ, + dt: 1f / 60f, + cellId: viewerCellId); + var mode = new LocalPlayerModeState { IsPlayerMode = true }; + var chase = new ChaseCameraInputState { Retail = retailChase }; + var origin = new LiveWorldOriginState(); + origin.SetPlaceholder(0x01, 0x01); + var source = new RuntimeWorldFrameRootSource( + physics, + cells, + mode, + chase, + new LocalPlayerControllerSlot(), + origin); + var camera = new FlyCamera { Position = new Vector3(5f, 6f, 7f) }; + WorldCameraFrame cameraFrame = CameraFrame(camera); + bool previousRetailCamera = CameraDiagnostics.UseRetailChaseCamera; + + try + { + CameraDiagnostics.UseRetailChaseCamera = true; + WorldRootFrame result = source.Resolve(in cameraFrame); + + Assert.Same(playerCell, result.PlayerRoot); + Assert.Same(viewerCell, result.ViewerRoot); + Assert.True(result.PlayerInsideCell); + Assert.True(result.CameraInsideCell); + Assert.False(result.PlayerSeenOutside); + Assert.True(result.RootSeenOutside); + Assert.True(result.RenderSky); + Assert.Equal(playerCellId, result.PlayerCellId); + Assert.Equal(viewerCellId, result.ViewerCellId); + Assert.Equal(camera.Position, result.ViewerEyePosition); + } + finally + { + CameraDiagnostics.UseRetailChaseCamera = previousRetailCamera; + } + } + + [Fact] + public void Runtime_root_source_preserves_null_root_fallback_before_player_membership() + { + var physics = new PhysicsEngine(); + var source = new RuntimeWorldFrameRootSource( + physics, + new CellVisibility(), + new LocalPlayerModeState(), + new ChaseCameraInputState(), + new LocalPlayerControllerSlot(), + new LiveWorldOriginState()); + WorldCameraFrame camera = CameraFrame(new FlyCamera()); + + WorldRootFrame result = source.Resolve(in camera); + + Assert.Null(result.PlayerRoot); + Assert.Null(result.ViewerRoot); + Assert.Equal(0u, result.PlayerCellId); + Assert.Equal(0u, result.ViewerCellId); + Assert.False(result.PlayerInsideCell); + Assert.False(result.CameraInsideCell); + Assert.True(result.RenderSky); + } + + [Fact] + public void Runtime_environment_copies_visible_cells_and_clear_restores_unscoped_lights() + { + const uint visibleCell = 0x01010100u; + const uint hiddenCell = 0x01010101u; + var lighting = new LightManager(); + var visibleLight = new LightSource + { + Kind = LightKind.Point, + CellId = visibleCell, + }; + var hiddenLight = new LightSource + { + Kind = LightKind.Point, + CellId = hiddenCell, + }; + lighting.Register(visibleLight); + lighting.Register(hiddenLight); + var environment = new RuntimeWorldFrameEnvironmentPreparation( + RuntimeOptions.Parse("test-dat", _ => null), + new WorldTimeService(SkyStateProvider.Default()), + lighting, + dispatcher: null, + environmentCells: null, + lightingUbo: null, + new WorldRenderRangeState(4, 12), + skyPes: null); + var borrowed = new HashSet { visibleCell }; + environment.ObserveDrawableCells(borrowed); + borrowed.Clear(); + borrowed.Add(hiddenCell); + WorldCameraFrame camera = CameraFrame(new FlyCamera()); + WorldRootFrame roots = default; + RenderFrameFoundation foundation = default; + + environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null); + + Assert.Contains(visibleLight, lighting.PointSnapshot); + Assert.DoesNotContain(hiddenLight, lighting.PointSnapshot); + + environment.ClearDrawableCells(); + environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null); + + Assert.Contains(visibleLight, lighting.PointSnapshot); + Assert.Contains(hiddenLight, lighting.PointSnapshot); + } + + [Fact] + public void Production_builder_preserves_the_frame_preparation_order() + { + string source = BuilderSource(); + + AssertAppearsInOrder( + source, + "WorldCameraFrame camera = _camera.Resolve();", + "_visibility.Begin(in camera, waitingForLogin);", + "_settings.Apply(in camera);", + "WorldRootFrame roots = _roots.Resolve(in camera);", + "_environment.Prepare(in camera, in roots, in foundation, activeDayGroup);", + "_visibility.PublishViewProjection(in camera);", + "HashSet animated = _animated.Capture();", + "WorldBuildingFrame buildings = _buildings.Gather("); + } + + [Fact] + public void Environment_preparation_keeps_lighting_snapshot_before_ubo_upload() + { + string source = BuilderSource(); + + AssertAppearsInOrder( + source, + "UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);", + "_lighting.UpdateViewerLight(roots.PlayerViewPosition);", + "_lighting.Tick(camera.Position);", + "_lighting.BuildPointLightSnapshot(", + "_dispatcher?.SetSceneLights(_lighting.PointSnapshot);", + "_environmentCells?.SetPointSnapshot(_lighting.PointSnapshot);", + "SceneLightingUbo ubo = SceneLightingUbo.Build(", + "_lightingUbo?.Upload(ubo);", + "RenderingDiagnostics.EmitLight("); + } + + [Fact] + public void Game_window_uses_the_typed_builder_and_releases_it_before_render_owners() + { + string source = GameWindowSource(); + + Assert.Equal( + 1, + CountOccurrences(source, "_worldRenderFrameBuilder!.Build(")); + Assert.DoesNotContain("private void UpdateSunFromSky(", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void UpdateSkyPes(", source, StringComparison.Ordinal); + Assert.DoesNotContain("private static float ParseEnvFloat(", source, StringComparison.Ordinal); + AssertAppearsInOrder( + source, + "if (_cameraController is not null && !portalViewportVisible)", + "_retailAlphaQueue.BeginFrame();", + "_worldRenderFrameBuilder!.Build(", + "if (IsLiveModeWaitingForLogin)", + "normalWorldDrawn = true;"); + AssertAppearsInOrder( + source, + "_worldRenderFrameBuilder = null;", + "_skyPesFrame = null;", + "new(\"equipped children\"", + "new(\"effect network state\"", + "new(\"audio\"", + "new(\"mesh draw dispatcher\"", + "new(\"environment cells\"", + "new(\"scene lighting\""); + } + + private static string BuilderSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "WorldRenderFrameBuilder.cs")); + + private static WorldCameraFrame CameraFrame(FlyCamera camera) => new( + camera, + camera.Projection, + camera.View * camera.Projection, + default, + Matrix4x4.Identity, + camera.Position); + + private static AcDream.Core.World.Cells.EnvCell CoreCell( + uint id, + bool seenOutside) => new( + id, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + seenOutside, + containmentBsp: null); + + private static string GameWindowSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + private static int CountOccurrences(string source, string needle) + { + int count = 0; + int cursor = 0; + while ((cursor = source.IndexOf(needle, cursor, StringComparison.Ordinal)) >= 0) + { + count++; + cursor += needle.Length; + } + + return count; + } + + private static void AssertAppearsInOrder(string source, params string[] needles) + { + int cursor = -1; + foreach (string needle in needles) + { + int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Missing expected source fragment: {needle}"); + Assert.True(next > cursor, $"Out-of-order source fragment: {needle}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } + + private sealed class RecordingCamera( + List calls, + WorldCameraFrame result) : IWorldFrameCameraSource + { + public WorldCameraFrame Resolve() + { + calls.Add("camera"); + return result; + } + } + + private sealed class RecordingVisibility(List calls) + : IWorldFrameVisibilityPreparation + { + public bool WaitingForLogin { get; private set; } + + public void Begin(in WorldCameraFrame camera, bool waitingForLogin) + { + calls.Add("visibility:begin"); + WaitingForLogin = waitingForLogin; + } + + public void PublishViewProjection(in WorldCameraFrame camera) => + calls.Add("visibility:projection"); + } + + private sealed class RecordingSettings(List calls) + : IWorldFrameSettingsPreview + { + public void Apply(in WorldCameraFrame camera) => calls.Add("settings"); + } + + private sealed class RecordingRoots( + List calls, + WorldRootFrame result) : IWorldFrameRootSource + { + public WorldRootFrame Resolve(in WorldCameraFrame camera) + { + calls.Add("roots"); + return result; + } + } + + private sealed class RecordingEnvironment(List calls) + : IWorldFrameEnvironmentPreparation + { + public RenderFrameFoundation Foundation { get; private set; } + + public IReadOnlySet? DrawableCells { get; private set; } + + public int ClearCount { get; private set; } + + public void Prepare( + in WorldCameraFrame camera, + in WorldRootFrame roots, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup) + { + calls.Add("environment"); + Foundation = foundation; + } + + public void ObserveDrawableCells(IReadOnlySet drawableCells) => + DrawableCells = drawableCells; + + public void ClearDrawableCells() => ClearCount++; + } + + private sealed class RecordingAnimated( + List calls, + HashSet result) : IWorldFrameAnimatedEntitySource + { + public HashSet Capture() + { + calls.Add("animated"); + return result; + } + } + + private sealed class RecordingBuildings( + List calls, + WorldBuildingFrame result) : IWorldFrameBuildingSource + { + public WorldBuildingFrame Gather( + LoadedCell? viewerRoot, + uint viewerCellId, + in FrustumPlanes frustum) + { + calls.Add("buildings"); + return result; + } + } +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 6aecc398..86898d81 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -43,6 +43,8 @@ public sealed class RuntimeOptionsTests Assert.False(opts.UiProbeDump); Assert.Null(opts.UiProbeScript); Assert.Null(opts.AutomationArtifactDirectory); + Assert.Equal(0.7f, opts.FogStartMultiplier); + Assert.Equal(0.95f, opts.FogEndMultiplier); Assert.False(opts.UiProbeEnabled); Assert.False(opts.HasLiveCredentials); } @@ -180,6 +182,26 @@ public sealed class RuntimeOptionsTests Assert.Equal(12, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "12" })).LegacyStreamRadius); } + [Fact] + public void FogMultipliers_ParseInvariantFloats_AndFallBackIndependently() + { + var parsed = RuntimeOptions.Parse(AnyDatDir, Env(new() + { + ["ACDREAM_FOG_START_MULT"] = "0.625", + ["ACDREAM_FOG_END_MULT"] = "1.125", + })); + Assert.Equal(0.625f, parsed.FogStartMultiplier); + Assert.Equal(1.125f, parsed.FogEndMultiplier); + + var invalid = RuntimeOptions.Parse(AnyDatDir, Env(new() + { + ["ACDREAM_FOG_START_MULT"] = "not-a-number", + ["ACDREAM_FOG_END_MULT"] = "", + })); + Assert.Equal(0.7f, invalid.FogStartMultiplier); + Assert.Equal(0.95f, invalid.FogEndMultiplier); + } + [Fact] public void DiagnosticFlags_RespectExactValueOne() {