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:
@@ -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 "F4TMainMenuInject.h"
|
||||
#include "F4TMenuInput.h"
|
||||
#include "F4TNetworking.h"
|
||||
#include "F4TPrismaUI.h"
|
||||
@@ -94,26 +95,6 @@ namespace F4T::PrismaUI
|
||||
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()
|
||||
{
|
||||
if (!g_visible) {
|
||||
@@ -121,7 +102,7 @@ namespace F4T::PrismaUI
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -306,6 +287,8 @@ namespace F4T::PrismaUI
|
||||
|
||||
void OnGameDataReady()
|
||||
{
|
||||
F4T::MainMenuInject::Install();
|
||||
|
||||
g_api = PRISMA_UI_API::RequestPluginAPI<PRISMA_UI_API::IVPrismaUI4>();
|
||||
if (!g_api) {
|
||||
LogWarning(
|
||||
|
||||
Reference in New Issue
Block a user