diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7db802e7..78208f5f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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: ` 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. diff --git a/docs/launch-options.md b/docs/launch-options.md index 5c1d0e3c..02f97a89 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -181,6 +181,7 @@ fall back to `ACDREAM_DAT_DIR`. | `ACDREAM_ORBIT_DISTANCE_METERS` | `=`, 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` | `=`, 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` | `=`, 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: ` and `[AS-GF1-PROBE] TrySynchronize: ` 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` | `==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` | `=` (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) | diff --git a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs index f27b37d3..96f351c7 100644 --- a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs +++ b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs @@ -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 diff --git a/src/AcDream.App/Rendering/CreatureAppraisalViewportDiagnostics.cs b/src/AcDream.App/Rendering/CreatureAppraisalViewportDiagnostics.cs new file mode 100644 index 00000000..31ff553e --- /dev/null +++ b/src/AcDream.App/Rendering/CreatureAppraisalViewportDiagnostics.cs @@ -0,0 +1,52 @@ +using System; + +namespace AcDream.App.Rendering; + +/// +/// 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 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 +/// 's four +/// gates or '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 ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT=1; +/// 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. +/// +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}"); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index b496b206..dfad6dbb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -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( + 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() {