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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 05:54:00 +02:00
parent bc6f09f987
commit 6d6e5b5fa5
8 changed files with 1463 additions and 583 deletions

View file

@ -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` |

View file

@ -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`;

View file

@ -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<LoadedCell> _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<uint> _outdoorSceneParticleEntityIds = new();
private readonly HashSet<uint> _visibleSceneParticleEntityIds = new();
private readonly HashSet<uint> _noSceneParticleEntityIds = new();
@ -248,13 +255,6 @@ public sealed class GameWindow : IDisposable
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _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<uint> _animatedIdsScratch = new();
/// <summary>
/// Tier 1 cache (#53): per-entity classification results for static
/// entities (those NOT in <see cref="_animatedEntities"/>). 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.
/// <summary>
private readonly HashSet<SkyPesKey> _activeSkyPes = new();
private readonly HashSet<SkyPesKey> _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<uint> _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<SkyPesKey>();
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<System.Numerics.Matrix4x4>(),
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);
}
/// <summary>
/// Phase 5d — retail <c>AdminEnvirons</c> (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<uint> 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<uint>? animatedIds = _animatedIdsScratch;
var envCellViewProj = cameraFrame.ViewProjection;
HashSet<uint> 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<AcDream.App.Rendering.Wb.BuildingRegistry>())
{
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<LoadedCell> 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;
}
/// <summary>
/// Derive the current sun (directional light, slot 0 of the UBO)
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,
/// plus the cell ambient. Indoor cells force the sun intensity to
/// zero and substitute a flat 0.2 white ambient — exact retail
/// behavior per <c>CellManager::ChangePosition</c> @ 0x004559B0,
/// which calls <c>SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)</c>
/// when the player's <c>CObjCell::seen_outside</c> flag is 0.
/// Indoor brightness then comes from per-cell point lights
/// (Setup.Lights on the cell's static objects, registered through
/// <see cref="AcDream.Core.Lighting.LightingHookSink"/>).
/// 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.
/// </summary>
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}");
}
/// <summary>A.5 T22: parse a float environment variable, returning
/// <paramref name="defaultValue"/> when the variable is absent or unparseable.</summary>
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()),

View file

@ -0,0 +1,135 @@
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Vfx;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// 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.
/// </summary>
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<SkyPesKey> _active = [];
private readonly HashSet<SkyPesKey> _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<SkyPesKey>();
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<Matrix4x4>(),
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);
}
}

View file

@ -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;
/// <summary>Camera facts borrowed for one world render preparation.</summary>
internal readonly record struct WorldCameraFrame(
ICamera Camera,
Matrix4x4 Projection,
Matrix4x4 ViewProjection,
FrustumPlanes Frustum,
Matrix4x4 InverseView,
Vector3 Position);
/// <summary>Player-lighting and collided-viewer roots for one frame.</summary>
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;
}
/// <summary>Borrowed building scratch, valid only until the next build.</summary>
internal readonly record struct WorldBuildingFrame(
LoadedCell? OutdoorNode,
IReadOnlyList<LoadedCell> NearbyBuildingCells);
/// <summary>
/// Immutable one-frame world facts. Collection members borrow reusable owner
/// scratch and must not be retained beyond the next <see cref="WorldRenderFrameBuilder.Build"/>.
/// </summary>
internal readonly record struct WorldRenderFrame(
WorldCameraFrame Camera,
WorldRootFrame Roots,
WorldBuildingFrame Buildings,
HashSet<uint> 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<uint> drawableCells);
void ClearDrawableCells();
}
internal interface IWorldFrameAnimatedEntitySource
{
HashSet<uint> Capture();
}
internal interface IWorldFrameBuildingSource
{
WorldBuildingFrame Gather(
LoadedCell? viewerRoot,
uint viewerCellId,
in FrustumPlanes frustum);
}
/// <summary>
/// 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.
/// </summary>
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<uint> 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<uint> 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<uint> _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<uint> 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<LiveEntityAnimationState> _live;
private readonly RetailStaticAnimatingObjectScheduler? _statics;
private readonly EquippedChildRenderController? _equipped;
private readonly HashSet<uint> _scratch = [];
public RuntimeWorldFrameAnimatedEntitySource(
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> live,
RetailStaticAnimatingObjectScheduler? statics,
EquippedChildRenderController? equipped)
{
_live = live ?? throw new ArgumentNullException(nameof(live));
_statics = statics;
_equipped = equipped;
}
public HashSet<uint> 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<LoadedCell> _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);
}
}

View file

@ -46,7 +46,9 @@ public sealed record RuntimeOptions(
string? AcDir,
bool UiProbeDump,
string? UiProbeScript,
string? AutomationArtifactDirectory)
string? AutomationArtifactDirectory,
float FogStartMultiplier,
float FogEndMultiplier)
{
/// <summary>
/// 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);
}
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
@ -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;
}

View file

@ -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<string> 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<uint> { 41u, 42u };
var buildings = new List<LoadedCell>();
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<uint> { 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<ArgumentNullException>(() =>
new WorldRenderFrameBuilder(null!, visibility, settings, roots, environment, animated, buildings));
Assert.Throws<ArgumentNullException>(() =>
new WorldRenderFrameBuilder(camera, null!, settings, roots, environment, animated, buildings));
Assert.Throws<ArgumentNullException>(() =>
new WorldRenderFrameBuilder(camera, visibility, null!, roots, environment, animated, buildings));
Assert.Throws<ArgumentNullException>(() =>
new WorldRenderFrameBuilder(camera, visibility, settings, null!, environment, animated, buildings));
Assert.Throws<ArgumentNullException>(() =>
new WorldRenderFrameBuilder(camera, visibility, settings, roots, null!, animated, buildings));
Assert.Throws<ArgumentNullException>(() =>
new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, null!, buildings));
Assert.Throws<ArgumentNullException>(() =>
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<WorldRenderRangeState>(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<LiveEntityAnimationState>(slot);
var source = new RuntimeWorldFrameAnimatedEntitySource(
live,
statics: null,
equipped: null);
HashSet<uint> first = source.Capture();
first.Add(0xDEADBEEFu);
HashSet<uint> 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<List<LoadedCell>>(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<uint> { 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<uint> 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<CellPortal>(),
Array.Empty<uint>(),
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<string> calls,
WorldCameraFrame result) : IWorldFrameCameraSource
{
public WorldCameraFrame Resolve()
{
calls.Add("camera");
return result;
}
}
private sealed class RecordingVisibility(List<string> 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<string> calls)
: IWorldFrameSettingsPreview
{
public void Apply(in WorldCameraFrame camera) => calls.Add("settings");
}
private sealed class RecordingRoots(
List<string> calls,
WorldRootFrame result) : IWorldFrameRootSource
{
public WorldRootFrame Resolve(in WorldCameraFrame camera)
{
calls.Add("roots");
return result;
}
}
private sealed class RecordingEnvironment(List<string> calls)
: IWorldFrameEnvironmentPreparation
{
public RenderFrameFoundation Foundation { get; private set; }
public IReadOnlySet<uint>? 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<uint> drawableCells) =>
DrawableCells = drawableCells;
public void ClearDrawableCells() => ClearCount++;
}
private sealed class RecordingAnimated(
List<string> calls,
HashSet<uint> result) : IWorldFrameAnimatedEntitySource
{
public HashSet<uint> Capture()
{
calls.Add("animated");
return result;
}
}
private sealed class RecordingBuildings(
List<string> calls,
WorldBuildingFrame result) : IWorldFrameBuildingSource
{
public WorldBuildingFrame Gather(
LoadedCell? viewerRoot,
uint viewerCellId,
in FrustumPlanes frustum)
{
calls.Add("buildings");
return result;
}
}
}

View file

@ -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()
{