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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace RE
|
||||||
|
{
|
||||||
|
class UI;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace F4T::MainMenuInject
|
||||||
|
{
|
||||||
|
bool IsMainMenuOnMainPanel(const RE::UI* a_ui);
|
||||||
|
void Install();
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user