diff --git a/Celeste.Mod.mm/Mod/UI/CriticalErrorHandler.cs b/Celeste.Mod.mm/Mod/UI/CriticalErrorHandler.cs index 24d0d81da..6619937b5 100644 --- a/Celeste.Mod.mm/Mod/UI/CriticalErrorHandler.cs +++ b/Celeste.Mod.mm/Mod/UI/CriticalErrorHandler.cs @@ -1,3 +1,4 @@ +using Celeste.Editor; using Celeste.Mod.Core; using Celeste.Mod.Helpers; using Microsoft.Xna.Framework; @@ -17,6 +18,11 @@ namespace Celeste.Mod.UI { public sealed class CriticalErrorHandler : Overlay, IDisposable { + private bool NoFade = false; + private float PlayerSpriteSize = MaxPlayerSpriteSize; + private const float MaxPlayerSpriteSize = 0.5f; + private const float MaxOptionsHeight = Celeste.TargetHeight * 0.9f; + private const float MaxOptionSpacing = 4; private static readonly FieldInfo f_Engine_scene = typeof(Engine).GetField("scene", BindingFlags.NonPublic | BindingFlags.Instance); private static readonly FieldInfo f_Engine_nextScene = typeof(Engine).GetField("nextScene", BindingFlags.NonPublic | BindingFlags.Instance); @@ -38,12 +44,13 @@ private enum UserChoice { FlushSaveData, RetryLevel, SaveAndQuit, - ReturnToMainMenu + ReturnToMainMenu, + OpenDebugMap } public static CriticalErrorHandler CurrentHandler { get; private set; } - public static ExceptionDispatchInfo HandleCriticalError(ExceptionDispatchInfo error) { + public static ExceptionDispatchInfo HandleCriticalError(ExceptionDispatchInfo error, DisplayState? targetState = null) { if (!CoreModule.Settings.UseInGameCrashHandler) return error; @@ -62,6 +69,8 @@ public static ExceptionDispatchInfo HandleCriticalError(ExceptionDispatchInfo er CurrentHandler.EncounteredAdditionalErrors = true; AmendLogFile(CurrentHandler.LogFile, "Encountered an additional critical error", error.SourceException); } + if (targetState is { } state) + CurrentHandler.State = state; // Invoke the critical error event Everest.Events.CriticalError(CurrentHandler); @@ -114,10 +123,11 @@ static void DoImmediateSceneSwitch(Scene newScene) { CurrentHandler.RemoveSelf(); oldScene?.Entities?.UpdateLists(); - Scene errorScene = new Scene(); - errorScene.Add(new HudRenderer()); - errorScene.Add(new HiresSnow()); - errorScene.Add(CurrentHandler); + Scene errorScene = new Scene { + new HudRenderer(), + new HiresSnow(), + CurrentHandler + }; CurrentHandler.State = DisplayState.CleanScene; errorScene.Entities.UpdateLists(); @@ -136,7 +146,7 @@ static void DoImmediateSceneSwitch(Scene newScene) { blueScreenScene.Add(CurrentHandler); CurrentHandler.State = DisplayState.BlueScreen; blueScreenScene.Entities.UpdateLists(); - + DoImmediateSceneSwitch(blueScreenScene); return null; } @@ -205,9 +215,9 @@ static string EvalSafe(Func func) { static string FormatByteCount(long bytes) { switch (bytes) { case >= 1024L*1024L*1024L*1024L: return $"{bytes / (1024L*1024L*1024L) / 1024f}TB"; // This should never happen, but just to be future proof .-. - case >= 1024L*1024L*1024L: return $"{bytes / (1024L*1024) / 1024f}GB"; - case >= 1024L*1024L: return $"{bytes / (1024L) / 1024f}MB"; - case >= 1024L: return $"{bytes / 1024f}KB"; + case >= 1024L*1024L*1024L: return $"{bytes / (1024L*1024) / 1024f}GB"; + case >= 1024L*1024L: return $"{bytes / (1024L) / 1024f}MB"; + case >= 1024L: return $"{bytes / 1024f}KB"; default: return $"{bytes}B"; } } @@ -240,7 +250,7 @@ static string FormatByteCount(long bytes) { updatesAvailable = false; } } - + foreach (EverestModule mod in Everest._Modules) { writer.Write($" - {mod.Metadata.Name}: "); writer.Write($"{mod.Metadata.VersionString}"); @@ -261,7 +271,7 @@ static string FormatByteCount(long bytes) { writer.WriteLine($"Crash HResult: {error.HResult}"); writer.WriteLine($"Inner exception (if any): {error.InnerException}"); writer.WriteLine(); - + StackTrace trace = new(error, true); StackFrame latestFrame = trace.GetFrame(0); if (latestFrame != null) { @@ -317,7 +327,7 @@ public DisplayState State { private set { state = value; if (optMenu != null) - ConfigureOptionsMenu(); + ConfigureOptionsMenu(); } } @@ -361,13 +371,12 @@ public void Dispose() { playerRenderTarget = null; } + private UserChoice? MenuChoice; + private IEnumerator Routine() { - retry:; - if (State != DisplayState.BlueScreen) - yield return FadeIn(); - else - Fade = 1f; + Fade = 0f; + retry: ; // Create the options menu optMenu = new TextMenu() { AutoScroll = false }; @@ -379,22 +388,24 @@ private IEnumerator Routine() { throw new NotSupportedException(); Process.Start(new ProcessStartInfo() { - FileName = openProg, - ArgumentList = { Path.GetDirectoryName(LogFile) }, - UseShellExecute = true + FileName = openProg, + ArgumentList = { Path.GetDirectoryName(LogFile) }, + UseShellExecute = true }); })); - UserChoice? choice = null; if (Session != null) { - optMenu.Add(new TextMenu.Button("Retry level") { Disabled = !CanExecuteChoice(UserChoice.RetryLevel) }.Pressed(() => choice = UserChoice.RetryLevel)); - optMenu.Add(new TextMenu.Button("Save & Quit") { Disabled = !CanExecuteChoice(UserChoice.SaveAndQuit) }.Pressed(() => choice = UserChoice.SaveAndQuit)); + if (Celeste.PlayMode == Celeste.PlayModes.Debug) + optMenu.Add(new TextMenu.Button("Open debug map") { Disabled = !CanExecuteChoice(UserChoice.OpenDebugMap) }.Pressed(() => MenuChoice = UserChoice.OpenDebugMap)); + + optMenu.Add(new TextMenu.Button("Retry level") { Disabled = !CanExecuteChoice(UserChoice.RetryLevel) }.Pressed(() => MenuChoice = UserChoice.RetryLevel)); + optMenu.Add(new TextMenu.Button("Save & Quit") { Disabled = !CanExecuteChoice(UserChoice.SaveAndQuit) }.Pressed(() => MenuChoice = UserChoice.SaveAndQuit)); } if (SaveData.Instance != null && !hasFlushedSaveData) - optMenu.Add(new TextMenu.Button("Save current progress") { Disabled = !CanExecuteChoice(UserChoice.FlushSaveData) }.Pressed(() => choice = UserChoice.FlushSaveData)); + optMenu.Add(new TextMenu.Button("Save current progress") { Disabled = !CanExecuteChoice(UserChoice.FlushSaveData) }.Pressed(() => MenuChoice = UserChoice.FlushSaveData)); - optMenu.Add(new TextMenu.Button("Return to main menu") { Disabled = !CanExecuteChoice(UserChoice.ReturnToMainMenu) }.Pressed(() => choice = UserChoice.ReturnToMainMenu)); + optMenu.Add(new TextMenu.Button("Return to main menu") { Disabled = !CanExecuteChoice(UserChoice.ReturnToMainMenu) }.Pressed(() => MenuChoice = UserChoice.ReturnToMainMenu)); optMenu.Add(new TextMenu.Button("Exit Game").Pressed(() => { Scene.OnEndOfFrame += static () => Engine.Instance.Exit(); @@ -406,23 +417,30 @@ private IEnumerator Routine() { ConfigureOptionsMenu(); + if (State != DisplayState.BlueScreen) + yield return FadeIn(); + else + Fade = 1f; + + MenuChoice = null; + // Wait for the user to make a choice, or the scene to change, or the display state to change Scene prevScene = Celeste.Scene; DisplayState prevState = State; - while (choice == null && prevScene == Celeste.Scene && State == prevState) + while (MenuChoice == null && prevScene == Celeste.Scene && State == prevState) yield return null; if (prevScene != Celeste.Scene || prevState != State) goto retry; // Fade out the menu - if (State != DisplayState.BlueScreen) + if (State != DisplayState.BlueScreen && !NoFade) yield return FadeOut(); // Execute the choice - if (!ExecuteUserChoice(choice.Value)) { - failedChoices.Add(choice.Value); + if (!ExecuteUserChoice(MenuChoice.Value)) { + failedChoices.Add(MenuChoice.Value); goto retry; } if (CurrentHandler == this) @@ -434,6 +452,7 @@ private IEnumerator Routine() { private bool CanExecuteChoice(UserChoice choice) => !failedChoices.Contains(choice) && choice switch { UserChoice.FlushSaveData => SaveData.Instance != null && !hasFlushedSaveData, UserChoice.RetryLevel => Session != null, + UserChoice.OpenDebugMap => Session != null, UserChoice.SaveAndQuit => SaveData.Instance != null && Session != null, UserChoice.ReturnToMainMenu => true, _ => false @@ -445,11 +464,8 @@ private bool ExecuteUserChoice(UserChoice choice) { SaveData save = SaveData.Instance; switch (choice) { case UserChoice.FlushSaveData: - if (save != null) { - save.BeforeSave(); - UserIO.Save(SaveData.GetFilename(save.FileSlot), UserIO.Serialize(save)); - patch_UserIO.ForceSerializeModSave(); - } + if (save != null) + ManuallySaveGame(); hasFlushedSaveData = true; return true; // We don't want to reset the overlay yet @@ -462,6 +478,15 @@ private bool ExecuteUserChoice(UserChoice choice) { Celeste.Scene = new LevelLoader(Session); break; + case UserChoice.OpenDebugMap: + if (Session == null) { + Logger.Warn("crit-error-handler", "Can't open debug editor as no session is present!"); + return false; + } + + Celeste.Scene = new MapEditor(Session.Area); + break; + case UserChoice.SaveAndQuit: if (Session == null || save == null) { Logger.Warn("crit-error-handler", "Can't save-and-quit as either session or save data is not present!"); @@ -470,9 +495,7 @@ private bool ExecuteUserChoice(UserChoice choice) { // Do a save manually Session.InArea = true; - save.BeforeSave(); - UserIO.Save(SaveData.GetFilename(save.FileSlot), UserIO.Serialize(save)); - patch_UserIO.ForceSerializeModSave(); + ManuallySaveGame(); // Load the overworld Celeste.Scene = new OverworldLoader(Overworld.StartMode.MainMenu); @@ -494,23 +517,65 @@ private bool ExecuteUserChoice(UserChoice choice) { return true; } + // Forcibly saves the game. + private void ManuallySaveGame() { + if (SaveData.Instance is null) return; + SaveData.Instance.BeforeSave(); + UserIO.Save(SaveData.GetFilename(SaveData.Instance.FileSlot), UserIO.Serialize(SaveData.Instance)); + patch_UserIO.ForceSerializeModSave(); + } + + // Sets up the options and player sprite visually. + // NOTE: To be honest, I'm not entirely sure why this code works. Good luck to you if you alter it. private void ConfigureOptionsMenu() { - optMenu.ItemSpacing = 4; + // First, we find how much space we have worst-case + optMenu.ItemSpacing = 0; optMenu.RecalculateSize(); - - if (UsePlayerSprite) { - optMenu.Position = new Vector2(Celeste.TargetWidth * 0.15f, Celeste.TargetHeight * 0.55f); - optMenu.Justify = new Vector2(0.5f, 0f); - - // Reduce item spacing if there are too many items - if (optMenu.Position.Y + optMenu.Height > Celeste.TargetHeight * 0.85f) { - optMenu.ItemSpacing = 0; - optMenu.RecalculateSize(); + PlayerSpriteSize = MaxPlayerSpriteSize; + + float originalSpriteSize = Celeste.TargetHeight * PlayerSpriteSize; + + // Check how much space we have left over + float verticalDeadSpace = MaxOptionsHeight - optMenu.Height; + if (UsePlayerSprite) verticalDeadSpace -= originalSpriteSize; + if (verticalDeadSpace < 0 && UsePlayerSprite) { + // We need to shrink the player sprite to fit the screen + PlayerSpriteSize = Math.Min((originalSpriteSize + verticalDeadSpace) / Celeste.TargetHeight, MaxPlayerSpriteSize); + if (PlayerSpriteSize <= 0) { + Logger.Warn("crit-error-handler", "Not enough vertical space for player sprite, disabling"); + disablePlayerSprite = true; + PlayerSpriteSize = 0; } - } else { - optMenu.Position = new Vector2(Celeste.TargetWidth * 0.15f, Celeste.TargetHeight * 0.6f); - optMenu.Justify = new Vector2(0.5f, 0.5f); + + verticalDeadSpace += originalSpriteSize - PlayerSpriteSize * Celeste.TargetHeight; } + + int optionCount = ((patch_TextMenu)optMenu).Items.Count; + + float optionSpacing = verticalDeadSpace / optionCount; + + if (optionSpacing < 0) + // We are completely out of room. RIP. We intentionally don't clamp optionSpacing to >=0 to keep everything on screen even with overlap + Logger.Warn("crit-error-handler", $"Not enough vertical space for options menu, forcing overlap"); + + optMenu.ItemSpacing = Math.Min(optionSpacing, MaxOptionSpacing); + optMenu.RecalculateSize(); + + if (optMenu.Height - 1f > MaxOptionsHeight) // -1f to give a leeway for float shenanigans + Logger.Warn("crit-error-handler", $"Got incorrect height for option menu, is this a bug? (Expected {MaxOptionsHeight}, got {optMenu.Height})"); + + // We center the options within the available space ONLY if the player sprite isn't there, + // since it looks fine uncentered with the player but awful without + float availableSpace = MaxOptionsHeight - optMenu.Height; + + float optY = (Celeste.TargetHeight - MaxOptionsHeight) / 2f; + if (UsePlayerSprite) + optY += Celeste.TargetHeight * PlayerSpriteSize; + else + optY += availableSpace / 2; + + optMenu.Position = new Vector2(Celeste.TargetWidth * 0.15f, optY + optMenu.Height / 2); + optMenu.Justify = new Vector2(0.5f, 0.5f); } public override void Added(Scene scene) { @@ -543,8 +608,7 @@ public override void SceneEnd(Scene scene) { public override void Update() { // Check if another overlay is active if (Scene is IOverlayHandler ovlHandler && ovlHandler.Overlay != this) { - if (ovlHandler.Overlay != null) - return; + if (ovlHandler.Overlay != null) return; ovlHandler.Overlay = this; // Restore ourselves as the active overlay } @@ -578,7 +642,7 @@ public override void Update() { // Play the get-up animation first playerSprite.Play("rollGetUp"); playerShouldTeabag = true; - crouchTimer = 0; + crouchTimer = 0; } } @@ -591,10 +655,28 @@ public override void Update() { } // Update the options menu - if (Fade == 1) - optMenu?.Update(); + if (Fade == 1) optMenu?.Update(); base.Update(); + + // Handle option keys + if (Engine.Scene.Tracker.GetEntity() == null && Engine.Scene.Tracker.GetEntity() == null) { + if (Session != null) { + if (Celeste.PlayMode == Celeste.PlayModes.Debug && CoreModule.Settings.DebugMap.Pressed) { + CoreModule.Settings.DebugMap.ConsumePress(); + NoFade = true; + MenuChoice = UserChoice.OpenDebugMap; + } else if (Input.MenuCancel.Pressed) { + Input.MenuCancel.ConsumePress(); + NoFade = true; + MenuChoice = UserChoice.RetryLevel; + } + } else if (Input.MenuCancel.Pressed) { + Input.MenuCancel.ConsumePress(); + NoFade = true; + MenuChoice = UserChoice.ReturnToMainMenu; + } + } } private void BeforeRender() { @@ -679,9 +761,13 @@ public override void Render() { try { HudRenderer.BeginRender(sampler: SamplerState.PointClamp); try { - Vector2 drawPos = new Vector2(Celeste.TargetWidth * 0.15f, Celeste.TargetHeight * 0.5f); - float size = Celeste.TargetHeight * 0.65f; - Draw.SpriteBatch.Draw((RenderTarget2D) playerRenderTarget, new Rectangle((int) (drawPos.X - size / 2), (int) (drawPos.Y - size), (int) size, (int) size), Color.White * Fade); + Vector2 drawPos = new(Celeste.TargetWidth * 0.15f, (Celeste.TargetHeight - MaxOptionsHeight) / 2); + float size = Celeste.TargetHeight * PlayerSpriteSize; + Draw.SpriteBatch.Draw( + (RenderTarget2D) playerRenderTarget, + new Rectangle((int) (drawPos.X - size / 2), (int) drawPos.Y, (int) size, (int) size), + Color.White * Fade + ); } finally { HudRenderer.EndRender(); } @@ -709,7 +795,7 @@ void DrawLineWrap(string text, float scale, Color color, Vector2 posOff = defaul if (ActiveFont.Measure(text).X * scale > availSpace) { // Do binary search to determine the cutoff point int start = 0, end = text.Length; - while (start < end -1) { + while (start < end - 1) { int middle = start + (end - start) / 2; float textSize = ActiveFont.Measure(text.Substring(0, middle)).X * scale; if (textSize > availSpace) @@ -759,13 +845,29 @@ void DrawLineWrap(string text, float scale, Color color, Vector2 posOff = defaul // since that means the crash was in there if (i != 0 && btLines[i].StartsWith("at Hook<")) continue; DrawLineWrap(btLines[i], 0.4f, Color.Gray); - if (textPos.Y >= Celeste.TargetHeight * 0.9f && i+1 < btLines.Length) { + if (textPos.Y >= Celeste.TargetHeight * 0.9f && i + 1 < btLines.Length) { DrawLineWrap("...", 0.5f, Color.Gray); break; } } } + [Command("criterror", "Shows the Everest critical error handler. Optionally can take a display state of 'initial', 'overlay', or 'cleanscene'.")] + internal static void CmdCriticalError(string displayState = "initial") { + if (displayState is null) { + Engine.Commands.Log("Expected a display state of .", Color.Yellow); + return; + } + + DisplayState state = displayState switch { + "overlay" => DisplayState.Overlay, + "cleanscene" => DisplayState.CleanScene, + _ => DisplayState.Initial, + }; + Exception exc = new(Calc.Random.NextSingle() > 0.95 ? "smots gaming" : "Hello, world!"); + ExceptionDispatchInfo.SetCurrentStackTrace(exc); + HandleCriticalError(ExceptionDispatchInfo.Capture(exc), state); + } } }