#pragma once #include #include #include #include #include #include #include #include #include #include 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 inline auto Api() -> Reflection::UnrealApiCall& { static Reflection::UnrealApiCall 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 inline auto ClassOf() -> UClass* { static Reflection::UnrealObjectRef Ref{ T::ClassPath(), T::DebugTag() }; return static_cast(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( STR("[{}] '{}' is not in the chain of {}\n"), m_tag, FunctionName, m_obj->GetFullName()); return; } Output::send( STR("[{}] {} -- frame is {} bytes:\n"), m_tag, FunctionName, Fn->GetStructureSize()); for (FProperty* Prop : Fn->ForEachProperty()) { if (!Prop) continue; Output::send( 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( STR("[{}] no class on {}\n"), m_tag, m_obj->GetFullName()); return; } Output::send( STR("[{}] properties of {}:\n"), m_tag, m_obj->GetFullName()); for (FProperty* Prop : Cls->ForEachPropertyInChain()) { if (!Prop) continue; Output::send( 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( 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( STR("[{}] class unresolved -- accepting {} unchecked\n"), m_tag, Obj->GetFullName()); m_obj = Obj; return; } if (!Obj->IsA(Expected)) { Output::send( 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 will log if the // engine function actually has a ReturnValue property, which is the // signal that you meant to use CallForReturn. template auto Call(const TCHAR* FunctionName, ParamsStruct& Params) const -> bool { if (!m_obj) { Output::send( STR("[{}] '{}' on an invalid wrapper\n"), m_tag, FunctionName); return false; } return Detail::Api().Invoke(m_obj, FunctionName, Params); } auto CallVoid(const TCHAR* FunctionName) const -> bool { NoParams p{}; return Call(FunctionName, p); } // Zero-parameter getter. template auto CallForReturn(const TCHAR* FunctionName) const -> std::optional { struct Params { R ReturnValue; }; Params p{}; return CallForReturn(FunctionName, p, &Params::ReturnValue); } // Getter with parameters. ReturnValue must be the LAST member of // ParamsStruct, matching the engine's frame layout. template auto CallForReturn(const TCHAR* FunctionName, ParamsStruct& Params, R ParamsStruct::* ReturnMember) const -> std::optional { if (!m_obj) { Output::send( STR("[{}] '{}' on an invalid wrapper\n"), m_tag, FunctionName); return std::nullopt; } return Detail::Api().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 auto PropertyPtr(const TCHAR* PropertyName) const -> T* { if (!m_obj) return nullptr; FProperty* Prop = m_obj->GetPropertyByNameInChain(PropertyName); if (!Prop) { Output::send( STR("[{}] property '{}' not found on {}\n"), m_tag, PropertyName, m_obj->GetFullName()); return nullptr; } if (static_cast(Prop->GetSize()) != sizeof(T)) { Output::send( STR("[{}] property '{}' is {} bytes engine-side, read as {} bytes\n"), m_tag, PropertyName, Prop->GetSize(), sizeof(T)); return nullptr; } return reinterpret_cast( reinterpret_cast(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 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(Prop->GetSize()) != sizeof(T)) { Output::send( STR("[{}] property '{}' is {} bytes engine-side, read as {} bytes\n"), m_tag, PropertyName, Prop->GetSize(), sizeof(T)); return nullptr; } return reinterpret_cast( reinterpret_cast(m_obj) + Prop->GetOffset_Internal()); } template auto PropertyValue(const TCHAR* PropertyName) const -> std::optional { if (T* Ptr = PropertyPtr(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(PropertyName); return Ptr ? *Ptr : nullptr; } // ------------------------------------------------------------------ // For `uint8 bFoo : 1;` UPROPERTY bitfields specifically. Do NOT // reach for PropertyValue/PropertyPtr 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 // 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 { if (!m_obj) return std::nullopt; FProperty* Prop = m_obj->GetPropertyByNameInChain(PropertyName); if (!Prop) { Output::send( STR("[{}] property '{}' not found on {}\n"), m_tag, PropertyName, m_obj->GetFullName()); return std::nullopt; } FBoolProperty* BoolProp = CastField(Prop); if (!BoolProp) { Output::send( STR("[{}] property '{}' is not a bool/bitfield property on {} -- ") STR("use PropertyValue for a plain field instead\n"), m_tag, PropertyName, m_obj->GetFullName()); return std::nullopt; } const uint8* Base = reinterpret_cast(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() logs once on the failed resolve either way. // ------------------------------------------------------------------ template inline auto ObjectIsA(UObject* Obj) -> bool { if (!Obj) return false; UClass* Cls = ClassOf(); return Cls && Obj->IsA(Cls); } }