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

@ -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}");
}
}