From 6f232573c7e8f672c89e491066b355bdc448abdf Mon Sep 17 00:00:00 2001 From: aonkeeper4 Date: Thu, 18 Dec 2025 22:08:43 +0000 Subject: [PATCH 1/3] Add Everest reskin support for the tabs/options on chapter panels for collab maps --- UI/InGameOverworldHelper.cs | 185 +++++++++++++++++++----------------- 1 file changed, 100 insertions(+), 85 deletions(-) diff --git a/UI/InGameOverworldHelper.cs b/UI/InGameOverworldHelper.cs index 25fc41d..64316aa 100644 --- a/UI/InGameOverworldHelper.cs +++ b/UI/InGameOverworldHelper.cs @@ -2,6 +2,7 @@ using Celeste.Mod.Entities; using Celeste.Mod.Helpers; using Microsoft.Xna.Framework; +using Mono.Cecil; using Mono.Cecil.Cil; using Monocle; using MonoMod.Cil; @@ -107,6 +108,8 @@ public static List Parse(string dialog) { private static Hook onReloadLevelHook; private static Hook onChangePresenceHook; + private static ILHook ilSwapRoutineHook; + internal static void Load() { Everest.Events.Level.OnPause += OnPause; On.Celeste.Audio.SetMusic += OnSetMusic; @@ -134,6 +137,11 @@ internal static void Load() { onChangePresenceHook = new Hook( typeof(Everest.DiscordSDK).GetMethod("UpdatePresence", BindingFlags.NonPublic | BindingFlags.Instance), typeof(InGameOverworldHelper).GetMethod("OnDiscordChangePresence", BindingFlags.NonPublic | BindingFlags.Static)); + + + ilSwapRoutineHook = new ILHook( + typeof(OuiChapterPanel).GetMethod("SwapRoutine", BindingFlags.NonPublic | BindingFlags.Instance)!.GetStateMachineTarget()!, + ModOuiChapterPanelSwapRoutine); hookOnMapDataOrigLoad = new Hook( typeof(MapData).GetMethod("orig_Load", BindingFlags.NonPublic | BindingFlags.Instance), @@ -351,6 +359,7 @@ private static void OnChapterPanelReset(On.Celeste.OuiChapterPanel.orig_Reset or self.modes.Add(new OuiChapterPanel.Option { Label = Dialog.Clean("collabutils2_overworld_gym"), BgColor = Calc.HexToColor("FFD07E"), + Bg = GFX.Gui[GetModdedPath(self, "areaselect/tab")], Icon = GFX.Gui["CollabUtils2/menu/ppt"], }); } @@ -364,6 +373,7 @@ private static void OnChapterPanelReset(On.Celeste.OuiChapterPanel.orig_Reset or self.modes.Add(new OuiChapterPanel.Option { Label = Dialog.Clean("collabutils2_overworld_exit"), BgColor = Calc.HexToColor("FA5139"), + Bg = GFX.Gui[GetModdedPath(self, "areaselect/tab")], Icon = GFX.Gui["menu/exit"], }); @@ -375,6 +385,10 @@ private static void OnChapterPanelReset(On.Celeste.OuiChapterPanel.orig_Reset or private class OuiChapterPanelGymOption : OuiChapterPanel.Option { public string GymTechDifficuty; } + + private static MethodInfo m_OuiChapterPanel__ModAreaselectTexture = typeof(OuiChapterPanel).GetMethod("_ModAreaselectTexture", BindingFlags.NonPublic | BindingFlags.Instance)!; + private static string GetModdedPath(OuiChapterPanel panel, string path) + => (string) m_OuiChapterPanel__ModAreaselectTexture.Invoke(panel, [path]); private static void ChapterPanelSwapToGym(OuiChapterPanel self) { self.Area.Mode = (AreaMode) 1; @@ -400,6 +414,7 @@ private static void ChapterPanelSwapToGym(OuiChapterPanel self) { var checkpoint = new OuiChapterPanelGymOption { Label = Dialog.Clean($"{LobbyHelper.GetCollabNameForSID(techInfo.AreaSID)}_gym_{techName}_name", null), BgColor = difficultyColors[techInfo.Difficulty], + Bg = GFX.Gui[GetModdedPath(self, "areaselect/tab")], Icon = GFX.Gui[$"CollabUtils2/areaselect/startpoint_{techInfo.Difficulty}"], CheckpointLevelName = $"{techInfo.AreaSID}|{techInfo.Level}", Large = false, @@ -571,30 +586,23 @@ private static int OnChapterPanelGetModeHeight(On.Celeste.OuiChapterPanel.orig_G return orig(self); } - private static void OnChapterPanelSwap(On.Celeste.OuiChapterPanel.orig_Swap orig, OuiChapterPanel self) { - if (Engine.Scene != overworldWrapper?.Scene || (!gymSubmenuSelected(self) - && !Dialog.Has(collabInGameForcedArea.Name + "_collabcredits") - && !Dialog.Has(collabInGameForcedArea.Name + "_collabcreditstags") - && !CollabModule.Instance.SaveData.SessionsPerLevel.ContainsKey(self.Area.SID))) { - - // this isn't an in-game chapter panel, or there is no custom second page (no credits, no saved state, no gyms) => use vanilla - orig(self); - return; - } - - bool selectingMode = self.selectingMode; + private static bool ShouldModChapterPanelSwap(OuiChapterPanel self) + => Engine.Scene == overworldWrapper?.Scene && (gymSubmenuSelected(self) + || Dialog.Has(collabInGameForcedArea.Name + "_collabcredits") + || Dialog.Has(collabInGameForcedArea.Name + "_collabcreditstags") + || CollabModule.Instance.SaveData.SessionsPerLevel.ContainsKey(self.Area.SID)); - if (!selectingMode) { + private static void OnChapterPanelSwap(On.Celeste.OuiChapterPanel.orig_Swap orig, OuiChapterPanel self) { + if (!ShouldModChapterPanelSwap(self) || !self.selectingMode) { + // this isn't an in-game chapter panel, + // or there is no custom second page (no credits, no saved state, no gyms), + // or we are not swapping from the mode select screen => use vanilla orig(self); return; } if (gymSubmenuSelected(self)) { activeGymTech = CollabMapDataProcessor.GymLevels[collabInGameForcedArea.SID].Tech; - - self.Focused = false; - self.Overworld.ShowInputUI = !selectingMode; - self.Add(new Coroutine(ChapterPanelSwapGymsRoutine(self))); } else { string areaName = collabInGameForcedArea.Name; panelCollabCredits = FancyText.Parse(Dialog.Get(areaName + "_collabcredits").Replace("{break}", "{n}"), int.MaxValue, int.MaxValue, defaultColor: Color.Black); @@ -604,39 +612,86 @@ private static void OnChapterPanelSwap(On.Celeste.OuiChapterPanel.orig_Swap orig } else { panelCollabCreditsTags = new List(); } - - self.Focused = false; - self.Overworld.ShowInputUI = !selectingMode; - self.Add(new Coroutine(ChapterPanelSwapRoutine(self))); } - } - - private static IEnumerator ChapterPanelSwapRoutine(OuiChapterPanel self) { - float fromHeight = self.height; - string forcedArea = collabInGameForcedArea.Name; - int toHeight = Dialog.Has(forcedArea + "_collabcredits") ? 730 : (Dialog.Has(forcedArea + "_collabcreditstags") ? 450 : 300); - self.resizing = true; - self.PlayExpandSfx(fromHeight, toHeight); + orig(self); + } - float offset = 800f; - for (float p = 0f; p < 1f; p += Engine.DeltaTime * 4f) { - yield return null; - self.contentOffset = new Vector2(440f + offset * Ease.CubeIn(p), self.contentOffset.Y); - self.height = MathHelper.Lerp(fromHeight, toHeight, Ease.CubeOut(p * 0.5f)); + private static void ModOuiChapterPanelSwapRoutine(ILContext il) { + ILCursor cursor = new(il); + + // mod the routine's `toHeight` + if (cursor.TryGotoNextBestFit(MoveType.AfterLabel, + instr => instr.MatchStfld(out FieldReference toHeight) + && toHeight.DeclaringType.Name.Contains("SwapRoutine") + && toHeight.Name.Contains("toHeight"))) { + cursor.EmitLdloc1(); + cursor.EmitDelegate(ModToHeight); + } + + // replace the chapter panel's checkpoints with a single dummy checkpoint + if (cursor.TryGotoNextBestFit(MoveType.After, + instr => instr.MatchCall("_GetCheckpoints"))) { + cursor.EmitLdloc1(); + cursor.EmitDelegate(ModCheckpoints); + } + + // mod the chapter panel's options + if (cursor.TryGotoNextBestFit(MoveType.After, + instr => instr.MatchLdloc1(), + instr => instr.MatchLdcI4(0), + instr => instr.MatchCallvirt("set_option"))) { + cursor.MoveAfterLabels(); + cursor.EmitLdloc1(); + cursor.EmitDelegate(ModOptions); + } + + return; + + static int ModToHeight(int orig, OuiChapterPanel panel) { + return ShouldModChapterPanelSwap(panel) && panel.selectingMode + ? gymSubmenuSelected(panel) + ? GetChapterPanelGymToHeight(panel) + : GetChapterPanelToHeight(panel) + : orig; + } + + static HashSet ModCheckpoints(HashSet orig, OuiChapterPanel panel) { + return ShouldModChapterPanelSwap(panel) && !panel.selectingMode + ? ["CollabUtils2_dummyCheckpoint"] + : orig; + } + + static void ModOptions(OuiChapterPanel panel) { + if (!ShouldModChapterPanelSwap(panel) || panel.selectingMode) + return; + + if (gymSubmenuSelected(panel)) { + SetupChapterPanelGymOptions(panel); + } else { + SetupChapterPanelOptions(panel); + } } + } - self.selectingMode = false; + private static int GetChapterPanelToHeight(OuiChapterPanel self) { + string forcedArea = collabInGameForcedArea.Name; + return Dialog.Has(forcedArea + "_collabcredits") ? 730 : (Dialog.Has(forcedArea + "_collabcreditstags") ? 450 : 300); + } + private static void SetupChapterPanelOptions(OuiChapterPanel self) { List checkpoints = self.checkpoints; + Color? startOptionColor = checkpoints.FirstOrDefault(option => option.Icon.AtlasPath.EndsWith("startpoint"))?.BgColor; + Color? checkpointOptionColor = checkpoints.FirstOrDefault(option => option.Icon.AtlasPath.EndsWith("checkpoint"))?.BgColor; checkpoints.Clear(); bool hasContinueOption = CollabModule.Instance.SaveData.SessionsPerLevel.ContainsKey(self.Area.SID); checkpoints.Add(new OuiChapterPanel.Option { Label = Dialog.Clean(hasContinueOption ? "collabutils2_chapterpanel_start" : "overworld_start", null), - BgColor = Calc.HexToColor("eabe26"), - Icon = GFX.Gui["areaselect/startpoint"], + BgColor = startOptionColor ?? Calc.HexToColor("eabe26"), + Bg = GFX.Gui[GetModdedPath(self, "areaselect/tab")], + Icon = GFX.Gui[GetModdedPath(self, "areaselect/startpoint")], CheckpointRotation = Calc.Random.Choose(-1, 1) * Calc.Random.Range(0.05f, 0.2f), CheckpointOffset = new Vector2(Calc.Random.Range(-16, 16), Calc.Random.Range(-16, 16)), Large = false, @@ -646,7 +701,9 @@ private static IEnumerator ChapterPanelSwapRoutine(OuiChapterPanel self) { if (hasContinueOption) { checkpoints.Add(new OuiChapterPanel.Option { Label = Dialog.Clean("collabutils2_chapterpanel_continue", null), - Icon = GFX.Gui["areaselect/checkpoint"], + BgColor = checkpointOptionColor ?? Calc.HexToColor("3c6180"), + Bg = GFX.Gui[GetModdedPath(self, "areaselect/tab")], + Icon = GFX.Gui[GetModdedPath(self, "areaselect/checkpoint")], CheckpointRotation = Calc.Random.Choose(-1, 1) * Calc.Random.Range(0.05f, 0.2f), CheckpointOffset = new Vector2(Calc.Random.Range(-16, 16), Calc.Random.Range(-16, 16)), Large = false, @@ -656,40 +713,13 @@ private static IEnumerator ChapterPanelSwapRoutine(OuiChapterPanel self) { } self.option = hasContinueOption ? 1 : 0; - - for (int i = 0; i < checkpoints.Count; i++) { - checkpoints[i].SlideTowards(i, checkpoints.Count, true); - } - - checkpoints[hasContinueOption ? 1 : 0].Pop = 1f; - for (float p = 0f; p < 1f; p += Engine.DeltaTime * 4f) { - yield return null; - self.height = MathHelper.Lerp(fromHeight, toHeight, Ease.CubeOut(Math.Min(1f, 0.5f + p * 0.5f))); - self.contentOffset = new Vector2(440f + offset * (1f - Ease.CubeOut(p)), self.contentOffset.Y); - } - - self.contentOffset = new Vector2(440f, self.contentOffset.Y); - self.height = toHeight; - self.Focused = true; - self.resizing = false; } - private static IEnumerator ChapterPanelSwapGymsRoutine(OuiChapterPanel self) { - float fromHeight = self.height; - int toHeight = 730; - - self.resizing = true; - self.PlayExpandSfx(fromHeight, toHeight); - - float offset = 800f; - for (float p = 0f; p < 1f; p += Engine.DeltaTime * 4f) { - yield return null; - self.contentOffset = new Vector2(440f + offset * Ease.CubeIn(p), self.contentOffset.Y); - self.height = MathHelper.Lerp(fromHeight, toHeight, Ease.CubeOut(p * 0.5f)); - } - - self.selectingMode = false; + private static int GetChapterPanelGymToHeight(OuiChapterPanel self) { + return 730; + } + private static void SetupChapterPanelGymOptions(OuiChapterPanel self) { List checkpoints = self.checkpoints; checkpoints.Clear(); @@ -699,6 +729,7 @@ private static IEnumerator ChapterPanelSwapGymsRoutine(OuiChapterPanel self) { var checkpoint = new OuiChapterPanelGymOption { Label = Dialog.Clean($"{LobbyHelper.GetCollabNameForSID(techInfo.AreaSID)}_gym_{techName}_name", null), BgColor = difficultyColors[techInfo.Difficulty], + Bg = GFX.Gui[GetModdedPath(self, "areaselect/tab")], Icon = GFX.Gui[$"CollabUtils2/areaselect/startpoint_{techInfo.Difficulty}"], CheckpointLevelName = $"{techInfo.AreaSID}|{techInfo.Level}", Large = false, @@ -709,22 +740,6 @@ private static IEnumerator ChapterPanelSwapGymsRoutine(OuiChapterPanel self) { } self.option = 0; - - for (int i = 0; i < checkpoints.Count; i++) { - checkpoints[i].SlideTowards(i, checkpoints.Count, true); - } - - checkpoints[0].Pop = 1f; - for (float p = 0f; p < 1f; p += Engine.DeltaTime * 4f) { - yield return null; - self.height = MathHelper.Lerp(fromHeight, toHeight, Ease.CubeOut(Math.Min(1f, 0.5f + p * 0.5f))); - self.contentOffset = new Vector2(440f + offset * (1f - Ease.CubeOut(p)), self.contentOffset.Y); - } - - self.contentOffset = new Vector2(440f, self.contentOffset.Y); - self.height = toHeight; - self.Focused = true; - self.resizing = false; } private static void OnChapterPanelDrawCheckpoint(On.Celeste.OuiChapterPanel.orig_DrawCheckpoint orig, OuiChapterPanel self, Vector2 center, object option, int checkpointIndex) { From c6c26d4d7c1047d0ea80406e22e250527af6a589 Mon Sep 17 00:00:00 2001 From: aonkeeper4 Date: Thu, 18 Dec 2025 22:14:07 +0000 Subject: [PATCH 2/3] Dispose SwapRoutine ILHook on unload --- UI/InGameOverworldHelper.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/UI/InGameOverworldHelper.cs b/UI/InGameOverworldHelper.cs index 64316aa..d04c13c 100644 --- a/UI/InGameOverworldHelper.cs +++ b/UI/InGameOverworldHelper.cs @@ -138,7 +138,6 @@ internal static void Load() { typeof(Everest.DiscordSDK).GetMethod("UpdatePresence", BindingFlags.NonPublic | BindingFlags.Instance), typeof(InGameOverworldHelper).GetMethod("OnDiscordChangePresence", BindingFlags.NonPublic | BindingFlags.Static)); - ilSwapRoutineHook = new ILHook( typeof(OuiChapterPanel).GetMethod("SwapRoutine", BindingFlags.NonPublic | BindingFlags.Instance)!.GetStateMachineTarget()!, ModOuiChapterPanelSwapRoutine); @@ -197,6 +196,9 @@ internal static void Unload() { onChangePresenceHook?.Dispose(); onChangePresenceHook = null; + + ilSwapRoutineHook?.Dispose(); + ilSwapRoutineHook = null; } internal static AreaData collabInGameForcedArea; From b9c76b9177cc693f54d7eccbc83613134f9398c9 Mon Sep 17 00:00:00 2001 From: aonkeeper4 Date: Fri, 19 Dec 2025 00:13:39 +0000 Subject: [PATCH 3/3] Fix crash when using "Learn" tab --- UI/InGameOverworldHelper.cs | 76 +++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/UI/InGameOverworldHelper.cs b/UI/InGameOverworldHelper.cs index d04c13c..b72a392 100644 --- a/UI/InGameOverworldHelper.cs +++ b/UI/InGameOverworldHelper.cs @@ -631,7 +631,7 @@ private static void ModOuiChapterPanelSwapRoutine(ILContext il) { cursor.EmitDelegate(ModToHeight); } - // replace the chapter panel's checkpoints with a single dummy checkpoint + // replace the chapter panel's checkpoints with a single null checkpoint if (cursor.TryGotoNextBestFit(MoveType.After, instr => instr.MatchCall("_GetCheckpoints"))) { cursor.EmitLdloc1(); @@ -660,7 +660,7 @@ static int ModToHeight(int orig, OuiChapterPanel panel) { static HashSet ModCheckpoints(HashSet orig, OuiChapterPanel panel) { return ShouldModChapterPanelSwap(panel) && !panel.selectingMode - ? ["CollabUtils2_dummyCheckpoint"] + ? [null] : orig; } @@ -927,31 +927,27 @@ private static void ModOuiChapterPanelRender(ILContext il) { cursor.Index = 0; - // 5-1. Get line to jump to after the next injection. - ILLabel afterOptionLabel = cursor.DefineLabel(); - - if (cursor.TryGotoNext(MoveType.Before, - instr => instr.MatchLdarg(0), - instr => instr.MatchLdfld("selectingMode"))) { - cursor.MarkLabel(afterOptionLabel); - } - - cursor.Index = 0; - - // 5-2. Draw the difficulty underneath the checkpoint label in gyms. + // 5. Draw the difficulty underneath the checkpoint label in gyms. while (cursor.TryGotoNextBestFit(MoveType.Before, - instr => instr.MatchLdarg(0), - instr => instr.MatchCallvirt("get_options"), - instr => instr.MatchLdarg(0), - instr => instr.MatchCallvirt("get_option"), - instr => true, - instr => instr.MatchLdfld(typeof(OuiChapterPanel.Option), "Label"))) { + instr => instr.MatchCall(typeof(ActiveFont), "Draw"), + instr => instr.MatchLdarg0(), + instr => instr.MatchLdfld("selectingMode"), + instr => instr.MatchBrfalse(out _))) { Logger.Log("CollabUtils2/InGameOverworldHelper", $"Modding chapter option label position at {cursor.Index} in IL for OuiChapterPanel.Render"); + ILLabel normalDraw = cursor.DefineLabel(), afterNormalDraw = cursor.DefineLabel(); + + // `if (shouldModChapterOptionLabelPosition(this)) { modChapterOptionLabelPosition(..., this); } else { ActiveFont.Draw(...); }` cursor.Emit(OpCodes.Ldarg_0); - cursor.EmitDelegate>(modChapterOptionLabelPosition); + cursor.EmitDelegate(shouldModChapterOptionLabelPosition); + cursor.Emit(OpCodes.Brfalse, normalDraw); + cursor.EmitLdarg0(); + cursor.EmitDelegate(modChapterOptionLabelPosition); + cursor.Emit(OpCodes.Br, afterNormalDraw); + cursor.MarkLabel(normalDraw); + cursor.Index++; // original call to `ActiveFont.Draw` is here + cursor.MarkLabel(afterNormalDraw); - cursor.Emit(OpCodes.Brtrue, afterOptionLabel); cursor.Index++; } } @@ -999,21 +995,27 @@ private static bool hideChapterNumberIfNecessary(bool orig, OuiChapterPanel self } } - private static bool modChapterOptionLabelPosition(OuiChapterPanel self) { - if (overworldWrapper != null) { - if (gymSubmenuSelected(self) && !self.selectingMode) { - var option = self.options[self.option]; - string difficulty = option is OuiChapterPanelGymOption o ? o.GymTechDifficuty : null; - if (difficulty != null) { - string difficultyLabel = Dialog.Clean($"collabutils2_difficulty_{difficulty}"); - Vector2 renderPos = self.OptionsRenderPosition; - ActiveFont.Draw(option.Label, renderPos + new Vector2(0f, -140f), new Vector2(0.5f, 1f), Vector2.One * (1f + self.wiggler.Value * 0.1f), Color.Black * 0.8f); - ActiveFont.Draw(difficultyLabel, renderPos + new Vector2(0f, -140f), new Vector2(0.5f, 0f), Vector2.One * 0.6f * (1f + self.wiggler.Value * 0.1f), Color.Black * 0.8f); - return true; - } - } - } - return false; + private static bool shouldModChapterOptionLabelPosition(OuiChapterPanel self) { + if (overworldWrapper == null) + return false; + + if (!gymSubmenuSelected(self) || self.selectingMode) + return false; + + if (self.options[self.option] is not OuiChapterPanelGymOption) + return false; + + return true; + } + + private static void modChapterOptionLabelPosition(string text, Vector2 position, Vector2 justify, Vector2 scale, Color color, OuiChapterPanel self) { + OuiChapterPanel.Option option = self.options[self.option]; + string difficulty = option is OuiChapterPanelGymOption o ? o.GymTechDifficuty : null; + string difficultyLabel = Dialog.Clean($"collabutils2_difficulty_{difficulty}"); + Vector2 renderPos = self.OptionsRenderPosition; + + ActiveFont.Draw(option.Label, renderPos + new Vector2(0f, -140f), new Vector2(0.5f, 1f), Vector2.One * (1f + self.wiggler.Value * 0.1f), color); + ActiveFont.Draw(difficultyLabel, renderPos + new Vector2(0f, -140f), new Vector2(0.5f, 0f), Vector2.One * 0.6f * (1f + self.wiggler.Value * 0.1f), color); } private static IEnumerator OnJournalEnter(On.Celeste.OuiJournal.orig_Enter orig, OuiJournal self, Oui from) {