fix(ui): Campaign AS gate fixes AS-GF1 — extras-list overflow ruled OUT as a code defect; paperdoll regression not isolated, probe added instead

Two owner-reported defects at the Campaign AS connected gate on the
examination window (player targets): the animated paperdoll no longer
renders at all, and a "reserved black rectangle" appears at the window's
bottom with the character extras list clipped mid-row at default (310x400)
window size.

ROOT CAUSE — extras-list overflow (the "clipped mid-row" half of defect 2):
NOT a code bug. AS3 (armor-level trio) and AS4 (society/allegiance/
configurable extras) grew the extras list past its DAT-authored 87px region
(element 0x10000335) at the window's minimum size — a new hermetic
regression test proves the worst-case combination (every AS3+AS4 addition
at once) reaches 20 rows / 400px of content, a 4.6x overflow. But retail's
own LayoutDesc authors NO scrollbar for this listbox either
(ScrollbarElementId == 0, verified against both the committed fixture and a
fresh tools/LayoutDump read of the live installed DAT — no drift), and the
SAME test proves UiItemList's pre-existing, unmodified wheel-scroll handler
(OnEvent's UiEventType.Scroll branch) already reveals every row on the next
paint. A scrollbar-less, wheel-scrollable list clipped to its authored
region until the user scrolls or resizes IS retail's own already-correctly-
ported mechanism, not a regression — so no fix was made here.

ROOT CAUSE — paperdoll / "black rectangle" (defect 1): NOT ISOLATED despite
exhaustive investigation. Every file the Campaign AS diff touches
(AppraisalUiController.cs, RetailUiRuntime.cs, CreatureAppraisalRows.cs,
AllegianceRankTitleTable.cs, CharacterIdentityText.cs,
CharacterSheetProvider.cs, InteractionRetainedUiComposition.cs, plus two
unrelated mechanical PublicWeenieFlags-literal refactors) was reviewed in
full against the pre-Campaign-AS baseline. The same worst-case regression
test proves Apply/ApplyCreature/RebuildCreatureStats/BuildExtra never throw
and always leave ActiveView == Character, CurrentObjectId != 0, and the
viewport's full ancestor-visibility chain Visible == true — ruling out
RetailCreatureAppraisalFrameView.TryGetVisibleTarget's first three gates.
CreatureAppraisalPresentation.cs and LivePresentationComposition.cs (the
entire render-time viewport pipeline) are byte-for-byte unchanged across
the whole 974fe88a..87e98395 window. UiViewport.OnDraw draws NOTHING (not
black) when its TextureSlot is unassigned, and the creaturePanel's own
full-panel backdrop (0x10000141) is what would show through instead — the
most likely explanation tying both defects to ONE underlying condition, but
its exact trigger (TryGetVisibleTarget's CurrentObjectId check, or
TrySynchronize's live-entity/mesh-availability check) lies in code nothing
in Campaign AS touches, and could not be reproduced hermetically (needs a
live entity + a live examine exchange).

Filed #443 with the full investigation trail. Added a temporary,
state-change-gated diagnostic probe (ACDREAM_PROBE_CREATURE_APPRAISAL_
VIEWPORT=1, CreatureAppraisalViewportDiagnostics) at both
TryGetVisibleTarget and TrySynchronize so the next live repro pinpoints the
exact failing reason instead of another guess. Per CLAUDE.md's "no
workarounds without explicit approval" and the investigation mode's own
escape hatch ("if you cannot root-cause, say what runtime evidence you
need instead of shipping a guess"), no behavioral fix was shipped for
defect 1.

Tests: AcDream.App.Tests hermetic filter 6,337/0; full-solution hermetic
suite 15,612/0 (all 14 projects green, including the known #442 flake,
which did not trip this run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-25 12:58:29 +02:00
parent 87e9839561
commit 65f6f5848a
5 changed files with 302 additions and 7 deletions

View file

@ -24,6 +24,91 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #443 — Examination window: player-target paperdoll viewport renders nothing (AS-GF1)
**Status:** OPEN — probe added, root cause NOT isolated.
**Component:** examination window / creature-appraisal private viewport.
**Filed:** 2026-08-25, AS-GF1 gate-fix session.
Owner report at the Campaign AS connected gate: the animated 3-D paperdoll
in the examination window (LayoutDesc `0x2100006B` element `0x10000148`)
worked correctly at baseline `974fe88a` (praised the same session) and was
gone by `87e98395` (ten commits later, AS2-AS5). The same gate also
reported "a reserved black rectangle at the window's bottom" and mid-row
clipping in the character extras list at the window's default (minimum
310x400) size, fixed by dragging the window taller.
**Exhaustive investigation (this session) found NO code bug in the
Campaign AS diff for either symptom:**
- Every file the AS2-AS5 window touches
(`AppraisalUiController.cs`, `RetailUiRuntime.cs`,
`CreatureAppraisalRows.cs`, `AllegianceRankTitleTable.cs` (new),
`CharacterIdentityText.cs`, `CharacterSheetProvider.cs`,
`InteractionRetainedUiComposition.cs`, plus two unrelated
mechanical `PublicWeenieFlags`-literal refactors) was read in full
against the pre-Campaign-AS baseline.
- A new hermetic regression test,
`AppraisalUiControllerTests.CharacterResponse_WorstCaseExtrasCombination_DoesNotThrowAndViewportGateStaysOpen`,
applies EVERY AS3+AS4 extras-list addition at once (armor-level trio,
society, allegiance cascade, ratings, all seven configurable extras) —
the combination none of the individual AS3/AS4 tests exercise together —
through the REAL DAT-derived examination layout and REAL row templates.
It proves `Apply`/`ApplyCreature`/`RebuildCreatureStats`/`BuildExtra`
never throw and always leave `ActiveView == Character`,
`CurrentObjectId != 0`, and the viewport's full ancestor-visibility
chain (`creaturePanel` → root) `Visible == true`, even in this worst
case. `RetailCreatureAppraisalFrameView.TryGetVisibleTarget`'s first
three gates (ActiveView, windowFrame visible, viewport visible) are
therefore unaffected.
- The SAME test also proves the extras list's clip-then-wheel-scroll
behavior is correct and unaffected: `UiItemList.OnEvent`'s
`UiEventType.Scroll` handler (pre-existing, unmodified) moves the
shared `Scroll` offset, and the next `LayoutCells()` pass reveals every
row, including the very last one of the 20-row / 400px worst case
against the DAT-authored 87px region. Retail's own LayoutDesc authors
NO scrollbar for this listbox either (`ScrollbarElementId == 0`,
verified against BOTH the committed fixture `tests/AcDream.App.Tests/
UI/Layout/fixtures/examine_2100006B_100005F2.json` and a fresh
`tools/LayoutDump` read of the live installed DAT — no drift). A
scrollbar-less, wheel-scrollable list clipped to its authored region
until the user scrolls or resizes IS retail's actual, already-correctly-
ported mechanism — not a regression.
- `src/AcDream.App/UI/UiViewport.cs:52` draws NOTHING (not black) when its
`TextureSlot` is unassigned (`if (!Visible || !TextureSlot.IsAssigned)
return;`). The creaturePanel's own full-panel backdrop
(`0x10000141`, DID `0x06004CC2`, ZLevel 100 — the furthest-back layer,
spanning the panel's whole `300x365` rect) is what shows through
wherever nothing else paints over it. This is the most likely explanation
for BOTH the missing paperdoll AND the "black rectangle": if the
viewport's `TextureSlot` never gets assigned, this backdrop is what the
owner is actually seeing, and it is genuinely the SAME defect wearing
two descriptions, not two.
- `CreatureAppraisalPresentation.cs` and `LivePresentationComposition.cs`
(the entire render-time viewport pipeline: `TryGetVisibleTarget`'s
fourth gate `CurrentObjectId`, `TrySynchronize`'s live-entity/mesh
lookup, and the dispatcher/composition gate that constructs the
presenter at all) are byte-for-byte UNCHANGED across the whole
`974fe88a..87e98395` window (`git log -p` for both files is empty).
**Conclusion:** the trigger is one of `TryGetVisibleTarget`'s
`CurrentObjectId` check or `TrySynchronize`'s `LiveEntityRuntime.
TryGetWorldEntity`/`MeshRefs.Count` check, in code nothing in Campaign AS
touches — meaning either a pre-existing, previously-latent condition this
gate round happened to trigger, or a live/timing condition a hermetic test
cannot reproduce (no live entity, no live wire exchange).
**Probe added this session** (temporary — delete with the real fix):
`ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT=1`
`CreatureAppraisalViewportDiagnostics` in `CreatureAppraisalPresentation.cs`
logs `[AS-GF1-PROBE] creature-appraisal viewport: <reason>` on every REASON
TRANSITION (not every frame) from both `TryGetVisibleTarget` and
`RetailCreatureAppraisalCloneFactory.TrySynchronize`. Next step: relaunch
with the flag set, examine a player, and read which one of the five
possible reasons (`no ActiveView`, `windowFrame hidden`, `viewport hidden`,
`no CurrentObjectId`, `entity not found`, `no MeshRefs`) fires — that
pinpoints the real fix.
## #442 — Flake: DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords fails intermittently under full parallel suite load
**Status:** OPEN.

View file

@ -181,6 +181,7 @@ fall back to `ACDREAM_DAT_DIR`.
| `ACDREAM_ORBIT_DISTANCE_METERS` | `=<float>`, must be finite and `>0` | Diagnostic-only initial distance for the offline orbit camera, so deterministic renderer acceptance captures land inside a finite shadow reach. | Own doc comment: "used by deterministic renderer acceptance captures." Rejects non-finite/non-positive values silently (parses to `null`, camera default used). | `null` (unset) → normal camera default | `RuntimeOptions.InitialOrbitDistanceMeters``GameWindow.cs:1412` → offline orbit-camera composition |
| `ACDREAM_ORBIT_PITCH_DEGREES` | `=<float>`, clamped `[-89, 89]` | Diagnostic-only initial orbit camera elevation. | Values outside `[-89,89]` or non-finite are silently rejected (→ `null`, default kept) rather than clamped. | `null` | `RuntimeOptions.InitialOrbitPitchDegrees``GameWindow.cs:1414` |
| `ACDREAM_ORBIT_YAW_DEGREES` | `=<float>`, must be finite | Diagnostic-only initial orbit camera heading. | Non-finite values silently rejected (→ `null`). | `null` | `RuntimeOptions.InitialOrbitYawDegrees``GameWindow.cs:1413` |
| `ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT` | `=1` | #443 temporary probe for the examination-window player-paperdoll regression: logs `[AS-GF1-PROBE] TryGetVisibleTarget: <reason>` and `[AS-GF1-PROBE] TrySynchronize: <reason>` on every REASON TRANSITION (not every frame), pinpointing which of the two private-viewport gates rejects the target | print-only, state-change-gated so cost stays near zero even left on for a whole session | off | `CreatureAppraisalViewportDiagnostics.Enabled` (`CreatureAppraisalViewportDiagnostics.cs`), consumed by `RetailCreatureAppraisalFrameView.TryGetVisibleTarget` and `RetailCreatureAppraisalCloneFactory.TrySynchronize` in `CreatureAppraisalPresentation.cs` |
| `ACDREAM_PROBE_REVEAL_RADIUS` | `=<int>=1` (unparsable or `<1` → override absent; floor is 1, not 0) | #280 A/B measurement probe: forces the OUTDOOR reveal gate to use this landblock radius instead of the derived streaming window (near radius clamped to it), so a route can be measured with the pre-#280 behavior (`=1`, old `OutdoorNeighborhoodRadius`) vs. current | **Changes what gets revealed, not just measured** — genuinely resizes the reveal/visible window used by the live reveal gate. CLAUDE.md: "Leave it unset for any measurement or gate run — with it set you are measuring a different window than production." `=0` is rejected by the parser specifically because it would hang the very A/B route it exists to measure (`RequiredRenderRadius==0` fails `invalid-readiness-shape`). Not a user setting, not in Settings/RuntimeOptions, not persisted. | unset (derivation in charge, no override) | `StreamingDiagnostics.RevealRadiusOverride` (`StreamingDiagnostics.cs:25-27,76-80`), applied by `StreamingDiagnostics.ApplyRevealRadiusOverride` |
| `ACDREAM_PROBE_WORLD_FRAME` | `=1` | gates one `[world-frame] agree` line per projected conversion in `DatLiveEntityProjectionMaterializer`, recording the world-frame center both `LiveWorldOriginState` (App) and Runtime's physics-state owner used (issue #283, "measurement only; it never gates placement") | print-only | off | `PhysicsDiagnostics.ProbeWorldFrameEnabled` |
| `ACDREAM_SKY_PHASE_SECONDS` | `=<float>` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of wall-clock time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null` → wall-clock driven (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds``SkyRenderer.cs:79,85` (cloud UV scroll) **and** `AtmosphericPostProcessGraph.cs:560,586,671` (foliage-wind clock) |

View file

@ -134,18 +134,39 @@ internal sealed class RetailCreatureAppraisalFrameView :
width = 0;
height = 0;
if (_controller.ActiveView is not (
AppraisalView.Creature or AppraisalView.Character)
|| !IsEffectivelyVisible(_windowFrame)
|| !IsEffectivelyVisible(_viewport)
|| _controller.CurrentObjectId == 0u)
AppraisalView.Creature or AppraisalView.Character))
{
CreatureAppraisalViewportDiagnostics.ReportGate(
$"no ActiveView (was {_controller.ActiveView})");
return false;
}
if (!IsEffectivelyVisible(_windowFrame))
{
CreatureAppraisalViewportDiagnostics.ReportGate("windowFrame hidden");
return false;
}
if (!IsEffectivelyVisible(_viewport))
{
CreatureAppraisalViewportDiagnostics.ReportGate("viewport hidden");
return false;
}
if (_controller.CurrentObjectId == 0u)
{
CreatureAppraisalViewportDiagnostics.ReportGate("no CurrentObjectId");
return false;
}
serverGuid = _controller.CurrentObjectId;
width = (int)_viewport.Width;
height = (int)_viewport.Height;
return width > 0 && height > 0;
if (width <= 0 || height <= 0)
{
CreatureAppraisalViewportDiagnostics.ReportGate(
$"zero viewport extent ({width}x{height})");
return false;
}
CreatureAppraisalViewportDiagnostics.ReportGate("open");
return true;
}
public void SetTextureHandle(uint textureHandle) =>
@ -203,11 +224,19 @@ internal sealed class RetailCreatureAppraisalCloneFactory :
synchronizedClone = null;
boundsMin = Vector3.Zero;
boundsMax = Vector3.Zero;
if (!_entities.TryGet(serverGuid, out WorldEntity source)
|| source.MeshRefs.Count == 0)
if (!_entities.TryGet(serverGuid, out WorldEntity source))
{
CreatureAppraisalViewportDiagnostics.ReportSync(
$"entity not found (guid 0x{serverGuid:X8})");
return false;
}
if (source.MeshRefs.Count == 0)
{
CreatureAppraisalViewportDiagnostics.ReportSync(
$"no MeshRefs (guid 0x{serverGuid:X8})");
return false;
}
CreatureAppraisalViewportDiagnostics.ReportSync("synchronized");
WorldEntity clone = currentClone is not null
&& currentClone.SourceGfxObjOrSetupId == source.SourceGfxObjOrSetupId

View file

@ -0,0 +1,52 @@
using System;
namespace AcDream.App.Rendering;
/// <summary>
/// AS-GF1 (2026-08-25): a temporary, state-change-gated probe for the
/// creature-examination paperdoll regression (#443 — the animated paperdoll
/// stopped rendering for player targets sometime in Campaign AS, but
/// exhaustive review of the AS2-AS5 diff plus a worst-case hermetic
/// regression test (AppraisalUiControllerTests.
/// CharacterResponse_WorstCaseExtrasCombination_DoesNotThrowAndViewportGateStaysOpen)
/// proved <see cref="AcDream.App.UI.Layout.AppraisalUiController"/> never
/// blocks ActiveView/CurrentObjectId/the viewport's ancestor-visibility
/// chain, even under the combined AS3+AS4 worst case). This means the actual
/// failing condition is one of
/// <see cref="RetailCreatureAppraisalFrameView.TryGetVisibleTarget"/>'s four
/// gates or <see cref="RetailCreatureAppraisalCloneFactory.TrySynchronize"/>'s
/// two — none of which the Campaign AS diff touches — and could not be
/// reproduced hermetically (it needs a live entity + a live examine
/// exchange). Enable with <c>ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT=1</c>;
/// logs only on a REASON transition (not every frame) to stay cheap enough
/// to leave on for a whole session. Delete this class and its call sites in
/// the commit that lands the real fix.
/// </summary>
internal static class CreatureAppraisalViewportDiagnostics
{
public static bool Enabled { get; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT") == "1";
// Two independent last-reason latches — TryGetVisibleTarget and
// TrySynchronize each run every frame and would otherwise "transition"
// against EACH OTHER's most recent line on every healthy frame,
// defeating the point of state-change-only logging.
private static string? _lastGateReason;
private static string? _lastSyncReason;
public static void ReportGate(string reason)
{
if (!Enabled || reason == _lastGateReason)
return;
_lastGateReason = reason;
Console.WriteLine($"[AS-GF1-PROBE] TryGetVisibleTarget: {reason}");
}
public static void ReportSync(string reason)
{
if (!Enabled || reason == _lastSyncReason)
return;
_lastSyncReason = reason;
Console.WriteLine($"[AS-GF1-PROBE] TrySynchronize: {reason}");
}
}

View file

@ -1153,6 +1153,134 @@ public sealed class AppraisalUiControllerTests
("Society:", "Celestial Hand"), ExtraRow(extra, 0));
}
// ── AS-GF1 diagnostic: DEFECT-1/DEFECT-2 root-cause probe ──────────────
// Combines EVERY AS3+AS4 extras-list addition on one response (armor
// levels, society, allegiance cascade, ratings, all seven configurable
// extras) through the REAL examination layout + REAL row templates —
// the worst-case content length none of the individual AS3/AS4 tests
// exercise together. Confirms (a) no exception anywhere in
// Apply/ApplyCreature/RebuildCreatureStats/BuildExtra for this
// combination, (b) the viewport's own visibility gate
// (RetailCreatureAppraisalFrameView.TryGetVisibleTarget's ActiveView/
// CurrentObjectId/ancestor-visible conditions) is unaffected by extras-
// list length, and (c) measures the real overflow magnitude driving
// DEFECT 2.
[Fact]
public void CharacterResponse_WorstCaseExtrasCombination_DoesNotThrowAndViewportGateStaysOpen()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Worstcase",
Type = ItemType.Creature,
});
using var interaction = NewInteraction(objects, []);
var templates = new CreatureAppraisalRowTemplateFactory(
FixtureLoader.LoadExaminationRowTemplateInfos(),
NoTexture,
defaultFont: null);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
[],
[],
() => { },
() => { },
templates,
resolveCharacterTitle: titleId => titleId == 13u ? "War Mage" : null,
localFactionBits: () => 0x1)!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
var properties = new PropertyBundle();
properties.Strings[5u] = "Template"; // Character-view marker
properties.Ints[0x105] = 13; // CharacterTitleId
properties.Ints[113] = 1; // Gender: male
properties.Ints[188] = 1; // HeritageGroup: Aluvian
properties.Ints[281] = 0x1; // Faction1Bits: Celestial Hand
properties.Ints[287] = 50; // Society rank
properties.Ints[30] = 5; // AllegianceRank >= 1
properties.Ints[35] = 12; // AllegianceFollowers (unused once titles present)
properties.Strings[21u] = "Monarch Title";
properties.Strings[35u] = "Patron Title"; // different from Monarch -> two rows
properties.Ints[0x133] = 10; // DamageRating
properties.Ints[0x134] = 10; // DamageResistRating
properties.Ints[0x15E] = 10; // DotResistRating
properties.Strings[10u] = "Fellows";
properties.Strings[43u] = "1/1/2003"; // DateOfBirth
properties.Ints[125] = 100000; // Age (seconds in Dereth)
properties.Ints[181] = 7; // ChessRank
properties.Ints[192] = 3; // FishingSkill
properties.Ints[43u] = 2; // NumDeaths (int table, same numeric id as DateOfBirth string id)
properties.Ints[262] = 5; // NumCharacterTitles
var armorLevels = new AppraiseInfoParser.ArmorLevel(
Head: 100, Chest: 110, Abdomen: 120,
UpperArm: 130, LowerArm: 140, Hand: 150,
UpperLeg: 160, LowerLeg: 170, Foot: 180);
bool applied = controller.Apply(Parsed(
properties, MinimalCreatureProfile(), armorLevels: armorLevels));
Assert.True(applied);
Assert.Equal(AppraisalView.Character, controller.ActiveView);
Assert.NotEqual(0u, controller.CurrentObjectId);
// The viewport-visibility gate this test exists to protect:
// RetailCreatureAppraisalFrameView.TryGetVisibleTarget requires the
// creaturePanel (viewport's ancestor) to report Visible, exactly
// like SetActiveView's `_creaturePanel.Visible = view is Creature or
// Character` line sets it.
UiElement creaturePanel = layout.FindElement(
AppraisalUiController.CreaturePanelId)!;
UiElement viewportHost = layout.FindElement(
AppraisalUiController.CreatureViewportId)!;
Assert.True(creaturePanel.Visible);
for (UiElement? current = viewportHost; current is not null; current = current.Parent)
Assert.True(current.Visible, $"ancestor 0x{current.EventId:X8} is not Visible");
UiItemList extra = CreatureExtraList(layout);
int rowCount = extra.GetNumUIItems();
UiElement extraHost = layout.FindElement(
AppraisalUiController.CreatureExtraListId)!;
float contentHeight = rowCount * 20f; // CreatureAppraisalLayeredList.NewList's CellHeight
Console.WriteLine(
$"[AS-GF1] worst-case extras: {rowCount} rows, "
+ $"{contentHeight}px content vs extraHost authored "
+ $"{extraHost.Height}px at default window size.");
// DEFECT 2's measured overflow: AS3+AS4's combined worst case is
// dramatically taller than the DAT-authored 87px region at the
// window's minimum (310x400) size.
Assert.True(rowCount > 15, $"expected a long worst-case list, got {rowCount} rows");
Assert.True(contentHeight > extraHost.Height * 2,
$"expected content ({contentHeight}px) to badly overflow the "
+ $"authored host ({extraHost.Height}px)");
// AS-GF1 probe: does UiItemList's generic wheel-scroll handler
// (OnEvent's UiEventType.Scroll branch, gated on CellWidth > 0f,
// which NewList sets) actually reveal the rows below the fold, the
// same way it does for every other scrollable list in this
// controller? If so, the overflow is inert (retail's own DAT
// authors NO ScrollbarElementId for either 0x10000149 or 0x10000335
// either — verified against both the committed fixture and a fresh
// `tools/LayoutDump 0x2100006B 0x10000140 --props` read of the live
// installed DAT) and NOT itself a code defect.
UiItemSlot lastRow = Assert.IsType<UiTemplateListSlot>(
extra.GetItem(rowCount - 1));
Assert.False(lastRow.Visible, "expected the last row to start below the fold");
extra.OnEvent(new UiEvent(
extra.EventId, extra, UiEventType.Scroll, Data0: -1000));
Assert.True(extra.Scroll.ScrollY > 0, "expected the wheel event to move the scroll offset");
// OnEvent only moves the shared Scroll's offset; cell.Visible/Top are
// only recomputed by LayoutCells(), which OnDraw calls every frame.
// Simulate the next paint (this hermetic test never renders one).
extra.LayoutCells();
Assert.True(lastRow.Visible, "expected scrolling to reveal the last row");
}
[Fact]
public void CreatureResponse_NeverGainsArmorLevelTrioOrLegend()
{