fix(render): restore retail per-cell alpha order

Reconstruct one combined static/dynamic object-part stream for each ordinary outdoor or interior cell, compute authored SortCenter CYpt keys, and stable-sort far to near before projecting opaque and delayed subsets. Prepare real cell-particle records at the leaf, preserve every S4-c2 router outcome, and merge object and particle delayed records by retained key before either source appends to the unchanged CLIP/ALPHA FIFO lists. Cell turns remain cell-major; equal cross-source ties are deterministically object-first.

File AP-241 and AP-242 for the remaining separate opaque/row-5 channels and unrepresented equal-key common ordinal. File AP-243 for the paired-binary correction: retail shares the cell CYpt/heading beyond 50 m while this bounded port always uses the more exact authored per-part center. Pin 162 active AP rows and correct world-alpha and AlphaFlushCounts prose.

Lead-approved scope clarification: RetailPViewPassExecutor.WalkLeaf.cs and RetailPViewPassExecutor.cs are the minimum existing production leaf adapter and thin particle-prepare forwarder omitted by the literal Walk/Wb file list. They contain no router, queue, mask, state, depth, or flush behavior; relocating them would create an artificial seam.

Gates: Release solution build 0W/0E; shader/manifest 32/32; focused production 210/210; real allocation 3/3 at 0 B; one-shot hermetic 16743/0/0 across 14 assemblies; InstalledDat 385 pass/10 documented fail/1 skip with all six AlphaFlushSites passing; git diff --check PASS. Initial no-restore solution build failed NETSDK1004 for 42 missing scratch assets; one solution restore preceded the official build.

Mutation proof, each restored before final gates:

1. Reverse comparator: authored-center order expected [202,101], actual [101,202].

2. Move ties left: multipart/subset order expected [11,12,21,22], actual [22,21,12,11].

3. Restore static/dynamic blocks: expected [2,3,1], actual [3,1,2].

4. Use entity origin: authored-center order expected [202,101], actual [101,202].

5. Restore particle tail: expected [Wb,Particle,Wb,Particle], actual [Wb,Wb,Particle,Particle].

6. Scope-global sort: first cell model X expected 5, actual 50.

7. Restore dead camera parameter: SubmitWalkAlphaInstance parameter count expected 2, actual 3.

8. Restore stale global-queue prose: exact Assert.DoesNotContain failure on distance-sorts one shared queue.

9. Remove AP-241 identity: Assert.Single found no matching row.

10. Allocate in real merge: expected 0 B, actual 3072 B.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-04 13:18:54 +02:00
parent 06b986622b
commit a86ec73ece
17 changed files with 1067 additions and 197 deletions

View file

@ -1135,8 +1135,11 @@ explicit Campaign OVERHAUL divergences, not accepted retail contracts:
retail's `DrawLandCell` then `DrawSortCell` interleave for each land cell; retail's `DrawLandCell` then `DrawSortCell` interleave for each land cell;
- depth clear/seal events are emitted for zero-outside-view roots and do not - depth clear/seal events are emitted for zero-outside-view roots and do not
consume retail's persistent previous-call `uint16 portalsDrawnCount` gate; consume retail's persistent previous-call `uint16 portalsDrawnCount` gate;
- `RetailAlphaQueue` distance-sorts one shared queue, whereas retail owns two - ordinary object and particle parts now receive retail's stable per-cell
FIFO mesh lists and drains clip before alpha at exact barriers. authored-CYpt order before entering the two FIFO mesh lists, which drain
CLIP before ALPHA at exact barriers. The retained differences are AP-241
(opaque/row-5 GPU channels), AP-242 (equal cross-source tie ordinal), and
AP-243 (retail's >50 m shared-cell-key optimization).
The exact target and evidence are recorded in The exact target and evidence are recorded in
`docs/research/2026-09-01-overhaul/oh1-retail-world-contract.md`; OH5, OH6, and `docs/research/2026-09-01-overhaul/oh1-retail-world-contract.md`; OH5, OH6, and

File diff suppressed because one or more lines are too long

View file

@ -400,11 +400,14 @@ renderers classify translucent mesh batches correctly, but they have no live
retail `CPartCell`/`CShadowPart` list and render particles in a separate retail `CPartCell`/`CShadowPart` list and render particles in a separate
batcher. `src/AcDream.App/Rendering/RetailAlphaQueue.cs` is therefore an batcher. `src/AcDream.App/Rendering/RetailAlphaQueue.cs` is therefore an
acdream-owned runtime seam above the extracted mesh pipeline. During the main acdream-owned runtime seam above the extracted mesh pipeline. During the main
world frame, `WbDrawDispatcher` and `ParticleRenderer` submit transparent world frame, `WalkFrameDriver` reconstructs retail's stable far-to-near order
GfxObj subsets and scene particles into one stable far-to-near stream keyed by inside each object-cell turn from transformed authored DAT `SortCenter`, merges
the transformed DAT `SortCenter`; only adjacent compatible entries may batch. the cell's `WbDrawDispatcher` GfxObj subsets with prepared `ParticleRenderer`
records, then appends the result to retail's two FIFO lists (CLIP/ALPHA).
It never performs one scope-global distance sort; only adjacent compatible
entries may batch at drain.
Billboard particle textures are resident bindless `sampler2DArray` handles in Billboard particle textures are resident bindless `sampler2DArray` handles in
the per-instance vertex ABI, so different textures preserve that sorted order the per-instance vertex ABI, so different textures preserve that per-cell order
inside one instanced draw; only a DAT blend-mode boundary splits the run. This inside one instanced draw; only a DAT blend-mode boundary splits the run. This
keeps dense particle fields from becoming one Vulkan draw per alternating keeps dense particle fields from becoming one Vulkan draw per alternating
texture. `WalkFrameDriver` drains the landscape and world scopes at the exact texture. `WalkFrameDriver` drains the landscape and world scopes at the exact

View file

@ -1349,72 +1349,86 @@ register count, allowed files, and every gate claim. A failed lens gets one
bounded fix contract; a third fix round stops and is written up. Dual PASS bounded fix contract; a third fix round stops and is written up. Dual PASS
lands c3a; the lead then writes c3b's deletion-only contract from that code. lands c3a; the lead then writes c3b's deletion-only contract from that code.
## 19. S4-c3a stop record — owner attempts 35 exhausted (2026-09-04) ### 18.7 Implementation result (scratch implementation, 2026-09-04)
**STOPPED UNLANDED.** The clean scratch worktree The bounded c3a implementation now builds one combined registry view for each
`C:\Users\erikn\source\repos\acdream\.claude\worktrees\s4-c3a-impl` ordinary outdoor/interior object-cell turn, transforms each GfxObj's authored
ends at `359061b8299dd77a2335935868b739b8295b9b0c`. Nothing from c3a was `SortCenter`, applies a stable descending per-cell insertion sort, and derives
cherry-picked to the campaign branch; the campaign renderer remains the both opaque/cutout commands and delayed object records from that same order.
reviewed S4-c2 stack through `252886e84`, with this §18 contract at The real particle leaf prepares but does not expose delayed records; the cell
`5c9da6fc2`. c3b is locked and G3/G4 remain unpassed. event merges their retained keys with the object's range before either source
appends to the unchanged CLIP/ALPHA FIFO lists. Row-5 immediate routing,
capacity-drop/source rollback, barriers, and building-shell ownership are
unchanged. Exact equal object/particle keys use the disclosed object-first tie.
The complete unlanded scratch stack is: The paired-binary correction is recorded as AP-243: retail's
`UpdateObjCell` greater-than-50 m branch at `0x005A06B7..0x005A0720` gives
all parts one cell CYpt/heading through `CPhysicsObj::UpdateViewerDistance`
and therefore retains insertion order, while this bounded port always uses
the more exact per-part authored center. AP-241/AP-242 record the two other
narrow residuals. The active AP header and physical table are pinned at 162
rows, with exactly one row for each AP-241/AP-242/AP-243.
1. `44e2bc227b` — initial c3a implementation; Lead-approved process-scope clarification: the real-path integration also
2. `b6bf6c131` — lead fix-round-1 contract; changes exactly `src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs`
3. `eea5793d2` — fix round 1; and `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs`. The literal
4. `14397b14c` — lead fix-round-2 contract, explicitly the last round; `{Walk,Wb}/` production-file list omitted the existing leaf adapter and the
5. `359061b82` — fix round 2. thin `PrepareCellParticleAlpha` forwarder required to reach the expressly
allowed `ParticleRenderer`; relocating that bridge would create an artificial
seam. These two files contain only the interface adaptation and forwarder—no
router, queue, mask, pipeline, reference, depth, or flush behavior. The lead
approved this minimum real-path reachability delta before commit. It is a
contract-scope clarification, not a retail behavior deviation, so it files no
additional AP row.
Attempt 3 failed the retail lens because billboard/mode-25 particles keyed Clean-state return matrix (no graphical client was launched):
CYpt from their rendered/AABB center instead of the GfxObj-authored
`sort_center`; the same lens found one stale static-record-order comment.
Fix round 1 separated authored and visual centers, applied particle scale and
orientation to the authored point, made prepared payload reservation lazy,
rolled back false/exception append exactly once, bounded preparation to 3,000
CLIP plus 3,000 ALPHA candidates, and corrected the prose.
Attempt 4 failed the retail re-review on one universal edge: a successfully - Initial solution `--no-restore` build was invalid setup evidence: 42
decoded real GfxObj with no Surface DID was replaced by projects lacked `project.assets.json` (`NETSDK1004`). One solution restore
`ParticleGfxInfo.Default`, discarding its authored center. The installed scan followed; the official Release solution build then succeeded with 0
found zero practical exposure (2,051 emitter records / 360 referenced GfxObjs, warnings and 0 errors.
all with a nonzero first Surface DID), but retail's CYpt read is surface- - Shader descriptor/manifest/SPIR-V validation: 32/32. No shader source,
independent. Fix round 2 preserved the decoded record and made only texture checked-in SPIR-V, manifest, Content model, serializer, or package format
acquisition conditional; it also corrected the synthetic-fixture evidence changed.
wording. - Focused queue/router/particle/Wb/walk/EnvCell/PView production filter:
210/210. The three real warmed routes (EnvCell whole-leaf SubmitRhi,
immediate mesh particle RHI, and the new driver/particle/dispatcher merge)
pass 3/3 and each asserts 0 managed bytes.
- Official one-shot 14-assembly hermetic gate: 16,743 passed / 0 skipped /
0 failed. Evidence:
`%TEMP%/acdream-s4-c3a-impl-20260904-2015`.
- InstalledDat with explicit `C:\Turbine\Asheron's Call`: 385 passed / 10
failed / 1 skipped. The failures are exactly TowerAscent, the two #383
layout sweeps, #458 `Oh_doorway_still_first_frame_diff`, and the six
retained `AlphaFlushCounts_*`; all six paired `AlphaFlushSites_*` pass.
Evidence: `%TEMP%/acdream-s4-c3a-installed-20260904-2017`.
- `git diff --check`: pass.
Attempt 5's final retail lens **PASSED**. It reconfirmed the paired executable Observed mutation first failures (every mutation was reversed with a patch
and PDB, `CPhysicsPart::UpdateViewerDistance @0x0050E030`, stable descending before the return matrix):
per-cell order, combined static/dynamic membership, the real object/particle
merge, building separation, unchanged two-list FIFO/router/row-5 behavior,
and AP-241/AP-242/AP-243 truth/count in the scratch stack. Its focused
reproduction passed 117/117.
The final production/gate-honesty lens found no production, lifecycle, 1. Reverse the CYpt comparator: `PopulateCellObjects_UsesAuthoredSortCenterAndStableFarToNearOrder`
allocation, scope, register, or retained-memory defect. It nevertheless expected `[202,101]`, actual `[101,202]` at the first sequence assertion.
returned **FAIL** on one mandatory evidence error. The initial candidate's 2. Move equal keys left (`>=`): `PopulateCellObjects_EqualCyptRetainsEntityPartAndSubsetOrder`
mutation ledger says renaming AP-241 first fails `Assert.Single`. The unchanged expected `[11,12,21,22]`, actual `[22,21,12,11]`.
pin actually computes `rowIndex == -1` and first fails the earlier AP-section 3. Restore static/dynamic blocks: `PopulateCellObjects_InterleavesStaticAndDynamicOpaquePartsByCypt`
boundary `Assert.True` in `WalkStaticStreamPopulatorTests.cs`; `Assert.Single` expected `[2,3,1]`, actual `[3,1,2]`.
is reached later. The other eighteen mutation claims match their actual first 4. Replace authored SortCenter with entity translation: the authored-center
assertions. Correcting that sentence would be a third c3a fix round, forbidden test first expected `[202,101]`, actual `[101,202]`.
by plan §5, packet §18.6, and the owner's explicit attempt limit. Therefore no 5. Restore the old object-then-particle tail: `CellTurn_RealParticlePreparationMergesWithObjectAlphaByCypt`
evidence-only correction, landing, graphical gate, or c3b contract follows. expected source order `[Wb,Particle,Wb,Particle]`, actual
`[Wb,Wb,Particle,Particle]` at position 1.
Final read-only evidence on `359061b82`: 6. Sort all staged objects scope-globally before replay:
`SeparateCellTurnsRemainCellMajorWhenLaterCellIsFarther` first expected
- Release solution: 0 warnings / 0 errors; model X `5`, actual `50`.
- focused §19 lane: 95/95; no-surface target: 1/1; final retail superset: 7. Restore a dead optional camera parameter on `SubmitWalkAlphaInstance`:
117/117; `WorldAlphaCyptContract_RetainsTheKeyWithoutDeadCameraSubmitThreading`
- AP boundary/count pin: 1/1; physical and unique AP counts: 162; first expected parameter count 2, actual 3.
- real allocation pins: 2/2 at 0 managed bytes; 8. Reinsert “distance-sorts one shared queue” into the inventory:
- shader/manifest/SPIR-V: 32/32; `WorldAlphaCyptDocumentationAndRegister_PinPerCellTruthAndThreeResiduals`
- one-shot hermetic artifact: 16,751 passed / 0 skipped / 0 failed at first failed `Assert.DoesNotContain` on that exact phrase.
`%TEMP%\acdream-s4-c3a-fix1-20260904-2215`; 9. Rename AP-241 away: that same truth pin first failed `Assert.Single`
- inclusive InstalledDat artifact: 385 passed / 10 documented failures / 1 because no AP-241 row matched.
skip at `%TEMP%\acdream-s4-c3a-fix1-installed-inclusive-20260904-2222`; 10. Allocate one object in the real cell-merge path:
- `git diff --check`: PASS; scratch HEAD clean; no graphical client launched. `ProductionCellObjectParticleMerge_WarmedPathAllocatesZeroBytes` first
expected 0, actual 3,072 managed bytes.
Resume only after an explicit owner process change. The minimum mechanical
correction is known, but the stop rule exists precisely to prevent a green
renderer from bypassing a failed evidence contract.

View file

@ -18,8 +18,10 @@ namespace AcDream.App.Rendering;
/// <summary> /// <summary>
/// Instanced renderer for retail particle emitters. Scene particles submit to /// Instanced renderer for retail particle emitters. Scene particles submit to
/// <see cref="RetailAlphaQueue"/> while a world frame is active so their /// <see cref="RetailAlphaQueue"/> while a world frame is active so their
/// compositing order is shared with ordinary translucent GfxObj parts. Sky and /// compositing order is merged by authored CYpt with ordinary translucent
/// sealed off-screen passes retain their independent immediate path. /// GfxObj parts at each owning cell turn before either source enters the two
/// FIFO lists. Sky and sealed off-screen passes retain their independent
/// immediate path.
/// </summary> /// </summary>
public sealed unsafe partial class ParticleRenderer : IDisposable public sealed unsafe partial class ParticleRenderer : IDisposable
{ {
@ -163,6 +165,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
private readonly List<MeshParticleDraw> _meshDrawListScratch = new(64); private readonly List<MeshParticleDraw> _meshDrawListScratch = new(64);
private readonly List<MeshParticleInstance> _meshRunScratch = new(64); private readonly List<MeshParticleInstance> _meshRunScratch = new(64);
private readonly List<ParticleSubmission> _submissionScratch = new(128); private readonly List<ParticleSubmission> _submissionScratch = new(128);
private readonly List<PreparedParticleAlphaSubmission> _preparedCellAlphaScratch = new(128);
private readonly List<RuntimeParticleEmitter> _scopedEmitterScratch = new(64); private readonly List<RuntimeParticleEmitter> _scopedEmitterScratch = new(64);
private readonly List<DeferredParticleDraw> _deferredAlpha = new(128); private readonly List<DeferredParticleDraw> _deferredAlpha = new(128);
private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256]; private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256];
@ -315,6 +318,105 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
FinishDraw(camera, renderPass); FinishDraw(camera, renderPass);
} }
/// <summary>
/// S4-c3a production cell-shadow seam. Builds the same real billboard and
/// mesh records as <see cref="DrawForCell"/>, orders particle parts by
/// their authored CYpt key, executes row-5 immediate draws at this exact
/// cell turn, and returns only delayed records without appending them.
/// The walk driver merges those retained keys with ordinary object parts
/// before calling <see cref="PreparedParticleAlphaSubmission.Append"/>.
/// </summary>
internal ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareForCellAlpha(
ICamera camera,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass,
uint cellId,
uint clipSlot = 0)
{
_preparedCellAlphaScratch.Clear();
if (camera is null)
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
_particles.CopyRenderableEmittersInCell(renderPass, cellId, _scopedEmitterScratch);
if (_scopedEmitterScratch.Count == 0)
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
BuildDrawLists(
cameraWorldPos,
renderPass,
cameraRight,
cameraUp,
emitterFilter: null,
_scopedEmitterScratch,
clipSlot);
if (_submissionScratch.Count == 0)
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
bool defers = renderPass == ParticleRenderPass.Scene && _alphaQueue?.IsCollecting == true;
if (!defers)
{
DrawOrdered(camera);
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
}
ParticleSubmissionOrdering.Sort(_submissionScratch);
Matrix4x4 viewProjection = camera.View * camera.Projection;
RetailAlphaQueue queue = _alphaQueue!;
for (int i = 0; i < _submissionScratch.Count; i++)
{
ParticleSubmission submission = _submissionScratch[i];
TranslucencyKind translucency = submission.Kind == ParticleSubmissionKind.Mesh
? _meshDrawListScratch[submission.DrawIndex].Batch.Translucency
: default;
uint colorArgb = submission.Kind == ParticleSubmissionKind.Mesh
? _meshDrawListScratch[submission.DrawIndex].Instance.ColorArgb
: default;
RetailAlphaMeshDecision decision = RouteParticleSubmission(
submission.Kind, translucency, colorArgb);
if (decision.Action is RetailAlphaMeshAction.Append
or RetailAlphaMeshAction.AppendClipAndImmediate)
{
_dispatchDeferredParticle = submission.Kind == ParticleSubmissionKind.Billboard
? new DeferredParticleDraw(
submission.Kind,
_drawListScratch[submission.DrawIndex],
default,
viewProjection)
: new DeferredParticleDraw(
submission.Kind,
default,
_meshDrawListScratch[submission.DrawIndex],
viewProjection);
int token = ReserveDispatchDeferredParticle();
_preparedCellAlphaScratch.Add(new PreparedParticleAlphaSubmission(
queue,
decision.List,
_alphaSource,
token,
decision.OverrideClipmap,
submission.DistanceSq,
submission.Sequence));
}
if (decision.Action is RetailAlphaMeshAction.Immediate
or RetailAlphaMeshAction.AppendClipAndImmediate)
{
_drawImmediateParticle(
viewProjection,
submission.Kind,
submission.DrawIndex,
opaqueDepthState: decision.Action == RetailAlphaMeshAction.Immediate);
}
}
return CollectionsMarshal.AsSpan(_preparedCellAlphaScratch);
}
private void FinishDraw(ICamera camera, ParticleRenderPass renderPass) private void FinishDraw(ICamera camera, ParticleRenderPass renderPass)
{ {
if (_submissionScratch.Count == 0) if (_submissionScratch.Count == 0)
@ -738,11 +840,10 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Matrix4x4 model = Matrix4x4.CreateScale(particle.Size) Matrix4x4 model = Matrix4x4.CreateScale(particle.Size)
* Matrix4x4.CreateFromQuaternion(orientation) * Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(particle.Position); * Matrix4x4.CreateTranslation(particle.Position);
// S4-c2: RetailAlphaOrdering.ComputeViewerDistance is deleted (the // The queue remains FIFO. S4-c3a retains this authored part-center
// alpha queue is FIFO now), but this distanceSq is still needed by // distance for the owning cell's object/particle pre-append merge;
// the UNRELATED non-deferred immediate-draw path's own local sort // the unrelated non-deferred immediate path uses the same key for its
// (ParticleSubmissionOrdering.Sort, ParticleRenderer.Rhi.cs) — // own local ParticleSubmissionOrdering.Sort.
// inlined here rather than resurrecting the deleted helper.
Vector3 worldSortCenter = Vector3.Transform(renderData.SortCenter, model); Vector3 worldSortCenter = Vector3.Transform(renderData.SortCenter, model);
float distanceSq = Vector3.DistanceSquared(worldSortCenter, cameraWorldPosition); float distanceSq = Vector3.DistanceSquared(worldSortCenter, cameraWorldPosition);
var instance = new MeshParticleInstance( var instance = new MeshParticleInstance(

View file

@ -16,6 +16,27 @@ internal readonly record struct ParticleSubmission(
float DistanceSq, float DistanceSq,
int Sequence); int Sequence);
/// <summary>
/// One scene-particle alpha record prepared at its owning cell turn but not
/// yet appended. S4-c3a lets <c>WalkFrameDriver</c> merge this retained CYpt
/// key with ordinary object parts before either source enters retail's FIFO
/// list. The source payload is reserved during preparation; <see
/// cref="RetailAlphaQueue.TryAppend"/> remains the sole visibility edge and
/// still owns capacity-drop cleanup by registering the source before a drop.
/// </summary>
internal readonly record struct PreparedParticleAlphaSubmission(
RetailAlphaQueue Queue,
RetailAlphaList List,
IRetailAlphaDrawSource Source,
int Token,
bool OverrideClipmap,
float DistanceSq,
int Sequence)
{
internal void Append() =>
Queue.TryAppend(List, Source, Token, OverrideClipmap);
}
/// <summary> /// <summary>
/// Shared ordering for the two retail particle geometry paths. Transparent /// Shared ordering for the two retail particle geometry paths. Transparent
/// particles are submitted back-to-front; creation/enumeration order is the /// particles are submitted back-to-front; creation/enumeration order is the

View file

@ -89,9 +89,9 @@ internal sealed partial class RetailPViewPassExecutor
/// <summary>S3 chunk 3 fix round 1 (F2): the production /// <summary>S3 chunk 3 fix round 1 (F2): the production
/// <c>IWalkFrameLeafRenderer.HasRenderableEmittersInCell</c> wiring — /// <c>IWalkFrameLeafRenderer.HasRenderableEmittersInCell</c> wiring —
/// both <see cref="DrawLandscapeStaticParticles"/> and /// both walk particle-prepare leaves use
/// <see cref="DrawCellParticles"/> draw through /// <c>ParticleRenderPass.Scene</c>, so this asks that SAME pass before
/// <c>ParticleRenderPass.Scene</c>, so this asks that SAME pass.</summary> /// either leaf constructs its real delayed records.</summary>
internal bool HasWalkRenderableEmittersInCell(uint cellId) => internal bool HasWalkRenderableEmittersInCell(uint cellId) =>
_particles?.HasRenderableEmittersInCell(ParticleRenderPass.Scene, cellId) ?? false; _particles?.HasRenderableEmittersInCell(ParticleRenderPass.Scene, cellId) ?? false;
@ -271,11 +271,11 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
public void ClearInteriorDepth() => _clearInteriorDepth(); public void ClearInteriorDepth() => _clearInteriorDepth();
public void DrawStaticParticles(uint cellId) => public ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareStaticParticles(uint cellId) =>
_passes.DrawLandscapeStaticParticles(_frame, cellId); _passes.PrepareCellParticleAlpha(_frame, cellId);
public void DrawCellParticles(uint cellId) => public ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareCellParticles(uint cellId) =>
_passes.DrawCellParticles(_frame, cellId); _passes.PrepareCellParticleAlpha(_frame, cellId);
public int DrawExitSeals() => _drawExitSeals(); public int DrawExitSeals() => _drawExitSeals();

View file

@ -555,6 +555,21 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
} }
} }
internal ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareCellParticleAlpha(
RetailPViewFrameInput frame,
uint cellId)
{
if (_particles is null || _particleRenderer is null)
return ReadOnlySpan<PreparedParticleAlphaSubmission>.Empty;
return _particleRenderer.PrepareForCellAlpha(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
cellId,
clipSlot: 0);
}
public void ClearInteriorDepth() public void ClearInteriorDepth()
{ {
_surface.ClearInteriorDepth(); _surface.ClearInteriorDepth();

View file

@ -42,6 +42,15 @@ internal readonly record struct WalkFrameStaticRecords(
/// </summary> /// </summary>
internal interface IWalkFrameWorldData internal interface IWalkFrameWorldData
{ {
/// <summary>The exact combined ordinary-object membership for one indoor
/// cell, preserving the registry's single static/dynamic insertion
/// sequence for S4-c3a's per-part stable CYpt sort.</summary>
WalkFrameStaticRecords GetCellObjects(uint cellId);
/// <summary>The exact combined ordinary-object membership for one
/// landscape cell; see <see cref="GetCellObjects"/>.</summary>
WalkFrameStaticRecords GetOutdoorObjects(uint cellId);
/// <summary>An indoor <c>PView::DrawCells</c> flood cell's (or a building /// <summary>An indoor <c>PView::DrawCells</c> flood cell's (or a building
/// look-in's) static content — <c>RenderProjectionClass.IndoorCellStatic</c>.</summary> /// look-in's) static content — <c>RenderProjectionClass.IndoorCellStatic</c>.</summary>
WalkFrameStaticRecords GetCellStatics(uint cellId); WalkFrameStaticRecords GetCellStatics(uint cellId);
@ -113,8 +122,8 @@ internal interface IWalkFrameLeafRenderer
/// pipeline-state citation. A <see cref="WalkFrameEventKind.StreamMark"/> /// pipeline-state citation. A <see cref="WalkFrameEventKind.StreamMark"/>
/// is NOT one of them (F10: the ordered stream it draws is opaque-only, /// is NOT one of them (F10: the ordered stream it draws is opaque-only,
/// depth test AND write on, no blending); the F2 particle-turn exception /// depth test AND write on, no blending); the F2 particle-turn exception
/// still holds (a genuinely empty <see cref="DrawStaticParticles"/>/<see /// still holds (a genuinely empty <see cref="PrepareStaticParticles"/>/<see
/// cref="DrawCellParticles"/> turn neither submits nor flushes). This is /// cref="PrepareCellParticles"/> turn neither submits nor flushes). This is
/// order-preserving by construction: the batch's GPU submission point is /// order-preserving by construction: the batch's GPU submission point is
/// always the SAME point the unbatched terrain draws would have occupied /// always the SAME point the unbatched terrain draws would have occupied
/// (immediately before the next real submission), so pixel output is /// (immediately before the next real submission), so pixel output is
@ -133,10 +142,10 @@ internal interface IWalkFrameLeafRenderer
/// name="cellId"/> has any renderable emitter right now — production /// name="cellId"/> has any renderable emitter right now — production
/// wires this to <c>ParticleSystem.HasRenderableEmittersInCell</c> with /// wires this to <c>ParticleSystem.HasRenderableEmittersInCell</c> with
/// the SAME <c>ParticleRenderPass.Scene</c> pass both <see /// the SAME <c>ParticleRenderPass.Scene</c> pass both <see
/// cref="DrawStaticParticles"/> and <see cref="DrawCellParticles"/> /// cref="PrepareStaticParticles"/> and <see cref="PrepareCellParticles"/>
/// already draw through. <see cref="WalkFrameDriver.Replay"/> asks this /// prepare through. <see cref="WalkFrameDriver.Replay"/> asks this BEFORE
/// BEFORE flushing the pending terrain batch (or calling either particle /// flushing the pending terrain batch (or preparing either particle leaf)
/// leaf) so a genuinely empty particle turn submits nothing and does not /// so a genuinely empty particle turn submits nothing and does not
/// force an otherwise-unneeded terrain flush.</summary> /// force an otherwise-unneeded terrain flush.</summary>
bool HasRenderableEmittersInCell(uint cellId); bool HasRenderableEmittersInCell(uint cellId);
@ -157,12 +166,12 @@ internal interface IWalkFrameLeafRenderer
/// (0x00514a70) draws an emitter at its own current cell's object turn /// (0x00514a70) draws an emitter at its own current cell's object turn
/// regardless of its attached owner's registry membership, so this is a /// regardless of its attached owner's registry membership, so this is a
/// cell lookup, not an owner union.</summary> /// cell lookup, not an owner union.</summary>
void DrawStaticParticles(uint cellId); ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareStaticParticles(uint cellId);
/// <summary>Submits one indoor cell's own-cell particle emitters at that /// <summary>Submits one indoor cell's own-cell particle emitters at that
/// cell's own object-list turn. Meshes have already entered the ordered /// cell's own object-list turn. Meshes have already entered the ordered
/// stream before this leaf event.</summary> /// stream before this leaf event.</summary>
void DrawCellParticles(uint cellId); ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareCellParticles(uint cellId);
/// <summary><c>PView::DrawCells</c> @0x005a4840's gated full depth clear /// <summary><c>PView::DrawCells</c> @0x005a4840's gated full depth clear
/// (pc:432731-432732) between the outside stage and the interior root's /// (pc:432731-432732) between the outside stage and the interior root's
@ -347,7 +356,7 @@ internal enum WalkFrameEventKind : byte
/// <summary><see cref="IWalkFrameLeafRenderer.DrawExitSeals"/>.</summary> /// <summary><see cref="IWalkFrameLeafRenderer.DrawExitSeals"/>.</summary>
ExitSeals, ExitSeals,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawStaticParticles"/> — /// <summary><see cref="IWalkFrameLeafRenderer.PrepareStaticParticles"/> —
/// ONE landscape (land) cell's (<see cref="WalkFrameEvent.CellId"/>) /// ONE landscape (land) cell's (<see cref="WalkFrameEvent.CellId"/>)
/// own-cell particle emitters, emitted AT ITS OWN WALK TURN. Retail's /// own-cell particle emitters, emitted AT ITS OWN WALK TURN. Retail's
/// falls containment is positional: an outdoor emitter's polys join the /// falls containment is positional: an outdoor emitter's polys join the
@ -368,7 +377,7 @@ internal enum WalkFrameEventKind : byte
/// owns one shadow in its OWN current cell, never its parent's).</summary> /// owns one shadow in its OWN current cell, never its parent's).</summary>
StaticParticles, StaticParticles,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawCellParticles"/> — /// <summary><see cref="IWalkFrameLeafRenderer.PrepareCellParticles"/> —
/// <see cref="WalkFrameEvent.CellId"/> is the cell. Fires at EVERY /// <see cref="WalkFrameEvent.CellId"/> is the cell. Fires at EVERY
/// visited interior cell's own object-list turn regardless of whether /// visited interior cell's own object-list turn regardless of whether
/// that cell has any visible static/dynamic owner record: an emitter /// that cell has any visible static/dynamic owner record: an emitter
@ -481,11 +490,23 @@ internal readonly struct WalkFrameEvent
internal static WalkFrameEvent AlphaBarrier() => internal static WalkFrameEvent AlphaBarrier() =>
new(WalkFrameEventKind.AlphaBarrier, 0, 0, 0f, null); new(WalkFrameEventKind.AlphaBarrier, 0, 0, 0f, null);
internal static WalkFrameEvent LandscapeCellParticles(uint cellId) => internal static WalkFrameEvent LandscapeCellParticles(
new(WalkFrameEventKind.StaticParticles, 0, cellId, 0f, null); uint cellId, int alphaExclusiveEnd, bool includeParticles) =>
new(
WalkFrameEventKind.StaticParticles,
alphaExclusiveEnd,
cellId,
includeParticles ? 1f : 0f,
null);
internal static WalkFrameEvent CellParticles(uint cellId) => internal static WalkFrameEvent CellParticles(
new(WalkFrameEventKind.CellParticles, 0, cellId, 0f, null); uint cellId, int alphaExclusiveEnd, bool includeParticles) =>
new(
WalkFrameEventKind.CellParticles,
alphaExclusiveEnd,
cellId,
includeParticles ? 1f : 0f,
null);
internal static WalkFrameEvent LandscapeFlush() => internal static WalkFrameEvent LandscapeFlush() =>
new(WalkFrameEventKind.LandscapeFlush, 0, 0, 0f, null); new(WalkFrameEventKind.LandscapeFlush, 0, 0, 0f, null);
@ -1138,7 +1159,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_alphaSubmissions[alphaCursor]; _alphaSubmissions[alphaCursor];
_dispatcher.SubmitWalkAlphaInstance( _dispatcher.SubmitWalkAlphaInstance(
in batch, in batch,
_cameraWorldPosition,
_viewProjection); _viewProjection);
} }
break; break;
@ -1205,18 +1225,16 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// current cell, drawn at that cell's object turn // current cell, drawn at that cell's object turn
// regardless of any owner's registry membership — // regardless of any owner's registry membership —
// this is a cell lookup, not an owner union. // this is a cell lookup, not an owner union.
if (_leafRenderer.HasRenderableEmittersInCell(e.CellId)) SubmitCellAlpha(
{ e,
FlushPendingTerrainBatch(); ref alphaCursor,
_leafRenderer.DrawStaticParticles(e.CellId); staticParticleTurn: true);
}
break; break;
case WalkFrameEventKind.CellParticles: case WalkFrameEventKind.CellParticles:
if (_leafRenderer.HasRenderableEmittersInCell(e.CellId)) SubmitCellAlpha(
{ e,
FlushPendingTerrainBatch(); ref alphaCursor,
_leafRenderer.DrawCellParticles(e.CellId); staticParticleTurn: false);
}
break; break;
} }
} }
@ -1231,13 +1249,62 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_events.Clear(); _events.Clear();
_markPositions.Clear(); _markPositions.Clear();
_alphaSubmissions.Clear(); _alphaSubmissions.Clear();
_pendingTerrainBatch.Clear(); _pendingTerrainBatch.Clear();
_alphaSubmitMark = 0; _alphaSubmitMark = 0;
_readyToReplay = false; _readyToReplay = false;
_ctx = null; _ctx = null;
} }
} }
/// <summary>
/// S4-c3a: completes one retail object-cell turn. Object delayed subsets
/// are already in stable far→near part order. Real particle records are
/// prepared only now, then merged by their retained CYpt before either
/// source appends to the two untouched FIFO queue lists. Object wins an
/// exactly equal cross-source key; AP-242 records that deterministic
/// source tie because acdream has no shared physical registration
/// ordinal. The event always drains its object range, including a cell
/// with no particle emitters.
/// </summary>
private void SubmitCellAlpha(
in WalkFrameEvent e,
ref int alphaCursor,
bool staticParticleTurn)
{
ReadOnlySpan<PreparedParticleAlphaSubmission> particles =
ReadOnlySpan<PreparedParticleAlphaSubmission>.Empty;
bool includeParticles = e.FloatArg != 0f;
if (includeParticles && _leafRenderer.HasRenderableEmittersInCell(e.CellId))
{
// Preserve the existing particle-turn terrain boundary: a row-5
// immediate mesh may draw during preparation.
FlushPendingTerrainBatch();
particles = staticParticleTurn
? _leafRenderer.PrepareStaticParticles(e.CellId)
: _leafRenderer.PrepareCellParticles(e.CellId);
}
int objectEnd = e.IntArg;
int particleIndex = 0;
while (alphaCursor < objectEnd || particleIndex < particles.Length)
{
bool takeParticle = particleIndex < particles.Length
&& (alphaCursor >= objectEnd
|| particles[particleIndex].DistanceSq
> _alphaSubmissions[alphaCursor].SortDistanceSq);
if (takeParticle)
{
particles[particleIndex++].Append();
}
else
{
WbDrawDispatcher.WalkClassifiedBatch batch =
_alphaSubmissions[alphaCursor++];
_dispatcher.SubmitWalkAlphaInstance(in batch, _viewProjection);
}
}
}
/// <summary>S3 chunk 3 fix round 1 (F2), round 2 (F10): submits the /// <summary>S3 chunk 3 fix round 1 (F2), round 2 (F10): submits the
/// pending terrain batch as ONE <see cref="IWalkFrameLeafRenderer.DrawLandCellBatch"/> /// pending terrain batch as ONE <see cref="IWalkFrameLeafRenderer.DrawLandCellBatch"/>
/// call, if it holds anything, and clears it. Called immediately before /// call, if it holds anything, and clears it. Called immediately before
@ -1446,23 +1513,18 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
+ "view set — the walk and draw driver are desynchronized."); + "view set — the walk and draw driver are desynchronized.");
} }
VisitedLandscapeCellIds.Add(cellId); VisitedLandscapeCellIds.Add(cellId);
WalkFrameStaticRecords records = _worldData.GetOutdoorStatics(cellId); WalkFrameStaticRecords records = _worldData.GetOutdoorObjects(cellId);
WalkFrameStaticRecords dynamics = _worldData.GetOutdoorDynamics(cellId); _populator.PopulateCellObjects(
_populator.PopulateOutdoorStatics( _stream,
_stream, cellId, records.Records, records.TupleLandblockId, WalkDrawStage.OutdoorStatic,
_cameraWorldPosition, _viewProjection, cellId,
this, _landscapeViewRouteIndex, records.Records,
alphaSubmissions: _alphaSubmissions); records.TupleLandblockId,
_populator.PopulateCellDynamics( _cameraWorldPosition,
_stream, cellId, dynamics.Records, dynamics.TupleLandblockId, _viewProjection,
_cameraWorldPosition, _viewProjection, this,
this, _landscapeViewRouteIndex, _landscapeViewRouteIndex,
alphaSubmissions: _alphaSubmissions); _alphaSubmissions);
if (_alphaSubmissions.Count != _alphaSubmitMark)
{
MarkIfGrown();
MarkAlphaIfGrown();
}
// FW4 (the #132 positional invariant): this cell's emitters submit // FW4 (the #132 positional invariant): this cell's emitters submit
// AT THIS TURN, so nearer buildings' pre-punch barriers drain them // AT THIS TURN, so nearer buildings' pre-punch barriers drain them
// against still-true depth — see WalkFrameEventKind.StaticParticles. // against still-true depth — see WalkFrameEventKind.StaticParticles.
@ -1473,7 +1535,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// has any visible static/dynamic owner record (Campaign OVERHAUL S2 // has any visible static/dynamic owner record (Campaign OVERHAUL S2
// chunk 6 — a suspended/hidden owner's emitter must still show). // chunk 6 — a suspended/hidden owner's emitter must still show).
MarkIfGrown(); MarkIfGrown();
_events.Add(WalkFrameEvent.LandscapeCellParticles(cellId)); bool includeParticles = _cellParticleTurnsDrawnThisFrame.Add(cellId);
_alphaSubmitMark = _alphaSubmissions.Count;
_events.Add(WalkFrameEvent.LandscapeCellParticles(
cellId,
_alphaSubmitMark,
includeParticles));
} }
/// <summary>S4-c2: retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a17c0's /// <summary>S4-c2: retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a17c0's
@ -1837,13 +1904,19 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// The shell loop captured the exact live portal_view for this cell. // The shell loop captured the exact live portal_view for this cell.
// Static and dynamic object lists reuse that same route: retail's // Static and dynamic object lists reuse that same route: retail's
// DrawEnvCell and DrawObjCell consume the same PortalList. // DrawEnvCell and DrawObjCell consume the same PortalList.
WalkFrameStaticRecords records = _worldData.GetCellStatics(cellId); WalkFrameStaticRecords records = _worldData.GetCellObjects(cellId);
_populator.PopulateCell( _populator.PopulateCellObjects(
_stream, stage, cellId, records.Records, records.TupleLandblockId, _stream,
_cameraWorldPosition, _viewProjection, stage,
this, viewRouteIndex, _alphaSubmissions); cellId,
records.Records,
records.TupleLandblockId,
_cameraWorldPosition,
_viewProjection,
this,
viewRouteIndex,
_alphaSubmissions);
MarkIfGrown(); MarkIfGrown();
MarkAlphaIfGrown();
if (stage == WalkDrawStage.LookInStatic) if (stage == WalkDrawStage.LookInStatic)
{ {
@ -1851,19 +1924,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
LookInCells.Add(cellId); LookInCells.Add(cellId);
} }
WalkFrameStaticRecords dynamics = _worldData.GetCellDynamics(cellId);
_populator.PopulateCellDynamics(
_stream,
cellId,
dynamics.Records,
dynamics.TupleLandblockId,
_cameraWorldPosition,
_viewProjection,
this,
viewRouteIndex,
alphaSubmissions: _alphaSubmissions);
MarkIfGrown();
MarkAlphaIfGrown();
// Fires for every cell turn regardless of whether that cell has any // Fires for every cell turn regardless of whether that cell has any
// visible static/dynamic owner record (Campaign OVERHAUL S2 chunk 6 — // visible static/dynamic owner record (Campaign OVERHAUL S2 chunk 6 —
// a suspended/hidden owner's emitter must still show), but ONCE per // a suspended/hidden owner's emitter must still show), but ONCE per
@ -1873,8 +1933,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// frame (a chamber reached through two portals). The same frame-scoped // frame (a chamber reached through two portals). The same frame-scoped
// set that dedupes the cell SHELL dedupes the particle turn (chunk 6 // set that dedupes the cell SHELL dedupes the particle turn (chunk 6
// review F1). // review F1).
if (_cellParticleTurnsDrawnThisFrame.Add(cellId)) bool includeParticles = _cellParticleTurnsDrawnThisFrame.Add(cellId);
_events.Add(WalkFrameEvent.CellParticles(cellId)); _alphaSubmitMark = _alphaSubmissions.Count;
_events.Add(WalkFrameEvent.CellParticles(
cellId,
_alphaSubmitMark,
includeParticles));
} }
private void CaptureCellViews(uint cellId) private void CaptureCellViews(uint cellId)

View file

@ -83,10 +83,12 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellObjectsCache = new();
private readonly Dictionary<string, string> _facilityShadowProbeSignatures = new(); private readonly Dictionary<string, string> _facilityShadowProbeSignatures = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new(); private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorObjectsMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new();
// Campaign OVERHAUL S2 chunk 5: every distinct entity id counted into // Campaign OVERHAUL S2 chunk 5: every distinct entity id counted into
@ -179,8 +181,10 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_tupleLandblockId = tupleLandblockId; _tupleLandblockId = tupleLandblockId;
_cellCache.Clear(); _cellCache.Clear();
_cellDynamicCache.Clear(); _cellDynamicCache.Clear();
_cellObjectsCache.Clear();
_outdoorMaterialized.Clear(); _outdoorMaterialized.Clear();
_outdoorDynamicsMaterialized.Clear(); _outdoorDynamicsMaterialized.Clear();
_outdoorObjectsMaterialized.Clear();
_shellMaterialized.Clear(); _shellMaterialized.Clear();
_arenaLength = 0; _arenaLength = 0;
UnregisteredRenderMembershipCount = 0; UnregisteredRenderMembershipCount = 0;
@ -252,6 +256,16 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
return records; return records;
} }
public WalkFrameStaticRecords GetCellObjects(uint cellId)
{
if (_cellObjectsCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: null);
EmitFacilityShadowProbe(records.Records, cellId, "combined");
_cellObjectsCache[cellId] = records;
return records;
}
public WalkFrameStaticRecords GetCellDynamics(uint cellId) public WalkFrameStaticRecords GetCellDynamics(uint cellId)
{ {
if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
@ -285,6 +299,20 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
return records; return records;
} }
public WalkFrameStaticRecords GetOutdoorObjects(uint cellId)
{
if (_outdoorObjectsMaterialized.TryGetValue(
cellId,
out WalkFrameStaticRecords cached))
{
return cached;
}
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: null);
_outdoorObjectsMaterialized[cellId] = records;
return records;
}
/// <summary> /// <summary>
/// Campaign OVERHAUL S2 chunk 5: one cell id's borrowed retail render /// Campaign OVERHAUL S2 chunk 5: one cell id's borrowed retail render
/// membership. Reads <see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/> /// membership. Reads <see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/>
@ -303,7 +331,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
/// entity into every crossed cell under its own true id, so no /// entity into every crossed cell under its own true id, so no
/// additional indoor/outdoor dispatch is needed here. /// additional indoor/outdoor dispatch is needed here.
/// </summary> /// </summary>
private WalkFrameStaticRecords ResolveCellView(uint cellId, bool dynamic) private WalkFrameStaticRecords ResolveCellView(uint cellId, bool? dynamic)
{ {
IReadOnlyList<RetailPartEntry> entries = IReadOnlyList<RetailPartEntry> entries =
_shadows.GetRetailPartEntriesInCell(cellId); _shadows.GetRetailPartEntriesInCell(cellId);
@ -335,7 +363,8 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
} }
if (record.EntityPayload.IsBuildingShell) if (record.EntityPayload.IsBuildingShell)
continue; // buildings draw at their own shell turn. continue; // buildings draw at their own shell turn.
if (IsDynamicProjectionClass(record.ProjectionClass) != dynamic) if (dynamic.HasValue
&& IsDynamicProjectionClass(record.ProjectionClass) != dynamic.Value)
continue; continue;
if (written == _cellViewScratch.Length) if (written == _cellViewScratch.Length)

View file

@ -18,14 +18,11 @@ namespace AcDream.App.Rendering.Walk;
/// shell over terrain) — and retail resolves those by first-drawn-wins in /// shell over terrain) — and retail resolves those by first-drawn-wins in
/// ITS walk/cell-content order, not by any texture/material grouping. So /// ITS walk/cell-content order, not by any texture/material grouping. So
/// this populator appends one <see cref="OrderedDrawCommand"/> per (entity, /// this populator appends one <see cref="OrderedDrawCommand"/> per (entity,
/// opaque batch) in the SAME order the caller's <c>records</c> span presents /// opaque batch) in the per-cell stable far→near part order reconstructed
/// them — never material-grouped, never re-sorted. Translucent batches go to /// from authored GfxObj SortCenter. Opaque/cutout and delayed projections
/// the SAME <see cref="RetailAlphaQueue"/> the classic/packed paths already /// derive from that same order. Translucent batches then enter the SAME two
/// use (global far→near re-sort still applies; walk-order submission only /// FIFO <see cref="RetailAlphaQueue"/> lists as particles; the queues never
/// improves retail's submission-order tie-break fidelity for coincident /// perform a scope-global sort.</para>
/// distances — <c>WbDrawDispatcher.DeferTransparentGroups</c>'s own doc
/// comment cites the same <c>CShadowPart::insertion_sort</c> stability this
/// preserves).</para>
/// ///
/// <para>Reads no retained scene state itself: every method takes the target /// <para>Reads no retained scene state itself: every method takes the target
/// <see cref="OrderedDrawStream"/> and a caller-supplied span of already /// <see cref="OrderedDrawStream"/> and a caller-supplied span of already
@ -40,6 +37,12 @@ internal sealed class WalkStaticStreamPopulator
private readonly WbDrawDispatcher _dispatcher; private readonly WbDrawDispatcher _dispatcher;
private readonly List<WbDrawDispatcher.WalkClassifiedBatch> _batchScratch = new(); private readonly List<WbDrawDispatcher.WalkClassifiedBatch> _batchScratch = new();
private readonly List<WbDrawDispatcher.WalkClassifiedSelectionPart> _selectionScratch = new(); private readonly List<WbDrawDispatcher.WalkClassifiedSelectionPart> _selectionScratch = new();
private readonly List<CellBatch> _cellBatchScratch = new();
private readonly record struct CellBatch(
WbDrawDispatcher.WalkClassifiedBatch Batch,
WalkDrawStage Stage,
uint LocalEntityId);
internal WalkStaticStreamPopulator(WbDrawDispatcher dispatcher) internal WalkStaticStreamPopulator(WbDrawDispatcher dispatcher)
{ {
@ -147,6 +150,120 @@ internal sealed class WalkStaticStreamPopulator
} }
} }
/// <summary>
/// S4-c3a: classifies the cell's combined static/dynamic registry view,
/// computes every subset's CYpt from its authored part sort center,
/// performs retail's stable descending insertion sort, and projects the
/// same order to opaque commands and delayed records. Equal keys retain
/// registry/part/subset order because only a strictly farther record is
/// moved left.
///
/// <para>AP-243: this always-authored per-part key is deliberately more
/// exact than retail's greater-than-50 m branch. Retail
/// <c>UpdateObjCell</c> @0x005A06B7..0x005A0720 gives every part the
/// shared cell CYpt/heading there, preserving insertion order; acquiring
/// that cell-distance/heading LOD input is outside this bounded repair.</para>
/// </summary>
internal void PopulateCellObjects(
OrderedDrawStream stream,
WalkDrawStage staticStage,
uint cellId,
ReadOnlySpan<RenderProjectionRecord> records,
uint tupleLandblockId,
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection,
IWalkLookInViewSource? views,
int viewRouteIndex,
List<WbDrawDispatcher.WalkClassifiedBatch> alphaSubmissions)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentNullException.ThrowIfNull(alphaSubmissions);
_cellBatchScratch.Clear();
for (int recordIndex = 0; recordIndex < records.Length; recordIndex++)
{
ref readonly RenderProjectionRecord record = ref records[recordIndex];
bool liveDynamic = record.ProjectionClass is RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild;
WalkDrawStage stage = liveDynamic ? WalkDrawStage.Dynamic : staticStage;
_batchScratch.Clear();
_selectionScratch.Clear();
_dispatcher.ClassifyEntityForWalk(
in record,
tupleLandblockId,
_batchScratch,
_selectionScratch,
liveDynamic,
views,
viewRouteIndex,
cellId,
diagnosticViewProjection: viewProjection);
for (int batchIndex = 0; batchIndex < _batchScratch.Count; batchIndex++)
{
WbDrawDispatcher.WalkClassifiedBatch batch = _batchScratch[batchIndex];
Vector3 worldSortCenter = Vector3.Transform(batch.LocalSortCenter, batch.Transform);
batch = batch with
{
SortDistanceSq = Vector3.DistanceSquared(
worldSortCenter, cameraWorldPosition),
};
_cellBatchScratch.Add(new CellBatch(
batch,
stage,
record.Source.LocalEntityId));
}
for (int selectionIndex = 0; selectionIndex < _selectionScratch.Count; selectionIndex++)
{
WbDrawDispatcher.WalkClassifiedSelectionPart part =
_selectionScratch[selectionIndex];
_dispatcher.PublishWalkSelectionPart(in part);
}
}
StableSortCellBatches(_cellBatchScratch);
for (int i = 0; i < _cellBatchScratch.Count; i++)
{
CellBatch item = _cellBatchScratch[i];
WbDrawDispatcher.WalkClassifiedBatch batch = item.Batch;
if (batch.IsOpaque)
{
int commandIndex = stream.Count;
stream.Append(new OrderedDrawCommand(
batch.Key, batch.Transform, item.Stage, cellId, batch.ClipSlot,
batch.Lights, batch.IndoorFlag, batch.Alpha,
batch.SelectionLighting, batch.DetailCategory));
_dispatcher.ProbeFacilityStairCommandAppended(
commandIndex,
item.LocalEntityId,
cellId,
item.Stage,
in batch);
}
else
{
alphaSubmissions.Add(batch);
}
}
}
private static void StableSortCellBatches(List<CellBatch> batches)
{
for (int i = 1; i < batches.Count; i++)
{
CellBatch value = batches[i];
int insertion = i;
while (insertion > 0
&& value.Batch.SortDistanceSq > batches[insertion - 1].Batch.SortDistanceSq)
{
batches[insertion] = batches[insertion - 1];
insertion--;
}
batches[insertion] = value;
}
}
private void ClassifyAndAppend( private void ClassifyAndAppend(
OrderedDrawStream stream, OrderedDrawStream stream,
WalkDrawStage stage, WalkDrawStage stage,
@ -195,7 +312,7 @@ internal sealed class WalkStaticStreamPopulator
if (alphaSubmissions is null) if (alphaSubmissions is null)
{ {
_dispatcher.SubmitWalkAlphaInstance( _dispatcher.SubmitWalkAlphaInstance(
in batch, cameraWorldPosition, viewProjection); in batch, viewProjection);
} }
else else
{ {

View file

@ -20,7 +20,10 @@ namespace AcDream.App.Rendering.Wb;
/// key's VALUE never orders anything: groups are enumerated in the dictionary's /// key's VALUE never orders anything: groups are enumerated in the dictionary's
/// insertion order and sorted by cull mode then camera distance /// insertion order and sorted by cull mode then camera distance
/// (<c>CompareOpaqueSubmissionOrder</c> / <c>CompareTransparentSubmissionOrder</c>), /// (<c>CompareOpaqueSubmissionOrder</c> / <c>CompareTransparentSubmissionOrder</c>),
/// and the delayed-alpha path sorts by viewer distance then submission ordinal. /// while the normal-world delayed-alpha path orders authored part centers
/// stably within each cell before appending to two FIFO lists. The grouped
/// fallback restores submission ordinal after material grouping; it is not a
/// scope-global world-alpha distance sort.
/// The key reaches only equality, hashing, and the scene-digest fingerprints.</para> /// The key reaches only equality, hashing, and the scene-digest fingerprints.</para>
/// ///
/// <para>Campaign VM VM6 review fix round: <see cref="FoliageFlags"/> joined /// <para>Campaign VM VM6 review fix round: <see cref="FoliageFlags"/> joined

View file

@ -146,8 +146,10 @@ public sealed partial class WbDrawDispatcher
/// (the authored GfxObj sort center — the same value /// (the authored GfxObj sort center — the same value
/// <c>InstanceGroup.LocalSortCenters</c> carries per instance today; S4-c2 /// <c>InstanceGroup.LocalSortCenters</c> carries per instance today; S4-c2
/// deleted its one alpha-ordering consumer, <c>RetailAlphaOrdering.ComputeViewerDistance</c>, /// deleted its one alpha-ordering consumer, <c>RetailAlphaOrdering.ComputeViewerDistance</c>,
/// but the field itself stays: <c>InstanceGroup.LocalSortCenters</c> is a /// but S4-c3a restores its retail use: <c>WalkStaticStreamPopulator</c>
/// general per-instance record this type mirrors, not an alpha-only one). /// transforms it through the part model and retains the resulting
/// squared CYpt key in <see cref="SortDistanceSq"/> before the per-cell
/// stable sort. The key feeds two FIFO alpha lists; neither queue sorts.
/// </summary> /// </summary>
internal readonly record struct WalkClassifiedBatch( internal readonly record struct WalkClassifiedBatch(
GroupKey Key, GroupKey Key,
@ -159,7 +161,8 @@ public sealed partial class WbDrawDispatcher
Vector2 SelectionLighting, Vector2 SelectionLighting,
uint DetailCategory, uint DetailCategory,
bool IsOpaque, bool IsOpaque,
Vector3 LocalSortCenter); Vector3 LocalSortCenter,
float SortDistanceSq = 0f);
/// <summary> /// <summary>
/// One retail-picking part surfaced by <see cref="ClassifyEntityForWalk"/> /// One retail-picking part surfaced by <see cref="ClassifyEntityForWalk"/>
@ -889,10 +892,8 @@ public sealed partial class WbDrawDispatcher
/// </summary> /// </summary>
internal void SubmitWalkAlphaInstance( internal void SubmitWalkAlphaInstance(
in WalkClassifiedBatch batch, in WalkClassifiedBatch batch,
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection) Matrix4x4 viewProjection)
{ {
_ = cameraWorldPosition; // S4-c2: retail's queues are FIFO, not distance-sorted.
RetailAlphaQueue queue = _alphaQueue RetailAlphaQueue queue = _alphaQueue
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"SubmitWalkAlphaInstance requires an active RetailAlphaQueue."); "SubmitWalkAlphaInstance requires an active RetailAlphaQueue.");

View file

@ -47,9 +47,12 @@ namespace AcDream.App.Rendering.Wb;
/// <c>DrawElementsIndirectCommand</c>. Per-frame ring allocations carry instance /// <c>DrawElementsIndirectCommand</c>. Per-frame ring allocations carry instance
/// matrices (binding 0), per-group batch metadata/texture-table slots (binding /// matrices (binding 0), per-group batch metadata/texture-table slots (binding
/// 1), and the indirect draw commands. Opaque world groups remain MDI-batched. /// 1), and the indirect draw commands. Opaque world groups remain MDI-batched.
/// Transparent world instances enter <see cref="RetailAlphaQueue"/> so ordinary /// Transparent world instances enter <see cref="RetailAlphaQueue"/> after
/// GfxObj parts and particles share retail's stable far-to-near stream; sealed /// <c>WalkFrameDriver</c> reconstructs retail's stable far-to-near order
/// off-screen consumers retain the immediate transparent MDI path. /// independently for each object-cell turn from authored part SortCenter.
/// Object and particle records are merged before they append to retail's two
/// FIFO lists; sealed off-screen consumers retain the immediate transparent
/// MDI path.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -2419,14 +2422,11 @@ public sealed partial class WbDrawDispatcher : IDisposable
throw new InvalidOperationException( throw new InvalidOperationException(
"One retail alpha scope cannot combine different view-projection matrices."); "One retail alpha scope cannot combine different view-projection matrices.");
// Retail CShadowPart::insertion_sort (0x006B5130) is stable:
// equal-CYpt parts keep the order in which the cell submitted them.
// Material grouping is an acdream batching detail and must not become // Material grouping is an acdream batching detail and must not become
// that tiebreak. Reconstruct the original draw-local instance order // a tiebreak. Reconstruct original draw-local submission order before
// before handing entries to the queue — S4-c2: retail's own queue is // handing entries to retail's FIFO queue. The normal-world walk path
// FIFO (D3DPolyRender::AddMeshToAlphaList never sorts), so preserving // does not use this grouped route: WalkStaticStreamPopulator performs
// this walk-order sequence IS the ordering, not a sort key for a // the per-cell stable CYpt sort before its per-instance submissions.
// later comparator.
_alphaFingerprintScratch.Clear(); _alphaFingerprintScratch.Clear();
foreach (InstanceGroup group in _translucentDraws) foreach (InstanceGroup group in _translucentDraws)
{ {

View file

@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Numerics; using System.Numerics;
@ -12,6 +13,7 @@ using AcDream.App.Rendering.Walk;
using AcDream.App.Tests.Rendering.Gpu; using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
using AcDream.Core.Vfx;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
@ -130,9 +132,17 @@ public sealed partial class WalkFrameDriverTests
alpha.Flush(RetailAlphaFlushSite.SortCellExit, 0.75f); alpha.Flush(RetailAlphaFlushSite.SortCellExit, 0.75f);
} }
public void DrawStaticParticles(uint cellId) => log.Add($"PARTICLES:{cellId:x8}"); public ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareStaticParticles(uint cellId)
{
log.Add($"PARTICLES:{cellId:x8}");
return ReadOnlySpan<PreparedParticleAlphaSubmission>.Empty;
}
public void DrawCellParticles(uint cellId) => log.Add($"CELL-PARTICLES:{cellId:x8}"); public ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareCellParticles(uint cellId)
{
log.Add($"CELL-PARTICLES:{cellId:x8}");
return ReadOnlySpan<PreparedParticleAlphaSubmission>.Empty;
}
} }
private sealed class RecordingTrace(List<string> log) : IWalkFrameDriverTrace private sealed class RecordingTrace(List<string> log) : IWalkFrameDriverTrace
@ -141,6 +151,39 @@ public sealed partial class WalkFrameDriverTests
log.Add($"FLUSH:{commandCount}:{string.Join(',', stages.Distinct())}"); log.Add($"FLUSH:{commandCount}:{string.Join(',', stages.Distinct())}");
} }
private sealed class ProductionParticleLeaf(
ParticleSystem particles,
ParticleRenderer renderer,
ICamera camera,
Vector3 cameraWorldPosition) : IWalkFrameLeafRenderer
{
public void DrawSky() { }
public void DrawLandCellBatch(
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells) { }
public bool HasRenderableEmittersInCell(uint cellId) =>
particles.HasRenderableEmittersInCell(ParticleRenderPass.Scene, cellId);
public void DrawCellShell(uint cellId) { }
public ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareStaticParticles(uint cellId) =>
renderer.PrepareForCellAlpha(
camera, cameraWorldPosition, ParticleRenderPass.Scene, cellId);
public ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareCellParticles(uint cellId) =>
renderer.PrepareForCellAlpha(
camera, cameraWorldPosition, ParticleRenderPass.Scene, cellId);
public void ClearInteriorDepth() { }
public void FlushLandscape() { }
public int DrawExitSeals() => 0;
public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) { }
public void AlphaBarrier() { }
public void FlushSortCellExit() { }
}
private sealed class IdentityCamera : ICamera
{
public Matrix4x4 View => Matrix4x4.Identity;
public Matrix4x4 Projection => Matrix4x4.Identity;
public float Aspect { get; set; } = 1f;
}
private sealed class FakeWorldData : IWalkFrameWorldData private sealed class FakeWorldData : IWalkFrameWorldData
{ {
public readonly Dictionary<uint, WalkFrameStaticRecords> CellStaticsByCell = new(); public readonly Dictionary<uint, WalkFrameStaticRecords> CellStaticsByCell = new();
@ -153,12 +196,22 @@ public sealed partial class WalkFrameDriverTests
public WalkFrameStaticRecords GetCellStatics(uint cellId) => public WalkFrameStaticRecords GetCellStatics(uint cellId) =>
CellStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty); CellStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
public WalkFrameStaticRecords GetCellObjects(uint cellId) =>
Combine(
CellStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty),
CellDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty));
public WalkFrameStaticRecords GetCellDynamics(uint cellId) => public WalkFrameStaticRecords GetCellDynamics(uint cellId) =>
CellDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty); CellDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) => public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) =>
OutdoorStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty); OutdoorStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
public WalkFrameStaticRecords GetOutdoorObjects(uint cellId) =>
Combine(
OutdoorStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty),
OutdoorDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty));
public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId) => public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId) =>
OutdoorDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty); OutdoorDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
@ -167,6 +220,25 @@ public sealed partial class WalkFrameDriverTests
public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building) => public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building) =>
WorldTransformByBuilding.GetValueOrDefault(building, Matrix4x4.Identity); WorldTransformByBuilding.GetValueOrDefault(building, Matrix4x4.Identity);
private static WalkFrameStaticRecords Combine(
WalkFrameStaticRecords first,
WalkFrameStaticRecords second)
{
if (second.Records.Count == 0)
return first;
RenderProjectionRecord[] dynamicRecords = second.Records
.Select(record => record with
{
ProjectionClass = RenderProjectionClass.LiveDynamicRoot,
})
.ToArray();
if (first.Records.Count == 0)
return new WalkFrameStaticRecords(dynamicRecords, second.TupleLandblockId);
return new WalkFrameStaticRecords(
first.Records.Concat(dynamicRecords).ToArray(),
first.TupleLandblockId);
}
} }
// ── The walk-level test context (interior flood + building portal pass) ─ // ── The walk-level test context (interior flood + building portal pass) ─
@ -1001,7 +1073,9 @@ public sealed partial class WalkFrameDriverTests
new[] new[]
{ {
"ALPHA", "PUNCH:4@v0", "SHELL:00000104", "ALPHA", "PUNCH:4@v0", "SHELL:00000104",
"FLUSH:1:LookInStatic", "FLUSH:1:Dynamic", // S4-c3a: one cell shadow list, so static + dynamic are one
// sorted stream segment rather than two artificial blocks.
"FLUSH:2:LookInStatic,Dynamic",
"CELL-PARTICLES:00000104", "CELL-PARTICLES:00000104",
"FLUSH:1:BuildingShell", "FLUSH:1:BuildingShell",
}, },
@ -1805,6 +1879,205 @@ public sealed partial class WalkFrameDriverTests
} }
} }
[Fact]
public void CellTurn_RealParticlePreparationMergesWithObjectAlphaByCypt()
{
using var fx = new DispatcherFixture();
const uint cellId = 0x8C040005u;
const ulong objectGfx = 0x0200_0C31UL;
InjectRenderData(fx.Manager, objectGfx, MakeFlatMesh(
MakeBatch(0x08100C31u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1)));
var worldData = new FakeWorldData();
worldData.OutdoorStaticsByCell[cellId] = new WalkFrameStaticRecords(
new[]
{
MakeRecord(1, 0, new Vector3(20, 0, 0),
[new MeshRef((uint)objectGfx, Matrix4x4.Identity)]),
MakeRecord(2, 0, new Vector3(40, 0, 0),
[new MeshRef((uint)objectGfx, Matrix4x4.Identity)]),
},
0x8C04u);
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(42));
EmitterDesc desc = new()
{
DatId = 0x32000C31u,
Type = AcDream.Core.Vfx.ParticleType.Still,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 100f,
LifetimeMax = 100f,
StartAlpha = 1f,
EndAlpha = 1f,
};
int particle30 = particles.SpawnEmitter(desc, new Vector3(30, 0, 0));
// Equal to object 1: AP-242's deterministic source tie is object-first.
int particle20 = particles.SpawnEmitter(desc, new Vector3(20, 0, 0));
particles.UpdateEmitterOwnerCell(particle30, cellId);
particles.UpdateEmitterOwnerCell(particle20, cellId);
using var renderer = new ParticleRenderer(
fx.Device,
fx.FrameLifetime,
fx.Scope,
particles,
meshAdapter: fx.MeshAdapter,
alphaQueue: fx.AlphaQueue);
var leaf = new ProductionParticleLeaf(
particles, renderer, new IdentityCamera(), Vector3.Zero);
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
var ctx = new TestContext();
IWalkEventSink sink = driver;
using DrawScope draw = fx.BeginDraw(beginAlpha: true);
renderer.BeginFrame(frameSlot: 0);
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
var activeViews = new WalkPortalView();
WalkCopyView.AppendFullViewportQuad(
activeViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
sink.OnLandscapeViews(activeViews);
sink.OnLandscapeCellTurn(cellId);
driver.EndFrame();
driver.Replay(draw.Frame, draw.Pass);
List<RetailAlphaEntry> entries = QueueAlphaEntries(fx.AlphaQueue);
Assert.Equal(4, entries.Count);
Assert.Equal(
[
typeof(WbDrawDispatcher),
typeof(ParticleRenderer),
typeof(WbDrawDispatcher),
typeof(ParticleRenderer),
],
entries.Select(static entry => entry.Source.GetType().DeclaringType));
fx.AlphaQueue.AbortFrame();
}
[Fact]
public void SeparateCellTurnsRemainCellMajorWhenLaterCellIsFarther()
{
using var fx = new DispatcherFixture();
const uint firstCell = 0x8C040005u;
const uint secondCell = 0x8C040006u;
const ulong objectGfx = 0x0200_0C32UL;
InjectRenderData(fx.Manager, objectGfx, MakeFlatMesh(
MakeBatch(0x08100C32u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1)));
var worldData = new FakeWorldData();
worldData.OutdoorStaticsByCell[firstCell] = new WalkFrameStaticRecords(
new[] { MakeRecord(1, 0, new Vector3(5, 0, 0),
[new MeshRef((uint)objectGfx, Matrix4x4.Identity)]) },
0x8C04u);
worldData.OutdoorStaticsByCell[secondCell] = new WalkFrameStaticRecords(
new[] { MakeRecord(2, 0, new Vector3(50, 0, 0),
[new MeshRef((uint)objectGfx, Matrix4x4.Identity)]) },
0x8C04u);
var log = new List<string>();
var leaf = new RecordingLeafRenderer(log, fx.AlphaQueue);
leaf.CellsWithoutEmitters.UnionWith([firstCell, secondCell]);
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
var ctx = new TestContext();
IWalkEventSink sink = driver;
using DrawScope draw = fx.BeginDraw(beginAlpha: true);
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
var activeViews = new WalkPortalView();
WalkCopyView.AppendFullViewportQuad(
activeViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
sink.OnLandscapeViews(activeViews);
sink.OnLandscapeCellTurn(firstCell);
sink.OnLandscapeCellTurn(secondCell);
driver.EndFrame();
driver.Replay(draw.Frame, draw.Pass);
Assert.Equal(2, QueueAlphaEntries(fx.AlphaQueue).Count);
IList payload = (IList)typeof(WbDrawDispatcher).GetField(
"_deferredAlpha", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(fx.Dispatcher)!;
Matrix4x4 firstModel = (Matrix4x4)payload[0]!.GetType().GetProperty("Model")!
.GetValue(payload[0])!;
Matrix4x4 secondModel = (Matrix4x4)payload[1]!.GetType().GetProperty("Model")!
.GetValue(payload[1])!;
Assert.Equal(5f, firstModel.M41);
Assert.Equal(50f, secondModel.M41);
fx.AlphaQueue.AbortFrame();
}
[Fact]
public void ProductionCellObjectParticleMerge_WarmedPathAllocatesZeroBytes()
{
using var fx = new DispatcherFixture();
const uint cellId = 0x8C040007u;
const ulong objectGfx = 0x0200_0C33UL;
InjectRenderData(fx.Manager, objectGfx, MakeFlatMesh(
MakeBatch(0x08100C33u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1)));
var worldData = new FakeWorldData();
worldData.OutdoorStaticsByCell[cellId] = new WalkFrameStaticRecords(
new[] { MakeRecord(1, 0, new Vector3(40, 0, 0),
[new MeshRef((uint)objectGfx, Matrix4x4.Identity)]) },
0x8C04u);
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(42));
int handle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000C33u,
Type = AcDream.Core.Vfx.ParticleType.Still,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 100f,
LifetimeMax = 100f,
StartAlpha = 1f,
EndAlpha = 1f,
},
new Vector3(20, 0, 0));
particles.UpdateEmitterOwnerCell(handle, cellId);
using var renderer = new ParticleRenderer(
fx.Device,
fx.FrameLifetime,
fx.Scope,
particles,
meshAdapter: fx.MeshAdapter,
alphaQueue: fx.AlphaQueue);
var leaf = new ProductionParticleLeaf(
particles, renderer, new IdentityCamera(), Vector3.Zero);
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
var ctx = new TestContext();
IWalkEventSink sink = driver;
var activeViews = new WalkPortalView();
WalkCopyView.AppendFullViewportQuad(
activeViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
using DrawScope draw = fx.BeginDraw();
fx.Dispatcher.BeginFrame(frameSlot: 0);
renderer.BeginFrame(frameSlot: 0);
fx.Device.RecordingEnabled = false;
void RunCellTurn()
{
fx.AlphaQueue.BeginFrame();
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
sink.OnLandscapeViews(activeViews);
sink.OnLandscapeCellTurn(cellId);
driver.EndFrame();
driver.Replay(draw.Frame, draw.Pass);
fx.AlphaQueue.AbortFrame();
}
long allocated = ZeroAllocationProbe.MeasureWarmed(
RunCellTurn,
batchSize: 128,
warmupBatches: 2,
samples: 4);
Assert.Equal(0, allocated);
}
private static List<RetailAlphaEntry> QueueAlphaEntries(RetailAlphaQueue queue) =>
(List<RetailAlphaEntry>)typeof(RetailAlphaQueue).GetField(
"_alpha", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(queue)!;
// ── F4(b) (S3 chunk 3 fix round 1 §9.6): an interior root with one // ── F4(b) (S3 chunk 3 fix round 1 §9.6): an interior root with one
// surviving exit view — the SAME fixture as // surviving exit view — the SAME fixture as
// RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells // RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells
@ -2217,6 +2490,8 @@ public sealed partial class WalkFrameDriverTests
public ObjectMeshManager Manager => _meshAdapter.MeshManager!; public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
public WbMeshAdapter MeshAdapter => _meshAdapter;
public DrawScope BeginDraw(bool beginAlpha = false) public DrawScope BeginDraw(bool beginAlpha = false)
{ {
if (beginAlpha) if (beginAlpha)

View file

@ -120,6 +120,11 @@ public sealed class WalkStaticStreamPopulatorTests
private static ObjectRenderData MakeFlatMesh(params ObjectRenderBatch[] batches) => private static ObjectRenderData MakeFlatMesh(params ObjectRenderBatch[] batches) =>
new() { Batches = new List<ObjectRenderBatch>(batches) }; new() { Batches = new List<ObjectRenderBatch>(batches) };
private static ObjectRenderData MakeSortedMesh(
Vector3 sortCenter,
params ObjectRenderBatch[] batches) =>
new() { SortCenter = sortCenter, Batches = new List<ObjectRenderBatch>(batches) };
// ── Reflection seam: ObjectMeshManager owns no test-injection API, and // ── Reflection seam: ObjectMeshManager owns no test-injection API, and
// driving real GPU/GfxObj upload for a unit test is out of this stage's // driving real GPU/GfxObj upload for a unit test is out of this stage's
// scope — ObjectRenderData/ObjectRenderBatch are plain settable classes, // scope — ObjectRenderData/ObjectRenderBatch are plain settable classes,
@ -135,6 +140,224 @@ public sealed class WalkStaticStreamPopulatorTests
dict[id] = data; dict[id] = data;
} }
[Fact]
public void PopulateCellObjects_UsesAuthoredSortCenterAndStableFarToNearOrder()
{
using var fx = new DispatcherFixture();
const ulong nearOrigin = 0x0100_0C01UL;
const ulong farAuthoredCenter = 0x0100_0C02UL;
InjectRenderData(fx.Manager, nearOrigin, MakeSortedMesh(
Vector3.Zero,
MakeBatch(101, TranslucencyKind.AlphaBlend, 101, 0, 3, 1)));
InjectRenderData(fx.Manager, farAuthoredCenter, MakeSortedMesh(
new Vector3(10, 0, 0),
MakeBatch(202, TranslucencyKind.AlphaBlend, 202, 0, 3, 2)));
RenderProjectionRecord[] records =
[
MakeRecord(1, 0, new Vector3(5, 0, 0), [new MeshRef((uint)nearOrigin, Matrix4x4.Identity)]),
MakeRecord(2, 0, Vector3.Zero, [new MeshRef((uint)farAuthoredCenter, Matrix4x4.Identity)]),
];
var alpha = new List<WbDrawDispatcher.WalkClassifiedBatch>();
var stream = new OrderedDrawStream();
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
populator.PopulateCellObjects(
stream,
WalkDrawStage.CellStatic,
0x8C040112u,
records,
0x8C04u,
Vector3.Zero,
Matrix4x4.Identity,
views: null,
viewRouteIndex: -1,
alpha);
Assert.Empty(stream.Keys);
Assert.Equal([202u, 101u], alpha.Select(static batch => batch.Key.FirstIndex));
Assert.Equal([100f, 25f], alpha.Select(static batch => batch.SortDistanceSq));
}
[Fact]
public void PopulateCellObjects_EqualCyptRetainsEntityPartAndSubsetOrder()
{
using var fx = new DispatcherFixture();
const ulong setup = 0x1000_0C10UL;
const ulong firstPart = 0x0100_0C11UL;
const ulong secondPart = 0x0100_0C12UL;
InjectRenderData(fx.Manager, firstPart, MakeSortedMesh(
Vector3.Zero,
MakeBatch(11, TranslucencyKind.AlphaBlend, 11, 0, 3, 1),
MakeBatch(12, TranslucencyKind.AlphaBlend, 12, 0, 3, 2)));
InjectRenderData(fx.Manager, secondPart, MakeSortedMesh(
Vector3.Zero,
MakeBatch(21, TranslucencyKind.AlphaBlend, 21, 0, 3, 3),
MakeBatch(22, TranslucencyKind.AlphaBlend, 22, 0, 3, 4)));
InjectRenderData(fx.Manager, setup, new ObjectRenderData
{
IsSetup = true,
SetupParts =
[
(firstPart, Matrix4x4.CreateTranslation(10, 0, 0)),
(secondPart, Matrix4x4.CreateTranslation(0, 10, 0)),
],
});
RenderProjectionRecord record = MakeRecord(
10, 0, Vector3.Zero, [new MeshRef((uint)setup, Matrix4x4.Identity)]);
var alpha = new List<WbDrawDispatcher.WalkClassifiedBatch>();
new WalkStaticStreamPopulator(fx.Dispatcher).PopulateCellObjects(
new OrderedDrawStream(),
WalkDrawStage.CellStatic,
0x8C040112u,
[record],
0x8C04u,
Vector3.Zero,
Matrix4x4.Identity,
views: null,
viewRouteIndex: -1,
alpha);
Assert.Equal([11u, 12u, 21u, 22u], alpha.Select(static batch => batch.Key.FirstIndex));
Assert.All(alpha, static batch => Assert.Equal(100f, batch.SortDistanceSq));
}
[Fact]
public void PopulateCellObjects_InterleavesStaticAndDynamicOpaquePartsByCypt()
{
using var fx = new DispatcherFixture();
const ulong nearStatic = 0x0100_0C21UL;
const ulong farDynamic = 0x0100_0C22UL;
const ulong middleStatic = 0x0100_0C23UL;
InjectRenderData(fx.Manager, nearStatic, MakeFlatMesh(
MakeBatch(1, TranslucencyKind.Opaque, 1, 0, 3, 1)));
InjectRenderData(fx.Manager, farDynamic, MakeFlatMesh(
MakeBatch(2, TranslucencyKind.Opaque, 2, 0, 3, 2)));
InjectRenderData(fx.Manager, middleStatic, MakeFlatMesh(
MakeBatch(3, TranslucencyKind.Opaque, 3, 0, 3, 3)));
RenderProjectionRecord dynamic = MakeRecord(
2, 0, new Vector3(30, 0, 0), [new MeshRef((uint)farDynamic, Matrix4x4.Identity)])
with { ProjectionClass = RenderProjectionClass.LiveDynamicRoot };
RenderProjectionRecord[] records =
[
MakeRecord(1, 0, new Vector3(10, 0, 0), [new MeshRef((uint)nearStatic, Matrix4x4.Identity)]),
dynamic,
MakeRecord(3, 0, new Vector3(20, 0, 0), [new MeshRef((uint)middleStatic, Matrix4x4.Identity)]),
];
var stream = new OrderedDrawStream();
new WalkStaticStreamPopulator(fx.Dispatcher).PopulateCellObjects(
stream,
WalkDrawStage.CellStatic,
0x8C040112u,
records,
0x8C04u,
Vector3.Zero,
Matrix4x4.Identity,
views: null,
viewRouteIndex: -1,
[]);
Assert.Equal([2u, 3u, 1u], stream.Keys.Select(static key => key.FirstIndex));
Assert.Equal(
[WalkDrawStage.Dynamic, WalkDrawStage.CellStatic, WalkDrawStage.CellStatic],
stream.Stages);
}
[Fact]
public void WorldAlphaCyptContract_RetainsTheKeyWithoutDeadCameraSubmitThreading()
{
Type batchType = typeof(WbDrawDispatcher).GetNestedType(
"WalkClassifiedBatch", BindingFlags.NonPublic)
?? throw new InvalidOperationException("WalkClassifiedBatch was not found.");
PropertyInfo sortDistance = batchType.GetProperty("SortDistanceSq")
?? throw new InvalidOperationException("SortDistanceSq was not retained.");
Assert.Equal(typeof(float), sortDistance.PropertyType);
MethodInfo submit = typeof(WbDrawDispatcher).GetMethod(
"SubmitWalkAlphaInstance", BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("SubmitWalkAlphaInstance was not found.");
ParameterInfo[] parameters = submit.GetParameters();
Assert.Equal(2, parameters.Length);
Assert.Equal("batch", parameters[0].Name);
Assert.Equal(typeof(Matrix4x4), parameters[1].ParameterType);
Assert.DoesNotContain(parameters, static parameter => parameter.ParameterType == typeof(Vector3));
string root = FindRepoRoot();
string populator = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "Walk", "WalkStaticStreamPopulator.cs"));
Assert.Contains("batch.LocalSortCenter, batch.Transform", populator);
Assert.Contains("SortDistanceSq = Vector3.DistanceSquared", populator);
Assert.Contains("value.Batch.SortDistanceSq >", populator);
Assert.Contains("AP-243", populator);
Assert.Contains("greater-than-50 m", populator);
Assert.Contains("0x005A06B7..0x005A0720", populator);
}
[Fact]
public void WorldAlphaCyptDocumentationAndRegister_PinPerCellTruthAndThreeResiduals()
{
string root = FindRepoRoot();
string inventory = File.ReadAllText(Path.Combine(
root, "docs", "architecture", "worldbuilder-inventory.md"));
string architecture = File.ReadAllText(Path.Combine(
root, "docs", "architecture", "acdream-architecture.md"));
string registerPath = Path.Combine(
root, "docs", "architecture", "retail-divergence-register.md");
string register = File.ReadAllText(registerPath);
Assert.Contains("per-cell", inventory, StringComparison.OrdinalIgnoreCase);
Assert.Contains("two FIFO", inventory, StringComparison.Ordinal);
Assert.DoesNotContain(
"distance-sorts one shared queue", inventory, StringComparison.OrdinalIgnoreCase);
Assert.Contains("per-cell", architecture, StringComparison.OrdinalIgnoreCase);
Assert.Contains("two FIFO", architecture, StringComparison.Ordinal);
Assert.DoesNotContain(
"one stable far-to-near stream keyed", architecture, StringComparison.OrdinalIgnoreCase);
Assert.Contains("162 active rows", register, StringComparison.Ordinal);
int apSectionStart = register.IndexOf(
"## 3. Documented approximation (AP)", StringComparison.Ordinal);
int apSectionEnd = register.IndexOf(
"## 4. Temporary stopgap (TS)", StringComparison.Ordinal);
Assert.True(apSectionStart >= 0, "AP section heading must exist.");
Assert.True(apSectionEnd > apSectionStart, "TS heading must follow the AP section.");
foreach (string id in new[] { "AP-241", "AP-242", "AP-243" })
{
int rowIndex = register.IndexOf($"| {id} |", StringComparison.Ordinal);
Assert.True(
rowIndex > apSectionStart && rowIndex < apSectionEnd,
$"{id} row must be inside the AP table before the TS section; "
+ $"rowIndex={rowIndex}, AP=[{apSectionStart},{apSectionEnd}).");
}
Assert.Single(File.ReadLines(registerPath), static line =>
line.StartsWith("| AP-241 |", StringComparison.Ordinal));
Assert.Single(File.ReadLines(registerPath), static line =>
line.StartsWith("| AP-242 |", StringComparison.Ordinal));
Assert.Single(File.ReadLines(registerPath), static line =>
line.StartsWith("| AP-243 |", StringComparison.Ordinal));
Assert.Equal(
162,
File.ReadLines(registerPath).Count(static line =>
line.StartsWith("| AP-", StringComparison.Ordinal)));
Assert.Contains("greater than 50 m", register, StringComparison.Ordinal);
Assert.Contains("0x005A06B7..0x005A0720", register, StringComparison.Ordinal);
}
private static string FindRepoRoot()
{
string? directory = AppContext.BaseDirectory;
while (directory is not null)
{
if (File.Exists(Path.Combine(directory, "AcDream.slnx")))
return directory;
directory = Directory.GetParent(directory)?.FullName;
}
throw new DirectoryNotFoundException("Could not locate AcDream.slnx.");
}
// ── Deliverable 1: ClassifyEntityForWalk data equivalence ───────────── // ── Deliverable 1: ClassifyEntityForWalk data equivalence ─────────────
[Fact] [Fact]
@ -573,13 +796,12 @@ public sealed class WalkStaticStreamPopulatorTests
var key = new GroupKey(10, 2, 6, new GpuTextureSlot(3), 1, TranslucencyKind.AlphaBlend, FoliageFlags: 0); var key = new GroupKey(10, 2, 6, new GpuTextureSlot(3), 1, TranslucencyKind.AlphaBlend, FoliageFlags: 0);
Vector3 localSortCenter = new(1, 2, 3); Vector3 localSortCenter = new(1, 2, 3);
Matrix4x4 model = Matrix4x4.CreateTranslation(4, 5, 6); Matrix4x4 model = Matrix4x4.CreateTranslation(4, 5, 6);
var cameraWorldPosition = Vector3.Zero;
var batch = new WbDrawDispatcher.WalkClassifiedBatch( var batch = new WbDrawDispatcher.WalkClassifiedBatch(
key, model, ClipSlot: 7, WbDrawDispatcher.InstanceLightSet.Disabled, IndoorFlag: 1, key, model, ClipSlot: 7, WbDrawDispatcher.InstanceLightSet.Disabled, IndoorFlag: 1,
Alpha: 0.5f, SelectionLighting: new Vector2(0.25f, 0.75f), DetailCategory: 1, Alpha: 0.5f, SelectionLighting: new Vector2(0.25f, 0.75f), DetailCategory: 1,
IsOpaque: false, LocalSortCenter: localSortCenter); IsOpaque: false, LocalSortCenter: localSortCenter);
fx.Dispatcher.SubmitWalkAlphaInstance(in batch, cameraWorldPosition, Matrix4x4.Identity); fx.Dispatcher.SubmitWalkAlphaInstance(in batch, Matrix4x4.Identity);
Assert.Equal(1, fx.AlphaQueue.PendingCount); Assert.Equal(1, fx.AlphaQueue.PendingCount);
Assert.Equal(1, fx.AlphaQueue.AlphaCount); Assert.Equal(1, fx.AlphaQueue.AlphaCount);
@ -614,7 +836,6 @@ public sealed class WalkStaticStreamPopulatorTests
WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch(detailCategory: 1u); WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch(detailCategory: 1u);
on.Dispatcher.SubmitWalkAlphaInstance( on.Dispatcher.SubmitWalkAlphaInstance(
in building, in building,
Vector3.Zero,
Matrix4x4.Identity); Matrix4x4.Identity);
Assert.Equal(0, on.AlphaQueue!.PendingCount); Assert.Equal(0, on.AlphaQueue!.PendingCount);
@ -636,7 +857,6 @@ public sealed class WalkStaticStreamPopulatorTests
WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch(detailCategory: 1u); WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch(detailCategory: 1u);
off.Dispatcher.SubmitWalkAlphaInstance( off.Dispatcher.SubmitWalkAlphaInstance(
in building, in building,
Vector3.Zero,
Matrix4x4.Identity); Matrix4x4.Identity);
Assert.Equal(1, off.AlphaQueue!.PendingCount); Assert.Equal(1, off.AlphaQueue!.PendingCount);
@ -656,7 +876,6 @@ public sealed class WalkStaticStreamPopulatorTests
WbDrawDispatcher.WalkClassifiedBatch batch = AlphaWalkBatch(detailCategory: 0u); WbDrawDispatcher.WalkClassifiedBatch batch = AlphaWalkBatch(detailCategory: 0u);
ordinary.Dispatcher.SubmitWalkAlphaInstance( ordinary.Dispatcher.SubmitWalkAlphaInstance(
in batch, in batch,
Vector3.Zero,
Matrix4x4.Identity); Matrix4x4.Identity);
Assert.Equal(1, ordinary.AlphaQueue!.PendingCount); Assert.Equal(1, ordinary.AlphaQueue!.PendingCount);
@ -750,11 +969,11 @@ public sealed class WalkStaticStreamPopulatorTests
key, Matrix4x4.Identity, 0, WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f, key, Matrix4x4.Identity, 0, WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f,
Vector2.Zero, 0, IsOpaque: false, LocalSortCenter: new Vector3(0, 0, 10)); Vector2.Zero, 0, IsOpaque: false, LocalSortCenter: new Vector3(0, 0, 10));
fx.Dispatcher.SubmitWalkAlphaInstance(in batch, Vector3.Zero, Matrix4x4.Identity); fx.Dispatcher.SubmitWalkAlphaInstance(in batch, Matrix4x4.Identity);
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
fx.Dispatcher.SubmitWalkAlphaInstance( fx.Dispatcher.SubmitWalkAlphaInstance(
in batch, Vector3.Zero, Matrix4x4.CreateTranslation(1, 0, 0))); in batch, Matrix4x4.CreateTranslation(1, 0, 0)));
fx.AlphaQueue.AbortFrame(); fx.AlphaQueue.AbortFrame();
} }

View file

@ -218,8 +218,10 @@ public sealed partial class WalkTraceConformanceTests
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells) { } IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells) { }
public bool HasRenderableEmittersInCell(uint cellId) => false; public bool HasRenderableEmittersInCell(uint cellId) => false;
public void DrawCellShell(uint cellId) { } public void DrawCellShell(uint cellId) { }
public void DrawStaticParticles(uint cellId) { } public ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareStaticParticles(uint cellId) =>
public void DrawCellParticles(uint cellId) { } ReadOnlySpan<PreparedParticleAlphaSubmission>.Empty;
public ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareCellParticles(uint cellId) =>
ReadOnlySpan<PreparedParticleAlphaSubmission>.Empty;
public void ClearInteriorDepth() { } public void ClearInteriorDepth() { }
public void FlushLandscape() => RecordFlush(RetailAlphaFlushSite.LandscapeFlush, 0f); public void FlushLandscape() => RecordFlush(RetailAlphaFlushSite.LandscapeFlush, 0f);
public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) { } public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) { }
@ -275,6 +277,8 @@ public sealed partial class WalkTraceConformanceTests
/// query returns the shared empty record.</summary> /// query returns the shared empty record.</summary>
private sealed class EmptyAlphaDepthWorldData : IWalkFrameWorldData private sealed class EmptyAlphaDepthWorldData : IWalkFrameWorldData
{ {
public WalkFrameStaticRecords GetCellObjects(uint cellId) => WalkFrameStaticRecords.Empty;
public WalkFrameStaticRecords GetOutdoorObjects(uint cellId) => WalkFrameStaticRecords.Empty;
public WalkFrameStaticRecords GetCellStatics(uint cellId) => WalkFrameStaticRecords.Empty; public WalkFrameStaticRecords GetCellStatics(uint cellId) => WalkFrameStaticRecords.Empty;
public WalkFrameStaticRecords GetCellDynamics(uint cellId) => WalkFrameStaticRecords.Empty; public WalkFrameStaticRecords GetCellDynamics(uint cellId) => WalkFrameStaticRecords.Empty;
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) => WalkFrameStaticRecords.Empty; public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) => WalkFrameStaticRecords.Empty;
@ -475,16 +479,14 @@ public sealed partial class WalkTraceConformanceTests
/// so every 0f-threshold flush observes count (0, 0) — always a real /// so every 0f-threshold flush observes count (0, 0) — always a real
/// drain (0 is never <c>&lt; 0</c>), matching retail's own always-drains /// drain (0 is never <c>&lt; 0</c>), matching retail's own always-drains
/// behavior at threshold 0f, but with a different count than the /// behavior at threshold 0f, but with a different count than the
/// capture's real content volume; (2) even WITH content, acdream appends /// capture's real content volume; (2) AP-238 coalesces transparent
/// one queue entry per INSTANCE, where retail's <c>AddMeshToAlphaList</c> /// EnvCell work to one token per <c>(cell,list)</c>, whereas retail
/// appends one entry per SUBSET per <c>DrawMesh</c> call — a single /// appends each contributing subset; (3) AP-239/AP-240 change
/// multi-subset instance inflates retail's count relative to acdream's; /// CLIP-vs-ALPHA membership for the named ordinary-Wb surface cases.
/// (3) acdream's CLIP list is structurally empty for ordinary content /// Ordinary Wb and particle paths otherwise append per real subset; the
/// (M2's new register row) — <c>WbDrawDispatcher.IsOpaque</c> filters /// former broad "per instance versus per subset" explanation was false.
/// clip-mapped subsets out before they ever reach a submit call, so /// See the S4 packet's §9/§10/§11 subsections for the full per-pose
/// acdream's CLIP count reads 0 wherever retail's capture shows nonzero /// sequences.
/// CLIP entries. See the S4 packet's §9/§10/§11 subsections for the
/// full per-pose sequences.
/// </summary> /// </summary>
private void RunAlphaFlushCountsGate(string fixtureName) private void RunAlphaFlushCountsGate(string fixtureName)
{ {