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

194
Features/IUIFeature.hpp Normal file
View File

@@ -0,0 +1,194 @@
#pragma once
#include <chrono>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <exception>
#include <DynamicOutput/DynamicOutput.hpp>
#include <Unreal/UObject.hpp>
#include <Static/LogGate.hpp>
namespace PalUIExtension::Features
{
using namespace RC;
using namespace RC::Unreal;
class IUIFeature
{
public:
using StringType = std::basic_string<TCHAR>;
virtual ~IUIFeature() = default;
virtual auto Name() const -> const TCHAR* = 0;
virtual auto InstallHooks() -> void = 0;
virtual auto OnWorldTeardown() -> void {}
virtual auto SetEnabled(bool enabled) -> void {}
virtual auto GetEnabled() -> bool { return false; }
// ---- logging ---------------------------------------------------
template <typename... Args>
auto SendLog(LogLevel::LogLevel Level, const TCHAR* Fmt, Args&&... Fargs) const -> void
{
Emit(Level, Fmt, std::forward<Args>(Fargs)...);
}
template <typename... Args>
auto SendLogOnce(const TCHAR* Key, LogLevel::LogLevel Level, const TCHAR* Fmt, Args&&... Fargs) const -> void
{
if (!Key) return;
if (!m_log_once_keys.insert(StringType{ Key }).second) return;
Emit(Level, Fmt, std::forward<Args>(Fargs)...);
}
template <typename... Args>
auto SendLogThrottled(const TCHAR* Key, int64_t IntervalMs, LogLevel::LogLevel Level, const TCHAR* Fmt, Args&&... Fargs) const -> void
{
if (!Key) return;
using Clock = std::chrono::steady_clock;
const auto Now = Clock::now();
auto& Last = m_log_throttle[StringType{ Key }];
if (Last.time_since_epoch().count() != 0 && std::chrono::duration_cast<std::chrono::milliseconds>(Now - Last).count() < IntervalMs)
{
return;
}
Last = Now;
Emit(Level, Fmt, std::forward<Args>(Fargs)...);
}
template <typename... Args>
auto SendVerbose(const TCHAR* Fmt, Args&&... Fargs) const -> void
{
if constexpr (Static::kVerboseLogging)
{
Emit(LogLevel::Verbose, Fmt, std::forward<Args>(Fargs)...);
}
}
auto ResetLogOnce() const -> void
{
m_log_once_keys.clear();
m_log_throttle.clear();
}
// ---- argument helpers -------------------------------------------
static auto NameOf(UObject* Obj) -> StringType { return Obj ? Obj->GetName() : StringType{ STR("<null>") }; }
static auto FullNameOf(UObject* Obj) -> StringType { return Obj ? Obj->GetFullName() : StringType{ STR("<null>") }; }
static auto ClassNameOf(UObject* Obj) -> StringType
{
if (!Obj) return StringType{ STR("<null>") };
UClass* Cls = Obj->GetClassPrivate();
return Cls ? Cls->GetName() : StringType{ STR("<no class>") };
}
static auto AddressOf(const void* P) -> uint64_t
{
return static_cast<uint64_t>(reinterpret_cast<uintptr_t>(P));
}
private:
template <typename... Args>
auto Emit(LogLevel::LogLevel Level, const TCHAR* Fmt, Args&&... Fargs) const -> void
{
if (!Fmt) return;
try
{
switch (Level)
{
case LogLevel::Error:
Output::send<LogLevel::Error>(STR("PalUIExt [{}] "), Name());
Output::send<LogLevel::Error>(Fmt, std::forward<Args>(Fargs)...);
Output::send<LogLevel::Error>(STR("\n"));
break;
case LogLevel::Warning:
Output::send<LogLevel::Warning>(STR("PalUIExt [{}] "), Name());
Output::send<LogLevel::Warning>(Fmt, std::forward<Args>(Fargs)...);
Output::send<LogLevel::Warning>(STR("\n"));
break;
case LogLevel::Verbose:
Output::send<LogLevel::Verbose>(STR("PalUIExt [{}] "), Name());
Output::send<LogLevel::Verbose>(Fmt, std::forward<Args>(Fargs)...);
Output::send<LogLevel::Verbose>(STR("\n"));
break;
default:
Output::send<LogLevel::Normal>(STR("PalUIExt [{}] "), Name());
Output::send<LogLevel::Normal>(Fmt, std::forward<Args>(Fargs)...);
Output::send<LogLevel::Normal>(STR("\n"));
break;
}
}
catch (const std::exception&)
{
try { Output::send<LogLevel::Error>(STR("PalUIExt: malformed log format string\n")); }
catch (...) {}
}
catch (...)
{
}
}
mutable std::unordered_set<StringType> m_log_once_keys;
mutable std::unordered_map<StringType, std::chrono::steady_clock::time_point> m_log_throttle;
};
class ScopedHotPathTimer
{
public:
ScopedHotPathTimer(const IUIFeature& Feature, const TCHAR* Label,
int64_t ReportAboveMicros = 250)
: m_feature(Feature), m_label(Label), m_threshold(ReportAboveMicros)
{
if constexpr (Static::kProfileHotPath)
{
m_start = std::chrono::steady_clock::now();
}
}
~ScopedHotPathTimer()
{
if constexpr (Static::kProfileHotPath)
{
const auto Micros = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - m_start).count();
if (Micros >= m_threshold)
{
m_feature.SendLogThrottled(m_label, 1000, LogLevel::Warning,
STR("slow path: {} took {}us"), m_label, Micros);
}
}
}
ScopedHotPathTimer(const ScopedHotPathTimer&) = delete;
ScopedHotPathTimer& operator=(const ScopedHotPathTimer&) = delete;
private:
const IUIFeature& m_feature;
const TCHAR* m_label;
int64_t m_threshold;
std::chrono::steady_clock::time_point m_start{};
};
}

View File

@@ -0,0 +1,449 @@
#pragma once
#include <optional>
#include <unordered_map>
#include <DynamicOutput/DynamicOutput.hpp>
#include <Unreal/Hooks.hpp>
#include <Unreal/UObject.hpp>
#include <Unreal/NameTypes.hpp>
#include <Reflection/UnrealApiCall.hpp>
#include <Api/UmgTypes.hpp>
#include <Api/Overlay.hpp>
#include <Api/WidgetTree.hpp>
#include <Api/WidgetBlueprintGeneratedClass.hpp>
#include <Api/PalCommonCharacterSlot.hpp>
#include <Api/Mod/PalDetail_PalIconNightEye.hpp>
#include <Api/Pal/PalIndividual.hpp>
#include <Api/Pal/PalUtility.hpp>
#include <Static/EventHookRegistry.hpp>
#include "IUIFeature.hpp"
namespace PalUIExtension::Features
{
using namespace RC;
using namespace RC::Unreal;
class PalBoxNightWorkIndicator final : public IUIFeature
{
public:
auto Name() const -> const TCHAR* override { return STR("PalBoxNightWorkIndicator"); }
auto InstallHooks() -> void override
{
const auto BindId = Static::EventHookRegistry::Register(
STR("PalBoxNightWorkIndicator::UpdateSlotBinded"),
Api::PalCommonCharacterSlot::OnUpdateSlotBindedPath(),
nullptr,
&PalBoxNightWorkIndicator::OnUpdateSlotBinded,
this);
if (BindId == Static::EventHookRegistry::InvalidHook) {
SendLog(LogLevel::Error, STR("InstallHooks: 'On Update Slot Binded' failed to bind"));
}
const auto EmptyId = Static::EventHookRegistry::Register(
STR("PalBoxNightWorkIndicator::SetEmpty"),
Api::PalCommonCharacterSlot::OnSetEmptyBindedPath(),
nullptr,
&PalBoxNightWorkIndicator::OnSetEmpty,
this);
if (EmptyId == Static::EventHookRegistry::InvalidHook) {
SendLog(LogLevel::Warning, STR("InstallHooks: 'On Set Empty Binded' failed to bind."));
}
SendLog(LogLevel::Normal, STR("InstallHooks: bind={} empty={}"), BindId, EmptyId);
}
auto OnWorldTeardown() -> void override
{
// Every pointer in here belongs to a world that is going away.
SendLog(LogLevel::Normal,
STR("teardown: dropping {} cached slots (injected={}, tier2 walks={}, tier3 constructs={})"),
static_cast<int32>(m_slots.size()),
m_injection_done ? 1 : 0,
m_tier2_walks,
m_fallback_constructs);
m_slots.clear();
m_injection_done = false;
m_injection_failed = false;
m_tier2_walks = 0;
m_fallback_constructs = 0;
ResetLogOnce();
}
auto SetEnabled(bool bEnabled) -> void override { m_enabled = bEnabled; }
auto GetEnabled() -> bool override { return m_enabled; }
private:
static constexpr const TCHAR* kBadgeInstanceName = STR("PalUI_NightWorkBadge");
static constexpr size_t kMaxCachedSlots = 2048;
struct SlotEntry
{
UObject* Badge{ nullptr };
UObject* Tree{ nullptr }; // the slot widget's WidgetTree, for validation
bool bVisible{ false };
bool bStateKnown{ false };
};
bool m_enabled{ true };
bool m_injection_done{ false };
bool m_injection_failed{ false };
int32 m_tier2_walks{ 0 };
int32 m_fallback_constructs{ 0 };
std::unordered_map<UObject*, SlotEntry> m_slots;
// ---- hooks ---------------------------------------------------
static void OnUpdateSlotBinded(UnrealScriptFunctionCallableContext& Ctx, void* CustomData)
{
auto* Self = static_cast<PalBoxNightWorkIndicator*>(CustomData);
if (!Self || !Self->m_enabled || !Ctx.Context) return;
auto& Frame = Ctx.GetParams<Api::PalCommonCharacterSlot::UpdateSlotBindedFrame>();
Self->Apply(Ctx.Context, Frame.TargetSlot);
}
static void OnSetEmpty(UnrealScriptFunctionCallableContext& Ctx, void* CustomData)
{
auto* Self = static_cast<PalBoxNightWorkIndicator*>(CustomData);
if (!Self || !Self->m_enabled || !Ctx.Context) return;
Self->SetBadgeVisible(Ctx.Context, false);
}
// ---- the work ------------------------------------------------
auto Apply(UObject* SlotWidget, UObject* TargetSlot) -> void
{
ScopedHotPathTimer Timer{ *this, STR("NightWorkIndicator::Apply") };
EnsureTemplateInjection(SlotWidget);
const auto Species = SpeciesFromSlot(TargetSlot);
if (!Species.has_value())
{
SendLogOnce(STR("Apply::no-species"), LogLevel::Verbose,
STR("Apply: no species from slot {} (class {}). Hiding badge. "
"If this repeats for FULL tiles, GetHandle/GetCharacterID is the broken link."),
NameOf(TargetSlot), ClassNameOf(TargetSlot));
SetBadgeVisible(SlotWidget, false);
return;
}
const auto Nocturnal = Api::Pal::PalUtility::Instance().NocturnalFor(*Species, SlotWidget);
if (!Nocturnal.has_value())
{
SendLogThrottled(STR("Apply::no-nocturnal"), 5000, LogLevel::Warning,
STR("Apply: nocturnal lookup returned nothing for species '{}'. "
"Character database not ready, or the species ID needs variant stripping."),
Species->ToString());
SetBadgeVisible(SlotWidget, false, /*bRecordState=*/false);
return;
}
SetBadgeVisible(SlotWidget, *Nocturnal);
}
auto SpeciesFromSlot(UObject* Slot) const -> std::optional<FName>
{
if (!Slot) return std::nullopt;
Api::Pal::LooseObject S{ Slot, STR("PalIndividualSlot") };
UObject* Handle = S.CallForReturn<UObject*>(STR("GetHandle")).value_or(nullptr);
return Api::Pal::PalIndividual::CharacterIdFromHandle(Handle);
}
// ==============================================================
// TIER 1 -- class template injection
// ==============================================================
auto EnsureTemplateInjection(UObject* AnySlotWidget) -> void
{
if (m_injection_done || m_injection_failed || !AnySlotWidget) return;
// One attempt. On failure the flag latches, because retrying a
// class-level operation on every bind is exactly the per-tile
// cost this rewrite exists to remove.
m_injection_failed = true;
Api::WidgetBlueprintGeneratedClass GenClass =
Api::WidgetBlueprintGeneratedClass::Of(AnySlotWidget);
if (!GenClass.IsValid())
{
SendLog(LogLevel::Warning,
STR("injection: {} is not a WidgetBlueprintGeneratedClass instance. "
"Falling back to per-instance attach for the whole session."),
ClassNameOf(AnySlotWidget));
return;
}
Api::WidgetTree Template = GenClass.TemplateTree();
if (!Template.IsValid())
{
SendLog(LogLevel::Warning,
STR("injection: class {} has no template WidgetTree (cooked-tree build?). "
"Falling back to per-instance attach for the whole session."),
ClassNameOf(AnySlotWidget));
return;
}
// Already there from a previous world? Then nothing to do the class survived the transition.
if (Template.FindObjectByName(kBadgeInstanceName))
{
m_injection_done = true;
m_injection_failed = false;
SendLog(LogLevel::Normal, STR("injection: template already carries the badge; reusing."));
return;
}
UObject* AnchorObj = Template.FindObjectByName(STR("CanvasPanel_249"));
if (!AnchorObj)
{
SendLog(LogLevel::Warning,
STR("injection: 'CanvasPanel_249' is not in the template tree of {}. "
"The designer name changed, or the anchor moved into a nested widget. "
"Dump the tree to find its new name."),
ClassNameOf(AnySlotWidget));
return;
}
Api::CanvasPanel Anchor{ AnchorObj };
if (!Anchor.IsValid())
{
SendLog(LogLevel::Warning,
STR("injection: 'CanvasPanel_249' resolved to a {}, not a CanvasPanel."),
ClassNameOf(AnchorObj));
return;
}
UClass* BadgeClass = Api::ClassOf<Api::Mod::PalDetail_PalIconNightEye>();
if (!BadgeClass)
{
SendLog(LogLevel::Error,
STR("injection: badge class '{}' did not resolve. Is the mod pak mounted?"),
Api::Mod::PalDetail_PalIconNightEye::ClassPath());
return;
}
UObject* TemplateBadge = Template.ConstructWidget(BadgeClass, kBadgeInstanceName);
if (!TemplateBadge)
{
SendLog(LogLevel::Error,
STR("injection: failed to construct '{}' under the template tree."),
kBadgeInstanceName);
return;
}
if (!Anchor.AddChild(TemplateBadge).IsValid())
{
SendLog(LogLevel::Error,
STR("injection: AddChild returned no slot. The badge exists but is not parented; "
"it will not duplicate into instances."));
return;
}
m_injection_done = true;
m_injection_failed = false;
SendLog(LogLevel::Normal,
STR("injection: badge added to the {} template tree. Instances built from here on "
"carry it for free."),
ClassNameOf(AnySlotWidget));
VerifyBadgeDefaultsCollapsed(TemplateBadge);
}
auto VerifyBadgeDefaultsCollapsed(UObject* TemplateBadge) -> void
{
Api::Mod::PalDetail_PalIconNightEye W{ TemplateBadge };
if (!W.IsValid()) return;
const auto Vis = W.GetIconVisibility();
if (!Vis.has_value())
{
SendLog(LogLevel::Warning,
STR("injection: could not read the template badge's default visibility. "
"Confirm by eye that it is hidden on non-palbox screens."));
return;
}
}
// ==============================================================
// Applying the state
// ==============================================================
auto SetBadgeVisible(UObject* SlotWidget, bool bVisible, bool bRecordState = true) -> void
{
if (!SlotWidget) return;
SlotEntry* Entry = ResolveEntry(SlotWidget);
if (!Entry || !Entry->Badge) return; // ResolveEntry logged
// The real gate. Per widget, not a memo of one -- this is what
// the old comment described and the old code could not do.
if (bRecordState && Entry->bStateKnown && Entry->bVisible == bVisible) return;
Api::Mod::PalDetail_PalIconNightEye W{ Entry->Badge };
if (!W.IsValid())
{
SendLogOnce(STR("SetBadgeVisible::bad-badge"), LogLevel::Warning,
STR("SetBadgeVisible: cached badge on {} is not a PalDetail_PalIconNightEye "
"(it is a {}). Dropping the cache entry."),
NameOf(SlotWidget), ClassNameOf(Entry->Badge));
m_slots.erase(SlotWidget);
return;
}
const bool bApplied = W.SetIconVisibility(bVisible);
if (!bApplied)
{
SendLogThrottled(STR("SetBadgeVisible::call-failed"), 5000, LogLevel::Warning,
STR("SetBadgeVisible: SetIconVisibility failed on the badge under {}. "
"Not recording state, so the next bind retries."),
NameOf(SlotWidget));
Entry->bStateKnown = false;
return;
}
if (!bRecordState)
{
Entry->bStateKnown = false;
return;
}
Entry->bVisible = bVisible;
Entry->bStateKnown = true;
}
// ==============================================================
// The lookup. Cache -> tree walk -> construct.
// ==============================================================
auto ResolveEntry(UObject* SlotWidget) -> SlotEntry*
{
// The validation token: one pointer read, no ProcessEvent.
UObject* Tree = Api::WidgetTree::FromUserWidget(SlotWidget).Get();
if (!Tree)
{
SendLogOnce(STR("ResolveEntry::no-tree"), LogLevel::Warning,
STR("ResolveEntry: {} has no WidgetTree. Not a UUserWidget, or the hook is "
"firing on the wrong object."),
NameOf(SlotWidget));
return nullptr;
}
if (auto It = m_slots.find(SlotWidget); It != m_slots.end())
{
// A recycled address holding a DIFFERENT widget has a
// different tree. This turns a collision into a miss.
if (It->second.Tree == Tree && It->second.Badge &&
!It->second.Badge->IsUnreachable())
{
return &It->second;
}
SendVerbose(STR("ResolveEntry: stale entry for {} dropped (address recycled)."),
NameOf(SlotWidget));
m_slots.erase(It);
}
UObject* Badge = FindOrAttachBadge(SlotWidget, Tree);
if (!Badge) return nullptr;
if (m_slots.size() >= kMaxCachedSlots)
{
// Not an LRU on purpose: this bound should never be
// reached, so reaching it is a signal, and a silent
// eviction policy would hide it.
SendLogThrottled(STR("ResolveEntry::cache-full"), 30000, LogLevel::Warning,
STR("ResolveEntry: slot cache hit {} entries and was cleared. Slot widgets are "
"not being reused the way this assumes; entries are accumulating."),
static_cast<int32>(m_slots.size()));
m_slots.clear();
}
auto [It, bInserted] = m_slots.emplace(SlotWidget, SlotEntry{ Badge, Tree, false, false });
return &It->second;
}
auto FindOrAttachBadge(UObject* SlotWidget, UObject* TreeObj) -> UObject*
{
Api::WidgetTree Tree{ TreeObj };
++m_tier2_walks;
if (UObject* Existing = Tree.FindObjectByName(kBadgeInstanceName))
{
return Existing;
}
++m_fallback_constructs;
SendLogThrottled(STR("FindOrAttachBadge::fallback"), 10000, LogLevel::Warning,
STR("per-instance construct #{} (walks={}, injection_done={}). Expected to settle "
"shortly after startup. If this keeps climbing, template duplication is not "
"carrying the badge into new instances."),
m_fallback_constructs, m_tier2_walks, m_injection_done ? 1 : 0);
UClass* BadgeClass = Api::ClassOf<Api::Mod::PalDetail_PalIconNightEye>();
if (!BadgeClass)
{
SendLogOnce(STR("FindOrAttachBadge::no-class"), LogLevel::Error,
STR("badge class '{}' did not resolve. Is the mod pak mounted?"),
Api::Mod::PalDetail_PalIconNightEye::ClassPath());
return nullptr;
}
UObject* Badge = Tree.ConstructWidget(BadgeClass, kBadgeInstanceName);
if (!Badge)
{
SendLogOnce(STR("FindOrAttachBadge::construct-failed"), LogLevel::Error,
STR("could not construct '{}' under the tree of {}."),
kBadgeInstanceName, NameOf(SlotWidget));
return nullptr;
}
Api::PalCommonCharacterSlot Slot{ SlotWidget };
if (!Slot.IsValid())
{
SendLogOnce(STR("FindOrAttachBadge::bad-slot"), LogLevel::Warning,
STR("{} is a {}, not a PalCommonCharacterSlot. The hook is on the wrong class."),
NameOf(SlotWidget), ClassNameOf(SlotWidget));
return nullptr;
}
Api::CanvasPanel Anchor = Api::CanvasPanel{ Slot.Child< Api::CanvasPanel>(STR("CanvasPanel_249")) };
if (!Anchor.IsValid())
{
SendLogOnce(STR("FindOrAttachBadge::no-anchor"), LogLevel::Warning,
STR("no 'CanvasPanel_249' under {}. The badge was constructed but has nowhere to go; "
"it will stay invisible."),
NameOf(SlotWidget));
return nullptr;
}
if (!Anchor.AddChild(Badge).IsValid())
{
SendLogOnce(STR("FindOrAttachBadge::attach-failed"), LogLevel::Warning,
STR("AddChild returned no slot attaching the badge under {}."),
NameOf(SlotWidget));
return nullptr;
}
return Badge;
}
};
}

View File

@@ -0,0 +1,10 @@
#include "PalBoxPalNumber.hpp"
inline auto PalUIExtension::Features::PalBoxPalNumber::SameIndividual(const PalKey& A, const PalKey& B) -> bool
{
return A.InstanceID.A == B.InstanceID.A && A.InstanceID.B == B.InstanceID.B &&
A.InstanceID.C == B.InstanceID.C && A.InstanceID.D == B.InstanceID.D &&
A.PlayerUID.A == B.PlayerUID.A && A.PlayerUID.B == B.PlayerUID.B &&
A.PlayerUID.C == B.PlayerUID.C && A.PlayerUID.D == B.PlayerUID.D;
}

View File

@@ -0,0 +1,331 @@
#pragma once
#include <optional>
#include <string>
#include <type_traits>
#include <DynamicOutput/DynamicOutput.hpp>
#include <Unreal/Hooks.hpp>
#include <Unreal/UObject.hpp>
#include <Reflection/UnrealApiCall.hpp>
#include <Api/CanvasPanel.hpp>
#include <Api/CanvasPanelSlot.hpp>
#include <Api/PalDetailWidget.hpp>
#include <Api/Mod/PalDetail_PalNumber.hpp>
#include <Api/Pal/PalIndividual.hpp>
#include <Api/Pal/PalTypes.hpp>
#include <Api/Pal/PalUtility.hpp>
#include <Helpers/PalHelpers.hpp>
#include <Static/EventHookRegistry.hpp>
#include <Static/LogGate.hpp>
#include <Static/WidgetRegistry.hpp>
#include "IUIFeature.hpp"
namespace PalUIExtension::Features
{
using namespace RC;
using namespace RC::Unreal;
using namespace Helpers;
struct Tag_PalNumberWidget {};
class PalBoxPalNumber final : public IUIFeature
{
public:
bool m_enabled = true;
static constexpr const TCHAR* kInstanceName = STR("PalUI_PalDetail_PalNumber");
// nudge the rest of the canvas components down this much to make room for pal number
static constexpr double kCanvasNudgeY = 18.0;
static constexpr double kLabelOffsetY = 8.0;
static constexpr double kLabelOffsetXFraction = 0.4;
auto Name() const -> const TCHAR* override { return STR("PalNumberLabel"); }
auto InstallHooks() -> void override
{
Static::EventHookRegistry::Register(
STR("PalNumberLabel::BindFromHandle"),
Api::PalDetailWidget::BindFromHandlePath(),
&PalBoxPalNumber::OnBindFromHandle,
nullptr,
this);
Static::EventHookRegistry::Register(
STR("PalNumberLabel::SetEmpty"),
Api::PalDetailWidget::SetEmptyPath(),
&PalBoxPalNumber::OnSetEmpty,
nullptr,
this);
}
auto OnWorldTeardown() -> void override {
ForgetAll();
}
auto SetEnabled(bool bEnabled) -> void override { m_enabled = bEnabled; }
auto GetEnabled() -> bool override { return m_enabled; }
private:
UObject* m_owner{}; // the detail panel this memo describes
UObject* m_label{}; // the injected widget under that panel
Helpers::PalKey m_key{};
bool m_has_key{};
int32 m_index{};
bool m_has_index{};
bool m_visible{ true }; // last visibility WE set
bool m_layout_done{}; // canvas nudge applied for m_owner
// ---- hooks ----------------------------------------
static void OnBindFromHandle(UnrealScriptFunctionCallableContext& Ctx, void* CustomData)
{
auto* Self = static_cast<PalBoxPalNumber*>(CustomData);
if (!Self || !Ctx.Context) return;
auto& Frame = Ctx.GetParams<Api::PalDetailWidget::BindFromHandleFrame>();
Self->HandleBind(Ctx.Context, Frame.targetHandle);
}
static void OnSetEmpty(UnrealScriptFunctionCallableContext& Ctx, void* CustomData)
{
auto* Self = static_cast<PalBoxPalNumber*>(CustomData);
if (!Self || !Ctx.Context) return;
Self->HandleEmpty(Ctx.Context);
}
// ---- the actual work -----------------------------------------
auto HandleBind(UObject* Owner, UObject* Handle) -> void
{
if (Owner != m_owner) AdoptOwner(Owner);
const auto Key = ReadKey(Handle);
if (!Key.has_value())
{
HideLabel();
m_has_key = false;
return;
}
if (m_has_key && SamePalIndividual(m_key, *Key)) return; // the early out that matters
UObject* Label = ResolveLabel(Owner);
if (!Label) return;
Api::Mod::PalDetail_PalNumberWidget W{ Label };
if (!W.IsValid()) return;
m_key = *Key;
m_has_key = true;
const auto Species = W.SetPalBinding(BuildBindingParam(m_key));
if (!Species.has_value())
{
ShowLabel(W);
return;
}
const auto Index = Api::Pal::PalUtility::Instance().ZukanIndexFor(*Species, Owner);
ShowLabel(W);
ApplyIndex(W, Index);
}
auto HandleEmpty(UObject* Owner) -> void
{
if (Owner != m_owner) AdoptOwner(Owner);
m_has_key = false;
HideLabel();
}
auto ApplyIndex(Api::Mod::PalDetail_PalNumberWidget& W, std::optional<int32> Index) -> void
{
const int32 Resolved = Index.value_or(kUnknownIndex);
if (m_has_index && m_index == Resolved) return;
m_index = Resolved;
m_has_index = true;
const FString Text = (Resolved == kUnknownIndex) ? FString(STR("?")) : FString::Printf(STR("%d"), Resolved);
if (!W.SetPalNumberText(Text))
{
m_has_index = false;
SendLog(LogLevel::Error, STR("[PalNumberLabel] SetPalNumberText failed on {}\n"), W.Get() ? W.Get()->GetFullName() : STR("<null>"));
}
}
// ---- widget resolution ---------------------------------------
auto ResolveLabel(UObject* Owner) -> UObject*
{
if (m_label && IsOurLabel(m_label, Owner)) return m_label;
const auto Key = RegistryKey(Owner);
if (UObject* Cached = Static::WidgetRegistry::GetObject(Key);
Cached && IsOurLabel(Cached, Owner))
{
m_label = Cached;
return m_label;
}
bool bWasNew = false;
UObject* Found = WidgetApi().FindOrConstruct(Owner, kInstanceName, bWasNew);
if (!Found)
{
SendLog(LogLevel::Error, STR("[PalNumberLabel] could not find or construct '{}' under {}\n"), kInstanceName, Owner->GetFullName());
return nullptr;
}
if (bWasNew && !AttachLabel(Owner, Found))
{
return nullptr;
}
m_label = Found;
m_visible = true; // freshly attached widgets are visible
Static::WidgetRegistry::Register(Key, Found);
return m_label;
}
auto AttachLabel(UObject* Owner, UObject* Label) -> bool
{
Api::PalDetailWidget Panel{ Owner };
if (!Panel.IsValid())
{
Output::send<LogLevel::Error>(
STR("[PalNumberLabel] {} is not a PalDetailWidget\n"), Owner->GetFullName());
return false;
}
Api::CanvasPanel Canvas = Panel.CanvasInfo();
if (!Canvas.IsValid())
{
Canvas = Panel.Child<Api::CanvasPanel>(
STR("Canvas_Info"), Api::WidgetTree::EScope::IncludeNestedUserWidgets);
}
if (!Canvas.IsValid()) return false;
if (!m_layout_done)
{
UObject* CanvasSlotObj = Canvas.GetSlotObject();
Api::CanvasPanelSlot CanvasSlot = CanvasSlotObj
? Api::CanvasPanelSlot{ CanvasSlotObj } : Api::CanvasPanelSlot{};
if (CanvasSlot.IsValid())
{
Api::FVector2D_ Pos = CanvasSlot.GetPosition().value_or(Api::FVector2D_{ 0, 0 });
Pos.Y += kCanvasNudgeY;
CanvasSlot.SetPosition(Pos);
}
m_layout_done = true;
}
Api::PanelSlot Added = Canvas.AddChild(Label);
if (!Added.IsValid()) return false;
Api::CanvasPanelSlot LabelSlot{ Added.Get() };
if (LabelSlot.IsValid())
{
const Api::FVector2D_ Size = LabelSlot.GetSize().value_or(Api::FVector2D_{ 0, 0 });
LabelSlot.SetPosition(Api::FVector2D_{ Size.X * kLabelOffsetXFraction, kLabelOffsetY });
}
return true;
}
// ---- visibility ----------------------------------------------
auto ShowLabel(Api::Mod::PalDetail_PalNumberWidget& W) -> void
{
if (m_visible) return;
if (W.SetVisibility(Api::ESlateVisibility_::SelfHitTestInvisible)) m_visible = true;
}
auto HideLabel() -> void
{
if (!m_visible || !m_label) return;
Api::Widget W{ m_label };
if (!W.IsValid()) return;
if (W.SetVisibility(Api::ESlateVisibility_::Collapsed)) m_visible = false;
}
// ---- identity -------------------------------------------------
static auto ReadKey(UObject* Handle) -> std::optional<PalKey>
{
if (!Handle) return std::nullopt;
Api::Pal::LooseObject H{ Handle, STR("PalIndividualCharacterHandle") };
auto Id = H.CallForReturn<Api::Pal::FPalInstanceID>(STR("GetIndividualID"));
if (!Id.has_value()) return std::nullopt;
return PalKey{ Id->PlayerUID, Id->InstanceID };
}
static auto BuildBindingParam(const PalKey& Key) -> Api::Pal::FPalInstanceID
{
return Api::Pal::FPalInstanceID{ Key.PlayerUID, Key.InstanceID, FString{} };
}
// ---- memo management -----------------------------------------
auto AdoptOwner(UObject* Owner) -> void
{
m_owner = Owner;
m_label = nullptr;
m_has_key = false;
m_has_index = false;
m_visible = true;
m_layout_done = false;
}
auto ForgetAll() -> void
{
m_owner = nullptr;
m_label = nullptr;
m_has_key = false;
m_has_index = false;
m_visible = true;
m_layout_done = false;
}
static auto IsOurLabel(UObject* Candidate, UObject* Owner) -> bool
{
if (!Candidate || !Owner) return false;
if (Candidate->GetOuterPrivate() != Owner) return false;
return Api::ObjectIsA<Api::Mod::PalDetail_PalNumberWidget>(Candidate);
}
static auto RegistryKey(UObject* Owner) -> Static::WidgetRegistry::KeyType
{
static_assert(std::is_same_v<TCHAR, wchar_t>,
"RegistryKey builds its suffix with std::to_wstring");
return Static::WidgetRegistry::KeyType{ STR("PalBoxNumberLabel:") } +
std::to_wstring(reinterpret_cast<uintptr_t>(Owner));
}
static auto WidgetApi() -> Reflection::UnrealApiCall<Tag_PalNumberWidget>&
{
static Reflection::UnrealApiCall<Tag_PalNumberWidget> Api{
Api::Mod::PalDetail_PalNumberWidget::ClassPath(),
Api::Mod::PalDetail_PalNumberWidget::DebugTag() };
return Api;
}
static constexpr int32 kUnknownIndex = -1;
};
}

View File

@@ -0,0 +1,281 @@
#pragma once
#include <optional>
#include <string>
#include <type_traits>
#include <DynamicOutput/DynamicOutput.hpp>
#include <Unreal/Hooks.hpp>
#include <Unreal/UObject.hpp>
#include <Reflection/UnrealApiCall.hpp>
#include <Api/CanvasPanel.hpp>
#include <Api/CanvasPanelSlot.hpp>
#include <Api/MainMenuPalWidget.hpp>
#include <Api/Mod/PalStatus_PalNumber.hpp>
#include <Api/Pal/PalIndividual.hpp>
#include <Api/Pal/PalTypes.hpp>
#include <Api/Pal/PalUtility.hpp>
#include <Helpers/PalHelpers.hpp>
#include <Static/EventHookRegistry.hpp>
#include <Static/LogGate.hpp>
#include <Static/WidgetRegistry.hpp>
#include "IUIFeature.hpp"
namespace PalUIExtension::Features
{
using namespace RC;
using namespace RC::Unreal;
struct Tag_PalStatusNumberWidget {};
class PalStatusPalNumber final : public IUIFeature
{
public:
bool m_enabled = true;
static constexpr const TCHAR* kInstanceName = STR("PalUI_PalStatus_PalNumber");
// nudge the rest of the canvas components down this much to make room for pal number
static constexpr double kLabelOffsetY = -25;
static constexpr double kLabelOffsetXFraction = 2.4;
auto Name() const -> const TCHAR* override { return STR("PalStatusNumberLabel"); }
auto InstallHooks() -> void override
{
Static::EventHookRegistry::Register(
STR("PalStatusPalNumber::BindFromHandle"),
Api::MainMenuPalWidget::BindFromHandlePath(),
&PalStatusPalNumber::OnBindFromHandle,
nullptr,
this);
}
auto OnWorldTeardown() -> void override { ForgetAll(); }
auto SetEnabled(bool bEnabled) -> void override { m_enabled = bEnabled; }
auto GetEnabled() -> bool override { return m_enabled; }
private:
UObject* m_owner{}; // the status panel this memo describes
UObject* m_label{}; // the injected widget under that panel
PalKey m_key{};
bool m_has_key{};
int32 m_index{};
bool m_has_index{};
bool m_visible{ true }; // last visibility WE set
bool m_layout_done{}; // canvas nudge applied for m_owner
// ---- hooks ----------------------------------------
static void OnBindFromHandle(UnrealScriptFunctionCallableContext& Ctx, void* CustomData)
{
auto* Self = static_cast<PalStatusPalNumber*>(CustomData);
if (!Self || !Ctx.Context) return;
auto& Frame = Ctx.GetParams<Api::MainMenuPalWidget::BindFromHandleFrame>();
Self->HandleBind(Ctx.Context, Frame.targetHandle);
}
auto HandleBind(UObject* Owner, UObject* Handle) -> void
{
if (Owner != m_owner) AdoptOwner(Owner);
const auto Key = ReadKey(Handle);
if (!Key.has_value())
{
HideLabel();
m_has_key = false;
return;
}
if (m_has_key && SamePalIndividual(m_key, *Key)) return; // the early out that matters
UObject* Label = ResolveLabel(Owner);
if (!Label) return;
Api::Mod::PalStatus_PalNumberWidget W{ Label };
if (!W.IsValid()) return;
m_key = *Key;
m_has_key = true;
const auto Species = W.SetPalBinding(BuildBindingParam(m_key));
if (!Species.has_value())
{
ShowLabel(W);
return;
}
const auto Index = Api::Pal::PalUtility::Instance().ZukanIndexFor(*Species, Owner);
ShowLabel(W);
ApplyIndex(W, Index);
}
auto ApplyIndex(Api::Mod::PalStatus_PalNumberWidget& W, std::optional<int32> Index) -> void
{
const int32 Resolved = Index.value_or(kUnknownIndex);
if (m_has_index && m_index == Resolved) return;
m_index = Resolved;
m_has_index = true;
const FString Text = (Resolved == kUnknownIndex) ? FString(STR("?")) : FString::Printf(STR("%d"), Resolved);
if (!W.SetPalNumberText(Text))
{
m_has_index = false;
SendLog(LogLevel::Error, STR("SetPalNumberText failed on {}\n"), W.Get() ? W.Get()->GetFullName() : STR("<null>"));
}
}
// ---- widget resolution ---------------------------------------
auto ResolveLabel(UObject* Owner) -> UObject*
{
if (m_label && IsOurLabel(m_label, Owner)) return m_label;
const auto Key = RegistryKey(Owner);
if (UObject* Cached = Static::WidgetRegistry::GetObject(Key);
Cached && IsOurLabel(Cached, Owner))
{
m_label = Cached;
return m_label;
}
bool bWasNew = false;
UObject* Found = WidgetApi().FindOrConstruct(Owner, kInstanceName, bWasNew);
if (!Found)
{
SendLog(LogLevel::Error, STR("could not find or construct '{}' under {}\n"), kInstanceName, Owner->GetFullName());
return nullptr;
}
if (bWasNew && !AttachLabel(Owner, Found))
{
return nullptr;
}
m_label = Found;
m_visible = true; // freshly attached widgets are visible
Static::WidgetRegistry::Register(Key, Found);
return m_label;
}
auto AttachLabel(UObject* Owner, UObject* Label) -> bool
{
Api::MainMenuPalWidget Panel{ Owner };
if (!Panel.IsValid())
{
Output::send<LogLevel::Error>(
STR("[PalNumberLabel] {} is not a MainMenuPalWidget\n"), Owner->GetFullName());
return false;
}
Api::CanvasPanel Canvas = Panel.CanvasPalName();
if (!Canvas.IsValid())
{
Canvas = Panel.Child<Api::CanvasPanel>(STR("Canvas_PalName"));
}
if (!Canvas.IsValid()) return false;
Api::PanelSlot Added = Canvas.AddChild(Label);
if (!Added.IsValid()) return false;
Api::CanvasPanelSlot LabelSlot{ Added.Get() };
if (LabelSlot.IsValid())
{
const Api::FVector2D_ Size = LabelSlot.GetSize().value_or(Api::FVector2D_{ 0, 0 });
LabelSlot.SetPosition(Api::FVector2D_{ Size.X * kLabelOffsetXFraction, kLabelOffsetY });
}
return true;
}
// ---- visibility ----------------------------------------------
auto ShowLabel(Api::Mod::PalStatus_PalNumberWidget& W) -> void
{
if (m_visible) return;
if (W.SetVisibility(Api::ESlateVisibility_::SelfHitTestInvisible)) m_visible = true;
}
auto HideLabel() -> void
{
if (!m_visible || !m_label) return;
Api::Widget W{ m_label };
if (!W.IsValid()) return;
if (W.SetVisibility(Api::ESlateVisibility_::Collapsed)) m_visible = false;
}
// ---- identity -------------------------------------------------
static auto ReadKey(UObject* Handle) -> std::optional<PalKey>
{
if (!Handle) return std::nullopt;
Api::Pal::LooseObject H{ Handle, STR("PalIndividualCharacterHandle") };
auto Id = H.CallForReturn<Api::Pal::FPalInstanceID>(STR("GetIndividualID"));
if (!Id.has_value()) return std::nullopt;
return PalKey{ Id->PlayerUID, Id->InstanceID };
}
static auto BuildBindingParam(const PalKey& Key) -> Api::Pal::FPalInstanceID
{
return Api::Pal::FPalInstanceID{ Key.PlayerUID, Key.InstanceID, FString{} };
}
auto AdoptOwner(UObject* Owner) -> void
{
m_owner = Owner;
m_label = nullptr;
m_has_key = false;
m_has_index = false;
m_visible = true;
}
auto ForgetAll() -> void
{
m_owner = nullptr;
m_label = nullptr;
m_has_key = false;
m_has_index = false;
m_visible = true;
}
static auto IsOurLabel(UObject* Candidate, UObject* Owner) -> bool
{
if (!Candidate || !Owner) return false;
if (Candidate->GetOuterPrivate() != Owner) return false;
return Api::ObjectIsA<Api::Mod::PalStatus_PalNumberWidget>(Candidate);
}
static auto RegistryKey(UObject* Owner) -> Static::WidgetRegistry::KeyType
{
static_assert(std::is_same_v<TCHAR, wchar_t>,
"RegistryKey builds its suffix with std::to_wstring");
return Static::WidgetRegistry::KeyType{ STR("PalStatusNumberLabel:") } +
std::to_wstring(reinterpret_cast<uintptr_t>(Owner));
}
static auto WidgetApi() -> Reflection::UnrealApiCall<Tag_PalStatusNumberWidget>&
{
static Reflection::UnrealApiCall<Tag_PalStatusNumberWidget> Api{
Api::Mod::PalStatus_PalNumberWidget::ClassPath(),
Api::Mod::PalStatus_PalNumberWidget::DebugTag() };
return Api;
}
static constexpr int32 kUnknownIndex = -1;
};
}