checkpoint: preserve user-gated FW closeout fixes

This commit is contained in:
Erik 2026-08-31 08:27:37 +02:00
parent a2f2eb7d78
commit b8befded8b
22 changed files with 1005 additions and 198 deletions

View file

@ -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 > `TerrainModernRenderer` (terrain), fed by the unified PView stack. This is the
> authoritative current draw model; the 2026-05-31 reset handoff is historical. > authoritative current draw model; the 2026-05-31 reset handoff is historical.
**One visibility owner.** `RetailPViewRenderer.DrawInside` is the production **One visibility owner.** `RetailFrameWalk`, driven once per frame by
world gate. Its root is the collided camera/viewer cell, or the synthetic `WalkFrameDriver.Collect`, is the production authority for rooting, portal
outdoor cell adaptation; the player's current cell separately owns sunlight recursion, visible cells/buildings, per-view admission, and draw order. Its root
and indoor lighting. A null root exists only for login/debug/streaming-gap is the collided camera/viewer cell or retail's synthetic outdoor cell; the
fallback frames. `RetailPViewRenderer` is the one authoritative PView owner and player's current cell separately owns sunlight and indoor lighting. A null root
product family: it builds the main frame, deliberate per-building exterior exists only for login/debug/streaming-gap fallback frames. The classes named
floods for the synthetic outdoor root, and separate interior-root look-in `RetailPViewRenderer` and `RetailPViewPassExecutor` remain composition/pass
frames. No second per-frame ACME visibility BFS competes with that family. 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 **Current draw discipline.** The walk records one ordered event stream for
statics first. Interior-root building look-ins then punch all entry apertures landscape cells, building portals, EnvCell shells, statics, dynamics, particles,
before drawing their shell and contents. The landscape shared-alpha scope depth boundaries, and alpha barriers. `WalkFrameDriver` replays those events in
flushes before the root-specific depth boundary. Interior roots perform the retail order through the Vulkan leaf renderers. Interior roots preserve retail's
conditional depth clear and then write true-depth exit seals; the synthetic conditional depth clear and true-depth exit seals; the synthetic outdoor root
outdoor root retains world depth and writes far-Z building-entry punches. retains world depth and stamps far-Z entry punches. World translucents and scene
Opaque EnvCell shells, immediate far-to-near transparent EnvCell shells, cell particles share `RetailAlphaQueue` and drain at the walk's exact barriers.
statics and their particles, then the surviving main-stage dynamics and their Private portal/paperdoll viewports and retained UI execute only after the world
particles follow. Look-in and outside-stage dynamics intentionally draw in stream finishes. Projectiles are ordinary live-entity draws, never a separate
their earlier landscape phases. The final world shared-alpha scope flushes global pass.
before private portal/paperdoll viewports and UI.
The modern renderer intentionally does not hard-clip every shell or entity to **Portal-slice clipping is part of admission, not a later approximation.** Each
the accumulated polygon. It combines PView admission and viewcone checks with walk visit retains the exact authored pixel-space portal polygon. The frame
retail's punch/seal depth discipline; terrain/outside slices use the bounded adapter converts it once to a Vulkan GPU clip slot, and the cell shell, statics,
clip-plane/scissor adaptation. World Wb translucents and Scene particles share and dynamics admitted by that visit draw through that same slot. A content
one stable far-to-near `RetailAlphaQueue`; EnvCell transparent shells and drawing sphere may reject a slice before submission, but an accepted slice
private viewports remain immediate. Projectiles are ordinary live-entity draws, never emits the whole mesh unclipped. The same cell can therefore draw more
never a separate global pass. 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`, Retail anchors are `SmartBox::RenderNormalMode @ 0x00453AA0`,
`PView::DrawInside @ 0x005A5860`, `PView::DrawCells @ 0x005A4840`, `PView::DrawInside @ 0x005A5860`, `PView::DrawCells @ 0x005A4840`,

View file

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

View file

@ -271,9 +271,10 @@ the transformed DAT `SortCenter`; only adjacent compatible entries may batch.
Billboard particle textures are resident bindless `sampler2DArray` handles in Billboard particle textures are resident bindless `sampler2DArray` handles in
the per-instance vertex ABI, so different textures preserve that sorted order 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 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. keeps dense particle fields from becoming one Vulkan draw per alternating
`RetailPViewRenderer` drains the landscape scope before the optional depth texture. `WalkFrameDriver` drains the landscape and world scopes at the exact
clear, and `GameWindow` drains the final scope before private viewports/UI. 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, Sky and sealed off-screen render targets remain independent. No DAT reader,
mesh decoder, or second scene graph was introduced. Retail anchors: mesh decoder, or second scene graph was introduced. Retail anchors:
`CPhysicsPart::UpdateViewerDistance` `0x0050E030`, `CPhysicsPart::UpdateViewerDistance` `0x0050E030`,
@ -281,7 +282,10 @@ mesh decoder, or second scene graph was introduced. Retail anchors:
`CShadowPart::insertion_sort` `0x006B5130`, `CShadowPart::insertion_sort` `0x006B5130`,
`D3DPolyRender::AddMeshToAlphaList` `0x0059C230`, and `D3DPolyRender::AddMeshToAlphaList` `0x0059C230`, and
`D3DPolyRender::FlushAlphaList` `0x0059D2E0`. The modern per-cell-order 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).** **Retail portal-space viewport adapter (2026-07-15).**
`src/AcDream.App/Rendering/PortalTunnelPresentation.cs` uses the extracted `src/AcDream.App/Rendering/PortalTunnelPresentation.cs` uses the extracted

View file

@ -538,6 +538,18 @@ Stages FW0FW6; 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). **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). **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 ### Phase O — DatPath Unification — SHIPPED 2026-05-21
**Tagline:** ONE thing touches the DATs. **Tagline:** ONE thing touches the DATs.

View file

@ -12,6 +12,17 @@ world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon
travel, same-location revisit, exact graceful disconnect, and uncapped travel, same-location revisit, exact graceful disconnect, and uncapped
fresh-process reconnect with canonical JSON/PNG evidence. 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 Before new M4 quest/emote/character-creation subsystem bodies enter the App
layer, the active structural prerequisite is the behavior-preserving layer, the active structural prerequisite is the behavior-preserving
`GameWindow` decomposition in `GameWindow` decomposition in

View file

@ -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, path keeps the old seal draw. Suites: hermetic 6,762/0, Walk lane 213/1,
InstalledDat walk conformance 40/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 - Entities gate per view via `Render::viewconeCheck` at their cell's walk
turn; dynamics-last and alpha interleave per the walk's stages turn; dynamics-last and alpha interleave per the walk's stages
(`RetailAlphaQueue` becomes the stream's alpha stage or is absorbed — (`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, side-by-side feel gate vs retail (chase, zoom saturation ≈13 m boom,
collision, slope align). 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 ### FW6 — closeout
- Divergence-register reconciliation (added: landblock-stage streaming - 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 here as stages close (the Modern Runtime plan's convention). FW3 and FW4
are the cutover stages — their revert anchors are mandatory entries. 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) ## Risks (tracked, from the spec)
- **Perf** — decided by FW3's checkpoint, numbers over hope. - **Perf** — decided by FW3's checkpoint, numbers over hope.

View file

@ -506,7 +506,8 @@ internal sealed class FrameRootCompositionPhase
live.LandblockPipeline.RenderPublisher?.WalkLandscape live.LandblockPipeline.RenderPublisher?.WalkLandscape
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"The retail frame walk requires the landscape registry."), "The retail frame walk requires the landscape registry."),
d.CellVisibility), d.CellVisibility,
d.PhysicsEngine.ShadowObjects),
retailPViewPassExecutor, retailPViewPassExecutor,
retailPViewPassExecutor), retailPViewPassExecutor),
retailPViewCells, retailPViewCells,

View file

@ -202,11 +202,11 @@ internal sealed partial class RetailPViewPassExecutor
/// </summary> /// </summary>
internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
{ {
private readonly RetailPViewPassExecutor _passes; private RetailPViewPassExecutor _passes = null!;
private readonly RetailPViewFrameInput _frame; private RetailPViewFrameInput _frame = null!;
private readonly ClipFrameAssembly _clipAssembly; private ClipFrameAssembly _clipAssembly = null!;
private readonly Action _clearInteriorDepth; private Action _clearInteriorDepth = null!;
private readonly Action _drawExitSeals; private Action _drawExitSeals = null!;
private readonly HashSet<uint> _singleCellScratch = new(); private readonly HashSet<uint> _singleCellScratch = new();
private readonly List<uint> _singleCellListScratch = new(); private readonly List<uint> _singleCellListScratch = new();
private readonly Dictionary<uint, int> _singleCellClipScratch = new(1); private readonly Dictionary<uint, int> _singleCellClipScratch = new(1);
@ -217,10 +217,24 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
ClipFrameAssembly clipAssembly, ClipFrameAssembly clipAssembly,
Action clearInteriorDepth, Action clearInteriorDepth,
Action drawExitSeals) Action drawExitSeals)
=> Reset(passes, frame, clipAssembly, clearInteriorDepth, drawExitSeals);
/// <summary>
/// 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.
/// </summary>
internal void Reset(
RetailPViewPassExecutor passes,
RetailPViewFrameInput frame,
ClipFrameAssembly clipAssembly,
Action clearInteriorDepth,
Action drawExitSeals)
{ {
_passes = passes ?? throw new ArgumentNullException(nameof(passes)); _passes = passes ?? throw new ArgumentNullException(nameof(passes));
_frame = frame ?? throw new ArgumentNullException(nameof(frame)); _frame = frame ?? throw new ArgumentNullException(nameof(frame));
_clipAssembly = clipAssembly; _clipAssembly = clipAssembly
?? throw new ArgumentNullException(nameof(clipAssembly));
_clearInteriorDepth = clearInteriorDepth _clearInteriorDepth = clearInteriorDepth
?? throw new ArgumentNullException(nameof(clearInteriorDepth)); ?? throw new ArgumentNullException(nameof(clearInteriorDepth));
_drawExitSeals = drawExitSeals _drawExitSeals = drawExitSeals

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene;
using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -29,6 +30,7 @@ internal sealed class RetailPViewRenderer
// frames instead of `new HashSet<uint>(pvFrame.OrderedVisibleCells)` every // frames instead of `new HashSet<uint>(pvFrame.OrderedVisibleCells)` every
// call. Every walk consumer reads it synchronously in this frame. // call. Every walk consumer reads it synchronously in this frame.
private readonly HashSet<uint> _drawableCellsScratch = new(); private readonly HashSet<uint> _drawableCellsScratch = new();
private readonly HashSet<uint> _visibleCellsScratch = new();
// FW3 visual-gate fix: the interior root's dynamics phase, invoked by // FW3 visual-gate fix: the interior root's dynamics phase, invoked by
// the driver's clearInteriorDepth closure at the walk's pre-clear // the driver's clearInteriorDepth closure at the walk's pre-clear
@ -37,6 +39,20 @@ internal sealed class RetailPViewRenderer
// cleared in finally. // cleared in finally.
private Action? _walkPreClearDynamics; 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 // ACDREAM_PROBE_WALK_ROOT (FW3 visual-gate apparatus, throwaway): the
// previous frame's root kind + a post-flip frame countdown so each // previous frame's root kind + a post-flip frame countdown so each
// interior/outdoor transition dumps 8 frames of rooting facts. // interior/outdoor transition dumps 8 frames of rooting facts.
@ -62,7 +78,8 @@ internal sealed class RetailPViewRenderer
RenderSceneShadowRuntime renderSceneShadow, RenderSceneShadowRuntime renderSceneShadow,
Walk.WalkBuildingRegistry walkBuildings, Walk.WalkBuildingRegistry walkBuildings,
Walk.WalkLandscapeAssembler walkLandscape, Walk.WalkLandscapeAssembler walkLandscape,
CellVisibility walkCellRegistry) CellVisibility walkCellRegistry,
ShadowObjectRegistry shadows)
{ {
_renderSceneShadow = renderSceneShadow _renderSceneShadow = renderSceneShadow
?? throw new ArgumentNullException(nameof(renderSceneShadow)); ?? throw new ArgumentNullException(nameof(renderSceneShadow));
@ -72,7 +89,11 @@ internal sealed class RetailPViewRenderer
?? throw new ArgumentNullException(nameof(walkLandscape)); ?? throw new ArgumentNullException(nameof(walkLandscape));
_walkCellRegistry = walkCellRegistry _walkCellRegistry = walkCellRegistry
?? throw new ArgumentNullException(nameof(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 // 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 // stream appends classify records immediately (WalkStaticStreamPopulator
// runs at append time, not at Replay time), so the world data must // runs at append time, not at Replay time), so the world data must
// already be rebuilt for this frame before the walk starts. // already be rebuilt for this frame before the walk starts.
Walk.WalkFrameDriver? walkDriver = null; Walk.WalkFrameDriver walkDriver;
WalkProductionLeafRenderer walkLeafRenderer;
{ {
Matrix4x4 view = ctx.CameraView; Matrix4x4 view = ctx.CameraView;
var forward = Vector3.Normalize(new Vector3(-view.M13, -view.M23, -view.M33)); 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. // reached before the world pass has published its scope.
float viewportWidth = attachment?.Width ?? 1024f; float viewportWidth = attachment?.Width ?? 1024f;
float viewportHeight = attachment?.Height ?? 720f; float viewportHeight = attachment?.Height ?? 720f;
var walkContext = new Walk.WalkProductionFrameContext( if (_walkFrameContextScratch is null)
_walkCellRegistry!, {
_walkBuildings!, _walkFrameContextScratch = new Walk.WalkProductionFrameContext(
ctx.ViewerEyePos, _walkCellRegistry,
forward, _walkBuildings,
ctx.ViewProjection, ctx.ViewerEyePos,
viewportWidth, forward,
viewportHeight); ctx.ViewProjection,
viewportWidth,
viewportHeight);
}
else
{
_walkFrameContextScratch.Reset(
ctx.ViewerEyePos,
forward,
ctx.ViewProjection,
viewportWidth,
viewportHeight);
}
Walk.WalkProductionFrameContext walkContext = _walkFrameContextScratch;
_walkLandscape!.SetViewer(ctx.ViewerCellId, ctx.ViewerEyePos); _walkLandscape!.SetViewer(ctx.ViewerCellId, ctx.ViewerEyePos);
Walk.WalkLandscape walkLandscape = _walkLandscape.Landscape; Walk.WalkLandscape walkLandscape = _walkLandscape.Landscape;
@ -185,47 +218,60 @@ internal sealed class RetailPViewRenderer
ctx.RenderCenterLbX, ctx.RenderCenterLbX,
ctx.RenderCenterLbY); ctx.RenderCenterLbY);
Action clearInteriorDepth = () => _activeWalkPasses = walkExecutor;
_activeWalkFrame = ctx;
_activeWalkClipAssembly = clipAssembly;
if (_walkLeafRendererScratch is null)
{ {
// FW3 visual-gate fix (owner report: doors/candles invisible _walkLeafRendererScratch = new WalkProductionLeafRenderer(
// looking out; the crossing vanish): retail draws the walkExecutor,
// OUTSIDE world's objects INSIDE LScape::draw — strictly ctx,
// BEFORE the depth clear + seals (the #118 house-exit clipAssembly,
// clip+vanish lesson: anything drawn after the seals z-fails _walkClearInteriorDepthAction,
// against their true-depth stamp the moment it stands beyond _walkDrawExitSealsAction);
// the door plane). The surviving dynamic routes + outdoor }
// particles + weather therefore run HERE, at the walk's else
// pre-clear boundary, for an interior root. {
_walkPreClearDynamics?.Invoke(); _walkLeafRendererScratch.Reset(
// Retail PView::DrawCells 0x005A4872 drains the landscape walkExecutor,
// alpha list immediately before the gated full depth clear — ctx,
// mirrors DrawLandscapeThroughOutsideView's own pre-clear clipAssembly,
// drain. _walkClearInteriorDepthAction,
passes.FlushLandscapeAlpha(); _walkDrawExitSealsAction);
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!);
walkLeafRenderer = new WalkProductionLeafRenderer( if (_walkFrameDriverScratch is null)
walkExecutor!, ctx, clipAssembly, clearInteriorDepth, drawExitSeals); {
walkDriver = new Walk.WalkFrameDriver( _walkFrameDriverScratch = new Walk.WalkFrameDriver(
walkExecutor!.Dispatcher, walkExecutor.Dispatcher,
walkLeafRenderer, _walkLeafRendererScratch,
_walkWorldData, _walkWorldData,
clipFrame: clipAssembly.Frame); clipFrame: clipAssembly.Frame);
}
else
{
_walkFrameDriverScratch.RebindFrame(
_walkLeafRendererScratch,
clipAssembly.Frame);
}
walkDriver = _walkFrameDriverScratch;
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled)
{ {
AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame = AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame =
_probeWalkRootFrame % 90 == 0; _probeWalkRootFrame % 90 == 0;
} }
walkDriver.Collect( try
_frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext, {
ctx.ViewProjection, ctx.CameraWorldPosition); walkDriver.Collect(
_frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext,
ctx.ViewProjection, ctx.CameraWorldPosition);
}
catch
{
ClearWalkFrameBindings();
throw;
}
AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame = false; AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame = false;
// FW4 slice 1: an interior root's terrain/sky/punch clip slices // 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. // OrderedVisibleCells side-channel for every production consumer.
_drawableCellsScratch.Clear(); _drawableCellsScratch.Clear();
_drawableCellsScratch.UnionWith(walkDriver.VisitedCells); _drawableCellsScratch.UnionWith(walkDriver.VisitedCells);
walkDriver.CopyVisibleCellsTo(_visibleCellsScratch);
// Phase I cathedral instrumentation (synthesis §Phase I.3): the // Phase I cathedral instrumentation (synthesis §Phase I.3): the
// continuous rooting line SEPARATES the true root flood // continuous rooting line SEPARATES the true root flood
@ -326,7 +373,7 @@ internal sealed class RetailPViewRenderer
RetailPViewFrameResult result = _frameResultScratch.Reset( RetailPViewFrameResult result = _frameResultScratch.Reset(
clipAssembly, clipAssembly,
drawableCells, drawableCells,
prepareCells, _visibleCellsScratch,
counts, counts,
sourceCounts, sourceCounts,
diagnosticPartition: null); diagnosticPartition: null);
@ -360,6 +407,7 @@ internal sealed class RetailPViewRenderer
finally finally
{ {
_walkPreClearDynamics = null; _walkPreClearDynamics = null;
ClearWalkFrameBindings();
} }
// OUTDOOR root: the LScape-boundary alpha drain deferred from the // 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 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;
}
/// <summary>Campaign FW3.2b-2 — THE PRODUCTION ROOTING; Campaign FW3.4a — /// <summary>Campaign FW3.2b-2 — THE PRODUCTION ROOTING; Campaign FW3.4a —
/// REPLAY ONLY. <paramref name="driver"/> already ran its Collect pass /// REPLAY ONLY. <paramref name="driver"/> already ran its Collect pass
/// earlier in <see cref="DrawInside"/> (before <c>PrepareCellBatches</c>); /// earlier in <see cref="DrawInside"/> (before <c>PrepareCellBatches</c>);

View file

@ -465,10 +465,10 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
{ {
private readonly WbDrawDispatcher _dispatcher; private readonly WbDrawDispatcher _dispatcher;
private readonly WalkStaticStreamPopulator _populator; private readonly WalkStaticStreamPopulator _populator;
private readonly IWalkFrameLeafRenderer _leafRenderer; private IWalkFrameLeafRenderer _leafRenderer;
private readonly IWalkFrameWorldData _worldData; private readonly IWalkFrameWorldData _worldData;
private readonly IWalkFrameDriverTrace? _trace; private readonly IWalkFrameDriverTrace? _trace;
private readonly ClipFrame? _clipFrame; private ClipFrame? _clipFrame;
private readonly OrderedDrawStream _stream = new(); private readonly OrderedDrawStream _stream = new();
private readonly List<WalkFrameEvent> _events = new(); private readonly List<WalkFrameEvent> _events = new();
private readonly List<int> _markPositions = new(); private readonly List<int> _markPositions = new();
@ -493,6 +493,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private readonly List<int> _floodViewRouteScratch = new(); private readonly List<int> _floodViewRouteScratch = new();
private WalkPlane _lookInCyPlane; 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<RenderProjectionId> _outdoorDrawnThisFrame = new();
private readonly HashSet<uint> _outdoorParticleOwnersDrawnThisFrame = new();
IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns; IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
/// <summary>The set form of <see cref="LookInCellTurns"/>, for drawn-once /// <summary>The set form of <see cref="LookInCellTurns"/>, for drawn-once
@ -524,6 +531,19 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
} }
} }
private static void UnionNewOwners(
in WalkFrameStaticRecords records,
HashSet<uint> destination,
HashSet<uint> drawnOnce)
{
foreach (RenderProjectionRecord record in records.Records)
{
uint ownerId = record.Source.LocalEntityId;
if (ownerId != 0 && drawnOnce.Add(ownerId))
destination.Add(ownerId);
}
}
internal List<WalkBuilding> VisitedBuildings { get; } = new(); internal List<WalkBuilding> VisitedBuildings { get; } = new();
internal HashSet<uint> VisitedLandscapeCellIds { get; } = new(); internal HashSet<uint> VisitedLandscapeCellIds { get; } = new();
@ -554,6 +574,77 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_populator = new WalkStaticStreamPopulator(dispatcher); _populator = new WalkStaticStreamPopulator(dispatcher);
} }
/// <summary>
/// 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.
/// </summary>
internal void RebindFrame(
IWalkFrameLeafRenderer leafRenderer,
ClipFrame? clipFrame)
{
AbortFrame();
_leafRenderer = leafRenderer
?? throw new ArgumentNullException(nameof(leafRenderer));
_clipFrame = clipFrame;
}
/// <summary>Discard only transient frame state; retained capacities stay
/// available for the next frame.</summary>
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;
}
/// <summary>
/// Copies the walk's complete retail <c>CObjCell::IsInView</c> 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.
/// </summary>
internal void CopyVisibleCellsTo(HashSet<uint> 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);
}
/// <summary> /// <summary>
/// Drives one complete frame at retail's root (<c>SmartBox::RenderNormalMode</c>): /// Drives one complete frame at retail's root (<c>SmartBox::RenderNormalMode</c>):
/// <see cref="Collect"/> immediately followed by <see cref="Replay"/>. Kept /// <see cref="Collect"/> immediately followed by <see cref="Replay"/>. Kept
@ -601,8 +692,16 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
ArgumentNullException.ThrowIfNull(ctx); ArgumentNullException.ThrowIfNull(ctx);
BeginFrame(ctx, viewProjection, cameraWorldPosition); BeginFrame(ctx, viewProjection, cameraWorldPosition);
walk.WalkFrame(cameraCellId, cameraCell, landscape, ctx, this); try
EndFrame(); {
walk.WalkFrame(cameraCellId, cameraCell, landscape, ctx, this);
EndFrame();
}
catch
{
AbortFrame();
throw;
}
} }
/// <summary> /// <summary>
@ -644,6 +743,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_lookInPlanes.Clear(); _lookInPlanes.Clear();
_visibleClipSlotScratch.Clear(); _visibleClipSlotScratch.Clear();
_lookInCyPlane = ctx.CyPlane; _lookInCyPlane = ctx.CyPlane;
_outdoorDrawnThisFrame.Clear();
_outdoorParticleOwnersDrawnThisFrame.Clear();
LookInCells.Clear(); LookInCells.Clear();
VisitedBuildings.Clear(); VisitedBuildings.Clear();
VisitedLandscapeCellIds.Clear(); VisitedLandscapeCellIds.Clear();
@ -697,82 +798,94 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
+ "replay."); + "replay.");
} }
if (_stream.Count > 0) try
_dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions);
int cursor = 0;
for (int i = 0; i < _events.Count; i++)
{ {
WalkFrameEvent e = _events[i]; if (_stream.Count > 0)
switch (e.Kind) _dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions);
int cursor = 0;
for (int i = 0; i < _events.Count; i++)
{ {
case WalkFrameEventKind.StreamMark: WalkFrameEvent e = _events[i];
int end = e.IntArg; switch (e.Kind)
int count = end - cursor; {
if (_trace is not null) case WalkFrameEventKind.StreamMark:
_trace.OnFlush(count, _stream.Stages.GetRange(cursor, count)); int end = e.IntArg;
_dispatcher.DrawOrderedRange(encoder, cursor, count); int count = end - cursor;
cursor = end; if (_trace is not null)
break; _trace.OnFlush(count, _stream.Stages.GetRange(cursor, count));
case WalkFrameEventKind.Sky: _dispatcher.DrawOrderedRange(encoder, cursor, count);
_leafRenderer.DrawSky(); cursor = end;
break; break;
case WalkFrameEventKind.TerrainSlice: case WalkFrameEventKind.Sky:
_leafRenderer.DrawTerrainSlice(e.IntArg); _leafRenderer.DrawSky();
break; break;
case WalkFrameEventKind.CellShell: case WalkFrameEventKind.TerrainSlice:
_leafRenderer.DrawCellShell(e.CellId, checked((uint)e.IntArg)); _leafRenderer.DrawTerrainSlice(e.IntArg);
break; break;
case WalkFrameEventKind.PunchFan: case WalkFrameEventKind.CellShell:
_leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg); _leafRenderer.DrawCellShell(e.CellId, checked((uint)e.IntArg));
break; break;
case WalkFrameEventKind.AlphaBarrier: case WalkFrameEventKind.PunchFan:
_leafRenderer.AlphaBarrier(); _leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg);
break; break;
case WalkFrameEventKind.ClearInteriorDepth: case WalkFrameEventKind.AlphaBarrier:
_leafRenderer.ClearInteriorDepth(); _leafRenderer.AlphaBarrier();
break; break;
case WalkFrameEventKind.ExitSeals: case WalkFrameEventKind.ClearInteriorDepth:
_leafRenderer.DrawExitSeals(); _leafRenderer.ClearInteriorDepth();
break; break;
case WalkFrameEventKind.StaticParticles: case WalkFrameEventKind.ExitSeals:
_staticParticleOwnerScratch.Clear(); _leafRenderer.DrawExitSeals();
UnionOwners( break;
e.Building is WalkBuilding shellOwner case WalkFrameEventKind.StaticParticles:
? _worldData.GetBuildingShellStatics(shellOwner) _staticParticleOwnerScratch.Clear();
: _worldData.GetOutdoorStatics(e.CellId), if (e.Building is WalkBuilding shellOwner)
_staticParticleOwnerScratch); {
if (e.Building is null) 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( UnionOwners(
_worldData.GetOutdoorDynamics(e.CellId), _worldData.GetCellStatics(e.CellId),
_staticParticleOwnerScratch); _staticParticleOwnerScratch);
} UnionOwners(
if (_staticParticleOwnerScratch.Count > 0) _worldData.GetCellDynamics(e.CellId),
_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,
_staticParticleOwnerScratch); _staticParticleOwnerScratch);
} if (_staticParticleOwnerScratch.Count > 0)
break; {
_leafRenderer.DrawCellParticles(
e.CellId,
_staticParticleOwnerScratch);
}
break;
}
} }
} }
finally
_stream.Reset(); {
_events.Clear(); _stream.Reset();
_markPositions.Clear(); _events.Clear();
_readyToReplay = false; _markPositions.Clear();
_readyToReplay = false;
_ctx = null;
}
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -817,11 +930,11 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_populator.PopulateOutdoorStatics( _populator.PopulateOutdoorStatics(
_stream, cellId, records.Records, records.TupleLandblockId, _stream, cellId, records.Records, records.TupleLandblockId,
_cameraWorldPosition, _viewProjection, _cameraWorldPosition, _viewProjection,
this, _landscapeViewRouteIndex); this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame);
_populator.PopulateCellDynamics( _populator.PopulateCellDynamics(
_stream, cellId, dynamics.Records, dynamics.TupleLandblockId, _stream, cellId, dynamics.Records, dynamics.TupleLandblockId,
_cameraWorldPosition, _viewProjection, _cameraWorldPosition, _viewProjection,
this, _landscapeViewRouteIndex); this, _landscapeViewRouteIndex, _outdoorDrawnThisFrame);
// FW4 (the #132 positional invariant): this cell's emitter owners // FW4 (the #132 positional invariant): this cell's emitter owners
// submit AT THIS TURN, so nearer buildings' pre-punch barriers // submit AT THIS TURN, so nearer buildings' pre-punch barriers
// drain them against still-true depth — see // drain them against still-true depth — see

View file

@ -60,6 +60,14 @@ public sealed class WalkLandscape
public int ViewerCellX; // viewer cell & 7 per axis (SqCoord) public int ViewerCellX; // viewer cell & 7 per axis (SqCoord)
public int ViewerCellY; public int ViewerCellY;
/// <summary>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.</summary>
public float ViewerWorldOriginX;
public float ViewerWorldOriginY;
public int[] BlockDrawList = []; public int[] BlockDrawList = [];
public int BlockDrawCount; public int BlockDrawCount;
@ -154,8 +162,8 @@ public sealed class WalkLandscape
// Seed the west column (grid x = 0) into parity row 0. // Seed the west column (grid x = 0) into parity row 0.
for (int j = 0; j <= MidWidth; j++) for (int j = 0; j <= MidWidth; j++)
WalkVisibilityMath.FillClipHeights( WalkVisibilityMath.FillClipHeights(
(0 - ViewerBlockX) * BlockLength, ViewerWorldOriginX + (0 - ViewerBlockX) * BlockLength,
(j - ViewerBlockY) * BlockLength, ViewerWorldOriginY + (j - ViewerBlockY) * BlockLength,
cyPlane, edgePlanes, intervals[j]); cyPlane, edgePlanes, intervals[j]);
for (int bx = 0; bx < MidWidth; bx++) for (int bx = 0; bx < MidWidth; bx++)
{ {
@ -163,8 +171,8 @@ public sealed class WalkLandscape
int eastRow = ((bx - 1) & 1) * cornerRow; int eastRow = ((bx - 1) & 1) * cornerRow;
for (int j = 0; j <= MidWidth; j++) for (int j = 0; j <= MidWidth; j++)
WalkVisibilityMath.FillClipHeights( WalkVisibilityMath.FillClipHeights(
(bx + 1 - ViewerBlockX) * BlockLength, ViewerWorldOriginX + (bx + 1 - ViewerBlockX) * BlockLength,
(j - ViewerBlockY) * BlockLength, ViewerWorldOriginY + (j - ViewerBlockY) * BlockLength,
cyPlane, edgePlanes, intervals[eastRow + j]); cyPlane, edgePlanes, intervals[eastRow + j]);
for (int by = 0; by < MidWidth; by++) for (int by = 0; by < MidWidth; by++)
{ {
@ -205,8 +213,8 @@ public sealed class WalkLandscape
block.CellInView[i] = WalkBoundingType.EntirelyInside; block.CellInView[i] = WalkBoundingType.EntirelyInside;
return; return;
} }
float x0 = (bx - ViewerBlockX) * BlockLength; float x0 = ViewerWorldOriginX + (bx - ViewerBlockX) * BlockLength;
float y0 = (by - ViewerBlockY) * BlockLength; float y0 = ViewerWorldOriginY + (by - ViewerBlockY) * BlockLength;
int cornerRow = n + 1; int cornerRow = n + 1;
var grid = new float[2 * cornerRow][]; var grid = new float[2 * cornerRow][];
for (int i = 0; i < grid.Length; i++) grid[i] = new float[32]; for (int i = 0; i < grid.Length; i++) grid[i] = new float[32];

View file

@ -111,6 +111,10 @@ public sealed class WalkLandscapeAssembler
int cellIndex = (int)low - 1; int cellIndex = (int)low - 1;
Landscape.ViewerCellX = cellIndex / 8; Landscape.ViewerCellX = cellIndex / 8;
Landscape.ViewerCellY = cellIndex % 8; Landscape.ViewerCellY = cellIndex % 8;
Landscape.ViewerWorldOriginX = DeriveViewerBlockOrigin(
cameraOrigin.X, Landscape.ViewerCellX);
Landscape.ViewerWorldOriginY = DeriveViewerBlockOrigin(
cameraOrigin.Y, Landscape.ViewerCellY);
} }
else else
{ {
@ -118,11 +122,30 @@ public sealed class WalkLandscapeAssembler
// the camera origin, matching the test builder's fallback // the camera origin, matching the test builder's fallback
// (Position::get_outside_cell_id via SmartBox::RenderNormalMode's // (Position::get_outside_cell_id via SmartBox::RenderNormalMode's
// seen_outside arm). // seen_outside arm).
Landscape.ViewerCellX = Math.Clamp((int)MathF.Floor(cameraOrigin.X / 24f), 0, 7); Landscape.ViewerWorldOriginX = MathF.Floor(
Landscape.ViewerCellY = Math.Clamp((int)MathF.Floor(cameraOrigin.Y / 24f), 0, 7); 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);
} }
} }
/// <summary>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.</summary>
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;
}
/// <summary><c>ring &lt;= 1 ? 8 : ring == 2 ? 4 : ring &lt;= 4 ? 2 : 1</c> /// <summary><c>ring &lt;= 1 ? 8 : ring == 2 ? 4 : ring &lt;= 4 ? 2 : 1</c>
/// — the live-observed resolution pyramid (recon 2026-08-30): 8×8 in the /// — 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 34, 1×1 beyond. Buildings only /// 3×3 core, 4×4 at ring 2, 2×2 at rings 34, 1×1 beyond. Buildings only

View file

@ -25,8 +25,9 @@ namespace AcDream.App.Rendering.Walk;
/// true eye ray (near/far unprojection) are observably equivalent to /// true eye ray (near/far unprojection) are observably equivalent to
/// retail's exact construction for this contract. /// retail's exact construction for this contract.
/// ///
/// One instance is a per-frame value (like <c>WalkTraceReplayContext</c>): /// Production retains one instance per renderer and rebinds its frame-local
/// construct fresh each frame with that frame's camera pose. /// camera values through <see cref="Reset"/>. The registries and grow-only
/// active-view scratch remain renderer-lifetime owners.
/// </summary> /// </summary>
public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrameWalkContext public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrameWalkContext
{ {
@ -35,19 +36,28 @@ public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrame
private sealed class InverseViewProjectionRayCaster : IWalkRayCaster private sealed class InverseViewProjectionRayCaster : IWalkRayCaster
{ {
private readonly Matrix4x4 _inverseViewProjection; private Matrix4x4 _inverseViewProjection;
private readonly float _viewportWidth; private float _viewportWidth;
private readonly float _viewportHeight; private float _viewportHeight;
public InverseViewProjectionRayCaster( public InverseViewProjectionRayCaster(
Matrix4x4 viewProjection, float viewportWidth, float viewportHeight) 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( throw new ArgumentException(
"The walk's view-projection matrix must be invertible.", "The walk's view-projection matrix must be invertible.",
nameof(viewProjection)); 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; _viewportWidth = viewportWidth;
_viewportHeight = viewportHeight; _viewportHeight = viewportHeight;
} }
@ -72,8 +82,8 @@ public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrame
private readonly CellVisibility _cells; private readonly CellVisibility _cells;
private readonly WalkBuildingRegistry _buildings; private readonly WalkBuildingRegistry _buildings;
private readonly Matrix4x4 _viewProjection; private Matrix4x4 _viewProjection;
private readonly IWalkRayCaster _rays; private readonly InverseViewProjectionRayCaster _rays;
private Vector2[] _activeViewVerts = new Vector2[32]; private Vector2[] _activeViewVerts = new Vector2[32];
private int _activeViewVertCount; private int _activeViewVertCount;
@ -89,19 +99,39 @@ public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrame
{ {
_cells = cells ?? throw new ArgumentNullException(nameof(cells)); _cells = cells ?? throw new ArgumentNullException(nameof(cells));
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
_rays = new InverseViewProjectionRayCaster(
viewProjection, viewportWidth, viewportHeight);
Reset(worldViewpoint, forward, viewProjection, viewportWidth, viewportHeight);
}
/// <summary>
/// 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.
/// </summary>
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; WorldViewpoint = worldViewpoint;
_viewProjection = viewProjection; _viewProjection = viewProjection;
ViewportWidth = viewportWidth; ViewportWidth = viewportWidth;
ViewportHeight = viewportHeight; ViewportHeight = viewportHeight;
_rays = new InverseViewProjectionRayCaster(viewProjection, viewportWidth, viewportHeight); _activeViewVertCount = 0;
// The retail CY near plane: N = forward, d = -dot(eye, forward) - znear. // The retail CY near plane: N = forward, d = -dot(eye, forward) - znear.
CyPlane = new WalkPlane(forward, -Vector3.Dot(worldViewpoint, forward) - ZNear); CyPlane = new WalkPlane(forward, -Vector3.Dot(worldViewpoint, forward) - ZNear);
} }
public Vector3 WorldViewpoint { get; } public Vector3 WorldViewpoint { get; private set; }
public float ViewportWidth { get; } public float ViewportWidth { get; private set; }
public float ViewportHeight { get; } public float ViewportHeight { get; private set; }
public WalkPlane CyPlane { get; } public WalkPlane CyPlane { get; private set; }
public IWalkRayCaster Rays => _rays; public IWalkRayCaster Rays => _rays;
public IWalkFrameContext CellContext => this; public IWalkFrameContext CellContext => this;

View file

@ -1,6 +1,7 @@
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene;
using AcDream.Core.Physics;
namespace AcDream.App.Rendering.Walk; namespace AcDream.App.Rendering.Walk;
@ -54,6 +55,7 @@ namespace AcDream.App.Rendering.Walk;
internal sealed class WalkProductionWorldData : IWalkFrameWorldData internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{ {
private readonly WalkBuildingRegistry _buildings; private readonly WalkBuildingRegistry _buildings;
private readonly ShadowObjectRegistry _shadows;
private RenderSceneQuery _scene; private RenderSceneQuery _scene;
private uint _tupleLandblockId; private uint _tupleLandblockId;
private int _renderCenterLbX; private int _renderCenterLbX;
@ -77,9 +79,12 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private RenderProjectionRecord[] _arena = new RenderProjectionRecord[4096]; private RenderProjectionRecord[] _arena = new RenderProjectionRecord[4096];
private int _arenaLength; private int _arenaLength;
internal WalkProductionWorldData(WalkBuildingRegistry buildings) internal WalkProductionWorldData(
WalkBuildingRegistry buildings,
ShadowObjectRegistry shadows)
{ {
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
} }
/// <summary>Rebuilds the frame's outdoor/shell buckets and clears the /// <summary>Rebuilds the frame's outdoor/shell buckets and clears the
@ -136,11 +141,12 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
shells.Add(record); shells.Add(record);
continue; continue;
} }
uint cellId = LandscapeCellId( BucketOutdoorRecord(
record.Transform.Position, _renderCenterLbX, _renderCenterLbY); in record,
if (!_outdoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket)) _shadows.GetOwnerCells(record.Source.LocalEntityId),
_outdoorByCell[cellId] = bucket = new List<RenderProjectionRecord>(); _outdoorByCell,
bucket.Add(record); _renderCenterLbX,
_renderCenterLbY);
} }
required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorDynamic); required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorDynamic);
@ -155,18 +161,70 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i]; ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i];
uint cellId = LandscapeCellId( BucketOutdoorRecord(
record.Transform.Position, _renderCenterLbX, _renderCenterLbY); in record,
if (!_outdoorDynamicsByCell.TryGetValue( _shadows.GetOwnerCells(record.Source.LocalEntityId),
cellId, _outdoorDynamicsByCell,
out List<RenderProjectionRecord>? bucket)) _renderCenterLbX,
{ _renderCenterLbY);
_outdoorDynamicsByCell[cellId] = bucket = new List<RenderProjectionRecord>();
}
bucket.Add(record);
} }
} }
/// <summary>
/// Installs one outdoor object's render shadow in every outdoor cell of
/// its authoritative physics <c>CELLARRAY</c>. This is retail's
/// <c>CPhysicsObj::add_shadows_to_cells</c> →
/// <c>CPartArray::AddPartsShadow</c> 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.
/// </summary>
internal static void BucketOutdoorRecord(
in RenderProjectionRecord record,
IReadOnlyList<uint> shadowCells,
Dictionary<uint, List<RenderProjectionRecord>> 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<uint, List<RenderProjectionRecord>> buckets)
{
if (!buckets.TryGetValue(
cellId,
out List<RenderProjectionRecord>? bucket))
{
buckets[cellId] = bucket = new List<RenderProjectionRecord>();
}
bucket.Add(record);
}
/// <summary>The landscape cell owning a RENDER-ORIGIN-RELATIVE position /// <summary>The landscape cell owning a RENDER-ORIGIN-RELATIVE position
/// — retail's 24 m cell grid inside the 192 m landblock, producing the /// — retail's 24 m cell grid inside the 192 m landblock, producing the
/// same TRUE <c>(lb &amp; 0xFFFF0000) | (cellX*8 + cellY + 1)</c> encoding /// same TRUE <c>(lb &amp; 0xFFFF0000) | (cellX*8 + cellY + 1)</c> encoding

View file

@ -97,11 +97,14 @@ internal sealed class WalkStaticStreamPopulator
Vector3 cameraWorldPosition, Vector3 cameraWorldPosition,
Matrix4x4 viewProjection, Matrix4x4 viewProjection,
IWalkLookInViewSource? views = null, IWalkLookInViewSource? views = null,
int viewRouteIndex = -1) int viewRouteIndex = -1,
ISet<RenderProjectionId>? drawnOnce = null)
{ {
ArgumentNullException.ThrowIfNull(stream); ArgumentNullException.ThrowIfNull(stream);
for (int i = 0; i < records.Length; i++) for (int i = 0; i < records.Length; i++)
{ {
if (drawnOnce is not null && !drawnOnce.Add(records[i].Id))
continue;
ClassifyAndAppend( ClassifyAndAppend(
stream, WalkDrawStage.OutdoorStatic, cellId, in records[i], stream, WalkDrawStage.OutdoorStatic, cellId, in records[i],
tupleLandblockId, cameraWorldPosition, viewProjection, tupleLandblockId, cameraWorldPosition, viewProjection,
@ -117,11 +120,14 @@ internal sealed class WalkStaticStreamPopulator
Vector3 cameraWorldPosition, Vector3 cameraWorldPosition,
Matrix4x4 viewProjection, Matrix4x4 viewProjection,
IWalkLookInViewSource? lookInViews = null, IWalkLookInViewSource? lookInViews = null,
int lookInRouteIndex = -1) int lookInRouteIndex = -1,
ISet<RenderProjectionId>? drawnOnce = null)
{ {
ArgumentNullException.ThrowIfNull(stream); ArgumentNullException.ThrowIfNull(stream);
for (int i = 0; i < records.Length; i++) for (int i = 0; i < records.Length; i++)
{ {
if (drawnOnce is not null && !drawnOnce.Add(records[i].Id))
continue;
ClassifyAndAppend( ClassifyAndAppend(
stream, stream,
WalkDrawStage.Dynamic, WalkDrawStage.Dynamic,

View file

@ -193,6 +193,20 @@ public sealed partial class WbDrawDispatcher
ArgumentNullException.ThrowIfNull(batches); ArgumentNullException.ThrowIfNull(batches);
ArgumentNullException.ThrowIfNull(selectionParts); 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 entity =
RenderInstanceCandidate.FromProjection( RenderInstanceCandidate.FromProjection(
in projection, in projection,

View file

@ -2146,6 +2146,22 @@ public sealed class ShadowObjectRegistry
return System.Array.Empty<ShadowEntry>(); return System.Array.Empty<ShadowEntry>();
} }
/// <summary>
/// The exact ordered <c>CELLARRAY</c> membership retained for one physics
/// object. Retail consumes this same membership twice: collision walks
/// each cell's <c>shadow_object_list</c>, while rendering installs the
/// object's parts in every member cell through
/// <c>CPhysicsObj::add_shadows_to_cells</c> / <c>CPartArray::AddPartsShadow</c>
/// (0x00514AE0). The returned view is borrowed and remains valid only
/// until the next shadow-registry mutation.
/// </summary>
public IReadOnlyList<uint> GetOwnerCells(uint entityId)
{
if (_entityToCells.TryGetValue(entityId, out List<uint>? cells))
return cells;
return System.Array.Empty<uint>();
}
public int TotalRegistered => _entityToCells.Count; public int TotalRegistered => _entityToCells.Count;
/// <summary> /// <summary>

View file

@ -606,6 +606,57 @@ public sealed class WalkFrameDriverTests
Assert.Equal(new[] { "SKY", "TERRAIN:0" }, log); Assert.Equal(new[] { "SKY", "TERRAIN:0" }, log);
} }
[Fact]
public void RebindFrame_ReusesTheDriverAndRoutesTheNextFrameToTheNewLeaf()
{
using var fx = new DispatcherFixture();
var firstLog = new List<string>();
var secondLog = new List<string>();
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<string>();
var ctx = new TestContext();
var driver = new WalkFrameDriver(
fx.Dispatcher, new RecordingLeafRenderer(new List<string>()), 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 // ── Deliverable: an outdoor landscape-cell turn with no building appends
// straight to the stream (no shell call — outdoor cells have no EnvCell // straight to the stream (no shell call — outdoor cells have no EnvCell
// shell), and the accumulated content flushes at frame end. ─────────── // shell), and the accumulated content flushes at frame end. ───────────
@ -650,12 +701,67 @@ public sealed class WalkFrameDriverTests
Assert.Equal( Assert.Equal(
new[] { "FLUSH:2:OutdoorStatic,Dynamic", "PARTICLES:12d,12e" }, new[] { "FLUSH:2:OutdoorStatic,Dynamic", "PARTICLES:12d,12e" },
log); log);
var visibleCells = new HashSet<uint> { 0xDEAD_BEEFu };
driver.CopyVisibleCellsTo(visibleCells);
Assert.Equal(new[] { 0x8C040005u }, visibleCells);
GpuRecordedMultiDrawIndirect[] mdi = GpuRecordedMultiDrawIndirect[] mdi =
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().ToArray(); fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().ToArray();
Assert.Equal(2, mdi.Length); Assert.Equal(2, mdi.Length);
Assert.Equal(2u, mdi.Aggregate(0u, static (sum, call) => sum + call.DrawCount)); 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<string>();
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<GpuRecordedMultiDrawIndirect>().ToArray();
Assert.Single(mdi);
Assert.Equal(1u, mdi[0].DrawCount);
}
// ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture — // ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture —
// FW3.2a's own referee) ───────────────────────────────────────────────── // FW3.2a's own referee) ─────────────────────────────────────────────────

View file

@ -1,4 +1,5 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Walk; using AcDream.App.Rendering.Walk;
namespace AcDream.App.Tests.Rendering.Walk; namespace AcDream.App.Tests.Rendering.Walk;
@ -12,6 +13,15 @@ namespace AcDream.App.Tests.Rendering.Walk;
/// SetViewer exactly once per fixture).</summary> /// SetViewer exactly once per fixture).</summary>
public sealed class WalkLandscapeAssemblerTests public sealed class WalkLandscapeAssemblerTests
{ {
private sealed class LandscapeCellRecorder : IWalkEventSink
{
public HashSet<uint> 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 LandblockId = 0xA9B4FFFFu; // block (0xA9, 0xB4)
private const uint CameraCellId = 0xA9B40001u; // same block, outdoor landcell 1 private const uint CameraCellId = 0xA9B40001u; // same block, outdoor landcell 1
@ -123,6 +133,19 @@ public sealed class WalkLandscapeAssemblerTests
Assert.Equal(1, assembler.Landscape.ViewerCellY); 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] [Fact]
public void SetViewer_InteriorCameraDerivesViewerCellFromOrigin() public void SetViewer_InteriorCameraDerivesViewerCellFromOrigin()
{ {
@ -132,6 +155,21 @@ public sealed class WalkLandscapeAssemblerTests
Assert.Equal(2, assembler.Landscape.ViewerCellX); // floor(50 / 24) = 2 Assert.Equal(2, assembler.Landscape.ViewerCellX); // floor(50 / 24) = 2
Assert.Equal(3, assembler.Landscape.ViewerCellY); // floor(74 / 24) = 3 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] [Fact]
@ -145,4 +183,53 @@ public sealed class WalkLandscapeAssemblerTests
Assert.NotNull(assembler.Landscape.Blocks[CenterIndex()]); 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<WalkBuildingFactory.Entry>());
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));
}
} }

View file

@ -128,4 +128,49 @@ public sealed class WalkProductionFrameContextTests
new CellVisibility(), new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY, new CellVisibility(), new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY,
default, 1024f, 768f)); 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<ArgumentException>(() =>
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));
}
} }

View file

@ -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<uint, List<RenderProjectionRecord>>();
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<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketOutdoorRecord(
in record,
Array.Empty<uint>(),
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 },
};
}

View file

@ -190,6 +190,51 @@ public sealed class WalkStaticStreamPopulatorTests
Assert.Equal((uint)alphaGfxObj, selectionParts[1].GfxObjId); 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<WbDrawDispatcher.WalkClassifiedBatch>();
var selectionParts =
new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
fx.Dispatcher.ClassifyEntityForWalk(
in projectile,
tupleLandblockId: 0x8C04u,
batches,
selectionParts,
liveDynamic: true);
Assert.Empty(batches);
Assert.Empty(selectionParts);
}
[Fact] [Fact]
public void ClassifyEntityForWalk_SetupComposite_EncodesPartAndSetupPartIndexLikePackedRoute() public void ClassifyEntityForWalk_SetupComposite_EncodesPartAndSetupPartIndexLikePackedRoute()
{ {