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>
240 lines
9.9 KiB
C#
240 lines
9.9 KiB
C#
using System.Runtime.CompilerServices;
|
|
using System.Text.Json;
|
|
using AcDream.App.UI.Layout;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.Options;
|
|
using EnumIDMap = DatReaderWriter.DBObjs.EnumIDMap;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Regenerates every committed retail layout fixture from production portal.dat.
|
|
/// The test is inert unless <c>ACDREAM_REGENERATE_UI_FIXTURES=1</c> is set, keeping
|
|
/// normal test runs deterministic and dat-independent.
|
|
/// </summary>
|
|
public sealed class RetailLayoutFixtureGenerator
|
|
{
|
|
private static readonly (uint Id, string FileName)[] Layouts =
|
|
{
|
|
(ChatWindowController.LayoutId, "chat_2100006f.json"),
|
|
(0x21000016u, "toolbar_21000016.json"),
|
|
(0x2100001Du, "link_status_2100001D.json"),
|
|
(0x21000020u, "vitae_21000020.json"),
|
|
(0x21000023u, "inventory_21000023.json"),
|
|
(0x21000024u, "paperdoll_21000024.json"),
|
|
(0x2100002Eu, "character_2100002E.json"),
|
|
(SpellbookWindowController.LayoutId, "spellbook_21000034.json"),
|
|
(0x2100006Cu, "vitals_2100006C.json"),
|
|
(IndicatorBarController.LayoutId, "indicators_21000071.json"),
|
|
(0x21000072u, "powerbar_21000072.json"),
|
|
(0x21000073u, "combat_21000073.json"),
|
|
(0x21000074u, "radar_21000074.json"),
|
|
};
|
|
|
|
[Fact]
|
|
public void RegenerateAllRetailFixtures_WhenExplicitlyRequested()
|
|
{
|
|
if (!string.Equals(
|
|
Environment.GetEnvironmentVariable("ACDREAM_REGENERATE_UI_FIXTURES"),
|
|
"1",
|
|
StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
|
?? Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
"Documents",
|
|
"Asheron's Call");
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
|
|
var localStrings = new DatStringResolver(dats);
|
|
foreach ((string key, string expected) in new[]
|
|
{
|
|
("ID_SpellComp_Category_Scarabs", "SCARABS"),
|
|
("ID_SpellComp_Category_Herbs", "HERBS"),
|
|
("ID_SpellComp_Category_Gems", "POWDERED GEMS"),
|
|
("ID_SpellComp_Category_Alchemical", "ALCHEMICAL SUBSTANCES"),
|
|
("ID_SpellComp_Category_Talismans", "TALISMANS"),
|
|
("ID_SpellComp_Category_Tapers", "TAPERS"),
|
|
("ID_SpellComp_Category_Peas", "PEAS"),
|
|
})
|
|
{
|
|
Assert.Equal(expected, localStrings.Resolve(
|
|
0x23000001u, DatStringResolver.ComputeHash(key)));
|
|
}
|
|
Assert.Equal(
|
|
"SELECT A SPELL",
|
|
localStrings.Resolve(
|
|
0x23000001u,
|
|
DatStringResolver.ComputeHash("ID_Effects_Info_SelectASpell")));
|
|
|
|
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
|
|
Assert.True(dats.Portal.TryGet<EnumIDMap>(masterDid, out var master));
|
|
Assert.True(master!.ClientEnumToID.TryGetValue(5u, out uint uiMapDid));
|
|
Assert.True(dats.Portal.TryGet<EnumIDMap>(uiMapDid, out var uiMap));
|
|
Assert.True(uiMap!.ClientEnumToID.TryGetValue(2u, out uint dialogsLayoutDid));
|
|
var dialogs = LayoutImporter.ImportInfos(
|
|
dats,
|
|
dialogsLayoutDid,
|
|
RetailDialogFactory.RootElementId(RetailDialogType.Confirmation));
|
|
Assert.NotNull(dialogs);
|
|
var dialogsJson = JsonSerializer.Serialize(dialogs, new JsonSerializerOptions
|
|
{
|
|
IncludeFields = true,
|
|
WriteIndented = true,
|
|
});
|
|
File.WriteAllText(
|
|
Path.Combine(FixtureDirectory(), $"dialogs_{dialogsLayoutDid:X8}.json"),
|
|
dialogsJson);
|
|
|
|
const uint smartboxLayoutDid = 0x2100000Fu;
|
|
var fps = LayoutImporter.ImportInfos(
|
|
dats,
|
|
smartboxLayoutDid,
|
|
0x10000047u);
|
|
Assert.NotNull(fps);
|
|
Assert.Equal(0x4000001Au, fps!.FontDid);
|
|
var fpsStrings = new DatStringResolver(dats);
|
|
Assert.Equal("FPS: ", fpsStrings.Resolve(0x23000001u, 0x0DCFFF73u, 0));
|
|
Assert.Equal(@"\nDEG: ", fpsStrings.Resolve(0x23000001u, 0x0DCFFF73u, 1));
|
|
var fpsJson = JsonSerializer.Serialize(fps, new JsonSerializerOptions
|
|
{
|
|
IncludeFields = true,
|
|
WriteIndented = true,
|
|
});
|
|
File.WriteAllText(
|
|
Path.Combine(FixtureDirectory(), $"smartbox_fps_{smartboxLayoutDid:X8}.json"),
|
|
fpsJson);
|
|
|
|
foreach ((uint rootId, string fileName) in new[]
|
|
{
|
|
(EffectsUiController.PositiveRootId, "effects_positive_2100001B.json"),
|
|
(EffectsUiController.NegativeRootId, "effects_negative_2100001B.json"),
|
|
})
|
|
{
|
|
ElementInfo? effects = LayoutImporter.ImportInfos(
|
|
dats, EffectsUiController.LayoutId, rootId);
|
|
Assert.NotNull(effects);
|
|
var effectsJson = JsonSerializer.Serialize(effects, new JsonSerializerOptions
|
|
{
|
|
IncludeFields = true,
|
|
WriteIndented = true,
|
|
});
|
|
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), effectsJson);
|
|
}
|
|
|
|
foreach ((uint layoutId, uint rootId, string fileName) in new[]
|
|
{
|
|
(EffectsUiController.LayoutId, EffectsUiController.RowTemplateId,
|
|
"effects_row_2100001B_10000128.json"),
|
|
(CharacterController.LayoutId, CharacterController.RootId,
|
|
"character_info_2100006E_10000183.json"),
|
|
(0x2100006Bu, 0x100005F2u, "examine_2100006B_100005F2.json"),
|
|
(AppraisalUiController.LayoutId, CreatureAppraisalRowTemplateFactory.TemplateId,
|
|
"examine_row_2100006B_10000166.json"),
|
|
(AppraisalUiController.LayoutId, SpellExamineComponentTemplateFactory.TemplateId,
|
|
"examine_component_2100006B_1000032E.json"),
|
|
(VendorUiController.LayoutId, VendorUiController.RootId,
|
|
"vendor_21000012_100000B7.json"),
|
|
})
|
|
{
|
|
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);
|
|
Assert.NotNull(panelPart);
|
|
File.WriteAllText(
|
|
Path.Combine(FixtureDirectory(), fileName),
|
|
JsonSerializer.Serialize(panelPart, new JsonSerializerOptions
|
|
{
|
|
IncludeFields = true,
|
|
WriteIndented = true,
|
|
}));
|
|
}
|
|
|
|
CreatureDisplayNameResolver creatureNames =
|
|
CreatureDisplayNameResolver.Load(dats);
|
|
Assert.Equal("Ghost", creatureNames.Resolve(77));
|
|
|
|
ElementInfo? miniGame = LayoutImporter.ImportInfos(
|
|
dats, MiniGameUiController.LayoutId, MiniGameUiController.RootId);
|
|
Assert.NotNull(miniGame);
|
|
File.WriteAllText(
|
|
Path.Combine(FixtureDirectory(), "mini_game_2100001E.json"),
|
|
JsonSerializer.Serialize(miniGame, new JsonSerializerOptions
|
|
{
|
|
IncludeFields = true,
|
|
WriteIndented = true,
|
|
}));
|
|
|
|
foreach ((uint rootId, string fileName) in new[]
|
|
{
|
|
(0x10000466u,
|
|
"component_category_21000033_10000466.json"),
|
|
(0x10000467u,
|
|
"component_row_21000033_10000467.json"),
|
|
})
|
|
{
|
|
ElementInfo? componentTemplate = LayoutImporter.ImportInfos(
|
|
dats, 0x21000033u, rootId);
|
|
Assert.NotNull(componentTemplate);
|
|
var componentJson = JsonSerializer.Serialize(componentTemplate,
|
|
new JsonSerializerOptions
|
|
{
|
|
IncludeFields = true,
|
|
WriteIndented = true,
|
|
});
|
|
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), componentJson);
|
|
}
|
|
|
|
foreach (var (layoutId, fileName) in Layouts)
|
|
{
|
|
var info = LayoutImporter.ImportInfos(dats, layoutId);
|
|
Assert.NotNull(info);
|
|
if (layoutId == CombatUiController.LayoutId)
|
|
{
|
|
var labels = CombatUiLabels.Resolve(info, new DatStringResolver(dats));
|
|
Assert.Equal("Speed", labels.Speed);
|
|
Assert.Equal("Power", labels.Power);
|
|
Assert.Equal("Repeat Attacks", labels.RepeatAttacks);
|
|
Assert.Equal("Auto Target", labels.AutoTarget);
|
|
Assert.Equal("Keep in View", labels.KeepInView);
|
|
Assert.Equal("High", labels.High);
|
|
Assert.Equal("Medium", labels.Medium);
|
|
Assert.Equal("Low", labels.Low);
|
|
var strings = new DatStringResolver(dats);
|
|
uint[] tabIds =
|
|
{
|
|
0x100000A3u, 0x100000A4u, 0x100000A5u, 0x100000A6u,
|
|
0x100000A7u, 0x100000A8u, 0x100000A9u, 0x100005C2u,
|
|
};
|
|
string[] expected = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII"];
|
|
for (int i = 0; i < tabIds.Length; i++)
|
|
{
|
|
uint id = tabIds[i];
|
|
ElementInfo tab = FindInfo(info, id)!;
|
|
Assert.NotNull(tab);
|
|
Assert.True(tab.TryGetEffectiveProperty(0x17u, out UiPropertyValue label));
|
|
Assert.Equal(expected[i], strings.Resolve(label.StringInfoValue));
|
|
}
|
|
}
|
|
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
|
{
|
|
IncludeFields = true,
|
|
WriteIndented = true,
|
|
});
|
|
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), json);
|
|
}
|
|
|
|
}
|
|
|
|
private static string FixtureDirectory([CallerFilePath] string thisFile = "")
|
|
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures");
|
|
|
|
private static ElementInfo? FindInfo(ElementInfo root, uint id)
|
|
{
|
|
if (root.Id == id) return root;
|
|
foreach (ElementInfo child in root.Children)
|
|
if (FindInfo(child, id) is { } match) return match;
|
|
return null;
|
|
}
|
|
}
|