diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 544d92d3..01dc6d75 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -1096,34 +1096,47 @@ devtools, and screenshots; then closes the GPU-flight transaction in > `TerrainModernRenderer` (terrain), fed by the unified PView stack. This is the > authoritative current draw model; the 2026-05-31 reset handoff is historical. -**One visibility owner.** `RetailPViewRenderer.DrawInside` is the production -world gate. Its root is the collided camera/viewer cell, or the synthetic -outdoor cell adaptation; the player's current cell separately owns sunlight -and indoor lighting. A null root exists only for login/debug/streaming-gap -fallback frames. `RetailPViewRenderer` is the one authoritative PView owner and -product family: it builds the main frame, deliberate per-building exterior -floods for the synthetic outdoor root, and separate interior-root look-in -frames. No second per-frame ACME visibility BFS competes with that family. +**One visibility owner.** `RetailFrameWalk`, driven once per frame by +`WalkFrameDriver.Collect`, is the production authority for rooting, portal +recursion, visible cells/buildings, per-view admission, and draw order. Its root +is the collided camera/viewer cell or retail's synthetic outdoor cell; the +player's current cell separately owns sunlight and indoor lighting. A null root +exists only for login/debug/streaming-gap fallback frames. The classes named +`RetailPViewRenderer` and `RetailPViewPassExecutor` remain composition/pass +facades around that one walk; they do not build a second visibility product. +`PortalVisibilityBuilder` is retained only by tests and research diagnostics and +has no production caller. `CellVisibility` does not decide frame visibility. -**Current draw discipline.** Outside-view slices draw sky, terrain, and outdoor -statics first. Interior-root building look-ins then punch all entry apertures -before drawing their shell and contents. The landscape shared-alpha scope -flushes before the root-specific depth boundary. Interior roots perform the -conditional depth clear and then write true-depth exit seals; the synthetic -outdoor root retains world depth and writes far-Z building-entry punches. -Opaque EnvCell shells, immediate far-to-near transparent EnvCell shells, cell -statics and their particles, then the surviving main-stage dynamics and their -particles follow. Look-in and outside-stage dynamics intentionally draw in -their earlier landscape phases. The final world shared-alpha scope flushes -before private portal/paperdoll viewports and UI. +**Current draw discipline.** The walk records one ordered event stream for +landscape cells, building portals, EnvCell shells, statics, dynamics, particles, +depth boundaries, and alpha barriers. `WalkFrameDriver` replays those events in +retail order through the Vulkan leaf renderers. Interior roots preserve retail's +conditional depth clear and true-depth exit seals; the synthetic outdoor root +retains world depth and stamps far-Z entry punches. World translucents and scene +particles share `RetailAlphaQueue` and drain at the walk's exact barriers. +Private portal/paperdoll viewports and retained UI execute only after the world +stream finishes. Projectiles are ordinary live-entity draws, never a separate +global pass. -The modern renderer intentionally does not hard-clip every shell or entity to -the accumulated polygon. It combines PView admission and viewcone checks with -retail's punch/seal depth discipline; terrain/outside slices use the bounded -clip-plane/scissor adaptation. World Wb translucents and Scene particles share -one stable far-to-near `RetailAlphaQueue`; EnvCell transparent shells and -private viewports remain immediate. Projectiles are ordinary live-entity draws, -never a separate global pass. +**Portal-slice clipping is part of admission, not a later approximation.** Each +walk visit retains the exact authored pixel-space portal polygon. The frame +adapter converts it once to a Vulkan GPU clip slot, and the cell shell, statics, +and dynamics admitted by that visit draw through that same slot. A content +drawing sphere may reject a slice before submission, but an accepted slice +never emits the whole mesh unclipped. The same cell can therefore draw more +than once through distinct apertures, exactly as retail's `viewconeCheck` plus +`portal_view` loop requires. Particles remain unclipped at submission because +retail contains them positionally through owner-cell ordering, opaque depth, +and alpha barriers. The complete visibility answer is the union of the walk's +visited EnvCells and visited landscape land cells. EnvCell batch preparation +consumes only the former; particle, light, and shadow visibility consume the +union. Omitting the landscape half disables every outdoor emitter even though +its owner still reaches the draw stream. + +WorldBuilder contributes DAT decoding, mesh preparation, residency, and Vulkan +batch mechanics only; it makes no visibility or ordering decision. Forced merge +breaks at walk event boundaries preserve retail order while compatible draws +inside a leaf may still use order-preserving MDI merging. Retail anchors are `SmartBox::RenderNormalMode @ 0x00453AA0`, `PView::DrawInside @ 0x005A5860`, `PView::DrawCells @ 0x005A4840`, diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index bafd4099..4ee88dd8 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -325,7 +325,6 @@ research and is no longer active. | AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 | | ~~AP-28~~ | **RETIRED 2026-08-08 (Campaign A slice A2).** The three picked AL parameters and the gain-driven eviction are both gone. `RetailSoundMixer` now carries the byte-decoded retail curve — `g = dist < 5 ? vol : 25·vol/dist²`, clamped to 1, ONE master multiply, `db = ceil(20·log10 g)`, and a hard −50 dB no-allocate floor (audible radius ≈94.2 m at unity) — with pan as retail's `−15·sin(Δbearing)` in whole decibels and a 5-metre integer deadzone. Every AL source is source-relative with `AL_ROLLOFF_FACTOR = 0` and the global distance model is `None`, so AL contributes no attenuation of its own; the old `InverseDistanceClamped` ref-2 m curve was inverse FIRST power (`2/d`), quieter than retail up close and far louder at range with no cutoff at all. Voice eviction now compares the DAT-authored float priority strictly-less in ring order per `SoundManager::PlaySoundInternal` @ `0x0054FEC0` (the row's old `FUN_00550ad0` citation was wrong — that address is inside an `IntrusiveHashTable` constructor). The residual pan-LAW approximation is AP-173; retail's own `s_bPlaySoundOnlyWhenActive` gate is TS-64. | retired | — | — | `SoundManager::GetAttenuation @ 0x00550020`; `SoundManager::PlaySoundInternal @ 0x00550170` and `@ 0x0054FEC0`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | -| 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`; `RetailPViewPassExecutor.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/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) | @@ -510,7 +509,6 @@ research and is no longer active. | TS-59 | No outbound Flow report (retail emits a 6-byte bytes-received+interval header whenever the inbound remote interval advances). | `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (interval clock present; no Flow emission) | ACE parses the Flow header and has no handler (PacketHeaderOptional.cs:117-124); the standalone unsequenced form would trip the watermark hole. Retail itself never consumes inbound Flow and has no throttle (`WireRoomLeft` is a folded return-1). | A future server that rate-adapts on client Flow reports sees nothing. | `SharedNet::ProcessNewRemoteInterval @ 0x00543A80`; `ClientFlowQueue::WireRoomLeft @ 0x0052C1C0` (folded) | | TS-60 | No 140 s dead-link declaration or referral auto-reconnect in the transport; a silent server is only visible through `LinkStatusSnapshot.SecondsSinceLastPacket` (presentational). | `src/AcDream.Core.Net/Transport/ReliableTransport.cs`; `src/AcDream.Core.Net/WorldSession.cs` (`BuildLinkStatus`) | The input (seconds since last inbound) is already exposed; session lifecycle/reconnect is Runtime's ownership domain and deserves its own campaign rather than a transport-embedded side effect. Every ACE transport death is silence, so nothing server-side depends on the client reacting at 140 s. | A dead link idles until the user acts; no automatic recall/referral reconnect where retail would attempt one. | `ClientNet::ProcessConnection @ 0x00545450` tail (the two 140.0 literals) | | TS-61 | A UDP send failure burns the reliable sequence and its ISAAC word (the encode commits before `_net.Send`); retail keeps the sealed packet at the queue head and retries with the same key. | `src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs` (`SendGameMessage`) | A connectionless-socket `SendTo` failure is effectively unreachable in practice (no route/ICMP errors surface on later receives, not sends, on Windows UDP); recovering it faithfully needs a full outbound packet queue. The N1 review accepted the exposure explicitly. | One `SocketException` on send would desync the outbound cipher permanently (session death; observable as `[net-out-EX]` followed by silence). | `FlowQueue::TransmitNewPackets @ 0x00547C2C` (retry-from-head) | -| TS-56 | Chase-camera mouse input retains acdream's invented post-filter yaw/pitch scalars (`0.004`/`0.003` radians per count), and held-key pitch/zoom retain their non-retail integration shapes. Retail mouse look passes `FilterMouseInput(delta) × configured sensitivity × 1/15` as the replacement scale to `CameraSet::Rotate`, which then applies the shared 8° angle; retail held pitch uses the same angle and zoom scales the viewer offset multiplicatively. | `src/AcDream.App/Input/CameraPointerInputController.cs`; `src/AcDream.App/Input/MouseLookController.cs`; `src/AcDream.App/Rendering/CameraFrameController.cs` | Slice 8 is behavior-preserving ownership work. The named-retail audit proves the mismatch but has not yet extracted the configured mouse-sensitivity default or the exact caller flags needed for a complete feel port; changing only one scalar here would create a mixed input model. | RMB/MMB orbit, held pitch, and zoom can feel slower, faster, or differently accelerated than retail even though callback ordering and filtering are correct. | `CameraSet::Rotate @ 0x00458310`; `CameraSet::MouseLookHandler` call at `0x00458EF9`; `CameraSet::Raise @ 0x00457B00`; `CameraSet::Closer @ 0x004586D0`; `docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md` | | TS-62 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** NO Position route in the dormant executor runs a live `ConstrainTo` binding - including the `SetPosition`/`SetPositionSimple` routes. `RuntimeAuthoritativePositionRoute.ConstrainPhase` (None/Before/After) is classified for EVERY accepted route and recorded into the execution trace, but the constrain-before-vs-after distinction exists purely as classified metadata pending a live binding at the production cutover. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`/`BuildPositionTrace`); `RuntimeAuthoritativePositionRouteClassifier.cs` (`ConstrainPhase`) | Host-cutover work with no Runtime-side owner to bind to yet; the canonical snapshot's Position IS refreshed on every accepted route, so the fact is retained - only the live constrain/smoothing behavior is deferred. The trace carries the exact phase a host must bind. | Until a host wires it, ANY Position continuation applies its raw pose with no constrain-distance clamp or smoothing - a visible pop instead of retail's constrained correction, on exactly the entities created while an authored placement was in flight. | `SmartBox::HandleReceivedPosition` 0x00453FD0, the three `ConstrainTo` sites (~93007 remote-after, ~93024 teleport-after, ~93041 local-ordinary-before) | | TS-63 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** `ApplyResidentCellCleanup`'s three branches: (1) claimed-cell + celless + NOT under lost-cell/deferred ownership - retail's genuine `AddObjectToBeDestroyed` case - has no safe Runtime destruction owner yet, so the executor performs a typed ABANDONMENT (`RejectedAuthority`) instead of destroying; (2) claimed + celless + deferred returns `DeferredUnderLostCellOwnership` - retail's destruction bookkeeping for this exact entity is already owned by the lost-cell/deferred `SetPosition` lifetime (a statement, not a parallel mechanism); (3) claimedCell==0 returns `CelllessNoWeenieMarkUnreachable` and is NOT a divergence - every admitted envelope structurally carries a WeenieDescription (`HasValidShape`), so retail's no-weenie destruction alternative is unreachable through this construction. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyResidentCellCleanup`; the Abandon conversion in `ApplyEnvelope`) | No production caller yet; every branch is typed and test-observable; building a parallel destruction mechanism ahead of the object-table/lost-cell cutover wiring would be the exact workaround class CLAUDE.md forbids - failing closed is the honest interim. | Branch (1): a genuinely claimed-but-celless-undeferred entity aborts the drain and SURVIVES where retail destroys it, until the cutover wiring lands. Branch (3): a future envelope construction without a WeenieDescription would break the premise and needs re-examination. | `SmartBox::HandleCreateObject` 0x00454C80 tail (~93933 destruction mark; ~93942-93943 un-mark/no-weenie) | | TS-64 | **Retail's sound-preference surface is only partly present.** Retail registers eight `[Sound]` keys in `SoundManager::InitPrefs` @ `0x005503F0`; two are unimplemented in acdream. (a) `s_bPlaySoundOnlyWhenActive` (default **1**) is checked against `Device::m_bIsActiveApp` in every entry point and in both `PlaySoundInternal` overloads, so an unfocused retail client is SILENT; acdream keeps playing when the window loses focus. (b) `s_SoundFeatures == 1` forces pan to dead centre; acdream's `RetailSoundMixer.Mix`/`GetPan` take a `panningEnabled` flag with conformance coverage, but no preference is wired behind it, so panning can never be turned off. The three enable bools (`Sound Disabled`, `Ambient Sound Disabled`, `Interface Sound Disabled`) also have no acdream counterpart — note retail's on-disk polarity is inverted relative to its backing variables, so a future reader must not assume the sense. | `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (no focus gate); `src/AcDream.Core/Audio/RetailSoundMixer.cs` (`panningEnabled`, unwired) | Slice A2 kept its blast radius on the mixing model: window-focus state and a preference surface are host plumbing rather than mixing math, and the mixer parameter exists so wiring them later needs no math change. | Alt-tabbed acdream keeps making noise where retail goes quiet; users cannot disable panning or the individual sound classes. | `SoundManager::InitPrefs @ 0x005503F0`; `SoundManager::PlaySoundInternal @ 0x0054FEC0` and `@ 0x00550170`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §1 | diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index b3d5ec59..36cbb903 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -271,9 +271,10 @@ the transformed DAT `SortCenter`; only adjacent compatible entries may batch. Billboard particle textures are resident bindless `sampler2DArray` handles in the per-instance vertex ABI, so different textures preserve that sorted order inside one instanced draw; only a DAT blend-mode boundary splits the run. This -keeps dense particle fields from becoming one GL draw per alternating texture. -`RetailPViewRenderer` drains the landscape scope before the optional depth -clear, and `GameWindow` drains the final scope before private viewports/UI. +keeps dense particle fields from becoming one Vulkan draw per alternating +texture. `WalkFrameDriver` drains the landscape and world scopes at the exact +alpha barriers emitted by `RetailFrameWalk`; the frame orchestrator reaches +private viewports/UI only after the world stream completes. Sky and sealed off-screen render targets remain independent. No DAT reader, mesh decoder, or second scene graph was introduced. Retail anchors: `CPhysicsPart::UpdateViewerDistance` `0x0050E030`, @@ -281,7 +282,10 @@ mesh decoder, or second scene graph was introduced. Retail anchors: `CShadowPart::insertion_sort` `0x006B5130`, `D3DPolyRender::AddMeshToAlphaList` `0x0059C230`, and `D3DPolyRender::FlushAlphaList` `0x0059D2E0`. The modern per-cell-order and -EnvCell-shell residual is tracked explicitly as AP-34. +EnvCell-shell residual is tracked explicitly as AP-34. WorldBuilder does not +choose visible cells, portal slices, or cross-cell order: it receives the +walk's ordered records and GPU clip slots and performs asset preparation plus +order-preserving Vulkan batching only. **Retail portal-space viewport adapter (2026-07-15).** `src/AcDream.App/Rendering/PortalTunnelPresentation.cs` uses the extracted diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 16306685..3e342da2 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -538,6 +538,18 @@ Stages FW0–FW6; no long-lived dual path. **Spec:** [`docs/superpowers/specs/2026-08-30-campaign-w-retail-frame-walk-design.md`](../superpowers/specs/2026-08-30-campaign-w-retail-frame-walk-design.md). **Plan + ledger:** [`docs/plans/2026-08-30-campaign-fw-frame-walk.md`](2026-08-30-campaign-fw-frame-walk.md). +**2026-08-31 checkpoint — code complete, final connected gate pending.** +`RetailFrameWalk` is now the only production visibility/order owner. Exact +walk portal polygons route shells, statics, and dynamics through the same +Vulkan clip slots (`11e68aad`), fixing the cathedral defect's actual mechanism: +accepted content was previously emitted whole after a Boolean sphere test. +The stale frame-product carrier is deleted, and the retail camera adjustment +laws are complete (`90eba0ec`, `4d037974`). Release hermetic App gates passed +6,729/6,729 before a still-open character-selection client locked the Release +outputs. Campaign FW remains ACTIVE until both standard connected routes and +the complete cathedral/transition/physical-camera visual matrix pass. Safety +rollback before the final batch: `572de1ec`. + ### Phase O — DatPath Unification — SHIPPED 2026-05-21 **Tagline:** ONE thing touches the DATs. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 97cc4612..6e41aa57 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -12,6 +12,17 @@ world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with canonical JSON/PNG evidence. +**Active render prerequisite (2026-08-31): Campaign FW — retail frame walk.** +The production cutover and camera port are code-complete: one retail-derived +walk now owns rooting, portal recursion, per-view clipping, draw order, depth +events, and shared-alpha barriers. The cathedral through-wall mechanism was a +whole-mesh submission after Boolean portal admission; exact walk aperture clip +slots now route shells, statics, and dynamics together. The campaign remains +ACTIVE until the standard connected routes and owner visual matrix prove the +positioned cathedral player/NPC transition, particles, seams, doorways, portal +arrival, and retail camera feel. Plan/ledger: +[`2026-08-30-campaign-fw-frame-walk.md`](2026-08-30-campaign-fw-frame-walk.md). + Before new M4 quest/emote/character-creation subsystem bodies enter the App layer, the active structural prerequisite is the behavior-preserving `GameWindow` decomposition in diff --git a/docs/plans/2026-08-30-campaign-fw-frame-walk.md b/docs/plans/2026-08-30-campaign-fw-frame-walk.md index 9ffa2f5d..d07e2b25 100644 --- a/docs/plans/2026-08-30-campaign-fw-frame-walk.md +++ b/docs/plans/2026-08-30-campaign-fw-frame-walk.md @@ -549,6 +549,48 @@ z-tested) where the old apparatus had no entry. The legacy !walkActive path keeps the old seal draw. Suites: hermetic 6,762/0, Walk lane 213/1, InstalledDat walk conformance 40/1. +**FW4 FINAL CUTOVER — CODE COMPLETE 2026-08-31.** The cathedral remote +player defect was not a wrong-cell admission bug. Exact installed-DAT replay +at the owner's reported poses proved that retail legitimately reaches +0xF4180112 through a small, bounded four-plane portal view; suppressing that +cell or its dynamics would have hidden valid content. The defect was the +modern leaf contract: acdream used the drawing sphere as a Boolean admission +test and then emitted the complete shell/entity mesh through clip slot zero. +Retail instead loops every `portal_view`, runs `viewconeCheck`, and draws the +accepted content through that exact aperture. Commit `11e68aad` carries each +walk visit's authored pixel polygon into a Vulkan clip slot and routes the +same slot to the cell shell, statics, and dynamics. Multiple surviving views +therefore produce multiple clipped draws; particles remain positionally +contained by their owner turn and alpha barriers. Focused walk/installed-DAT +tests pass 22/22 and the hermetic App suite passed 6,727/6,727 in Release. + +The remaining production carrier deletion landed in `69e69408`, `cb22691b`, +and `a2f2eb7d`: the old executor/product frame, `PortalFrame`, `ClipAssembly`, +`WalkLookInViews`, and borrowed-product exchange are gone. The concrete +`RetailPViewRenderer`/`RetailPViewPassExecutor` class names remain as narrow +composition/pass facades around the single `WalkFrameDriver`; they no longer +own a competing visibility product. `PortalVisibilityBuilder` has no +production caller and remains test/research material. The walk's visible set +feeds preparation, particles, lights, and shadows. `PortalDepthMaskRenderer` +is retained deliberately because it now performs the walk's retail far-Z +punches and true-depth exit seals, not the removed visibility decision. + +**FW4 cathedral gate PASSED 2026-08-31.** The first closeout round exposed +one concrete cutover regression: `877e935a` published `VisitedCells` (EnvCell +flood/look-ins) as the complete visibility answer but omitted +`VisitedLandscapeCellIds`. The draw stream still carried the exact cathedral +waterfall owners, yet `ParticleVisibilityController` made every outdoor +emitter fail retail's `CObjCell::IsInView` gate. The correction publishes the +union to particle/light/shadow consumers while keeping EnvCell batch +preparation scoped to EnvCells. The live probe now records every +`0xCF418000..13` waterfall owner reaching the deferred alpha queue across the +0x104 -> outdoor -> 0x101/0x100 transition; Walk tests pass 220/1 and the exact +installed-DAT subset passes 21/0. The owner then passed the live matrix: +waterfalls/outdoor particles restored, the player and special NPC hidden by +opaque cathedral walls, no player disappearance at the cell transition, and +no seam/building distortion. Both standard connected routes remain mandatory +before the campaign status changes from ACTIVE. + - Entities gate per view via `Render::viewconeCheck` at their cell's walk turn; dynamics-last and alpha interleave per the walk's stages (`RetailAlphaQueue` becomes the stream's alpha stage or is absorbed — @@ -588,6 +630,17 @@ InstalledDat walk conformance 40/1. side-by-side feel gate vs retail (chase, zoom saturation ≈13 m boom, collision, slope align). +**CODE COMPLETE 2026-08-31 (`90eba0ec`, `4d037974`).** Retail's +multiplicative distance adjustment, per-component write-refusal gates, +eight-degree raise/lower/rotate step, `FilterMouseInput × sensitivity × 1/15` +mouse scaling, and first-person special cases are ported from the named +decomp. `Closer` in-head correctly stays in-head because its 0.18 m candidate +fails the 0.5 m near gate; `Farther` takes the authored 0.6 m back/0.5 m up +escape; in-head Raise/Lower changes target direction Z by 0.2 and clamps to +±0.8 without moving the eye. Camera tests pass 55/55 and the post-camera +hermetic App suite passed 6,729/6,729 in Release. TS-56 is retired. The +physical-display feel matrix remains part of the final owner gate. + ### FW6 — closeout - Divergence-register reconciliation (added: landblock-stage streaming @@ -611,6 +664,14 @@ Each stage lands as its own commit series; record `git revert` anchors here as stages close (the Modern Runtime plan's convention). FW3 and FW4 are the cutover stages — their revert anchors are mandatory entries. +- **Night-work safety anchor:** `572de1ec30d981abfb5c88f331e1a20c6213b63b` + (`checkpoint: preserve cathedral look-in investigation state`). Reverting + the 2026-08-31 aperture/camera/cleanup batch means returning exactly to this + commit; no history rewrite is required. +- **Portal-slice clipping:** `11e68aad`. +- **Camera completion:** `90eba0ec`, `4d037974`. +- **Final stale frame-product removal:** `a2f2eb7d`. + ## Risks (tracked, from the spec) - **Perf** — decided by FW3's checkpoint, numbers over hope. diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 03ad8e2f..a1a2d5e2 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -506,7 +506,8 @@ internal sealed class FrameRootCompositionPhase live.LandblockPipeline.RenderPublisher?.WalkLandscape ?? throw new InvalidOperationException( "The retail frame walk requires the landscape registry."), - d.CellVisibility), + d.CellVisibility, + d.PhysicsEngine.ShadowObjects), retailPViewPassExecutor, retailPViewPassExecutor), retailPViewCells, diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs index a9534dd9..d6c85f9f 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs @@ -202,11 +202,11 @@ internal sealed partial class RetailPViewPassExecutor /// internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer { - private readonly RetailPViewPassExecutor _passes; - private readonly RetailPViewFrameInput _frame; - private readonly ClipFrameAssembly _clipAssembly; - private readonly Action _clearInteriorDepth; - private readonly Action _drawExitSeals; + private RetailPViewPassExecutor _passes = null!; + private RetailPViewFrameInput _frame = null!; + private ClipFrameAssembly _clipAssembly = null!; + private Action _clearInteriorDepth = null!; + private Action _drawExitSeals = null!; private readonly HashSet _singleCellScratch = new(); private readonly List _singleCellListScratch = new(); private readonly Dictionary _singleCellClipScratch = new(1); @@ -217,10 +217,24 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer ClipFrameAssembly clipAssembly, Action clearInteriorDepth, Action drawExitSeals) + => Reset(passes, frame, clipAssembly, clearInteriorDepth, drawExitSeals); + + /// + /// FW6 allocation closeout: bind the retained leaf and its retained + /// one-cell collections to the current frame. Replay is synchronous, so + /// no frame may outlive these references. + /// + internal void Reset( + RetailPViewPassExecutor passes, + RetailPViewFrameInput frame, + ClipFrameAssembly clipAssembly, + Action clearInteriorDepth, + Action drawExitSeals) { _passes = passes ?? throw new ArgumentNullException(nameof(passes)); _frame = frame ?? throw new ArgumentNullException(nameof(frame)); - _clipAssembly = clipAssembly; + _clipAssembly = clipAssembly + ?? throw new ArgumentNullException(nameof(clipAssembly)); _clearInteriorDepth = clearInteriorDepth ?? throw new ArgumentNullException(nameof(clearInteriorDepth)); _drawExitSeals = drawExitSeals diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 03c9fb87..dcaac8ef 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering.Scene; +using AcDream.Core.Physics; using AcDream.Core.World; namespace AcDream.App.Rendering; @@ -29,6 +30,7 @@ internal sealed class RetailPViewRenderer // frames instead of `new HashSet(pvFrame.OrderedVisibleCells)` every // call. Every walk consumer reads it synchronously in this frame. private readonly HashSet _drawableCellsScratch = new(); + private readonly HashSet _visibleCellsScratch = new(); // FW3 visual-gate fix: the interior root's dynamics phase, invoked by // the driver's clearInteriorDepth closure at the walk's pre-clear @@ -37,6 +39,20 @@ internal sealed class RetailPViewRenderer // cleared in finally. private Action? _walkPreClearDynamics; + // FW6 allocation closeout: the walk's large event/view/route scratch, + // frame context, and one-cell leaf collections are renderer-lifetime + // owners. Only their frame-local bindings change. Before this cutover all + // three were constructed inside DrawInside and accounted for the measured + // ~1.5 MiB/frame dense-town tail. + private Walk.WalkFrameDriver? _walkFrameDriverScratch; + private Walk.WalkProductionFrameContext? _walkFrameContextScratch; + private WalkProductionLeafRenderer? _walkLeafRendererScratch; + private readonly Action _walkClearInteriorDepthAction; + private readonly Action _walkDrawExitSealsAction; + private RetailPViewPassExecutor? _activeWalkPasses; + private RetailPViewFrameInput? _activeWalkFrame; + private ClipFrameAssembly? _activeWalkClipAssembly; + // ACDREAM_PROBE_WALK_ROOT (FW3 visual-gate apparatus, throwaway): the // previous frame's root kind + a post-flip frame countdown so each // interior/outdoor transition dumps 8 frames of rooting facts. @@ -62,7 +78,8 @@ internal sealed class RetailPViewRenderer RenderSceneShadowRuntime renderSceneShadow, Walk.WalkBuildingRegistry walkBuildings, Walk.WalkLandscapeAssembler walkLandscape, - CellVisibility walkCellRegistry) + CellVisibility walkCellRegistry, + ShadowObjectRegistry shadows) { _renderSceneShadow = renderSceneShadow ?? throw new ArgumentNullException(nameof(renderSceneShadow)); @@ -72,7 +89,11 @@ internal sealed class RetailPViewRenderer ?? throw new ArgumentNullException(nameof(walkLandscape)); _walkCellRegistry = walkCellRegistry ?? throw new ArgumentNullException(nameof(walkCellRegistry)); - _walkWorldData = new Walk.WalkProductionWorldData(_walkBuildings); + _walkWorldData = new Walk.WalkProductionWorldData( + _walkBuildings, + shadows ?? throw new ArgumentNullException(nameof(shadows))); + _walkClearInteriorDepthAction = ClearWalkInteriorDepth; + _walkDrawExitSealsAction = DrawWalkExitSeals; } // T2 (BR-4): retail has NO distance constant on the flood-admission chain @@ -129,8 +150,7 @@ internal sealed class RetailPViewRenderer // stream appends classify records immediately (WalkStaticStreamPopulator // runs at append time, not at Replay time), so the world data must // already be rebuilt for this frame before the walk starts. - Walk.WalkFrameDriver? walkDriver = null; - WalkProductionLeafRenderer walkLeafRenderer; + Walk.WalkFrameDriver walkDriver; { Matrix4x4 view = ctx.CameraView; var forward = Vector3.Normalize(new Vector3(-view.M13, -view.M23, -view.M33)); @@ -140,14 +160,27 @@ internal sealed class RetailPViewRenderer // reached before the world pass has published its scope. float viewportWidth = attachment?.Width ?? 1024f; float viewportHeight = attachment?.Height ?? 720f; - var walkContext = new Walk.WalkProductionFrameContext( - _walkCellRegistry!, - _walkBuildings!, - ctx.ViewerEyePos, - forward, - ctx.ViewProjection, - viewportWidth, - viewportHeight); + if (_walkFrameContextScratch is null) + { + _walkFrameContextScratch = new Walk.WalkProductionFrameContext( + _walkCellRegistry, + _walkBuildings, + ctx.ViewerEyePos, + forward, + ctx.ViewProjection, + viewportWidth, + viewportHeight); + } + else + { + _walkFrameContextScratch.Reset( + ctx.ViewerEyePos, + forward, + ctx.ViewProjection, + viewportWidth, + viewportHeight); + } + Walk.WalkProductionFrameContext walkContext = _walkFrameContextScratch; _walkLandscape!.SetViewer(ctx.ViewerCellId, ctx.ViewerEyePos); Walk.WalkLandscape walkLandscape = _walkLandscape.Landscape; @@ -185,47 +218,60 @@ internal sealed class RetailPViewRenderer ctx.RenderCenterLbX, ctx.RenderCenterLbY); - Action clearInteriorDepth = () => + _activeWalkPasses = walkExecutor; + _activeWalkFrame = ctx; + _activeWalkClipAssembly = clipAssembly; + if (_walkLeafRendererScratch is null) { - // FW3 visual-gate fix (owner report: doors/candles invisible - // looking out; the crossing vanish): retail draws the - // OUTSIDE world's objects INSIDE LScape::draw — strictly - // BEFORE the depth clear + seals (the #118 house-exit - // clip+vanish lesson: anything drawn after the seals z-fails - // against their true-depth stamp the moment it stands beyond - // the door plane). The surviving dynamic routes + outdoor - // particles + weather therefore run HERE, at the walk's - // pre-clear boundary, for an interior root. - _walkPreClearDynamics?.Invoke(); - // Retail PView::DrawCells 0x005A4872 drains the landscape - // alpha list immediately before the gated full depth clear — - // mirrors DrawLandscapeThroughOutsideView's own pre-clear - // drain. - passes.FlushLandscapeAlpha(); - passes.ClearInteriorDepth(); - }; - // FW4 slice 2: the seals stamp the WALK'S OWN flood cells (see - // DrawWalkExitPortalMasks). walkDriver is assigned below, before - // any Replay can fire this closure. - Action drawExitSeals = () => - DrawWalkExitPortalMasks(ctx, passes, clipAssembly, walkDriver!); + _walkLeafRendererScratch = new WalkProductionLeafRenderer( + walkExecutor, + ctx, + clipAssembly, + _walkClearInteriorDepthAction, + _walkDrawExitSealsAction); + } + else + { + _walkLeafRendererScratch.Reset( + walkExecutor, + ctx, + clipAssembly, + _walkClearInteriorDepthAction, + _walkDrawExitSealsAction); + } - walkLeafRenderer = new WalkProductionLeafRenderer( - walkExecutor!, ctx, clipAssembly, clearInteriorDepth, drawExitSeals); - walkDriver = new Walk.WalkFrameDriver( - walkExecutor!.Dispatcher, - walkLeafRenderer, - _walkWorldData, - clipFrame: clipAssembly.Frame); + if (_walkFrameDriverScratch is null) + { + _walkFrameDriverScratch = new Walk.WalkFrameDriver( + walkExecutor.Dispatcher, + _walkLeafRendererScratch, + _walkWorldData, + clipFrame: clipAssembly.Frame); + } + else + { + _walkFrameDriverScratch.RebindFrame( + _walkLeafRendererScratch, + clipAssembly.Frame); + } + walkDriver = _walkFrameDriverScratch; if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) { AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame = _probeWalkRootFrame % 90 == 0; } - walkDriver.Collect( - _frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext, - ctx.ViewProjection, ctx.CameraWorldPosition); + try + { + walkDriver.Collect( + _frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext, + ctx.ViewProjection, ctx.CameraWorldPosition); + } + catch + { + ClearWalkFrameBindings(); + throw; + } AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame = false; // FW4 slice 1: an interior root's terrain/sky/punch clip slices @@ -248,6 +294,7 @@ internal sealed class RetailPViewRenderer // OrderedVisibleCells side-channel for every production consumer. _drawableCellsScratch.Clear(); _drawableCellsScratch.UnionWith(walkDriver.VisitedCells); + walkDriver.CopyVisibleCellsTo(_visibleCellsScratch); // Phase I cathedral instrumentation (synthesis §Phase I.3): the // continuous rooting line SEPARATES the true root flood @@ -326,7 +373,7 @@ internal sealed class RetailPViewRenderer RetailPViewFrameResult result = _frameResultScratch.Reset( clipAssembly, drawableCells, - prepareCells, + _visibleCellsScratch, counts, sourceCounts, diagnosticPartition: null); @@ -360,6 +407,7 @@ internal sealed class RetailPViewRenderer finally { _walkPreClearDynamics = null; + ClearWalkFrameBindings(); } // OUTDOOR root: the LScape-boundary alpha drain deferred from the @@ -386,6 +434,46 @@ internal sealed class RetailPViewRenderer private readonly Walk.RetailFrameWalk _frameWalk = new(); + private void ClearWalkInteriorDepth() + { + RetailPViewPassExecutor passes = _activeWalkPasses + ?? throw new InvalidOperationException( + "The retained walk leaf has no active pass binding."); + + // FW3 visual-gate fix (owner report: doors/candles invisible looking + // out; the crossing vanish): retail draws outside objects inside + // LScape::draw, before the clear+seals. + _walkPreClearDynamics?.Invoke(); + passes.FlushLandscapeAlpha(); + passes.ClearInteriorDepth(); + } + + private void DrawWalkExitSeals() + { + RetailPViewFrameInput frame = _activeWalkFrame + ?? throw new InvalidOperationException( + "The retained walk leaf has no active frame binding."); + RetailPViewPassExecutor passes = _activeWalkPasses + ?? throw new InvalidOperationException( + "The retained walk leaf has no active pass binding."); + ClipFrameAssembly clipAssembly = _activeWalkClipAssembly + ?? throw new InvalidOperationException( + "The retained walk leaf has no active clip binding."); + Walk.WalkFrameDriver driver = _walkFrameDriverScratch + ?? throw new InvalidOperationException( + "The retained walk leaf has no active driver binding."); + + DrawWalkExitPortalMasks(frame, passes, clipAssembly, driver); + } + + private void ClearWalkFrameBindings() + { + _walkFrameDriverScratch?.AbortFrame(); + _activeWalkPasses = null; + _activeWalkFrame = null; + _activeWalkClipAssembly = null; + } + /// Campaign FW3.2b-2 — THE PRODUCTION ROOTING; Campaign FW3.4a — /// REPLAY ONLY. already ran its Collect pass /// earlier in (before PrepareCellBatches); diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index c87f54fe..12538873 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -465,10 +465,10 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource { private readonly WbDrawDispatcher _dispatcher; private readonly WalkStaticStreamPopulator _populator; - private readonly IWalkFrameLeafRenderer _leafRenderer; + private IWalkFrameLeafRenderer _leafRenderer; private readonly IWalkFrameWorldData _worldData; private readonly IWalkFrameDriverTrace? _trace; - private readonly ClipFrame? _clipFrame; + private ClipFrame? _clipFrame; private readonly OrderedDrawStream _stream = new(); private readonly List _events = new(); private readonly List _markPositions = new(); @@ -493,6 +493,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource private readonly List _floodViewRouteScratch = new(); private WalkPlane _lookInCyPlane; + // Retail adds each physics object to every overlapped cell's shadow-part + // list, then CPhysicsPart::Get/SetDrawnThisFrame (0x0059F388) prevents + // those aliases from drawing more than once. The walk's outdoor buckets + // now carry the same multi-cell aliases, so retain the same frame guard. + private readonly HashSet _outdoorDrawnThisFrame = new(); + private readonly HashSet _outdoorParticleOwnersDrawnThisFrame = new(); + IReadOnlyList IWalkLookInViewSource.LookInCellTurns => LookInCellTurns; /// The set form of , for drawn-once @@ -524,6 +531,19 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource } } + private static void UnionNewOwners( + in WalkFrameStaticRecords records, + HashSet destination, + HashSet drawnOnce) + { + foreach (RenderProjectionRecord record in records.Records) + { + uint ownerId = record.Source.LocalEntityId; + if (ownerId != 0 && drawnOnce.Add(ownerId)) + destination.Add(ownerId); + } + } + internal List VisitedBuildings { get; } = new(); internal HashSet VisitedLandscapeCellIds { get; } = new(); @@ -554,6 +574,77 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _populator = new WalkStaticStreamPopulator(dispatcher); } + /// + /// FW6 allocation closeout: reuse this driver's large event/view/stream + /// scratch across production frames while rebinding the frame-local leaf + /// and clip destination. A prior failed frame is discarded here so the + /// renderer's report-and-continue policy cannot poison the next frame. + /// + internal void RebindFrame( + IWalkFrameLeafRenderer leafRenderer, + ClipFrame? clipFrame) + { + AbortFrame(); + _leafRenderer = leafRenderer + ?? throw new ArgumentNullException(nameof(leafRenderer)); + _clipFrame = clipFrame; + } + + /// Discard only transient frame state; retained capacities stay + /// available for the next frame. + internal void AbortFrame() + { + _ctx = null; + _viewProjection = default; + _cameraWorldPosition = default; + _skyDrawnThisFrame = false; + _currentDcStage = null; + _readyToReplay = false; + _stream.Reset(); + _events.Clear(); + _markPositions.Clear(); + VisitedCells.Clear(); + LookInCellTurns.Clear(); + _lookInTurns.Clear(); + _lookInSlices.Clear(); + _lookInPlanes.Clear(); + _visibleClipSlotScratch.Clear(); + _floodViewRouteScratch.Clear(); + _outdoorDrawnThisFrame.Clear(); + _outdoorParticleOwnersDrawnThisFrame.Clear(); + _lookInCyPlane = default; + LookInCells.Clear(); + VisitedBuildings.Clear(); + VisitedLandscapeCellIds.Clear(); + InteriorFloodCells.Clear(); + _staticParticleOwnerScratch.Clear(); + _cellViewRouteIndex = 0; + _landscapeViewRouteIndex = -1; + } + + /// + /// Copies the walk's complete retail CObjCell::IsInView answer: + /// EnvCells reached by interior floods/look-ins plus outdoor land cells + /// visited by the landscape walk. The two families stay separately + /// retained because only the first is valid EnvCell batch input, but + /// particles, lights, and shadows consume their union. + /// + internal void CopyVisibleCellsTo(HashSet destination) + { + ArgumentNullException.ThrowIfNull(destination); + if (ReferenceEquals(destination, VisitedCells) + || ReferenceEquals(destination, VisitedLandscapeCellIds)) + { + throw new ArgumentException( + "The visible-cell destination cannot alias a walk source set.", + nameof(destination)); + } + + destination.Clear(); + destination.UnionWith(VisitedCells); + destination.UnionWith(VisitedLandscapeCellIds); + } + /// /// Drives one complete frame at retail's root (SmartBox::RenderNormalMode): /// immediately followed by . Kept @@ -601,8 +692,16 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource ArgumentNullException.ThrowIfNull(ctx); BeginFrame(ctx, viewProjection, cameraWorldPosition); - walk.WalkFrame(cameraCellId, cameraCell, landscape, ctx, this); - EndFrame(); + try + { + walk.WalkFrame(cameraCellId, cameraCell, landscape, ctx, this); + EndFrame(); + } + catch + { + AbortFrame(); + throw; + } } /// @@ -644,6 +743,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _lookInPlanes.Clear(); _visibleClipSlotScratch.Clear(); _lookInCyPlane = ctx.CyPlane; + _outdoorDrawnThisFrame.Clear(); + _outdoorParticleOwnersDrawnThisFrame.Clear(); LookInCells.Clear(); VisitedBuildings.Clear(); VisitedLandscapeCellIds.Clear(); @@ -697,82 +798,94 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource + "replay."); } - if (_stream.Count > 0) - _dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions); - - int cursor = 0; - for (int i = 0; i < _events.Count; i++) + try { - WalkFrameEvent e = _events[i]; - switch (e.Kind) + if (_stream.Count > 0) + _dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions); + + int cursor = 0; + for (int i = 0; i < _events.Count; i++) { - case WalkFrameEventKind.StreamMark: - int end = e.IntArg; - int count = end - cursor; - if (_trace is not null) - _trace.OnFlush(count, _stream.Stages.GetRange(cursor, count)); - _dispatcher.DrawOrderedRange(encoder, cursor, count); - cursor = end; - break; - case WalkFrameEventKind.Sky: - _leafRenderer.DrawSky(); - break; - case WalkFrameEventKind.TerrainSlice: - _leafRenderer.DrawTerrainSlice(e.IntArg); - break; - case WalkFrameEventKind.CellShell: - _leafRenderer.DrawCellShell(e.CellId, checked((uint)e.IntArg)); - break; - case WalkFrameEventKind.PunchFan: - _leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg); - break; - case WalkFrameEventKind.AlphaBarrier: - _leafRenderer.AlphaBarrier(); - break; - case WalkFrameEventKind.ClearInteriorDepth: - _leafRenderer.ClearInteriorDepth(); - break; - case WalkFrameEventKind.ExitSeals: - _leafRenderer.DrawExitSeals(); - break; - case WalkFrameEventKind.StaticParticles: - _staticParticleOwnerScratch.Clear(); - UnionOwners( - e.Building is WalkBuilding shellOwner - ? _worldData.GetBuildingShellStatics(shellOwner) - : _worldData.GetOutdoorStatics(e.CellId), - _staticParticleOwnerScratch); - if (e.Building is null) - { + WalkFrameEvent e = _events[i]; + switch (e.Kind) + { + case WalkFrameEventKind.StreamMark: + int end = e.IntArg; + int count = end - cursor; + if (_trace is not null) + _trace.OnFlush(count, _stream.Stages.GetRange(cursor, count)); + _dispatcher.DrawOrderedRange(encoder, cursor, count); + cursor = end; + break; + case WalkFrameEventKind.Sky: + _leafRenderer.DrawSky(); + break; + case WalkFrameEventKind.TerrainSlice: + _leafRenderer.DrawTerrainSlice(e.IntArg); + break; + case WalkFrameEventKind.CellShell: + _leafRenderer.DrawCellShell(e.CellId, checked((uint)e.IntArg)); + break; + case WalkFrameEventKind.PunchFan: + _leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg); + break; + case WalkFrameEventKind.AlphaBarrier: + _leafRenderer.AlphaBarrier(); + break; + case WalkFrameEventKind.ClearInteriorDepth: + _leafRenderer.ClearInteriorDepth(); + break; + case WalkFrameEventKind.ExitSeals: + _leafRenderer.DrawExitSeals(); + break; + case WalkFrameEventKind.StaticParticles: + _staticParticleOwnerScratch.Clear(); + if (e.Building is WalkBuilding shellOwner) + { + UnionOwners( + _worldData.GetBuildingShellStatics(shellOwner), + _staticParticleOwnerScratch); + } + else + { + UnionNewOwners( + _worldData.GetOutdoorStatics(e.CellId), + _staticParticleOwnerScratch, + _outdoorParticleOwnersDrawnThisFrame); + UnionNewOwners( + _worldData.GetOutdoorDynamics(e.CellId), + _staticParticleOwnerScratch, + _outdoorParticleOwnersDrawnThisFrame); + } + if (_staticParticleOwnerScratch.Count > 0) + _leafRenderer.DrawStaticParticles(_staticParticleOwnerScratch); + break; + case WalkFrameEventKind.CellParticles: + _staticParticleOwnerScratch.Clear(); UnionOwners( - _worldData.GetOutdoorDynamics(e.CellId), + _worldData.GetCellStatics(e.CellId), _staticParticleOwnerScratch); - } - if (_staticParticleOwnerScratch.Count > 0) - _leafRenderer.DrawStaticParticles(_staticParticleOwnerScratch); - break; - case WalkFrameEventKind.CellParticles: - _staticParticleOwnerScratch.Clear(); - UnionOwners( - _worldData.GetCellStatics(e.CellId), - _staticParticleOwnerScratch); - UnionOwners( - _worldData.GetCellDynamics(e.CellId), - _staticParticleOwnerScratch); - if (_staticParticleOwnerScratch.Count > 0) - { - _leafRenderer.DrawCellParticles( - e.CellId, + UnionOwners( + _worldData.GetCellDynamics(e.CellId), _staticParticleOwnerScratch); - } - break; + if (_staticParticleOwnerScratch.Count > 0) + { + _leafRenderer.DrawCellParticles( + e.CellId, + _staticParticleOwnerScratch); + } + break; + } } } - - _stream.Reset(); - _events.Clear(); - _markPositions.Clear(); - _readyToReplay = false; + finally + { + _stream.Reset(); + _events.Clear(); + _markPositions.Clear(); + _readyToReplay = false; + _ctx = null; + } } // ------------------------------------------------------------------ @@ -817,11 +930,11 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _populator.PopulateOutdoorStatics( _stream, cellId, records.Records, records.TupleLandblockId, _cameraWorldPosition, _viewProjection, - this, _landscapeViewRouteIndex); + this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame); _populator.PopulateCellDynamics( _stream, cellId, dynamics.Records, dynamics.TupleLandblockId, _cameraWorldPosition, _viewProjection, - this, _landscapeViewRouteIndex); + this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame); // FW4 (the #132 positional invariant): this cell's emitter owners // submit AT THIS TURN, so nearer buildings' pre-punch barriers // drain them against still-true depth — see diff --git a/src/AcDream.App/Rendering/Walk/WalkLandscape.cs b/src/AcDream.App/Rendering/Walk/WalkLandscape.cs index bad0855c..695a91a2 100644 --- a/src/AcDream.App/Rendering/Walk/WalkLandscape.cs +++ b/src/AcDream.App/Rendering/Walk/WalkLandscape.cs @@ -60,6 +60,14 @@ public sealed class WalkLandscape public int ViewerCellX; // viewer cell & 7 per axis (SqCoord) public int ViewerCellY; + /// The viewer landblock's southwest corner in the current + /// render-center coordinate system. The landscape grid is indexed around + /// the viewer's DAT landblock, while camera/portal planes are expressed + /// relative to the independently moving render center. Those bases differ + /// by whole 192 m blocks whenever the render center trails the camera. + public float ViewerWorldOriginX; + public float ViewerWorldOriginY; + public int[] BlockDrawList = []; public int BlockDrawCount; @@ -154,8 +162,8 @@ public sealed class WalkLandscape // Seed the west column (grid x = 0) into parity row 0. for (int j = 0; j <= MidWidth; j++) WalkVisibilityMath.FillClipHeights( - (0 - ViewerBlockX) * BlockLength, - (j - ViewerBlockY) * BlockLength, + ViewerWorldOriginX + (0 - ViewerBlockX) * BlockLength, + ViewerWorldOriginY + (j - ViewerBlockY) * BlockLength, cyPlane, edgePlanes, intervals[j]); for (int bx = 0; bx < MidWidth; bx++) { @@ -163,8 +171,8 @@ public sealed class WalkLandscape int eastRow = ((bx - 1) & 1) * cornerRow; for (int j = 0; j <= MidWidth; j++) WalkVisibilityMath.FillClipHeights( - (bx + 1 - ViewerBlockX) * BlockLength, - (j - ViewerBlockY) * BlockLength, + ViewerWorldOriginX + (bx + 1 - ViewerBlockX) * BlockLength, + ViewerWorldOriginY + (j - ViewerBlockY) * BlockLength, cyPlane, edgePlanes, intervals[eastRow + j]); for (int by = 0; by < MidWidth; by++) { @@ -205,8 +213,8 @@ public sealed class WalkLandscape block.CellInView[i] = WalkBoundingType.EntirelyInside; return; } - float x0 = (bx - ViewerBlockX) * BlockLength; - float y0 = (by - ViewerBlockY) * BlockLength; + float x0 = ViewerWorldOriginX + (bx - ViewerBlockX) * BlockLength; + float y0 = ViewerWorldOriginY + (by - ViewerBlockY) * BlockLength; int cornerRow = n + 1; var grid = new float[2 * cornerRow][]; for (int i = 0; i < grid.Length; i++) grid[i] = new float[32]; diff --git a/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs b/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs index e1871383..45642eaa 100644 --- a/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs +++ b/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs @@ -111,6 +111,10 @@ public sealed class WalkLandscapeAssembler int cellIndex = (int)low - 1; Landscape.ViewerCellX = cellIndex / 8; Landscape.ViewerCellY = cellIndex % 8; + Landscape.ViewerWorldOriginX = DeriveViewerBlockOrigin( + cameraOrigin.X, Landscape.ViewerCellX); + Landscape.ViewerWorldOriginY = DeriveViewerBlockOrigin( + cameraOrigin.Y, Landscape.ViewerCellY); } else { @@ -118,11 +122,30 @@ public sealed class WalkLandscapeAssembler // the camera origin, matching the test builder's fallback // (Position::get_outside_cell_id via SmartBox::RenderNormalMode's // seen_outside arm). - Landscape.ViewerCellX = Math.Clamp((int)MathF.Floor(cameraOrigin.X / 24f), 0, 7); - Landscape.ViewerCellY = Math.Clamp((int)MathF.Floor(cameraOrigin.Y / 24f), 0, 7); + Landscape.ViewerWorldOriginX = MathF.Floor( + cameraOrigin.X / WalkLandscape.BlockLength) * WalkLandscape.BlockLength; + Landscape.ViewerWorldOriginY = MathF.Floor( + cameraOrigin.Y / WalkLandscape.BlockLength) * WalkLandscape.BlockLength; + Landscape.ViewerCellX = Math.Clamp((int)MathF.Floor( + (cameraOrigin.X - Landscape.ViewerWorldOriginX) / WalkLandscape.CellLength), 0, 7); + Landscape.ViewerCellY = Math.Clamp((int)MathF.Floor( + (cameraOrigin.Y - Landscape.ViewerWorldOriginY) / WalkLandscape.CellLength), 0, 7); } } + /// Recover the whole-block render-center offset from the exact + /// outdoor cell identity. Using the cell center makes the calculation + /// stable at cell edges: every valid position in the authored 24 m cell + /// lies within 12 m of the expected center, far from the 96 m rounding + /// boundary between possible 192 m block origins. + private static float DeriveViewerBlockOrigin(float cameraAxis, int cellAxis) + { + float cellCenter = (cellAxis + 0.5f) * WalkLandscape.CellLength; + return MathF.Round( + (cameraAxis - cellCenter) / WalkLandscape.BlockLength, + MidpointRounding.AwayFromZero) * WalkLandscape.BlockLength; + } + /// ring <= 1 ? 8 : ring == 2 ? 4 : ring <= 4 ? 2 : 1 /// — the live-observed resolution pyramid (recon 2026-08-30): 8×8 in the /// 3×3 core, 4×4 at ring 2, 2×2 at rings 3–4, 1×1 beyond. Buildings only diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs b/src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs index a71ea982..3e5701f8 100644 --- a/src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs +++ b/src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs @@ -25,8 +25,9 @@ namespace AcDream.App.Rendering.Walk; /// true eye ray (near/far unprojection) are observably equivalent to /// retail's exact construction for this contract. /// -/// One instance is a per-frame value (like WalkTraceReplayContext): -/// construct fresh each frame with that frame's camera pose. +/// Production retains one instance per renderer and rebinds its frame-local +/// camera values through . The registries and grow-only +/// active-view scratch remain renderer-lifetime owners. /// public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrameWalkContext { @@ -35,19 +36,28 @@ public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrame private sealed class InverseViewProjectionRayCaster : IWalkRayCaster { - private readonly Matrix4x4 _inverseViewProjection; - private readonly float _viewportWidth; - private readonly float _viewportHeight; + private Matrix4x4 _inverseViewProjection; + private float _viewportWidth; + private float _viewportHeight; public InverseViewProjectionRayCaster( Matrix4x4 viewProjection, float viewportWidth, float viewportHeight) + => Reset(viewProjection, viewportWidth, viewportHeight); + + internal void Reset( + Matrix4x4 viewProjection, float viewportWidth, float viewportHeight) { - if (!Matrix4x4.Invert(viewProjection, out _inverseViewProjection)) + if (!Matrix4x4.Invert(viewProjection, out Matrix4x4 inverseViewProjection)) { throw new ArgumentException( "The walk's view-projection matrix must be invertible.", nameof(viewProjection)); } + // Publish only after validation succeeds. Matrix4x4.Invert writes + // its out value even on failure; assigning the field directly + // would silently corrupt the retained ray caster for the next + // report-and-continue frame. + _inverseViewProjection = inverseViewProjection; _viewportWidth = viewportWidth; _viewportHeight = viewportHeight; } @@ -72,8 +82,8 @@ public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrame private readonly CellVisibility _cells; private readonly WalkBuildingRegistry _buildings; - private readonly Matrix4x4 _viewProjection; - private readonly IWalkRayCaster _rays; + private Matrix4x4 _viewProjection; + private readonly InverseViewProjectionRayCaster _rays; private Vector2[] _activeViewVerts = new Vector2[32]; private int _activeViewVertCount; @@ -89,19 +99,39 @@ public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrame { _cells = cells ?? throw new ArgumentNullException(nameof(cells)); _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); + _rays = new InverseViewProjectionRayCaster( + viewProjection, viewportWidth, viewportHeight); + Reset(worldViewpoint, forward, viewProjection, viewportWidth, viewportHeight); + } + + /// + /// FW6 allocation closeout: rebind this retained context to one frame's + /// camera values while preserving its active-view scratch. The cell and + /// building registries are lifetime owners and therefore never change. + /// + internal void Reset( + Vector3 worldViewpoint, + Vector3 forward, + Matrix4x4 viewProjection, + float viewportWidth, + float viewportHeight) + { + // Validate/invert before publishing any new frame value so a bad + // camera matrix leaves the previous usable binding intact. + _rays.Reset(viewProjection, viewportWidth, viewportHeight); WorldViewpoint = worldViewpoint; _viewProjection = viewProjection; ViewportWidth = viewportWidth; ViewportHeight = viewportHeight; - _rays = new InverseViewProjectionRayCaster(viewProjection, viewportWidth, viewportHeight); + _activeViewVertCount = 0; // The retail CY near plane: N = forward, d = -dot(eye, forward) - znear. CyPlane = new WalkPlane(forward, -Vector3.Dot(worldViewpoint, forward) - ZNear); } - public Vector3 WorldViewpoint { get; } - public float ViewportWidth { get; } - public float ViewportHeight { get; } - public WalkPlane CyPlane { get; } + public Vector3 WorldViewpoint { get; private set; } + public float ViewportWidth { get; private set; } + public float ViewportHeight { get; private set; } + public WalkPlane CyPlane { get; private set; } public IWalkRayCaster Rays => _rays; public IWalkFrameContext CellContext => this; diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs index 46e63070..a8aed48e 100644 --- a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs +++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs @@ -1,6 +1,7 @@ using System.Numerics; using System.Runtime.InteropServices; using AcDream.App.Rendering.Scene; +using AcDream.Core.Physics; namespace AcDream.App.Rendering.Walk; @@ -54,6 +55,7 @@ namespace AcDream.App.Rendering.Walk; internal sealed class WalkProductionWorldData : IWalkFrameWorldData { private readonly WalkBuildingRegistry _buildings; + private readonly ShadowObjectRegistry _shadows; private RenderSceneQuery _scene; private uint _tupleLandblockId; private int _renderCenterLbX; @@ -77,9 +79,12 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData private RenderProjectionRecord[] _arena = new RenderProjectionRecord[4096]; private int _arenaLength; - internal WalkProductionWorldData(WalkBuildingRegistry buildings) + internal WalkProductionWorldData( + WalkBuildingRegistry buildings, + ShadowObjectRegistry shadows) { _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); + _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); } /// Rebuilds the frame's outdoor/shell buckets and clears the @@ -136,11 +141,12 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData shells.Add(record); continue; } - uint cellId = LandscapeCellId( - record.Transform.Position, _renderCenterLbX, _renderCenterLbY); - if (!_outdoorByCell.TryGetValue(cellId, out List? bucket)) - _outdoorByCell[cellId] = bucket = new List(); - bucket.Add(record); + BucketOutdoorRecord( + in record, + _shadows.GetOwnerCells(record.Source.LocalEntityId), + _outdoorByCell, + _renderCenterLbX, + _renderCenterLbY); } required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorDynamic); @@ -155,18 +161,70 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData for (int i = 0; i < count; i++) { ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i]; - uint cellId = LandscapeCellId( - record.Transform.Position, _renderCenterLbX, _renderCenterLbY); - if (!_outdoorDynamicsByCell.TryGetValue( - cellId, - out List? bucket)) - { - _outdoorDynamicsByCell[cellId] = bucket = new List(); - } - bucket.Add(record); + BucketOutdoorRecord( + in record, + _shadows.GetOwnerCells(record.Source.LocalEntityId), + _outdoorDynamicsByCell, + _renderCenterLbX, + _renderCenterLbY); } } + /// + /// Installs one outdoor object's render shadow in every outdoor cell of + /// its authoritative physics CELLARRAY. This is retail's + /// CPhysicsObj::add_shadows_to_cells → + /// CPartArray::AddPartsShadow path: a large object straddling a + /// landblock edge must remain reachable when its origin cell leaves the + /// landscape walk. Objects without a collision registration (notably + /// short-lived visual effects) retain the root-position fallback. + /// + internal static void BucketOutdoorRecord( + in RenderProjectionRecord record, + IReadOnlyList shadowCells, + Dictionary> buckets, + int renderCenterLbX, + int renderCenterLbY) + { + ArgumentNullException.ThrowIfNull(shadowCells); + ArgumentNullException.ThrowIfNull(buckets); + + bool added = false; + for (int i = 0; i < shadowCells.Count; i++) + { + uint cellId = shadowCells[i]; + uint cellIndex = cellId & 0xFFFFu; + if (cellIndex is < 1u or > 64u) + continue; + + AddToBucket(in record, cellId, buckets); + added = true; + } + + if (!added) + { + uint cellId = LandscapeCellId( + record.Transform.Position, + renderCenterLbX, + renderCenterLbY); + AddToBucket(in record, cellId, buckets); + } + } + + private static void AddToBucket( + in RenderProjectionRecord record, + uint cellId, + Dictionary> buckets) + { + if (!buckets.TryGetValue( + cellId, + out List? bucket)) + { + buckets[cellId] = bucket = new List(); + } + bucket.Add(record); + } + /// The landscape cell owning a RENDER-ORIGIN-RELATIVE position /// — retail's 24 m cell grid inside the 192 m landblock, producing the /// same TRUE (lb & 0xFFFF0000) | (cellX*8 + cellY + 1) encoding diff --git a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs index b44f0a1d..ef9a51ef 100644 --- a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs +++ b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs @@ -97,11 +97,14 @@ internal sealed class WalkStaticStreamPopulator Vector3 cameraWorldPosition, Matrix4x4 viewProjection, IWalkLookInViewSource? views = null, - int viewRouteIndex = -1) + int viewRouteIndex = -1, + ISet? drawnOnce = null) { ArgumentNullException.ThrowIfNull(stream); for (int i = 0; i < records.Length; i++) { + if (drawnOnce is not null && !drawnOnce.Add(records[i].Id)) + continue; ClassifyAndAppend( stream, WalkDrawStage.OutdoorStatic, cellId, in records[i], tupleLandblockId, cameraWorldPosition, viewProjection, @@ -117,11 +120,14 @@ internal sealed class WalkStaticStreamPopulator Vector3 cameraWorldPosition, Matrix4x4 viewProjection, IWalkLookInViewSource? lookInViews = null, - int lookInRouteIndex = -1) + int lookInRouteIndex = -1, + ISet? drawnOnce = null) { ArgumentNullException.ThrowIfNull(stream); for (int i = 0; i < records.Length; i++) { + if (drawnOnce is not null && !drawnOnce.Add(records[i].Id)) + continue; ClassifyAndAppend( stream, WalkDrawStage.Dynamic, diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs index 10bfa8a5..038dc3e3 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs @@ -193,6 +193,20 @@ public sealed partial class WbDrawDispatcher ArgumentNullException.ThrowIfNull(batches); ArgumentNullException.ThrowIfNull(selectionParts); + // Retail CPhysicsObj::set_nodraw -> CPartArray::SetNoDrawInternal + // removes the PartArray from drawing immediately; logical object, + // cell, script, particle, and eventual DeleteObject lifetimes remain + // independent. The retained render scene expresses that exact edge + // by clearing RenderProjectionFlags.Draw. The pre-FW dispatcher read + // WorldEntity.IsDrawVisible before classifying, but the production + // frame-walk consumes immutable RenderProjectionRecords and therefore + // must honor the equivalent record flag here. Without this gate an + // impacted spell projectile remained visible until ACE's delayed + // DeleteObject five seconds later even though its SetState had already + // set NoDraw. + if ((projection.Flags & RenderProjectionFlags.Draw) == 0) + return; + RenderInstanceCandidate entity = RenderInstanceCandidate.FromProjection( in projection, diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 1669146d..21ceebb3 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -2146,6 +2146,22 @@ public sealed class ShadowObjectRegistry return System.Array.Empty(); } + /// + /// The exact ordered CELLARRAY membership retained for one physics + /// object. Retail consumes this same membership twice: collision walks + /// each cell's shadow_object_list, while rendering installs the + /// object's parts in every member cell through + /// CPhysicsObj::add_shadows_to_cells / CPartArray::AddPartsShadow + /// (0x00514AE0). The returned view is borrowed and remains valid only + /// until the next shadow-registry mutation. + /// + public IReadOnlyList GetOwnerCells(uint entityId) + { + if (_entityToCells.TryGetValue(entityId, out List? cells)) + return cells; + return System.Array.Empty(); + } + public int TotalRegistered => _entityToCells.Count; /// diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index eb7102b3..608a097c 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -606,6 +606,57 @@ public sealed class WalkFrameDriverTests Assert.Equal(new[] { "SKY", "TERRAIN:0" }, log); } + [Fact] + public void RebindFrame_ReusesTheDriverAndRoutesTheNextFrameToTheNewLeaf() + { + using var fx = new DispatcherFixture(); + var firstLog = new List(); + var secondLog = new List(); + var ctx = new TestContext(); + var driver = new WalkFrameDriver( + fx.Dispatcher, new RecordingLeafRenderer(firstLog), new FakeWorldData()); + var walk = new RetailFrameWalk(); + var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] }; + + using DrawScope draw = fx.BeginDraw(); + driver.Collect( + walk, 0u, null, landscape, ctx, + Matrix4x4.Identity, Vector3.Zero); + driver.Replay(draw.Frame, draw.Pass); + + driver.RebindFrame(new RecordingLeafRenderer(secondLog), clipFrame: null); + driver.Collect( + walk, 0u, null, landscape, ctx, + Matrix4x4.Identity, Vector3.Zero); + driver.Replay(draw.Frame, draw.Pass); + + Assert.Equal(new[] { "SKY", "TERRAIN:0" }, firstLog); + Assert.Equal(new[] { "SKY", "TERRAIN:0" }, secondLog); + } + + [Fact] + public void RebindFrame_DiscardsAnIncompletePriorFrameAndRecovers() + { + using var fx = new DispatcherFixture(); + var log = new List(); + var ctx = new TestContext(); + var driver = new WalkFrameDriver( + fx.Dispatcher, new RecordingLeafRenderer(new List()), new FakeWorldData()); + var walk = new RetailFrameWalk(); + var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] }; + + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + driver.RebindFrame(new RecordingLeafRenderer(log), clipFrame: null); + + using DrawScope draw = fx.BeginDraw(); + driver.Collect( + walk, 0u, null, landscape, ctx, + Matrix4x4.Identity, Vector3.Zero); + driver.Replay(draw.Frame, draw.Pass); + + Assert.Equal(new[] { "SKY", "TERRAIN:0" }, log); + } + // ── Deliverable: an outdoor landscape-cell turn with no building appends // straight to the stream (no shell call — outdoor cells have no EnvCell // shell), and the accumulated content flushes at frame end. ─────────── @@ -650,12 +701,67 @@ public sealed class WalkFrameDriverTests Assert.Equal( new[] { "FLUSH:2:OutdoorStatic,Dynamic", "PARTICLES:12d,12e" }, log); + var visibleCells = new HashSet { 0xDEAD_BEEFu }; + driver.CopyVisibleCellsTo(visibleCells); + Assert.Equal(new[] { 0x8C040005u }, visibleCells); GpuRecordedMultiDrawIndirect[] mdi = fx.Device.Calls.OfType().ToArray(); Assert.Equal(2, mdi.Length); Assert.Equal(2u, mdi.Aggregate(0u, static (sum, call) => sum + call.DrawCount)); } + [Fact] + public void OnLandscapeCellTurn_MultiCellShadowAlias_DrawsMeshAndParticlesOnlyOnce() + { + using var fx = new DispatcherFixture(); + var log = new List(); + const ulong gfxObj = 0x0200_0021UL; + InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh( + MakeBatch(0x08100021u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + + var ctx = new TestContext(); + var worldData = new FakeWorldData(); + RenderProjectionRecord shadowAlias = MakeRecord( + 303, + 0x50000002, + Vector3.Zero, + [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]); + WalkFrameStaticRecords aliases = new( + new[] { shadowAlias }, + 0xF07Fu); + worldData.OutdoorDynamicsByCell[0xF07F0040u] = aliases; + worldData.OutdoorDynamicsByCell[0xF0800001u] = aliases; + + var driver = new WalkFrameDriver( + fx.Dispatcher, + new RecordingLeafRenderer(log), + worldData, + new RecordingTrace(log)); + + using DrawScope draw = fx.BeginDraw(); + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + var activeViews = new WalkPortalView(); + WalkCopyView.AppendFullViewportQuad( + activeViews, + ctx.Rays, + ctx.WorldViewpoint, + ctx.ViewportWidth, + ctx.ViewportHeight); + ((IWalkEventSink)driver).OnLandscapeViews(activeViews); + ((IWalkEventSink)driver).OnLandscapeCellTurn(0xF07F0040u); + ((IWalkEventSink)driver).OnLandscapeCellTurn(0xF0800001u); + driver.EndFrame(); + driver.Replay(draw.Frame, draw.Pass); + + Assert.Equal( + new[] { "FLUSH:1:Dynamic", "PARTICLES:12f" }, + log); + GpuRecordedMultiDrawIndirect[] mdi = + fx.Device.Calls.OfType().ToArray(); + Assert.Single(mdi); + Assert.Equal(1u, mdi[0].DrawCount); + } + // ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture — // FW3.2a's own referee) ───────────────────────────────────────────────── diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeAssemblerTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeAssemblerTests.cs index 99ff9734..7caa9d69 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeAssemblerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeAssemblerTests.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.Rendering; using AcDream.App.Rendering.Walk; namespace AcDream.App.Tests.Rendering.Walk; @@ -12,6 +13,15 @@ namespace AcDream.App.Tests.Rendering.Walk; /// SetViewer exactly once per fixture). public sealed class WalkLandscapeAssemblerTests { + private sealed class LandscapeCellRecorder : IWalkEventSink + { + public HashSet Cells { get; } = []; + + public void Emit(in WalkEvent walkEvent) { } + + public void OnLandscapeCellTurn(uint cellId) => Cells.Add(cellId); + } + private const uint LandblockId = 0xA9B4FFFFu; // block (0xA9, 0xB4) private const uint CameraCellId = 0xA9B40001u; // same block, outdoor landcell 1 @@ -123,6 +133,19 @@ public sealed class WalkLandscapeAssemblerTests Assert.Equal(1, assembler.Landscape.ViewerCellY); } + [Fact] + public void SetViewer_OutdoorCellRecoversWholeBlockRenderCenterOffset() + { + var assembler = new WalkLandscapeAssembler(); + + assembler.SetViewer(0xF07F0022u, new Vector3(107.31238f, 222.21594f, 0f)); + + Assert.Equal(4, assembler.Landscape.ViewerCellX); + Assert.Equal(1, assembler.Landscape.ViewerCellY); + Assert.Equal(0f, assembler.Landscape.ViewerWorldOriginX); + Assert.Equal(192f, assembler.Landscape.ViewerWorldOriginY); + } + [Fact] public void SetViewer_InteriorCameraDerivesViewerCellFromOrigin() { @@ -132,6 +155,21 @@ public sealed class WalkLandscapeAssemblerTests Assert.Equal(2, assembler.Landscape.ViewerCellX); // floor(50 / 24) = 2 Assert.Equal(3, assembler.Landscape.ViewerCellY); // floor(74 / 24) = 3 + Assert.Equal(0f, assembler.Landscape.ViewerWorldOriginX); + Assert.Equal(0f, assembler.Landscape.ViewerWorldOriginY); + } + + [Fact] + public void SetViewer_InteriorCameraUsesRenderCenterBlockAndPositiveLocalCell() + { + var assembler = new WalkLandscapeAssembler(); + + assembler.SetViewer(0xA9B40105u, new Vector3(-10f, 222f, 0f)); + + Assert.Equal(-192f, assembler.Landscape.ViewerWorldOriginX); + Assert.Equal(192f, assembler.Landscape.ViewerWorldOriginY); + Assert.Equal(7, assembler.Landscape.ViewerCellX); + Assert.Equal(1, assembler.Landscape.ViewerCellY); } [Fact] @@ -145,4 +183,53 @@ public sealed class WalkLandscapeAssemblerTests Assert.NotNull(assembler.Landscape.Blocks[CenterIndex()]); } + + [Fact] + public void TuskerIsland_RenderCenterOffset_DoesNotCullTheViewerLandblock() + { + // Live regression capture 2026-08-31. Cell 0x22 is local cell + // (x=4,y=1), hence the camera must be in x=[96,120], y=[24,48] + // in the viewer landblock. The render center was one block south, + // so the published eye was y=222.216 (= 30.216 + 192). The landscape + // grid is viewer-block-local and must carry that 192 m basis offset + // when classifying its blocks against the camera plane. + const uint tuskerLandblock = 0xF07FFFFFu; + const uint tuskerCell = 0xF07F0022u; + var eye = new Vector3(107.31238f, 222.21594f, 16.352213f); + var forward = Vector3.Normalize(new Vector3(-0.66922873f, 0.6854568f, -0.286918f)); + var viewProjection = new Matrix4x4( + 0.79605f, -0.39642528f, -0.66922873f, -0.6692153f, + 0.7772036f, 0.4060382f, 0.6854568f, 0.6854431f, + 0f, 1.8946906f, -0.286918f, -0.28691226f, + -258.13306f, -78.669205f, -75.91117f, -75.809654f); + + var assembler = new WalkLandscapeAssembler(); + assembler.PublishLandblock( + tuskerLandblock, + maxZ: 220f, + minZ: -10f, + Array.Empty()); + assembler.SetViewer(tuskerCell, eye); + + var context = new WalkProductionFrameContext( + new CellVisibility(), + new WalkBuildingRegistry(), + eye, + forward, + viewProjection, + 1760f, + 990f); + var recorder = new LandscapeCellRecorder(); + + new RetailFrameWalk().WalkFrame( + tuskerCell, + cameraCell: null, + assembler.Landscape, + context, + recorder); + + Assert.Contains( + recorder.Cells, + cellId => (cellId & 0xFFFF0000u) == (tuskerLandblock & 0xFFFF0000u)); + } } diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs index e8daa1dc..a2fc29cb 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs @@ -128,4 +128,49 @@ public sealed class WalkProductionFrameContextTests new CellVisibility(), new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY, default, 1024f, 768f)); } + + [Fact] + public void Reset_RebindsCameraValuesAndRetainsTheRayCaster() + { + Matrix4x4 firstProjection = SimpleViewProjection(); + var ctx = new WalkProductionFrameContext( + new CellVisibility(), new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY, + firstProjection, 1024f, 768f); + IWalkRayCaster retainedRays = ctx.Rays; + + var eye = new Vector3(4f, 5f, 6f); + Vector3 forward = Vector3.UnitX; + Matrix4x4 secondProjection = + Matrix4x4.CreateLookAt(eye, eye + forward, Vector3.UnitZ) + * Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 4f, 16f / 9f, 0.1f, 500f); + + ctx.Reset(eye, forward, secondProjection, 1760f, 990f); + + Assert.Same(retainedRays, ctx.Rays); + Assert.Equal(eye, ctx.WorldViewpoint); + Assert.Equal(1760f, ctx.ViewportWidth); + Assert.Equal(990f, ctx.ViewportHeight); + Assert.Equal(forward, ctx.CyPlane.Normal); + Assert.Equal(-Vector3.Dot(eye, forward) - WalkProductionFrameContext.ZNear, ctx.CyPlane.D); + } + + [Fact] + public void Reset_WithBadProjectionLeavesThePreviousBindingUsable() + { + Matrix4x4 projection = SimpleViewProjection(); + var eye = new Vector3(1f, 2f, 3f); + var ctx = new WalkProductionFrameContext( + new CellVisibility(), new WalkBuildingRegistry(), eye, Vector3.UnitY, + projection, 1024f, 768f); + Vector3 rayBeforeFailure = ctx.Rays.RayThrough(200f, 300f); + + Assert.Throws(() => + ctx.Reset(new Vector3(9f), Vector3.UnitX, default, 1f, 1f)); + + Assert.Equal(eye, ctx.WorldViewpoint); + Assert.Equal(1024f, ctx.ViewportWidth); + Assert.Equal(768f, ctx.ViewportHeight); + Assert.Equal(Vector3.UnitY, ctx.CyPlane.Normal); + Assert.Equal(rayBeforeFailure, ctx.Rays.RayThrough(200f, 300f)); + } } diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs new file mode 100644 index 00000000..98509591 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs @@ -0,0 +1,54 @@ +using System.Numerics; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Walk; + +namespace AcDream.App.Tests.Rendering.Walk; + +public sealed class WalkProductionWorldDataTests +{ + [Fact] + public void BucketOutdoorRecord_UsesEveryOutdoorPhysicsShadowCellAcrossLandblockEdge() + { + RenderProjectionRecord record = Record( + id: 0x1234u, + position: new Vector3(191f, 191f, 0f)); + var buckets = new Dictionary>(); + + WalkProductionWorldData.BucketOutdoorRecord( + in record, + [0xF07F0040u, 0xF0800001u, 0xF4180101u], + buckets, + renderCenterLbX: 0xF0, + renderCenterLbY: 0x7F); + + Assert.Equal([0xF07F0040u, 0xF0800001u], buckets.Keys.Order()); + Assert.All(buckets.Values, bucket => Assert.Equal(record, Assert.Single(bucket))); + } + + [Fact] + public void BucketOutdoorRecord_UnregisteredEffectFallsBackToRootPositionCell() + { + RenderProjectionRecord record = Record( + id: 0x5678u, + position: new Vector3(193f, 25f, 0f)); + var buckets = new Dictionary>(); + + WalkProductionWorldData.BucketOutdoorRecord( + in record, + Array.Empty(), + buckets, + renderCenterLbX: 0xEF, + renderCenterLbY: 0x7F); + + Assert.Equal([0xF07F0002u], buckets.Keys); + Assert.Equal(record, Assert.Single(buckets[0xF07F0002u])); + } + + private static RenderProjectionRecord Record(uint id, Vector3 position) => + new RenderProjectionRecord() with + { + Id = RenderProjectionId.FromRaw(id), + Transform = new RenderTransform(Matrix4x4.CreateTranslation(position)), + Source = new RenderSourceMetadata() with { LocalEntityId = id }, + }; +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs index 62b29a58..dce5a30f 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs @@ -190,6 +190,51 @@ public sealed class WalkStaticStreamPopulatorTests Assert.Equal((uint)alphaGfxObj, selectionParts[1].GfxObjId); } + [Fact] + public void ClassifyEntityForWalk_NoDrawProjectileRecord_SubmitsNeitherMeshNorSelection() + { + using var fx = new DispatcherFixture(); + const ulong projectileGfxObj = 0x0100_0003UL; + InjectRenderData(fx.Manager, projectileGfxObj, MakeFlatMesh( + MakeBatch( + 0x08000003u, + TranslucencyKind.Opaque, + firstIndex: 0, + baseVertex: 0, + indexCount: 3, + textureSlotIndex: 1))); + + RenderProjectionRecord projectile = MakeRecord( + localEntityId: 101, + serverGuid: 0x7000_0101u, + position: Vector3.Zero, + meshRefs: [new MeshRef((uint)projectileGfxObj, Matrix4x4.Identity)]) + with + { + // ACE's projectile-impact SetState sets NoDraw immediately; + // the later DeleteObject intentionally arrives five seconds + // afterward. The journal projects that state as resident and + // hidden, with Draw cleared. + Flags = RenderProjectionFlags.SpatiallyResident + | RenderProjectionFlags.Selectable + | RenderProjectionFlags.Hidden, + }; + + var batches = new List(); + var selectionParts = + new List(); + + fx.Dispatcher.ClassifyEntityForWalk( + in projectile, + tupleLandblockId: 0x8C04u, + batches, + selectionParts, + liveDynamic: true); + + Assert.Empty(batches); + Assert.Empty(selectionParts); + } + [Fact] public void ClassifyEntityForWalk_SetupComposite_EncodesPartAndSetupPartIndexLikePackedRoute() {