diff --git a/Hooks/ModCardHandOutlinePatchHelper.cs b/Hooks/ModCardHandOutlinePatchHelper.cs
new file mode 100644
index 00000000..1d5e5b90
--- /dev/null
+++ b/Hooks/ModCardHandOutlinePatchHelper.cs
@@ -0,0 +1,53 @@
+using Godot;
+using HarmonyLib;
+using MegaCrit.Sts2.Core.Combat;
+using MegaCrit.Sts2.Core.Models;
+using MegaCrit.Sts2.Core.Nodes.Cards.Holders;
+
+namespace BaseLib.Hooks;
+
+internal static class ModCardHandOutlinePatchHelper
+{
+ internal static bool TryGetRule(NHandCardHolder holder, out CardModel model, out ModCardHandOutlineRule rule)
+ {
+ model = null!;
+ rule = default;
+
+ if (!holder.IsNodeReady() || holder.CardNode?.Model is not { } m)
+ return false;
+
+ var evaluated = ModCardHandOutlineRegistry.EvaluateBest(m);
+ if (evaluated is not { } r)
+ return false;
+
+ model = m;
+ rule = r;
+ return true;
+ }
+
+ internal static void ApplyHighlight(NHandCardHolder holder, CardModel model, ModCardHandOutlineRule rule)
+ {
+ if (CombatManager.Instance is not { IsInProgress: true })
+ return;
+
+ var vanillaShow = model.CanPlay() || model.ShouldGlowRed || model.ShouldGlowGold;
+ var force = rule.VisibleWhenUnplayable && !vanillaShow;
+ if (!vanillaShow && !force)
+ return;
+
+ var highlight = holder.CardNode!.CardHighlight;
+ if (force)
+ highlight.AnimShow();
+
+ highlight.Modulate = rule.ResolveColor(model);
+ }
+
+ internal static void ApplyFlash(NHandCardHolder holder, CardModel model, ModCardHandOutlineRule rule)
+ {
+ if (AccessTools.Field(typeof(NHandCardHolder), "_flash")?.GetValue(holder) is not Control flash ||
+ !GodotObject.IsInstanceValid(flash))
+ return;
+
+ flash.Modulate = rule.ResolveColor(model);
+ }
+}
\ No newline at end of file
diff --git a/Hooks/ModCardHandOutlineRegistry.cs b/Hooks/ModCardHandOutlineRegistry.cs
new file mode 100644
index 00000000..c8b203bd
--- /dev/null
+++ b/Hooks/ModCardHandOutlineRegistry.cs
@@ -0,0 +1,268 @@
+using System.Collections.Concurrent;
+using Godot;
+using MegaCrit.Sts2.Core.Models;
+using MegaCrit.Sts2.Core.Nodes.Cards.Holders;
+
+namespace BaseLib.Hooks;
+
+///
+/// Per–card-type custom outline colors for the in-hand .
+/// Applied after vanilla via Harmony. Foreign providers (e.g. RitsuLib)
+/// merge via .
+///
+public static class ModCardHandOutlineRegistry
+{
+ private static readonly Func ForeignPredicateAlreadySatisfied = static _ => true;
+
+ private static int _sequence;
+ private static int _foreignOrder;
+
+ private static readonly ConcurrentDictionary> RulesByCardType = new();
+ private static readonly Lock ForeignLock = new();
+ private static readonly List ForeignProviders = [];
+ private static readonly List ForeignDynamicProviders = [];
+
+ ///
+ /// Registers a rule for .
+ ///
+ public static void Register(ModCardHandOutlineRule rule) where TCard : CardModel
+ {
+ Register(typeof(TCard), rule);
+ }
+
+ ///
+ /// Registers a rule for ( subtype).
+ ///
+ public static void Register(Type cardType, ModCardHandOutlineRule rule)
+ {
+ ArgumentNullException.ThrowIfNull(cardType);
+ ArgumentNullException.ThrowIfNull(rule.When);
+
+ if (!typeof(CardModel).IsAssignableFrom(cardType))
+ throw new ArgumentException(
+ $"Type '{cardType.FullName}' must be a subtype of {typeof(CardModel).FullName}.",
+ nameof(cardType));
+
+ var seq = Interlocked.Increment(ref _sequence);
+ var wrapped = new RegisteredRule(rule, seq);
+
+ RulesByCardType.AddOrUpdate(
+ cardType,
+ _ => [wrapped],
+ (_, existing) =>
+ {
+ var copy = new List(existing) { wrapped };
+ return copy;
+ });
+ }
+
+ ///
+ /// Merges outline evaluation from another assembly (e.g. RitsuLib). The delegate must return
+ /// when no rule applies, otherwise paint fields only — the foreign registry has already
+ /// evaluated When. Uses so the boundary stays a nullable struct (no heap boxing).
+ ///
+ public static void RegisterForeign(string modId, string sourceId,
+ Func evaluateBestFromForeign)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(modId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
+ ArgumentNullException.ThrowIfNull(evaluateBestFromForeign);
+
+ var order = Interlocked.Increment(ref _foreignOrder);
+ lock (ForeignLock)
+ {
+ ForeignProviders.Add(new ForeignProvider(evaluateBestFromForeign, order));
+ }
+ }
+
+ ///
+ /// Merges dynamic outline evaluation from another assembly. The delegate returns current paint resolver and metadata.
+ ///
+ public static void RegisterForeignDynamic(string modId, string sourceId,
+ Func ResolveColor, int Priority, bool VisibleWhenUnplayable)?> evaluateBestFromForeign)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(modId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
+ ArgumentNullException.ThrowIfNull(evaluateBestFromForeign);
+
+ var order = Interlocked.Increment(ref _foreignOrder);
+ lock (ForeignLock)
+ {
+ ForeignDynamicProviders.Add(new ForeignDynamicProvider(evaluateBestFromForeign, order));
+ }
+ }
+
+ ///
+ /// Clears all rules and foreign providers (tests / tooling).
+ ///
+ public static void ClearForTests()
+ {
+ RulesByCardType.Clear();
+ lock (ForeignLock)
+ {
+ ForeignProviders.Clear();
+ ForeignDynamicProviders.Clear();
+ }
+ }
+
+ ///
+ /// Applies the best matching registered outline for this holder.
+ ///
+ /// if a rule was applied.
+ public static bool TryRefreshOutlineForHolder(NHandCardHolder? holder)
+ {
+ if (holder == null || !holder.IsNodeReady() || holder.CardNode?.Model is not { } model)
+ return false;
+
+ var rule = EvaluateBest(model);
+ if (!rule.HasValue)
+ return false;
+
+ ModCardHandOutlinePatchHelper.ApplyHighlight(holder, model, rule.Value);
+ return true;
+ }
+
+ ///
+ /// Applies outline only when the winning rule uses .
+ ///
+ public static bool TryRefreshDynamicOutlineForHolder(NHandCardHolder? holder)
+ {
+ if (holder == null || !holder.IsNodeReady() || holder.CardNode?.Model is not { } model)
+ return false;
+
+ var rule = EvaluateBest(model);
+ if (!rule.HasValue || rule.Value.DynamicColor == null)
+ return false;
+
+ ModCardHandOutlinePatchHelper.ApplyHighlight(holder, model, rule.Value);
+ return true;
+ }
+
+ internal static ModCardHandOutlineRule? EvaluateBest(CardModel model)
+ {
+ var local = EvaluateLocalBest(model);
+ ForeignCandidate? foreignBest = null;
+
+ List snapshot;
+ lock (ForeignLock)
+ {
+ snapshot = [..ForeignProviders];
+ }
+
+ foreach (var provider in snapshot)
+ {
+ (Color Color, int Priority, bool VisibleWhenUnplayable)? foreignPaint;
+ try
+ {
+ foreignPaint = provider.Evaluate(model);
+ }
+ catch
+ {
+ continue;
+ }
+
+ if (foreignPaint is not { } paint)
+ continue;
+
+ var candidate = new ModCardHandOutlineRule(ForeignPredicateAlreadySatisfied, paint.Color, paint.Priority,
+ paint.VisibleWhenUnplayable);
+
+ if (foreignBest is null ||
+ RuleWins(candidate, provider.Order, foreignBest.Value.Rule, foreignBest.Value.Order))
+ foreignBest = new ForeignCandidate(candidate, provider.Order);
+ }
+
+ List dynamicSnapshot;
+ lock (ForeignLock)
+ {
+ dynamicSnapshot = [..ForeignDynamicProviders];
+ }
+
+ foreach (var provider in dynamicSnapshot)
+ {
+ (Func ResolveColor, int Priority, bool VisibleWhenUnplayable)? foreignPaint;
+ try
+ {
+ foreignPaint = provider.Evaluate(model);
+ }
+ catch
+ {
+ continue;
+ }
+
+ if (foreignPaint is not { } paint || paint.ResolveColor == null)
+ continue;
+
+ var candidate = new ModCardHandOutlineRule(
+ ForeignPredicateAlreadySatisfied,
+ paint.ResolveColor(),
+ paint.Priority,
+ paint.VisibleWhenUnplayable)
+ {
+ DynamicColor = _ => paint.ResolveColor(),
+ };
+
+ if (foreignBest is null ||
+ RuleWins(candidate, provider.Order, foreignBest.Value.Rule, foreignBest.Value.Order))
+ foreignBest = new ForeignCandidate(candidate, provider.Order);
+ }
+
+ switch (local)
+ {
+ case null when foreignBest is null:
+ return null;
+ case null:
+ return foreignBest.Value.Rule;
+ }
+
+ if (foreignBest is null)
+ return local.Value.Rule;
+
+ return RuleWins(foreignBest.Value.Rule, foreignBest.Value.Order, local.Value.Rule, local.Value.Sequence)
+ ? foreignBest.Value.Rule
+ : local.Value.Rule;
+ }
+
+ private static RegisteredRule? EvaluateLocalBest(CardModel model)
+ {
+ RegisteredRule? best = null;
+
+ for (var t = model.GetType();
+ t != null && typeof(CardModel).IsAssignableFrom(t);
+ t = t.BaseType)
+ {
+ if (!RulesByCardType.TryGetValue(t, out var list))
+ continue;
+
+ foreach (var entry in list.Where(entry => entry.Rule.When(model)).Where(entry => best is null
+ || entry.Rule.Priority > best.Value.Rule.Priority
+ || (entry.Rule.Priority == best.Value.Rule.Priority &&
+ entry.Sequence > best.Value.Sequence)))
+ best = entry;
+ }
+
+ return best;
+ }
+
+ private static bool RuleWins(ModCardHandOutlineRule challenger, int challengerOrder,
+ ModCardHandOutlineRule incumbent,
+ int incumbentOrder)
+ {
+ if (challenger.Priority != incumbent.Priority)
+ return challenger.Priority > incumbent.Priority;
+
+ return challengerOrder > incumbentOrder;
+ }
+
+ private readonly record struct RegisteredRule(ModCardHandOutlineRule Rule, int Sequence);
+
+ private readonly record struct ForeignProvider(
+ Func Evaluate,
+ int Order);
+
+ private readonly record struct ForeignDynamicProvider(
+ Func ResolveColor, int Priority, bool VisibleWhenUnplayable)?> Evaluate,
+ int Order);
+
+ private readonly record struct ForeignCandidate(ModCardHandOutlineRule Rule, int Order);
+}
\ No newline at end of file
diff --git a/Hooks/ModCardHandOutlineRule.cs b/Hooks/ModCardHandOutlineRule.cs
new file mode 100644
index 00000000..66046a5a
--- /dev/null
+++ b/Hooks/ModCardHandOutlineRule.cs
@@ -0,0 +1,53 @@
+using Godot;
+using MegaCrit.Sts2.Core.Models;
+
+namespace BaseLib.Hooks;
+
+///
+/// Custom hand-card outline tint for after vanilla
+/// playable / gold / red. Register with .
+///
+/// When this returns true for the card instance, the outline color may apply.
+/// Godot modulate color (alpha is respected; vanilla highlights use ~0.98).
+///
+/// When several rules match, the highest wins; ties favor the most recently registered
+/// rule.
+///
+///
+/// If true, the highlight is forced visible with this color even when the card is not playable and vanilla would not
+/// show gold/red (still only while combat is in progress).
+///
+public readonly record struct ModCardHandOutlineRule(
+ Func When,
+ Color Color,
+ int Priority = 0,
+ bool VisibleWhenUnplayable = false)
+{
+ ///
+ /// Optional dynamic color resolver. When assigned and passes, this is evaluated on refresh
+ /// to produce current outline color.
+ ///
+ public Func? DynamicColor { get; init; }
+
+ ///
+ /// Creates a rule with a dynamic color resolver.
+ ///
+ public static ModCardHandOutlineRule Dynamic(
+ Func when,
+ Func colorWhen,
+ int priority = 0,
+ bool visibleWhenUnplayable = false)
+ {
+ ArgumentNullException.ThrowIfNull(when);
+ ArgumentNullException.ThrowIfNull(colorWhen);
+ return new ModCardHandOutlineRule(when, Colors.White, priority, visibleWhenUnplayable)
+ {
+ DynamicColor = colorWhen,
+ };
+ }
+
+ internal Color ResolveColor(CardModel card)
+ {
+ return DynamicColor?.Invoke(card) ?? Color;
+ }
+}
\ No newline at end of file
diff --git a/Patches/Cards/NHandCardHolderHandOutlinePatches.cs b/Patches/Cards/NHandCardHolderHandOutlinePatches.cs
new file mode 100644
index 00000000..739de2a0
--- /dev/null
+++ b/Patches/Cards/NHandCardHolderHandOutlinePatches.cs
@@ -0,0 +1,96 @@
+using System.Collections.Concurrent;
+using BaseLib.Hooks;
+using Godot;
+using HarmonyLib;
+using MegaCrit.Sts2.Core.Helpers;
+using MegaCrit.Sts2.Core.Nodes.Cards.Holders;
+
+namespace BaseLib.Patches.Cards;
+
+[HarmonyPatch(typeof(NHandCardHolder), nameof(NHandCardHolder.UpdateCard))]
+internal static class NHandCardHolderUpdateCardHandOutlinePatch
+{
+ [HarmonyPostfix]
+ public static void Postfix(NHandCardHolder __instance)
+ {
+ if (!ModCardHandOutlinePatchHelper.TryGetRule(__instance, out var model, out var rule))
+ return;
+
+ ModCardHandOutlinePatchHelper.ApplyHighlight(__instance, model, rule);
+ }
+}
+
+[HarmonyPatch(typeof(NHandCardHolder), nameof(NHandCardHolder.Flash))]
+internal static class NHandCardHolderFlashHandOutlinePatch
+{
+ [HarmonyPostfix]
+ public static void Postfix(NHandCardHolder __instance)
+ {
+ if (!ModCardHandOutlinePatchHelper.TryGetRule(__instance, out var model, out var rule))
+ return;
+
+ ModCardHandOutlinePatchHelper.ApplyFlash(__instance, model, rule);
+ }
+}
+
+[HarmonyPatch(typeof(NHandCardHolder), nameof(NHandCardHolder._Ready))]
+internal static class NHandCardHolderDynamicOutlineReadyPatch
+{
+ private static readonly ConcurrentDictionary TokensByHolderId = new();
+
+ [HarmonyPostfix]
+ public static void Postfix(NHandCardHolder __instance)
+ {
+ if (!GodotObject.IsInstanceValid(__instance) || !__instance.IsInsideTree() || __instance.GetTree() == null)
+ return;
+
+ var id = __instance.GetInstanceId();
+ if (!TokensByHolderId.TryAdd(id, new CancellationTokenSource()))
+ return;
+
+ var cts = TokensByHolderId[id];
+ TaskHelper.RunSafely(RunDynamicRefreshLoop(__instance, id, cts.Token));
+ }
+
+ private static async Task RunDynamicRefreshLoop(NHandCardHolder holder, ulong id, CancellationToken token)
+ {
+ try
+ {
+ while (!token.IsCancellationRequested && GodotObject.IsInstanceValid(holder))
+ {
+ if (!holder.IsInsideTree())
+ break;
+
+ ModCardHandOutlineRegistry.TryRefreshDynamicOutlineForHolder(holder);
+ var tree = holder.GetTree();
+ if (tree == null || !GodotObject.IsInstanceValid(tree))
+ break;
+
+ await tree.ToSignal(tree, SceneTree.SignalName.ProcessFrame);
+ }
+ }
+ finally
+ {
+ StopLoop(id);
+ }
+ }
+
+ internal static void StopLoop(ulong id)
+ {
+ if (!TokensByHolderId.TryRemove(id, out var cts))
+ return;
+
+ cts.Cancel();
+ cts.Dispose();
+ }
+}
+
+[HarmonyPatch(typeof(NHandCardHolder), nameof(NHandCardHolder._ExitTree))]
+internal static class NHandCardHolderDynamicOutlineExitTreePatch
+{
+ [HarmonyPrefix]
+ public static void Prefix(NHandCardHolder __instance)
+ {
+ NHandCardHolderDynamicOutlineReadyPatch.StopLoop(__instance.GetInstanceId());
+ }
+}
\ No newline at end of file