Add runtime MainMenu injection and docs

Introduce a C++ runtime injection that adds a Multiplayer row to the Fallout 4 main menu instead of patching MainMenu.swf. Adds F4TMainMenuInject.h/cpp implementing injection, ITEM_PRESS handling, accept/button hooks, resync tasks and logging; integrates installation by calling MainMenuInject::Install() from PrismaUI init. Update PrismaUI to use the new helper and remove a duplicate IsMainMenuOnMainPanel implementation. Documentation updated to deprecate FFDec SWF patching (new docs/MainMenu_Injection.md, note in FFDEC guide, and setup.md) and restore-vanilla-mainmenu.ps1 messaging updated to instruct using the runtime injection.
This commit is contained in:
2026-06-09 14:52:27 +12:00
parent 1f39c3a929
commit d4d3268b12
7 changed files with 869 additions and 22 deletions
+4
View File
@@ -1,7 +1,11 @@
# FFDec guide — add Multiplayer button to MainMenu.swf # FFDec guide — add Multiplayer button to MainMenu.swf
> **Deprecated:** Use C++ runtime injection instead. See [`MainMenu_Injection.md`](MainMenu_Injection.md). Do not deploy a patched `MainMenu.swf` for Commonwealth Online.
Step-by-step instructions for adding the **Multiplayer** main-menu item using [JPEXS FFDec](https://github.com/jindrapetrik/jpexs-decompiler/releases), without breaking **Settings → Gameplay / Display / Audio**. Step-by-step instructions for adding the **Multiplayer** main-menu item using [JPEXS FFDec](https://github.com/jindrapetrik/jpexs-decompiler/releases), without breaking **Settings → Gameplay / Display / Audio**.
This guide is kept for historical reference only.
--- ---
## Before you start ## Before you start
+61
View File
@@ -0,0 +1,61 @@
# MainMenu Multiplayer — C++ runtime injection
Commonwealth Online adds a **Multiplayer** row to the Fallout 4 title-screen main menu at runtime. No SWF patching is required.
## Requirements
- Fallout 4 launched via **F4SE**
- `CommonwealthOnline.dll` in `Data/F4SE/Plugins/`
- **PrismaUI_F4** and the server browser HTML view installed (for the overlay)
- **Vanilla** `MainMenu.swf` — or no loose override at all (game loads from `Fallout4 - Interface.ba2`)
## Do not ship a patched SWF
| Do | Don't |
|----|--------|
| Use vanilla `MainMenu.swf` (61,959 bytes) or no loose file | Deploy an FFDec-patched `Interface/MainMenu.swf` |
| Let the plugin inject the row and handle clicks | Re-import the full `MainMenu.as` script in FFDec |
A patched SWF can break **Settings → Gameplay / Display / Audio**. The C++ injection path keeps vanilla bytecode intact.
To restore vanilla from the game archive:
```powershell
.\tools\restore-vanilla-mainmenu.ps1
```
## How it works
1. **List injection** — After `StartMenuBase::InitMainList` builds the menu, the plugin inserts `{ text: "Multiplayer", index: 15 }` after the **Load** row (index 4). A fallback task re-injects if ActionScript rebuilds the list without calling the native hook (for example after deleting the last save).
2. **Selection handling** — A Scaleform `ITEM_PRESS` listener intercepts index `15` on the title screen (`MAIN_STATE`, not pause menu) and opens the PrismaUI server browser. Vanilla `onMainListItemPress` is not run for that row.
3. **Browser lifecycle** — Existing `WatchMainMenuState` hides the browser when leaving `MAIN_STATE`. **F9** still toggles the browser.
## Test checklist
| Case | Expected |
|------|----------|
| Title screen main list | **Multiplayer** appears after **Load** |
| Pause menu | No Multiplayer row |
| Click Multiplayer | Server browser opens; no SaveLoad panel flicker |
| Accept / gamepad A on Multiplayer | Same |
| Settings → Gameplay / Display / Audio | No crash |
| Settings → Controls | Works |
| Open Multiplayer → Settings | Browser closes immediately |
| Delete last save / list rebuild | Multiplayer row reappears |
| F9 | Still toggles browser |
| No loose `MainMenu.swf` | Still works (BA2 vanilla) |
## Log checks
In `CommonwealthOnline.log`, look for `[MainMenuInject]` lines:
- `InitMainList hook and MainMenu injection tasks installed.`
- `Injected Multiplayer row after Load.`
- `Registered ITEM_PRESS listener ('…').`
## Deprecated approach
The FFDec SWF patch guide is superseded by this document:
- [`FFDEC_MainMenu_Multiplayer_Guide.md`](FFDEC_MainMenu_Multiplayer_Guide.md) — historical reference only
- [`PATCH_MainMenu_Multiplayer.md`](../Interface/exported/PATCH_MainMenu_Multiplayer.md) — historical reference only
+4
View File
@@ -26,6 +26,10 @@ Dedicated test save
Avoid testing with a full modlist while developing the core systems. Avoid testing with a full modlist while developing the core systems.
## 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).
## Version Tracking ## Version Tracking
Record the exact versions used: Record the exact versions used:
+12
View File
@@ -0,0 +1,12 @@
#pragma once
namespace RE
{
class UI;
}
namespace F4T::MainMenuInject
{
bool IsMainMenuOnMainPanel(const RE::UI* a_ui);
void Install();
}
+782
View File
@@ -0,0 +1,782 @@
#include "pch.h"
#include "F4TMainMenuInject.h"
#include "F4TNetworking.h"
#include "F4TPrismaUI.h"
#include "Scaleform/G/GFx_FunctionHandler.h"
#include "Scaleform/G/GFx_Movie.h"
#include "Scaleform/G/GFx_Value.h"
#include <Windows.h>
#include <algorithm>
#include <cstring>
#include <string>
#include <string_view>
#include <vector>
namespace F4T::MainMenuInject
{
namespace
{
constexpr auto kMultiplayerIndex = 15;
constexpr auto kLoadIndex = 4;
constexpr auto kResyncThrottleFrames = 30;
constexpr auto kItemPressListenerPriority = 100;
constexpr auto kOnButtonEventVtableIndex = 0x08;
constexpr auto kEntryListPath = "MainPanel_mc.List_mc.entryList";
constexpr auto kListWidgetPath = "MainPanel_mc.List_mc";
constexpr std::array<std::string_view, 3> kItemPressEventCandidates{
"itemPress",
"listItemPress",
"listPress"
};
bool g_hooksInstalled = false;
bool g_resyncTaskStarted = false;
bool g_itemPressListenerRegistered = false;
bool g_buttonEventHookInstalled = false;
int g_resyncFrameCounter = 0;
int g_injectFailLogCooldown = 0;
std::string GetLogPrefix()
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " [MainMenuInject]";
}
void LogInfo(std::string_view a_message)
{
REX::INFO("{} {}", GetLogPrefix(), a_message);
}
void LogWarning(std::string_view a_message)
{
REX::WARN("{} {}", GetLogPrefix(), a_message);
}
void LogInjectFailure(std::string_view a_reason)
{
if (g_injectFailLogCooldown > 0) {
--g_injectFailLogCooldown;
return;
}
g_injectFailLogCooldown = 120;
LogWarning(a_reason);
}
std::optional<std::int32_t> GetEntryLogicalIndex(const Scaleform::GFx::Value& a_entry)
{
Scaleform::GFx::Value indexValue;
if (!a_entry.GetMember("index", &indexValue)) {
return std::nullopt;
}
if (indexValue.IsInt()) {
return indexValue.GetInt();
}
if (indexValue.IsUInt()) {
return static_cast<std::int32_t>(indexValue.GetUInt());
}
if (indexValue.IsNumber()) {
return static_cast<std::int32_t>(indexValue.GetNumber());
}
return std::nullopt;
}
bool IsPauseMode(Scaleform::GFx::Movie* a_movie)
{
if (!a_movie) {
return true;
}
Scaleform::GFx::Value pauseMode;
if (!a_movie->GetVariable(&pauseMode, "PauseMode") || !pauseMode.IsBoolean()) {
return false;
}
return pauseMode.GetBoolean();
}
bool IsMainStateForMenu(RE::MainMenu* a_mainMenu)
{
if (!a_mainMenu || !a_mainMenu->uiMovie) {
return false;
}
Scaleform::GFx::Value state;
if (a_mainMenu->menuObj.IsObject() && a_mainMenu->menuObj.GetMember("currentState", &state)) {
if (state.IsString()) {
const char* currentState = state.GetString();
return currentState == nullptr || std::strcmp(currentState, "MAIN_STATE") == 0;
}
}
if (a_mainMenu->uiMovie->GetVariable(&state, "currentState") && state.IsString()) {
const char* currentState = state.GetString();
return currentState == nullptr || std::strcmp(currentState, "MAIN_STATE") == 0;
}
return true;
}
bool IsTitleScreenMainMenu(RE::StartMenuBase* a_menu)
{
if (!a_menu || !a_menu->uiMovie) {
return false;
}
const auto* ui = RE::UI::GetSingleton();
if (!ui) {
return false;
}
const auto mainMenu = ui->GetMenu<RE::MainMenu>();
if (!mainMenu || mainMenu.get() != a_menu) {
return false;
}
return !IsPauseMode(a_menu->uiMovie.get());
}
bool EntryHasIndex(const Scaleform::GFx::Value& a_entry, std::int32_t a_index)
{
const auto logicalIndex = GetEntryLogicalIndex(a_entry);
return logicalIndex.has_value() && *logicalIndex == a_index;
}
bool EntryListHasIndex(const Scaleform::GFx::Value& a_entryList, std::int32_t a_index)
{
if (!a_entryList.IsArray()) {
return false;
}
const auto size = a_entryList.GetArraySize();
for (std::uint32_t i = 0; i < size; ++i) {
Scaleform::GFx::Value entry;
if (a_entryList.GetElement(i, &entry) && EntryHasIndex(entry, a_index)) {
return true;
}
}
return false;
}
std::optional<std::uint32_t> FindEntryArrayIndex(const Scaleform::GFx::Value& a_entryList, std::int32_t a_index)
{
if (!a_entryList.IsArray()) {
return std::nullopt;
}
const auto size = a_entryList.GetArraySize();
for (std::uint32_t i = 0; i < size; ++i) {
Scaleform::GFx::Value entry;
if (a_entryList.GetElement(i, &entry) && EntryHasIndex(entry, a_index)) {
return i;
}
}
return std::nullopt;
}
bool ResolveMainList(
RE::MainMenu* a_mainMenu,
Scaleform::GFx::Value& a_outListWidget,
Scaleform::GFx::Value& a_outEntryList)
{
if (!a_mainMenu || !a_mainMenu->uiMovie) {
return false;
}
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() || entryList.IsObject())) {
a_outListWidget = listWidget;
a_outEntryList = entryList;
return true;
}
}
}
}
if (!a_mainMenu->uiMovie->GetVariable(&a_outListWidget, kListWidgetPath) || !a_outListWidget.IsObject()) {
return false;
}
if (!a_outListWidget.GetMember("entryList", &a_outEntryList) &&
!a_mainMenu->uiMovie->GetVariable(&a_outEntryList, kEntryListPath)) {
return false;
}
return a_outEntryList.IsArray() || a_outEntryList.IsObject();
}
bool InsertEntryAfterIndex(
Scaleform::GFx::Movie* a_movie,
Scaleform::GFx::Value& a_entryList,
std::int32_t a_afterLogicalIndex,
const Scaleform::GFx::Value& a_newEntry)
{
const auto insertAt = FindEntryArrayIndex(a_entryList, a_afterLogicalIndex);
if (!insertAt.has_value()) {
return false;
}
const auto targetIndex = *insertAt + 1;
const auto oldSize = a_entryList.GetArraySize();
for (std::uint32_t i = oldSize; i > targetIndex; --i) {
Scaleform::GFx::Value entry;
if (!a_entryList.GetElement(i - 1, &entry)) {
return false;
}
if (!a_entryList.SetElement(i, entry)) {
return false;
}
}
(void)a_movie;
return a_entryList.SetElement(targetIndex, a_newEntry);
}
bool InjectMultiplayerRow(RE::MainMenu* a_mainMenu)
{
if (!a_mainMenu || !a_mainMenu->uiMovie || IsPauseMode(a_mainMenu->uiMovie.get())) {
LogInjectFailure("Injection skipped: MainMenu movie unavailable or pause mode active.");
return false;
}
Scaleform::GFx::Value listWidget;
Scaleform::GFx::Value entryList;
if (!ResolveMainList(a_mainMenu, listWidget, entryList)) {
LogInjectFailure("Injection skipped: could not resolve MainPanel_mc.List_mc.entryList.");
return false;
}
if (!entryList.IsArray()) {
LogInjectFailure("Injection skipped: entryList is not a GFx array.");
return false;
}
if (EntryListHasIndex(entryList, kMultiplayerIndex)) {
return true;
}
Scaleform::GFx::Value newEntry;
a_mainMenu->uiMovie->CreateObject(&newEntry);
if (!newEntry.IsObject()) {
LogInjectFailure("Injection skipped: failed to create GFx entry object.");
return false;
}
Scaleform::GFx::Value textValue{ "MULTIPLAYER" };
Scaleform::GFx::Value indexValue{ kMultiplayerIndex };
if (!newEntry.SetMember("text", textValue) || !newEntry.SetMember("index", indexValue)) {
LogInjectFailure("Injection skipped: failed to populate Multiplayer entry members.");
return false;
}
if (!InsertEntryAfterIndex(a_mainMenu->uiMovie.get(), entryList, kLoadIndex, newEntry)) {
LogInjectFailure(std::format(
"Injection skipped: could not find Load row (logical index {}).",
kLoadIndex));
return false;
}
listWidget.Invoke("InvalidateData");
LogInfo("Injected Multiplayer row after Load.");
return true;
}
void TryInjectForStartMenu(RE::StartMenuBase* a_menu)
{
if (!IsTitleScreenMainMenu(a_menu)) {
return;
}
InjectMultiplayerRow(static_cast<RE::MainMenu*>(a_menu));
}
void RegisterItemPressListener(RE::MainMenu* a_mainMenu);
void TryInjectOpenMainMenu()
{
auto* ui = RE::UI::GetSingleton();
if (!ui) {
return;
}
const auto mainMenu = ui->GetMenu<RE::MainMenu>();
if (!mainMenu || !mainMenu->uiMovie) {
return;
}
if (!ui->GetMenuOpen<RE::MainMenu>() && !mainMenu->menuCanBeVisible) {
return;
}
LogInfo("Running catch-up injection for already-open MainMenu.");
RegisterItemPressListener(mainMenu.get());
InjectMultiplayerRow(mainMenu.get());
}
using InitMainListFn = void (*)(RE::StartMenuBase*);
InitMainListFn g_originalInitMainList = nullptr;
void InitMainListHook(RE::StartMenuBase* a_this)
{
if (g_originalInitMainList) {
g_originalInitMainList(a_this);
}
if (!a_this) {
return;
}
if (!a_this->GetIsMenuReady()) {
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
taskInterface->AddTask([a_this]() {
TryInjectForStartMenu(a_this);
});
}
return;
}
TryInjectForStartMenu(a_this);
}
void OpenMultiplayerMenu(RE::MainMenu* a_mainMenu)
{
if (a_mainMenu && a_mainMenu->uiMovie) {
Scaleform::GFx::Value bgsCode;
if (a_mainMenu->menuObj.IsObject() &&
a_mainMenu->menuObj.GetMember("BGSCodeObj", &bgsCode) &&
bgsCode.IsObject()) {
bgsCode.Invoke("PlayOKSound");
} else if (a_mainMenu->uiMovie->GetVariable(&bgsCode, "BGSCodeObj") && bgsCode.IsObject()) {
bgsCode.Invoke("PlayOKSound");
}
}
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
taskInterface->AddTask([]() {
F4T::PrismaUI::ShowServerBrowser();
});
return;
}
F4T::PrismaUI::ShowServerBrowser();
}
bool IsMultiplayerRowSelected(RE::MainMenu* a_mainMenu)
{
if (!a_mainMenu || !a_mainMenu->uiMovie || IsPauseMode(a_mainMenu->uiMovie.get())) {
return false;
}
if (!IsMainStateForMenu(a_mainMenu)) {
return false;
}
Scaleform::GFx::Value listWidget;
Scaleform::GFx::Value entryList;
if (!ResolveMainList(a_mainMenu, listWidget, entryList)) {
return false;
}
Scaleform::GFx::Value selectedEntry;
if (!listWidget.GetMember("selectedEntry", &selectedEntry) || !selectedEntry.IsObject()) {
return false;
}
const auto selectedIndex = GetEntryLogicalIndex(selectedEntry);
return selectedIndex.has_value() && *selectedIndex == kMultiplayerIndex;
}
bool TryHandleMultiplayerSelection(RE::MainMenu* a_mainMenu)
{
if (!IsMultiplayerRowSelected(a_mainMenu)) {
return false;
}
OpenMultiplayerMenu(a_mainMenu);
LogInfo("Opened multiplayer menu from MainMenu selection.");
return true;
}
std::optional<std::string> TryReadClassItemPressEvent(
Scaleform::GFx::Movie* a_movie,
const char* a_className)
{
if (!a_movie || a_className == nullptr) {
return std::nullopt;
}
const std::array<Scaleform::GFx::Value, 1> classNameArg{ Scaleform::GFx::Value{ a_className } };
Scaleform::GFx::Value classDef;
if (!a_movie->Invoke(
"flash.utils.getDefinitionByName",
&classDef,
classNameArg.data(),
static_cast<std::uint32_t>(classNameArg.size())) ||
!classDef.IsObject()) {
return std::nullopt;
}
Scaleform::GFx::Value itemPressEvent;
if (!classDef.GetMember("ITEM_PRESS", &itemPressEvent) || !itemPressEvent.IsString()) {
return std::nullopt;
}
const char* eventName = itemPressEvent.GetString();
if (eventName == nullptr || *eventName == '\0') {
return std::nullopt;
}
return std::string{ eventName };
}
std::vector<std::string> ResolveItemPressEventNames(Scaleform::GFx::Movie* a_movie)
{
std::vector<std::string> eventNames;
eventNames.reserve(kItemPressEventCandidates.size() + 1);
const std::array<const char*, 2> kClassNames{
"Shared.AS3.BSScrollingList",
"BSScrollingList"
};
for (const auto* className : kClassNames) {
if (const auto resolved = TryReadClassItemPressEvent(a_movie, className)) {
if (std::find(eventNames.begin(), eventNames.end(), *resolved) == eventNames.end()) {
eventNames.push_back(*resolved);
}
}
}
Scaleform::GFx::Value itemPressEvent;
if (a_movie &&
a_movie->GetVariable(&itemPressEvent, "Shared.AS3.BSScrollingList.ITEM_PRESS") &&
itemPressEvent.IsString() &&
itemPressEvent.GetString() != nullptr) {
const auto variableName = std::string{ itemPressEvent.GetString() };
if (std::find(eventNames.begin(), eventNames.end(), variableName) == eventNames.end()) {
eventNames.push_back(variableName);
}
}
for (const auto candidate : kItemPressEventCandidates) {
const auto candidateName = std::string{ candidate };
if (std::find(eventNames.begin(), eventNames.end(), candidateName) == eventNames.end()) {
eventNames.push_back(candidateName);
}
}
return eventNames;
}
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);
if (!mainMenu || !mainMenu->uiMovie) {
return;
}
Scaleform::GFx::Value target;
if (!a_params.args[0].GetMember("target", &target) || !target.IsObject()) {
return;
}
Scaleform::GFx::Value selectedEntry;
if (!target.GetMember("selectedEntry", &selectedEntry) || !selectedEntry.IsObject()) {
return;
}
const auto selectedIndex = GetEntryLogicalIndex(selectedEntry);
if (!selectedIndex.has_value() || *selectedIndex != kMultiplayerIndex) {
return;
}
OpenMultiplayerMenu(mainMenu);
LogInfo("Opened multiplayer menu from ITEM_PRESS handler.");
a_params.args[0].Invoke("stopImmediatePropagation");
}
};
ItemPressHandler g_itemPressHandler;
void RegisterItemPressListener(RE::MainMenu* a_mainMenu)
{
if (!a_mainMenu || !a_mainMenu->uiMovie || g_itemPressListenerRegistered) {
return;
}
if (!a_mainMenu->menuObj.IsObject()) {
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
taskInterface->AddTask([a_mainMenu]() {
RegisterItemPressListener(a_mainMenu);
});
}
return;
}
Scaleform::GFx::Value listWidget;
Scaleform::GFx::Value entryList;
if (!ResolveMainList(a_mainMenu, listWidget, entryList)) {
return;
}
const auto eventNames = ResolveItemPressEventNames(a_mainMenu->uiMovie.get());
Scaleform::GFx::Value handler;
a_mainMenu->uiMovie->CreateFunction(&handler, &g_itemPressHandler, a_mainMenu);
std::size_t registeredListeners = 0;
for (const auto& eventName : eventNames) {
const std::array<Scaleform::GFx::Value, 4> args{
Scaleform::GFx::Value{ eventName.c_str() },
handler,
Scaleform::GFx::Value{ true },
Scaleform::GFx::Value{ kItemPressListenerPriority }
};
if (a_mainMenu->menuObj.Invoke("addEventListener", nullptr, args.data(), args.size())) {
++registeredListeners;
}
if (listWidget.Invoke("addEventListener", nullptr, args.data(), args.size())) {
++registeredListeners;
}
}
if (registeredListeners == 0) {
LogWarning("Failed to register ITEM_PRESS listeners on MainMenu.");
return;
}
g_itemPressListenerRegistered = true;
LogInfo(std::format(
"Registered {} ITEM_PRESS listener(s); candidates: {}",
registeredListeners,
eventNames.size()));
}
using OnButtonEventFn = void (*)(RE::IMenu*, const RE::ButtonEvent*);
OnButtonEventFn g_originalOnButtonEvent = nullptr;
void OnButtonEventHook(RE::IMenu* a_this, const RE::ButtonEvent* a_event)
{
if (a_this && a_event && a_event->QJustPressed() && !a_event->QDisabled()) {
const auto& userEvent = a_event->QUserEvent();
if (userEvent == "Accept"sv) {
const auto* ui = RE::UI::GetSingleton();
const auto mainMenu = ui ? ui->GetMenu<RE::MainMenu>() : nullptr;
if (mainMenu && mainMenu.get() == a_this && TryHandleMultiplayerSelection(mainMenu.get())) {
return;
}
}
}
if (g_originalOnButtonEvent) {
g_originalOnButtonEvent(a_this, a_event);
}
}
void PollMultiplayerActivation()
{
const auto* ui = RE::UI::GetSingleton();
if (!ui || !ui->GetMenuOpen<RE::MainMenu>() || !IsMainMenuOnMainPanel(ui)) {
return;
}
const auto mainMenu = ui->GetMenu<RE::MainMenu>();
if (!mainMenu || !IsMultiplayerRowSelected(mainMenu.get())) {
return;
}
static bool lastLeftButtonDown = false;
static bool lastAcceptDown = false;
static auto lastActivationTime = std::chrono::steady_clock::time_point{};
const auto leftButtonDown = (GetAsyncKeyState(VK_LBUTTON) & 0x8000) != 0;
const auto acceptDown = (GetAsyncKeyState(VK_RETURN) & 0x8000) != 0;
const auto leftButtonPressed = leftButtonDown && !lastLeftButtonDown;
const auto acceptPressed = acceptDown && !lastAcceptDown;
lastLeftButtonDown = leftButtonDown;
lastAcceptDown = acceptDown;
if (!leftButtonPressed && !acceptPressed) {
return;
}
const auto now = std::chrono::steady_clock::now();
if (lastActivationTime.time_since_epoch().count() != 0 && now - lastActivationTime < 300ms) {
return;
}
lastActivationTime = now;
OpenMultiplayerMenu(mainMenu.get());
LogInfo("Opened multiplayer menu from activation poll.");
}
void ResyncMultiplayerRow()
{
if (++g_resyncFrameCounter < kResyncThrottleFrames) {
return;
}
g_resyncFrameCounter = 0;
const auto* ui = RE::UI::GetSingleton();
if (!ui || !ui->GetMenuOpen<RE::MainMenu>() || !IsMainMenuOnMainPanel(ui)) {
return;
}
const auto mainMenu = ui->GetMenu<RE::MainMenu>();
if (!mainMenu || !mainMenu->uiMovie || IsPauseMode(mainMenu->uiMovie.get())) {
return;
}
Scaleform::GFx::Value listWidget;
Scaleform::GFx::Value entryList;
if (!ResolveMainList(mainMenu.get(), listWidget, entryList) || !entryList.IsArray()) {
return;
}
if (!EntryListHasIndex(entryList, kMultiplayerIndex)) {
InjectMultiplayerRow(mainMenu.get());
}
if (!g_itemPressListenerRegistered) {
RegisterItemPressListener(mainMenu.get());
}
}
class MainMenuOpenCloseSink :
public RE::BSTEventSink<RE::MenuOpenCloseEvent>
{
public:
RE::BSEventNotifyControl ProcessEvent(
const RE::MenuOpenCloseEvent& a_event,
RE::BSTEventSource<RE::MenuOpenCloseEvent>* a_source) override
{
(void)a_source;
if (a_event.menuName != RE::MainMenu::MENU_NAME) {
return RE::BSEventNotifyControl::kContinue;
}
if (a_event.opening) {
const auto* ui = RE::UI::GetSingleton();
if (!ui) {
return RE::BSEventNotifyControl::kContinue;
}
const auto mainMenu = ui->GetMenu<RE::MainMenu>();
if (!mainMenu) {
return RE::BSEventNotifyControl::kContinue;
}
g_itemPressListenerRegistered = false;
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
taskInterface->AddTask([mainMenu = mainMenu.get()]() {
RegisterItemPressListener(mainMenu);
TryInjectForStartMenu(mainMenu);
});
}
} else {
g_itemPressListenerRegistered = false;
}
return RE::BSEventNotifyControl::kContinue;
}
};
MainMenuOpenCloseSink g_mainMenuOpenCloseSink;
}
bool IsMainMenuOnMainPanel(const RE::UI* a_ui)
{
if (!a_ui) {
return false;
}
const auto mainMenu = a_ui->GetMenu<RE::MainMenu>();
if (!mainMenu || !mainMenu->uiMovie) {
return false;
}
return IsMainStateForMenu(mainMenu.get());
}
void Install()
{
if (g_hooksInstalled) {
return;
}
REL::Relocation<std::uintptr_t> startMenuVtable{ RE::StartMenuBase::VTABLE[0] };
g_originalInitMainList = reinterpret_cast<InitMainListFn>(
startMenuVtable.write_vfunc(0x15, &InitMainListHook));
if (!g_originalInitMainList) {
LogWarning("InitMainList vtable hook failed to install.");
return;
}
g_originalOnButtonEvent = reinterpret_cast<OnButtonEventFn>(
startMenuVtable.write_vfunc(kOnButtonEventVtableIndex, &OnButtonEventHook));
g_buttonEventHookInstalled = g_originalOnButtonEvent != nullptr;
if (!g_buttonEventHookInstalled) {
LogWarning("StartMenuBase::OnButtonEvent hook failed to install; Accept on MULTIPLAYER may not open the browser.");
}
auto* ui = RE::UI::GetSingleton();
if (ui) {
ui->RegisterSink(std::addressof(g_mainMenuOpenCloseSink));
} else {
LogWarning("UI singleton unavailable; MainMenu open/close listener not registered.");
}
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
if (!g_resyncTaskStarted) {
taskInterface->AddTaskPermanent([]() {
PollMultiplayerActivation();
ResyncMultiplayerRow();
});
g_resyncTaskStarted = true;
}
taskInterface->AddTask([]() {
TryInjectOpenMainMenu();
});
}
g_hooksInstalled = true;
LogInfo("InitMainList hook and MainMenu injection tasks installed.");
}
}
+4 -21
View File
@@ -1,5 +1,6 @@
#include "pch.h" #include "pch.h"
#include "F4TMainMenuInject.h"
#include "F4TMenuInput.h" #include "F4TMenuInput.h"
#include "F4TNetworking.h" #include "F4TNetworking.h"
#include "F4TPrismaUI.h" #include "F4TPrismaUI.h"
@@ -94,26 +95,6 @@ namespace F4T::PrismaUI
RequestNativeCursorRestore(); RequestNativeCursorRestore();
} }
bool IsMainMenuOnMainPanel(const RE::UI* a_ui)
{
if (!a_ui) {
return false;
}
const auto mainMenu = a_ui->GetMenu<RE::MainMenu>();
if (!mainMenu || !mainMenu->uiMovie) {
return false;
}
Scaleform::GFx::Value state;
if (!mainMenu->uiMovie->GetVariable(&state, "currentState") || !state.IsString()) {
return true;
}
const char* currentState = state.GetString();
return currentState == nullptr || std::strcmp(currentState, "MAIN_STATE") == 0;
}
void WatchMainMenuState() void WatchMainMenuState()
{ {
if (!g_visible) { if (!g_visible) {
@@ -121,7 +102,7 @@ namespace F4T::PrismaUI
} }
const auto* ui = RE::UI::GetSingleton(); const auto* ui = RE::UI::GetSingleton();
if (!ui || !ui->GetMenuOpen<RE::MainMenu>() || !IsMainMenuOnMainPanel(ui)) { if (!ui || !ui->GetMenuOpen<RE::MainMenu>() || !F4T::MainMenuInject::IsMainMenuOnMainPanel(ui)) {
HideServerBrowserInternal(); HideServerBrowserInternal();
} }
} }
@@ -306,6 +287,8 @@ namespace F4T::PrismaUI
void OnGameDataReady() void OnGameDataReady()
{ {
F4T::MainMenuInject::Install();
g_api = PRISMA_UI_API::RequestPluginAPI<PRISMA_UI_API::IVPrismaUI4>(); g_api = PRISMA_UI_API::RequestPluginAPI<PRISMA_UI_API::IVPrismaUI4>();
if (!g_api) { if (!g_api) {
LogWarning( LogWarning(
+2 -1
View File
@@ -27,4 +27,5 @@ Write-Host " $repoSwf"
Write-Host " $deploySwf" Write-Host " $deploySwf"
Write-Host "" Write-Host ""
Write-Host "Settings -> Gameplay/Display/Audio should work again." Write-Host "Settings -> Gameplay/Display/Audio should work again."
Write-Host "Re-add the Multiplayer button with surgical FFDec edits only (see Interface/exported/PATCH_MainMenu_Multiplayer.md)." Write-Host "Do not deploy a patched MainMenu.swf. Multiplayer is injected at runtime by CommonwealthOnline.dll."
Write-Host "See docs/MainMenu_Injection.md for setup and testing."