From 797ee51f0b5211cee7afe6c6d94bd5b7f00382e5 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:48:35 -0400 Subject: [PATCH 1/5] Keep the stance row on screen at any player-frame height PlayerSubWindow is pinned to (70, 9, 3, 3) so the themed frame cannot cover the native attack perimeter, which leaves it 114px tall inside the 193px window -- minus whatever the bordered draw template insets. The stance and invocation row is the bottom-most content and was anchored 96..112 from the TOP, so its lower edge fell outside the client area and the row was clipped: Mage Hunter, Defensive, Invocation, Over Channel and Recovery were all unreadable. Anchor both edges to the sub-window's bottom instead. The row keeps its 16px height and now rides 2px above the lower edge, so it stays visible at the 193px minimum and at any size the player resizes to, rather than depending on a draw-template inset nothing in the XML can see. Applied to all seven player-window variants in both skins; the two were already byte-identical here. No window size and no layout profile changes, so saved player layouts are untouched. The audit gains a check that both labels anchor to the bottom, treating an absent anchor tag as the top-anchored default that caused this. Co-Authored-By: Claude Opus 5 (1M context) --- spinui_glass/EQUI_PlayerWindow.xml | 14 ++++--- spinui_glass/EQUI_PlayerWindow1.xml | 14 ++++--- spinui_glass/EQUI_PlayerWindow2.xml | 14 ++++--- spinui_glass/EQUI_PlayerWindow3.xml | 14 ++++--- spinui_glass/EQUI_PlayerWindow4.xml | 14 ++++--- spinui_glass/EQUI_PlayerWindow5.xml | 14 ++++--- spinui_glass/EQUI_PlayerWindow6.xml | 14 ++++--- spinui_reloaded/EQUI_PlayerWindow.xml | 14 ++++--- spinui_reloaded/EQUI_PlayerWindow1.xml | 14 ++++--- spinui_reloaded/EQUI_PlayerWindow2.xml | 14 ++++--- spinui_reloaded/EQUI_PlayerWindow3.xml | 14 ++++--- spinui_reloaded/EQUI_PlayerWindow4.xml | 14 ++++--- spinui_reloaded/EQUI_PlayerWindow5.xml | 14 ++++--- spinui_reloaded/EQUI_PlayerWindow6.xml | 14 ++++--- tools/audit_combat_ui.py | 51 +++++++++++++++++++++++++- 15 files changed, 161 insertions(+), 86 deletions(-) diff --git a/spinui_glass/EQUI_PlayerWindow.xml b/spinui_glass/EQUI_PlayerWindow.xml index 4984b33..d1f5aeb 100644 --- a/spinui_glass/EQUI_PlayerWindow.xml +++ b/spinui_glass/EQUI_PlayerWindow.xml @@ -517,12 +517,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -550,12 +551,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_glass/EQUI_PlayerWindow1.xml b/spinui_glass/EQUI_PlayerWindow1.xml index 4f370c8..762c1ea 100644 --- a/spinui_glass/EQUI_PlayerWindow1.xml +++ b/spinui_glass/EQUI_PlayerWindow1.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_glass/EQUI_PlayerWindow2.xml b/spinui_glass/EQUI_PlayerWindow2.xml index 4f370c8..762c1ea 100644 --- a/spinui_glass/EQUI_PlayerWindow2.xml +++ b/spinui_glass/EQUI_PlayerWindow2.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_glass/EQUI_PlayerWindow3.xml b/spinui_glass/EQUI_PlayerWindow3.xml index 4f370c8..762c1ea 100644 --- a/spinui_glass/EQUI_PlayerWindow3.xml +++ b/spinui_glass/EQUI_PlayerWindow3.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_glass/EQUI_PlayerWindow4.xml b/spinui_glass/EQUI_PlayerWindow4.xml index 4f370c8..762c1ea 100644 --- a/spinui_glass/EQUI_PlayerWindow4.xml +++ b/spinui_glass/EQUI_PlayerWindow4.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_glass/EQUI_PlayerWindow5.xml b/spinui_glass/EQUI_PlayerWindow5.xml index 4f370c8..762c1ea 100644 --- a/spinui_glass/EQUI_PlayerWindow5.xml +++ b/spinui_glass/EQUI_PlayerWindow5.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_glass/EQUI_PlayerWindow6.xml b/spinui_glass/EQUI_PlayerWindow6.xml index 4f370c8..762c1ea 100644 --- a/spinui_glass/EQUI_PlayerWindow6.xml +++ b/spinui_glass/EQUI_PlayerWindow6.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_reloaded/EQUI_PlayerWindow.xml b/spinui_reloaded/EQUI_PlayerWindow.xml index 72b974b..cfb7871 100644 --- a/spinui_reloaded/EQUI_PlayerWindow.xml +++ b/spinui_reloaded/EQUI_PlayerWindow.xml @@ -517,12 +517,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -550,12 +551,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_reloaded/EQUI_PlayerWindow1.xml b/spinui_reloaded/EQUI_PlayerWindow1.xml index e6a0869..3148a45 100644 --- a/spinui_reloaded/EQUI_PlayerWindow1.xml +++ b/spinui_reloaded/EQUI_PlayerWindow1.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_reloaded/EQUI_PlayerWindow2.xml b/spinui_reloaded/EQUI_PlayerWindow2.xml index e6a0869..3148a45 100644 --- a/spinui_reloaded/EQUI_PlayerWindow2.xml +++ b/spinui_reloaded/EQUI_PlayerWindow2.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_reloaded/EQUI_PlayerWindow3.xml b/spinui_reloaded/EQUI_PlayerWindow3.xml index e6a0869..3148a45 100644 --- a/spinui_reloaded/EQUI_PlayerWindow3.xml +++ b/spinui_reloaded/EQUI_PlayerWindow3.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_reloaded/EQUI_PlayerWindow4.xml b/spinui_reloaded/EQUI_PlayerWindow4.xml index e6a0869..3148a45 100644 --- a/spinui_reloaded/EQUI_PlayerWindow4.xml +++ b/spinui_reloaded/EQUI_PlayerWindow4.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_reloaded/EQUI_PlayerWindow5.xml b/spinui_reloaded/EQUI_PlayerWindow5.xml index e6a0869..3148a45 100644 --- a/spinui_reloaded/EQUI_PlayerWindow5.xml +++ b/spinui_reloaded/EQUI_PlayerWindow5.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/spinui_reloaded/EQUI_PlayerWindow6.xml b/spinui_reloaded/EQUI_PlayerWindow6.xml index e6a0869..3148a45 100644 --- a/spinui_reloaded/EQUI_PlayerWindow6.xml +++ b/spinui_reloaded/EQUI_PlayerWindow6.xml @@ -519,12 +519,13 @@ true true false - 96 - 112 + 18 + 2 6 132 false - true + false + false true @@ -552,12 +553,13 @@ true true true - 96 - 112 + 18 + 2 232 6 false - true + false + false true diff --git a/tools/audit_combat_ui.py b/tools/audit_combat_ui.py index e3a55f0..89fb8db 100644 --- a/tools/audit_combat_ui.py +++ b/tools/audit_combat_ui.py @@ -444,9 +444,12 @@ def audit_player_and_target() -> None: ) if player_min_size != PLAYER_MIN_SIZE: fail(f"PlayerWindow resize bounds changed: {player_min_size}") + # Offsets measure upward from PlayerSubWindow's bottom edge: the row is + # 16px tall (18 -> 2) riding 2px above that edge, so it stays on screen at + # any height rather than being clipped by the pinned 193px window. for label, expected, alignment in ( - (stance, (96, 112, 6, 132), "false"), - (invocation, (96, 112, 232, 6), "true"), + (stance, (18, 2, 6, 132), "false"), + (invocation, (18, 2, 232, 6), "true"), ): actual = tuple( child_int(label, tag) for tag in ( @@ -1323,6 +1326,49 @@ def semantic_signature(node: ET.Element): fail(f"variant audit coverage changed unexpectedly: {checked}") +def audit_stance_row_visibility() -> None: + """Prove the stance/invocation row cannot fall outside the sub-window. + + PlayerSubWindow is pinned to (70, 9, 3, 3) so the themed frame never + covers the native attack perimeter, which leaves it 114px tall inside a + 193px window -- minus whatever the bordered draw template insets. The + stance row is the bottom-most content, so anchoring it a fixed distance + from the TOP put its lower edge past the client area and clipped it. Both + edges anchor to the BOTTOM instead, so the row rides the sub-window's + lower edge at any height the player resizes to. + """ + checked = 0 + for skin in COMBAT_SKINS: + for name in PLAYER_WINDOW_FILES: + root = root_for_path(skin / name) + label = f"{skin.name}/{name}" + for element in ("PW_StanceLabel", "PW_InvocationInfo"): + node = item(root, "Label", element) + # An absent anchor tag means EQ defaults it to the top edge, + # which is the clipping bug this guards -- so read it without + # failing on absence and treat the default as top-anchored. + anchors = tuple((node.findtext(tag) or "true").strip() + for tag in ("TopAnchorToTop", + "BottomAnchorToTop")) + if anchors != ("false", "false"): + fail( + f"{label} {element} anchors to the top, so a fixed " + "window height can clip it out of view" + ) + top = child_int(node, "TopAnchorOffset") + bottom = child_int(node, "BottomAnchorOffset") + # Offsets measure upward from the sub-window's bottom edge, so + # the top offset is the larger of the two. + if top <= bottom: + fail(f"{label} {element} has an inverted bottom anchor") + if top - bottom < 14: + fail(f"{label} {element} row is too short to read: " + f"{top - bottom}px") + checked += 1 + if checked != 28: + fail(f"stance row coverage changed unexpectedly: {checked}") + + def main() -> int: audit_attack_indicator_contract() audit_player_and_target() @@ -1332,6 +1378,7 @@ def main() -> int: audit_accessibility() audit_default_visibility() audit_variant_safety() + audit_stance_row_visibility() stock_checked = audit_optional_stock_parity() print("Combat Command Center audit: ALL PASS") print(" Player/Target/ToT | Group 1..11 | XTarget 0..22 | Raid groups 1..12") From 14811f58ee2b80a87392a979ea302f11d29a1ec2 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:17:12 -0400 Subject: [PATCH 2/5] Give the player frame the height its stance row always needed Measured from an in-game screenshot rather than inferred: with the row anchored to the sub-window's bottom, the visible text ended 101px below the sub-window's top edge, which puts PlayerSubWindow's client area at ~116px against the 128px box it occupies. The bordered rounded template eats ~12px that nothing in the XML exposes, so the layout -- authored as if the client were the full box -- never had room for the bottom row. At 193px the client was ~102px while the content above the stance row already ended at 94, leaving 8px for a 16px row. Bottom-anchoring alone could not fix that: it kept the row on screen but slid it under PW_AltAdvGauge, which is listed after it in Pieces and therefore draws over it, so the stance text came out as a 3px sliver. Worse than clipped. PlayerWindow goes to 360x207, which puts the client at ~116px and leaves a 4px gap between the AA gauge and the row. TargetWindow stays 360x193: it has no stance row, and only shared the height for symmetry. The row keeps its bottom anchoring, so the remaining uncertainty in that ~12px border estimate shifts the row rather than hiding it. Regenerates the 24 layout profiles and the Glass payload, and moves the pinned player-frame contracts in restyle_combat, audit_combat_ui and the layout generator to 207. Co-Authored-By: Claude Opus 5 (1M context) --- UI_Spin_qeynos_LO1.ini | 2 +- docs/UIfix/Screenshot_20260815_150209.png | Bin 0 -> 42769 bytes layouts/combat-focus/UI_Spin_qeynos_LO1.ini | 2 +- layouts/hybrid/UI_Spin_qeynos_LO1.ini | 2 +- .../combat-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../1920x1080/hybrid/UI_Spin_qeynos_LO1.ini | 2 +- .../social-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../combat-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../2048x1080/hybrid/UI_Spin_qeynos_LO1.ini | 2 +- .../social-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../combat-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../2560x1080/hybrid/UI_Spin_qeynos_LO1.ini | 2 +- .../social-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../combat-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../2560x1440/hybrid/UI_Spin_qeynos_LO1.ini | 2 +- .../social-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../combat-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../3440x1440/hybrid/UI_Spin_qeynos_LO1.ini | 2 +- .../social-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../combat-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../3840x1600/hybrid/UI_Spin_qeynos_LO1.ini | 2 +- .../social-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../combat-focus/UI_Spin_qeynos_LO1.ini | 2 +- .../3840x2160/hybrid/UI_Spin_qeynos_LO1.ini | 2 +- .../social-focus/UI_Spin_qeynos_LO1.ini | 2 +- layouts/social-focus/UI_Spin_qeynos_LO1.ini | 2 +- spinui_glass/EQUI_PlayerWindow.xml | 4 ++-- spinui_glass/EQUI_PlayerWindow1.xml | 4 ++-- spinui_glass/EQUI_PlayerWindow2.xml | 4 ++-- spinui_glass/EQUI_PlayerWindow3.xml | 4 ++-- spinui_glass/EQUI_PlayerWindow4.xml | 4 ++-- spinui_glass/EQUI_PlayerWindow5.xml | 4 ++-- spinui_glass/EQUI_PlayerWindow6.xml | 4 ++-- spinui_glass/default1080.ini | 2 +- spinui_glass/default1440.ini | 2 +- spinui_glass/default4k.ini | 2 +- spinui_reloaded/EQUI_PlayerWindow.xml | 4 ++-- spinui_reloaded/EQUI_PlayerWindow1.xml | 4 ++-- spinui_reloaded/EQUI_PlayerWindow2.xml | 4 ++-- spinui_reloaded/EQUI_PlayerWindow3.xml | 4 ++-- spinui_reloaded/EQUI_PlayerWindow4.xml | 4 ++-- spinui_reloaded/EQUI_PlayerWindow5.xml | 4 ++-- spinui_reloaded/EQUI_PlayerWindow6.xml | 4 ++-- spinui_reloaded/default1080.ini | 2 +- spinui_reloaded/default1440.ini | 2 +- spinui_reloaded/default4k.ini | 2 +- tools/audit_combat_ui.py | 11 +++++++---- tools/generate_spinui_layout.py | 8 ++++---- tools/restyle_combat.py | 2 +- 49 files changed, 71 insertions(+), 68 deletions(-) create mode 100644 docs/UIfix/Screenshot_20260815_150209.png diff --git a/UI_Spin_qeynos_LO1.ini b/UI_Spin_qeynos_LO1.ini index de5c35f..127a1c2 100644 --- a/UI_Spin_qeynos_LO1.ini +++ b/UI_Spin_qeynos_LO1.ini @@ -716,7 +716,7 @@ ClickThrough=0 Escapable=0 Show=1 Width=360 -Height=193 +Height=207 [PlayerWindow_1] Show=0 [PlayerWindow_2] diff --git a/docs/UIfix/Screenshot_20260815_150209.png b/docs/UIfix/Screenshot_20260815_150209.png new file mode 100644 index 0000000000000000000000000000000000000000..0e982f494c08befd51acc079d9bd86e9c957d52d GIT binary patch literal 42769 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 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow1.xml b/spinui_glass/EQUI_PlayerWindow1.xml index 762c1ea..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow1.xml +++ b/spinui_glass/EQUI_PlayerWindow1.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow2.xml b/spinui_glass/EQUI_PlayerWindow2.xml index 762c1ea..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow2.xml +++ b/spinui_glass/EQUI_PlayerWindow2.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow3.xml b/spinui_glass/EQUI_PlayerWindow3.xml index 762c1ea..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow3.xml +++ b/spinui_glass/EQUI_PlayerWindow3.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow4.xml b/spinui_glass/EQUI_PlayerWindow4.xml index 762c1ea..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow4.xml +++ b/spinui_glass/EQUI_PlayerWindow4.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow5.xml b/spinui_glass/EQUI_PlayerWindow5.xml index 762c1ea..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow5.xml +++ b/spinui_glass/EQUI_PlayerWindow5.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/EQUI_PlayerWindow6.xml b/spinui_glass/EQUI_PlayerWindow6.xml index 762c1ea..c06343b 100644 --- a/spinui_glass/EQUI_PlayerWindow6.xml +++ b/spinui_glass/EQUI_PlayerWindow6.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_glass/default1080.ini b/spinui_glass/default1080.ini index 7da6655..ec94932 100644 --- a/spinui_glass/default1080.ini +++ b/spinui_glass/default1080.ini @@ -336,7 +336,7 @@ YRef=top XPos=20.416667% YPos=46.018519% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/spinui_glass/default1440.ini b/spinui_glass/default1440.ini index 4401687..b6983d6 100644 --- a/spinui_glass/default1440.ini +++ b/spinui_glass/default1440.ini @@ -336,7 +336,7 @@ YRef=top XPos=28.515625% YPos=53.541667% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/spinui_glass/default4k.ini b/spinui_glass/default4k.ini index 709d185..1a92103 100644 --- a/spinui_glass/default4k.ini +++ b/spinui_glass/default4k.ini @@ -336,7 +336,7 @@ YRef=top XPos=35.677083% YPos=68.379630% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/spinui_reloaded/EQUI_PlayerWindow.xml b/spinui_reloaded/EQUI_PlayerWindow.xml index cfb7871..7941f8b 100644 --- a/spinui_reloaded/EQUI_PlayerWindow.xml +++ b/spinui_reloaded/EQUI_PlayerWindow.xml @@ -1360,10 +1360,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow1.xml b/spinui_reloaded/EQUI_PlayerWindow1.xml index 3148a45..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow1.xml +++ b/spinui_reloaded/EQUI_PlayerWindow1.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow2.xml b/spinui_reloaded/EQUI_PlayerWindow2.xml index 3148a45..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow2.xml +++ b/spinui_reloaded/EQUI_PlayerWindow2.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow3.xml b/spinui_reloaded/EQUI_PlayerWindow3.xml index 3148a45..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow3.xml +++ b/spinui_reloaded/EQUI_PlayerWindow3.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow4.xml b/spinui_reloaded/EQUI_PlayerWindow4.xml index 3148a45..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow4.xml +++ b/spinui_reloaded/EQUI_PlayerWindow4.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow5.xml b/spinui_reloaded/EQUI_PlayerWindow5.xml index 3148a45..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow5.xml +++ b/spinui_reloaded/EQUI_PlayerWindow5.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/EQUI_PlayerWindow6.xml b/spinui_reloaded/EQUI_PlayerWindow6.xml index 3148a45..580b277 100644 --- a/spinui_reloaded/EQUI_PlayerWindow6.xml +++ b/spinui_reloaded/EQUI_PlayerWindow6.xml @@ -1361,10 +1361,10 @@ 360 - 193 + 207 280 - 193 + 207 false false WDT_RoundedTransparentNoArrow diff --git a/spinui_reloaded/default1080.ini b/spinui_reloaded/default1080.ini index 7da6655..ec94932 100644 --- a/spinui_reloaded/default1080.ini +++ b/spinui_reloaded/default1080.ini @@ -336,7 +336,7 @@ YRef=top XPos=20.416667% YPos=46.018519% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/spinui_reloaded/default1440.ini b/spinui_reloaded/default1440.ini index 4401687..b6983d6 100644 --- a/spinui_reloaded/default1440.ini +++ b/spinui_reloaded/default1440.ini @@ -336,7 +336,7 @@ YRef=top XPos=28.515625% YPos=53.541667% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/spinui_reloaded/default4k.ini b/spinui_reloaded/default4k.ini index 709d185..1a92103 100644 --- a/spinui_reloaded/default4k.ini +++ b/spinui_reloaded/default4k.ini @@ -336,7 +336,7 @@ YRef=top XPos=35.677083% YPos=68.379630% Width=360 -Height=193 +Height=207 TickTimer=1 AttackTimer=1 [PlayerWindow_1] diff --git a/tools/audit_combat_ui.py b/tools/audit_combat_ui.py index 89fb8db..5ee41e7 100644 --- a/tools/audit_combat_ui.py +++ b/tools/audit_combat_ui.py @@ -287,7 +287,7 @@ def audit_attack_indicator_contract() -> None: fail(f"{label} attack fill lost its topmost perimeter binding") window = item(root, "Screen", "PlayerWindow") - if dimensions(window) != (360, 193): + if dimensions(window) != (360, 207): fail(f"{label} changed the canonical player-window size") minimum = ( child_int(window, "MinHSize"), child_int(window, "MinVSize") @@ -341,7 +341,7 @@ def audit_attack_indicator_contract() -> None: # Resolve real rectangles at both supported extremes. This catches # gaps, clipped corners, and one-sided anchor regressions that an # XML symbol-table check cannot see. - for size in ((360, 193), PLAYER_MIN_SIZE): + for size in ((360, 207), PLAYER_MIN_SIZE): frame_width, frame_height = size expected_rects = { "A_AttackIndicatorAnimTop": @@ -428,8 +428,11 @@ def audit_player_and_target() -> None: if child_text(player_subwindow, "Style_Border") != "true": fail("PlayerSubWindow must remain the visible compact command frame") player_window = item(player, "Screen", "PlayerWindow") - if dimensions(player_window) != (360, 193): - fail("PlayerWindow must remain 360x193") + # 207, not 193: the bordered sub-window's client area is ~12px shorter + # than its box, so the stance/invocation row had no room and was clipped. + # TargetWindow has no such row and stays 193. + if dimensions(player_window) != (360, 207): + fail("PlayerWindow must remain 360x207") if (child_text(player_window, "Style_Border") != "false" or child_text(player_window, "Style_Transparent") != "true"): fail("PlayerWindow reintroduced the faint maximum-canvas perimeter") diff --git a/tools/generate_spinui_layout.py b/tools/generate_spinui_layout.py index b4003e2..016eae6 100644 --- a/tools/generate_spinui_layout.py +++ b/tools/generate_spinui_layout.py @@ -163,7 +163,7 @@ def P_for(screen_w, screen_h, x, y, w=None, h=None, show=None, extra=None): # --- center combat cluster (above chat) --------------------------------- # Player and Target plates share the hotbar block's outer edges, so the # pair is perfectly centered over the rows beneath (block midpoint 1720). - "PlayerWindow": P(1170, 771, 360, 193, show=1), + "PlayerWindow": P(1170, 771, 360, 207, show=1), "TargetWindow": P(1898, 771, 360, 193, show=1), # Every pet layout preserves the historical x=1180 right edge and y=965 # bottom baseline: 8px before PlayerWindow and 7px above the hotbar row. @@ -299,7 +299,7 @@ def q(x, y, w=None, h=None, show=None, extra=None): # plate instead of inside it (seen at 2560x1080). tot_x, tot_y = max(8, sw - 1080), 536 plate_clash = ( - tot_y < player_y + 193 and tot_y + 100 > player_y and ( + tot_y < player_y + 207 and tot_y + 100 > player_y and ( (tot_x < player_x + 360 and tot_x + 232 > player_x) or (tot_x < target_x + 360 and tot_x + 232 > target_x))) if plate_clash: @@ -323,7 +323,7 @@ def q(x, y, w=None, h=None, show=None, extra=None): "HotButtonWnd": q(center - 588, chat_top - 98, 272, 93, show=1), "HotButtonWnd2": q(center + 289, chat_top - 98, 272, 93, show=1), "HotButtonWnd11": q(64, max(8, chat_top - 273), 98, 271, show=0), - "PlayerWindow": q(player_x, player_y, 360, 193, show=1), + "PlayerWindow": q(player_x, player_y, 360, 207, show=1), "TargetWindow": q(target_x, player_y, 360, 193, show=1), "PetInfoWindow": q(pet_x, pet_y, 356, 210, show=0), "PetInfoWindow_1": q(pet_x + 157, pet_y - 24, @@ -570,7 +570,7 @@ def rebuild_chat_manager(lines: list[str], font: int = CHAT_FONT_1440) -> list[s XML_SIZES = { # Player/Target keep their full transparent interaction and buff hosts for # placement math even though only the compact lower subframes are painted. - "PlayerWindow": (360, 193), "TargetWindow": (360, 193), + "PlayerWindow": (360, 207), "TargetWindow": (360, 193), "PetInfoWindow": (356, 210), "BuffWindow": (216, 640), "PetInfoWindow_1": (356, 210), "PetInfoWindow_2": (356, 210), "PetInfoWindow_3": (441, 181), diff --git a/tools/restyle_combat.py b/tools/restyle_combat.py index 631a0f2..4c6b057 100644 --- a/tools/restyle_combat.py +++ b/tools/restyle_combat.py @@ -62,7 +62,7 @@ # The authored 360x193 command frames remain unchanged on first load. Their # anchored subwindows can safely contract to these Legends-compatible bounds, # so users may resize them without clipping the command rows. -PLAYER_MIN_SIZE = (280, 193) +PLAYER_MIN_SIZE = (280, 207) TARGET_MIN_SIZE = (260, 174) # EverQuest recognizes the attack indicator by exact animation and ScreenID # names, then owns its attack-on visibility, red tint, and flashing cadence. From a2bf3e6ab2149941296dd50448b846fc6d344f87 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:01:33 -0400 Subject: [PATCH 3/5] Keep alert cards off the crowd-control panel The control panel is auto-placed on whichever side of the window has room -- above, right, left or below -- and it is click-through, so a player cannot drag it out of the way. positionAlertWindow only knew how to dodge it in the "above" case: companionAbove was the sole avoidance and it is true only when the panel sits entirely above the window. Land the panel to the right, left or below and the alert cards were placed straight over it, hiding the very crowd-control timers the panel exists to show. Move the placement maths into electron/alert-placement.ts and generalise the dodge to every side. "auto" now skips any side the panel occupies, and an explicit Alert location is honoured but pushed clear of the panel rather than drawn over it. With no panel on screen the result is unchanged: auto still prefers above. Covered by scripts/test-alert-placement.cjs, including the panel-above case that used to be the only one handled, so that behaviour cannot regress. Co-Authored-By: Claude Opus 5 (1M context) --- .../electron/alert-placement.ts | 131 ++++++++++++++++ loremaster-desktop/electron/main.ts | 49 ++---- loremaster-desktop/package.json | 1 + .../scripts/test-alert-placement.cjs | 140 ++++++++++++++++++ 4 files changed, 288 insertions(+), 33 deletions(-) create mode 100644 loremaster-desktop/electron/alert-placement.ts create mode 100644 loremaster-desktop/scripts/test-alert-placement.cjs diff --git a/loremaster-desktop/electron/alert-placement.ts b/loremaster-desktop/electron/alert-placement.ts new file mode 100644 index 0000000..a73eb3f --- /dev/null +++ b/loremaster-desktop/electron/alert-placement.ts @@ -0,0 +1,131 @@ +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export type AlertSide = "above" | "below" | "left" | "right"; +export type AlertAnchorSetting = "auto" | AlertSide; + +// Preference order auto has always used: above first, then right, then below, +// then left as the last resort. +export const ALERT_SIDES: readonly AlertSide[] = ["above", "right", "below", "left"]; + +export interface AlertPlacementRequest { + anchor: AlertAnchorSetting; + /** The seed/expanded window the alerts belong to. */ + main: Rect; + alert: { width: number; height: number }; + /** The crowd-control panel when it is on screen, otherwise null. */ + control: Rect | null; + workArea: Rect; + gap: number; +} + +export interface AlertPlacement { + x: number; + y: number; + side: AlertSide; + anchor: AlertAnchorSetting; +} + +export function intersects(a: Rect, b: Rect | null | undefined): boolean { + if (!b) return false; + return (a.x < b.x + b.width + && a.x + a.width > b.x + && a.y < b.y + b.height + && a.y + a.height > b.y); +} + +function clamp(value: number, low: number, high: number): number { + return Math.min(Math.max(value, low), Math.max(low, high)); +} + +/** Where the alert sits on a given side before anything is dodged. */ +function baseRect(side: AlertSide, request: AlertPlacementRequest): Rect { + const { main, alert, gap } = request; + const centredX = main.x + Math.round((main.width - alert.width) / 2); + const centredY = main.y + Math.round((main.height - alert.height) / 2); + if (side === "above") { + return { x: centredX, y: main.y - alert.height - gap, ...alert }; + } + if (side === "below") { + return { x: centredX, y: main.y + main.height + gap, ...alert }; + } + if (side === "left") { + return { x: main.x - alert.width - gap, y: centredY, ...alert }; + } + return { x: main.x + main.width + gap, y: centredY, ...alert }; +} + +// Push the alert further out along the side it is on, so it clears the control +// panel instead of landing on top of it. This generalises what the old code +// did only for a panel sitting above the window. +function clearControl(rect: Rect, side: AlertSide, request: AlertPlacementRequest): Rect { + const { control, gap } = request; + if (!intersects(rect, control) || !control) return rect; + if (side === "above") { + return { ...rect, y: Math.min(rect.y, control.y - rect.height - gap) }; + } + if (side === "below") { + return { ...rect, y: Math.max(rect.y, control.y + control.height + gap) }; + } + if (side === "left") { + return { ...rect, x: Math.min(rect.x, control.x - rect.width - gap) }; + } + return { ...rect, x: Math.max(rect.x, control.x + control.width + gap) }; +} + +function clampToWorkArea(rect: Rect, workArea: Rect): Rect { + return { + ...rect, + x: clamp(rect.x, workArea.x, workArea.x + workArea.width - rect.width), + y: clamp(rect.y, workArea.y, workArea.y + workArea.height - rect.height), + }; +} + +function resolveSide(side: AlertSide, request: AlertPlacementRequest): Rect { + return clampToWorkArea( + clearControl(baseRect(side, request), side, request), + request.workArea, + ); +} + +function fitsOnScreen(rect: Rect, workArea: Rect): boolean { + return (rect.x >= workArea.x + && rect.y >= workArea.y + && rect.x + rect.width <= workArea.x + workArea.width + && rect.y + rect.height <= workArea.y + workArea.height); +} + +/** + * Choose where the alert stack goes. + * + * The control panel is auto-placed on whichever side of the window has room, + * and it is click-through, so a player cannot drag it out of the way. The + * alerts therefore have to be the ones that move: `auto` skips any side the + * panel occupies, and an explicit choice is honoured but pushed clear of the + * panel rather than drawn over it. + */ +export function placeAlertWindow(request: AlertPlacementRequest): AlertPlacement { + const { anchor, control, workArea } = request; + + if (anchor !== "auto") { + const rect = resolveSide(anchor, request); + return { x: rect.x, y: rect.y, side: anchor, anchor }; + } + + const resolved = ALERT_SIDES.map((side) => ({ side, rect: resolveSide(side, request) })); + const clear = resolved.find(({ rect }) => ( + fitsOnScreen(rect, workArea) && !intersects(rect, control) + )); + // Nothing is both on screen and clear -- prefer staying clear of the panel + // over staying fully on screen, since an alert drawn over the panel hides + // the crowd-control timers this window exists to show. + const fallback = resolved.find(({ rect }) => !intersects(rect, control)) + ?? resolved.find(({ rect }) => fitsOnScreen(rect, workArea)) + ?? resolved[0]; + const chosen = clear ?? fallback; + return { x: chosen.rect.x, y: chosen.rect.y, side: chosen.side, anchor }; +} diff --git a/loremaster-desktop/electron/main.ts b/loremaster-desktop/electron/main.ts index f230745..c3dc8a2 100644 --- a/loremaster-desktop/electron/main.ts +++ b/loremaster-desktop/electron/main.ts @@ -14,6 +14,7 @@ import { type InventoryEntry, } from "./gear-plan"; import { ItemIntelligenceService } from "./item-intelligence"; +import { placeAlertWindow } from "./alert-placement"; import { acknowledgePortableUpdateRelaunch, PortableUpdateService, @@ -1608,39 +1609,21 @@ function positionAlertWindow(): void { const settings = engine?.getState().settings ?? defaultSettings; const alertSize = scaledSize(ALERT_SIZE, settings.fontScale); const anchorBounds = mainWindow.getBounds(); - const companionBounds = controlWindow?.isVisible() ? controlWindow.getBounds() : null; - const companionAbove = Boolean( - companionBounds && companionBounds.y + companionBounds.height <= anchorBounds.y); - const workArea = screen.getDisplayMatching(anchorBounds).workArea; - const gap = 10; - let anchor = settings.alerts.alertAnchor; - if (anchor === "auto") { - const aboveEdge = companionAbove && companionBounds - ? companionBounds.y - : anchorBounds.y; - const above = aboveEdge - workArea.y; - const below = workArea.y + workArea.height - (anchorBounds.y + anchorBounds.height); - const right = workArea.x + workArea.width - (anchorBounds.x + anchorBounds.width); - anchor = above >= alertSize.height + gap && above >= below - ? "above" - : right >= alertSize.width + gap ? "right" - : below >= alertSize.height + gap ? "below" : "left"; - } - let x = anchorBounds.x + Math.round((anchorBounds.width - alertSize.width) / 2); - let y = (companionAbove && companionBounds ? companionBounds.y : anchorBounds.y) - - alertSize.height - gap; - if (anchor === "below") y = anchorBounds.y + anchorBounds.height + gap; - if (anchor === "left") { - x = anchorBounds.x - alertSize.width - gap; - y = anchorBounds.y + Math.round((anchorBounds.height - alertSize.height) / 2); - } - if (anchor === "right") { - x = anchorBounds.x + anchorBounds.width + gap; - y = anchorBounds.y + Math.round((anchorBounds.height - alertSize.height) / 2); - } - x = clamp(x, workArea.x, workArea.x + workArea.width - alertSize.width); - y = clamp(y, workArea.y, workArea.y + workArea.height - alertSize.height); - alertWindow.setBounds({ x, y, ...alertSize }, false); + // The control panel auto-places itself on whichever side has room and is + // click-through, so a player cannot drag it clear. The alerts are the ones + // that have to move out of its way -- on every side, not just above it. + const control = controlWindow && !controlWindow.isDestroyed() && controlWindow.isVisible() + ? controlWindow.getBounds() + : null; + const placement = placeAlertWindow({ + anchor: settings.alerts.alertAnchor, + main: anchorBounds, + alert: alertSize, + control, + workArea: screen.getDisplayMatching(anchorBounds).workArea, + gap: 10, + }); + alertWindow.setBounds({ x: placement.x, y: placement.y, ...alertSize }, false); } function setWindowMode(expanded: boolean, preserveAnchor = false): void { diff --git a/loremaster-desktop/package.json b/loremaster-desktop/package.json index f59093e..8229f29 100644 --- a/loremaster-desktop/package.json +++ b/loremaster-desktop/package.json @@ -17,6 +17,7 @@ "test:items": "node scripts/test-item-intelligence.cjs", "test:updates": "node scripts/test-portable-updater.cjs", "test:skin-updates": "node scripts/test-spinui-updater.cjs", + "test:alert-placement": "node scripts/test-alert-placement.cjs", "test:rc-backup": "node scripts/test-rc-backup.cjs" }, "dependencies": { diff --git a/loremaster-desktop/scripts/test-alert-placement.cjs b/loremaster-desktop/scripts/test-alert-placement.cjs new file mode 100644 index 0000000..df78efa --- /dev/null +++ b/loremaster-desktop/scripts/test-alert-placement.cjs @@ -0,0 +1,140 @@ +const assert = require("node:assert/strict"); + +const { + ALERT_SIDES, + intersects, + placeAlertWindow, +} = require("../dist-electron/alert-placement.js"); + +const WORK = { x: 0, y: 0, width: 1920, height: 1080 }; +const MAIN = { x: 800, y: 500, width: 320, height: 120 }; +const ALERT = { width: 360, height: 140 }; +const GAP = 10; + +function place(overrides = {}) { + return placeAlertWindow({ + anchor: "auto", + main: MAIN, + alert: ALERT, + control: null, + workArea: WORK, + gap: GAP, + ...overrides, + }); +} + +function rectOf(result) { + return { x: result.x, y: result.y, ...ALERT }; +} + +// A control panel placed hard against each side of the main window, matching +// what syncControlWindow produces for that side. +function controlOn(side, size = { width: 300, height: 90 }) { + if (side === "above") { + return { x: MAIN.x, y: MAIN.y - size.height - 6, ...size }; + } + if (side === "below") { + return { x: MAIN.x, y: MAIN.y + MAIN.height + 6, ...size }; + } + if (side === "left") { + return { x: MAIN.x - size.width - 6, y: MAIN.y, ...size }; + } + return { x: MAIN.x + MAIN.width + 6, y: MAIN.y, ...size }; +} + +function testStaysOnScreen() { + for (const side of ALERT_SIDES) { + for (const control of [null, ...ALERT_SIDES.map(controlOn)]) { + const r = rectOf(place({ anchor: side, control })); + assert.ok(r.x >= WORK.x && r.y >= WORK.y, `${side} escaped top/left`); + assert.ok(r.x + r.width <= WORK.x + WORK.width, `${side} escaped right`); + assert.ok(r.y + r.height <= WORK.y + WORK.height, `${side} escaped bottom`); + } + } + console.log(" always inside the work area: PASS"); +} + +function testNoControlKeepsHistoricPreference() { + // With room everywhere and no panel, auto has always preferred above. + const result = place(); + assert.equal(result.side, "above"); + assert.equal(result.y, MAIN.y - ALERT.height - GAP); + console.log(" no panel -> unchanged 'above' preference: PASS"); +} + +function testControlAboveStillClears() { + // The one case the old code handled: alerts sit above the panel, not on it. + const control = controlOn("above"); + const result = place({ control }); + const r = rectOf(result); + assert.equal(intersects(r, control), false); + assert.ok(r.y + r.height <= control.y, "alert must sit above the panel"); + console.log(" panel above -> alert clears it (regression guard): PASS"); +} + +function testAutoAvoidsPanelOnEverySide() { + for (const side of ALERT_SIDES) { + const control = controlOn(side); + const result = place({ control }); + const r = rectOf(result); + assert.equal( + intersects(r, control), false, + `auto placed the alert on top of a panel sitting ${side}`, + ); + } + console.log(" auto avoids the panel on all four sides: PASS"); +} + +function testExplicitAnchorIsNudgedClear() { + // The user pinned alerts to a side; honour the side but clear the panel. + for (const side of ALERT_SIDES) { + const control = controlOn(side); + const result = place({ anchor: side, control }); + const r = rectOf(result); + assert.equal(result.side, side, "explicit anchor must be preserved"); + assert.equal( + intersects(r, control), false, + `explicit '${side}' still overlapped the panel`, + ); + } + console.log(" explicit anchor kept, nudged clear of the panel: PASS"); +} + +function testHiddenPanelChangesNothing() { + const withPanel = place({ control: null }); + const same = place({ control: null }); + assert.deepEqual(withPanel, same); + console.log(" hidden panel is a no-op: PASS"); +} + +function testTallPanelFallsBackWithoutOverlap() { + // A panel that leaves no clear room on its own side must not trap the alert + // underneath it -- auto has to pick a different side. + const control = { x: MAIN.x - 40, y: 0, width: 420, height: MAIN.y + MAIN.height }; + const r = rectOf(place({ control })); + assert.equal(intersects(r, control), false); + console.log(" no room above -> auto picks a clear side: PASS"); +} + +function testIntersectsHelper() { + const a = { x: 0, y: 0, width: 10, height: 10 }; + assert.equal(intersects(a, { x: 10, y: 0, width: 10, height: 10 }), false); + assert.equal(intersects(a, { x: 9, y: 9, width: 10, height: 10 }), true); + assert.equal(intersects(a, null), false); + console.log(" intersects helper: PASS"); +} + +function main() { + console.log("alert placement:"); + testIntersectsHelper(); + testNoControlKeepsHistoricPreference(); + testHiddenPanelChangesNothing(); + testControlAboveStillClears(); + testAutoAvoidsPanelOnEverySide(); + testExplicitAnchorIsNudgedClear(); + testTallPanelFallsBackWithoutOverlap(); + testStaysOnScreen(); + console.log("alert placement: ALL PASS"); +} + +main(); From a09128b1645343a77efed907307415d41377fdf0 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:11:51 -0400 Subject: [PATCH 4/5] Stop the Windows updater from killing startup on Linux PortableUpdateService resolved the Windows PowerShell path in its constructor, and resolveWindowsPowerShell throws whenever SystemRoot and WINDIR are absent -- which is always, off Windows. main.ts calls initializeUpdateServices() synchronously inside app.whenReady(), ahead of createWindow() and ensureTray(), so that throw aborted the whole ready handler: no engine, no window, no tray icon. The only trace was an UnhandledPromiseRejectionWarning. Resolve the PowerShell path where it is actually used instead -- the Windows portable-replacement path in installAndRelaunch -- so construction is platform-neutral and the failure, when it matters, still surfaces to the caller performing the update. Measured on a KDE session against packaged AppImages, with the stale single-instance lock holder cleared between runs: with this fix engine started, window shown, tray item registered without it none of the three The RC identity overrides are not involved: the passing build carries all of them, including desktopName=loremaster-rc.desktop. scripts/test-updater-startup.cjs builds both update services with SystemRoot and WINDIR removed, so this is caught on any platform rather than only on the machine that runs it. It and the alert-placement suite now run in both CI jobs. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build-loremaster.yml | 4 + .../electron/portable-updater.ts | 15 ++- loremaster-desktop/package.json | 1 + .../scripts/test-updater-startup.cjs | 96 +++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 loremaster-desktop/scripts/test-updater-startup.cjs diff --git a/.github/workflows/build-loremaster.yml b/.github/workflows/build-loremaster.yml index 06bd60f..cc7b602 100644 --- a/.github/workflows/build-loremaster.yml +++ b/.github/workflows/build-loremaster.yml @@ -250,6 +250,8 @@ jobs: pnpm test:updates pnpm test:skin-updates pnpm test:rc-backup + pnpm test:alert-placement + pnpm test:updater-startup - name: Fetch stock-layout reference commit shell: bash @@ -390,6 +392,8 @@ jobs: pnpm build pnpm test:gear pnpm test:rc-backup + pnpm test:alert-placement + pnpm test:updater-startup - name: Verify installer selftest run: python3 installer/spinui_installer.py --selftest diff --git a/loremaster-desktop/electron/portable-updater.ts b/loremaster-desktop/electron/portable-updater.ts index 38dc802..d155101 100644 --- a/loremaster-desktop/electron/portable-updater.ts +++ b/loremaster-desktop/electron/portable-updater.ts @@ -488,7 +488,7 @@ export class PortableUpdateService { private readonly spawnImpl: typeof spawn; private readonly minExecutableBytes: number; private readonly maxExecutableBytes: number; - private readonly powershellPath: string; + private readonly configuredPowershellPath: string | null; private readonly listeners = new Set(); private progress: UpdateProgress = { phase: "idle", percent: 0, detail: "Ready to check for updates." }; private activeDownload: Promise | null = null; @@ -505,7 +505,16 @@ export class PortableUpdateService { this.spawnImpl = options.spawnImpl ?? spawn; this.minExecutableBytes = options.minExecutableBytes ?? MIN_EXECUTABLE_BYTES; this.maxExecutableBytes = options.maxExecutableBytes ?? MAX_EXECUTABLE_BYTES; - this.powershellPath = options.powershellPath ?? resolveWindowsPowerShell(); + this.configuredPowershellPath = options.powershellPath ?? null; + } + + // Resolved at install time, not construction time. The constructor runs + // during app startup on every platform -- main.ts calls it synchronously + // inside app.whenReady(), ahead of createWindow() and ensureTray() -- so a + // throw here silently costs the window and the tray icon. Only the Windows + // portable-replacement path actually needs PowerShell. + private resolvePowershellPath(): string { + return this.configuredPowershellPath ?? resolveWindowsPowerShell(); } subscribe(listener: ProgressListener): () => void { @@ -669,7 +678,7 @@ export class PortableUpdateService { } this.emit({ phase: "installing", percent: 100, detail: "Closing Loremaster, installing the update, and reopening it.", version: update.version }); const child: ChildProcess = this.spawnImpl( - this.powershellPath, + this.resolvePowershellPath(), [ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", update.helperPath, diff --git a/loremaster-desktop/package.json b/loremaster-desktop/package.json index 8229f29..7cd0b3c 100644 --- a/loremaster-desktop/package.json +++ b/loremaster-desktop/package.json @@ -18,6 +18,7 @@ "test:updates": "node scripts/test-portable-updater.cjs", "test:skin-updates": "node scripts/test-spinui-updater.cjs", "test:alert-placement": "node scripts/test-alert-placement.cjs", + "test:updater-startup": "node scripts/test-updater-startup.cjs", "test:rc-backup": "node scripts/test-rc-backup.cjs" }, "dependencies": { diff --git a/loremaster-desktop/scripts/test-updater-startup.cjs b/loremaster-desktop/scripts/test-updater-startup.cjs new file mode 100644 index 0000000..a449855 --- /dev/null +++ b/loremaster-desktop/scripts/test-updater-startup.cjs @@ -0,0 +1,96 @@ +// Startup guard: constructing the update services must never throw, on any +// platform. main.ts calls initializeUpdateServices() synchronously inside +// app.whenReady(), before createWindow() and ensureTray(). A constructor that +// throws there takes the window and the tray icon down with it, and the only +// symptom is an UnhandledPromiseRejectionWarning in a log nobody reads. +const assert = require("node:assert/strict"); +const { mkdtemp, rm } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +const { PortableUpdateService } = require("../dist-electron/portable-updater.js"); +const { SpinUISkinUpdateService } = require("../dist-electron/spinui-updater.js"); + +async function withTempDir(run) { + const dir = await mkdtemp(path.join(os.tmpdir(), "updater-startup-")); + try { + await run(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +// A Linux/macOS process has neither of the variables the Windows PowerShell +// lookup needs. Clear them explicitly so this test proves the same thing when +// it runs on a Windows CI runner. +function withoutWindowsEnvironment(run) { + const saved = { SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR }; + delete process.env.SystemRoot; + delete process.env.WINDIR; + try { + return run(); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +async function testPortableUpdaterConstructs() { + await withTempDir(async (dir) => { + withoutWindowsEnvironment(() => { + const service = new PortableUpdateService({ + currentVersion: "0.4.0", + userDataDir: dir, + executablePath: null, + }); + assert.ok(service, "the portable updater must construct without Windows"); + assert.equal(typeof service.subscribe, "function"); + }); + }); + console.log(" portable updater constructs without Windows PowerShell: PASS"); +} + +async function testSkinUpdaterConstructs() { + await withTempDir(async (dir) => { + withoutWindowsEnvironment(() => { + const service = new SpinUISkinUpdateService({ userDataDir: dir, eqRoot: null }); + assert.ok(service, "the skin updater must construct without Windows"); + }); + }); + console.log(" skin updater constructs without Windows PowerShell: PASS"); +} + +async function testStartupSequenceSurvives() { + // The exact shape main.ts uses: build both services back to back and let any + // throw escape, the way it would inside app.whenReady(). + await withTempDir(async (dir) => { + withoutWindowsEnvironment(() => { + assert.doesNotThrow(() => { + const portable = new PortableUpdateService({ + currentVersion: "0.4.0", + userDataDir: dir, + executablePath: null, + }); + portable.subscribe(() => {}); + const skins = new SpinUISkinUpdateService({ userDataDir: dir, eqRoot: null }); + skins.subscribe(() => {}); + }, "initializeUpdateServices must not throw during startup"); + }); + }); + console.log(" full startup sequence does not throw: PASS"); +} + +async function main() { + console.log("updater startup:"); + await testPortableUpdaterConstructs(); + await testSkinUpdaterConstructs(); + await testStartupSequenceSurvives(); + console.log("updater startup: ALL PASS"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); From 99c8fd66ef3fe90894b8c36ec85283699ea9d8de Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:03:30 -0400 Subject: [PATCH 5/5] Never park an alert card on top of the seed window The new placement clamped each candidate into the work area before testing whether it fitted, and a clamped rectangle always fits -- so the test was a tautology and "auto" chose "above" no matter how little room was there. With the seed near a screen edge the clamped rectangle landed directly on it. The alert window is always-on-top, so the seed could not be opened, expanded or dragged: analyze and settings became unreachable. Rank candidates before clamping, and treat covering the seed as disqualifying. Every side now offers two candidates -- its natural position and that position pulled back on screen -- and they are ranked together: fully clear first, then on-screen and off the seed, then merely off the seed. Staying off the seed outranks honouring an explicit Alert location, because an unreachable seed costs the whole application. The old space-based preference is restored too: above wins only while it has at least as much room as below. The previous tests missed this because the fixture put the window at (800,500) with room on every side, and asserting "inside the work area" after clamping proved nothing. The suite now sweeps the seed across nine screen positions -- corners, edges and centre -- against every anchor, at the real 128x74 seed and 420x112 alert sizes, and asserts the alert never intersects it: 45 cases. Co-Authored-By: Claude Opus 5 (1M context) --- .../electron/alert-placement.ts | 82 +++++++++++++------ .../scripts/test-alert-placement.cjs | 75 +++++++++++++++++ 2 files changed, 131 insertions(+), 26 deletions(-) diff --git a/loremaster-desktop/electron/alert-placement.ts b/loremaster-desktop/electron/alert-placement.ts index a73eb3f..7b78a85 100644 --- a/loremaster-desktop/electron/alert-placement.ts +++ b/loremaster-desktop/electron/alert-placement.ts @@ -85,47 +85,77 @@ function clampToWorkArea(rect: Rect, workArea: Rect): Rect { }; } -function resolveSide(side: AlertSide, request: AlertPlacementRequest): Rect { - return clampToWorkArea( - clearControl(baseRect(side, request), side, request), - request.workArea, - ); +// Base position for a side, with the control panel dodged, but NOT clamped. +// Clamping is deliberately withheld here: a clamped rectangle always fits the +// work area, so testing fit after clamping is a tautology that makes every +// side look viable. +function candidate(side: AlertSide, request: AlertPlacementRequest): Rect { + return clearControl(baseRect(side, request), side, request); } -function fitsOnScreen(rect: Rect, workArea: Rect): boolean { +function insideWorkArea(rect: Rect, workArea: Rect): boolean { return (rect.x >= workArea.x && rect.y >= workArea.y && rect.x + rect.width <= workArea.x + workArea.width && rect.y + rect.height <= workArea.y + workArea.height); } +// The seed window is the only way to reach analyze and settings, and the only +// thing a player can drag. An alert card parked on top of it makes the whole +// application unreachable, so overlapping it disqualifies a side outright -- +// ahead of honouring a preferred anchor. +function usable(rect: Rect, request: AlertPlacementRequest): boolean { + return (insideWorkArea(rect, request.workArea) + && !intersects(rect, request.control) + && !intersects(rect, request.main)); +} + +// Mirrors the historic preference: above first, but only while it has at +// least as much room as below; otherwise try the sides before dropping below. +function preferenceOrder(request: AlertPlacementRequest): readonly AlertSide[] { + const { main, workArea } = request; + const spaceAbove = main.y - workArea.y; + const spaceBelow = workArea.y + workArea.height - (main.y + main.height); + return spaceAbove >= spaceBelow + ? (["above", "right", "below", "left"] as const) + : (["right", "below", "left", "above"] as const); +} + /** * Choose where the alert stack goes. * * The control panel is auto-placed on whichever side of the window has room, * and it is click-through, so a player cannot drag it out of the way. The - * alerts therefore have to be the ones that move: `auto` skips any side the - * panel occupies, and an explicit choice is honoured but pushed clear of the - * panel rather than drawn over it. + * alerts therefore have to be the ones that move: they dodge the panel on + * every side, and they never cover the seed window. + * + * Every side yields two candidates -- its natural position, and that position + * pulled back inside the work area. Near a screen edge the natural one falls + * off screen while the clamped one can land on the seed, so both are offered + * and ranked together rather than clamping blindly at the end. */ export function placeAlertWindow(request: AlertPlacementRequest): AlertPlacement { - const { anchor, control, workArea } = request; + const { workArea, main } = request; + const sides = preferenceOrder(request); + const optionsFor = (side: AlertSide) => { + const raw = candidate(side, request); + return [{ side, rect: raw }, { side, rect: clampToWorkArea(raw, workArea) }]; + }; + const preferred = request.anchor === "auto" ? [] : optionsFor(request.anchor); + const options = [...preferred, ...sides.flatMap(optionsFor)]; - if (anchor !== "auto") { - const rect = resolveSide(anchor, request); - return { x: rect.x, y: rect.y, side: anchor, anchor }; - } + const chosen = options.find(({ rect }) => usable(rect, request)) + // Nothing ideal: staying off the seed matters more than the panel, and + // both matter more than honouring the requested side. + ?? options.find(({ rect }) => insideWorkArea(rect, workArea) + && !intersects(rect, main)) + ?? options.find(({ rect }) => !intersects(rect, main)) + ?? options[0]; - const resolved = ALERT_SIDES.map((side) => ({ side, rect: resolveSide(side, request) })); - const clear = resolved.find(({ rect }) => ( - fitsOnScreen(rect, workArea) && !intersects(rect, control) - )); - // Nothing is both on screen and clear -- prefer staying clear of the panel - // over staying fully on screen, since an alert drawn over the panel hides - // the crowd-control timers this window exists to show. - const fallback = resolved.find(({ rect }) => !intersects(rect, control)) - ?? resolved.find(({ rect }) => fitsOnScreen(rect, workArea)) - ?? resolved[0]; - const chosen = clear ?? fallback; - return { x: chosen.rect.x, y: chosen.rect.y, side: chosen.side, anchor }; + return { + x: chosen.rect.x, + y: chosen.rect.y, + side: chosen.side, + anchor: request.anchor, + }; } diff --git a/loremaster-desktop/scripts/test-alert-placement.cjs b/loremaster-desktop/scripts/test-alert-placement.cjs index df78efa..95aafb5 100644 --- a/loremaster-desktop/scripts/test-alert-placement.cjs +++ b/loremaster-desktop/scripts/test-alert-placement.cjs @@ -124,6 +124,77 @@ function testIntersectsHelper() { console.log(" intersects helper: PASS"); } + +// --- regression: the alert must never cover the seed window ----------------- +// A clamped rectangle always "fits" the work area, so checking fit after +// clamping is a tautology: auto then picks "above" no matter how little room +// is there, and near a screen edge that lands on top of the seed. The seed is +// the only way to reach analyze/settings and to drag the app, so covering it +// makes the application unusable. +const SEED = { width: 128, height: 74 }; +const REAL_ALERT = { width: 420, height: 112 }; + +function placeSeed(main, overrides = {}) { + return placeAlertWindow({ + anchor: "auto", + main, + alert: REAL_ALERT, + control: null, + workArea: WORK, + gap: 10, + ...overrides, + }); +} + +function testNeverCoversSeedAnywhereOnScreen() { + const xs = [WORK.x, 400, WORK.x + WORK.width - SEED.width]; + const ys = [WORK.y, 300, WORK.y + WORK.height - SEED.height]; + let checked = 0; + for (const x of xs) { + for (const y of ys) { + const main = { x, y, ...SEED }; + for (const anchor of ["auto", ...ALERT_SIDES]) { + const r = placeSeed(main, { anchor }); + const rect = { x: r.x, y: r.y, ...REAL_ALERT }; + assert.equal( + intersects(rect, main), false, + `alert covered the seed at (${x},${y}) with anchor ${anchor}`, + ); + checked += 1; + } + } + } + console.log(` never covers the seed (${checked} positions): PASS`); +} + +function testSeedAtTopDoesNotChooseAbove() { + const main = { x: 900, y: WORK.y, ...SEED }; + const r = placeSeed(main); + assert.notEqual(r.side, "above", "no room above a seed pinned to the top edge"); + const rect = { x: r.x, y: r.y, ...REAL_ALERT }; + assert.equal(intersects(rect, main), false); + console.log(" seed at the top edge -> auto avoids 'above': PASS"); +} + +function testSeedAtBottomDoesNotChooseBelow() { + const main = { x: 900, y: WORK.y + WORK.height - SEED.height, ...SEED }; + const r = placeSeed(main); + assert.notEqual(r.side, "below", "no room below a seed pinned to the bottom edge"); + const rect = { x: r.x, y: r.y, ...REAL_ALERT }; + assert.equal(intersects(rect, main), false); + console.log(" seed at the bottom edge -> auto avoids 'below': PASS"); +} + +function testAvoidsSeedAndPanelTogether() { + const main = { x: 900, y: WORK.y, ...SEED }; + const control = { x: main.x + main.width + 6, y: main.y, width: 300, height: 90 }; + const r = placeSeed(main, { control }); + const rect = { x: r.x, y: r.y, ...REAL_ALERT }; + assert.equal(intersects(rect, main), false, "covered the seed"); + assert.equal(intersects(rect, control), false, "covered the control panel"); + console.log(" avoids the seed and the panel at once: PASS"); +} + function main() { console.log("alert placement:"); testIntersectsHelper(); @@ -134,6 +205,10 @@ function main() { testExplicitAnchorIsNudgedClear(); testTallPanelFallsBackWithoutOverlap(); testStaysOnScreen(); + testSeedAtTopDoesNotChooseAbove(); + testSeedAtBottomDoesNotChooseBelow(); + testAvoidsSeedAndPanelTogether(); + testNeverCoversSeedAnywhereOnScreen(); console.log("alert placement: ALL PASS"); }