fix(chargen): Campaign CC gate round 1 re-test 3 — R4-1..R4-4

Four visual residuals from the lead's own live-client captures of
1.0.2-cc.m, all root-caused via decomp + live-DAT evidence:

- R4-1: Skills credits value overlapped mid-caption again. Root cause
  was a missing UiLayoutPolicy raw-edge reflow on UiButton's value-child
  rect (the child is base-inherited across four sibling buttons of
  differing widths, so its baked-in OriginalParentWidth diverges from
  the actual 231px-wide Skills credits button) plus an HJustify.Right
  value child mapped to Center instead of a real far-edge Right.
- R4-2: the single-sprite scrollbar thumb tiled (GL_REPEAT) instead of
  drawing once — DrawTiled was reused for a small fixed marker graphic
  whose native size is far smaller than the track-proportional thumb
  rect. New DrawThumbMarker draws exactly one native-size instance.
- R4-3: the skills info-box formula line clipped past the surrounding
  gold frame's own authored bottom edge (the pane's own raw box is 20px
  taller than the frame that visually contains it) — clamp the pane's
  Height to the frame's bottom (register AD-105, since retail's
  ShowSkillsText has no code relationship to the frame to cite).
- R4-4: the Appearance help text started mid-sentence — the box was
  never touched by its page controller, so it kept UiText's chat-style
  PreserveEndOnLayout=true default; the scroll model's wasAtEnd check is
  vacuously true on its first-ever overflow transition, pinning the
  first render to the bottom. Set PreserveEndOnLayout=false (a static
  top-oriented report, not a transcript) and wired the box's own nested
  authored scrollbar, never wired before.

App suite live-DAT env 5372/3 -> 5379/3 (+7, zero regressions). Runtime
1735/0 unchanged. Full solution 14585/4 skips/1 failure (the documented
Core.Net NakEmission full-solution-only flake, confirmed standalone-pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 19:05:23 +02:00
parent 28704db4bf
commit e6acb800cc
11 changed files with 678 additions and 12 deletions

View file

@ -1673,6 +1673,36 @@ public sealed class CharacterCreationLiveDatTests
Assert.Equal(116f, valueChild.X);
}
/// <summary>
/// R4-1 (re-test 3): the raw authored value-child X (116, pinned above)
/// is NOT where the value actually draws — <c>UiLayoutPolicy</c>'s
/// raw-edge reflow (the value child's own Right-tracking edge modes
/// against its base-inherited 150px <c>OriginalParentWidth</c> vs the
/// Skills-credits button's actual 231px width) shifts it to X=197,
/// landing right after the caption's own measured 193px span instead
/// of colliding mid-caption ("Available Skill0Credits"). Health's own
/// value child shares the SAME 150px OriginalParentWidth as its OWN
/// actual 150px-wide button (no divergence), so it reflows to its
/// byte-identical raw rect — proving the fix is additive, not a
/// blanket shift.
/// </summary>
[InstalledDatFact]
public void SkillsCreditsButton_ValueBoxReflowsPastCaption_HealthValueBoxUnchanged()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiButton skillsCredits = Assert.IsType<UiButton>(screen.FindElement(0x100003F9u));
Assert.Equal((197f, 0f, 34f, 28f), skillsCredits.ValueBox);
Assert.Equal(UiButton.LabelAlignment.Right, skillsCredits.ValueAlign);
UiButton health = Assert.IsType<UiButton>(screen.FindElement(0x100003E3u));
Assert.Equal((116f, 0f, 34f, 28f), health.ValueBox);
}
/// <summary>
/// R3-3 (re-test 2): the info-box title (<c>0x100003fb</c>) and
/// description (<c>0x100003fc</c>) panes' own AUTHORED boxes overlap —
@ -1714,6 +1744,41 @@ public sealed class CharacterCreationLiveDatTests
Assert.True(description.Y > title.Y);
}
/// <summary>
/// R4-3 (re-test 3): the description pane's own raw box (Y=460,
/// H=100 -&gt; bottom Y=560) extends PAST the bottom of the gold
/// decorative frame that visually contains both info panes
/// (<c>0x100003fa</c>, Y=430 H=110 -&gt; bottom Y=540 — the SAME
/// corner/edge sprite family GF-12 already renders,
/// <c>0x100002de-e3</c>/<c>0x100000e8</c>/<c>0xea</c>). Pins the
/// geometric mismatch itself (so a future DAT re-extract that removes
/// it is visible) — <c>CharacterCreationSkillsPageTests</c>' own fixture
/// covers the constructor's Height-clamp behavior against this exact
/// shape.
/// </summary>
[InstalledDatFact]
public void SkillsInfoBoxFrame_ShorterThanDescriptionPane()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
ElementInfo frame = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003FAu));
ElementInfo description = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003FCu));
float frameBottom = frame.Y + frame.Height;
float paneBottom = description.Y + description.Height;
Assert.True(
frameBottom < paneBottom,
$"expected the frame's own bottom (Y={frame.Y} H={frame.Height}, bottom={frameBottom}) to sit "
+ $"ABOVE the description pane's own raw bottom (Y={description.Y} H={description.Height}, "
+ $"bottom={paneBottom}) — if it no longer does, CharacterCreationSkillsPage's Height clamp "
+ "may no longer be needed");
}
/// <summary>
/// R3-4/R3-7 (re-test 2): retail authors TWO distinct
/// <c>UIElement_Scrollbar</c> thumb shapes. Chat's own scrollbar

View file

@ -457,6 +457,37 @@ public sealed class CharacterCreationUiControllerTests
Assert.Equal(VJustify.Top, environment.SkillInfoText().VerticalJustify);
}
/// <summary>
/// R4-3 (Campaign CC gate round 1 re-test 3): the description pane's
/// own raw box (<c>0x100003fc</c>) is TALLER than the surrounding gold
/// decorative frame that visually contains it (<c>0x100003fa</c> —
/// live-DAT-measured, see <see cref="CharacterCreationSkillsPage"/>'s
/// own R4-3 comment for the full geometry + decomp citation: retail's
/// <c>ShowSkillsText</c> has no code relationship between the text
/// panes and this frame, so the frame's own authored bottom edge is
/// the only ground truth for "the visible box"). Before this fix, a
/// long skill's formula line could draw into blank page space below
/// the frame's own border — <c>BuildSkillsPage</c>'s fixture frame
/// (Y=0 H=50) is deliberately shorter than <c>TextInfo</c>'s own
/// default pane Height (60), so this pins the constructor clamping the
/// live-mounted pane's own Height down to the frame's bottom edge
/// (50) instead of leaving it at its own larger raw 60.
/// </summary>
[Fact]
public void SkillsPage_InfoBoxDescriptionPane_HeightClampedToFrameBottom()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
Assert.Equal(50f, environment.SkillInfoText().Height, 3f);
// The title pane sits OUTSIDE the frame's own child range in this
// fixture (a sibling, not touched by the clamp) — confirms the fix
// is scoped to the description pane only, matching the constructor.
Assert.Equal(60f, environment.SkillInfoTitle().Height, 3f);
}
/// <summary>R2-4a: retail re-selects the row after an arrow click too
/// (<c>ListenToElementMessage @0x004814c0</c>'s own
/// <c>SetSelectedItem(...,1)</c> call following
@ -1229,6 +1260,34 @@ public sealed class CharacterCreationUiControllerTests
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
}
/// <summary>
/// R4-4 (Campaign CC gate round 1 re-test 3): the framed help/
/// instructions box (<c>0x100003ab</c>) — before this fix, this
/// element was never touched by <see cref="CharacterCreationAppearancePage"/>'s
/// constructor at all, so it kept <see cref="UiText"/>'s own chat-style
/// default (<c>PreserveEndOnLayout=true</c>) and its own nested
/// authored scrollbar (live-DAT-confirmed a direct Type-11 child,
/// property <c>0x72</c>) was never wired to
/// <see cref="UiText.Scroll"/>. Pins both halves of the fix: the box is
/// no longer chat-style bottom-pinned, and the scrollbar's
/// <see cref="UiScrollbar.Model"/> now points at the SAME
/// <see cref="UiScrollable"/> the text itself scrolls.
/// </summary>
[Fact]
public void AppearancePage_HelpText_TopOriented_AndOwnScrollbarIsWired()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
UiText helpText = Assert.IsType<UiText>(
environment.Screen.FindElement(CharacterCreationAppearancePage.HelpTextId));
Assert.False(helpText.PreserveEndOnLayout);
UiScrollbar helpScroll = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(helpText, 0x100002E7u));
Assert.Same(helpText.Scroll, helpScroll.Model);
}
/// <summary>
/// GF-10 (Campaign CC gate round 1 Batch B): ports
/// <c>gmCGAppearancePage::ZoomIn @0x0047CF00</c>
@ -2813,6 +2872,21 @@ public sealed class CharacterCreationUiControllerTests
page.Children.Add(ScrollbarInfo(0x100003F8u));
page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge
page.Children.Add(TextInfo(0x100003FBu));
// R4-3 (re-test 3): the description pane's own decorative frame
// (0x100003fa, live-DAT-measured SHORTER than the pane it visually
// contains — see CharacterCreationSkillsPage's own R4-3 comment).
// Y=0/Height=50 here is deliberately shorter than TextInfo's own
// default Height=60 so CharacterCreationSkillsPageTests can pin the
// constructor's Height clamp without needing the real installed
// DAT's exact pixel geometry.
page.Children.Add(new ElementInfo
{
Id = 0x100003FAu,
Type = 12u,
Y = 0f,
Width = 200f,
Height = 50f,
});
page.Children.Add(TextInfo(0x100003FCu));
return page;
}
@ -2912,6 +2986,22 @@ public sealed class CharacterCreationUiControllerTests
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomInId));
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomOutId));
// R4-4 (re-test 3): the framed instructions box, with its OWN
// nested authored scrollbar child — live-DAT-confirmed shape (a
// direct Type-11 child of the Type-12 text box, the SAME nesting
// CharacterCreationSummaryPage's HowToScrollRelativeId already
// uses). Deliberately taller than one view's worth so the long
// static help paragraph genuinely overflows in the test below.
var helpText = TextInfo(CharacterCreationAppearancePage.HelpTextId);
var helpScrollInfo = new ElementInfo { Id = 0x100002E7u, Type = 11u, Width = 12f, Height = 40f };
// UiText/UiField's own dat-children carve-out (LayoutImporter.BuildWidget)
// only builds a child that carries its own authored StateMedia — the
// SAME "genuinely renderable chrome, not swallowed prototype data"
// gate the real scrollbar's own DirectState track sprite satisfies.
helpScrollInfo.StateMedia[""] = (0x06001919u, 1);
helpText.Children.Add(helpScrollInfo);
page.Children.Add(helpText);
return page;
}

View file

@ -499,6 +499,106 @@ public class DatWidgetFactoryTests
Assert.Equal("42", button.ValueLabel);
}
/// <summary>
/// R4-1 (Campaign CC gate round 1 re-test 3): the value child
/// (<c>0x100002f3</c>) is BASE-INHERITED across four sibling buttons of
/// DIFFERING widths — Health/Stamina/Mana at 150px share the same
/// child id/rect (local X=116) as the wider, 231px Skills credits
/// button — so the child's own authored <c>OriginalParentWidth</c>
/// (baked in at whichever button FIRST resolved it, 150) diverges from
/// the ACTUAL containing button's current width (231) for exactly the
/// wider button. Before this fix, <c>ValueBox</c> was the child's raw
/// (un-reflowed) rect regardless — this fixture reproduces that exact
/// shape (a 150px "design" width baked into the child, hosted under a
/// 231px-wide button, with the child's own Right-tracking edge modes
/// 2/1) and proves <c>UiLayoutPolicy</c>'s raw-edge reflow now shifts
/// the value box by the SAME 81px the button grew (116 -> 197),
/// landing clear of "Available Skill Credits"'s own measured span
/// instead of colliding mid-caption ("Available Skill0Credits").
/// </summary>
[Fact]
public void BuildButton_ValueChildBaseInheritedNarrowerParent_ReflowsToWiderButton()
{
uint captionStringId = 333u;
var info = new ElementInfo { Type = 1, Width = 231, Height = 28 };
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0),
};
var valueChild = new ElementInfo
{
Type = 12,
X = 116,
Y = 0,
Width = 34,
Height = 28,
Left = 2,
Top = 1,
Right = 1,
Bottom = 1,
OriginalParentWidth = 150,
OriginalParentHeight = 28,
HasOriginalParentSize = true,
HJustify = HJustify.Right,
};
info.Children.Add(valueChild);
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: value => value.StringId == captionStringId ? "Available Skill Credits" : null));
Assert.Equal("Available Skill Credits", button.Label);
// 116 + (231-150) = 197 -> width/height preserved (34/28).
Assert.Equal((197f, 0f, 34f, 28f), button.ValueBox);
Assert.Equal(UiButton.LabelAlignment.Right, button.ValueAlign);
}
/// <summary>
/// Negative companion to the reflow test above: a value child whose
/// OWN authored parent width already MATCHES the actual button (the
/// Health/Stamina/Mana shape, un-widened) reflows to its byte-identical
/// raw rect — delta is zero, so this is confirmed additive, not a
/// blanket shift.
/// </summary>
[Fact]
public void BuildButton_ValueChildOriginalParentMatchesActual_RectUnchanged()
{
uint captionStringId = 334u;
var info = new ElementInfo { Type = 1, Width = 150, Height = 28 };
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0),
};
var valueChild = new ElementInfo
{
Type = 12,
X = 116,
Y = 0,
Width = 34,
Height = 28,
Left = 2,
Top = 1,
Right = 1,
Bottom = 1,
OriginalParentWidth = 150,
OriginalParentHeight = 28,
HasOriginalParentSize = true,
};
info.Children.Add(valueChild);
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: value => value.StringId == captionStringId ? "Health" : null));
Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox);
}
/// <summary>
/// Negative companion: a button whose caption was LIFTED from a
/// distinct Type-12 child (the town-marker shape,

View file

@ -1,3 +1,8 @@
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using Xunit;
@ -466,4 +471,56 @@ public class UiScrollbarTests
Assert.Equal(expectedWidth, width, 3);
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
/// <summary>
/// R4-2 (Campaign CC gate round 1 re-test 3): the re-test-2 single-
/// sprite-thumb fallback (R3-4/R3-7, <see cref="UiScrollbar.OnDraw"/>'s
/// no-cap-sprites branch) used to call the same UV-repeat
/// <c>DrawTiled</c> the 3-slice middle tile uses — for a small fixed
/// "diamond" marker sprite drawn into a MUCH taller track-proportional
/// thumb rect, GL_REPEAT wrapping visibly tiled the marker several
/// times down the track (Summary's overview bar ~9, Skills 2, per the
/// live capture). Proves the fix draws exactly ONE quad for the thumb
/// texture whose V range never exceeds native (1.0) — i.e. one
/// unstretched, untiled sprite instance — even though the computed
/// thumb rect (168px trackLen * ThumbRatio 0.75 = 126px, well past the
/// sprite's native 16px) is far taller than the sprite.
/// </summary>
[Fact]
public void SingleSpriteThumb_DrawsOneUntiledInstance_NotRepeatedDownTrack()
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
const uint thumbTex = 42u;
var model = new UiScrollable { ContentHeight = 200, ViewHeight = 150 };
var bar = new UiScrollbar
{
Width = 16f,
Height = 200f,
SpriteResolve = id => id == thumbTex ? (thumbTex, 16, 16) : (0u, 0, 0),
ThumbSprite = thumbTex,
// ThumbTopSprite/ThumbBotSprite stay unset -> the R3-4/R3-7
// single-sprite fallback shape (no 3-slice caps authored).
Model = model,
};
bar.DrawSelfAndChildren(ctx);
var thumbSegments = renderer.DebugSpriteSegmentVerts
.Where(s => s.Texture == thumbTex)
.ToArray();
Assert.Single(thumbSegments);
var verts = thumbSegments[0].Verts;
// 8 floats/vertex (x,y,u,v,r,g,b,a), one quad = 6 vertices.
Assert.Equal(6, verts.Count / 8);
for (int i = 0; i < verts.Count; i += 8)
Assert.True(verts[i + 3] <= 1.0001f, $"thumb sprite V={verts[i + 3]} exceeds native (tiled)");
}
}