feat(render): S2 chunk 6 — particle emitters draw by their own cell (add_particle_shadow_to_cell)

Owner G2 finding: the purple cloud around an arriving character no longer
drew. The server keeps the player Hidden until acdream sends LoginComplete at
reveal completion (retail-correct); the Hidden-state script's emitters spawn
in the arrival cell and are view-eligible when the world appears, but the
walk drew an owner's emitters only through the owner's registry rows, and a
hidden owner's shadow is suspended. Retail's
CPhysicsObj::add_particle_shadow_to_cell (0x00514a70) gives an emitter one
shadow in its OWN current cell, drawn at that cell's turn regardless of the
parent's hidden state (add_shadows_to_cells 0x00514aed skips the flood for
state & 0x1000).

Port: ParticleSystem keeps a per-pass cell -> renderable-handles index
(maintained at every renderable/OwnerCellId change) and
CopyRenderableEmittersInCell; ParticleRenderer.DrawForCell; the walk draws
particles BY CELL at the existing turns (interior CellParticles, landscape
LandscapeCellParticles), the events fire for every visited cell, and every
owner-union particle path is deleted (UnionOwners/UnionNewOwners for
particles, the outdoor drawn-owner dedupe, the executor's owner
classification sets, the context ParticleOwnerIds members). The post-replay
per-cell pass double-submitted the root flood's emitters and is deleted: an
emitter draws once, at its cell's replay turn. AD-117 item 4 becomes a port
note (the index lives in the particle system; an emitter is not a physics
object in acdream). The temporary [pes-spawn]/[pes-vis] traces are removed
and the ACDREAM_DUMP_PLAYSCRIPT row restored.

Verified: timed arrival route logs/selfgate-20260903-062522-haze-chunk6,
frame h02-arrive-400ms shows the cloud at the character in Facility Hub.
Gates (Release): Core 4,987/4,987; Content 214/214; Runtime 1,884/1,884; App
hermetic lane 6,760/6,760; App InstalledDat 217 pass / 2 pre-existing #383.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-03 06:28:09 +02:00
parent 2d20ee917b
commit f6b4584bf3
14 changed files with 400 additions and 336 deletions

View file

@ -7,21 +7,6 @@ namespace AcDream.App.Tests.Rendering;
public sealed class RetailPViewPassExecutorTests
{
[Fact]
public void Particle_classifications_reset_before_an_empty_following_frame()
{
var classifications = new RetailPViewParticleClassifications();
classifications.ReplaceOutdoor(new HashSet<uint> { 7u });
classifications.Visible.Add(8u);
classifications.Dynamics.Add(9u);
classifications.BeginFrame();
Assert.Empty(classifications.Outdoor);
Assert.Empty(classifications.Visible);
Assert.Empty(classifications.Dynamics);
}
[Fact]
public void Extracted_contracts_retain_no_window_callbacks_or_visibility_owner()
{
@ -42,17 +27,8 @@ public sealed class RetailPViewPassExecutorTests
}
[Fact]
public void Concrete_executor_forwards_frame_reset_and_brackets_terrain_diagnostics()
public void Concrete_executor_brackets_terrain_diagnostics()
{
MethodInfo begin = typeof(RetailPViewPassExecutor).GetMethod(
nameof(RetailPViewPassExecutor.BeginFrame))!;
IReadOnlyList<CompiledCall> beginCalls = CompiledCallGraph.Read(begin);
Assert.True(
CompiledCallGraph.IndexOf(
beginCalls,
typeof(RetailPViewParticleClassifications),
nameof(RetailPViewParticleClassifications.BeginFrame)) >= 0);
MethodInfo landscape = typeof(RetailPViewPassExecutor).GetMethod(
"DrawWalkTerrainSlice",
BindingFlags.Instance | BindingFlags.NonPublic)!;

View file

@ -81,23 +81,9 @@ public sealed class WalkFrameDriverTests
alpha.Flush();
}
public void DrawStaticParticles(IReadOnlySet<uint> ownerIds)
{
var sorted = new List<uint>(ownerIds);
sorted.Sort();
log.Add($"PARTICLES:{string.Join(",", sorted.ConvertAll(o => o.ToString("x")))}");
}
public void DrawStaticParticles(uint cellId) => log.Add($"PARTICLES:{cellId:x8}");
public void DrawCellParticles(
uint cellId,
IReadOnlySet<uint> staticParticleOwnerIds)
{
var sorted = new List<uint>(staticParticleOwnerIds);
sorted.Sort();
log.Add(
$"CELL-PARTICLES:{cellId:x8}:"
+ string.Join(",", sorted.ConvertAll(o => o.ToString("x"))));
}
public void DrawCellParticles(uint cellId) => log.Add($"CELL-PARTICLES:{cellId:x8}");
}
private sealed class RecordingTrace(List<string> log) : IWalkFrameDriverTrace
@ -287,8 +273,8 @@ public sealed class WalkFrameDriverTests
{
"SKY", "TERRAIN:0", "CLEAR", "SEALS",
"SHELL:00000101", "SHELL:00000100",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101:66",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100:65",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
},
log);
@ -435,8 +421,8 @@ public sealed class WalkFrameDriverTests
new[]
{
"CLEAR", "SEALS", "SHELL:00000101", "SHELL:00000100",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101:66",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100:65",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
},
log);
@ -539,14 +525,15 @@ public sealed class WalkFrameDriverTests
driver.Replay(draw.Frame, draw.Pass);
Assert.Equal(
// FW4 #132 positional invariant: the building's shell emitters
// submit at the shell turn, after the shell content flushes.
// Campaign OVERHAUL S2 chunk 6: a building's shell fires no
// particle turn of its own any more — interior emitters draw at
// their own cell's CellParticles turn instead.
new[]
{
"ALPHA", "PUNCH:4@v0", "SHELL:00000104",
"FLUSH:1:LookInStatic", "FLUSH:1:Dynamic",
"CELL-PARTICLES:00000104:ca,cb",
"FLUSH:1:BuildingShell", "PARTICLES:c9",
"CELL-PARTICLES:00000104",
"FLUSH:1:BuildingShell",
},
log);
@ -623,6 +610,42 @@ public sealed class WalkFrameDriverTests
Assert.Equal(2, log.Count(entry => entry == "SEALS"));
}
// Campaign OVERHAUL S2 chunk 6 pin — the portal-haze bug this chunk
// fixes: a hidden/suspended owner (a portalling player materializing
// into its arrival cell) publishes NO registry rows there, so
// GetCellStatics/GetCellDynamics report empty records for that cell —
// yet retail's CPhysicsObj::add_particle_shadow_to_cell (0x00514a70)
// still draws that owner's emitter at the cell's own object-list turn,
// because particle draw membership is the emitter's OWN cell, never a
// registry/owner lookup. The walk must fire the cell's particle turn
// regardless of whether any static/dynamic record names an owner there.
[Fact]
public void EmitCellContentsTurn_FiresCellParticlesEvenWhenTheCellHasNoStaticOrDynamicRecords()
{
using var fx = new DispatcherFixture();
var log = new List<string>();
var leaf = new RecordingLeafRenderer(log);
var ctx = new TestContext();
const uint arrivalCellId = 0x8A020141u;
var arrivalCell = new WalkCell { CellId = arrivalCellId };
ctx.Cells[arrivalCellId] = arrivalCell;
// FakeWorldData has NO entry at all for the arrival cell —
// GetCellStatics/GetCellDynamics both fall back to
// WalkFrameStaticRecords.Empty, exactly the "hidden owner, no
// registry rows" scenario.
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
IWalkEventSink sink = driver;
using DrawScope draw = fx.BeginDraw();
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
sink.OnInteriorFloodDrawTurn([arrivalCellId]);
driver.EndFrame();
driver.Replay(draw.Frame, draw.Pass);
Assert.Contains("CELL-PARTICLES:8a020141", log);
}
[Fact]
public void LandscapeStampBoundary_RearmsWholeShellForPostClearRootRepaint()
{
@ -877,9 +900,10 @@ public sealed class WalkFrameDriverTests
driver.Replay(draw.Frame, draw.Pass);
// FW4 #132 positional invariant: the cell's emitters submit at its
// own landscape turn, after its meshes flush.
// own landscape turn, after its meshes flush. Campaign OVERHAUL S2
// chunk 6: the leaf draws by CELL, not by owner set.
Assert.Equal(
new[] { "FLUSH:2:OutdoorStatic,Dynamic", "PARTICLES:12d,12e" },
new[] { "FLUSH:2:OutdoorStatic,Dynamic", "PARTICLES:8c040005" },
log);
var visibleCells = new HashSet<uint> { 0xDEAD_BEEFu };
driver.CopyVisibleCellsTo(visibleCells);
@ -890,8 +914,15 @@ public sealed class WalkFrameDriverTests
Assert.Equal(2u, mdi.Aggregate(0u, static (sum, call) => sum + call.DrawCount));
}
// Campaign OVERHAUL S2 chunk 6: the mesh-dedup shape (a shadow alias's
// record entering two landscape cells' dictionaries) is a MESH-only
// concern — retail's per-part render membership is whole-object per
// cell. Particles are NOT part of that alias: an emitter owns exactly
// one shadow in its own current cell (add_particle_shadow_to_cell
// 0x00514a70), so each VISITED landscape cell fires its own independent
// StaticParticles turn with no cross-cell dedupe needed or performed.
[Fact]
public void OnLandscapeCellTurn_MultiCellShadowAlias_DrawsMeshAndParticlesOnlyOnce()
public void OnLandscapeCellTurn_MultiCellShadowAlias_DrawsMeshOnceButParticlesPerVisitedCell()
{
using var fx = new DispatcherFixture();
var log = new List<string>();
@ -934,7 +965,7 @@ public sealed class WalkFrameDriverTests
driver.Replay(draw.Frame, draw.Pass);
Assert.Equal(
new[] { "FLUSH:1:Dynamic", "PARTICLES:12f" },
new[] { "FLUSH:1:Dynamic", "PARTICLES:f07f0040", "PARTICLES:f0800001" },
log);
GpuRecordedMultiDrawIndirect[] mdi =
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().ToArray();