feat(chat): Campaign CH slice CH6a — retail chat-window layout + 8-grip resize

Swap ChatWindowController's imported main-chat LayoutDesc from the wrong
0x21000006 (an unrelated layout whose root and 800px resize bar appear
nowhere in the EoR gameplay UI) to retail's ACTUAL main chat window,
0x2100006F (window root 0x10000600, authored 410x100 — confirmed by a
direct DAT dump, found in dats.Local not dats.Portal). Every downstream
compensation that existed only to paper over the wrong import is deleted:
the hand-cropped 490px content width, the dropped 800px resize bar, the
9px transcript patch, the orphan-sibling pruning, the max/min-vs-scrollbar
overlap shift, and the scrollbar top-reclaim. The window now mounts with
RetailWindowChrome.Imported (0x2100006F's own 8 border/corner elements are
its complete chrome) instead of the universal nine-slice wrapper.

LayoutImporter/DatWidgetFactory gain a Type-9 (UIElement_Resizebar) case:
UiResizeGrip decodes retail's exact four-bool BorderLocation algorithm
(0x2A=bottom/0x2B=left/0x2C=right/0x2D=top,
UIElement_Resizebar::StartMouseResizing @0x0046B7E0) into a ResizeEdges
bitmask. A direct DAT dump established the true shape: only 7 of the 8
grip-position ids are Type 9 — the straight top-EDGE strip (0x1000069C) is
a Type-2 Dragbar (move handle), not a Resizebar, because the main window
has no title bar. UiRoot now gives a directly-hit grip's own edges
priority over its generic proximity heuristic, and a directly-hit move
handle the same priority over ambient proximity — so the plain top strip
moves the window while its two corner grips resize it including the Y
axis, and all 4 edges + 4 corners work everywhere else. This also fixes
the reported "no diagonal cursor at corners" (CursorFeedbackController's
existing RetailCursorCatalog cursor ids already matched the DAT exactly;
they just never received a genuine diagonal edge combination) and "cannot
grow in Y from the bottom-right corner" (the old NineSlice+crop mount's
indirection is gone; the Imported mount uses the DAT's real
minH=100/maxH=2000/minW=300/maxW=2000 directly).

The 8 cosmetic "_Locked" border-art twins default hidden (register row
AP-185 — retail's UiLocked-driven art swap between the two skins is not
ported; UiRoot.UiLocked continues to gate the underlying interaction
correctly either way). The 4 chat-window-1..4 indicator buttons import
generically (visible, inert) for CH6b to wire. The two hand-drawn
translucent-black tints on the transcript/input are removed now that
their parent panels draw their own authored background sprites.

Filed #366 (chat window's new-unseen-text indicator 0x1000048C is
swallowed by UiText.ConsumesDatChildren, pre-existing and out of scope).
Corrected the research doc's "all eight grips" claim against the direct
DAT dump. Full Release suite: 12,317 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 09:38:27 +02:00
parent ab82347d42
commit 1fd515436c
19 changed files with 6734 additions and 5694 deletions

View file

@ -1,3 +1,4 @@
using System.Linq;
using System.Numerics;
using AcDream.App.UI;
@ -590,6 +591,195 @@ public class UiRootInputTests
Assert.True((UiRoot.HitEdges(panel, 200, 200, 5) & ResizeEdges.Bottom) == 0);
}
// ── Literal Resizebar grip priority (Campaign CH slice CH6a) ─────────────
// Retail authors 8 grip/dragbar elements at a window's edges/corners
// (docs/research/2026-08-09-chat-retail-window-shell.md §2.1/§2.3). A
// directly-hit grip's own edges must win over the generic proximity
// heuristic, and a directly-hit move handle (dragbar) must win over an
// overlapping proximity match — this is what lets the main chat window's
// top STRIP move the window while its top-left/top-right CORNERS resize it.
private static UiPanel WindowWithCornerGrips(
float left = 100, float top = 100, float width = 200, float height = 100)
{
var window = new UiPanel
{
Left = left, Top = top, Width = width, Height = height,
Draggable = true, Resizable = true,
MinWidth = 40, MinHeight = 40, MaxWidth = 2000, MaxHeight = 2000,
};
window.AddChild(new UiResizeGrip { BorderLocation = UiResizeGrip.Border.UpperLeft, Left = 0, Top = 0, Width = 5, Height = 5 });
window.AddChild(new UiResizeGrip { BorderLocation = UiResizeGrip.Border.UpperRight, Left = width - 5, Top = 0, Width = 5, Height = 5 });
window.AddChild(new UiResizeGrip { BorderLocation = UiResizeGrip.Border.LowerLeft, Left = 0, Top = height - 5, Width = 5, Height = 5 });
window.AddChild(new UiResizeGrip { BorderLocation = UiResizeGrip.Border.LowerRight, Left = width - 5, Top = height - 5, Width = 5, Height = 5 });
return window;
}
[Theory]
[InlineData(UiResizeGrip.Border.UpperLeft)]
[InlineData(UiResizeGrip.Border.UpperRight)]
[InlineData(UiResizeGrip.Border.LowerLeft)]
[InlineData(UiResizeGrip.Border.LowerRight)]
public void CornerGrip_GrowsBothAxes_WhenDraggedOutward(UiResizeGrip.Border corner)
{
var root = new UiRoot { Width = 800, Height = 600 };
var window = WindowWithCornerGrips();
root.AddChild(window);
var grip = window.Children.OfType<UiResizeGrip>().Single(g => g.BorderLocation == corner);
var gs = grip.ScreenPosition;
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
bool growsLeft = corner is UiResizeGrip.Border.UpperLeft or UiResizeGrip.Border.LowerLeft;
bool growsUp = corner is UiResizeGrip.Border.UpperLeft or UiResizeGrip.Border.UpperRight;
int dx = growsLeft ? -30 : 30;
int dy = growsUp ? -30 : 30;
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
Assert.True(root.ActiveResizeEdges != ResizeEdges.None);
root.OnMouseMove(pressX + dx, pressY + dy);
root.OnMouseUp(UiMouseButton.Left, pressX + dx, pressY + dy);
Assert.Equal(230f, window.Width);
Assert.Equal(130f, window.Height);
Assert.Equal(growsLeft ? 70f : 100f, window.Left);
Assert.Equal(growsUp ? 70f : 100f, window.Top);
}
[Theory]
[InlineData(UiResizeGrip.Border.UpperLeft)]
[InlineData(UiResizeGrip.Border.UpperRight)]
[InlineData(UiResizeGrip.Border.LowerLeft)]
[InlineData(UiResizeGrip.Border.LowerRight)]
public void CornerGrip_ShrinksBothAxes_WhenDraggedInward(UiResizeGrip.Border corner)
{
var root = new UiRoot { Width = 800, Height = 600 };
var window = WindowWithCornerGrips();
root.AddChild(window);
var grip = window.Children.OfType<UiResizeGrip>().Single(g => g.BorderLocation == corner);
var gs = grip.ScreenPosition;
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
bool growsLeft = corner is UiResizeGrip.Border.UpperLeft or UiResizeGrip.Border.LowerLeft;
bool growsUp = corner is UiResizeGrip.Border.UpperLeft or UiResizeGrip.Border.UpperRight;
// Shrink: drag the opposite direction from "grow".
int dx = growsLeft ? 30 : -30;
int dy = growsUp ? 30 : -30;
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX + dx, pressY + dy);
root.OnMouseUp(UiMouseButton.Left, pressX + dx, pressY + dy);
Assert.Equal(170f, window.Width);
Assert.Equal(70f, window.Height);
Assert.Equal(growsLeft ? 130f : 100f, window.Left);
Assert.Equal(growsUp ? 130f : 100f, window.Top);
}
[Fact]
public void CornerGrip_TakesPriorityOverBlanketResizableEdgesMask()
{
// A directly-hit grip's own edges win outright — even if the window's
// blanket ResizableEdges mask would otherwise exclude Top (the exact
// shape retail's main chat window needs: Top excluded from ambient
// proximity so the move-handle strip works, but the TL/TR corner grips
// still resize the Y/top axis).
var root = new UiRoot { Width = 800, Height = 600 };
var window = WindowWithCornerGrips();
window.ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
root.AddChild(window);
var grip = window.Children.OfType<UiResizeGrip>().Single(g => g.BorderLocation == UiResizeGrip.Border.UpperLeft);
var gs = grip.ScreenPosition;
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
Assert.Equal(ResizeEdges.Left | ResizeEdges.Top, root.ActiveResizeEdges);
root.OnMouseUp(UiMouseButton.Left, pressX, pressY);
}
[Fact]
public void GripEdges_AreMaskedByResizableEdges_WhenTheWindowExcludesAnAxis()
{
var root = new UiRoot { Width = 800, Height = 600 };
var window = WindowWithCornerGrips();
window.ResizableEdges = ResizeEdges.Left | ResizeEdges.Bottom; // no Right, no Top
root.AddChild(window);
var grip = window.Children.OfType<UiResizeGrip>().Single(g => g.BorderLocation == UiResizeGrip.Border.UpperRight);
var gs = grip.ScreenPosition;
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
// Border.UpperRight decodes to Right|Top; masked by Left|Bottom leaves nothing.
Assert.Equal(ResizeEdges.None, root.ActiveResizeEdges);
root.OnMouseUp(UiMouseButton.Left, pressX, pressY);
}
[Fact]
public void MoveHandle_TakesPriorityOverOverlappingAmbientResizeProximity()
{
// The main chat window's top strip (a Type-2 Dragbar) sits WITHIN
// ResizeGrip proximity of the window's own top edge — if the blanket
// mask includes Top, the generic proximity heuristic would otherwise
// also match there and steal the move. A directly-hit move handle must
// win over that ambient match.
var root = new UiRoot { Width = 800, Height = 600 };
var window = new UiPanel
{
Left = 100, Top = 100, Width = 200, Height = 100,
Draggable = true, Resizable = true,
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom,
};
var handle = new UiPanel { Left = 5, Top = 0, Width = 190, Height = 5, WindowMoveHandle = true };
window.AddChild(handle);
root.AddChild(window);
// Press well inside the handle strip but still within ResizeGrip(5) of
// the window's own top edge (y=100..105).
root.OnMouseDown(UiMouseButton.Left, 150, 102);
Assert.Equal(ResizeEdges.None, root.ActiveResizeEdges);
Assert.True(root.IsWindowMoveActive);
root.OnMouseMove(200, 152);
Assert.Equal(150f, window.Left);
Assert.Equal(150f, window.Top);
root.OnMouseUp(UiMouseButton.Left, 200, 152);
}
[Fact]
public void HoverResizeEdges_OverAGrip_ReturnsTheGripsOwnEdges()
{
var root = new UiRoot { Width = 800, Height = 600 };
var window = WindowWithCornerGrips();
root.AddChild(window);
var grip = window.Children.OfType<UiResizeGrip>().Single(g => g.BorderLocation == UiResizeGrip.Border.LowerRight);
var gs = grip.ScreenPosition;
root.OnMouseMove((int)(gs.X + 2), (int)(gs.Y + 2));
Assert.Equal(ResizeEdges.Right | ResizeEdges.Bottom, root.HoverResizeEdges);
}
[Fact]
public void HoverResizeEdges_OverAMoveHandle_IsNoneEvenWithinProximityOfTheTopEdge()
{
var root = new UiRoot { Width = 800, Height = 600 };
var window = new UiPanel
{
Left = 100, Top = 100, Width = 200, Height = 100,
Draggable = true, Resizable = true,
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom,
};
var handle = new UiPanel { Left = 5, Top = 0, Width = 190, Height = 5, WindowMoveHandle = true };
window.AddChild(handle);
root.AddChild(window);
root.OnMouseMove(150, 102);
Assert.Equal(ResizeEdges.None, root.HoverResizeEdges);
Assert.True(root.HoverWindowMove);
}
[Fact]
public void ComputeAnchoredRect_LeftRight_StretchesWidth()
{