Skip to content

Commit 4d8ffc8

Browse files
v1.2.0: keep CONTINUE correct when the menu is shown again
Visiting another menu screen and coming back left the CONTINUE entry labelled "TEXT BLOCK" and unresponsive, with its separator missing. UCrUW_MainMenuWidget::NativeConstruct is the label pass and it runs on every menu show: it re-reads ButtonsTexts, calls UTabButton::SetButtonText per entry, and clears the click gate while walking. Our entry has no ButtonsTexts key -- the plugin API cannot insert into a TMap -- so a re-shown menu got the placeholder label and a closed gate. MenuInstance::PatchNow now runs from the construct hook after the original returns, so the label and gate are correct in the frame the menu is built rather than corrected afterwards. The separator had an unrelated cause: Separator::IsInstanceOfClass enumerated live VerticalBox instances into a fixed 256-entry buffer, and a second menu instance pushed the live count to 333. The button's root box fell past the cut, so the check reported "not a live VerticalBox" and stood down. It now grows past the reported total and rescans; a truncated view can no longer be read as absence. Also widens the settled-instance guard to compare ButtonsBox child 0, not just the menu instance, so a genuinely new button is re-patched. Verified in game: LOAD GAME -> ESC returns to a menu with CONTINUE labelled, separated and focused; three constructs, each patched, separator built every time, no truncation warnings.
1 parent a014168 commit 4d8ffc8

7 files changed

Lines changed: 154 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
## v1.2.0 — 2026-08-07
4+
5+
**Fixes CONTINUE breaking after you visit another menu screen.**
6+
7+
Open `LOAD GAME` (or any other screen) and come back, and the CONTINUE entry lost its label — it read
8+
`TEXT BLOCK` — and stopped responding to clicks. The separator above `NEW GAME` disappeared with it. Starting the
9+
game again was the only way to get it back.
10+
11+
The game rebuilds the main menu every time it is shown, relabelling each entry from its own list of button names.
12+
CONTINUE is not in that list, so it came back blank, and the same pass switched its click handling back off. The
13+
mod now re-applies the label and the click handling in the same frame the menu is rebuilt, so the entry is correct
14+
the first time it is drawn rather than being corrected afterwards.
15+
16+
The separator had a second, unrelated cause: an internal check gave up once enough menu screens had been built,
17+
and silently concluded it had nowhere to draw. It no longer gives up.
18+
19+
If you use auto-update, this arrives on its own. Built for game build `0.2.8.121391-S`. Requires ModLoader v1.17.3
20+
(plugin interface 60).
21+
22+
323
## v1.1.0 — 2026-08-06
424

525
**The mod now updates itself.** No change to what it does in game.
1 KB
Binary file not shown.

src/AgenticalMods-Continue.vcxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
<ClCompile>
7474
<WarningLevel>Level3</WarningLevel>
7575
<SDLCheck>true</SDLCheck>
76-
<PreprocessorDefinitions>_DEBUG;MODLOADER_CLIENT_BUILD;CONTINUE_EXPORTS;MODLOADER_BUILD_TAG="1.1.0";_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
76+
<PreprocessorDefinitions>_DEBUG;MODLOADER_CLIENT_BUILD;CONTINUE_EXPORTS;MODLOADER_BUILD_TAG="1.2.0";_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
7777
<ConformanceMode>true</ConformanceMode>
7878
<LanguageStandard>stdcpp20</LanguageStandard>
7979
<PrecompiledHeader>NotUsing</PrecompiledHeader>
@@ -93,7 +93,7 @@
9393
<FunctionLevelLinking>true</FunctionLevelLinking>
9494
<IntrinsicFunctions>true</IntrinsicFunctions>
9595
<SDLCheck>true</SDLCheck>
96-
<PreprocessorDefinitions>NDEBUG;MODLOADER_CLIENT_BUILD;CONTINUE_EXPORTS;MODLOADER_BUILD_TAG="1.1.0";_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
96+
<PreprocessorDefinitions>NDEBUG;MODLOADER_CLIENT_BUILD;CONTINUE_EXPORTS;MODLOADER_BUILD_TAG="1.2.0";_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
9797
<ConformanceMode>true</ConformanceMode>
9898
<LanguageStandard>stdcpp20</LanguageStandard>
9999
<PrecompiledHeader>NotUsing</PrecompiledHeader>

src/construct_hook.cpp

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include "construct_hook.h"
2+
#include "menu_instance.h"
23
#include "plugin_config.h"
34
#include "plugin_helpers.h"
45
#include "save_discovery.h"
@@ -157,6 +158,23 @@ namespace ConstructHook
157158

158159
if (g_original)
159160
g_original(widget);
161+
162+
// NativeConstruct IS the label pass: it reads ButtonsTexts and calls
163+
// UTabButton::SetButtonText on every entry, and clears the click gate
164+
// while walking. It runs again every time the menu is shown, and our
165+
// entry has no ButtonsTexts key, so a menu returned to from a submenu
166+
// is re-labelled with the placeholder and re-gated shut. Patching in
167+
// this frame, after the game has finished, is what keeps the wrong
168+
// label from ever reaching the screen.
169+
__try
170+
{
171+
if (g_armed)
172+
MenuInstance::PatchNow(widget);
173+
}
174+
__except (EXCEPTION_EXECUTE_HANDLER)
175+
{
176+
LOG_ERROR("ConstructHook: post-construct patch raised; the entry may be unlabelled.");
177+
}
160178
}
161179
}
162180

src/menu_instance.cpp

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ namespace MenuInstance
3939

4040
void* g_patchedInstance = nullptr; // identity only -- never dereferenced after the frame it was validated in
4141
void* g_continueButton = nullptr;
42+
void* g_decidedChild0 = nullptr; // child 0 as it stood when this instance was last decided
4243
float g_pollTimer = 0.0f;
4344
bool g_loggedNoSubst = false;
4445
bool g_worldIsMenu = true; // unknown until the first world event -- poll
@@ -63,6 +64,30 @@ namespace MenuInstance
6364
return nullptr;
6465
}
6566

67+
// ButtonsBox child 0 right now, or null. The label lives on the BUTTON, not
68+
// on the menu widget: leaving LOAD GAME rebuilds ButtonsBox under a live
69+
// instance, so the instance pointer alone cannot tell a fresh button apart.
70+
void* CurrentChild0(void* instance)
71+
{
72+
IPluginObjectProperties* props = Props();
73+
if (!props || !instance)
74+
return nullptr;
75+
76+
PluginPropertyHandle pButtonsBox = props->FindPropertyOnObject(instance, "ButtonsBox");
77+
if (!pButtonsBox)
78+
return nullptr;
79+
80+
void* buttonsBox = nullptr;
81+
if (!props->GetObjectProperty(instance, pButtonsBox, &buttonsBox) || !buttonsBox)
82+
return nullptr;
83+
84+
P_GetChildAt child{};
85+
child.Index = 0;
86+
if (!Walker()->InvokeUFunctionByName(buttonsBox, kPanelClass, "GetChildAt", &child))
87+
return nullptr;
88+
return child.ReturnValue;
89+
}
90+
6691
// Builds the label through the engine rather than fabricating an FText.
6792
// The FString points at a static literal: input params are read-only and
6893
// ZeroConstructor, which is the standard Dumper-7 SDK pattern.
@@ -228,6 +253,23 @@ namespace MenuInstance
228253
}
229254
}
230255

256+
void PatchNow(void* widget)
257+
{
258+
if (!widget)
259+
return;
260+
261+
IPluginObjectWalker* walker = Walker();
262+
IPluginObjectProperties* props = Props();
263+
if (!walker || !props || !walker->IsReady() || !props->IsReady())
264+
return;
265+
266+
if (TryPatchInstance(widget))
267+
{
268+
g_patchedInstance = widget;
269+
g_decidedChild0 = CurrentChild0(widget);
270+
}
271+
}
272+
231273
void* GetContinueButton() { return g_continueButton; }
232274
void* GetMenuInstance() { return g_patchedInstance; }
233275

@@ -240,6 +282,7 @@ namespace MenuInstance
240282
// new. Pointers are identity-only and never dereferenced here.
241283
g_patchedInstance = nullptr;
242284
g_continueButton = nullptr;
285+
g_decidedChild0 = nullptr;
243286
}
244287
SaveDiscovery::InvalidateCache(); // the save set can change while playing
245288
}
@@ -270,15 +313,23 @@ namespace MenuInstance
270313
// again. Pointers are identity-only and never dereferenced here.
271314
g_patchedInstance = nullptr;
272315
g_continueButton = nullptr;
316+
g_decidedChild0 = nullptr;
273317
return;
274318
}
275319

276-
if (instance == g_patchedInstance)
320+
// Re-decide when child 0 is not the button this instance was decided on.
321+
// Comparing instances alone misses the rebuild that leaving LOAD GAME does:
322+
// same widget, fresh button, label gone (it shows UMG's "Text Block").
323+
// Recording the child for refusals too keeps a terminal refusal from
324+
// re-warning every poll.
325+
void* child0 = CurrentChild0(instance);
326+
if (instance == g_patchedInstance && child0 == g_decidedChild0)
277327
return;
278328

279329
if (TryPatchInstance(instance))
280330
{
281331
g_patchedInstance = instance;
332+
g_decidedChild0 = child0;
282333
seenAMenu = true; // race is over for this menu; stop per-frame walking
283334
}
284335
}

src/menu_instance.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ namespace MenuInstance
1616
// Re-arms per instance; returning to the main menu builds a new widget.
1717
void Tick(float deltaSeconds);
1818

19+
// Applies the patch immediately, for the construct hook to call once the
20+
// game's own NativeConstruct has returned.
21+
//
22+
// NativeConstruct IS the label pass: it reads ButtonsTexts and calls
23+
// UTabButton::SetButtonText on every entry, and it clears the click gate while
24+
// walking. It re-runs each time the menu is shown again, so a menu returned to
25+
// from a submenu is re-labelled from a TMap that has no key for our entry --
26+
// the button reverts to the placeholder and the gate goes back to zero.
27+
// Patching in the same frame is what keeps the wrong label from ever drawing;
28+
// the poll would correct it a quarter-second late, on screen.
29+
void PatchNow(void* widget);
30+
1931
// Scopes the poll to menu worlds. FindObjectsByClassNameInto walks all of
2032
// GObjects and builds a std::string per candidate; the SDK header forbids
2133
// calling it per tick. Called for every world, so an unrecognised name

src/separator.cpp

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
#include <cstdint>
66
#include <cstddef>
7+
#include <cstdlib>
78
#include <cstring>
89

910
// Mechanism:
@@ -28,7 +29,7 @@ namespace Separator
2829

2930
constexpr float kPollIntervalSeconds = 0.25f;
3031
constexpr int kMaxColorRetries = 40; // ~10s -- SetColors runs shortly post-construct
31-
constexpr int kClassCheckCapacity = 256; // live VerticalBox count at the main menu; generous margin
32+
constexpr int kClassCheckCapacity = 512; // starting size only -- IsInstanceOfClass grows and rescans
3233

3334
// Measured from the vanilla separator, not derived at runtime. Spacing, not
3435
// colour: colour must be read live (SetColors runs post-construct).
@@ -70,17 +71,57 @@ namespace Separator
7071
// enumerate live instances of className and look for a match. Required
7172
// before any VerticalBox-declared call touches the button's root widget.
7273
// Called once per settled attempt, not per poll.
74+
//
75+
// The scan must be COMPLETE. A fixed cap that truncates reports "not of this
76+
// class" for an object that is simply past the cut, which stands the
77+
// separator down on a menu that is otherwise fine -- measured after a
78+
// LOAD GAME round trip, where a second menu instance pushed live
79+
// VerticalBoxes to 333 against a 256 cap. The count is a function of how
80+
// many menus have been built, so no constant is safe; grow and rescan.
7381
bool IsInstanceOfClass(void* obj, const char* className)
7482
{
7583
if (!obj) return false;
76-
static PluginObjectInfo buf[kClassCheckCapacity];
77-
int total = Walker()->FindObjectsByClassNameInto(className, PluginObjectLookup_InstanceOnly, buf, kClassCheckCapacity);
78-
if (total > kClassCheckCapacity)
79-
LOG_WARN("Separator: class-check for \"%s\" truncated (%d live, checked %d).",
80-
className, total, kClassCheckCapacity);
81-
const int checked = total < kClassCheckCapacity ? total : kClassCheckCapacity;
82-
for (int i = 0; i < checked; ++i)
83-
if (buf[i].object == obj) return true;
84+
85+
static PluginObjectInfo* buf = nullptr;
86+
static int capacity = 0;
87+
88+
for (int attempt = 0; attempt < 2; ++attempt)
89+
{
90+
if (capacity < kClassCheckCapacity)
91+
{
92+
PluginObjectInfo* grown = static_cast<PluginObjectInfo*>(
93+
realloc(buf, sizeof(PluginObjectInfo) * kClassCheckCapacity));
94+
if (!grown)
95+
{
96+
LOG_WARN("Separator: class-check buffer allocation failed; standing down.");
97+
return false;
98+
}
99+
buf = grown;
100+
capacity = kClassCheckCapacity;
101+
}
102+
103+
const int total = Walker()->FindObjectsByClassNameInto(
104+
className, PluginObjectLookup_InstanceOnly, buf, capacity);
105+
const int checked = total < capacity ? total : capacity;
106+
for (int i = 0; i < checked; ++i)
107+
if (buf[i].object == obj)
108+
return true;
109+
110+
if (total <= capacity)
111+
return false; // the scan was complete: genuinely not this class
112+
113+
// Truncated -- grow past the reported total and rescan once.
114+
PluginObjectInfo* grown = static_cast<PluginObjectInfo*>(
115+
realloc(buf, sizeof(PluginObjectInfo) * (total + 64)));
116+
if (!grown)
117+
{
118+
LOG_WARN("Separator: class-check needs %d entries and the buffer could not grow; "
119+
"standing down rather than guessing.", total);
120+
return false;
121+
}
122+
buf = grown;
123+
capacity = total + 64;
124+
}
84125
return false;
85126
}
86127

0 commit comments

Comments
 (0)