#pragma once #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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(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 m_slots; // ---- hooks --------------------------------------------------- static void OnUpdateSlotBinded(UnrealScriptFunctionCallableContext& Ctx, void* CustomData) { auto* Self = static_cast(CustomData); if (!Self || !Self->m_enabled || !Ctx.Context) return; auto& Frame = Ctx.GetParams(); Self->Apply(Ctx.Context, Frame.TargetSlot); } static void OnSetEmpty(UnrealScriptFunctionCallableContext& Ctx, void* CustomData) { auto* Self = static_cast(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 { if (!Slot) return std::nullopt; Api::Pal::LooseObject S{ Slot, STR("PalIndividualSlot") }; UObject* Handle = S.CallForReturn(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(); 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(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(); 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; } }; }