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

@ -97,6 +97,63 @@ public sealed class CursorFeedbackControllerTests
Assert.Equal(new UiCursorMedia(0x06006128u, 16, 16), active.Cursor);
}
[Fact]
public void CornerGrip_ShowsTheDiagonalResizeCursor_NotTheStraightOne()
{
// Campaign CH slice CH6a, user-gate round 2 item 6: hovering a directly
// authored corner Resizebar grip (not just the generic proximity edge)
// must resolve to the retail diagonal cursor, matching the same pinned
// ids as ResizeEdges_chooseExpectedRetailCursor (0x06006126 for the
// NW-SE UL/LR diagonal).
var root = new UiRoot { Width = 400, Height = 300 };
var window = new UiPanel
{
Left = 50, Top = 40, Width = 150, Height = 100,
Draggable = true, Resizable = true,
};
var grip = new UiResizeGrip
{
BorderLocation = UiResizeGrip.Border.LowerRight,
Left = 145, Top = 95, Width = 5, Height = 5,
};
window.AddChild(grip);
root.AddChild(window);
var controller = new CursorFeedbackController();
var gs = grip.ScreenPosition;
root.OnMouseMove((int)(gs.X + 2), (int)(gs.Y + 2));
CursorFeedback feedback = controller.Update(root);
Assert.Equal(CursorFeedbackKind.ResizeDiagonalNwse, feedback.Kind);
Assert.Equal(new UiCursorMedia(0x06006126u, 16, 16), feedback.Cursor);
}
[Fact]
public void EdgeGrip_ShowsTheStraightResizeCursor()
{
var root = new UiRoot { Width = 400, Height = 300 };
var window = new UiPanel
{
Left = 50, Top = 40, Width = 150, Height = 100,
Draggable = true, Resizable = true,
};
var grip = new UiResizeGrip
{
BorderLocation = UiResizeGrip.Border.Bottom,
Left = 5, Top = 95, Width = 140, Height = 5,
};
window.AddChild(grip);
root.AddChild(window);
var controller = new CursorFeedbackController();
var gs = grip.ScreenPosition;
root.OnMouseMove((int)(gs.X + 2), (int)(gs.Y + 2));
CursorFeedback feedback = controller.Update(root);
Assert.Equal(CursorFeedbackKind.ResizeVertical, feedback.Kind);
Assert.Equal(new UiCursorMedia(0x06005E66u, 16, 16), feedback.Cursor);
}
[Fact]
public void AuthoredWidgetCursor_winsUntilSyntheticWindowMoveStarts()
{

View file

@ -7,9 +7,11 @@ using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Dat-free conformance tests for the committed chat_21000006.json golden fixture.
/// Verifies that LayoutImporter.ImportInfos correctly resolves the BaseElement /
/// BaseLayoutId inheritance chain for the chat window (LayoutDesc 0x21000006).
/// Dat-free conformance tests for the committed chat_2100006f.json golden fixture —
/// retail's ACTUAL main chat window (LayoutDesc <c>0x2100006F</c>, window root
/// <c>0x10000600</c>, authored 410x100; Campaign CH slice CH6a). Verifies that
/// LayoutImporter.ImportInfos correctly resolves the BaseElement / BaseLayoutId
/// inheritance chain.
/// </summary>
public class ChatLayoutConformanceTests
{
@ -50,6 +52,72 @@ public class ChatLayoutConformanceTests
Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input)
}
[Theory]
[InlineData(0x10000522u)]
[InlineData(0x10000523u)]
[InlineData(0x10000524u)]
[InlineData(0x10000525u)]
public void ChatFixture_ChatWindowIndicatorButtons_ImportFromTheAuthoredTree(uint indicatorId)
{
// gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80 writes these
// four state-mirror buttons for floating chat windows 1-4. CH6a imports
// them generically (visible, inert) and leaves wiring their live/pressed
// state to CH6b's floating-window slice.
var root = FixtureLoader.LoadChatInfos();
Assert.NotNull(Find(root, indicatorId));
}
[Theory]
[InlineData(0x10000522u)]
[InlineData(0x10000523u)]
[InlineData(0x10000524u)]
[InlineData(0x10000525u)]
public void MountedChatWindow_IndicatorButtons_ImportVisibleAndInert(uint indicatorId)
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, null, null, NoTex);
Assert.NotNull(controller);
UiElement? indicator = layout.FindElement(indicatorId);
Assert.NotNull(indicator);
Assert.True(indicator!.Visible);
// Inert: CH6a does not wire a click handler (CH6b's job — the
// one-directional visibility mirror has no button-press behavior of
// its own in retail either, per the research doc §1.4). UiButton
// structurally always reports HandlesClick=true (it can receive the
// event), but with no OnClick/OnClickAt bound, a click is a no-op.
var button = Assert.IsType<UiButton>(indicator);
Assert.Null(button.OnClick);
Assert.Null(button.OnClickAt);
}
[Theory]
[InlineData(0x10000693u)]
[InlineData(0x10000694u)]
[InlineData(0x10000695u)]
[InlineData(0x10000696u)]
[InlineData(0x10000697u)]
[InlineData(0x10000698u)]
[InlineData(0x10000699u)]
[InlineData(0x1000069Au)]
public void MountedChatWindow_LockedTwinBorderArt_DefaultsHidden(uint lockedTwinId)
{
// Register row AP-185: CH6a shows only the live (unlocked) grip/dragbar
// border-art set by default, matching UiRoot.UiLocked's own false
// default and avoiding a double-rendered border.
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, null, null, NoTex);
Assert.NotNull(controller);
UiElement? twin = layout.FindElement(lockedTwinId);
Assert.NotNull(twin);
Assert.False(twin!.Visible);
}
[Fact]
public void ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace()
{
@ -82,16 +150,39 @@ public class ChatLayoutConformanceTests
public void ChatFixture_WindowRootCarriesRetailHeightConstraints()
{
var root = FixtureLoader.LoadChatInfos();
var window = Find(root, 0x1000000Eu)!;
var window = Find(root, 0x10000600u)!;
Assert.True(window.TryGetEffectiveInteger(0x3Eu, out int minHeight));
Assert.True(window.TryGetEffectiveInteger(0x3Cu, out int maxHeight));
Assert.Equal(100, minHeight);
Assert.Equal(360, maxHeight);
Assert.Equal(2000, maxHeight);
}
[Fact]
public void ChatFixture_CroppedDockedRoot_ReflowsContentAcrossMountedWidth()
public void ChatFixture_WindowRootCarriesRetailWidthConstraints()
{
var root = FixtureLoader.LoadChatInfos();
var window = Find(root, 0x10000600u)!;
Assert.True(window.TryGetEffectiveInteger(0x3Fu, out int minWidth));
Assert.True(window.TryGetEffectiveInteger(0x3Du, out int maxWidth));
Assert.Equal(300, minWidth);
Assert.Equal(2000, maxWidth);
}
[Fact]
public void ChatFixture_RootIsAuthored410x100_NoCropNeeded()
{
// Campaign CH slice CH6a: 0x2100006F's window root is already the exact
// mounted extent — the 490px content-width crop that the wrong
// 0x21000006 import needed no longer applies to anything.
var root = FixtureLoader.LoadChatInfos();
Assert.Equal(410f, root.Width);
Assert.Equal(100f, root.Height);
}
[Fact]
public void ChatFixture_DockedRoot_ReflowsContentAcrossMountedWidth()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
@ -105,19 +196,24 @@ public class ChatLayoutConformanceTests
NoTex);
Assert.NotNull(controller);
// No crop-then-rebase dance needed: the root's OWN authored extent
// (410x100) already matches the design width children's imported
// LayoutPolicy captured, so growing it directly exercises the same
// raw-edge reflow the crop workaround needed a rebase step for.
var root = controller!.Root;
root.Width = 490;
root.Height = 100;
root.RebaseChildLayoutBaselines();
Assert.Equal(410f, root.Width);
root.Width = 615;
// Both panels are authored with a 5px margin to BOTH the left and right
// window edges (X=5, right-edge width = 410-(5+400) = 5) — a stretch
// grows the panel by the delta MINUS both margins: 615 - 5 - 5 = 605.
var transcriptPanel = layout.FindElement(0x10000010u)!;
var inputBar = layout.FindElement(0x10000013u)!;
transcriptPanel.ApplyAnchor(root.Width, root.Height);
inputBar.ApplyAnchor(root.Width, root.Height);
Assert.Equal(615f, transcriptPanel.Width);
Assert.Equal(615f, inputBar.Width);
Assert.Equal(605f, transcriptPanel.Width);
Assert.Equal(605f, inputBar.Width);
}
[Fact]
@ -158,6 +254,11 @@ public class ChatLayoutConformanceTests
null,
NoTex)!;
var root = new UiRoot { Width = 800, Height = 600 };
// Campaign CH slice CH6a: 0x2100006F's own border art IS the window
// chrome — Chrome=Imported, no content-width crop, no MinWidth override
// (the DAT's own 0x3D/0x3F=2000/300 govern). Frame == content, so the
// maximize math below operates directly on the authored 410x100 extent
// with the DAT's real minH=100/maxH=2000 (not the wrong layout's 360).
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
@ -165,36 +266,126 @@ public class ChatLayoutConformanceTests
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.NineSlice,
Chrome = RetailWindowChrome.Imported,
Left = 12f,
Top = 390f,
ContentWidth = 490f,
ContentHeight = controller.Root.Height,
RebaseContentLayout = true,
DatConstraintSource = controller.DatWindowInfo,
MinWidth = 200f,
});
controller.AttachWindow(handle);
var maxMin = Assert.IsType<UiButton>(layout.FindElement(0x1000046Fu));
// frame.Height=100, parentHeight=600, expansion=300 → targetHeight =
// clamp(min(400,600),100,2000) = 400. growUp because Top(390)+400>600.
// targetTop = max(0, 390-(400-100)) = 90. Lower edge fixed at 490.
maxMin.OnClick!();
Assert.True(controller.IsMaximized);
Assert.Equal(130f, handle.Top);
Assert.Equal(370f, handle.Height); // DAT max 360 + 2*5px shared chrome
Assert.Equal(500f, handle.Top + handle.Height); // lower edge stays fixed while growing upward
Assert.Equal(90f, handle.Top);
Assert.Equal(400f, handle.Height);
Assert.Equal(490f, handle.Top + handle.Height); // lower edge stays fixed while growing upward
Assert.Equal(RetailUiStateIds.Maximized, maxMin.ActiveRetailStateId);
controller.Root.ApplyAnchor(handle.Width, handle.Height);
Assert.Equal(360f, controller.Root.Height);
maxMin.OnClick!();
Assert.False(controller.IsMaximized);
Assert.Equal(390f, handle.Top);
Assert.Equal(110f, handle.Height);
Assert.Equal(100f, handle.Height);
Assert.Equal(RetailUiStateIds.Minimized, maxMin.ActiveRetailStateId);
}
[Theory]
[InlineData(0x1000069Bu, UiResizeGrip.Border.UpperLeft)]
[InlineData(0x1000069Du, UiResizeGrip.Border.UpperRight)]
[InlineData(0x1000069Fu, UiResizeGrip.Border.LowerLeft)]
[InlineData(0x100006A1u, UiResizeGrip.Border.LowerRight)]
public void MountedChatWindow_CornerGrip_ImportsWithCorrectBorderLocation(
uint elementId, UiResizeGrip.Border expected)
{
// Campaign CH slice CH6a, user-gate round 2 item 6: pins that the 4
// corner grips from 0x2100006F import with the RIGHT BorderLocation —
// the prerequisite for the reported "no diagonal cursor" / "can't grow
// in Y from the bottom-right corner" symptoms to be fixed at all.
var layout = FixtureLoader.LoadChat();
var grip = Assert.IsType<UiResizeGrip>(layout.FindElement(elementId));
Assert.Equal(expected, grip.BorderLocation);
}
[Fact]
public void MountedChatWindow_TopStrip_IsAMoveHandleNotAGrip()
{
// 0x1000069C (the plain top edge, between the two top corners) is a
// Type-2 Dragbar in the real DAT, not a Resizebar — retail's main chat
// window has no title bar, so the top strip moves the window while its
// two corners (above) resize it.
var layout = FixtureLoader.LoadChat();
var topStrip = layout.FindElement(0x1000069Cu);
Assert.NotNull(topStrip);
Assert.IsNotType<UiResizeGrip>(topStrip);
Assert.True(topStrip!.WindowMoveHandle);
}
[Fact]
public void MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks()
{
// The literal user-gate round 2 report: "the window cannot grow in the
// Y axis when dragging from the bottom-right corner." Exercises the
// FULL production path (ChatWindowController.Bind + RetailWindowFrame.Mount
// with the real Chrome=Imported options and the real DAT min/max
// constraints from 0x2100006F, minH=100/maxH=2000/minW=300/maxW=2000)
// rather than just the pure ResizeRect math.
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
null,
null,
NoTex)!;
var root = new UiRoot { Width = 1600, Height = 1200 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 440f,
DatConstraintSource = controller.DatWindowInfo,
});
controller.AttachWindow(handle);
var brGrip = Assert.IsType<UiResizeGrip>(layout.FindElement(0x100006A1u));
Assert.Equal(UiResizeGrip.Border.LowerRight, brGrip.BorderLocation);
var gs = brGrip.ScreenPosition;
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
// Shrink first (this direction was never in question per the report).
// Height is already AT the DAT's authored minimum (minH=100 == the
// authored 100px height), so it clamps rather than shrinking further —
// width has headroom (minW=300 < 410) and does shrink.
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX - 20, pressY - 20);
root.OnMouseUp(UiMouseButton.Left, pressX - 20, pressY - 20);
Assert.Equal(390f, handle.Width);
Assert.Equal(100f, handle.Height);
// Now grow from the shrunken state — this is the reported-broken direction.
var brGripAfterShrink = Assert.IsType<UiResizeGrip>(layout.FindElement(0x100006A1u));
var gs2 = brGripAfterShrink.ScreenPosition;
int pressX2 = (int)(gs2.X + 2), pressY2 = (int)(gs2.Y + 2);
root.OnMouseDown(UiMouseButton.Left, pressX2, pressY2);
root.OnMouseMove(pressX2 + 70, pressY2 + 70);
root.OnMouseUp(UiMouseButton.Left, pressX2 + 70, pressY2 + 70);
Assert.Equal(460f, handle.Width);
Assert.Equal(170f, handle.Height);
}
[Fact]
public void MountedChatScrollbar_ButtonsAndThumbDragDriveTranscriptModel()
{
@ -216,14 +407,10 @@ public class ChatLayoutConformanceTests
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.NineSlice,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 440f,
ContentWidth = 490f,
ContentHeight = controller.Root.Height,
RebaseContentLayout = true,
DatConstraintSource = controller.DatWindowInfo,
MinWidth = 200f,
});
controller.AttachWindow(handle);

View file

@ -11,7 +11,18 @@ namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// One-off generator for the committed chat golden fixture. Skipped by default —
/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate
/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made.
/// chat_2100006f.json, then commit it. Mirrors how vitals_2100006C.json was made.
///
/// <para>
/// Campaign CH slice CH6a swapped the imported main-chat LayoutDesc from the
/// wrong <c>0x21000006</c> (a different, unrelated chat layout whose root
/// <c>0x1000000E</c> and 800px resize bar <c>0x1000000F</c> appear nowhere in
/// the EoR gameplay UI) to retail's actual main chat window,
/// <c>0x2100006F</c> (window element <c>0x10000601</c>, layout root
/// <c>0x10000600</c>, authored 410x100 — see
/// docs/research/2026-08-09-chat-retail-window-shell.md §2.1 and §5). The old
/// <c>chat_21000006.json</c> fixture is retired with it.
/// </para>
/// </summary>
public class ChatLayoutFixtureGenerator
{
@ -22,7 +33,7 @@ public class ChatLayoutFixtureGenerator
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var info = LayoutImporter.ImportInfos(dats, 0x21000006u);
var info = LayoutImporter.ImportInfos(dats, 0x2100006Fu);
Assert.NotNull(info);
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
@ -35,5 +46,5 @@ public class ChatLayoutFixtureGenerator
// Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path.
private static string FixturePath([CallerFilePath] string thisFile = "")
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json");
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_2100006f.json");
}

View file

@ -35,7 +35,7 @@ public class ChatWindowControllerTests
/// <summary>
/// Build a minimal synthetic ElementInfo tree that mirrors the real chat
/// layout (0x21000006) with enough fidelity for Bind to succeed:
/// layout (0x2100006F) with enough fidelity for Bind to succeed:
/// root (Type-3)
/// transcriptPanel (Type-3) [0x10000010]
/// transcript (Type-12, no media) [0x10000011] ← built as UiText by factory; Bind binds in place
@ -111,7 +111,7 @@ public class ChatWindowControllerTests
var root = new ElementInfo
{
Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100,
Id = 0x10000600u, Type = 3, Width = 490, Height = 100,
};
root.Children.Add(transcriptPanel);
root.Children.Add(inputBar);
@ -299,7 +299,7 @@ public class ChatWindowControllerTests
public void Bind_Returns_Null_WhenTranscriptPanelMissing()
{
// Build a layout that is missing the transcript panel entirely.
var root = new ElementInfo { Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100 };
var root = new ElementInfo { Id = 0x10000600u, Type = 3, Width = 490, Height = 100 };
// No children → TranscriptPanelId and InputBarId are absent from the widget tree.
var layout = LayoutImporter.Build(root, NoTex, null);

View file

@ -50,6 +50,91 @@ public class DatWidgetFactoryTests
Assert.False(e.ClickThrough);
}
// ── Type 9 → UiResizeGrip (retail UIElement_Resizebar) ───────────────────
// Campaign CH slice CH6a: LayoutDesc 0x2100006F's main chat window authors
// 7 live Type-9 grips (corners + 3 straight edges — the top edge is a
// separate Type-2 Dragbar move handle, not a grip) via property bools
// 0x2A=bottom/0x2B=left/0x2C=right/0x2D=top. Real element ids + their
// decoded bools, confirmed against the installed DAT:
// 0x1000069B (TL) bottom=F left=T right=F top=T -> UpperLeft
// 0x1000069D (TR) bottom=F left=F right=T top=T -> UpperRight
// 0x1000069E (L) bottom=F left=T right=F top=F -> Left
// 0x1000069F (BL) bottom=T left=T right=F top=F -> LowerLeft
// 0x100006A0 (B) bottom=T left=F right=F top=F -> Bottom
// 0x100006A1 (BR) bottom=T left=F right=T top=F -> LowerRight
// 0x100006A2 (R) bottom=F left=F right=T top=F -> Right
[Fact]
public void Type9_Resizebar_NoBoolsSet_DecodesToNoneEdges()
{
var e = DatWidgetFactory.Create(GripInfo(), NoTex, null);
var grip = Assert.IsType<UiResizeGrip>(e);
Assert.Equal(UiResizeGrip.Border.None, grip.BorderLocation);
Assert.Equal(ResizeEdges.None, grip.Edges);
}
[Theory]
// (bottom, left, right, top) -> (Border, Edges) — the 8 authored grip shapes
// (edges set exactly one bool; corners set exactly two adjacent bools).
[InlineData(false, false, false, true, UiResizeGrip.Border.Top, ResizeEdges.Top)]
[InlineData(true, false, false, false, UiResizeGrip.Border.Bottom, ResizeEdges.Bottom)]
[InlineData(false, true, false, false, UiResizeGrip.Border.Left, ResizeEdges.Left)]
[InlineData(false, false, true, false, UiResizeGrip.Border.Right, ResizeEdges.Right)]
[InlineData(false, true, false, true, UiResizeGrip.Border.UpperLeft, ResizeEdges.Left | ResizeEdges.Top)]
[InlineData(false, false, true, true, UiResizeGrip.Border.UpperRight, ResizeEdges.Right | ResizeEdges.Top)]
[InlineData(true, true, false, false, UiResizeGrip.Border.LowerLeft, ResizeEdges.Left | ResizeEdges.Bottom)]
[InlineData(true, false, true, false, UiResizeGrip.Border.LowerRight, ResizeEdges.Right | ResizeEdges.Bottom)]
public void Type9_Resizebar_DecodesEachOfTheEightAuthoredGrips(
bool bottom, bool left, bool right, bool top,
UiResizeGrip.Border expectedBorder, ResizeEdges expectedEdges)
{
var e = DatWidgetFactory.Create(GripInfo(bottom, left, right, top), NoTex, null);
var grip = Assert.IsType<UiResizeGrip>(e);
Assert.Equal(expectedBorder, grip.BorderLocation);
Assert.Equal(expectedEdges, grip.Edges);
}
[Fact]
public void Type9_Resizebar_IsNotClickThrough_SoItCanCaptureTheResizeDrag()
{
// A grip is a precise hit region — it must NOT default to UiDatElement's
// decoration ClickThrough=true, or UiRoot's hit-test would skip straight
// past it to whatever's behind.
var e = DatWidgetFactory.Create(GripInfo(bottom: true), NoTex, null);
var grip = Assert.IsType<UiResizeGrip>(e);
Assert.False(grip.ClickThrough);
}
[Fact]
public void DecodeBorderLocation_MatchesRetailBranchOrder_RightBeatsLeftBeatsTopBeatsBottom()
{
// UIElement_Resizebar::StartMouseResizing @0x0046B7E0's if/else-if chain —
// retail's authored data never sets more than 2 bools, but the branch
// ORDER is still part of the port: right wins over left, and within a
// branch top wins over bottom.
Assert.Equal(
UiResizeGrip.Border.UpperRight,
UiResizeGrip.DecodeBorderLocation(bottom: true, left: true, right: true, top: true));
Assert.Equal(
UiResizeGrip.Border.UpperLeft,
UiResizeGrip.DecodeBorderLocation(bottom: true, left: true, right: false, top: true));
Assert.Equal(
UiResizeGrip.Border.Top,
UiResizeGrip.DecodeBorderLocation(bottom: true, left: false, right: false, top: true));
}
private static ElementInfo GripInfo(bool bottom = false, bool left = false, bool right = false, bool top = false)
{
var info = new ElementInfo { Id = 0x1000069Bu, Type = 9, Width = 5, Height = 5 };
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
if (bottom) state.Properties.Values[0x2Au] = Bool(true);
if (left) state.Properties.Values[0x2Bu] = Bool(true);
if (right) state.Properties.Values[0x2Cu] = Bool(true);
if (top) state.Properties.Values[0x2Du] = Bool(true);
info.States[UiStateInfo.DirectStateId] = state;
return info;
}
// ── Test 3: Type 12 → UiText (behavioral text widget) ────────────────────
[Fact]

View file

@ -40,22 +40,25 @@ public static class FixtureLoader
=> LoadInfos("vitals_2100006C.json");
/// <summary>
/// Deserializes the committed <c>chat_21000006.json</c> fixture into a raw
/// <see cref="ElementInfo"/> tree and builds the <see cref="ImportedLayout"/>
/// using a null-returning sprite resolver and no dat font — sufficient for
/// conformance checks on tree structure and resolved types.
/// Deserializes the committed <c>chat_2100006f.json</c> fixture (retail's
/// ACTUAL main chat window, LayoutDesc <c>0x2100006F</c> — Campaign CH slice
/// CH6a; the old <c>chat_21000006.json</c> fixture imported the WRONG,
/// unrelated layout) into a raw <see cref="ElementInfo"/> tree and builds the
/// <see cref="ImportedLayout"/> using a null-returning sprite resolver and no
/// dat font — sufficient for conformance checks on tree structure and
/// resolved types.
/// </summary>
public static ImportedLayout LoadChat()
=> LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null);
/// <summary>
/// Deserializes the committed <c>chat_21000006.json</c> fixture into a raw
/// Deserializes the committed <c>chat_2100006f.json</c> fixture into a raw
/// <see cref="ElementInfo"/> tree WITHOUT calling <see cref="LayoutImporter.Build"/>.
/// Use this when the test needs to inspect the resolved <see cref="ElementInfo"/>
/// tree directly (e.g. resolved Type values per element id).
/// </summary>
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
=> LoadInfos("chat_21000006.json");
=> LoadInfos("chat_2100006f.json");
/// <summary>Builds the committed retail radar LayoutDesc 0x21000074 fixture.</summary>
public static ImportedLayout LoadRadar()

View file

@ -16,7 +16,7 @@ public sealed class RetailLayoutFixtureGenerator
{
private static readonly (uint Id, string FileName)[] Layouts =
{
(0x21000006u, "chat_21000006.json"),
(ChatWindowController.LayoutId, "chat_2100006f.json"),
(0x21000016u, "toolbar_21000016.json"),
(0x2100001Du, "link_status_2100001D.json"),
(0x21000020u, "vitae_21000020.json"),

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()
{