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:
parent
2d20ee917b
commit
f6b4584bf3
14 changed files with 400 additions and 336 deletions
|
|
@ -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)!;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -837,6 +837,115 @@ public sealed class ParticleSystemTests
|
|||
Assert.Single(destination);
|
||||
}
|
||||
|
||||
// Campaign OVERHAUL S2 chunk 6: retail CPhysicsObj::add_particle_shadow_to_cell
|
||||
// (0x00514a70) gives an emitter exactly one shadow in ITS OWN current
|
||||
// cell. These pins drive CopyRenderableEmittersInCell purely through the
|
||||
// public ParticleSystem API — no ShadowObjectRegistry/owner concept is
|
||||
// involved at all.
|
||||
|
||||
[Fact]
|
||||
public void CellIndex_AddRemoveMove_TracksRenderableEmittersPerCell()
|
||||
{
|
||||
var sys = MakeSystem();
|
||||
var desc = new EmitterDesc
|
||||
{
|
||||
DatId = 0x32000090u,
|
||||
Type = ParticleType.Still,
|
||||
MaxParticles = 1,
|
||||
};
|
||||
int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: 501u);
|
||||
sys.UpdateEmitterOwnerCell(handle, 0x0102_0001u);
|
||||
var visible = new HashSet<uint> { 0x0102_0001u, 0x0102_0002u };
|
||||
sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true);
|
||||
|
||||
var destination = new List<ParticleEmitter>();
|
||||
|
||||
// Add: the emitter is enumerated by its cell once renderable.
|
||||
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0001u, destination);
|
||||
Assert.Equal(new[] { handle }, destination.Select(e => e.Handle));
|
||||
|
||||
// A different cell (even one that IS visible) enumerates nothing.
|
||||
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u, destination);
|
||||
Assert.Empty(destination);
|
||||
|
||||
// Move: the handle relocates from the old cell's bucket to the new
|
||||
// one — an emitter is in exactly one cell at a time.
|
||||
sys.UpdateEmitterOwnerCell(handle, 0x0102_0002u);
|
||||
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0001u, destination);
|
||||
Assert.Empty(destination);
|
||||
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u, destination);
|
||||
Assert.Equal(new[] { handle }, destination.Select(e => e.Handle));
|
||||
|
||||
// Remove: a hard stop clears the cell bucket too.
|
||||
sys.StopEmitter(handle, fadeOut: false);
|
||||
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u, destination);
|
||||
Assert.Empty(destination);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellIndex_MultipleEmittersInOneCell_EnumerateInSpawnOrder()
|
||||
{
|
||||
var sys = MakeSystem();
|
||||
var desc = new EmitterDesc
|
||||
{
|
||||
DatId = 0x32000091u,
|
||||
Type = ParticleType.Still,
|
||||
MaxParticles = 1,
|
||||
};
|
||||
int first = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: 601u);
|
||||
sys.UpdateEmitterOwnerCell(first, 0x0203_0005u);
|
||||
int second = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: 602u);
|
||||
sys.UpdateEmitterOwnerCell(second, 0x0203_0005u);
|
||||
int unattachedInSameCell = sys.SpawnEmitter(desc, Vector3.Zero);
|
||||
sys.UpdateEmitterOwnerCell(unattachedInSameCell, 0x0203_0005u);
|
||||
|
||||
var visible = new HashSet<uint> { 0x0203_0005u };
|
||||
sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true);
|
||||
|
||||
var destination = new List<ParticleEmitter>();
|
||||
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0203_0005u, destination);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { first, second, unattachedInSameCell },
|
||||
destination.Select(e => e.Handle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THE chunk-6 pin: an emitter's cell membership does not depend on its
|
||||
/// attached owner ever having any OTHER visible presence. This is what
|
||||
/// makes the retail-Hidden portalling player's emitter still draw in its
|
||||
/// arrival cell — <c>ParticleSystem</c> has no coupling to
|
||||
/// <c>ShadowObjectRegistry</c> at all; presentation/view-eligibility (set
|
||||
/// by the hook sink / <see cref="ParticleSystem.ApplyRetailView"/>) is the
|
||||
/// ONLY gate, never the owner's render/collision membership.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CopyRenderableEmittersInCell_FindsAnEmitterWhoseOwnerHasNoOtherPresence()
|
||||
{
|
||||
var sys = MakeSystem();
|
||||
var desc = new EmitterDesc
|
||||
{
|
||||
DatId = 0x32000092u,
|
||||
Type = ParticleType.Still,
|
||||
MaxParticles = 1,
|
||||
};
|
||||
// A large, otherwise-unused owner id stands in for a hidden/suspended
|
||||
// entity that publishes no registry rows anywhere; ParticleSystem
|
||||
// never looks at any such registry, so nothing needs to simulate one.
|
||||
const uint hiddenOwnerId = 0x5000_00FFu;
|
||||
const uint arrivalCellId = 0x8A02_0141u;
|
||||
int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: hiddenOwnerId);
|
||||
sys.UpdateEmitterOwnerCell(handle, arrivalCellId);
|
||||
|
||||
var visible = new HashSet<uint> { arrivalCellId };
|
||||
sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true);
|
||||
|
||||
var destination = new List<ParticleEmitter>();
|
||||
sys.CopyRenderableEmittersInCell(ParticleRenderPass.Scene, arrivalCellId, destination);
|
||||
|
||||
Assert.Equal(new[] { handle }, destination.Select(e => e.Handle));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderedIndexes_PreserveSpawnOrderAcrossHardRemovalAndPassFiltering()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue