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

@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Collections;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
@ -12,6 +13,7 @@ using AcDream.App.Rendering.Walk;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
@ -130,9 +132,17 @@ public sealed partial class WalkFrameDriverTests
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
@ -141,6 +151,39 @@ public sealed partial class WalkFrameDriverTests
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
{
public readonly Dictionary<uint, WalkFrameStaticRecords> CellStaticsByCell = new();
@ -153,12 +196,22 @@ public sealed partial class WalkFrameDriverTests
public WalkFrameStaticRecords GetCellStatics(uint cellId) =>
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) =>
CellDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) =>
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) =>
OutdoorDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
@ -167,6 +220,25 @@ public sealed partial class WalkFrameDriverTests
public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building) =>
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) ─
@ -1001,7 +1073,9 @@ public sealed partial class WalkFrameDriverTests
new[]
{
"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",
"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
// surviving exit view — the SAME fixture as
// RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells
@ -2217,6 +2490,8 @@ public sealed partial class WalkFrameDriverTests
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
public WbMeshAdapter MeshAdapter => _meshAdapter;
public DrawScope BeginDraw(bool beginAlpha = false)
{
if (beginAlpha)