initial commit; cv 0.0.3

This commit is contained in:
2026-09-07 19:33:58 -04:00
commit abf065ddde
63 changed files with 8816 additions and 0 deletions

49
Static/ClassCache.hpp Normal file
View File

@@ -0,0 +1,49 @@
#pragma once
#include <vector>
#include <DynamicOutput/DynamicOutput.hpp>
#include <Unreal/UObject.hpp>
namespace PalUIExtension::Static
{
using namespace RC;
using namespace RC::Unreal;
class ClassCache
{
public:
// Registers a slot to be nulled on Flush. The pointer must outlive the process -- every caller is a static, so this holds.
static auto RegisterSlot(UObject** Slot) -> void
{
if (!Slot) return;
s_slots.push_back(Slot);
}
static auto RegisterSlot(UClass** Slot) -> void
{
if (!Slot) return;
s_class_slots.push_back(Slot);
}
// Called on EVERY world teardown, before anything else runs.
static auto Flush() -> void
{
for (UObject** Slot : s_slots) { if (Slot) *Slot = nullptr; }
for (UClass** Slot : s_class_slots){ if (Slot) *Slot = nullptr; }
Output::send<LogLevel::Verbose>(
STR("[ClassCache] flushed {} object slots and {} class slots\n"),
static_cast<int32>(s_slots.size()),
static_cast<int32>(s_class_slots.size()));
}
static auto SlotCount() -> size_t { return s_slots.size() + s_class_slots.size(); }
private:
inline static std::vector<UObject**> s_slots{};
inline static std::vector<UClass**> s_class_slots{};
};
}

View File

@@ -0,0 +1,278 @@
#pragma once
#include <vector>
#include <string>
#include <cstdint>
#include <DynamicOutput/DynamicOutput.hpp>
#include <Unreal/Hooks.hpp>
#include <Unreal/UObjectGlobals.hpp>
#include <Unreal/UObject.hpp>
#include <Unreal/UClass.hpp>
#include <Unreal/UFunction.hpp>
namespace PalUIExtension::Static
{
using namespace RC;
using namespace RC::Unreal;
// Registry + deferred installer.
//
// Register() resolves the target UFunction immediately if it is already loaded. If it isn't, the request is parked in s_pending and a single
// ProcessEvent pre-callback watches for a call on a matching <class name, function name> pair, then installs the real hook.
//
// NOT THREAD SAFE.
class EventHookRegistry
{
public:
using StringType = std::basic_string<TCHAR>;
using HookId = uint32_t;
static constexpr HookId InvalidHook = 0;
private:
struct HookRequest
{
HookId Id{};
StringType Name; // owned, not a borrowed const TCHAR*
StringType FunctionPath; // owned
FName ClassName{}; // e.g. WBP_PalStatus_C
FName FunctionName{}; // e.g. Construct
UnrealScriptFunctionCallable Pre{};
UnrealScriptFunctionCallable Post{};
void* CustomData{};
};
struct InstalledHook
{
HookId Id{};
StringType Name;
UFunction* Function{};
int PreId{};
int PostId{};
};
inline static std::vector<HookRequest> s_pending{};
inline static std::vector<InstalledHook> s_installed{};
inline static HookId s_next_id{ 1 };
inline static bool s_watcher_installed{ false };
inline static bool s_shutdown{ false };
public:
static void Initialize(size_t expected_hooks = 16)
{
s_shutdown = false;
s_pending.reserve(expected_hooks);
s_installed.reserve(expected_hooks);
}
static HookId Register(
const TCHAR* HookName,
const TCHAR* FunctionPath,
UnrealScriptFunctionCallable Pre = [](UnrealScriptFunctionCallableContext&, void*) {},
UnrealScriptFunctionCallable Post = [](UnrealScriptFunctionCallableContext&, void*) {},
void* CustomData = nullptr)
{
if (!HookName || !FunctionPath)
{
Output::send<LogLevel::Error>(STR("[EventHookRegistry] null HookName or FunctionPath\n"));
return InvalidHook;
}
HookRequest Req{};
Req.Id = s_next_id++;
Req.Name = HookName;
Req.FunctionPath = FunctionPath;
Req.Pre = Pre;
Req.Post = Post;
Req.CustomData = CustomData;
StringType ClassPart, FuncPart;
if (!SplitFunctionPath(Req.FunctionPath, ClassPart, FuncPart))
{
Output::send<LogLevel::Error>(
STR("[EventHookRegistry] '{}': cannot parse '{}' -- expected ")
STR("/Path/To/Package.ClassName:FunctionName\n"), HookName, FunctionPath);
return InvalidHook;
}
Req.ClassName = FName(ClassPart.c_str(), FNAME_Add);
Req.FunctionName = FName(FuncPart.c_str(), FNAME_Add);
// Fast path: already loaded, install now, never touch ProcessEvent.
if (UFunction* Fn = UObjectGlobals::StaticFindObject<UFunction*>(nullptr, nullptr, Req.FunctionPath.c_str()))
{
Install(Req, Fn);
return Req.Id;
}
Output::send<LogLevel::Verbose>(
STR("[EventHookRegistry] '{}': '{}' not loaded yet, deferring\n"), HookName, FunctionPath);
s_pending.push_back(std::move(Req));
EnsureWatcher();
return s_next_id - 1;
}
static bool Deregister(const TCHAR* HookName)
{
if (!HookName) return false;
const StringType Name{ HookName };
for (size_t i = 0; i < s_installed.size(); ++i)
{
if (s_installed[i].Name != Name) continue;
Uninstall(s_installed[i]);
s_installed.erase(s_installed.begin() + i);
return true;
}
for (size_t i = 0; i < s_pending.size(); ++i)
{
if (s_pending[i].Name != Name) continue;
s_pending.erase(s_pending.begin() + i);
return true;
}
Output::send<LogLevel::Warning>(
STR("[EventHookRegistry] Deregister: no hook named '{}'\n"), HookName);
return false;
}
static bool Deregister(HookId Id)
{
for (size_t i = 0; i < s_installed.size(); ++i)
{
if (s_installed[i].Id != Id) continue;
Uninstall(s_installed[i]);
s_installed.erase(s_installed.begin() + i);
return true;
}
for (size_t i = 0; i < s_pending.size(); ++i)
{
if (s_pending[i].Id != Id) continue;
s_pending.erase(s_pending.begin() + i);
return true;
}
return false;
}
static bool IsInstalled(HookId Id)
{
for (const auto& H : s_installed) if (H.Id == Id) return true;
return false;
}
static size_t PendingCount() { return s_pending.size(); }
// The ProcessEvent callback cannot be unregistered in UE4SS, insteasd it drops all state and sets s_shutdown; the watcher
// then costs one bool test per ProcessEvent for the rest of the process.
static void Cleanup()
{
for (auto& H : s_installed) Uninstall(H);
s_installed.clear();
s_pending.clear();
s_shutdown = true;
}
private:
static void EnsureWatcher()
{
if (s_watcher_installed) return;
Hook::RegisterProcessEventPreCallback(&EventHookRegistry::OnProcessEvent);
s_watcher_installed = true;
Output::send<LogLevel::Verbose>(STR("[EventHookRegistry] ProcessEvent watcher installed\n"));
}
// HOT PATH. This runs for effectively every UFunction call in the game.
// Every line before the loop is there to make the common case a load,
// a branch, and a return.
static void OnProcessEvent(UObject* Context, UFunction* Function, void*)
{
if (s_shutdown || s_pending.empty()) return;
if (!Context || !Function) return;
UClass* Cls = Context->GetClassPrivate();
if (!Cls) return;
const FName ClsName = Cls->GetNamePrivate();
// Backwards so erase() doesn't disturb indices we haven't visited.
for (size_t i = s_pending.size(); i-- > 0;)
{
HookRequest& Req = s_pending[i];
if (Req.ClassName != ClsName) continue;
UFunction* Target = UObjectGlobals::StaticFindObject<UFunction*>(nullptr, nullptr, Req.FunctionPath.c_str());
if (!Target)
{
if (Req.FunctionName != Function->GetNamePrivate()) continue;
Output::send<LogLevel::Warning>(
STR("[EventHookRegistry] '{}': '{}' still will not resolve by path; ")
STR("falling back to the dispatched UFunction {}. If your hook fires on the ")
STR("wrong object, your path string is wrong.\n"),
Req.Name, Req.FunctionPath, Function->GetFullName());
Target = Function;
}
HookRequest Owned = std::move(Req);
s_pending.erase(s_pending.begin() + i);
Install(Owned, Target);
}
}
static void Install(HookRequest& Req, UFunction* Fn)
{
if (!Fn) return;
for (const auto& H : s_installed)
{
if (H.Function != Fn) continue;
Output::send<LogLevel::Warning>(
STR("[EventHookRegistry] '{}': {} is already hooked by '{}'. ")
STR("Installing anyway -- both callbacks will fire.\n"),
Req.Name, Fn->GetFullName(), H.Name);
break;
}
auto _pre = Req.Pre;
if (!_pre) _pre = [](UnrealScriptFunctionCallableContext&, void*) {};
auto _post = Req.Post;
if (!_post) _post = [](UnrealScriptFunctionCallableContext&, void*) {};
auto [PreId, PostId] = UObjectGlobals::RegisterHook(Fn, _pre, _post, Req.CustomData);
s_installed.push_back(InstalledHook{ Req.Id, Req.Name, Fn, PreId, PostId });
Output::send<LogLevel::Verbose>(
STR("[EventHookRegistry] '{}': installed on {} (pre={}, post={})\n"),
Req.Name, Fn->GetFullName(), PreId, PostId);
}
static void Uninstall(InstalledHook& H)
{
if (!H.Function) return;
UObjectGlobals::UnregisterHook(H.Function, std::make_tuple(H.PreId, H.PostId));
H.Function = nullptr;
}
// "/Game/Pal/Blueprint/UI/PalStatus/WBP_PalStatus.WBP_PalStatus_C:Construct"
// -> ClassName "WBP_PalStatus_C", FunctionName "Construct"
static bool SplitFunctionPath(const StringType& Path, StringType& OutClass, StringType& OutFunction)
{
const auto Colon = Path.rfind(STR(':'));
if (Colon == StringType::npos || Colon + 1 >= Path.size()) return false;
OutFunction = Path.substr(Colon + 1);
const StringType ObjectPart = Path.substr(0, Colon);
const auto Dot = ObjectPart.rfind(STR('.'));
OutClass = (Dot == StringType::npos) ? ObjectPart : ObjectPart.substr(Dot + 1);
return !OutClass.empty() && !OutFunction.empty();
}
};
}

9
Static/LogGate.hpp Normal file
View File

@@ -0,0 +1,9 @@
#pragma once
namespace PalUIExtension::Static
{
inline constexpr bool kVerboseLogging = false;
inline constexpr bool kProfileHotPath = true;
}

139
Static/WidgetRegistry.hpp Normal file
View File

@@ -0,0 +1,139 @@
#pragma once
#include <string>
#include <unordered_map>
#include <cstdint>
#include <DynamicOutput/DynamicOutput.hpp>
#include <Unreal/UObject.hpp>
#include <Api/Widget.hpp>
namespace PalUIExtension::Static
{
using namespace RC;
using namespace RC::Unreal;
using Api::Widget;
class WidgetRegistry
{
public:
using Generation = uint32_t;
using KeyType = std::basic_string<TCHAR>;
// ---- generation control --------------------------------------
// Advances the generation and drops every existing entry.
static auto BeginGeneration() -> Generation
{
++s_generation;
s_entries.clear();
Output::send<LogLevel::Verbose>(STR("[WidgetRegistry] generation advanced to {}\n"), s_generation);
return s_generation;
}
// Calls Widget::RemoveFromParent on every entry still in the
// current generation, THEN advances the generation.
//
// If an entry's pointer is already stale, this will call RemoveFromParent on whatever
// object now lives at that address. That is the one real cost of the gap this class cannot close. Keep your generations short.
static auto TeardownGeneration() -> void
{
for (auto& [Key, E] : s_entries)
{
if (E.Gen != s_generation || !E.Obj) continue;
Widget W{ E.Obj };
if (W) W.RemoveFromParent();
}
BeginGeneration();
}
static auto CurrentGeneration() -> Generation { return s_generation; }
// ---- registration -----------------------------------------------
static auto Register(const KeyType& Key, UObject* Obj) -> bool
{
if (!Obj){
Output::send<LogLevel::Warning>(STR("[WidgetRegistry] Register('{}'): refusing a null object\n"), Key.c_str());
return false;
}
if (s_entries.find(Key) != s_entries.end())
{
Output::send<LogLevel::Verbose>(STR("[WidgetRegistry] Register('{}'): overwriting existing entry\n"), Key.c_str());
}
s_entries[Key] = Entry{ Obj, s_generation };
return true;
}
static auto Register(const TCHAR* Key, UObject* Obj) -> bool
{
return Register(KeyType{ Key }, Obj);
}
static auto Unregister(const KeyType& Key) -> bool
{
return s_entries.erase(Key) > 0;
}
// ---- lookup -----------------------------------------------------
// Returns nullptr if the key is missing OR if the stored entry predates the current generation.
// A stale generation is treated identically to "never registered," and the dead entry is erased
// on the way out so it doesn't accumulate.
static auto GetObject(const KeyType& Key) -> UObject*
{
auto It = s_entries.find(Key);
if (It == s_entries.end()) return nullptr;
if (It->second.Gen != s_generation)
{
Output::send<LogLevel::Verbose>(STR("[WidgetRegistry] GetObject('{}'): stale generation ({} vs current {}), dropping\n"), Key.c_str(), It->second.Gen, s_generation);
s_entries.erase(It);
return nullptr;
}
return It->second.Obj;
}
static auto GetObject(const TCHAR* Key) -> UObject*
{
return GetObject(KeyType{ Key });
}
// Typed lookup. T must be one of the wrapper types in this tree (Widget, TextBlock, CanvasPanel, PanelWidget, ...) exposing
// ClassOf<T>() -- the same requirement UnrealWrapper's derived types already have. Constructing T{Obj} runs T's own IsA()
// check, so a cross-class stale pointer comes back as an invalid wrapper (IsValid() == false) instead of a wrong-typed one.
template <typename T>
static auto Get(const KeyType& Key) -> T
{
UObject* Obj = GetObject(Key);
return Obj ? T{ Obj } : T{};
}
template <typename T>
static auto Get(const TCHAR* Key) -> T
{
return Get<T>(KeyType{ Key });
}
static auto Contains(const KeyType& Key) -> bool
{
return GetObject(Key) != nullptr;
}
static auto Count() -> size_t { return s_entries.size(); }
// Drops everything without touching the Slate tree. Same caveat as BeginGeneration(): this does not remove widgets from their parents, it just makes the registry stop tracking them.
static auto Clear() -> void { s_entries.clear(); }
private:
struct Entry { UObject* Obj{}; Generation Gen{}; };
inline static std::unordered_map<KeyType, Entry> s_entries{};
inline static Generation s_generation{ 1 };
};
}