diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs
index 09d14335..4fb6c0c4 100644
--- a/src/AcDream.App/UI/Layout/CharacterStatController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs
@@ -399,7 +399,9 @@ public static class CharacterStatController
// last duplicate) so we get the wider-label copies (195px) for the unselected state.
BindFooterDynamic(layout, datFont, data, activeTab, attrSel, skillSel);
- UiElement? statList = layout.FindElement(ListBoxId);
+ UiElement? statList =
+ FindInPageContaining(layout, NameId, static el => HasDatElementId(el, ListBoxId))
+ ?? layout.FindElement(ListBoxId);
RebuildActiveList();
if (layout.Root is { } tabRoot2 && spriteResolve is not null)
@@ -1228,6 +1230,31 @@ public static class CharacterStatController
return null;
}
+ /// Finds the first descendant matching inside the
+ /// root-level tab page that contains . This is required for
+ /// duplicated tab-page ids such as the character list box (0x1000023D):
+ /// stores only the last duplicate in its id dictionary, which may be in a hidden page.
+ private static UiElement? FindInPageContaining(
+ ImportedLayout layout,
+ uint anchorId,
+ Func predicate)
+ {
+ if (layout.FindElement(anchorId) is not { } anchor || layout.Root is not { } root)
+ return null;
+
+ foreach (var page in root.Children)
+ {
+ if (page.Children.Count == 0) continue;
+ if (!ContainsWidget(page, anchor)) continue;
+ return FindInSubtree(page, predicate);
+ }
+
+ return null;
+ }
+
+ private static bool HasDatElementId(UiElement element, uint id)
+ => element is UiDatElement datElement && datElement.ElementId == id;
+
private static bool ContainsWidget(UiElement node, UiElement target)
{
if (ReferenceEquals(node, target)) return true;
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
index 403041a2..a9dac56d 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
@@ -678,6 +678,46 @@ public class CharacterStatControllerTests
Assert.Equal("Strength", rows[0].Children.OfType().ToList()[1].LinesProvider()[0].Text);
}
+ [Fact]
+ public void SkillsTab_MouseClick_RebuildsVisibleListWhenListIdIsDuplicated()
+ {
+ var root = new UiPanel { Width = 300, Height = 600 };
+ var attrPage = new UiPanel { Top = 25, Width = 300, Height = 575 };
+ var hiddenPage = new UiPanel { Top = 25, Width = 300, Height = 575 };
+ var name = new UiText();
+ var visibleList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
+ var hiddenDuplicateList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
+
+ attrPage.AddChild(name);
+ attrPage.AddChild(visibleList);
+ hiddenPage.AddChild(hiddenDuplicateList);
+ root.AddChild(attrPage);
+ root.AddChild(hiddenPage);
+
+ var layout = new ImportedLayout(root, new Dictionary
+ {
+ [CharacterStatController.NameId] = name,
+ // Mirrors the real import: the id dictionary can point at a hidden duplicate.
+ [CharacterStatController.ListBoxId] = hiddenDuplicateList,
+ });
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter,
+ spriteResolve: id => (id, 16, 16));
+
+ Assert.Equal("Strength", FirstRowName(visibleList));
+ Assert.Equal("", FirstRowName(hiddenDuplicateList));
+
+ var ui = new UiRoot { Width = 300, Height = 600 };
+ ui.AddChild(root);
+ Assert.IsType(ui.Pick(132, 12));
+
+ ui.OnMouseDown(UiMouseButton.Left, 132, 12);
+ ui.OnMouseUp(UiMouseButton.Left, 132, 12);
+
+ Assert.Equal("Melee Defense", FirstRowName(visibleList));
+ Assert.Equal("", FirstRowName(hiddenDuplicateList));
+ }
+
[Fact]
public void SkillsTab_SelectWarMagic_ShowsTrainedSkillFooter()
{
@@ -1027,6 +1067,32 @@ public class CharacterStatControllerTests
tab.OnClick!();
}
+ private static string FirstRowName(UiElement list)
+ {
+ var row = list.Children.OfType().FirstOrDefault();
+ if (row is null) return "";
+ var texts = row.Children.OfType().ToList();
+ return texts.Count > 1 ? texts[1].LinesProvider()[0].Text : "";
+ }
+
+ private static UiDatElement MakeDatElement(uint id, float top, float width, float height)
+ {
+ var info = new ElementInfo
+ {
+ Id = id,
+ Type = 3,
+ Y = top,
+ Width = width,
+ Height = height,
+ };
+ return new UiDatElement(info, static _ => (0u, 0, 0))
+ {
+ Top = top,
+ Width = width,
+ Height = height,
+ };
+ }
+
/// Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).
private static UiButton MakeButton()
{