From b6b01560457cf337625ca702c9bb7da1b1b189e3 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 4 Sep 2026 14:09:50 +0200 Subject: [PATCH] fix(render): retain surfaceless particle metadata Preserve a successfully decoded GfxObj ParticleGfxInfo when SurfaceId is zero and condition only texture acquisition. Missing/no-Gfx descriptors retain Default behavior; material, additive, routing, and untextured drawing remain unchanged. Add a production-path GfxObj-backed synthetic no-surface pin with distinct authored/AABB centers, non-unit size, nonidentity orientation, object/particle merge order, visual center, and unassigned texture-slot assertions. Correct the synthetic-DAT evidence wording and advance only the S4-c3a ledger row. Final F2 source audit found no defect: preparation holds reconstruction inputs only; Append reserves once; false and exception roll back the exact tail once; first-use rejection remains registered; accepted tokens stay stable; independent 3000-entry caps preserve row-2 immediate duplicates; flush/end/abort/next-preparation cleanup converges; retained capacity/bytes remain bounded; warmed production paths remain 0 B. Gates: no-surface target 1/1; unchanged section 19 baseline 95/95; AP boundary/count 1/1; real allocations 2/2 at 0 B; shader/manifest 32/32; Release solution 0 warnings/0 errors; diff-check pass. No graphical client. Mutation: restoring eea5793d2's SurfaceId-zero-to-Default ternary first failed CellTurn_GfxObjBillboardWithoutSurfaceRetainsAuthoredSortCenterAndUntexturedFallback at the authored-distance assertion, expected 40 actual 100. Co-Authored-By: Claude Sonnet 4.5 --- ...-09-01-campaign-overhaul-world-solidity.md | 2 +- .../s4-depth-alpha-packet.md | 66 +++++++++ src/AcDream.App/Rendering/ParticleRenderer.cs | 8 +- .../Rendering/Walk/WalkFrameDriverTests.cs | 134 +++++++++++++++++- 4 files changed, 202 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md index 6ba77d51..1df288f3 100644 --- a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md +++ b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md @@ -678,7 +678,7 @@ Update immediately when a slice changes state. Chat is not the ledger. | S4-c2 stop | **STOPPED UNLANDED 2026-09-04** — lead final-round contract `ac74fbd84`; evidence commit `c4cbc1d0d` in `s4-c2-impl` on `cc8e5677a`/`048d5b12f`; campaign renderer remains S4-c1 `766f9e749` | G3/G4 UNPASSED | Both sequential final lenses FAIL, so the required repair is the forbidden THIRD round. EnvCell transparent/ClipMap batches still draw in the opaque pass before FIFO submission and then redraw; CLIP/detail-immediate replay binds StraightAlpha with depth-write off rather than retail clip-test/depth-writing state; tests omit the preceding opaque call and pipeline descriptors. Source-owned EnvCell lists are unbounded under queue rejection, the claimed scan/filter/RHI 0-B evidence bypasses those paths, and AP-238/AP-240/result truth is false. Full file:line and gate record: S4 packet §13. No chunk cherry-pick, graphical gate, or soak. Resume only on explicit owner process change. | | S4-c2 owner repair | **STOPPED UNLANDED 2026-09-04** — owner-authorized implementation `a094bf2b7` is clean in `s4-c2-impl`; packet §15 is the review record | G3/G4 UNPASSED | The repaired renderer behavior passed the retail lens: exact whole-leaf pass membership, `0x08`/`0x09` CLIP, row-3 override, blend/reference/depth state, sentinel separation, detail state, rollback, allocation, AP-238/AP-240. The lens still returned FAIL: commit body omitted the required per-mutation first failures, packet mutation 5 names the wrong first assertion/address, and four source/test comments are stale. Sequential production lens therefore did not run. Correcting those items is a post-review fourth repair, which §14 does not authorize. Nothing landed or graphically gated; resume only by explicit owner process decision. | | S4-c2 attempts 1–5 | **ATTEMPT 1/5 CONSUMED; ATTEMPT 2/5 PASSED AND LANDED 2026-09-04** — reviewed implementation `daaeab0ba`; campaign stack `89f1e2676` → `9ccb61a8e` → `0aa166aa0` → `252886e84`; packet §17 | G3/G4 UNPASSED | Sequential retail/evidence then production/gate-honesty PASS. Fresh: Release 0W/0E; shader 32/32; affected 37/37; real allocation 2/2 at 0 B; production 239/239. Official no-retry hermetic 16,734/1: sole unchanged global `Console.Out` capture race, exact theory 2/2 isolated. InstalledDat 255/10 documented/1. Validation routes `selfgate-20260904-115818-s4c2-landed-route` and `selfgate-20260904-120025-s4c2-landed-g3b` PASS provisional. First soak failed on known #461/#462; retry and S4-c1 control both 9/9 graceful; stationary averages CPU p95 −0.01 ms, GPU p95 +0.16 ms, alloc p50 +1.2 KiB, update p95 −0.12 ms. Three attempts remain unused. | -| S4-c3a | **ATTEMPT 3 RETAIL FAIL; §19 FIX ROUND 1 IMPLEMENTED — REVIEW PENDING.** Candidate `44e2bc227b` used the billboard's rendered/AABB-derived center instead of GfxObj's authored sort center, reserved prepared payload before queue visibility, did not bound the new retained preparation list, and misstated static-record ordering. The §19 repair now keeps authored and visual centers distinct, applies scale/orientation to the authored CYpt point, reserves and exactly rolls back at Append, caps preparation at 3,000 CLIP + 3,000 ALPHA, preserves immediate duplicates, and corrects the comment. Return gates: Release 0W/0E; focused 95/95; register pin 1/1; real allocation 2/2 at 0 B; shader/manifest 32/32; hermetic 16,751/16,751; InstalledDat 385 pass / 10 expected fail / 1 skip with the exact documented set; eight discriminating mutations recorded in packet §19.5. | G3/G4 UNPASSED | Retail re-review F1/paired bytes/F3 and unchanged S4-c2 behavior, then production/gate-honesty review of the full c3a stack with special attention to F2. One bounded fix round remains after §19; a further finding stops the chunk. c3b remains locked. | +| S4-c3a | **§19 RETAIL RE-REVIEW FAIL; §20 FINAL FIX ROUND 2 IMPLEMENTED — FINAL REVIEW PENDING.** Candidate `eea5793d2` replaced every successfully decoded zero-Surface GfxObj with `ParticleGfxInfo.Default`, discarding authored SortCenter, AABB visual center, axes, and degrade mode. The §20 repair preserves decoded metadata and conditions only texture acquisition; a production-path synthetic no-Surface GfxObj proves distance² 40, object/particle merge order, visual center, and the untextured slot. Installed September 2013 exposure remains zero. Final F2 source audit found no additional defect. Return gates: target 1/1; unchanged focused baseline 95/95; AP pin 1/1; real allocation 2/2 at 0 B; shader/manifest 32/32; Release 0W/0E; one discriminating mutation recorded in packet §20.4. | G3/G4 UNPASSED | Final retail review of the no-Surface branch and wording, then sequential production/gate-honesty review of the complete c3a stack. Any further code, test, register, or evidence finding is a third fix round and stops c3a; c3b remains locked. | | S5 | — | G4 | fill | --- diff --git a/docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md b/docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md index daf84a59..6d0f9a17 100644 --- a/docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md +++ b/docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md @@ -1702,3 +1702,69 @@ no-surface branch and evidence wording first. Only PASS unlocks the sequential production/gate-honesty lens over the complete c3a stack. Any further code, test, register, or evidence correction would be a third fix round: stop the chunk and write it up; do not land or graphically gate it. + +### 20.4 Final-fix implementation result (Sonnet, 2026-09-04) + +**IMPLEMENTED — FINAL RETAIL REVIEW REQUIRED.** A successfully decoded GfxObj +now retains its complete `ParticleGfxInfo` when `SurfaceId == 0`; texture +acquisition alone remains conditional on a nonzero Surface DID. Missing or +unreadable GfxObjs and descriptors without a GfxObj retain the existing +`Default` behavior. The production-path synthetic GfxObj test supplies no +Surface DID or entry, a nonzero authored center distinct from its vertex-AABB +center, size 2, and a nonidentity orientation. It proves authored distance² +40, ordinary-object-then-particle merge order against distance² 49, the +unchanged visual center `(10,4,0)`, and an unassigned texture slot. The +evidence comment now calls both fixtures “production-path GfxObj-backed +synthetic DAT records,” not real or installed DATs. + +Required §20.2 final-round source audit found no additional defect: + +- `PrepareForCellAlpha` clears and rebuilds only reconstruction records; it + does not add source payload to `_deferredAlpha`. +- `PreparedParticleAlphaSubmission.Append` calls + `ReservePreparedDispatchDeferredParticle` exactly once at the visibility + edge, then calls `TryAppend` once. +- A false return rolls back that exact tail once outside the `try`; an + exception rolls it back once in the `catch` and rethrows. The rollback + method rejects any non-tail token, so an accepted earlier token cannot be + renumbered or removed. +- `RetailAlphaQueue.TryAppend` registers the source before its capacity + return. A rejected first-use source therefore still participates in the + next flush/end/abort reset while owning no rejected payload. Accepted token + indices remain stable. +- Independent retained counters admit only the first 3,000 CLIP and first + 3,000 ALPHA candidates from the already stable particle order. The row-2 + immediate action is computed independently of retention, so candidate + 3,001 still draws immediately. +- Flush, EndFrame, and AbortFrame reset `_deferredAlpha`, prepared RHI count, + and `_preparedCellAlphaScratch`; the next preparation also clears the + scratch before any early return or rebuild. The immediate-exception path + holds no source payload and converges on that next preparation/abort. +- The prepared list can retain at most 6,000 records and therefore at most the + 8,192-slot `List` geometric backing pinned by the diagnostic. Its bytes + remain included in `RetainedAlphaScratchBytes`. +- The real warmed driver/particle/dispatcher merge and the immediate-particle + RHI path both still measure zero managed bytes. + +Mutation ledger: restoring candidate `eea5793d2`'s +`SurfaceId == 0 ? ParticleGfxInfo.Default : ...` ternary made +`CellTurn_GfxObjBillboardWithoutSurfaceRetainsAuthoredSortCenterAndUntexturedFallback` +first fail its authored-distance assertion: expected `40`, actual `100`. +The mutation was restored before the return matrix. + +Automated return: + +- targeted no-surface production path — PASS, 1/1 after mutation restore; +- unchanged §19 focused baseline (excluding the separately executed new + target) — PASS, 95/95, 0 skipped; +- AP-241/AP-242/AP-243 boundary/count pin — PASS, 1/1; +- real production allocation pins — PASS, 2/2, both asserting 0 B; +- shader/manifest/SPIR-V lane — PASS, 32/32; +- Release solution build — PASS, 0 warnings / 0 errors; +- `git diff --check` — PASS (only the repository's LF-to-CRLF working-copy + notices). + +Only `ParticleRenderer.cs`, `WalkFrameDriverTests.cs`, this packet, and the +S4-c3a plan-ledger row changed. AP rows, queue/router/pipeline/shader/manifest/ +package behavior, and installed content remain unchanged. No graphical client +was launched. diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 2e8046e9..e91f7572 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -1114,14 +1114,16 @@ public sealed unsafe partial class ParticleRenderer : IDisposable _particleGfxInfoByGfxObj[gfxObjId] = info; } - resolved = info.SurfaceId == 0 - ? ParticleGfxInfo.Default - : info with + resolved = info; + if (info.SurfaceId != 0) + { + resolved = info with { TextureSlot = _textures.AcquireParticleTexture( emitter.Handle, info.SurfaceId), }; + } _particleGfxInfoByEmitter.Add(emitter.Handle, resolved); return resolved; } diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index defef610..5024adb7 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -1972,10 +1972,10 @@ public sealed partial class WalkFrameDriverTests } /// - /// S4-c3a fix round 1 F1: the real DAT-backed mode-2 path keeps the GfxObj - /// SortCenter distinct from its vertex-AABB visual center. The particle is - /// at (10,0,0), sized 2 and rotated +90 degrees around Z: authored - /// SortCenter (1,2,0) therefore lands at (6,2,0), distance² 40. The AABB + /// S4-c3a fix round 1 F1: production-path GfxObj-backed synthetic DAT + /// records keep SortCenter distinct from the vertex-AABB visual center. + /// The particle is at (10,0,0), sized 2 and rotated +90 degrees around Z: + /// authored SortCenter (1,2,0) lands at (6,2,0), distance² 40. The AABB /// center (2,0,0) remains the viewer-facing draw center at (10,4,0). /// The ordinary object at distance² 49 must win the cross-source merge. /// @@ -2109,6 +2109,132 @@ public sealed partial class WalkFrameDriverTests fx.AlphaQueue.AbortFrame(); } + /// + /// S4-c3a final fix round: a successfully decoded GfxObj without a Surface + /// still owns its authored SortCenter, bounds-derived visual center, axes, + /// and degrade mode. Only texture acquisition is absent. With the same + /// scale/orientation geometry as the surfaced sibling above, CYpt remains + /// distance² 40, the ordinary object at distance² 49 stays first, and the + /// billboard retains its untextured slot plus visual center. + /// + [Fact] + public void CellTurn_GfxObjBillboardWithoutSurfaceRetainsAuthoredSortCenterAndUntexturedFallback() + { + using var fx = new DispatcherFixture(); + const uint cellId = 0x8C0400A2u; + const uint objectGfx = 0x02000CA2u; + const uint particleGfx = 0x01000CA2u; + const uint degradeId = 0x11000CA2u; + InjectRenderData(fx.Manager, objectGfx, MakeFlatMesh( + MakeBatch(0x08100CA2u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1))); + var worldData = new FakeWorldData(); + worldData.OutdoorStaticsByCell[cellId] = new WalkFrameStaticRecords( + new[] { MakeRecord(1, 0, new Vector3(7, 0, 0), + [new MeshRef(objectGfx, Matrix4x4.Identity)]) }, + 0x8C04u); + + var gfx = new GfxObj + { + Id = particleGfx, + Flags = GfxObjFlags.HasDIDDegrade, + DIDDegrade = degradeId, + SortCenter = new Vector3(1, 2, 0), + VertexArray = new VertexArray + { + Vertices = + { + [0] = new SWVertex { Origin = new Vector3(1, 0, -1) }, + [1] = new SWVertex { Origin = new Vector3(3, 0, 1) }, + }, + }, + }; + var degrade = new GfxObjDegradeInfo + { + Id = degradeId, + Degrades = + { + new GfxObjInfo + { + Id = particleGfx, + DegradeMode = 2u, + MaxDist = float.MaxValue, + }, + }, + }; + using var dats = new NoopDatReaderWriter(); + dats.Add(particleGfx, gfx); + dats.Add(degradeId, degrade); + using var textures = new TextureCache(fx.Device, dats); + + var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(42)); + Quaternion orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f); + int handle = particles.SpawnEmitter( + new EmitterDesc + { + DatId = 0x32000CA2u, + Type = AcDream.Core.Vfx.ParticleType.Still, + GfxObjId = particleGfx, + MaxParticles = 1, + InitialParticles = 1, + LifetimeMin = 100f, + LifetimeMax = 100f, + StartAlpha = 1f, + EndAlpha = 1f, + StartSize = 2f, + EndSize = 2f, + Gravity = Vector3.Zero, + }, + new Vector3(10, 0, 0), + orientation); + particles.UpdateEmitterOwnerCell(handle, cellId); + using var renderer = new ParticleRenderer( + fx.Device, + fx.FrameLifetime, + fx.Scope, + particles, + textures, + dats, + fx.MeshAdapter, + 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); + + IList payload = DeferredParticlePayload(renderer); + object deferred = Assert.Single(payload.Cast()); + object billboard = deferred.GetType().GetProperty("Billboard")!.GetValue(deferred)!; + object instance = billboard.GetType().GetProperty("Instance")!.GetValue(billboard)!; + float authoredDistanceSq = (float)instance.GetType().GetField("DistanceSq")!.GetValue(instance)!; + Assert.Equal(40f, authoredDistanceSq, precision: 4); + Assert.Equal( + [typeof(WbDrawDispatcher), typeof(ParticleRenderer)], + QueueAlphaEntries(fx.AlphaQueue) + .Select(static entry => entry.Source.GetType().DeclaringType)); + + Vector3 visualCenter = (Vector3)instance.GetType().GetField("Position")!.GetValue(instance)!; + GpuTextureSlot textureSlot = (GpuTextureSlot)instance.GetType().GetField("TextureSlot")! + .GetValue(instance)!; + Assert.True( + Vector3.Distance(new Vector3(10, 4, 0), visualCenter) < 1e-4f, + $"Visual center was {visualCenter}, expected <10, 4, 0>."); + Assert.False(textureSlot.IsAssigned); + + fx.AlphaQueue.AbortFrame(); + } + [Fact] public void SeparateCellTurnsRemainCellMajorWhenLaterCellIsFarther() {