Add main menu button injection guide

Add a comprehensive step-by-step guide for injecting a custom main-menu button from an F4SE C++ plugin (docs/MainMenu_Button_Injection_Guide.md). Update existing docs to reference the new guide: add link in docs/MainMenu_Injection.md and expand the setup note in docs/setup.md to point to the full how-to and project-specific notes.
This commit is contained in:
2026-06-09 22:44:18 +12:00
parent e61922678b
commit 8ff760ed39
3 changed files with 664 additions and 1 deletions
+661
View File
@@ -0,0 +1,661 @@
# Fallout 4 Main Menu Button Injection — Step-by-Step Guide
Add a custom row to the Fallout 4 title-screen main menu from an **F4SE C++ plugin**, without patching `MainMenu.swf`.
This guide explains the full pattern used by Commonwealth Online. The same approach works for any mod that needs a new main-menu entry (Multiplayer, Mod Settings, Server Browser, etc.).
---
## Table of contents
- [Fallout 4 Main Menu Button Injection — Step-by-Step Guide](#fallout-4-main-menu-button-injection--step-by-step-guide)
- [Table of contents](#table-of-contents)
- [Why inject from C++ instead of patching the SWF?](#why-inject-from-c-instead-of-patching-the-swf)
- [Prerequisites](#prerequisites)
- [How the vanilla main menu works](#how-the-vanilla-main-menu-works)
- [Menu list widget path](#menu-list-widget-path)
- [Vanilla logical indices](#vanilla-logical-indices)
- [When the list is built](#when-the-list-is-built)
- [Title screen vs pause menu](#title-screen-vs-pause-menu)
- [Menu states](#menu-states)
- [Architecture overview](#architecture-overview)
- [Step 1 — Pick constants and project layout](#step-1--pick-constants-and-project-layout)
- [Step 2 — Hook `StartMenuBase::InitMainList`](#step-2--hook-startmenubaseinitmainlist)
- [Step 3 — Inject a row into the Scaleform list](#step-3--inject-a-row-into-the-scaleform-list)
- [Resolve the list](#resolve-the-list)
- [Guard conditions](#guard-conditions)
- [Create and insert the entry](#create-and-insert-the-entry)
- [Step 4 — Intercept clicks with an `ITEM_PRESS` listener](#step-4--intercept-clicks-with-an-item_press-listener)
- [Resolve the event name](#resolve-the-event-name)
- [Step 5 — Handle gamepad / Enter with `OnButtonEvent`](#step-5--handle-gamepad--enter-with-onbuttonevent)
- [Step 6 — Re-inject when the list is rebuilt](#step-6--re-inject-when-the-list-is-rebuilt)
- [Step 7 — Register on menu open / close](#step-7--register-on-menu-open--close)
- [Step 8 — Wire `Install()` from plugin load](#step-8--wire-install-from-plugin-load)
- [Step 9 — Implement your button action](#step-9--implement-your-button-action)
- [Important constraints](#important-constraints)
- [Localization (optional)](#localization-optional)
- [Testing checklist](#testing-checklist)
- [Log lines to look for](#log-lines-to-look-for)
- [Troubleshooting](#troubleshooting)
- [Custom row missing](#custom-row-missing)
- [Click does nothing](#click-does-nothing)
- [Crash on click (`0xC0000005`, fault `0xFFFFFFFFFFFFFFFF`)](#crash-on-click-0xc0000005-fault-0xffffffffffffffff)
- [Settings sub-menus crash](#settings-sub-menus-crash)
- [Row appears then disappears](#row-appears-then-disappears)
- [`g_player is NULL` on title screen](#g_player-is-null-on-title-screen)
- [Reference implementation](#reference-implementation)
---
## Why inject from C++ instead of patching the SWF?
Fallout 4's main menu is `Interface/MainMenu.swf` — ActionScript 3 running inside Scaleform GFx.
The old approach edits `MainMenu.swf` in [JPEXS FFDec](https://github.com/jindrapetrik/jpexs-decompiler/releases): add a constant, push a row in `InitList()`, add a `case` in `onMainListItemPress()`. That works when done carefully, but it is fragile:
| SWF patch risk | What goes wrong |
|---|---|
| Full class re-import in FFDec | Recompiles `RequestOptions` from decompiled source → **Settings → Gameplay / Display / Audio crash** |
| Patched SWF > ~64 KB | Strong signal the bytecode is corrupted (vanilla is ~62 KB) |
| Custom row without a matching `case` | Vanilla `onMainListItemPress` hits an unknown index → access violation reading `-1` |
| Loose SWF override conflicts | Another mod's `MainMenu.swf` wins in load order |
**C++ runtime injection** avoids all of that:
- Vanilla `MainMenu.swf` stays untouched (loaded from `Fallout4 - Interface.ba2`)
- You insert a GFx object into `entryList` after the game builds it
- You intercept the click **before** vanilla ActionScript runs
- Settings sub-menus keep working
---
## Prerequisites
| Requirement | Notes |
|---|---|
| Fallout 4 + **F4SE** | Plugin must load from `Data/F4SE/Plugins/YourMod.dll` |
| **CommonLibF4** (or equivalent) | RE types, vtable hooks, `REL::Relocation` |
| C++17 compiler | MSVC, x64 |
| Vanilla `MainMenu.swf` | No loose `Data/Interface/MainMenu.swf` override, or restore vanilla |
To restore vanilla `MainMenu.swf` from the game archive:
```powershell
.\tools\restore-vanilla-mainmenu.ps1
```
---
## How the vanilla main menu works
### Menu list widget path
The title-screen list lives at:
```text
MainMenu.menuObj.MainPanel_mc.List_mc.entryList
```
Each entry is a GFx object:
```text
{ "text": "...", "index": <number>, "disabled": <optional bool> }
```
The `index` field is a **logical ID** used by `onMainListItemPress()` — it is **not** the array position.
### Vanilla logical indices
From `Interface/exported/scripts/MainMenu.as` (reference only — do not re-import):
| Constant | Index | Typical label |
|---|---|---|
| `CONTINUE_INDEX` | 0 | Continue |
| `NEW_INDEX` | 1 | New |
| `QUICKSAVE_INDEX` | 2 | Quicksave (pause only) |
| `SAVE_INDEX` | 3 | Save (pause only) |
| `LOAD_INDEX` | 4 | Load |
| `DLC_INDEX` | 5 | Add-ons |
| `BETHESDANET_INDEX` | 6 | Bethesda.net |
| `HELP_INDEX` | 7 | Help |
| `SETTINGS_INDEX` | 8 | Settings |
| `CREDITS_INDEX` | 9 | Credits / Crew |
| `QUIT_INDEX` | 10 | Quit |
| `PLATFORM_HELP` | 11 | Platform help |
| `CREATIONCLUB_INDEX` | 12 | Creations |
| `PS5TRANSFERSAVE_INDEX` | 13 | PS5 transfer |
| `INSTALLED_CONTENT_INDEX` | 14 | Installed content (pause) |
| *(unused)* | **15** | Safe slot for a custom row |
**Use index 15** (or another value not referenced in vanilla `onMainListItemPress`). Commonwealth Online uses `15` for Multiplayer.
### When the list is built
`StartMenuBase::InitMainList` (vtable index `0x15`) populates `entryList`. The game calls it:
- When the main menu first opens
- When the list is rebuilt (e.g. after deleting the last save)
Hook this function to inject your row immediately after vanilla finishes.
### Title screen vs pause menu
The same `MainMenu` class serves both the title screen and the in-game pause menu. ActionScript sets `PauseMode = true` in-game.
**Only inject on the title screen.** Check `PauseMode` on the GFx movie before inserting a row.
### Menu states
`MainMenu.currentState` is a string such as `"MAIN_STATE"`. Sub-menus (Settings, Load, etc.) use other states. Your click handler should only act when:
- `PauseMode == false` (title screen)
- `currentState == "MAIN_STATE"` (top-level list visible)
---
## Architecture overview
```mermaid
flowchart TD
A[Plugin loads] --> B[Install hooks]
B --> C[Hook InitMainList vfunc 0x15]
B --> D[Hook OnButtonEvent vfunc 0x08]
B --> E[Register MenuOpenClose sink]
B --> F[Start resync task]
G[Main menu opens] --> H[InitMainList runs]
H --> I[Vanilla builds entryList]
I --> J[InitMainListHook injects custom row]
J --> K[Register ITEM_PRESS listener]
L[User selects custom row] --> M{Input path}
M -->|Mouse click| N[ITEM_PRESS handler]
M -->|Enter / gamepad A| O[OnButtonEvent hook]
N --> P[stopImmediatePropagation]
O --> P
P --> Q[Your action — open UI, etc.]
```
Five mechanisms work together:
1. **`InitMainList` hook** — insert the row after vanilla list build
2. **`ITEM_PRESS` listener** — intercept mouse / list activation
3. **`OnButtonEvent` hook** — intercept Accept (Enter / A button)
4. **`MenuOpenClose` sink** — re-register listeners when menu reopens
5. **Resync task** — re-inject if ActionScript rebuilds the list without calling your hook
---
## Step 1 — Pick constants and project layout
Create a dedicated module in your plugin:
```text
plugin/
include/YourMainMenuInject.h
src/YourMainMenuInject.cpp
```
Define constants at the top of the `.cpp` translation unit:
```cpp
constexpr auto kCustomButtonIndex = 15; // unused vanilla slot
constexpr auto kInsertAfterIndex = 4; // LOAD_INDEX — row appears after Load
constexpr auto kResyncThrottleFrames = 30;
constexpr auto kItemPressListenerPriority = 100;
constexpr auto kOnButtonEventVtableIndex = 0x08;
```
Public header:
```cpp
#pragma once
namespace RE { class UI; }
namespace YourMod::MainMenuInject
{
bool IsMainMenuOnMainPanel(const RE::UI* a_ui);
void Install();
}
```
---
## Step 2 — Hook `StartMenuBase::InitMainList`
`InitMainList` is a virtual function on `RE::StartMenuBase` at **vtable index `0x15`**.
```cpp
using InitMainListFn = void (*)(RE::StartMenuBase*);
InitMainListFn g_originalInitMainList = nullptr;
void InitMainListHook(RE::StartMenuBase* a_this)
{
if (g_originalInitMainList) {
g_originalInitMainList(a_this); // always call original first
}
if (!a_this) {
return;
}
// Menu movie may not be ready yet — defer to main thread
if (!a_this->GetIsMenuReady()) {
F4SE::GetTaskInterface()->AddTask([a_this]() {
TryInjectForStartMenu(a_this);
});
return;
}
TryInjectForStartMenu(a_this);
}
```
Install the hook in `Install()`:
```cpp
REL::Relocation<std::uintptr_t> startMenuVtable{ RE::StartMenuBase::VTABLE[0] };
g_originalInitMainList = reinterpret_cast<InitMainListFn>(
startMenuVtable.write_vfunc(0x15, &InitMainListHook));
```
`TryInjectForStartMenu` should verify you are on the **title-screen** `MainMenu` (not pause mode) before calling the inject function.
---
## Step 3 — Inject a row into the Scaleform list
### Resolve the list
```cpp
bool ResolveMainList(
RE::MainMenu* a_mainMenu,
Scaleform::GFx::Value& a_outListWidget,
Scaleform::GFx::Value& a_outEntryList)
{
// Prefer menuObj members, fall back to movie variables
if (a_mainMenu->menuObj.IsObject()) {
Scaleform::GFx::Value mainPanel;
if (a_mainMenu->menuObj.GetMember("MainPanel_mc", &mainPanel) && mainPanel.IsObject()) {
Scaleform::GFx::Value listWidget;
if (mainPanel.GetMember("List_mc", &listWidget) && listWidget.IsObject()) {
Scaleform::GFx::Value entryList;
if (listWidget.GetMember("entryList", &entryList) &&
entryList.IsArray()) {
a_outListWidget = listWidget;
a_outEntryList = entryList;
return true;
}
}
}
}
// Fallback path
if (!a_mainMenu->uiMovie->GetVariable(&a_outListWidget, "MainPanel_mc.List_mc")) {
return false;
}
return a_outListWidget.GetMember("entryList", &a_outEntryList) && a_outEntryList.IsArray();
}
```
### Guard conditions
Before injecting, check:
```cpp
if (!a_mainMenu || !a_mainMenu->uiMovie) return false;
if (IsPauseMode(a_mainMenu->uiMovie.get())) return false; // skip pause menu
if (EntryListHasIndex(entryList, kCustomButtonIndex)) return true; // already injected
```
`IsPauseMode` reads the `PauseMode` GFx variable:
```cpp
Scaleform::GFx::Value pauseMode;
a_movie->GetVariable(&pauseMode, "PauseMode");
return pauseMode.IsBoolean() && pauseMode.GetBoolean();
```
### Create and insert the entry
```cpp
Scaleform::GFx::Value newEntry;
a_mainMenu->uiMovie->CreateObject(&newEntry);
Scaleform::GFx::Value textValue{ "MY BUTTON" }; // or "$MY_STRING" for translation
Scaleform::GFx::Value indexValue{ kCustomButtonIndex };
newEntry.SetMember("text", textValue);
newEntry.SetMember("index", indexValue);
InsertEntryAfterIndex(a_mainMenu->uiMovie.get(), entryList, kInsertAfterIndex, newEntry);
listWidget.Invoke("InvalidateData"); // refresh the BSScrollingList display
```
`InsertEntryAfterIndex` finds the array element whose logical `index` matches `kInsertAfterIndex`, shifts later elements right, and inserts your entry at `position + 1`.
---
## Step 4 — Intercept clicks with an `ITEM_PRESS` listener
Vanilla handles list selection in `onMainListItemPress`. If your row reaches that function, the game will crash or misbehave because there is no `case` for your index.
Register a high-priority GFx listener that runs **first** and calls `stopImmediatePropagation()`:
```cpp
class ItemPressHandler : public Scaleform::GFx::FunctionHandler
{
public:
void Call(const Params& a_params) override
{
if (a_params.argCount < 1) return;
auto* mainMenu = static_cast<RE::MainMenu*>(a_params.userData);
Scaleform::GFx::Value target;
a_params.args[0].GetMember("target", &target);
Scaleform::GFx::Value selectedEntry;
target.GetMember("selectedEntry", &selectedEntry);
const auto index = GetEntryLogicalIndex(selectedEntry);
if (!index || *index != kCustomButtonIndex) return;
OpenCustomMenu(mainMenu);
a_params.args[0].Invoke("stopImmediatePropagation"); // block vanilla handler
}
};
```
### Resolve the event name
`BSScrollingList.ITEM_PRESS` is a string constant on the ActionScript class. Read it at runtime rather than hard-coding:
```cpp
// flash.utils.getDefinitionByName("Shared.AS3.BSScrollingList").ITEM_PRESS
// Fallback candidates: "itemPress", "listItemPress", "listPress"
```
Register on both `menuObj` and `List_mc`:
```cpp
Scaleform::GFx::Value handler;
a_mainMenu->uiMovie->CreateFunction(&handler, &g_itemPressHandler, a_mainMenu);
const std::array<Scaleform::GFx::Value, 4> args{
Scaleform::GFx::Value{ eventName.c_str() },
handler,
Scaleform::GFx::Value{ true }, // useCapture
Scaleform::GFx::Value{ kItemPressListenerPriority } // priority
};
a_mainMenu->menuObj.Invoke("addEventListener", nullptr, args.data(), args.size());
listWidget.Invoke("addEventListener", nullptr, args.data(), args.size());
```
---
## Step 5 — Handle gamepad / Enter with `OnButtonEvent`
Mouse clicks go through `ITEM_PRESS`. Keyboard Enter and gamepad A go through `StartMenuBase::OnButtonEvent` (vtable index **`0x08`**).
```cpp
void OnButtonEventHook(RE::IMenu* a_this, const RE::ButtonEvent* a_event)
{
if (a_this && a_event && a_event->QJustPressed() && !a_event->QDisabled()) {
if (a_event->QUserEvent() == "Accept"sv) {
auto* ui = RE::UI::GetSingleton();
auto mainMenu = ui ? ui->GetMenu<RE::MainMenu>() : nullptr;
if (mainMenu && mainMenu.get() == a_this &&
TryHandleCustomSelection(mainMenu.get())) {
return; // consumed — do not call vanilla
}
}
}
if (g_originalOnButtonEvent) {
g_originalOnButtonEvent(a_this, a_event);
}
}
```
`TryHandleCustomSelection` checks that the custom row is currently selected (reads `List_mc.selectedEntry.index`) before opening your UI.
---
## Step 6 — Re-inject when the list is rebuilt
ActionScript sometimes rebuilds `entryList` without going through `InitMainList` (e.g. deleting the last save disables Load and rebuilds the list). A periodic task catches this:
```cpp
void ResyncCustomRow()
{
// throttle to every N frames
if (!ui->GetMenuOpen<RE::MainMenu>()) return;
if (IsPauseMode(mainMenu->uiMovie.get())) return;
if (!EntryListHasIndex(entryList, kCustomButtonIndex)) {
InjectCustomRow(mainMenu.get());
}
if (!g_itemPressListenerRegistered) {
RegisterItemPressListener(mainMenu.get());
}
}
```
Register as a permanent F4SE task:
```cpp
F4SE::GetTaskInterface()->AddTaskPermanent([]() {
ResyncCustomRow();
});
```
---
## Step 7 — Register on menu open / close
When the main menu closes and reopens, Scaleform may recreate the movie. Reset your listener flag and re-inject:
```cpp
class MainMenuOpenCloseSink : public RE::BSTEventSink<RE::MenuOpenCloseEvent>
{
RE::BSEventNotifyControl ProcessEvent(
const RE::MenuOpenCloseEvent& a_event,
RE::BSTEventSource<RE::MenuOpenCloseEvent>*) override
{
if (a_event.menuName != RE::MainMenu::MENU_NAME) {
return RE::BSEventNotifyControl::kContinue;
}
if (a_event.opening) {
g_itemPressListenerRegistered = false;
F4SE::GetTaskInterface()->AddTask([mainMenu]() {
RegisterItemPressListener(mainMenu);
TryInjectForStartMenu(mainMenu);
});
} else {
g_itemPressListenerRegistered = false;
}
return RE::BSEventNotifyControl::kContinue;
}
};
RE::UI::GetSingleton()->RegisterSink(&g_mainMenuOpenCloseSink);
```
---
## Step 8 — Wire `Install()` from plugin load
Call `Install()` once game data is ready — not at `DLL_PROCESS_ATTACH`:
```cpp
// In your plugin's game-data-ready callback (e.g. kMessage_PostPostLoad or equivalent):
void OnGameDataReady()
{
YourMod::MainMenuInject::Install();
}
```
`Install()` should be idempotent:
```cpp
void Install()
{
if (g_hooksInstalled) return;
// 1. Hook InitMainList (vfunc 0x15)
// 2. Hook OnButtonEvent (vfunc 0x08)
// 3. Register MenuOpenClose sink
// 4. Start resync permanent task
// 5. Catch-up inject if menu is already open
g_hooksInstalled = true;
}
```
Commonwealth Online calls install from `F4T::PrismaUI::OnGameDataReady()`.
---
## Step 9 — Implement your button action
When the custom row is activated:
```cpp
void OpenCustomMenu(RE::MainMenu* a_mainMenu)
{
// 1. Ensure we are on MAIN_STATE (call onCancelPress in a loop if needed)
EnsureMainMenuMainState(a_mainMenu);
// 2. Play the standard OK sound
Scaleform::GFx::Value bgsCode;
if (a_mainMenu->menuObj.GetMember("BGSCodeObj", &bgsCode)) {
bgsCode.Invoke("PlayOKSound");
}
// 3. Defer heavy work to the main thread
F4SE::GetTaskInterface()->AddTask([]() {
YourMod::ShowYourUI(); // PrismaUI overlay, custom menu, dialog, etc.
});
}
```
### Important constraints
| Do | Don't |
|---|---|
| Open UI from an F4SE task on the main thread | Touch `PlayerCharacter` or world state from the title screen |
| Defer player-dependent logic until in-game | Install player tick hooks at `GameLoaded` (player may be null) |
| Hide your overlay when leaving `MAIN_STATE` | Call `StartState()` for a nonexistent menu state |
| Close your overlay when user opens Settings | Leave overlay visible over sub-menus |
---
## Localization (optional)
Two options for button text:
**Literal string** (simplest):
```cpp
Scaleform::GFx::Value textValue{ "MULTIPLAYER" };
```
**Translation key** (matches vanilla `$SETTINGS` style):
```cpp
Scaleform::GFx::Value textValue{ "$MY_MOD_BUTTON" };
```
Add the key to `Interface/Translate_en.txt` (loose file or ESP string table) if using `$` prefix.
---
## Testing checklist
Run through this list after every change:
| Step | Expected result |
|---|---|
| Title screen | Custom row appears in the correct position |
| Pause menu (in-game Esc) | Custom row **not** visible |
| Click the row (mouse) | Your UI opens; no crash |
| Select row + Enter / gamepad A | Same as click |
| Settings → Gameplay | No crash |
| Settings → Display | No crash |
| Settings → Audio | No crash |
| Settings → Controls | Works |
| Open your UI → open Settings | Your UI closes / hides |
| Delete last save (list rebuild) | Custom row reappears |
| No loose `MainMenu.swf` | Still works from BA2 |
| Plugin log | Hook install + inject + listener lines present |
### Log lines to look for
```
[MainMenuInject] InitMainList hook and MainMenu injection tasks installed.
[MainMenuInject] Injected Multiplayer row after Load.
[MainMenuInject] Registered N ITEM_PRESS listener(s); candidates: M
```
---
## Troubleshooting
### Custom row missing
- Confirm plugin loads via F4SE (check `Documents/My Games/Fallout4/F4SE/YourMod.log`)
- Confirm `InitMainList` hook installed (log line above)
- Confirm you are on the title screen, not pause menu
- Check for another mod's loose `MainMenu.swf` overriding yours
### Click does nothing
- `ITEM_PRESS` listener not registered — check log for registration failure
- Event name mismatch — verify `ResolveItemPressEventNames` finds the correct string
- `OnButtonEvent` hook missing — Enter / gamepad A will not work without it
### Crash on click (`0xC0000005`, fault `0xFFFFFFFFFFFFFFFF`)
Vanilla `onMainListItemPress` ran for your custom index. Fix:
1. Confirm `stopImmediatePropagation()` is called in the `ITEM_PRESS` handler
2. Confirm listener priority is high enough (`100`)
3. Do **not** patch the SWF with a row but no `case` handler
### Settings sub-menus crash
You have a corrupted loose `MainMenu.swf`. Fix:
```powershell
.\tools\restore-vanilla-mainmenu.ps1
```
Remove any FFDec-patched SWF from your mod package. Use C++ injection only.
### Row appears then disappears
ActionScript rebuilt the list. Ensure the resync permanent task is running (Step 6).
### `g_player is NULL` on title screen
Expected. Do not access the player character from main-menu code. Defer player hooks until the player is loaded in-game.
---
## Reference implementation
The complete working implementation in this repository:
| File | Purpose |
|---|---|
| `plugin/src/F4TMainMenuInject.cpp` | Full injection logic |
| `plugin/include/F4TMainMenuInject.h` | Public API |
| `plugin/src/F4TPrismaUI.cpp` | Calls `Install()` on game data ready |
+2
View File
@@ -2,6 +2,8 @@
Commonwealth Online adds a **Multiplayer** row to the Fallout 4 title-screen main menu at runtime. No SWF patching is required. Commonwealth Online adds a **Multiplayer** row to the Fallout 4 title-screen main menu at runtime. No SWF patching is required.
> **Full step-by-step guide for mod authors:** [`MainMenu_Button_Injection_Guide.md`](MainMenu_Button_Injection_Guide.md)
## Requirements ## Requirements
- Fallout 4 launched via **F4SE** - Fallout 4 launched via **F4SE**
+1 -1
View File
@@ -28,7 +28,7 @@ Avoid testing with a full modlist while developing the core systems.
## Main menu ## Main menu
Do **not** deploy a patched `Interface/MainMenu.swf`. Commonwealth Online injects the **Multiplayer** row at runtime via the F4SE plugin. Use vanilla `MainMenu.swf` or no loose override. See [`MainMenu_Injection.md`](MainMenu_Injection.md). Do **not** deploy a patched `Interface/MainMenu.swf`. Commonwealth Online injects the **Multiplayer** row at runtime via the F4SE plugin. Use vanilla `MainMenu.swf` or no loose override. See [`MainMenu_Button_Injection_Guide.md`](MainMenu_Button_Injection_Guide.md) (full how-to) and [`MainMenu_Injection.md`](MainMenu_Injection.md) (project-specific notes).
## Version Tracking ## Version Tracking