fix(app,headless): Campaign CC slice CC4 re-review round — R1 arbiter + R2-R4

CC4 re-review returned NOT CLOSED: R1 (MEDIUM, blocking) is a new residual
the F1 fix itself introduced, plus three LOW riders (R2, R3, R4).

R1 — nulling UiRoot.FixedCanvasSize on chargen Close() stripped it from
character-management, which stays active underneath and only sets the
canvas on its own activation edge. Root cause (reviewer-named): two
controllers writing one host-global with no owner. Fixed with the
root-cause shape (reviewer's option (c)): UiRoot.DeclareFixedCanvas(owner,
size)/RevokeFixedCanvas(owner), an owner-scoped arbiter — every declarer
must agree on the canvas size (a mismatch throws instead of silently
last-writer-wins), and the canvas nulls only once EVERY declarer has
revoked. Both CharacterCreationUiController and CharacterManagementUi-
Controller now declare/revoke instead of writing FixedCanvasSize directly;
grepped for stragglers, none remain in production code (the raw setter
stays public only for UiRootFixedCanvasTests' isolated scale-math
coverage). New test (reviewer-specified):
CharacterScreensFixedCanvasArbiterTests — two controllers sharing one
UiRoot, proving the canvas stays set through chargen's Exit-confirm Close
while char-management is still active, nulling only once char-management
also deactivates, plus the original F1 defect's own covering case (both
revoke together at world entry).

R3 — HeadlessSessionHostTests.ContentLease_InstallsRealChargenOptions_
SelectHeritageIsAccepted proves F6's install actually opens the gate: a
content lease carrying a real hand-built DatCharGen heritage (not
ChargenOptions.Empty) is installed, and TrySelectHeritage for it succeeds.

R2 — filed docs/ISSUES.md #402 for the pre-existing
Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate
full-suite flake (unrelated to Campaign CC).

R4 — fixed "unchached" -> "uncached" typo in
InteractionRetainedUiComposition.cs.

Runtime 1713/0, App 5127/13 skips (+2), Headless 166/0 (+1), full solution
Release build green. Live-DAT probes 7/7 under ACDREAM_PROBE_LIVE_MOUNT=1.
The known #402 flake did not fire across 3 consecutive full-suite runs
this session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 19:04:04 +02:00
parent ec854db045
commit 8add0667a5
9 changed files with 780 additions and 29 deletions

View file

@ -650,7 +650,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
// ResolveText several times per Refresh, and CharacterCreation-
// UiController.ApplyProgressState forces a full refresh on
// every page switch (`_lastRevision = long.MinValue`) — so an
// unchached resolver meant several fresh allocations + DatLock
// uncached resolver meant several fresh allocations + DatLock
// acquisitions per click. DatStringResolver's own constructor
// does no DAT I/O (only .Resolve reads), so building it here
// outside the lock matches this file's existing pattern

View file

@ -201,16 +201,15 @@ internal sealed class CharacterCreationUiController : IDisposable
Root.ClickThrough = false;
Root.Visible = false;
// AD-98: the same authored 800x600 fixed-canvas treatment as the
// character-management screen. CharacterManagementUiController sets
// UiRoot.FixedCanvasSize ONCE on its own activation edge
// (Tick's `if (!_active)` arm) and NULLS it in both Deactivate AND
// Dispose — it is NOT a per-tick set, and this controller must be
// symmetric with that exact shape (review fix round F1, 2026-08-15
// — the earlier claim here that it was safe to leave the canvas
// pinned forever was FALSE and left an 800x600-scaled canvas
// covering the in-world UI whenever this screen had been opened).
// See Open/Close/Deactivate/Dispose below for the matching set/null
// pair.
// character-management screen. Both controllers DECLARE/REVOKE
// through UiRoot's owner-scoped arbiter (review fix round R1,
// 2026-08-15) rather than writing UiRoot.FixedCanvasSize directly —
// char-management can be simultaneously active underneath this
// screen, and a raw write from either controller is a last-writer-
// wins race with no owner (the F1 fix's own Close() null wiped
// char-management's still-active canvas out from under it). See
// Open/Close/Deactivate/Dispose below for the matching declare/
// revoke pair.
_authoredCanvas = new Vector2(
Root.Width > 0f ? Root.Width : 800f,
Root.Height > 0f ? Root.Height : 600f);
@ -380,17 +379,19 @@ internal sealed class CharacterCreationUiController : IDisposable
/// <summary>Opens the screen at retail's authored default page
/// (<c>gmCharGenMainUI::gmCharGenMainUI</c>'s trailing
/// <c>SetProgressState(this, ECG_HERTAGE)</c>). Sets the fixed canvas
/// on this exact activation edge — matching
/// <see cref="CharacterManagementUiController"/>'s own one-shot set —
/// not per-tick; <see cref="Close"/>/<see cref="Deactivate"/>/
/// <see cref="Dispose"/> null it back out symmetrically.</summary>
/// <c>SetProgressState(this, ECG_HERTAGE)</c>). Declares the fixed
/// canvas on this exact activation edge through <see cref="UiRoot"/>'s
/// arbiter — matching <see cref="CharacterManagementUiController"/>'s
/// own one-shot declare — not per-tick; <see cref="Close"/>/
/// <see cref="Deactivate"/>/<see cref="Dispose"/> revoke it back out
/// symmetrically, and the canvas stays set for as long as ANY other
/// declarer (e.g. character-management underneath) remains active.</summary>
internal void Open()
{
if (_disposed)
return;
_isOpen = true;
_host.FixedCanvasSize = _authoredCanvas;
_host.DeclareFixedCanvas(this, _authoredCanvas);
ApplyProgressState(Page.Heritage);
}
@ -400,7 +401,7 @@ internal sealed class CharacterCreationUiController : IDisposable
return;
_isOpen = false;
Root.Visible = false;
_host.FixedCanvasSize = null;
_host.RevokeFixedCanvas(this);
}
public void Dispose()
@ -415,9 +416,11 @@ internal sealed class CharacterCreationUiController : IDisposable
finally
{
// Matches CharacterManagementUiController.Dispose's own
// unconditional null — defends against disposing while _isOpen
// (Close() is not otherwise called on this path).
_host.FixedCanvasSize = null;
// unconditional revoke — defends against disposing while
// _isOpen (Close() is not otherwise called on this path). Idle
// if Close() already revoked (RevokeFixedCanvas is a no-op for
// an owner that already revoked).
_host.RevokeFixedCanvas(this);
_back.OnClick = null;
_next.OnClick = null;
_finish.OnClick = null;

View file

@ -114,9 +114,13 @@ internal sealed class CharacterManagementUiController : IDisposable
// while this screen is active, the host stretches the ENTIRE canvas —
// widgets, glyphs, and the painted background (which carries the
// "World"/"Characters" captions as art) — as one unit via
// UiRoot.FixedCanvasSize. Resizing the root here instead is exactly the
// half-substitution that misaligned the widgets against the stretched
// art at the 2026-08-15 user gate.
// UiRoot.FixedCanvasSize, declared/revoked through the owner-scoped
// arbiter (review fix round R1, 2026-08-15) rather than written
// directly — character-creation can be simultaneously active on top
// of this screen, and a raw write from either controller is a last-
// writer-wins race with no owner. Resizing the root here instead of
// using the canvas is exactly the half-substitution that misaligned
// the widgets against the stretched art at the 2026-08-15 user gate.
_authoredCanvas = new Vector2(
Root.Width > 0f ? Root.Width : 800f,
Root.Height > 0f ? Root.Height : 600f);
@ -298,7 +302,7 @@ internal sealed class CharacterManagementUiController : IDisposable
{
_active = true;
Root.Visible = true;
_host.FixedCanvasSize = _authoredCanvas;
_host.DeclareFixedCanvas(this, _authoredCanvas);
_host.BringToFront(Root);
}
@ -368,7 +372,7 @@ internal sealed class CharacterManagementUiController : IDisposable
}
finally
{
_host.FixedCanvasSize = null;
_host.RevokeFixedCanvas(this);
_enter.OnClick = null;
_delete.OnClick = null;
_restore.OnClick = null;
@ -752,7 +756,7 @@ internal sealed class CharacterManagementUiController : IDisposable
{
_active = false;
Root.Visible = false;
_host.FixedCanvasSize = null;
_host.RevokeFixedCanvas(this);
}
foreach (UiButton row in _rows)
{

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
@ -41,9 +42,94 @@ public sealed class UiRoot : UiElement
/// renderer's quad chokepoint; the mouse entry points apply the inverse,
/// so <see cref="MouseX"/>/<see cref="MouseY"/> and every hit test live
/// in canvas space. Null (the in-world default) is native 1:1.
///
/// <para>
/// Campaign CC slice CC4 review-fix round R1 (2026-08-15): this raw
/// setter remains public for tests that exercise the scale/mouse-
/// mapping math in isolation (<c>UiRootFixedCanvasTests</c>), but
/// PRODUCTION code must go through <see cref="DeclareFixedCanvas"/>/
/// <see cref="RevokeFixedCanvas"/> instead of writing this property
/// directly. Two fixed-canvas screens can be active at once
/// (character-management underneath character-creation) and a raw
/// write from either one is a last-writer-wins race with no owner —
/// the F1 fix's own <c>Close()</c> null wiped the OTHER screen's still-
/// active canvas out from under it (see AD-98).
/// </para>
/// </summary>
public Vector2? FixedCanvasSize { get; set; }
/// <summary>Screens currently declaring a fixed canvas, keyed by owner
/// (see <see cref="DeclareFixedCanvas"/>).</summary>
private readonly Dictionary<object, Vector2> _fixedCanvasDeclarations = new();
/// <summary>
/// Declares that <paramref name="owner"/> wants the retained tree laid
/// out in <paramref name="size"/> while it is active. This is the single
/// arbiter for <see cref="FixedCanvasSize"/>: multiple owners may declare
/// concurrently (character-management stays declared while character-
/// creation is also open on top of it), and the effective
/// <see cref="FixedCanvasSize"/> is the shared declaration set's value.
/// Every current declarer must agree on the size — a mismatched second
/// declaration throws rather than silently overwriting the first
/// (Campaign CC CC4 review-fix round R1, 2026-08-15; see
/// <c>docs/architecture/retail-divergence-register.md</c> AD-98). Pair
/// every call with <see cref="RevokeFixedCanvas"/> on the SAME owner at
/// deactivate/close/dispose.
/// </summary>
public void DeclareFixedCanvas(object owner, Vector2 size)
{
ArgumentNullException.ThrowIfNull(owner);
if (_fixedCanvasDeclarations.TryGetValue(owner, out Vector2 existing))
{
if (existing == size)
return; // idempotent re-declare (e.g. a re-ticked activation edge)
throw new InvalidOperationException(
$"UiRoot.DeclareFixedCanvas: owner {owner} re-declared a different " +
$"canvas ({existing} -> {size}) without revoking first.");
}
foreach (Vector2 declared in _fixedCanvasDeclarations.Values)
{
if (declared != size)
{
throw new InvalidOperationException(
$"UiRoot.DeclareFixedCanvas: owner {owner} declared {size} but " +
$"another active owner already declared {declared} — every " +
"concurrently-active fixed-canvas screen must author the SAME " +
"canvas size (see AD-98).");
}
}
_fixedCanvasDeclarations[owner] = size;
FixedCanvasSize = size;
}
/// <summary>Revokes <paramref name="owner"/>'s declaration from
/// <see cref="DeclareFixedCanvas"/>. <see cref="FixedCanvasSize"/>
/// becomes null only once EVERY declarer has revoked; while another
/// owner is still declared, it stays set to that shared value. A
/// revoke from an owner that never declared (or already revoked) is a
/// no-op, matching the idempotent shutdown paths (<c>Deactivate</c>
/// AND <c>Dispose</c> can both revoke the same owner).</summary>
public void RevokeFixedCanvas(object owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (!_fixedCanvasDeclarations.Remove(owner))
return;
if (_fixedCanvasDeclarations.Count == 0)
{
FixedCanvasSize = null;
return;
}
foreach (Vector2 declared in _fixedCanvasDeclarations.Values)
{
FixedCanvasSize = declared;
break;
}
}
/// <summary>
/// The coordinate space the retained tree currently lays out in: the fixed
/// authored canvas while one is active, else the window itself. Anything