From 95597269607b1dc8727c4f66f44894b27376a3ff Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 27 May 2026 19:08:49 +0200 Subject: [PATCH] =?UTF-8?q?fix(render):=20Phase=20A8=20=E2=80=94=20pool=20?= =?UTF-8?q?aliasing=20in=20EnvCellRenderer=20(visual=20chaos=20root=20caus?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-Wave-5 indoor branch chaos (flickering, missing walls, GPU 100%, ~10 FPS) is caused by two interconnected pool-management bugs in EnvCellRenderer that line-by-line WB comparison surfaced in 30 minutes. Neither was found by the five post-Wave-5 speculative fixes because none of them inspected the pool path. Bug #1 — GetPooledList missing list.Clear(): The reuse branch returned pool lists with prior-frame data still inside. PrepareRenderBatches' merge phase pattern `gfxDict[k] = list; list.AddRange(...)` assumes empty lists. Without Clear(), lists grow unbounded each frame, GPU draws cumulative instance counts, and per-instance transforms become a stew of past + present data. Mirrors WB ObjectRenderManagerBase.cs:1221-1233. Bug #2 — Render uses snapshot.BatchedByCell.Count instead of PostPreparePoolIndex: The snapshot author dropped WB's PostPreparePoolIndex field calling it "scenery-only," then "compensated" in Render by setting _poolIndex to the cell count. The cell count has no relation to the pool — Prepare may have used 50+ pool lists for an 18-cell scene. Render's filter-path GetPooledList then returns lists that ARE in snapshot.BatchedByCell, corrupting the snapshot mid-Render. Restoring PostPreparePoolIndex (WB VisibilitySnapshot.cs:31) correctly places Render's pool cursor past the snapshot's owned region. Bug #3 (minor) — PopulateRecursive hardcoded isSetup:false for nested parts: Setup IDs use high-byte 0x02 (per retail). WB ObjectRenderManagerBase.cs:813 checks `(partId >> 24) == 0x02` to detect nested Setups. Our port always passed isSetup:false, silently dropping any nested Setup (its TryGetRenderData returns IsSetup=true, Render's `!IsSetup` guard skips the draw). Probably rare in EnvCells but fixed for completeness. Regression coverage: - GetPooledList_ReusedList_IsClearedBeforeReturn — would have failed pre-fix - GetPooledList_FreshList_IsAlwaysEmpty — sanity check - Snapshot_PostPreparePoolIndex_IsInitSettable — compile-time guarantee - Snapshot_PostPreparePoolIndex_DefaultsToZero — defensive default 86/86 App tests pass. Build green. The fix is the audit's primary deliverable; the GL state probe option-1 apparatus follows in a separate commit as defense-in-depth for any unidentified residual issue. Full audit + WB cross-reference in docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-28-a8-env-cell-renderer-audit-findings.md | 260 ++++++++++++++++++ .../Rendering/Wb/EnvCellRenderer.cs | 50 +++- .../Rendering/Wb/EnvCellVisibilitySnapshot.cs | 19 +- .../Rendering/Wb/EnvCellRendererTests.cs | 88 ++++++ 4 files changed, 408 insertions(+), 9 deletions(-) create mode 100644 docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md diff --git a/docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md b/docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md new file mode 100644 index 0000000..fd69aa9 --- /dev/null +++ b/docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md @@ -0,0 +1,260 @@ +# Phase A8 — EnvCellRenderer line-by-line audit findings (2026-05-28 PM) + +## TL;DR + +The post-Wave-5 visual chaos (flickering colors, missing walls, GPU 100%, ~10 FPS) +is caused by **two interconnected pool-management bugs** in +`src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs`. Both were introduced by the +WB port author's "simplification" — dropping `PostPreparePoolIndex` from the +snapshot (calling it scenery-only) and dropping `list.Clear()` from +`GetPooledList`. Either bug alone is enough to corrupt rendering; together they +explain every reported symptom precisely. + +Neither bug was found by the five post-Wave-5 speculative fixes because none +of them inspected the pool-management code path. The bug is in code the +subagent wrote that nobody had read line-by-line against WB. The audit pattern +in the handoff doc (Process retrospective §3 "Trust-but-verify on subagent +work") found it in under 30 minutes once actually applied. + +A third minor bug in `PopulateRecursive` is also documented for completeness. + +## Bug #1 — `GetPooledList` is missing `list.Clear()` + +### Code as shipped + +`src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:959-969`: + +```csharp +private List GetPooledList() +{ + lock (_listPool) + { + if (_poolIndex < _listPool.Count) return _listPool[_poolIndex++]; // ← MISSING Clear() + var fresh = new List(); + _listPool.Add(fresh); + _poolIndex++; + return fresh; + } +} +``` + +### WB canonical implementation + +`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectRenderManagerBase.cs:1221-1233`: + +```csharp +protected List GetPooledList() { + lock (_listPool) { + if (_poolIndex < _listPool.Count) { + var list = _listPool[_poolIndex++]; + list.Clear(); // ← CRITICAL: clears stale data from prior frames + return list; + } + var newList = new List(); + _listPool.Add(newList); + _poolIndex++; + return newList; + } +} +``` + +### Effect of the bug + +`PrepareRenderBatches`'s merge phase pattern (`EnvCellRenderer.cs:494-501`) is: + +```csharp +if (!gfxDict.TryGetValue(gfxKvp.Key, out var list)) +{ + list = GetPooledList(); + gfxDict[gfxKvp.Key] = list; +} +list.AddRange(gfxKvp.Value); // ← appends to whatever was in list +``` + +Each frame, when `GetPooledList` returns a previously-used pool list: +- That list STILL contains prior frames' instance data +- `list.AddRange(gfxKvp.Value)` appends the new frame's data on top +- Lists grow unbounded across frames +- The GPU receives buffers with N frames' worth of instance data + +After ~50 frames at 60Hz, every batch's instance count is ~50× what it should +be. The GPU processes 50× more vertices per draw. Hence GPU 100% + low FPS. + +Worse: the per-instance transforms in the appended data are STALE — they +represent where instances were in prior frames, not now. Hence "flickering +colors" (transforms appear/disappear randomly) and "missing walls" (instances +whose latest data has been buried under stale entries). + +## Bug #2 — `_poolIndex = snapshot.BatchedByCell.Count` instead of `snapshot.PostPreparePoolIndex` + +### Code as shipped + +`src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:614`: + +```csharp +lock (_renderLock) +{ + var snapshot = _activeSnapshot; + _shader.Use(); + _poolIndex = snapshot.BatchedByCell.Count; // reset point (mirrors WB line 405) ← WRONG +``` + +And `EnvCellVisibilitySnapshot.cs:11-13` (the snapshot definition): + +```csharp +/// The scenery-side VisibleGroups / VisibleGfxObjIds / IntersectingLandblocks +/// / PostPreparePoolIndex are dropped — we render scenery through +/// WbDrawDispatcher, not through this snapshot. +``` + +### WB canonical implementation + +`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs:405`: + +```csharp +_poolIndex = snapshot.PostPreparePoolIndex; +``` + +And `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilitySnapshot.cs:31`: + +```csharp +public int PostPreparePoolIndex { get; init; } +``` + +Stored at `EnvCellRenderManager.cs:367` during the Prepare→snapshot swap: + +```csharp +_activeSnapshot = new VisibilitySnapshot { + BatchedByCell = newBatchedByCell, + ... + PostPreparePoolIndex = _poolIndex // ← captured before _poolIndex reset +}; +``` + +### Effect of the bug + +The snapshot author's reasoning ("PostPreparePoolIndex is scenery-side, we +don't need it") is **wrong**. PostPreparePoolIndex has nothing to do with +scenery — it's the pool-index high-water mark from Prepare's merge phase, +which tells Render's filter-path where the pool's safe region begins. + +Specifically: +- After Prepare, `_listPool[0..K-1]` hold the data referenced by + `snapshot.BatchedByCell`. K = `_poolIndex` after merge. +- For Render to call `GetPooledList` safely (without trampling + snapshot data), `_poolIndex` must be set to K, so new pool calls return + `_listPool[K..]` — past the snapshot's region. + +Our broken code sets `_poolIndex = BatchedByCell.Count`, which has NO RELATION +to the pool's high-water mark. For a typical indoor scene at Holtburg with +~5-20 cells visible, `BatchedByCell.Count` ≈ 18, but Prepare may have used +50-100 pool lists (lists per unique (cellId, gfxObjId) combo). + +When Render's filter-path (`EnvCellRenderer.cs:684`) calls `GetPooledList`, +it returns a list at index 18 — which IS inside `snapshot.BatchedByCell`. With +Bug #1 fixed (Clear added), `Clear()` wipes that snapshot list. With Bug #1 +unfixed, `AddRange` corrupts it. Either way: snapshot data destroyed mid-Render. + +## Combined effect — why both bugs together explain the symptoms + +| Symptom | Bug #1 alone | Bug #2 alone | Both together | +|---|---|---|---| +| GPU 100%, ~10 FPS | YES (lists grow per frame) | partial | YES (compounding) | +| Flickering colors | YES (stale + new transforms mixed) | YES (snapshot lists overwritten mid-render) | YES (both effects) | +| Missing walls | YES (new instances buried under stale) | YES (snapshot data wiped before draw) | YES (both effects) | +| Black diagonal sliver (final report) | YES (lists hit memory pressure / driver limits) | YES (snapshot corruption produces degenerate transforms) | YES | + +The "ColorMask alpha-bit off" / "cull-cache stale" / "double terrain" / "missing +IndoorPass" fixes from the five post-Wave-5 attempts may all be real (some are +real bugs, just not THE bug). They didn't help visually because the pool +aliasing was always the dominant cause. + +## Bug #3 (minor) — `PopulateRecursive` always passes `isSetup: false` + +### Code as shipped + +`src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:358`: + +```csharp +foreach (var (partId, partTransform) in rd.SetupParts) +{ + var combined = partTransform * transform; + PopulateRecursive(group, partId, isSetup: false, combined, cellId); // ← always false +} +``` + +### WB canonical implementation + +`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectRenderManagerBase.cs:813`: + +```csharp +foreach (var (partId, partTransform) in renderData.SetupParts) { + PopulateRecursive(groups, partId, (partId >> 24) == 0x02, partTransform * transform, cellId, flags); +} +``` + +WB detects nested Setups by checking `(partId >> 24) == 0x02` (Setup IDs use +the 0x02 high byte in retail). Our code always treats child parts as plain +GfxObjs. + +### Effect + +For most cells in Holtburg, this doesn't matter — typical cells reference +simple GfxObj parts (high byte 0x01). But if any Setup contains a nested +Setup, our code adds a stub InstanceData with the nested Setup's ID into the +group dict, then `TryGetRenderData(nestedSetupId)` returns a Setup record, +and Render's `if (renderData != null && !renderData.IsSetup)` check fails → +instance silently dropped. Some renderings missing in specific cases but not +the catastrophic chaos. + +Fixed as a defense-in-depth measure to match WB exactly. + +## Fix plan + +### Required (closes Bugs #1 + #2): + +1. **`EnvCellRenderer.cs:959-969`** — Add `list.Clear()` before return in + `GetPooledList` reuse branch. +2. **`EnvCellVisibilitySnapshot.cs`** — Add `public int PostPreparePoolIndex { get; init; }`. +3. **`EnvCellRenderer.cs:523-534`** — Capture `_poolIndex` into snapshot's + `PostPreparePoolIndex` field during the atomic swap. +4. **`EnvCellRenderer.cs:614`** — Read `snapshot.PostPreparePoolIndex` + instead of `snapshot.BatchedByCell.Count`. + +### Recommended (closes Bug #3): + +5. **`EnvCellRenderer.cs:358`** — `isSetup: (partId >> 24) == 0x02` to + correctly detect nested Setups. + +### Apparatus (safety net for any unidentified bug): + +6. **GL state assertion probe extension** — extend `EmitDrawOrderProbe` to + log full GL state at each step boundary and compare against WB-expected + state. Lifts cost of finding the NEXT bug (if any) from a multi-hour + speculation cycle to a one-launch evidence cycle. + +## Regression coverage + +The pool aliasing bug is hard to unit-test without a GL context (Render is the +mutation site, and Render requires a shader and global mesh buffer). A targeted +test for the simpler invariant — `GetPooledList` returns a cleared list — is +sufficient: it directly verifies Bug #1's fix and provides a guard against +future regressions. Bug #2 is verified by the visual gate. + +## Process retrospective + +The "trust-but-verify subagent work" item in the handoff doc's Process +retrospective §3 names exactly this failure mode: the subagent wrote +`EnvCellRenderer.cs` (Wave 2, ~1013 LOC), build-test-green checks passed, but +the audit never happened. The five post-Wave-5 speculative fixes were chasing +symptoms because the audit step was skipped. + +The cost of the missed audit: ~1 hour of speculation across five fix attempts, +plus the user's five failed visual gates. +The cost of the audit, when actually performed: ~30 minutes to find both root +cause bugs via line-by-line comparison. + +**Rule for subagent-written code touching production paths:** always read the +diff line-by-line against the cited WB / retail source before merging. Bugs +in subagent code that "build clean and pass tests" can still be 100%-wrong on +the algorithm — the test coverage was not designed to catch them. diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 4820f51..c42bd3c 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -352,10 +352,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable foreach (var (partId, partTransform) in rd.SetupParts) { - // Each Setup part is a GfxObj — not a nested Setup. // Compose: world = partTransform (relative to setup) * setup world transform. + // + // FIX 2026-05-28: detect nested Setups via the high-byte + // 0x02 convention (Setup IDs start with 0x02; plain GfxObj + // IDs start with 0x01). Mirrors WB + // ObjectRenderManagerBase.cs:813. Original port hardcoded + // isSetup:false which silently dropped any nested Setup — + // its TryGetRenderData returns IsSetup=true, and Render's + // `if (!renderData.IsSetup)` guard then skips the draw. var combined = partTransform * transform; - PopulateRecursive(group, partId, isSetup: false, combined, cellId); + bool partIsSetup = (partId >> 24) == 0x02UL; + PopulateRecursive(group, partId, partIsSetup, combined, cellId); } } else @@ -518,12 +526,21 @@ public sealed unsafe class EnvCellRenderer : IDisposable } // WB EnvCellRenderManager.cs:361-372: atomic swap under _renderLock. + // + // FIX 2026-05-28 (pool aliasing root cause): capture _poolIndex's + // high-water mark from the merge phase into the snapshot's + // PostPreparePoolIndex BEFORE the reset to 0. Render reads it back + // to set its pool cursor past the snapshot's owned lists. Without + // this capture, Render's filter-path GetPooledList returns lists + // the snapshot is still referencing, corrupting per-frame instance + // data. See docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md. lock (_renderLock) { _activeSnapshot = new EnvCellVisibilitySnapshot { - BatchedByCell = newBatchedByCell, - VisibleLandblocks = landblocks, + BatchedByCell = newBatchedByCell, + VisibleLandblocks = landblocks, + PostPreparePoolIndex = _poolIndex, // VisibleGroups / VisibleGfxObjIds are stored as extra fields below. }; // Stash the global groups on the snapshot for use in the unfiltered render path. @@ -611,7 +628,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable var snapshot = _activeSnapshot; // WB EnvCellRenderManager.cs:403-404: _shader.Use(); - _poolIndex = snapshot.BatchedByCell.Count; // reset point (mirrors WB line 405) + // FIX 2026-05-28 (pool aliasing root cause): mirror WB + // EnvCellRenderManager.cs:405 — restore the pool cursor to the + // high-water mark Prepare's merge phase reached, so any + // GetPooledList calls below return lists past the snapshot's + // owned region. Original code used `snapshot.BatchedByCell.Count` + // (number of cells, e.g. 18) which has no relation to the pool + // index and pointed back into snapshot data, corrupting it + // mid-Render. See docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md. + _poolIndex = snapshot.PostPreparePoolIndex; // FIX 2026-05-28: invalidate static GL-state caches at start of Render. // Mirrors WB EnvCellRenderManager.cs:404-410: @@ -958,9 +983,22 @@ public sealed unsafe class EnvCellRenderer : IDisposable private List GetPooledList() { + // Mirrors WB ObjectRenderManagerBase.cs:1221-1233 — the reuse + // branch MUST clear the list before returning. PrepareRenderBatches' + // merge phase pattern is `gfxDict[k] = list; list.AddRange(...)`, + // which assumes the list is empty. Without the clear, lists grow + // unbounded across frames and each frame's draw includes all prior + // frames' stale data. Original port omitted the Clear() call — root + // cause of post-Wave-5 visual chaos (FIX 2026-05-28). See + // docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md. lock (_listPool) { - if (_poolIndex < _listPool.Count) return _listPool[_poolIndex++]; + if (_poolIndex < _listPool.Count) + { + var list = _listPool[_poolIndex++]; + list.Clear(); + return list; + } var fresh = new List(); _listPool.Add(fresh); _poolIndex++; diff --git a/src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs b/src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs index f9dfc69..77120b3 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs @@ -7,9 +7,9 @@ namespace AcDream.App.Rendering.Wb; /// WB's VisibilitySnapshot at /// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilitySnapshot.cs:1-36, /// narrowed to the fields actually consumes -/// (BatchedByCell + VisibleLandblocks). The scenery-side -/// VisibleGroups / VisibleGfxObjIds / IntersectingLandblocks -/// / PostPreparePoolIndex are dropped — we render scenery through +/// (BatchedByCell + VisibleLandblocks + PostPreparePoolIndex). +/// The scenery-side VisibleGroups / VisibleGfxObjIds / +/// IntersectingLandblocks are dropped — we render scenery through /// , not through this snapshot. /// /// Used as an immutable snapshot atomically swapped under the @@ -30,6 +30,19 @@ public sealed class EnvCellVisibilitySnapshot /// public Dictionary>> BatchedByCell { get; init; } = new(); + /// + /// Pool-index high-water mark after PrepareRenderBatches' merge phase. + /// Mirrors WB VisibilitySnapshot.PostPreparePoolIndex at + /// references/WorldBuilder/.../VisibilitySnapshot.cs:31. + /// Read by to set the pool + /// cursor to a safe region past the snapshot's owned lists, so any + /// GetPooledList calls inside Render don't trample data the + /// snapshot still references. Dropping this field caused the post-Wave-5 + /// visual chaos — see + /// docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md. + /// + public int PostPreparePoolIndex { get; init; } + /// True when no visible cells were produced this prepare cycle. public bool IsEmpty => VisibleLandblocks.Count == 0 && BatchedByCell.Count == 0; } diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index a3d9bb2..f31eadd 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -113,4 +113,92 @@ public class EnvCellRendererTests } // (Render() requires a GL context — visual-verified in Task 10.) + + // ----------------------------------------------------------------------- + // Pool-aliasing regression tests (2026-05-28 audit findings). + // + // Two interconnected bugs caused the post-Wave-5 visual chaos: + // 1. GetPooledList didn't clear reused lists, causing AddRange to grow + // pool entries unbounded across frames. + // 2. Render's pool cursor reset used `BatchedByCell.Count` (cell count, + // a small int with no relation to the pool) instead of WB's + // `PostPreparePoolIndex` (the pool high-water mark after Prepare), + // pointing Render's GetPooledList back into snapshot-owned lists. + // + // These tests use reflection to verify the fixes without widening + // EnvCellRenderer's public API. If either fix regresses, the + // corresponding test fails fast. + // ----------------------------------------------------------------------- + + [Fact] + public void Snapshot_PostPreparePoolIndex_IsInitSettable() + { + // Compile-time guarantee: the field exists and is init-only. + // If a future refactor renames or removes it, this test won't compile. + var s = new EnvCellVisibilitySnapshot { PostPreparePoolIndex = 42 }; + Assert.Equal(42, s.PostPreparePoolIndex); + } + + [Fact] + public void Snapshot_PostPreparePoolIndex_DefaultsToZero() + { + var s = new EnvCellVisibilitySnapshot(); + Assert.Equal(0, s.PostPreparePoolIndex); + } + + [Fact] + public void GetPooledList_ReusedList_IsClearedBeforeReturn() + { + // The bug: WB's GetPooledList clears the list before returning so + // the merge phase pattern `gfxDict[k] = list; list.AddRange(...)` + // populates fresh data. The original port omitted Clear() — each + // frame's lists grew unbounded with stale data layered on top. + // + // Reflection-based test that drives the private GetPooledList + + // _poolIndex/_listPool fields. If a future refactor removes the + // Clear() call, this test fails. + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); + + var type = typeof(EnvCellRenderer); + var getPooledListMethod = type.GetMethod("GetPooledList", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(getPooledListMethod); + var poolIndexField = type.GetField("_poolIndex", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(poolIndexField); + + // First call — creates _listPool[0], _poolIndex 0 → 1. + var first = (List)getPooledListMethod!.Invoke(r, null)!; + first.Add(new InstanceData()); + first.Add(new InstanceData()); + Assert.Equal(2, first.Count); + + // Reset cursor to 0 — simulates the start of the next prepare cycle. + poolIndexField!.SetValue(r, 0); + + // Second call — returns _listPool[0] (same as first). With the fix + // it should be cleared. Without the fix the list still has 2 items. + var second = (List)getPooledListMethod.Invoke(r, null)!; + Assert.Same(first, second); // reuses the same instance + Assert.Empty(second); // and the data is gone + } + + [Fact] + public void GetPooledList_FreshList_IsAlwaysEmpty() + { + // Sanity check for the fresh-list branch. _poolIndex past _listPool.Count + // should produce a brand-new empty list and grow the pool. + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); + + var type = typeof(EnvCellRenderer); + var getPooledListMethod = type.GetMethod("GetPooledList", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + var a = (List)getPooledListMethod!.Invoke(r, null)!; + var b = (List)getPooledListMethod.Invoke(r, null)!; + + Assert.NotSame(a, b); + Assert.Empty(a); + Assert.Empty(b); + } }