Files
PalUIExtension/Features/PalBoxNightWorkIndicator.hpp

449 lines
18 KiB
C++
Raw Permalink Normal View History

2026-09-07 19:33:58 -04:00
#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;
}
};
}