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