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

476
Api/UnrealWrapper.hpp Normal file
View File

@@ -0,0 +1,476 @@
#pragma once
#include <optional>
#include <type_traits>
#include <DynamicOutput/DynamicOutput.hpp>
#include <Unreal/UObjectGlobals.hpp>
#include <Unreal/UObject.hpp>
#include <Unreal/UClass.hpp>
#include <Unreal/CoreUObject/UObject/Class.hpp>
#include <Unreal/CoreUObject/UObject/UnrealType.hpp>
#include <Reflection/UnrealApiCall.hpp>
#include <Reflection/UnrealObjectRef.hpp>
namespace PalUIExtension::Api
{
using namespace RC;
using namespace RC::Unreal;
namespace Detail
{
struct Tag_Umg {};
// ONE dispatcher per return type, shared by every wrapper in this
// tree -- not one per wrapper class.
//
// The reason this is correct: UnrealApiCall::Invoke resolves the
// function with Target->GetFunctionByNameInChain(), which walks the
// *live object's* class chain. It never touches the ClassPath the
// UnrealApiCall was constructed with. So the class path is only
// used by ResolveClass/FindInstance/ConstructInstance, none of
// which we call here. Giving each wrapper class its own set of
// per-return-type statics would buy nothing but N redundant
// StaticFindObject calls.
//
// The corollary matters for the inheritance below: a base-class
// wrapper calling a function on a derived object works, because
// dispatch is by the object's chain, not by the wrapper's type.
//
// The path below is a placeholder that is never resolved. If you
// ever call ResolveClass on one of these, it will resolve UObject
// and the resulting IsA check will be meaningless.
template <typename R>
inline auto Api() -> Reflection::UnrealApiCall<Tag_Umg, R>&
{
static Reflection::UnrealApiCall<Tag_Umg, R> Instance{
STR("/Script/CoreUObject.Object"), STR("UMG") };
return Instance;
}
}
// Resolves and caches the UClass for a wrapper type T.
// T must expose static ClassPath() and static DebugTag().
template <typename T>
inline auto ClassOf() -> UClass*
{
static Reflection::UnrealObjectRef Ref{ T::ClassPath(), T::DebugTag() };
return static_cast<UClass*>(Ref.Get());
}
// ------------------------------------------------------------------
// Base for every UObject wrapper here.
//
// Lifetime: holds a raw UObject* and does nothing to keep it alive.
// These wrappers are meant to live inside one hook callback. Storing
// one across frames means storing a pointer the GC can invalidate,
// and a freed UObject slot will eventually be reused by a different
// object -- so a stale pointer does not read as null, it reads as
// some other widget.
// ------------------------------------------------------------------
class UnrealWrapper
{
public:
auto IsValid() const -> bool { return m_obj != nullptr; }
explicit operator bool() const { return IsValid(); }
auto Get() const -> UObject* { return m_obj; }
// ------------------------------------------------------------------
// Diagnostics.
//
// These were copy-pasted identically into PalDetailWidget and
// MainMenuPalWidget, differing only in the log prefix -- which
// m_tag already carries. Hoisted here so every wrapper has them and
// there is one copy to fix when the reflection API shifts.
//
// LogFunctionSignature is the tool for settling a frame layout
// without guessing: it prints the engine's own offsets and sizes
// for a UFunction's parameter block, which is exactly what the
// hand-written ParamsStructs in this codebase have to match. Run it
// before trusting a new struct, not after it misbehaves.
// ------------------------------------------------------------------
auto LogFunctionSignature(const TCHAR* FunctionName) const -> void
{
if (!m_obj) return;
UFunction* Fn = m_obj->GetFunctionByNameInChain(FunctionName);
if (!Fn)
{
Output::send<LogLevel::Warning>(
STR("[{}] '{}' is not in the chain of {}\n"),
m_tag, FunctionName, m_obj->GetFullName());
return;
}
Output::send<LogLevel::Warning>(
STR("[{}] {} -- frame is {} bytes:\n"),
m_tag, FunctionName, Fn->GetStructureSize());
for (FProperty* Prop : Fn->ForEachProperty())
{
if (!Prop) continue;
Output::send<LogLevel::Warning>(
STR(" [0x{:X}] {} ({} bytes)\n"),
Prop->GetOffset_Internal(), Prop->GetName(), Prop->GetSize());
}
}
// Every UPROPERTY on the object's class chain, with offset and
// size. The companion to the above for the property side: this is
// how you find out what a widget is actually called internally, or
// whether the field you want is a promoted variable at all.
auto LogProperties() const -> void
{
if (!m_obj) return;
UClass* Cls = m_obj->GetClassPrivate();
if (!Cls)
{
Output::send<LogLevel::Warning>(
STR("[{}] no class on {}\n"), m_tag, m_obj->GetFullName());
return;
}
Output::send<LogLevel::Warning>(
STR("[{}] properties of {}:\n"), m_tag, m_obj->GetFullName());
for (FProperty* Prop : Cls->ForEachPropertyInChain())
{
if (!Prop) continue;
Output::send<LogLevel::Warning>(
STR(" [0x{:X}] {} ({} bytes)\n"),
Prop->GetOffset_Internal(), Prop->GetName(), Prop->GetSize());
}
}
protected:
UnrealWrapper() = default;
UnrealWrapper(UObject* Obj, UClass* Expected, const TCHAR* DebugTag)
: m_tag(DebugTag ? DebugTag : STR("Wrapper"))
{
if (!Obj)
{
Output::send<LogLevel::Warning>(
STR("[{}] constructed from null object\n"), m_tag);
return;
}
if (!Expected)
{
// Class path did not resolve. Accept the object rather than
// reject it -- Invoke dispatches by the object's own chain,
// so calls will still work; you just lost the type check.
Output::send<LogLevel::Warning>(
STR("[{}] class unresolved -- accepting {} unchecked\n"),
m_tag, Obj->GetFullName());
m_obj = Obj;
return;
}
if (!Obj->IsA(Expected))
{
Output::send<LogLevel::Warning>(
STR("[{}] {} is not of the expected class\n"),
m_tag, Obj->GetFullName());
return;
}
m_obj = Obj;
}
struct NoParams {};
// ------------------------------------------------------------------
// Deliberate opt-out of the class check.
//
// The (Obj, Expected, Tag) constructor below treats a null Expected
// as "the ClassPath failed to resolve" and logs a Warning about it,
// which is right for that case and wrong for a caller that never
// had a ClassPath to begin with (LooseObject). The difference
// matters more than it looks: that Warning calls
// Obj->GetFullName(), which walks the whole outer chain calling
// GetName() -- an FName::ToString, i.e. a ProcessEvent -- at every
// level, then formats the line and writes it to every open output
// device, because Output::send has no level filter (see
// Static/LogGate.hpp).
//
// Paid once at startup for a genuinely unresolved class, that is
// fine. Paid on every LooseObject construction on a per-bind path,
// it is a dozen-plus ProcessEvent calls and a disk write per pal
// selection. This tag ctor says "unchecked on purpose" and stays
// silent.
//
// It does NOT weaken anything: LooseObject was already accepting
// the object unchecked. The only thing removed is a diagnostic
// that was describing a condition that wasn't happening.
// ------------------------------------------------------------------
struct UncheckedTag { explicit UncheckedTag() = default; };
UnrealWrapper(UObject* Obj, UncheckedTag, const TCHAR* DebugTag)
: m_obj(Obj), m_tag(DebugTag ? DebugTag : STR("Wrapper"))
{
}
// Void-returning UFUNCTIONs only. Detail::Api<void> will log if the
// engine function actually has a ReturnValue property, which is the
// signal that you meant to use CallForReturn.
template <typename ParamsStruct>
auto Call(const TCHAR* FunctionName, ParamsStruct& Params) const -> bool
{
if (!m_obj)
{
Output::send<LogLevel::Warning>(
STR("[{}] '{}' on an invalid wrapper\n"), m_tag, FunctionName);
return false;
}
return Detail::Api<void>().Invoke(m_obj, FunctionName, Params);
}
auto CallVoid(const TCHAR* FunctionName) const -> bool
{
NoParams p{};
return Call(FunctionName, p);
}
// Zero-parameter getter.
template <typename R>
auto CallForReturn(const TCHAR* FunctionName) const -> std::optional<R>
{
struct Params { R ReturnValue; };
Params p{};
return CallForReturn<Params, R>(FunctionName, p, &Params::ReturnValue);
}
// Getter with parameters. ReturnValue must be the LAST member of
// ParamsStruct, matching the engine's frame layout.
template <typename ParamsStruct, typename R>
auto CallForReturn(const TCHAR* FunctionName, ParamsStruct& Params,
R ParamsStruct::* ReturnMember) const -> std::optional<R>
{
if (!m_obj)
{
Output::send<LogLevel::Warning>(
STR("[{}] '{}' on an invalid wrapper\n"), m_tag, FunctionName);
return std::nullopt;
}
return Detail::Api<R>().InvokeForReturn(m_obj, FunctionName, Params, ReturnMember);
}
// ------------------------------------------------------------------
// Direct UPROPERTY access.
//
// Cheaper than ProcessEvent and the only option for properties with
// no UFUNCTION accessor (UPanelSlot::Parent and ::Content, for
// instance -- that class exposes zero UFUNCTIONs).
//
// Two things this does NOT handle:
// 1. Bitfield bools (uint8 bFoo : 1). FProperty::GetSize() returns
// 1 for those too, so the size check passes and you read the
// whole byte -- every flag packed into it, not just yours.
// Use the UFUNCTION getter for those. That is why UWidget's
// bIsEnabled/bIsVariable are not exposed here.
// 2. Writes. Poking a property directly skips the setter's
// SynchronizeProperties call, so the Slate widget will not
// update. Read by property, write by UFUNCTION.
//
// UE4SS API name check: GetPropertyByNameInChain and
// GetOffset_Internal are what I expect this version to expose;
// verify against your UObject.hpp / UnrealType.hpp before assuming
// this compiles as written.
// ------------------------------------------------------------------
template <typename T>
auto PropertyPtr(const TCHAR* PropertyName) const -> T*
{
if (!m_obj) return nullptr;
FProperty* Prop = m_obj->GetPropertyByNameInChain(PropertyName);
if (!Prop)
{
Output::send<LogLevel::Warning>(
STR("[{}] property '{}' not found on {}\n"),
m_tag, PropertyName, m_obj->GetFullName());
return nullptr;
}
if (static_cast<size_t>(Prop->GetSize()) != sizeof(T))
{
Output::send<LogLevel::Warning>(
STR("[{}] property '{}' is {} bytes engine-side, read as {} bytes\n"),
m_tag, PropertyName, Prop->GetSize(), sizeof(T));
return nullptr;
}
return reinterpret_cast<T*>(
reinterpret_cast<uint8*>(m_obj) + Prop->GetOffset_Internal());
}
// ------------------------------------------------------------------
// PropertyPtr for the case where ABSENCE IS AN EXPECTED ANSWER, not
// a mistake.
//
// PropertyPtr logs a Warning when the property is missing, and that
// log line calls Obj->GetFullName() -- an outer-chain walk with an
// FName::ToString at every level (see the UncheckedTag note above).
// That is the right behaviour when you are naming a property you
// believe exists and want to hear about it if you are wrong.
//
// It is the wrong behaviour when you are PROBING: "does this widget
// happen to be a UUserWidget, i.e. does it have a WidgetTree?" is a
// question asked once per node during a tree walk, and the answer is
// "no" for almost every node. Logging that is not a diagnostic, it's
// a per-node GetFullName() plus a disk write.
//
// A size mismatch still logs. That one is never an expected answer:
// the property exists but is not the shape you think it is, which is
// the silent-corruption case, not the absent case.
// ------------------------------------------------------------------
template <typename T>
auto TryPropertyPtr(const TCHAR* PropertyName) const -> T*
{
if (!m_obj) return nullptr;
FProperty* Prop = m_obj->GetPropertyByNameInChain(PropertyName);
if (!Prop) return nullptr;
if (static_cast<size_t>(Prop->GetSize()) != sizeof(T))
{
Output::send<LogLevel::Warning>(
STR("[{}] property '{}' is {} bytes engine-side, read as {} bytes\n"),
m_tag, PropertyName, Prop->GetSize(), sizeof(T));
return nullptr;
}
return reinterpret_cast<T*>(
reinterpret_cast<uint8*>(m_obj) + Prop->GetOffset_Internal());
}
template <typename T>
auto PropertyValue(const TCHAR* PropertyName) const -> std::optional<T>
{
if (T* Ptr = PropertyPtr<T>(PropertyName)) return *Ptr;
return std::nullopt;
}
// Convenience for the most common property shape in this tree: an
// ObjectProperty read back as a bare pointer. Silent on absence.
auto TryObjectProperty(const TCHAR* PropertyName) const -> UObject*
{
auto Ptr = TryPropertyPtr<UObject*>(PropertyName);
return Ptr ? *Ptr : nullptr;
}
// ------------------------------------------------------------------
// For `uint8 bFoo : 1;` UPROPERTY bitfields specifically. Do NOT
// reach for PropertyValue<uint8>/PropertyPtr<uint8> on one of
// these -- it is the wrong tool and it does not fail loudly.
//
// UHT reflects a bitfield as an FBoolProperty whose GetSize()
// reports the storage unit (1 byte), not "1 bit" -- there is no
// size check that can tell a lone byte-backed bool apart from one
// of several single-bit flags packed into a shared byte. When a
// header declares several `uint8 bX : 1;` fields back to back
// (exactly the bOverride_* run on USizeBox), the compiler is free
// to pack all of them into one physical byte. PropertyPtr<uint8>
// would then hand back that WHOLE byte -- every packed flag
// OR'd together -- for a call that named only one of them, and
// both the found-a-property and the size-matches checks would
// pass. That is a silent wrong answer, not a logged failure.
//
// FBoolProperty carries the two pieces of information that make
// this correct: GetByteOffset(), the offset of the specific byte
// within the struct (added ON TOP of the base FProperty offset
// ContainerPtrToValuePtr already applies -- see
// FBoolProperty::GetPropertyValue in UnrealType.hpp, which is
// exactly the two-offset addition this function replicates), and
// GetFieldMask(), the single bit within that byte belonging to
// this specific flag. Test the bit, don't read the byte.
auto PropertyBoolValue(const TCHAR* PropertyName) const -> std::optional<bool>
{
if (!m_obj) return std::nullopt;
FProperty* Prop = m_obj->GetPropertyByNameInChain(PropertyName);
if (!Prop)
{
Output::send<LogLevel::Warning>(
STR("[{}] property '{}' not found on {}\n"),
m_tag, PropertyName, m_obj->GetFullName());
return std::nullopt;
}
FBoolProperty* BoolProp = CastField<FBoolProperty>(Prop);
if (!BoolProp)
{
Output::send<LogLevel::Warning>(
STR("[{}] property '{}' is not a bool/bitfield property on {} -- ")
STR("use PropertyValue<T> for a plain field instead\n"),
m_tag, PropertyName, m_obj->GetFullName());
return std::nullopt;
}
const uint8* Base = reinterpret_cast<const uint8*>(m_obj)
+ Prop->GetOffset_Internal() + BoolProp->GetByteOffset();
return (*Base & BoolProp->GetFieldMask()) != 0;
}
UObject* m_obj{};
const TCHAR* m_tag{ STR("Wrapper") };
};
// ------------------------------------------------------------------
// RawObject -- the wrapper for objects this tree has no class for.
//
// Every helper that wants to read a property off an object it cannot
// name a ClassPath for was previously spelling this inline as a local
// struct deriving UnrealWrapper with Expected=nullptr (see the old
// WidgetTree::FromUserWidget). That spelling takes the "class path did
// not resolve" branch of the checking constructor, which emits a
// Warning and calls GetFullName() EVERY TIME -- on a per-bind path,
// that is the exact cost the UncheckedTag comment above exists to
// avoid.
//
// This uses UncheckedTag, so it is silent by construction. It is the
// same idea as Pal/PalIndividual.hpp's LooseObject, hoisted to where
// the UMG side can reach it without including the Pal headers.
// ------------------------------------------------------------------
class RawObject : public UnrealWrapper
{
public:
RawObject() = default;
explicit RawObject(UObject* Obj, const TCHAR* Tag = STR("Raw"))
: UnrealWrapper(Obj, UncheckedTag{}, Tag) {}
using UnrealWrapper::Call;
using UnrealWrapper::CallVoid;
using UnrealWrapper::CallForReturn;
using UnrealWrapper::PropertyPtr;
using UnrealWrapper::PropertyValue;
using UnrealWrapper::TryPropertyPtr;
using UnrealWrapper::TryObjectProperty;
};
// ------------------------------------------------------------------
// Cheap "is this object one of ours" probe.
//
// Wrapper constructors log when the IsA check fails, which is right for
// "I expected a CanvasPanel and got something else" and wrong for "walk
// this tree and tell me which nodes are panels." Use this to ask first,
// then construct.
//
// A false here can also mean the ClassPath never resolved. That is
// deliberately NOT treated as "yes" -- unlike the wrapper constructors,
// which accept unchecked on an unresolved class because refusing would
// break a call that would otherwise have worked. Here the answer feeds
// a branch, so guessing "yes" would send a non-panel down the panel
// path. ClassOf<T>() logs once on the failed resolve either way.
// ------------------------------------------------------------------
template <typename T>
inline auto ObjectIsA(UObject* Obj) -> bool
{
if (!Obj) return false;
UClass* Cls = ClassOf<T>();
return Cls && Obj->IsA(Cls);
}
}