Skip to content

Persistent UI Replacement, and Optimizations#16

Closed
Aeurias wants to merge 1 commit intoKSPModStewards:masterfrom
Aeurias:master
Closed

Persistent UI Replacement, and Optimizations#16
Aeurias wants to merge 1 commit intoKSPModStewards:masterfrom
Aeurias:master

Conversation

@Aeurias
Copy link

@Aeurias Aeurias commented Feb 27, 2026

  • Persistent singleton architecture, ensure that UI monitoring and texture replacement remain active across all game scenes without needing to be re-instantiated.
  • Reactive Harmony patches on UnityEngine.UI.Graphic.OnEnable and KSP.UI.UISkinManager.GetSkin. For texture replacement the moment a UI component is activated or a skin is retrieved.

Performance:

  • O(1) Lookup Cache: Implemented an idReplacementMap (Dictionary) and replacedTextureIds (HashSet) to track processed textures by their InstanceID. This bypasses expensive string-based matching and name resolution for previously encountered textures.
  • Raw byte[] data and basename strings for replacement textures are now pre-cached during the initial LoadTextures() phase, eliminating main-thread disk I/O bottlenecks during runtime replacement.
  • Optimized string manipulation within the replacement loop, favoring index-based substring operations over String.Split('/') to minimize heap allocations.

Skinning:

  • uGUI: Reflection-based skinning for UISkinDef templates. Discover UIStyle and UIStyleState fields to apply ZTheme assets.
  • IMGUI: Added support for intercepted skinning of the global HighLogic.Skin (GUISkin) to ensure appearance for legacy windows.

Scene Management:

  • Integrated with GameEvents.onLevelWasLoadedGUIReady to prevent the default KSP UI from being visible during scene loads.
  • Main Menu refresh upon entering the Main Menu to capture late-loading UI elements that are often reset by KSP's internal initialization.

Stability:

  • Debounced cursor updates, state tracking and a delayed Invoke mechanism for SetCursor calls to prevent redundant updates and console spam when multiple cursor textures are replaced in a single frame.
  • Replacement logic with additional null checks and graceful handling of empty asset dictionaries to ensure mod stability even when no texture packs are present.

- Persistent singleton architecture, ensure that UI monitoring and texture replacement remain active across all game scenes without needing to be re-instantiated.
- Reactive Harmony patches on `UnityEngine.UI.Graphic.OnEnable` and `KSP.UI.UISkinManager.GetSkin`. For texture replacement the moment a UI component is activated or a skin is retrieved.

Performance:
- O(1) Lookup Cache: Implemented an `idReplacementMap (Dictionary)` and `replacedTextureIds (HashSet)` to track processed textures by their `InstanceID`. This bypasses expensive string-based matching and name resolution for previously encountered textures.
- Raw `byte[]` data and `basename` strings for replacement textures are now pre-cached during the initial `LoadTextures()` phase, eliminating main-thread disk I/O bottlenecks during runtime replacement.
- Optimized string manipulation within the replacement loop, favoring index-based substring operations over `String.Split('/')` to minimize heap allocations.

Skinning:
- uGUI: Reflection-based skinning for `UISkinDef` templates. Discover `UIStyle` and `UIStyleState` fields to apply ZTheme assets.
- IMGUI: Added support for intercepted skinning of the global `HighLogic.Skin` (GUISkin) to ensure appearance for legacy windows.

Scene Management:
- Integrated with `GameEvents.onLevelWasLoadedGUIReady` to prevent the default KSP UI from being visible during scene loads.
- Main Menu refresh upon entering the Main Menu to capture late-loading UI elements that are often reset by KSP's internal initialization.

Stability:
- Debounced Cursor Updates, state tracking and a delayed `Invoke` mechanism for `SetCursor` calls to prevent redundant updates and console spam when multiple cursor textures are replaced in a single frame.
- Replacement logic with additional null checks and graceful handling of empty asset dictionaries to ensure mod stability even when no texture packs are present.
Copy link
Collaborator

@Phantomical Phantomical left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I liked the general idea of your approach, but there was a bunch of stuff I wasn't quite happy about how it worked so I ended up rewriting almost all of HUDReplacer on top of your branch.

So here's the things that I think were good ideas and I ended up keeping:

  • Comparing texture instance ids to see if the texture has been replaced. This is definitely way faster than doing a bunch of string ops and then calling LoadImage.
  • Moving the texture replace to onLevelWasLoadedGUIReady. This definitely makes more sense then doing it in HUDReplacer.Awake
  • Loading all the file data up-front.
  • Explicitly adding the textures from HighLogic.Skin. Turns out these are lazily loaded the first time the fields for the individual textures are accessed. This was the one thing missing to actually replace the textures for imgui UIs. I ended up doing it lazily the first time HighLogic.Skin is called in a scene for performance reasons, but the approach makes sense.

I have left comments on the remaining things I am confused about. You don't need to fix them or anything, I have already rebuilt HUDReplacer on top of your code :). I just figure doing a proper code review would help.

Comment on lines +883 to +905
[HarmonyPatch(typeof(KSP.UI.UIMasterController), "Awake")]
class Patch_UIMasterController_Awake
{
static void Postfix()
{
if (HUDReplacer.Instance != null)
{
HUDReplacer.Instance.RefreshAll();
}
}
}

[HarmonyPatch(typeof(KSP.UI.Screens.ApplicationLauncher), "Awake")]
class Patch_ApplicationLauncher_Awake
{
static void Postfix()
{
if (HUDReplacer.Instance != null)
{
HUDReplacer.Instance.RefreshAll();
}
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point of adding these? Both of them run before OnLevelGUIReady gets called and I'm pretty sure that KSP won't show any UI until after OnLevelGUIReady is called. This seems like it would result in extra work for no visible difference.

Comment on lines 112 to +131
public void Awake()
{
instance = this;
Debug.Log("HUDReplacer: Running scene change. " + HighLogic.LoadedScene);
if (Instance != null)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);

Debug.Log("HUDReplacer: Initializing persistent instance.");

if (Images is null)
if (Images == null || Images.Count == 0)
LoadTextures();

Debug.Log("HUDReplacer: Replacing textures...");
ReplaceTextures();
Debug.Log("HUDReplacer: Textures have been replaced!");
Debug.Log("HUDReplacer: Initial texture replacement...");
RefreshAll();

GameEvents.onLevelWasLoadedGUIReady.Add(OnLevelGUIReady);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs before module manager patching finishes, so it would be wrong to do so. The correct place would be to have the addon started either in the main menu or PSystemSetup.

Comment on lines +150 to +166
public void RunMainMenuRefreshSequence()
{
float[] delays = { 0.5f, 1.2f, 2.0f };
foreach (float delay in delays)
{
this.Invoke(
() =>
{
Debug.Log($"HUDReplacer: Performing Main Menu refresh ({delay}s).");
replacedTextureIds.Clear();
idReplacementMap.Clear();
RefreshAll();
},
delay
);
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point in this? What UIs elements are you finding aren't getting replaced?

}
}

public void ApplyUISkinDef(object uiSkinDef)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflection tends to be very slow. It's also not necessary in this case because all the relevant fields here are public (and in general, you can use Krafs.Publicizer to access private fields directly).

Comment on lines +61 to +90
[HarmonyPatch(typeof(Graphic), "OnEnable")]
class Patch_Graphic_OnEnable
{
static void Postfix(Graphic __instance)
{
if (HUDReplacer.Instance == null)
return;

if (__instance is Image img)
{
Texture2D tex = null;
if (img.sprite != null && img.sprite.texture != null)
tex = img.sprite.texture;
else if (img.mainTexture is Texture2D mTex)
tex = mTex;

if (tex != null)
{
HUDReplacer.Instance.ReplaceTextures(new Texture2D[] { tex });
}
}
else if (__instance is RawImage rawImg)
{
if (rawImg.texture is Texture2D tex)
{
HUDReplacer.Instance.ReplaceTextures(new Texture2D[] { tex });
}
}
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm struggling to think of cases where this patch will replace something that is not covered by the textures getting replaced in OnLevelGUIReady. Have you run into cases where mods are loading GUI textures in the middle of a scene?

Comment on lines +49 to +59
[HarmonyPatch(typeof(UISkinManager), nameof(UISkinManager.GetSkin), new Type[] { typeof(string) })]
class Patch_UISkinManager_GetSkin
{
static void Postfix(UISkinDef __result)
{
if (HUDReplacer.Instance != null && __result != null)
{
HUDReplacer.Instance.ApplyUISkinDef(__result);
}
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turns out that this isn't necessary because all the builtin UISkinDef instances are instantiated right away when the game is loaded.

@Phantomical
Copy link
Collaborator

Like I said above, this got merged in c6b9084 so I'll be closing it. Thanks for the PR!

@Phantomical Phantomical closed this Mar 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants