449 lines
18 KiB
C++
449 lines
18 KiB
C++
|
|
#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;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|