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:
@@ -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,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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user