diff --git a/.editorconfig b/.editorconfig index 54585022d..79add473a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -118,12 +118,28 @@ csharp_style_prefer_readonly_struct = true csharp_style_prefer_readonly_struct_member = true # Code-block preferences -csharp_prefer_braces = false:silent +csharp_prefer_braces = true:suggestion csharp_prefer_simple_using_statement = true csharp_style_namespace_declarations = file_scoped csharp_style_prefer_method_group_conversion = true csharp_style_prefer_primary_constructors = true csharp_style_prefer_top_level_statements = true +resharper_braces_for_ifelse = required +resharper_braces_for_dowhile = required +resharper_braces_for_fixed = required +resharper_braces_for_for = required +resharper_braces_for_foreach = required +resharper_braces_for_lock = required +resharper_braces_for_using = required +resharper_braces_for_while = required +resharper_enforce_if_statement_braces_highlighting = suggestion +resharper_enforce_dowhile_statement_braces_highlighting = required +resharper_enforce_fixed_statement_braces_highlighting = required +resharper_enforce_for_statement_braces_highlighting = required +resharper_enforce_foreach_statement_braces_highlighting = required +resharper_enforce_lock_statement_braces_highlighting = required +resharper_enforce_using_statement_braces_highlighting = required +resharper_enforce_while_statement_braces_highlighting = required # Expression-level preferences csharp_prefer_simple_default_expression = true diff --git a/.github/workflows/Build.CelesteStudio.yml b/.github/workflows/Build.CelesteStudio.yml index 2b9ae1882..0cfdad048 100644 --- a/.github/workflows/Build.CelesteStudio.yml +++ b/.github/workflows/Build.CelesteStudio.yml @@ -20,7 +20,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0' + dotnet-version: '9.0' - name: Set version suffix (release) run: sed -i "s/-dev//" Studio/CelesteStudio/Studio.cs @@ -69,7 +69,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0' + dotnet-version: '9.0' - name: Set version suffix (release) run: sed -i "s/-dev//" Studio/CelesteStudio/Studio.cs @@ -118,7 +118,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0' + dotnet-version: '9.0' - name: Install macOS workflow run: dotnet workload install macos - name: Switch XCode diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml index eae4fa267..f7621dbfd 100644 --- a/.github/workflows/Build.yml +++ b/.github/workflows/Build.yml @@ -39,7 +39,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0' + dotnet-version: '9.0' - name: Download Studio builds uses: actions/download-artifact@v4 diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 01ea71030..9777e306d 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -74,7 +74,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0' + dotnet-version: '9.0' - name: Setup Python uses: actions/setup-python@v5.2.0 with: diff --git a/CelesteTAS-EverestInterop/CelesteTAS-EverestInterop.csproj b/CelesteTAS-EverestInterop/CelesteTAS-EverestInterop.csproj index 6d54d31e7..64dc74806 100644 --- a/CelesteTAS-EverestInterop/CelesteTAS-EverestInterop.csproj +++ b/CelesteTAS-EverestInterop/CelesteTAS-EverestInterop.csproj @@ -5,6 +5,7 @@ TAS net7.0 latest + enable ../../.. lib-stripped @@ -28,7 +29,7 @@ - + @@ -36,13 +37,16 @@ - + + + - + + @@ -54,18 +58,22 @@ + + + - + - + - - - + + + + diff --git a/CelesteTAS-EverestInterop/Dialog/English.txt b/CelesteTAS-EverestInterop/Dialog/English.txt index 52100c974..f2d3584be 100644 --- a/CelesteTAS-EverestInterop/Dialog/English.txt +++ b/CelesteTAS-EverestInterop/Dialog/English.txt @@ -61,10 +61,13 @@ TAS_INFO_CUSTOM= Custom Info TAS_INFO_COPY_CUSTOM_TEMPLATE= Copy Custom Info Template to Clipboard TAS_INFO_SET_CUSTOM_TEMPLATE= Set Custom Info Template from Clipboard TAS_INFO_WATCH_ENTITY= Watch Entity -TAS_INFO_WATCH_ENTITY_TYPE= Watch Entity Info +TAS_INFO_WATCH_ENTITY_HUD_TYPE= Watch Entity Info (HUD) +TAS_INFO_WATCH_ENTITY_STUDIO_TYPE= Watch Entity Info (Studio) +TAS_INFO_WATCH_ENTITY_NONE= None TAS_INFO_WATCH_ENTITY_POSITION= Position TAS_INFO_WATCH_ENTITY_DECLARED_ONLY= Declared Only TAS_INFO_WATCH_ENTITY_ALL= All +TAS_INFO_WATCH_ENTITY_LOG_TO_CONSOLE= Log Entity Info to Console TAS_INFO_TEXT_SIZE= Text Size TAS_INFO_SUBPIXEL_INDICATOR_SIZE= Subpixel Indicator Size TAS_INFO_OPACITY= Opacity @@ -103,6 +106,11 @@ TAS_RESTORE_SETTINGS= Restore All Settings when TAS Stop TAS_LAUNCH_STUDIO_AT_BOOT= Launch Studio at Boot TAS_ATTEMPT_TO_CONNECT_TO_STUDIO= Attempt To Connect To Studio TAS_SHOW_STUDIO_UPDATE_BANNER= Show Studio Update Banner +TAS_OPEN_CONSOLE_IN_TAS= Allow Opening Console in TAS +TAS_SCROLLABLE_HISTORY_LOG= Scrollable Console History Log +TAS_BETTER_INVINCIBILITY= Better Invincibility +TAS_BETTER_INVINCIBLE_DESCRIPTION= Running "Set Invincible true" will still prevent dying, but avoids gameplay changes like bouncing of the death plane,{n} + to avoid accidentally desyncing the TAS. Applies only while a TAS is active. TAS_HIDE_FREEZE_FRAMES= Hide Freeze Frames during TAS TAS_HIDE_FREEZE_FRAMES_DESCRIPTION_1=When a freeze frame is encountered, run it and continue to the next frame TAS_HIDE_FREEZE_FRAMES_DESCRIPTION_2=before rendering. Applies only while a TAS is active. diff --git a/CelesteTAS-EverestInterop/Dialog/Simplified Chinese.txt b/CelesteTAS-EverestInterop/Dialog/Simplified Chinese.txt index 060326f3b..f3f3df446 100644 --- a/CelesteTAS-EverestInterop/Dialog/Simplified Chinese.txt +++ b/CelesteTAS-EverestInterop/Dialog/Simplified Chinese.txt @@ -65,6 +65,7 @@ TAS_INFO_WATCH_ENTITY_TYPE= 监视实体信息 TAS_INFO_WATCH_ENTITY_POSITION= 位置 TAS_INFO_WATCH_ENTITY_DECLARED_ONLY= 特有 TAS_INFO_WATCH_ENTITY_ALL= 所有 +TAS_INFO_WATCH_ENTITY_LOG_TO_COMMAND= 监视实体信息输出到控制台 TAS_INFO_TEXT_SIZE= 文字大小 TAS_INFO_SUBPIXEL_INDICATOR_SIZE= 亚像素指示器大小 TAS_INFO_OPACITY= 不透明度 @@ -101,6 +102,11 @@ TAS_CENTER_CAMERA_HORIZONTALLY_ONLY= 镜头只水平居中 TAS_RESTORE_SETTINGS= TAS 停止时恢复所有设置 TAS_LAUNCH_STUDIO_AT_BOOT= 启动游戏时打开 Studio TAS_ATTEMPT_TO_CONNECT_TO_STUDIO= 尝试连接 Studio +TAS_OPEN_CONSOLE_IN_TAS= 允许在 TAS 中打开控制台 +TAS_SCROLLABLE_HISTORY_LOG= 可滚动的控制台历史记录 +TAS_BETTER_INVINCIBILITY= 更好的无敌 +TAS_BETTER_INVINCIBLE_DESCRIPTION= 运行 "Set Invincible true" 既会阻止死亡, 同时也会避免例如在掉落致死时自动弹跳的游戏物理变化,{n} + 从而避免 TAS 意外 desync. 仅在 TAS 运行期间生效. TAS_HIDE_FREEZE_FRAMES= TAS 时跳过冻结帧 TAS_HIDE_FREEZE_FRAMES_DESCRIPTION_1=运行到冻结帧时自动跳过进入下一帧 TAS_HIDE_FREEZE_FRAMES_DESCRIPTION_2=仅在 TAS 时启用 diff --git a/CelesteTAS-EverestInterop/Source/Communication/CommunicationAdapterCeleste.cs b/CelesteTAS-EverestInterop/Source/Communication/CommunicationAdapterCeleste.cs index ea83e3725..7402efbb0 100644 --- a/CelesteTAS-EverestInterop/Source/Communication/CommunicationAdapterCeleste.cs +++ b/CelesteTAS-EverestInterop/Source/Communication/CommunicationAdapterCeleste.cs @@ -10,13 +10,13 @@ using StudioCommunication.Util; using TAS.EverestInterop; using TAS.EverestInterop.InfoHUD; +using TAS.InfoHUD; using TAS.Input; using TAS.Input.Commands; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; -#nullable enable - namespace TAS.Communication; public sealed class CommunicationAdapterCeleste() : CommunicationAdapterBase(Location.Celeste) { @@ -28,7 +28,7 @@ protected override void FullReset() { protected override void OnConnectionChanged() { if (Connected) { // Stall until input initialized to avoid sending invalid hotkey data - while (Hotkeys.KeysDict == null) { + while (Hotkeys.AllHotkeys == null) { Thread.Sleep(UpdateRate); } @@ -44,7 +44,7 @@ protected override void HandleMessage(MessageID messageId, BinaryReader reader) string path = reader.ReadString(); LogVerbose($"Received message FilePath: '{path}'"); - InputController.StudioTasFilePath = path; + Manager.AddMainThreadAction(() => Manager.Controller.FilePath = path); break; case MessageID.Hotkey: @@ -52,7 +52,7 @@ protected override void HandleMessage(MessageID messageId, BinaryReader reader) bool released = reader.ReadBoolean(); LogVerbose($"Received message Hotkey: {hotkey} ({(released ? "released" : "pressed")})"); - Hotkeys.KeysDict[hotkey].OverrideCheck = !released; + Hotkeys.AllHotkeys[hotkey].OverrideCheck = !released; break; case MessageID.SetCustomInfoTemplate: @@ -82,7 +82,6 @@ protected override void HandleMessage(MessageID messageId, BinaryReader reader) object? arg = gameDataType switch { GameDataType.ConsoleCommand => reader.ReadBoolean(), GameDataType.SettingValue => reader.ReadString(), - GameDataType.RawInfo => reader.ReadObject<(string, bool)>(), GameDataType.CommandHash => reader.ReadObject<(string, string[], string, int)>(), _ => null, }; @@ -114,9 +113,6 @@ protected override void HandleMessage(MessageID messageId, BinaryReader reader) case GameDataType.CustomInfoTemplate: gameData = !string.IsNullOrWhiteSpace(TasSettings.InfoCustomTemplate) ? TasSettings.InfoCustomTemplate : string.Empty; break; - case GameDataType.RawInfo: - gameData = InfoCustom.GetRawInfo(((string, bool))arg!); - break; case GameDataType.GameState: gameData = GameData.GetGameState(); break; @@ -158,10 +154,6 @@ protected override void HandleMessage(MessageID messageId, BinaryReader reader) writer.Write((string?)gameData ?? string.Empty); break; - case GameDataType.RawInfo: - writer.WriteObject(gameData); - break; - case GameDataType.GameState: writer.WriteObject((GameState?)gameData); break; @@ -169,7 +161,7 @@ protected override void HandleMessage(MessageID messageId, BinaryReader reader) case GameDataType.CommandHash: writer.Write((int)gameData!); break; - + case GameDataType.LevelInfo: writer.WriteObject((LevelInfo)gameData!); break; @@ -317,42 +309,45 @@ public void WriteCommandList(CommandInfo[] commands) { } private void ProcessRecordTAS(string fileName) { - if (!TASRecorderUtils.Installed) { + if (!TASRecorderInterop.Installed) { WriteRecordingFailed(RecordingFailedReason.TASRecorderNotInstalled); return; } - if (!TASRecorderUtils.FFmpegInstalled) { + if (!TASRecorderInterop.FFmpegInstalled) { WriteRecordingFailed(RecordingFailedReason.FFmpegNotInstalled); return; } - Manager.Controller.RefreshInputs(enableRun: true); - if (RecordingCommand.RecordingTimes.IsNotEmpty()) { - AbortTas("Can't use StartRecording/StopRecording with \"Record TAS\""); - return; - } - Manager.NextStates |= States.Enable; + Manager.AddMainThreadAction(() => { + Manager.Controller.RefreshInputs(); + if (RecordingCommand.RecordingTimes.IsNotEmpty()) { + AbortTas("Can't use StartRecording/StopRecording with \"Record TAS\""); + return; + } + Manager.EnableRun(); - int totalFrames = Manager.Controller.Inputs.Count; - if (totalFrames <= 0) return; + int totalFrames = Manager.Controller.Inputs.Count; + if (totalFrames <= 0) return; - TASRecorderUtils.StartRecording(fileName); - TASRecorderUtils.SetDurationEstimate(totalFrames); + TASRecorderInterop.StartRecording(fileName); + TASRecorderInterop.SetDurationEstimate(totalFrames); - if (!Manager.Controller.Commands.TryGetValue(0, out var commands)) { - return; - } - bool startsWithConsoleLoad = commands.Any(c => - c.Is("Console") && - c.Args.Length >= 1 && - ConsoleCommand.LoadCommandRegex.Match(c.Args[0].ToLower()) is { Success: true }); - - if (startsWithConsoleLoad) { - // Restart the music when we enter the level - Audio.SetMusic(null, startPlaying: false, allowFadeOut: false); - Audio.SetAmbience(null, startPlaying: false); - Audio.BusStopAll(Buses.GAMEPLAY, immediate: true); - } + if (!Manager.Controller.Commands.TryGetValue(0, out var commands)) { + return; + } + + bool startsWithConsoleLoad = commands.Any(c => + c.Attribute.Name.Equals("Console", StringComparison.OrdinalIgnoreCase) && + c.Args.Length >= 1 && + ConsoleCommand.LoadCommandRegex.Match(c.Args[0].ToLower()) is {Success: true}); + + if (startsWithConsoleLoad) { + // Restart the music when we enter the level + Audio.SetMusic(null, startPlaying: false, allowFadeOut: false); + Audio.SetAmbience(null, startPlaying: false); + Audio.BusStopAll(Buses.GAMEPLAY, immediate: true); + } + }); } protected override void LogInfo(string message) => Logger.Log(LogLevel.Info, "CelesteTAS/StudioCom", message); diff --git a/CelesteTAS-EverestInterop/Source/Communication/CommunicationWrapper.cs b/CelesteTAS-EverestInterop/Source/Communication/CommunicationWrapper.cs index a024478a2..4c1c7fc7c 100644 --- a/CelesteTAS-EverestInterop/Source/Communication/CommunicationWrapper.cs +++ b/CelesteTAS-EverestInterop/Source/Communication/CommunicationWrapper.cs @@ -71,7 +71,7 @@ public static void SendCurrentBindings() { return; } - Dictionary> nativeBindings = Hotkeys.KeysInteractWithStudio.ToDictionary(pair => (int) pair.Key, pair => pair.Value.Cast().ToList()); + Dictionary> nativeBindings = Hotkeys.StudioHotkeys.ToDictionary(pair => (int) pair.Key, pair => pair.Value.Cast().ToList()); comm.WriteCurrentBindings(nativeBindings); } public static void SendRecordingFailed(RecordingFailedReason reason) { diff --git a/CelesteTAS-EverestInterop/Source/Entities/Toast.cs b/CelesteTAS-EverestInterop/Source/Entities/Toast.cs index 60dd315ec..8945765f7 100644 --- a/CelesteTAS-EverestInterop/Source/Entities/Toast.cs +++ b/CelesteTAS-EverestInterop/Source/Entities/Toast.cs @@ -8,6 +8,11 @@ namespace TAS.Entities; +public class DashCollision() : Component(active: false, visible: false) { + public Solid Hit = null!; + public bool IsBounceHit = false; +} + [Tracked] internal class Toast : Entity { private const int Padding = 25; @@ -75,4 +80,4 @@ public static void ShowAndLog(string message, float duration = DefaultDuration, Show(message, duration); message.Log(logLevel); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/AreaCompleteInfo.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/AreaCompleteInfo.cs index b97441531..bc566b3a9 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/AreaCompleteInfo.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/AreaCompleteInfo.cs @@ -10,6 +10,7 @@ using StudioCommunication; using TAS.Input; using TAS.Input.Commands; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; using Comment = TAS.Input.Comment; @@ -18,7 +19,7 @@ namespace TAS.EverestInterop; public static class AreaCompleteInfo { private class Meta : ITasCommandMeta { - public string Insert => $"CompleteInfo{CommandInfo.Separator}[0;A 1]"; + public string Insert => $"CompleteInfo{CommandInfo.Separator}[0;A{CommandInfo.Separator}1]"; public bool HasArguments => true; } @@ -176,25 +177,20 @@ private static void CompleteInfoCommand(CommandLine commandLine, int studioLine, key = new AreaKey(id, mode).ToString(); } - if (!completeInfos.TryGetValue(key, out StringBuilder info)) { + if (!completeInfos.TryGetValue(key, out var info)) { completeInfos[key] = info = new StringBuilder(); } info.Clear(); - if (Manager.Controller.Comments.TryGetValue(filePath, out List comments)) { + + if (Manager.Controller.CurrentComments is { Count: > 0 } comments) { bool firstComment = true; - foreach (Comment comment in comments.Where(c => c.Line > fileLine)) { - if (fileLine + 1 == comment.Line) { - if (!firstComment) { - info.AppendLine(); - } - - firstComment = false; - info.Append($"{comment.Text}"); - fileLine++; - } else { - break; + foreach (var comment in comments) { + if (!firstComment) { + info.AppendLine(); } + + info.AppendLine(comment.Text); } } } diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/AutoMute.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/AutoMute.cs index 66a87d212..53ecb295b 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/AutoMute.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/AutoMute.cs @@ -67,8 +67,8 @@ private static readonly IDictionary, int> private static bool settingMusic; private static bool hasMuted; - private static bool ShouldBeMuted => Manager.FrameLoops >= 2 && !settingMusic; - private static bool FrameStep => Manager.Running && (Manager.States & States.FrameStep) != 0; + private static bool ShouldBeMuted => Manager.PlaybackSpeed >= 2.0f && !settingMusic; + private static bool FrameStep => Manager.CurrState == Manager.State.Paused; private static WeakReference dummy; private static EventInstance DummyEventInstance { @@ -197,4 +197,4 @@ private static void CelesteOnUpdate(On.Celeste.Celeste.orig_Update orig, Celeste } } } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Benchmark.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Benchmark.cs index a10215de6..258d31cf3 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Benchmark.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Benchmark.cs @@ -32,4 +32,4 @@ private static void EngineOnUpdate(On.Monocle.Engine.orig_Update orig, Engine se lastRunning = Manager.Running; } } -#endif \ No newline at end of file +#endif diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/CenterCamera.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/CenterCamera.cs index cda0d71d4..1b93fb46d 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/CenterCamera.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/CenterCamera.cs @@ -349,4 +349,4 @@ private static void ZoomCamera() { viewportScale = Calc.Clamp(viewportScale + delta, 0.2f, MaximumViewportScale); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/ConsoleEnhancements.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/ConsoleEnhancements.cs index 52b6237f2..47e1157d9 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/ConsoleEnhancements.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/ConsoleEnhancements.cs @@ -2,11 +2,13 @@ using System.Collections.Generic; using Celeste; using Celeste.Mod; +using Microsoft.Xna.Framework.Input; using Monocle; using MonoMod.Cil; -using TAS.EverestInterop.InfoHUD; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; +using TAS.InfoHUD; namespace TAS.EverestInterop; @@ -31,20 +33,67 @@ private static void InitializeHelperMethods() { [Load] private static void Load() { - IL.Monocle.Commands.Render += Commands_Render; + IL.Monocle.Commands.Render += IL_Commands_Render; } [Unload] private static void Unload() { - IL.Monocle.Commands.Render -= Commands_Render; + IL.Monocle.Commands.Render -= IL_Commands_Render; } [EnableRun] - private static void CloseCommand() { + private static void EnableRun() { + // Auto-close at start. Can be opened manually again Engine.Commands.Open = false; } - private static void Commands_Render(ILContext il) { + internal static void UpdateMeta() { + if (!Manager.Running) { + return; + } + + justClosed = false; + if (Engine.Commands.Open) { + Engine.Commands.UpdateOpen(); + if (!Engine.Commands.Open) { + justClosed = true; + } + } else if (Engine.Commands.Enabled) { + Engine.Commands.UpdateClosed(); + } + } + + private static bool justClosed = false; + internal static void OpenConsole() { + if (!Manager.Running) { + return; // Only allow inside a TAS, since outside it's already handled + } + if (Engine.Commands.Open) { + return; + } + if (justClosed) { + // when commands open, hotkeys are not updated (in Hotkeys.UpdateMeta(), updateKey = false) + // so if without this extra check: + // Gameloop 1: Commands open + // Gameloop 2: CoreModule.(Toggle)DebugConsole gets pressed (note this is not our OpenConsole hotkey), and Commands get closed + // Gameloop 3: Hotkeys find Commands are closed and decide to update, and find that OpenConsole gets pressed, so it Opens Console again! + return; + } + + // Copied from Commands.UpdateClosed + Engine.Commands.Open = true; + Engine.Commands.currentState = Keyboard.GetState(); + if (!Engine.Commands.installedListener) { + Engine.Commands.installedListener = true; + TextInput.OnInput += Engine.Commands.HandleChar; + } + if (!Engine.Commands.printedInfoMessage) { + Engine.Commands.Log("Use the 'help' command for a list of debug commands. Press Esc or use the 'q' command to close the console."); + Engine.Commands.printedInfoMessage = true; + } + } + + private static void IL_Commands_Render(ILContext il) { // Hijack string.Format("\n level: {0}, {1}", xObj, yObj) new ILCursor(il).FindNext(out ILCursor[] found, i => i.MatchLdstr("\n level: {0}, {1}"), @@ -106,4 +155,4 @@ public static string GetModName(Type type) { return "Unknown"; } } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Core.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Core.cs deleted file mode 100644 index 8f9b04cc6..000000000 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Core.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.Collections.Generic; -using Celeste; -using Microsoft.Xna.Framework; -using Mono.Cecil.Cil; -using Monocle; -using MonoMod.Cil; -using MonoMod.RuntimeDetour; -using TAS.Input.Commands; -using TAS.Module; -using TAS.Utils; -using GameInput = Celeste.Input; - -namespace TAS.EverestInterop; - -public static class Core { - private static bool InUpdate; - - [Load] - private static void Load() { - using (new DetourContext {After = new List {"*"}}) { - On.Celeste.Celeste.Update += Celeste_Update; - IL.Monocle.Engine.Update += EngineOnUpdate; - if (typeof(GameInput).GetMethod("UpdateGrab") is { } updateGrabMethod) { - HookHelper.SkipMethod(typeof(Core), nameof(IsPause), updateGrabMethod); - } - - // The original mod makes the MInput.Update call conditional and invokes UpdateInputs afterwards. - On.Monocle.MInput.Update += MInput_Update; - IL.Monocle.MInput.Update += MInputOnUpdate; - - // The original mod makes RunThread.Start run synchronously. - On.Celeste.RunThread.Start += RunThread_Start; - - // Forced: Allow "rendering" entities without actually rendering them. - IL.Monocle.Entity.Render += SkipRenderMethod; - } - } - - [Unload] - private static void Unload() { - On.Celeste.Celeste.Update -= Celeste_Update; - IL.Monocle.Engine.Update -= EngineOnUpdate; - On.Monocle.MInput.Update -= MInput_Update; - IL.Monocle.MInput.Update -= MInputOnUpdate; - On.Celeste.RunThread.Start -= RunThread_Start; - IL.Monocle.Entity.Render -= SkipRenderMethod; - } - - private static void Celeste_Update(On.Celeste.Celeste.orig_Update orig, Celeste.Celeste self, GameTime gameTime) { - InUpdate = false; - - if (!TasSettings.Enabled || !Manager.Running) { - orig(self, gameTime); - return; - } - - InUpdate = true; - - // The original patch doesn't store FrameLoops in a local variable, but it's only updated in UpdateInputs anyway. - int loops = Manager.SlowForwarding ? 1 : (int) Manager.FrameLoops; - for (int i = 0; i < loops; i++) { - float oldFreezeTimer = Engine.FreezeTimer; - - // Anything happening early on runs in the MInput.Update hook. - orig(self, gameTime); - Manager.AdvanceThroughHiddenFrame = false; - - if (TasSettings.HideFreezeFrames && oldFreezeTimer > 0f && oldFreezeTimer > Engine.FreezeTimer) { - Manager.AdvanceThroughHiddenFrame = true; - loops += 1; - } else if (RecordingCommand.StopFastForward) { - break; - } - } - - InUpdate = false; - } - - private static void EngineOnUpdate(ILContext il) { - ILCursor ilCursor = new(il); - if (ilCursor.TryGotoNext(MoveType.After, ins => ins.MatchCall(typeof(MInput), "Update"))) { - ILLabel label = ilCursor.DefineLabel(); - ilCursor.EmitDelegate(IsPause); - ilCursor.Emit(OpCodes.Brfalse, label) - .Emit(OpCodes.Ret) - .MarkLabel(label); - } - } - - private static bool IsPause() { - return Manager.SkipFrame && !Manager.IsLoading(); - } - - private static void MInput_Update(On.Monocle.MInput.orig_Update orig) { - if (!TasSettings.Enabled) { - orig(); - return; - } - - if (!Manager.Running) { - orig(); - } - - Manager.Update(); - } - - // update controller even the game is lose focus - private static void MInputOnUpdate(ILContext il) { - ILCursor ilCursor = new(il); - ilCursor.Goto(il.Instrs.Count - 1); - - if (ilCursor.TryGotoPrev(MoveType.After, i => i.MatchCallvirt("UpdateNull"))) { - ilCursor.EmitDelegate(UpdateGamePads); - } - - // skip the orig GamePads[j].UpdateNull(); - if (ilCursor.TryGotoNext(MoveType.After, i => i.MatchLdcI4(0))) { - ilCursor.Emit(OpCodes.Ldc_I4_4).Emit(OpCodes.Add); - } - } - - private static void UpdateGamePads() { - for (int i = 0; i < 4; i++) { - if (MInput.Active) { - MInput.GamePads[i].Update(); - } else { - MInput.GamePads[i].UpdateNull(); - } - } - } - - private static void RunThread_Start(On.Celeste.RunThread.orig_Start orig, Action method, string name, bool highPriority) { - if (Manager.Running && name != "USER_IO" && name != "MOD_IO") { - RunThread.RunThreadWithLogging(method); - return; - } - - orig(method, name, highPriority); - } - - private static void SkipRenderMethod(ILContext il) { - ILCursor ilCursor = new(il); - ILLabel startLabel = ilCursor.DefineLabel(); - ilCursor.Emit(OpCodes.Ldsfld, typeof(Core).GetFieldInfo(nameof(InUpdate))) - .Emit(OpCodes.Brfalse, startLabel) - .Emit(OpCodes.Ret) - .MarkLabel(startLabel); - } -} \ No newline at end of file diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/CriticalErrorHandlerFixer.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/CriticalErrorHandlerFixer.cs index a38318724..1e49d54f0 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/CriticalErrorHandlerFixer.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/CriticalErrorHandlerFixer.cs @@ -1,25 +1,19 @@ -using System; -using MonoMod.Utils; +using Celeste.Mod; +using Celeste.Mod.UI; using TAS.Module; -using TAS.Utils; namespace TAS.EverestInterop; public static class CriticalErrorHandlerFixer { - public static bool Handling => getCurrentHandler?.Invoke(null) != null; - private static FastReflectionDelegate getCurrentHandler; - [Load] private static void Load() { - Type type = ModUtils.VanillaAssembly.GetType("Celeste.Mod.UI.CriticalErrorHandler"); - if (type != null) { - type.GetMethod("Update")?.HookBefore(() => { - if (Manager.Running) { - Manager.DisableRun(); - } - }); + Everest.Events.OnCriticalError += HandleCriticalError; + } - getCurrentHandler = type.GetProperty("CurrentHandler")?.GetGetMethod()?.GetFastDelegate(); - } + [Unload] + private static void Unload() { + Everest.Events.OnCriticalError -= HandleCriticalError; } -} \ No newline at end of file + + private static void HandleCriticalError(CriticalErrorHandler criticalErrorHandler) => Manager.DisableRun(); +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/DebugRcPage.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/DebugRcPage.cs index 74da0c8ad..47408db07 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/DebugRcPage.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/DebugRcPage.cs @@ -24,8 +24,8 @@ public static class DebugRcPage { StringBuilder builder = new(); Everest.DebugRC.WriteHTMLStart(c, builder); WriteLine(builder, $"Running: {Manager.Running}"); - WriteLine(builder, $"State: {Manager.States}"); - WriteLine(builder, $"SaveState: {Savestates.IsSaved_Safe()}"); + WriteLine(builder, $"State: {Manager.CurrState}"); + WriteLine(builder, $"SaveState: {Savestates.IsSaved_Safe}"); WriteLine(builder, $"CurrentFrame: {Manager.Controller.CurrentFrameInTas}"); WriteLine(builder, $"TotalFrames: {Manager.Controller.Inputs.Count}"); WriteLine(builder, $"RoomName: {GameInfo.LevelName}"); @@ -49,31 +49,28 @@ void WriteIdErrorPage(string message) { Everest.DebugRC.WriteHTMLStart(c, builder); WriteLine(builder, $"

ERROR: {message}

"); WriteLine(builder, "Example: /tas/sendhotkey?id=Start&action=press"); - WriteLine(builder, $"Available id: {string.Join(", ", Enum.GetNames(typeof(HotkeyID)).Select(id => $"{id}"))}"); + WriteLine(builder, $"Available IDs: {string.Join(", ", Enum.GetNames(typeof(HotkeyID)).Select(id => $"{id}"))}"); WriteLine(builder, "Available action: press, release"); Everest.DebugRC.WriteHTMLEnd(c, builder); Everest.DebugRC.Write(c, builder.ToString()); } - NameValueCollection args = Everest.DebugRC.ParseQueryString(c.Request.RawUrl); - string idValue = args["id"]; - string pressValue = args["action"]; + var args = Everest.DebugRC.ParseQueryString(c.Request.RawUrl); + string? idValue = args["id"]; + string? pressValue = args["action"]; - if (idValue.IsNullOrEmpty()) { - WriteIdErrorPage("No id given."); - } else { - if (Enum.TryParse(idValue, true, out HotkeyID id) && (int) id < Enum.GetNames(typeof(HotkeyID)).Length) { - if (Hotkeys.KeysDict.TryGetValue(id, out Hotkeys.Hotkey hotkey)) { - bool press = !"release".Equals(pressValue, StringComparison.InvariantCultureIgnoreCase); - hotkey.OverrideCheck = press; - Everest.DebugRC.Write(c, "OK"); - } else { - WriteIdErrorPage($"Hotkeys.KeysDict doesn't have id {id}, please report to the developer."); - } - } else { - WriteIdErrorPage("Invalid id value."); - } + if (string.IsNullOrEmpty(idValue)) { + WriteIdErrorPage("No ID given."); + return; + } + if (!Enum.TryParse(idValue, ignoreCase: true, out var id) || !Hotkeys.AllHotkeys.TryGetValue(id, out var hotkey)) { + WriteIdErrorPage("Invalid ID value."); + return; } + + bool press = !"release".Equals(pressValue, StringComparison.InvariantCultureIgnoreCase); + hotkey.OverrideCheck = press; + Everest.DebugRC.Write(c, "OK"); } }; @@ -129,7 +126,7 @@ void WriteIdErrorPage(string message) { } else { WriteLine(builder, "OK"); Manager.AddMainThreadAction(() => { - InputController.StudioTasFilePath = filePath; + Manager.Controller.FilePath = filePath; Manager.EnableRun(); }); } diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/DesyncFixer.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/DesyncFixer.cs index 8a37dd4af..5905b83be 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/DesyncFixer.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/DesyncFixer.cs @@ -8,6 +8,7 @@ using Monocle; using MonoMod.Cil; using MonoMod.Utils; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; @@ -48,7 +49,7 @@ private static void Initialize() { } if (ModUtils.GetModule("DeadzoneConfig")?.GetType() is { } deadzoneConfigModuleType) { - HookHelper.SkipMethod(typeof(DesyncFixer), nameof(SkipDeadzoneConfig), deadzoneConfigModuleType.GetMethod("OnInputInitialize")); + deadzoneConfigModuleType.GetMethod("OnInputInitialize").SkipMethod(SkipDeadzoneConfig); } if (ModUtils.GetType("StrawberryJam2021", "Celeste.Mod.StrawberryJam2021.Entities.CustomAscendManager") is { } ascendManagerType) { @@ -75,12 +76,6 @@ private static void Load() { typeof(Entity).GetMethod("Update").HookAfter(AfterEntityUpdate); typeof(AscendManager).GetMethodInfo("Routine").GetStateMachineTarget().IlHook(MakeRngConsistent); - // https://github.com/EverestAPI/Everest/commit/b2a6f8e7c41ddafac4e6fde0e43a09ce1ac4f17e - // Autosaving prevents opening the menu to skip cutscenes during fast forward before Everest v2865. - if (Everest.Version < new Version(1, 2865)) { - typeof(Level).GetProperty("CanPause").GetGetMethod().IlHook(AllowPauseDuringSaving); - } - // System.IndexOutOfRangeException: Index was outside the bounds of the array. // https://discord.com/channels/403698615446536203/1148931167983251466/1148931167983251466 On.Celeste.LightingRenderer.SetOccluder += IgnoreSetOccluderCrash; @@ -188,16 +183,6 @@ private static bool SkipDeadzoneConfig() { return Manager.Running; } - private static void AllowPauseDuringSaving(ILCursor ilCursor, ILContext ilContext) { - if (ilCursor.TryGotoNext(MoveType.After, ins => ins.MatchCall(typeof(UserIO), "get_Saving"))) { - ilCursor.EmitDelegate(IsSaving); - } - } - - private static bool IsSaving(bool saving) { - return !Manager.Running && saving; - } - private static void IgnoreSetOccluderCrash(On.Celeste.LightingRenderer.orig_SetOccluder orig, LightingRenderer self, Vector3 center, Color mask, Vector2 light, Vector2 edgeA, Vector2 edgeB) { try { orig(self, center, mask, light, edgeA, edgeB); @@ -276,4 +261,4 @@ private static void AuraPopRandom() { Calc.PopRandom(); } } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/DisableAchievements.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/DisableAchievements.cs index 2fa0aaaf2..32549eb39 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/DisableAchievements.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/DisableAchievements.cs @@ -50,4 +50,4 @@ private static void Stats_Increment(On.Celeste.Stats.orig_Increment orig, Stat s orig(stat, increment); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/DisableRumble.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/DisableRumble.cs index 4a51f64b0..2351738a6 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/DisableRumble.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/DisableRumble.cs @@ -7,7 +7,7 @@ namespace TAS.EverestInterop; public static class DisableRumble { [Load] private static void Load() { - HookHelper.SkipMethod(typeof(DisableRumble), nameof(IsDisableRumble), typeof(MInput.GamePadData).GetMethodInfo("Rumble")); + typeof(MInput.GamePadData).GetMethodInfo("Rumble").SkipMethod(IsDisableRumble); } private static bool IsDisableRumble() { @@ -20,4 +20,4 @@ private static void EnableRun() { gamePadData.StopRumble(); } } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/EntityDataHelper.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/EntityDataHelper.cs index e0dad286f..de45fd11c 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/EntityDataHelper.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/EntityDataHelper.cs @@ -8,6 +8,7 @@ using Mono.Cecil.Cil; using Monocle; using MonoMod.Cil; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; @@ -100,8 +101,8 @@ private static void SetEntityData(this Entity entity, EntityData data) { } } - public static EntityData GetEntityData(this Entity entity) { - return entity != null && CachedEntityData.TryGetValue(entity, out EntityData data) ? data : null; + public static EntityData? GetEntityData(this Entity? entity) { + return entity != null && CachedEntityData.TryGetValue(entity, out var data) ? data : null; } private static void LevelOnEnd(On.Celeste.Level.orig_End orig, Level self) { @@ -226,35 +227,35 @@ private static void StrawberrySeedOnCtor(On.Celeste.StrawberrySeed.orig_ctor ori private static void ModSpawnEntity(ILContext il) { ILCursor cursor = new(il); - if (cursor.TryGotoNext( - i => i.OpCode == OpCodes.Callvirt && i.Operand.ToString() == "System.Void Monocle.Scene::Add(Monocle.Entity)")) { - cursor.Emit(OpCodes.Dup).Emit(OpCodes.Ldarg_0); + if (cursor.TryGotoNext(ins => ins.OpCode == OpCodes.Callvirt && ins.Operand.ToString() == "System.Void Monocle.Scene::Add(Monocle.Entity)")) { + cursor.EmitDup(); + cursor.EmitLdarg0(); + + // TODO: Better match if (il.ToString().Contains("ldfld Celeste.SeekerStatue Celeste.SeekerStatue/<>c__DisplayClass3_0::<>4__this") && ModUtils.VanillaAssembly.GetType("Celeste.SeekerStatue+<>c__DisplayClass3_0")?.GetFieldInfo("<>4__this") is { } seekerStatue ) { - cursor.Emit(OpCodes.Ldfld, seekerStatue); - } - - cursor.EmitDelegate>(SetCustomEntityData); - } - } - - private static void SetCustomEntityData(Entity spawnedEntity, Entity entity) { - if (entity.GetEntityData() is { } entityData) { - EntityData clonedEntityData = entityData.ShallowClone(); - if (spawnedEntity is FireBall fireBall) { - clonedEntityData.ID = clonedEntityData.ID * -100 - fireBall.index; - } else if (entity is CS03_OshiroRooftop) { - clonedEntityData.ID = 2; - } else { - clonedEntityData.ID *= -1; + cursor.EmitLdfld(seekerStatue); } - spawnedEntity.SetEntityData(clonedEntityData); + cursor.EmitStaticDelegate("SetCustomEntityData", static (Entity spawnedEntity, Entity entity) => { + if (entity.GetEntityData() is { } entityData) { + EntityData clonedEntityData = entityData.ShallowClone(); + if (spawnedEntity is FireBall fireBall) { + clonedEntityData.ID = clonedEntityData.ID * -100 - fireBall.index; + } else if (entity is CS03_OshiroRooftop) { + clonedEntityData.ID = 2; + } else { + clonedEntityData.ID *= -1; + } + + spawnedEntity.SetEntityData(clonedEntityData); + } + }); } } private static void CacheEntityData(Entity entity, EntityData data) { entity.SetEntityData(data); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/FastForwardBoost.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/FastForwardBoost.cs index d06da9f27..593581250 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/FastForwardBoost.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/FastForwardBoost.cs @@ -1,18 +1,19 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using Celeste; using Celeste.Mod; using Mono.Cecil.Cil; using Monocle; using MonoMod.Cil; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; namespace TAS.EverestInterop; public static class FastForwardBoost { - private static bool UltraFastForwarding => Manager.UltraFastForwarding; private static Type creditsType; [Initialize] @@ -140,7 +141,7 @@ private static void AddTypeToTracker(Type type, params Type[] subTypes) { } private static void BackdropRendererOnUpdate(On.Celeste.BackdropRenderer.orig_Update orig, BackdropRenderer self, Scene scene) { - if (UltraFastForwarding && Engine.FrameCounter % 1000 > 0) { + if (Manager.FastForwarding && Engine.FrameCounter % 1000 > 0) { return; } @@ -148,7 +149,7 @@ private static void BackdropRendererOnUpdate(On.Celeste.BackdropRenderer.orig_Up } private static void SoundEmitterOnUpdate(On.Celeste.SoundEmitter.orig_Update orig, SoundEmitter self) { - if (UltraFastForwarding) { + if (Manager.FastForwarding) { self.RemoveSelf(); } else { orig(self); @@ -158,7 +159,7 @@ private static void SoundEmitterOnUpdate(On.Celeste.SoundEmitter.orig_Update ori private static void SkipUpdateMethod(ILContext il) { ILCursor ilCursor = new(il); Instruction start = ilCursor.Next; - ilCursor.Emit(OpCodes.Call, typeof(Manager).GetProperty(nameof(Manager.UltraFastForwarding)).GetMethod); + ilCursor.Emit(OpCodes.Call, typeof(Manager).GetProperty(nameof(Manager.FastForwarding), BindingFlags.Public | BindingFlags.Static)!.GetMethod); ilCursor.Emit(OpCodes.Brfalse, start).Emit(OpCodes.Ret); } @@ -170,16 +171,16 @@ private static void LightningRendererOnUpdate(ILContext il) { } private static bool IsSkipLightningRendererUpdate() { - return Manager.UltraFastForwarding && Engine.FrameCounter % 30 > 0; + return Manager.FastForwarding && Engine.FrameCounter % 30 > 0; } private static void SeekerBarrierOnUpdate(ILContext il) { ILCursor cursor = new(il); if (!cursor.TryGotoNext(MoveType.AfterLabel, - instr => instr.MatchLdarg(0), - instr => instr.MatchLdfld("speeds"), - instr => instr.MatchLdlen()) + ins => ins.MatchLdarg(0), + ins => ins.MatchLdfld("speeds"), + ins => ins.MatchLdlen()) ) { return; } @@ -188,8 +189,8 @@ private static void SeekerBarrierOnUpdate(ILContext il) { cursor.EmitDelegate>(IsSkipSeekerBarrierOverloadPart); cursor.Emit(OpCodes.Brtrue, target); - if (!cursor.TryGotoNext(instr => instr.MatchLdarg(0), - instr => instr.MatchCall("Update")) + if (!cursor.TryGotoNext(ins => ins.MatchLdarg(0), + ins => ins.MatchCall("Update")) ) { return; } @@ -198,7 +199,7 @@ private static void SeekerBarrierOnUpdate(ILContext il) { } private static bool IsSkipSeekerBarrierOverloadPart() { - return UltraFastForwarding; + return Manager.FastForwarding; } private static void IgnoreGcCollect(ILContext il) { @@ -212,10 +213,10 @@ private static void IgnoreGcCollect(ILContext il) { } private static bool IsIgnoreGcCollect() { - return TasSettings.IgnoreGcCollect && UltraFastForwarding; + return TasSettings.IgnoreGcCollect && Manager.FastForwarding; } private static void InputOnOnInitialize() { CelesteTasModule.Instance.OnInputDeregister(); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/HideGameplay.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/HideGameplay.cs index b779a6ac3..7428be016 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/HideGameplay.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/HideGameplay.cs @@ -47,4 +47,4 @@ private static void GameplayRenderer_Render(ILContext il) { private static bool IsHideGamePlay() { return !TasSettings.ShowGameplay; } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/ActualEntityCollideHitbox.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/ActualEntityCollideHitbox.cs deleted file mode 100644 index d63f5acf3..000000000 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/ActualEntityCollideHitbox.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Celeste; -using Microsoft.Xna.Framework; -using Mono.Cecil.Cil; -using Monocle; -using MonoMod.Cil; -using StudioCommunication; -using TAS.Module; -using TAS.Utils; - -namespace TAS.EverestInterop.Hitboxes; - -public static partial class ActualEntityCollideHitbox { - private static readonly Dictionary LastPositions = new(); - private static readonly Dictionary LastColldables = new(); - - private static bool playerUpdated; - private static bool dontSaveLastPosition; - private static bool colliderListRendering; - - [Initialize] - private static void Initialize() { - if (ModUtils.GetType("SpirialisHelper", "Celeste.Mod.Spirialis.TimeController")?.GetMethodInfo("CustomELUpdate") is { } customELUpdate) { - customELUpdate.IlHook((cursor, _) => cursor.EmitDelegate(Clear)); - } - } - - [Load] - private static void Load() { - typeof(Player).GetMethod("orig_Update").IlHook(ModPlayerOrigUpdateEntity); - On.Celeste.Player.Update += PlayerOnUpdate; - On.Monocle.Hitbox.Render += HitboxOnRenderEntity; - On.Monocle.Circle.Render += CircleOnRender; - On.Monocle.ColliderList.Render += ColliderListOnRender; - On.Monocle.EntityList.Update += EntityListOnUpdate; - On.Celeste.Level.End += LevelOnEnd; - } - - [Unload] - private static void Unload() { - On.Celeste.Player.Update -= PlayerOnUpdate; - On.Monocle.Hitbox.Render -= HitboxOnRenderEntity; - On.Monocle.Circle.Render -= CircleOnRender; - On.Monocle.ColliderList.Render -= ColliderListOnRender; - On.Monocle.EntityList.Update -= EntityListOnUpdate; - On.Celeste.Level.End -= LevelOnEnd; - } - - private static void PlayerOnUpdate(On.Celeste.Player.orig_Update orig, Player self) { - dontSaveLastPosition = Manager.UltraFastForwarding || !TasSettings.ShowHitboxes || - TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Off || playerUpdated; - orig(self); - playerUpdated = true; - } - - private static void EntityListOnUpdate(On.Monocle.EntityList.orig_Update orig, EntityList self) { - Clear(); - orig(self); - } - - private static void LevelOnEnd(On.Celeste.Level.orig_End orig, Level self) { - Clear(); - orig(self); - } - - private static void Clear() { - playerUpdated = false; - LastPositions.Clear(); - LastColldables.Clear(); - } - - private static void ModPlayerOrigUpdateEntity(ILContext il) { - ILCursor ilCursor = new(il); - if (ilCursor.TryGotoNext(MoveType.After, ins => ins.MatchCastclass())) { - ilCursor.Emit(OpCodes.Dup).EmitDelegate>(SaveEntityPosition); - } - } - - private static void SaveEntityPosition(PlayerCollider playerCollider) { - Entity entity = playerCollider.Entity; - - if (dontSaveLastPosition || entity == null) { - return; - } - - entity.SaveActualCollidePosition(); - entity.SaveActualCollidable(); - } - - private static void CircleOnRender(On.Monocle.Circle.orig_Render orig, Circle self, Camera camera, Color color) { - DrawLastFrameHitbox(self, color, hitboxColor => orig(self, camera, hitboxColor)); - } - - private static void HitboxOnRenderEntity(On.Monocle.Hitbox.orig_Render orig, Hitbox self, Camera camera, Color color) { - DrawLastFrameHitbox(self, color, hitboxColor => orig(self, camera, hitboxColor)); - } - - private static void ColliderListOnRender(On.Monocle.ColliderList.orig_Render orig, ColliderList self, Camera camera, Color color) { - colliderListRendering = true; - DrawLastFrameHitbox(self, color, hitboxColor => orig(self, camera, hitboxColor)); - colliderListRendering = false; - } - - private static void DrawLastFrameHitbox(Collider self, Color color, Action invokeOrig) { - Entity entity = self.Entity; - - if (Manager.UltraFastForwarding - || !TasSettings.ShowHitboxes - || TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Off - || colliderListRendering && self is not ColliderList - || entity.Get() == null - || entity.Scene?.Tracker.GetEntity() == null - || entity.LoadActualCollidePosition() is not { } actualCollidePosition - || TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Append && entity.Position == actualCollidePosition && - entity.Collidable == entity.LoadActualCollidable() - ) { - invokeOrig(color); - return; - } - - Color lastFrameColor = - TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Append && entity.Position != actualCollidePosition - ? color.Invert() - : color; - - if (entity.Collidable && !entity.LoadActualCollidable()) { - lastFrameColor *= HitboxColor.UnCollidableAlpha; - } else if (!entity.Collidable && entity.LoadActualCollidable() & HitboxColor.UnCollidableAlpha > 0) { - lastFrameColor *= 1 / HitboxColor.UnCollidableAlpha; - } - - if (TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Append) { - if (entity.Position == actualCollidePosition) { - invokeOrig(lastFrameColor); - return; - } - - invokeOrig(color); - } - - Vector2 currentPosition = entity.Position; - - IEnumerable playerColliders = entity.Components.GetAll().ToArray(); - if (playerColliders.All(playerCollider => playerCollider.Collider != null)) { - if (playerColliders.Any(playerCollider => playerCollider.Collider == self)) { - entity.Position = actualCollidePosition; - invokeOrig(lastFrameColor); - entity.Position = currentPosition; - } else { - invokeOrig(color); - } - } else { - entity.Position = actualCollidePosition; - invokeOrig(lastFrameColor); - entity.Position = currentPosition; - } - } - - private static void SaveActualCollidePosition(this Entity entity) { - LastPositions[entity] = entity.Position; - } - - private static Vector2? LoadActualCollidePosition(this Entity entity) { - return LastPositions.TryGetValue(entity, out Vector2 result) ? result : null; - } - - private static void SaveActualCollidable(this Entity entity) { - LastColldables[entity] = entity.Collidable; - } - - private static bool LoadActualCollidable(this Entity entity) { - return LastColldables.TryGetValue(entity, out bool result) && result; - } -} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/ActualPlayerCollideHitbox.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/ActualPlayerCollideHitbox.cs deleted file mode 100644 index 86d09b692..000000000 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/ActualPlayerCollideHitbox.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using Celeste; -using Microsoft.Xna.Framework; -using Mono.Cecil.Cil; -using Monocle; -using MonoMod.Cil; -using StudioCommunication; -using TAS.Module; -using TAS.Utils; - -namespace TAS.EverestInterop.Hitboxes; - -public static partial class ActualEntityCollideHitbox { - private static readonly Color PlayerHitboxColor = Color.Red.Invert(); - private static readonly Color PlayerHurtboxColor = Color.Lime.Invert(); - - [Load] - private static void LoadPlayerHook() { - On.Celeste.Player.DebugRender += PlayerOnDebugRender; - typeof(Player).GetMethod("orig_Update").IlHook(ModPlayerOrigUpdate); - } - - [Unload] - private static void UnloadPlayerHook() { - On.Celeste.Player.DebugRender -= PlayerOnDebugRender; - } - - private static void ModPlayerOrigUpdate(ILContext il) { - ILCursor ilCursor = new(il); - if (ilCursor.TryGotoNext(MoveType.After, - ins => ins.OpCode == OpCodes.Callvirt && - ins.Operand.ToString().Contains("Monocle.Tracker::GetComponents()"))) { - ilCursor.Emit(OpCodes.Ldarg_0).EmitDelegate>(SavePlayerPosition); - } - } - - private static void SavePlayerPosition(Player player) { - if (Manager.UltraFastForwarding || !TasSettings.ShowHitboxes || TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Off || - playerUpdated) { - return; - } - - player.SaveActualCollidePosition(); - } - - private static void PlayerOnDebugRender(On.Celeste.Player.orig_DebugRender orig, Player player, Camera camera) { - if (Manager.UltraFastForwarding - || !TasSettings.ShowHitboxes - || TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Off - || player.Scene is Level {Transitioning: true} - || player.LoadActualCollidePosition() is not { } actualCollidePosition - || actualCollidePosition == player.Position - ) { - orig(player, camera); - return; - } - - if (TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Override) { - DrawAssistedHitbox(player, actualCollidePosition); - } - - orig(player, camera); - if (TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Append) { - DrawAssistedHitbox(player, actualCollidePosition); - } - } - - private static void DrawAssistedHitbox(Player player, Vector2 hitboxPosition) { - Collider origCollider = player.Collider; - Collider hurtbox = player.hurtbox; - Vector2 origPosition = player.Position; - - player.Position = hitboxPosition; - - Draw.HollowRect(origCollider, PlayerHitboxColor); - player.Collider = hurtbox; - Draw.HollowRect(hurtbox, PlayerHurtboxColor); - - player.Collider = origCollider; - player.Position = origPosition; - } -} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/CycleHitboxColor.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/CycleHitboxColor.cs index 170c61742..9f5b95a4d 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/CycleHitboxColor.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/CycleHitboxColor.cs @@ -5,6 +5,7 @@ using Celeste.Mod.UI; using Microsoft.Xna.Framework; using Monocle; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; @@ -201,4 +202,4 @@ private static void CmdChangeOtherCyclesHitboxColor(string color) { TasSettings.OtherCyclesHitboxColor = HitboxColor.HexToColor(color, DefaultOthersColor); CelesteTasModule.Instance.SaveSettings(); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/FreeCameraHitbox.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/FreeCameraHitbox.cs index 9f1ff502c..51016f34a 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/FreeCameraHitbox.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/FreeCameraHitbox.cs @@ -4,6 +4,7 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Monocle; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; @@ -53,7 +54,7 @@ private static void SubHudRendererOnRenderContent(orig_SubHudRenderer_RenderCont spriteEffects |= SpriteEffects.FlipHorizontally; } - if (ExtendedVariantsUtils.UpsideDown) { + if (ExtendedVariantsInterop.UpsideDown) { spriteEffects |= SpriteEffects.FlipVertically; } @@ -66,4 +67,4 @@ private static void SubHudRendererOnRenderContent(orig_SubHudRenderer_RenderCont orig(self, scene); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxColor.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxColor.cs index 9589d0a43..4c3e741c3 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxColor.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxColor.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using Celeste; @@ -7,6 +8,8 @@ using Mono.Cecil.Cil; using Monocle; using MonoMod.Cil; +using StudioCommunication; +using TAS.Gameplay.Hitboxes; using TAS.Module; namespace TAS.EverestInterop.Hitboxes; @@ -16,6 +19,7 @@ public static class HitboxColor { public static readonly Color DefaultTriggerColor = Color.MediumPurple; public static readonly Color DefaultPlatformColor = Color.Coral; public static readonly Color RespawnTriggerColor = Color.YellowGreen; + public static readonly Color CameraTriggerColor = Color.DarkGoldenrod; public static readonly Color PufferHeightCheckColor = Color.WhiteSmoke; public static readonly Color PufferPushRadiusColor = Color.DarkRed; @@ -138,15 +142,21 @@ private static Color GetCustomColor(Color color, Entity entity) { return color; } - Color customColor = entity switch { - ChangeRespawnTrigger => RespawnTriggerColor, - Trigger => TasSettings.TriggerHitboxColor, + Color customColor = Color.Red; // i hate warning CS0165 + bool found = false; + + customColor = entity switch { + Trigger => TriggerHitbox.GetHitboxColor(entity), Platform => TasSettings.PlatformHitboxColor, LookoutBlocker => Color.Green, _ => TasSettings.EntityHitboxColor }; - if (!entity.Collidable) { + if (TasSettings.ShowActualCollideHitboxes != ActualCollideHitboxType.Off && entity.LoadActualCollidable() is { } actualCollidable) { + if (!actualCollidable) { + customColor *= UnCollidableAlpha; + } + } else if (!entity.Collidable) { customColor *= UnCollidableAlpha; } @@ -174,4 +184,4 @@ private static void CmdChangePlatformHitboxColor(string color) { TasSettings.PlatformHitboxColor = HexToColor(color, DefaultPlatformColor); CelesteTasModule.Instance.SaveSettings(); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxConquerorBeam.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxConquerorBeam.cs index bd4bd8139..49828435c 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxConquerorBeam.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxConquerorBeam.cs @@ -1,6 +1,7 @@ using System; using Microsoft.Xna.Framework; using Monocle; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; @@ -50,4 +51,4 @@ private static void ModHitbox(On.Monocle.Entity.orig_DebugRender orig, Entity se Draw.Line(vector, vector2, HitboxColor.EntityColor); } } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxFinalBoss.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxFinalBoss.cs index 1780fc22e..c9e7cc122 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxFinalBoss.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxFinalBoss.cs @@ -50,4 +50,4 @@ private static void HitboxOnRender(On.Monocle.Hitbox.orig_Render orig, Hitbox se orig(self, camera, color); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxFixer.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxFixer.cs index 33299bc38..a077bf1ba 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxFixer.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxFixer.cs @@ -147,4 +147,4 @@ private static void DrawLine(int x, int y0, int y1, bool interchangeXy, Color co Draw.SpriteBatch.Draw(Draw.Pixel.Texture.Texture, rect, Draw.Pixel.ClipRect, color); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxNpc.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxNpc.cs index 59a50914d..3d1d88fb8 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxNpc.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxNpc.cs @@ -112,4 +112,4 @@ private static void EntityOnDebugRender(On.Monocle.Entity.orig_DebugRender orig, } } } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxOptimized.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxOptimized.cs index 574888e63..25a4fc48a 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxOptimized.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxOptimized.cs @@ -7,6 +7,7 @@ using Monocle; using MonoMod.Cil; using MonoMod.RuntimeDetour; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; @@ -49,7 +50,7 @@ private static void Initialize() { // but i have no good idea, so i put it aside } - using (new DetourContext {After = new List {"*"}}) { + using (new DetourConfigContext(new DetourConfig("CelesteTAS", before: ["*"])).Use()) { On.Monocle.Entity.DebugRender += ModDebugRender; } } @@ -201,7 +202,7 @@ private static void DrawPufferLaunchOrBounceIndicator(Puffer puffer) { if (z > pc.Collider.Bottom + puffer.Y) { return; } - + float top = Math.Max(pc.Collider.Top + puffer.Y, z); Draw.HollowRect(puffer.X - 7f, top, 14f, pc.Collider.Bottom + puffer.Y - top, puffer.Collidable ? HitboxColor.PufferHeightCheckColor : HitboxColor.PufferHeightCheckColor * HitboxColor.UnCollidableAlpha); diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxRoomBoundary.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxRoomBoundary.cs index e8e2d95a4..03398ed6d 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxRoomBoundary.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxRoomBoundary.cs @@ -27,4 +27,4 @@ private static void EntityListOnDebugRender(On.Monocle.EntityList.orig_DebugRend Draw.HollowRect(bounds.X - 1, bounds.Y - topExtra, bounds.Width + 2, bounds.Height + topExtra + 1, HitboxColor.RespawnTriggerColor); } } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxSimplified.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxSimplified.cs index d6de80c97..287a17c50 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxSimplified.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxSimplified.cs @@ -2,21 +2,25 @@ using System.Collections.Generic; using System.Reflection; using Celeste; +using JetBrains.Annotations; using Microsoft.Xna.Framework; using Mono.Cecil.Cil; using Monocle; using MonoMod.Cil; using TAS.EverestInterop.InfoHUD; +using TAS.Gameplay.Hitboxes; +using TAS.InfoHUD; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; namespace TAS.EverestInterop.Hitboxes; public static class HitboxSimplified { - private static readonly Lazy> GeckoHostile = new(() => + private static readonly Lazy?> GeckoHostile = new(() => ModUtils.GetType("JungleHelper", "Celeste.Mod.JungleHelper.Entities.Gecko")?.CreateGetDelegate("hostile")); - private static readonly Lazy> CustomClutterBlockBaseEnabled = new(() => + private static readonly Lazy?> CustomClutterBlockBaseEnabled = new(() => ModUtils.GetType("ClutterHelper", "Celeste.Mod.ClutterHelper.CustomClutterBlockBase")?.CreateGetDelegate("enabled")); private static readonly HashSet UselessTypes = new() { @@ -53,17 +57,18 @@ private static void Initialize() { UselessTypes.Add(type); } } - - HookHelper.SkipMethod(typeof(HitboxSimplified), nameof(IsSimplifiedHitboxes), "DebugRender", - ModUtils.GetType("FemtoHelper", "CustomMoonCreature") - ); + + ModUtils.GetType("FemtoHelper", "CustomMoonCreature") + ?.GetMethod("DebugRender") + ?.SkipMethod(IsSimplifiedHitboxes); } private static bool IsSimplifiedHitboxes() => TasSettings.ShowHitboxes && TasSettings.SimplifiedHitboxes; [Load] private static void Load() { - IL.Monocle.Entity.DebugRender += ModDebugRender; + typeof(Entity).GetMethodInfo(nameof(Entity.DebugRender)).SkipMethod(HideHitbox); + On.Monocle.Hitbox.Render += ModHitbox; On.Monocle.Grid.Render += CombineGridHitbox; IL.Monocle.Draw.HollowRect_float_float_float_float_Color += AvoidRedrawCorners; @@ -73,7 +78,6 @@ private static void Load() { [Unload] private static void Unload() { - IL.Monocle.Entity.DebugRender -= ModDebugRender; On.Monocle.Hitbox.Render -= ModHitbox; On.Monocle.Grid.Render -= CombineGridHitbox; IL.Monocle.Draw.HollowRect_float_float_float_float_Color -= AvoidRedrawCorners; @@ -81,15 +85,17 @@ private static void Unload() { On.Celeste.Level.End -= LevelOnEnd; } - private static void ModDebugRender(ILContext il) { - ILCursor ilCursor = new(il); - Instruction start = ilCursor.Next; - ilCursor.Emit(OpCodes.Ldarg_0).EmitDelegate>(HideHitbox); - ilCursor.Emit(OpCodes.Brfalse, start).Emit(OpCodes.Ret); - } + [PublicAPI] + public static bool HideHitbox(Entity entity) { + if (!TasSettings.ShowHitboxes || InfoWatchEntity.CurrentlyWatchedEntities.Contains(entity)) { + return false; + } + + if (TriggerHitbox.ShouldHideHitbox(entity)) { + return true; + } - private static bool HideHitbox(Entity entity) { - if (TasSettings.ShowHitboxes && TasSettings.SimplifiedHitboxes && !InfoWatchEntity.WatchingEntities.Contains(entity)) { + if (TasSettings.SimplifiedHitboxes) { Type type = entity.GetType(); if (UselessTypes.Contains(type)) { return true; @@ -306,4 +312,4 @@ private static void LevelOnEnd(On.Celeste.Level.orig_End orig, Level self) { orig(self); Followers.Clear(); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxToggle.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxToggle.cs index dbac501a0..7b33026ce 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxToggle.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxToggle.cs @@ -66,4 +66,4 @@ private static void GlitchOnApply(ILContext il) { private static bool IsShowHitbox() { return TasSettings.ShowHitboxes; } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxTrigger.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxTrigger.cs deleted file mode 100644 index 1cef47682..000000000 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxTrigger.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using Celeste; -using Mono.Cecil.Cil; -using Monocle; -using MonoMod.Cil; -using TAS.EverestInterop.InfoHUD; -using TAS.Module; - -namespace TAS.EverestInterop.Hitboxes; - -public static class HitboxTrigger { - [Load] - private static void Load() { - IL.Monocle.Entity.DebugRender += ModDebugRender; - } - - [Unload] - private static void Unload() { - IL.Monocle.Entity.DebugRender -= ModDebugRender; - } - - private static void ModDebugRender(ILContext il) { - ILCursor ilCursor = new(il); - Instruction start = ilCursor.Next; - ilCursor.Emit(OpCodes.Ldarg_0) - .EmitDelegate>(IsHideTriggerHitbox); - ilCursor.Emit(OpCodes.Brfalse, start).Emit(OpCodes.Ret); - } - - private static bool IsHideTriggerHitbox(Entity entity) { - return TasSettings.ShowHitboxes && !TasSettings.ShowTriggerHitboxes && entity is Trigger && - !InfoWatchEntity.WatchingEntities.Contains(entity); - } -} \ No newline at end of file diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxTriggerSpikes.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxTriggerSpikes.cs index 0ce28eb29..1389b3757 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxTriggerSpikes.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/HitboxTriggerSpikes.cs @@ -3,6 +3,7 @@ using Celeste.Mod.Entities; using Microsoft.Xna.Framework; using Monocle; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; @@ -189,4 +190,4 @@ private static void DrawSpikesHitboxes(TriggerSpikesOriginal triggerSpikes, Came } } } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/UnloadedRoomHitbox.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/UnloadedRoomHitbox.cs index d782d48a1..b563d2917 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/UnloadedRoomHitbox.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hitboxes/UnloadedRoomHitbox.cs @@ -618,4 +618,4 @@ private static void DrawDecalHitbox(LevelData levelData, List actions) { private static Rectangle CreateRect(this Vector2 position, int width, int height, int x, int y) { return new Rectangle((int) (position.X + x), (int) (position.Y + y), width, height); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/HotReloadHelper.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/HotReloadHelper.cs deleted file mode 100644 index 82c3a730d..000000000 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/HotReloadHelper.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.IO; -using Celeste.Mod; -using TAS.Module; -using TAS.Utils; - -#if DEBUG -namespace TAS.EverestInterop; - -public static class HotReloadHelper { - private static FileSystemWatcher watcher; - - [Load] - private static void Load() { - EverestModuleMetadata meta = CelesteTasModule.Instance.Metadata; - if (meta.PathDirectory.IsNullOrEmpty()) { - return; - } - - try { - watcher = new FileSystemWatcher { - Path = Path.GetDirectoryName(meta.DLL), - NotifyFilter = NotifyFilters.LastWrite, - }; - - watcher.Changed += (s, e) => { - if (e.FullPath == meta.DLL && Manager.Running) { - Manager.DisableRun(); - } - }; - - watcher.EnableRaisingEvents = true; - } catch (Exception e) { - e.LogException($"Failed watching folder: {Path.GetDirectoryName(meta.DLL)}"); - Unload(); - } - } - - [Unload] - private static void Unload() { - watcher?.Dispose(); - watcher = null; - } -} -#endif \ No newline at end of file diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Hotkeys.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Hotkeys.cs index 9e502e007..760105645 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Hotkeys.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Hotkeys.cs @@ -2,163 +2,161 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Text.RegularExpressions; using Celeste; using Celeste.Mod; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; -using Mono.Cecil.Cil; using Monocle; -using MonoMod.Cil; using StudioCommunication; using TAS.Communication; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; -using XNAKeys = Microsoft.Xna.Framework.Input.Keys; +using InputKeys = Microsoft.Xna.Framework.Input.Keys; using InputButtons = Microsoft.Xna.Framework.Input.Buttons; using Hud = TAS.EverestInterop.InfoHUD.InfoHud; using Camera = TAS.EverestInterop.CenterCamera; namespace TAS.EverestInterop; +/// Manages hotkeys for controlling TAS playback +/// Cannot use MInput, since that isn't updated while paused and already used for TAS inputs public static class Hotkeys { - private static IEnumerable bindingProperties; - private static FieldInfo bindingFieldInfo; - - private static readonly Lazy CelesteNetClientModuleInstance = new(() => - ModUtils.GetType("CelesteNet.Client", "Celeste.Mod.CelesteNet.Client.CelesteNetClientModule")?.GetFieldInfo("Instance")); - - private static readonly Lazy CelesteNetClientModuleContext = new(() => - ModUtils.GetType("CelesteNet.Client", "Celeste.Mod.CelesteNet.Client.CelesteNetClientModule")?.GetFieldInfo("Context")); - - private static readonly Lazy CelesteNetClientContextChat = new(() => - ModUtils.GetType("CelesteNet.Client", "Celeste.Mod.CelesteNet.Client.CelesteNetClientContext")?.GetFieldInfo("Chat")); - - private static readonly Lazy CelesteNetChatComponentActive = new(() => - ModUtils.GetType("CelesteNet.Client", "Celeste.Mod.CelesteNet.Client.Components.CelesteNetChatComponent")?.GetPropertyInfo("Active")); + private static readonly Lazy f_CelesteNetClientModule_Instance = new(() => ModUtils.GetType("CelesteNet.Client", "Celeste.Mod.CelesteNet.Client.CelesteNetClientModule")?.GetFieldInfo("Instance")); + private static readonly Lazy f_CelesteNetClientModule_Context = new(() => ModUtils.GetType("CelesteNet.Client", "Celeste.Mod.CelesteNet.Client.CelesteNetClientModule")?.GetFieldInfo("Context")); + private static readonly Lazy f_CelesteNetClientContext_Chat = new(() => ModUtils.GetType("CelesteNet.Client", "Celeste.Mod.CelesteNet.Client.CelesteNetClientContext")?.GetFieldInfo("Chat")); + private static readonly Lazy p_CelesteNetChatComponent_Active = new(() => ModUtils.GetType("CelesteNet.Client", "Celeste.Mod.CelesteNet.Client.Components.CelesteNetChatComponent")?.GetPropertyInfo("Active")); private static KeyboardState kbState; private static GamePadState padState; - public static Hotkey StartStop { get; private set; } - public static Hotkey Restart { get; private set; } - public static Hotkey FastForward { get; private set; } - public static Hotkey FastForwardComment { get; private set; } - public static Hotkey SlowForward { get; private set; } - public static Hotkey FrameAdvance { get; private set; } - public static Hotkey PauseResume { get; private set; } - public static Hotkey Hitboxes { get; private set; } - public static Hotkey TriggerHitboxes { get; private set; } - public static Hotkey SimplifiedGraphic { get; private set; } - public static Hotkey CenterCamera { get; private set; } - public static Hotkey LockCamera { get; private set; } - public static Hotkey SaveState { get; private set; } - public static Hotkey ClearState { get; private set; } - public static Hotkey InfoHud { get; private set; } - public static Hotkey FreeCamera { get; private set; } - public static Hotkey CameraUp { get; private set; } - public static Hotkey CameraDown { get; private set; } - public static Hotkey CameraLeft { get; private set; } - public static Hotkey CameraRight { get; private set; } - public static Hotkey CameraZoomIn { get; private set; } - public static Hotkey CameraZoomOut { get; private set; } - public static float RightThumbSticksX => padState.ThumbSticks.Right.X; + public static Hotkey StartStop { get; private set; } = null!; + public static Hotkey Restart { get; private set; } = null!; + public static Hotkey FastForward { get; private set; } = null!; + public static Hotkey FastForwardComment { get; private set; } = null!; + public static Hotkey SlowForward { get; private set; } = null!; + public static Hotkey FrameAdvance { get; private set; } = null!; + public static Hotkey PauseResume { get; private set; } = null!; + public static Hotkey Hitboxes { get; private set; } = null!; + public static Hotkey TriggerHitboxes { get; private set; } = null!; + public static Hotkey SimplifiedGraphic { get; private set; } = null!; + public static Hotkey CenterCamera { get; private set; } = null!; + public static Hotkey LockCamera { get; private set; } = null!; + public static Hotkey SaveState { get; private set; } = null!; + public static Hotkey ClearState { get; private set; } = null!; + public static Hotkey InfoHud { get; private set; } = null!; + public static Hotkey FreeCamera { get; private set; } = null!; + public static Hotkey CameraUp { get; private set; } = null!; + public static Hotkey CameraDown { get; private set; } = null!; + public static Hotkey CameraLeft { get; private set; } = null!; + public static Hotkey CameraRight { get; private set; } = null!; + public static Hotkey CameraZoomIn { get; private set; } = null!; + public static Hotkey CameraZoomOut { get; private set; } = null!; + public static Hotkey OpenConsole { get; private set; } = null!; - public static readonly Dictionary KeysDict = new(); - private static List hotKeysInteractWithStudio; - public static Dictionary> KeysInteractWithStudio = new(); + public static float RightThumbSticksX => padState.ThumbSticks.Right.X; - private static readonly List HotkeyIDsIgnoreOnStudio = new() { - HotkeyID.InfoHud, HotkeyID.FreeCamera, HotkeyID.CameraUp, HotkeyID.CameraDown, HotkeyID.CameraLeft, HotkeyID.CameraRight, - HotkeyID.CameraZoomIn, - HotkeyID.CameraZoomOut - }; + public static readonly Dictionary AllHotkeys = new(); + public static Dictionary> StudioHotkeys = new(); - static Hotkeys() { - InputInitialize(); - } + /// Hotkeys which shouldn't be triggered from Studio + private static readonly List StudioIgnoreHotkeys = [ + HotkeyID.InfoHud, + HotkeyID.FreeCamera, + HotkeyID.CameraUp, HotkeyID.CameraDown, HotkeyID.CameraLeft, HotkeyID.CameraRight, + HotkeyID.CameraZoomIn, HotkeyID.CameraZoomOut, + HotkeyID.OpenConsole + ]; + /// Checks if the CelesteNet chat is open private static bool CelesteNetChatting { get { - if (CelesteNetClientModuleInstance.Value?.GetValue(null) is not { } instance) { + if (f_CelesteNetClientModule_Instance.Value?.GetValue(null) is not { } instance) { return false; } - - if (CelesteNetClientModuleContext.Value?.GetValue(instance) is not { } context) { + if (f_CelesteNetClientModule_Context.Value?.GetValue(instance) is not { } context) { return false; } - - if (CelesteNetClientContextChat.Value?.GetValue(context) is not { } chat) { + if (f_CelesteNetClientContext_Chat.Value?.GetValue(context) is not { } chat) { return false; } - return CelesteNetChatComponentActive.Value?.GetValue(chat) as bool? == true; + return p_CelesteNetChatComponent_Active.Value?.GetValue(chat) as bool? == true; } } - private static void InputInitialize() { - KeysDict.Clear(); - KeysDict[HotkeyID.Start] = StartStop = BindingToHotkey(TasSettings.KeyStart); - KeysDict[HotkeyID.Restart] = Restart = BindingToHotkey(TasSettings.KeyRestart); - KeysDict[HotkeyID.FastForward] = FastForward = BindingToHotkey(TasSettings.KeyFastForward, true); - KeysDict[HotkeyID.FastForwardComment] = FastForwardComment = BindingToHotkey(TasSettings.KeyFastForwardComment); - KeysDict[HotkeyID.FrameAdvance] = FrameAdvance = BindingToHotkey(TasSettings.KeyFrameAdvance); - KeysDict[HotkeyID.SlowForward] = SlowForward = BindingToHotkey(TasSettings.KeySlowForward, true); - KeysDict[HotkeyID.Pause] = PauseResume = BindingToHotkey(TasSettings.KeyPause); - KeysDict[HotkeyID.Hitboxes] = Hitboxes = BindingToHotkey(TasSettings.KeyHitboxes); - KeysDict[HotkeyID.TriggerHitboxes] = TriggerHitboxes = BindingToHotkey(TasSettings.KeyTriggerHitboxes); - KeysDict[HotkeyID.Graphics] = SimplifiedGraphic = BindingToHotkey(TasSettings.KeyGraphics); - KeysDict[HotkeyID.Camera] = CenterCamera = BindingToHotkey(TasSettings.KeyCamera); - KeysDict[HotkeyID.LockCamera] = LockCamera = BindingToHotkey(TasSettings.KeyLockCamera); - KeysDict[HotkeyID.SaveState] = SaveState = BindingToHotkey(TasSettings.KeySaveState); - KeysDict[HotkeyID.ClearState] = ClearState = BindingToHotkey(TasSettings.KeyClearState); - KeysDict[HotkeyID.InfoHud] = InfoHud = BindingToHotkey(TasSettings.KeyInfoHud); - KeysDict[HotkeyID.FreeCamera] = FreeCamera = BindingToHotkey(TasSettings.KeyFreeCamera); - KeysDict[HotkeyID.CameraUp] = CameraUp = BindingToHotkey(new ButtonBinding(0, Keys.Up)); - KeysDict[HotkeyID.CameraDown] = CameraDown = BindingToHotkey(new ButtonBinding(0, Keys.Down)); - KeysDict[HotkeyID.CameraLeft] = CameraLeft = BindingToHotkey(new ButtonBinding(0, Keys.Left)); - KeysDict[HotkeyID.CameraRight] = CameraRight = BindingToHotkey(new ButtonBinding(0, Keys.Right)); - KeysDict[HotkeyID.CameraZoomIn] = CameraZoomIn = BindingToHotkey(new ButtonBinding(0, Keys.Home)); - KeysDict[HotkeyID.CameraZoomOut] = CameraZoomOut = BindingToHotkey(new ButtonBinding(0, Keys.End)); - - hotKeysInteractWithStudio = KeysDict.Where(pair => !HotkeyIDsIgnoreOnStudio.Contains(pair.Key)).Select(pair => pair.Value).ToList(); - KeysInteractWithStudio = KeysDict.Where(pair => !HotkeyIDsIgnoreOnStudio.Contains(pair.Key)) - .ToDictionary(pair => pair.Key, pair => pair.Value.Keys); - } + internal static bool Initialized { get; private set; } = false; + + [Initialize] + private static void Initialize() { + AllHotkeys.Clear(); + AllHotkeys[HotkeyID.Start] = StartStop = BindingToHotkey(TasSettings.KeyStart); + AllHotkeys[HotkeyID.Restart] = Restart = BindingToHotkey(TasSettings.KeyRestart); + AllHotkeys[HotkeyID.FastForward] = FastForward = BindingToHotkey(TasSettings.KeyFastForward, true); + AllHotkeys[HotkeyID.FastForwardComment] = FastForwardComment = BindingToHotkey(TasSettings.KeyFastForwardComment); + AllHotkeys[HotkeyID.FrameAdvance] = FrameAdvance = BindingToHotkey(TasSettings.KeyFrameAdvance); + AllHotkeys[HotkeyID.SlowForward] = SlowForward = BindingToHotkey(TasSettings.KeySlowForward, true); + AllHotkeys[HotkeyID.Pause] = PauseResume = BindingToHotkey(TasSettings.KeyPause); + AllHotkeys[HotkeyID.Hitboxes] = Hitboxes = BindingToHotkey(TasSettings.KeyHitboxes); + AllHotkeys[HotkeyID.TriggerHitboxes] = TriggerHitboxes = BindingToHotkey(TasSettings.KeyTriggerHitboxes); + AllHotkeys[HotkeyID.Graphics] = SimplifiedGraphic = BindingToHotkey(TasSettings.KeyGraphics); + AllHotkeys[HotkeyID.Camera] = CenterCamera = BindingToHotkey(TasSettings.KeyCamera); + AllHotkeys[HotkeyID.LockCamera] = LockCamera = BindingToHotkey(TasSettings.KeyLockCamera); + AllHotkeys[HotkeyID.SaveState] = SaveState = BindingToHotkey(TasSettings.KeySaveState); + AllHotkeys[HotkeyID.ClearState] = ClearState = BindingToHotkey(TasSettings.KeyClearState); + AllHotkeys[HotkeyID.InfoHud] = InfoHud = BindingToHotkey(TasSettings.KeyInfoHud); + AllHotkeys[HotkeyID.FreeCamera] = FreeCamera = BindingToHotkey(TasSettings.KeyFreeCamera); + AllHotkeys[HotkeyID.CameraUp] = CameraUp = BindingToHotkey(new ButtonBinding(0, Keys.Up)); + AllHotkeys[HotkeyID.CameraDown] = CameraDown = BindingToHotkey(new ButtonBinding(0, Keys.Down)); + AllHotkeys[HotkeyID.CameraLeft] = CameraLeft = BindingToHotkey(new ButtonBinding(0, Keys.Left)); + AllHotkeys[HotkeyID.CameraRight] = CameraRight = BindingToHotkey(new ButtonBinding(0, Keys.Right)); + AllHotkeys[HotkeyID.CameraZoomIn] = CameraZoomIn = BindingToHotkey(new ButtonBinding(0, Keys.Home)); + AllHotkeys[HotkeyID.CameraZoomOut] = CameraZoomOut = BindingToHotkey(new ButtonBinding(0, Keys.End)); + + var debugConsole = Celeste.Mod.Core.CoreModule.Settings.DebugConsole; + var toggleDebugConsole = Celeste.Mod.Core.CoreModule.Settings.ToggleDebugConsole; + AllHotkeys[HotkeyID.OpenConsole] = OpenConsole = new Hotkey( + debugConsole.Keys.Union(toggleDebugConsole.Keys).ToList(), + debugConsole.Buttons.Union(toggleDebugConsole.Buttons).ToList(), + keyCombo: false, held: false); + + StudioHotkeys = AllHotkeys + .Where(entry => !StudioIgnoreHotkeys.Contains(entry.Key)) + .ToDictionary(entry => entry.Key, entry => entry.Value.Keys); + + Initialized = true; + + CommunicationWrapper.SendCurrentBindings(); - private static Hotkey BindingToHotkey(ButtonBinding binding, bool held = false) { - return new(binding.Keys, binding.Buttons, true, held); + return; + + static Hotkey BindingToHotkey(ButtonBinding binding, bool held = false) { + return new(binding.Keys, binding.Buttons, true, held); + } } private static GamePadState GetGamePadState() { - GamePadState currentState = MInput.GamePads[0].CurrentState; for (int i = 0; i < 4; i++) { - currentState = GamePad.GetState((PlayerIndex) i); - if (currentState.IsConnected) { - break; + var state = GamePad.GetState((PlayerIndex) i); + if (state.IsConnected) { + return state; } } - return currentState; + // No controller connected + return default; } - - public static void Update() { - if (Manager.UltraFastForwarding) { - kbState = default; - padState = default; - } else if (!Engine.Instance.IsActive) { - kbState = default; - padState = GetGamePadState(); - } else { - kbState = Keyboard.GetState(); - padState = GetGamePadState(); - } - + internal static void UpdateMeta() { + // Determined which inputs are already used for something else bool updateKey = true; bool updateButton = true; + if (Engine.Commands.Open) { + updateKey = false; + } + if (!Manager.Running) { - if (Engine.Commands.Open || CelesteNetChatting) { + if (CelesteNetChatting) { updateKey = false; } @@ -166,31 +164,23 @@ public static void Update() { if (tracker.GetEntity() != null) { updateKey = false; } - if (tracker.GetEntity() != null) { updateButton = false; } } } - if (Manager.UltraFastForwarding) { - updateButton = false; - } - - if (Manager.UltraFastForwarding) { - foreach (Hotkey hotkey in hotKeysInteractWithStudio) { - hotkey.Update(updateKey, false); - } - } else { - foreach (Hotkey hotkey in KeysDict.Values) { - if (hotkey == InfoHud) { - hotkey.Update(); - } else { - hotkey.Update(updateKey, updateButton); - } + kbState = Keyboard.GetState(); + padState = GetGamePadState(); + foreach (var hotkey in AllHotkeys.Values) { + if (hotkey == InfoHud) { + hotkey.Update(); // Always update Info HUD + } else { + hotkey.Update(updateKey, updateButton); } } + // React to hotkeys AfterUpdate(); } @@ -215,109 +205,48 @@ private static void AfterUpdate() { TasSettings.CenterCamera = !TasSettings.CenterCamera; CelesteTasModule.Instance.SaveSettings(); } + + if (OpenConsole.Pressed) { + ConsoleEnhancements.OpenConsole(); + } } - Manager.Controller.FastForwardToNextComment(); Hud.Toggle(); Camera.ResetCamera(); } [DisableRun] private static void ReleaseAllKeys() { - foreach (Hotkey hotkey in KeysDict.Values) { + foreach (Hotkey hotkey in AllHotkeys.Values) { hotkey.OverrideCheck = false; } } -#pragma warning disable CS0612 - [Load] - private static void Load() { - On.Celeste.Input.Initialize += InputOnInitialize; - Type configUiType = typeof(ModuleSettingsKeyboardConfigUI); - if (typeof(Everest).Assembly.GetTypesSafe() - .FirstOrDefault(t => t.FullName == "Celeste.Mod.ModuleSettingsKeyboardConfigUIV2") is { } typeV2 - ) { - // Celeste v1.4: before Everest drop support v1.3.1.2 - if (typeV2.GetMethodInfo("Reset") is { } resetMethodV2) { - resetMethodV2.IlHook(ModReload); - } - } else if (configUiType.GetMethodInfo("Reset") is { } resetMethod) { - // Celeste v1.4: after Everest drop support v1.3.1.2 - resetMethod.IlHook(ModReload); - } else if (configUiType.GetMethodInfo("b__6_0") is { } reloadMethod) { - // Celeste v1.3 - reloadMethod.IlHook(ModReload); - } - } -#pragma warning restore CS0612 + /// Hotkey which is independent of the game Update loop + public class Hotkey(List keys, List buttons, bool keyCombo, bool held) { + public readonly List Keys = keys; + public readonly List Buttons = buttons; - [Unload] - private static void Unload() { - On.Celeste.Input.Initialize -= InputOnInitialize; - } - - private static void InputOnInitialize(On.Celeste.Input.orig_Initialize orig) { - orig(); - CommunicationWrapper.SendCurrentBindings(); - } - - private static void ModReload(ILContext il) { - bindingProperties = typeof(CelesteTasSettings) - .GetProperties(BindingFlags.Instance | BindingFlags.Public) - .Where(info => info.PropertyType == typeof(ButtonBinding) && - info.GetCustomAttribute() is { } extraDefaultKeyAttribute && - extraDefaultKeyAttribute.ExtraKey != Keys.None); - - ILCursor ilCursor = new(il); - if (ilCursor.TryGotoNext( - MoveType.After, - ins => ins.OpCode == OpCodes.Callvirt && ins.Operand.ToString().Contains("::Add(T)") - )) { - ilCursor.Emit(OpCodes.Ldloc_1).EmitDelegate>(AddExtraDefaultKey); - } - } - - private static void AddExtraDefaultKey(object bindingEntry) { - if (bindingFieldInfo == null) { - bindingFieldInfo = bindingEntry.GetType().GetFieldInfo("Binding"); - } - - if (bindingFieldInfo?.GetValue(bindingEntry) is not ButtonBinding binding) { - return; - } - - if (bindingProperties.FirstOrDefault(info => info.GetValue(TasSettings) == binding) is { } propertyInfo) { - binding.Keys.Add(propertyInfo.GetCustomAttribute().ExtraKey); - } - } - - public class Hotkey { - private static readonly Regex keysNameFixRegex = new(@"^D(\d)$", RegexOptions.Compiled); - - public readonly List Buttons; - private readonly bool held; - private readonly bool keyCombo; - public readonly List Keys; - private DateTime lastPressedTime; - public bool OverrideCheck; + internal bool OverrideCheck; - public Hotkey(List keys, List buttons, bool keyCombo, bool held) { - Keys = keys; - Buttons = buttons; - this.keyCombo = keyCombo; - this.held = held; - } + private DateTime doublePressTimeout; + private DateTime repeatTimeout; public bool Check { get; private set; } - public bool LastCheck { get; private set; } public bool Pressed => !LastCheck && Check; + public bool Released => LastCheck && !Check; - // note: dont check DoublePressed on render, since unstable DoublePressed response during frame drops public bool DoublePressed { get; private set; } - public bool Released => LastCheck && !Check; + public bool Repeated { get; private set; } - public void Update(bool updateKey = true, bool updateButton = true) { + public bool LastCheck { get; set; } + + private const double DoublePressTimeoutMS = 200.0; + private const double RepeatTimeoutMS = 500.0; + + internal void Update(bool updateKey = true, bool updateButton = true) { LastCheck = Check; + bool keyCheck; bool buttonCheck; @@ -333,25 +262,32 @@ public void Update(bool updateKey = true, bool updateButton = true) { Check = keyCheck || buttonCheck; + var now = DateTime.Now; if (Pressed) { - DateTime pressedTime = DateTime.Now; - DoublePressed = pressedTime.Subtract(lastPressedTime).TotalMilliseconds < 200; - lastPressedTime = DoublePressed ? default : pressedTime; + DoublePressed = now < doublePressTimeout; + doublePressTimeout = DoublePressed ? default : now + TimeSpan.FromMilliseconds(DoublePressTimeoutMS); + + Repeated = true; + repeatTimeout = now + TimeSpan.FromMilliseconds(RepeatTimeoutMS); + } else if (Check) { + DoublePressed = false; + Repeated = now >= repeatTimeout; } else { DoublePressed = false; + Repeated = false; + repeatTimeout = default; } } private bool IsKeyDown() { - if (Keys == null || Keys.Count == 0 || kbState == default) { + if (Keys.Count == 0 || kbState == default) { return false; } return keyCombo ? Keys.All(kbState.IsKeyDown) : Keys.Any(kbState.IsKeyDown); } - private bool IsButtonDown() { - if (Buttons == null || Buttons.Count == 0 || padState == default) { + if (Buttons.Count == 0 || padState == default) { return false; } @@ -361,7 +297,19 @@ private bool IsButtonDown() { public override string ToString() { List result = new(); if (Keys.IsNotEmpty()) { - result.Add(string.Join("+", Keys.Select(key => keysNameFixRegex.Replace(key.ToString(), "$1")))); + result.Add(string.Join("+", Keys.Select(key => key switch { + InputKeys.D0 => "0", + InputKeys.D1 => "1", + InputKeys.D2 => "2", + InputKeys.D3 => "3", + InputKeys.D4 => "4", + InputKeys.D5 => "5", + InputKeys.D6 => "6", + InputKeys.D7 => "7", + InputKeys.D8 => "8", + InputKeys.D9 => "9", + _ => key.ToString(), + }))); } if (Buttons.IsNotEmpty()) { @@ -394,7 +342,7 @@ private static void Unload() { } private static void CelesteOnRenderCore(On.Celeste.Celeste.orig_RenderCore orig, Celeste.Celeste self) { - if (Manager.UltraFastForwarding || !Engine.Instance.IsActive) { + if (Manager.FastForwarding || !Engine.Instance.IsActive) { UpdateNull(); } else { Update(); @@ -447,11 +395,3 @@ public void Update(ButtonState buttonState) { } } } - -public class DefaultButtonBinding2Attribute : DefaultButtonBindingAttribute { - public readonly XNAKeys ExtraKey; - - public DefaultButtonBinding2Attribute(Buttons button, params XNAKeys[] keys) : base(button, keys.IsEmpty() ? XNAKeys.None : keys[0]) { - ExtraKey = keys.Length > 1 ? keys[1] : XNAKeys.None; - } -} \ No newline at end of file diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoCustom.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoCustom.cs deleted file mode 100644 index c0d53e24a..000000000 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoCustom.cs +++ /dev/null @@ -1,548 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using Celeste; -using Microsoft.Xna.Framework; -using Monocle; -using StudioCommunication; -using TAS.EverestInterop.Lua; -using TAS.Input.Commands; -using TAS.Module; -using TAS.Utils; - -namespace TAS.EverestInterop.InfoHUD; - -public static class InfoCustom { - private static readonly Regex LuaRegex = new(@"\[\[(.+?)\]\]", RegexOptions.Compiled); - private static readonly Regex BraceRegex = new(@"\{(.+?)\}", RegexOptions.Compiled); - private static readonly Regex TypeNameRegex = new(@"^([.\w=+<>]+)(\[(.+?)\])?(@([^.]*))?$", RegexOptions.Compiled); - private static readonly Regex TypeNameSeparatorRegex = new(@"^[.+]", RegexOptions.Compiled); - private static readonly Regex MethodRegex = new(@"^(.+)\((.*)\)$", RegexOptions.Compiled); - private static readonly Dictionary AllTypes = new(); - private static readonly Dictionary> CachedParsedTypes = new(); - private static bool EnforceLegal => EnforceLegalCommand.EnabledWhenRunning && !AssertCommand.Running; - - public delegate bool HelperMethod(object obj, int decimals, out string formattedValue); - - // return true if obj is of expected parameter type. otherwise we call AutoFormatter - private static readonly Dictionary HelperMethods = new(); - - [Initialize] - private static void CollectAllTypeInfo() { - AllTypes.Clear(); - CachedParsedTypes.Clear(); - foreach (Type type in ModUtils.GetTypes()) { - if (type.FullName is { } fullName) { - string assemblyName = type.Assembly.GetName().Name; - string modName = ConsoleEnhancements.GetModName(type); - AllTypes[$"{fullName}@{assemblyName}"] = type; - AllTypes[$"{fullName}@{modName}"] = type; - - if (!fullName.StartsWith("Celeste.Mod.Everest+Events")) { - string fullNameAlternative = fullName.Replace("+", "."); - AllTypes[$"{fullNameAlternative}@{assemblyName}"] = type; - AllTypes[$"{fullNameAlternative}@{modName}"] = type; - } - } - } - } - - [Initialize] - private static void InitializeHelperMethods() { - HelperMethods.Add("toFrame()", HelperMethod_toFrame); - HelperMethods.Add("toPixelPerFrame()", HelperMethod_toPixelPerFrame); - } - - public static string GetInfo(int? decimals = null) { - decimals ??= TasSettings.CustomInfoDecimals; - Dictionary> cachedEntities = new(); - - return ParseTemplate(TasSettings.InfoCustomTemplate, decimals.Value, cachedEntities, false); - } - - [Monocle.Command("get", "get type.fieldOrProperty value. eg get Player,Position; get Level.Wind (CelesteTAS)")] - private static void GetCommand(string template) { - ParseTemplate($"{{{template}}}", TasSettings.CustomInfoDecimals, new Dictionary>(), true).ConsoleLog(); - } - - private static object ParseVector2(object input) { - if (input is Vector2 vector2) { - return (vector2.X, vector2.Y); - } else { - return input; - } - } - - public static object GetRawInfo((string, bool) args) { - var template = args.Item1; - var forceList = args.Item2; - if (!TryParseMemberNames(template, out string typeText, out List memberNames, out string errorMessage)) { - return errorMessage; - } - if (!TryParseType(typeText, out Type type, out string entityId, out errorMessage)) { - return errorMessage; - } - bool moreThanOneEntity = false; - if (type.IsSameOrSubclassOf(typeof(Entity))) { - moreThanOneEntity = FindEntities(type, entityId).Count() > 1; - } - if (memberNames.IsNotEmpty() && ( - type.GetGetMethod(memberNames.First()) is { IsStatic: true } || - type.GetFieldInfo(memberNames.First()) is { IsStatic: true } || - (MethodRegex.Match(memberNames.First()) is { Success: true } match && - type.GetMethodInfo(match.Groups[1].Value) is { IsStatic: true }) - )) { - return ParseVector2(GetMemberValue(type, null, memberNames)); - } - if (Engine.Scene is Level level) { - if (type.IsSameOrSubclassOf(typeof(Entity))) { - List entities = FindEntities(type, entityId); - if (entities == null) { - return new List(); - } - var result = entities.Select(entity => { - return ParseVector2(GetMemberValue(type, entity, memberNames)); - }).ToList(); - if (result.Count == 1 && !forceList) { - return result[0]; - } else { - return result; - } - } else if (type == typeof(Level)) { - return ParseVector2(GetMemberValue(type, level, memberNames)); - } else if (type == typeof(Session)) { - return ParseVector2(GetMemberValue(type, level.Session, memberNames)); - } else { - return $"Instance of {type.FullName} not found"; - } - } - return 0; - } - - public static string ParseTemplate(string template, int decimals, Dictionary> cachedEntities, bool consoleCommand) { - List GetCachedOrFindEntities(Type type, string entityId, Dictionary> dictionary) { - string entityText = $"{type.FullName}{entityId}"; - List entities; - if (dictionary.TryGetValue(entityText, out List value)) { - entities = value; - } else { - entities = FindEntities(type, entityId); - dictionary[entityText] = entities; - } - - return entities; - } - - string result = BraceRegex.Replace(template, match => { - string matchText = match.Groups[1].Value; - - if (!TryParseMemberNames(matchText, out string typeText, out List memberNames, out string errorMessage)) { - return errorMessage; - } - - if (!TryParseTypes(typeText, out List types, out string entityId, out errorMessage)) { - return errorMessage; - } - - string lastMemberName = memberNames.Last(); - string lastCharacter = lastMemberName.Substring(lastMemberName.Length - 1, 1); - if (lastCharacter is ":" or "=") { - lastMemberName = lastMemberName.Substring(0, lastMemberName.Length - 1).Trim(); - memberNames[memberNames.Count - 1] = lastMemberName; - } - - string helperMethod = ""; - if (HelperMethods.ContainsKey(lastMemberName)) { - helperMethod = lastMemberName; - memberNames = memberNames.SkipLast(1).ToList(); - } - - bool moreThanOneEntity = types.Where(type => type.IsSameOrSubclassOf(typeof(Entity))) - .SelectMany(type => GetCachedOrFindEntities(type, entityId, cachedEntities)).Count() > 1; - - List result = types.Select(type => { - if (memberNames.IsNotEmpty() && ( - type.GetGetMethod(memberNames.First()) is {IsStatic: true} || - type.GetFieldInfo(memberNames.First()) is {IsStatic: true} || - (MethodRegex.Match(memberNames.First()) is {Success: true} match && - type.GetMethodInfo(match.Groups[1].Value) is {IsStatic: true}) - )) { - return FormatValue(GetMemberValue(type, null, memberNames), helperMethod, decimals); - } - - if (Engine.Scene is Level level) { - if (type.IsSameOrSubclassOf(typeof(Entity))) { - List entities = GetCachedOrFindEntities(type, entityId, cachedEntities); - - if (entities == null) { - return "Ignore NPE Warning"; - } - - return string.Join("", entities.Select(entity => { - string value = FormatValue(GetMemberValue(type, entity, memberNames), helperMethod, decimals); - - if (moreThanOneEntity) { - if (entity.GetEntityData()?.ToEntityId().ToString() is { } id) { - value = $"\n[{id}] {value}"; - } else { - value = $"\n{value}"; - } - } - - return value; - })); - } else if (type == typeof(Level)) { - return FormatValue(GetMemberValue(type, level, memberNames), helperMethod, decimals); - } else if (type == typeof(Session)) { - return FormatValue(GetMemberValue(type, level.Session, memberNames), helperMethod, decimals); - } else { - return $"Instance of {type.FullName} not found"; - } - } - - return string.Empty; - }).Where(s => s.IsNotNullOrEmpty()).ToList(); - - string prefix = lastCharacter switch { - "=" => matchText, - ":" => $"{matchText} ", - _ => "" - }; - - string separator = types.First().IsSameOrSubclassOf(typeof(Entity)) ? "" : " "; - if (consoleCommand && separator.IsEmpty() && result.IsNotEmpty()) { - result[0] = result[0].TrimStart(); - } - - return $"{prefix}{string.Join(separator, result)}"; - }); - - return LuaRegex.Replace(result, match => { - if (EnforceLegal) { - return "Evaluate lua code is illegal when enforce legal"; - } - - string code = match.Groups[1].Value; - object[] objects = EvalLuaCommand.EvalLuaImpl(code); - return objects == null ? "null" : string.Join(", ", objects.Select(o => o?.ToString() ?? "null")); - }); - } - - public static bool TryParseMemberNames(string matchText, out string typeText, out List memberNames, out string errorMessage) { - typeText = errorMessage = ""; - memberNames = new List(); - - List splitText = matchText.Split('.').Select(s => s.Trim()).Where(s => s.IsNotEmpty()).ToList(); - if (splitText.Count <= 1) { - errorMessage = "missing member"; - return false; - } - - if (matchText.Contains("@")) { - int assemblyIndex = splitText.FindIndex(s => s.Contains("@")); - typeText = string.Join(".", splitText.Take(assemblyIndex + 1)); - memberNames = splitText.Skip(assemblyIndex + 1).ToList(); - } else { - typeText = splitText[0]; - memberNames = splitText.Skip(1).ToList(); - } - - if (memberNames.Count <= 0) { - errorMessage = "missing member"; - return false; - } - - return true; - } - - public static bool TryParseType(string text, out Type type, out string entityId, out string errorMessage) { - TryParseTypes(text, out List types, out entityId, out errorMessage); - - if (types.IsEmpty()) { - type = null; - return false; - } else { - type = types.First(); - return true; - } - } - - public static bool TryParseTypes(string text, out List types) { - return TryParseTypes(text, out types, out _, out _); - } - - public static bool TryParseTypes(string text, out List types, out string entityId) { - return TryParseTypes(text, out types, out entityId, out _); - } - - public static bool TryParseTypes(string text, out List types, out string entityId, out string errorMessage) { - types = new List(); - entityId = ""; - errorMessage = ""; - - if (!TryParseTypeName(text, out string typeNameMatched, out string typeNameWithAssembly, out entityId)) { - errorMessage = "parsing type name failed"; - return false; - } - - if (CachedParsedTypes.Keys.Contains(typeNameWithAssembly)) { - types = CachedParsedTypes[typeNameWithAssembly]; - } else { - // find the full type name - List matchTypeNames = AllTypes.Keys.Where(name => name.StartsWith(typeNameWithAssembly)).ToList(); - - string typeName = TypeNameSeparatorRegex.Replace(typeNameWithAssembly, ""); - if (matchTypeNames.IsEmpty()) { - // find the part of type name - matchTypeNames = AllTypes.Keys.Where(name => name.Contains($".{typeName}")).ToList(); - } - - if (matchTypeNames.IsEmpty()) { - // find the nested type name - matchTypeNames = AllTypes.Keys.Where(name => name.Contains($"+{typeName}")).ToList(); - } - - // one type can correspond to two keys (..@assemblyName and ..@modName), so we need Distinct() - types = matchTypeNames.Select(name => AllTypes[name]).Distinct().ToList(); - CachedParsedTypes[typeNameWithAssembly] = types; - } - - if (types.IsEmpty()) { - errorMessage = $"{typeNameMatched} not found"; - return false; - } else { - return true; - } - } - - private static bool TryParseTypeName(string text, out string typeNameMatched, out string typeNameWithAssembly, out string entityId) { - typeNameMatched = ""; - typeNameWithAssembly = ""; - entityId = ""; - if (TypeNameRegex.Match(text) is {Success: true} match) { - typeNameMatched = match.Groups[1].Value; - typeNameWithAssembly = $"{typeNameMatched}@{match.Groups[5].Value}"; - typeNameWithAssembly = typeNameWithAssembly switch { - "Theo@" => "TheoCrystal@", - "Jellyfish@" => "Glider@", - _ => typeNameWithAssembly - }; - entityId = match.Groups[3].Value; - return true; - } else { - return false; - } - } - - public static object GetMemberValue(Type type, object obj, List memberNames, bool setCommand = false) { - foreach (string memberName in memberNames) { - if (type.GetGetMethod(memberName) is { } getMethodInfo) { - if (getMethodInfo.IsStatic) { - obj = getMethodInfo.Invoke(null, null); - } else if (obj != null) { - if (obj is Actor actor && memberName == "ExactPosition") { - obj = actor.GetMoreExactPosition(true); - } else if (obj is Platform platform && memberName == "ExactPosition") { - obj = platform.GetMoreExactPosition(true); - } else { - obj = getMethodInfo.Invoke(obj, null); - } - } - } else if (type.GetFieldInfo(memberName) is { } fieldInfo) { - if (fieldInfo.IsStatic) { - obj = fieldInfo.GetValue(null); - } else if (obj != null) { - obj = setCommand switch { - true when obj is Actor actor && memberName == "Position" => actor.GetMoreExactPosition(true), - true when obj is Platform platform && memberName == "Position" => platform.GetMoreExactPosition(true), - _ => fieldInfo.GetValue(obj) - }; - } - } else if (MethodRegex.Match(memberName) is {Success: true} match && type.GetMethodInfo(match.Groups[1].Value) is { } methodInfo) { - if (EnforceLegal) { - return $"{memberName}: Calling methods is illegal when enforce legal"; - } else if (match.Groups[2].Value.IsNotNullOrWhiteSpace() || methodInfo.GetParameters().Length > 0) { - return $"{memberName}: Only method without parameters is supported"; - } else if (methodInfo.ReturnType == typeof(void)) { - return $"{memberName}: Method return void is not supported"; - } else if (methodInfo.IsStatic) { - obj = methodInfo.Invoke(null, null); - } else if (obj != null) { - if (obj is Actor actor && memberName == "get_ExactPosition()") { - obj = actor.GetMoreExactPosition(true); - } else if (obj is Platform platform && memberName == "get_ExactPosition()") { - obj = platform.GetMoreExactPosition(true); - } else { - obj = methodInfo.Invoke(obj, null); - } - } - } else { - if (obj == null) { - return $"{type.FullName}.{memberName} member not found"; - } else { - return $"{obj.GetType().FullName}.{memberName} member not found"; - } - } - - if (obj == null) { - return null; - } - - type = obj.GetType(); - } - - return obj; - } - - public static List FindEntities(Type type, string entityId) { - if (!Engine.Scene.Tracker.Entities.TryGetValue(type, out List entities)) { - entities = Engine.Scene.Entities.Where(entity => entity.GetType().IsSameOrSubclassOf(type)).ToList(); - } - - if (entityId.IsNullOrEmpty()) { - return entities; - } else { - return entities.Where(entity => entity.GetEntityData()?.ToEntityId().ToString() == entityId).ToList(); - } - } - - public static string FormatValue(object obj, string helperMethodName, int decimals) { - if (obj == null) { - return string.Empty; - } - - bool invalidParameter = false; - if (HelperMethods.TryGetValue(helperMethodName, out HelperMethod method)) { - if (method(obj, decimals, out string formattedValue)) { - return formattedValue; - } else { - invalidParameter = true; - } - } - - return $"{AutoFormatter(obj, decimals)}{(invalidParameter ? $",\n not a valid parameter of {helperMethodName}" : "")}"; - } - - public static bool HelperMethod_toFrame(object obj, int decimals, out string formattedValue) { - if (obj is float floatValue) { - formattedValue = GameInfo.ConvertToFrames(floatValue).ToString(); - return true; - } - - formattedValue = ""; - return false; - } - - public static bool HelperMethod_toPixelPerFrame(object obj, int decimals, out string formattedValue) { - if (obj is float floatValue) { - formattedValue = GameInfo.ConvertSpeedUnit(floatValue, SpeedUnit.PixelPerFrame).ToString(CultureInfo.InvariantCulture); - return true; - } - - if (obj is Vector2 vector2) { - formattedValue = GameInfo.ConvertSpeedUnit(vector2, SpeedUnit.PixelPerFrame).ToSimpleString(decimals); - return true; - } - - formattedValue = ""; - return false; - } - - public static string AutoFormatter(object obj, int decimals) { - if (obj is Vector2 vector2) { - return vector2.ToSimpleString(decimals); - } - - if (obj is Vector2Double vector2Double) { - return vector2Double.ToSimpleString(decimals); - } - - if (obj is float floatValue) { - return floatValue.ToFormattedString(decimals); - } - - if (obj is Scene) { - return obj.ToString(); - } - - if (obj is Entity entity) { - string id = entity.GetEntityData()?.ToEntityId().ToString() is { } value ? $"[{value}]" : ""; - return $"{entity}{id}"; - } - - if (obj is IEnumerable enumerable and not IEnumerable) { - bool compressed = enumerable is IEnumerable or IEnumerable; - string separator = compressed ? ",\n " : ", "; - return IEnumerableToString(enumerable, separator, compressed); - } - - if (obj is Collider collider) { - return ColliderToString(collider); - } - - return obj.ToString(); - } - - public static string IEnumerableToString(IEnumerable enumerable, string separator, bool compressed) { - StringBuilder sb = new(); - if (!compressed) { - foreach (object o in enumerable) { - if (sb.Length > 0) { - sb.Append(separator); - } - - sb.Append(o); - } - - return sb.ToString(); - } - - Dictionary keyValuePairs = new Dictionary(); - foreach (object obj in enumerable) { - string str = obj.ToString(); - if (keyValuePairs.ContainsKey(str)) { - keyValuePairs[str]++; - } else { - keyValuePairs.Add(str, 1); - } - } - - foreach (string key in keyValuePairs.Keys) { - if (sb.Length > 0) { - sb.Append(separator); - } - - if (keyValuePairs[key] == 1) { - sb.Append(key); - } else { - sb.Append($"{key} * {keyValuePairs[key]}"); - } - } - - return sb.ToString(); - } - - public static string ColliderToString(Collider collider, int iterationHeight = 1) { - if (collider is Hitbox hitbox) { - return $"Hitbox=[{hitbox.Left},{hitbox.Right}]×[{hitbox.Top},{hitbox.Bottom}]"; - } - - if (collider is Circle circle) { - if (circle.Position == Vector2.Zero) { - return $"Circle=radius {circle.Radius}"; - } else { - return $"Circle=radius {circle.Radius}, offset {circle.Position}"; - } - } - - if (collider is ColliderList list && iterationHeight > 0) { - return "ColliderList: {" + string.Join("; ", list.colliders.Select(s => ColliderToString(s, iterationHeight - 1))) + "}"; - } - - return collider.ToString(); - } -} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoHud.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoHud.cs index b4fa531a1..1ec20de92 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoHud.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoHud.cs @@ -13,7 +13,6 @@ namespace TAS.EverestInterop.InfoHUD; -// TODO show info hud on overworld public static class InfoHud { private static EaseInSubMenu subMenuItem; public static Vector2 Size { get; private set; } @@ -22,14 +21,17 @@ public static class InfoHud { private static void Load() { On.Celeste.Level.Render += LevelOnRender; On.Celeste.Pico8.Emulator.Render += EmulatorOnRender; + On.Monocle.Scene.Render += SceneOnRender; } [Unload] private static void Unload() { On.Celeste.Level.Render -= LevelOnRender; On.Celeste.Pico8.Emulator.Render -= EmulatorOnRender; + On.Monocle.Scene.Render -= SceneOnRender; } + private static void LevelOnRender(On.Celeste.Level.orig_Render orig, Level self) { orig(self); @@ -44,6 +46,14 @@ private static void EmulatorOnRender(On.Celeste.Pico8.Emulator.orig_Render orig, InfoMouse.DragAndDropHud(); } + private static void SceneOnRender(On.Monocle.Scene.orig_Render orig, Scene self) { + orig(self); + + if (self is Overworld) { + DrawInfo(self, drawSubpixelIndicator: false); + InfoMouse.DragAndDropHud(); + } + } public static void Toggle() { if (Hotkeys.InfoHud.DoublePressed) { TasSettings.InfoHud = !TasSettings.InfoHud; @@ -57,7 +67,7 @@ public static void Toggle() { } } - private static void DrawInfo(Scene scene) { + private static void DrawInfo(Scene scene, bool drawSubpixelIndicator = true) { if (!TasSettings.Enabled || !TasSettings.InfoHud) { return; } @@ -93,7 +103,9 @@ private static void DrawInfo(Scene scene) { float infoAlpha = 1f; Size = JetBrainsMonoFont.Measure(text) * fontSize; - Size = InfoSubPixelIndicator.TryExpandSize(Size, padding); + if (drawSubpixelIndicator) { + Size = InfoSubPixelIndicator.TryExpandSize(Size, padding); + } float maxX = viewWidth - Size.X - margin - padding * 2; float maxY = viewHeight - Size.Y - margin - padding * 2; @@ -115,7 +127,9 @@ private static void DrawInfo(Scene scene) { Draw.Rect(bgRect, Color.Black * alpha); - InfoSubPixelIndicator.DrawIndicator(bgRect.Bottom, padding, infoAlpha); + if (drawSubpixelIndicator) { + InfoSubPixelIndicator.DrawIndicator(bgRect.Bottom, padding, infoAlpha); + } Vector2 textPosition = new(x + padding, y + padding); Vector2 scale = new(fontSize); @@ -146,7 +160,7 @@ private static bool CollidePlayer(Level level, Rectangle bgRect) { private static void WriteTasInput(StringBuilder stringBuilder) { InputController controller = Manager.Controller; List inputs = controller.Inputs; - if (Manager.Running && controller.CurrentFrameInTas >= 0 && controller.CurrentFrameInTas < inputs.Count) { + if (Manager.Running && controller.CurrentFrameInTas >= 0 && controller.CurrentFrameInTas <= inputs.Count) { InputFrame current = controller.Current; if (controller.CurrentFrameInTas >= 1 && current != controller.Previous) { current = controller.Previous; @@ -175,7 +189,7 @@ string FormatInputFrame(InputFrame inputFrame) { int inputWidth = currentStr.Length + currentFrameLength + 2; inputWidth = Math.Max(inputWidth, 20); stringBuilder.AppendLine( - $"{currentStr.PadRight(inputWidth - currentFrameLength)}{controller.CurrentFrameInInputForHud}{current.RepeatString}"); + $"{currentStr.PadRight(inputWidth - currentFrameLength)}{controller.CurrentFrameInInput}{current.RepeatString}"); if (next != null) { stringBuilder.AppendLine(FormatInputFrame(next)); @@ -199,13 +213,21 @@ public static EaseInSubMenu CreateSubMenu() { TasSettings.InfoCustomTemplate = TextInput.GetClipboardText() ?? string.Empty; CelesteTasModule.Instance.SaveSettings(); })); - subMenu.Add(new TextMenuExt.EnumerableSlider("Info Watch Entity".ToDialogText(), CreateHudOptions(), - TasSettings.InfoWatchEntity).Change(value => TasSettings.InfoWatchEntity = value)); - subMenu.Add(new TextMenuExt.EnumerableSlider("Info Watch Entity Type".ToDialogText(), new[] { + + subMenu.Add(new TextMenuExt.EnumerableSlider("Info Watch Entity HUD Type".ToDialogText(), [ + new KeyValuePair(WatchEntityType.None, "Info Watch Entity None".ToDialogText()), + new KeyValuePair(WatchEntityType.Position, "Info Watch Entity Position".ToDialogText()), + new KeyValuePair(WatchEntityType.DeclaredOnly, "Info Watch Entity Declared Only".ToDialogText()), + new KeyValuePair(WatchEntityType.All, "Info Watch Entity All".ToDialogText()) + ], TasSettings.InfoWatchEntityHudType).Change(value => TasSettings.InfoWatchEntityHudType = value)); + subMenu.Add(new TextMenuExt.EnumerableSlider("Info Watch Entity Studio Type".ToDialogText(), [ + new KeyValuePair(WatchEntityType.None, "Info Watch Entity None".ToDialogText()), new KeyValuePair(WatchEntityType.Position, "Info Watch Entity Position".ToDialogText()), new KeyValuePair(WatchEntityType.DeclaredOnly, "Info Watch Entity Declared Only".ToDialogText()), - new KeyValuePair(WatchEntityType.All, "Info Watch Entity All".ToDialogText()), - }, TasSettings.InfoWatchEntityType).Change(value => TasSettings.InfoWatchEntityType = value)); + new KeyValuePair(WatchEntityType.All, "Info Watch Entity All".ToDialogText()) + ], TasSettings.InfoWatchEntityStudioType).Change(value => TasSettings.InfoWatchEntityStudioType = value)); + subMenu.Add(new TextMenu.OnOff("Info Watch Entity Log To Console".ToDialogText(), TasSettings.InfoWatchEntityLogToConsole) + .Change(value => TasSettings.InfoWatchEntityLogToConsole = value)); subMenu.Add(new TextMenuExt.IntSlider("Info Text Size".ToDialogText(), 5, 20, TasSettings.InfoTextSize).Change(value => TasSettings.InfoTextSize = value)); subMenu.Add(new TextMenuExt.IntSlider("Info Subpixel Indicator Size".ToDialogText(), 5, 20, TasSettings.InfoSubpixelIndicatorSize) diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoMouse.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoMouse.cs index c1298521d..d4098462d 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoMouse.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoMouse.cs @@ -6,6 +6,7 @@ using Microsoft.Xna.Framework; using Monocle; using TAS.Communication; +using TAS.InfoHUD; using TAS.Module; using TAS.Utils; @@ -224,4 +225,4 @@ private void DrawSelectedArea() { public override string ToString() { return start == null ? string.Empty : $"{left}, {top}, {right}, {bottom}"; } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoSubPixelIndicator.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoSubPixelIndicator.cs index bba1a52a7..a925c21e0 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoSubPixelIndicator.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoSubPixelIndicator.cs @@ -110,4 +110,4 @@ private static void DrawHollowRect(float left, float top, float width, float hei Draw.Rect(left + width - thickness, top, thickness, height, color); Draw.Rect(left, top + height - thickness, width, thickness, color); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoWatchEntity.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoWatchEntity.cs deleted file mode 100644 index c25cc1b2e..000000000 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoWatchEntity.cs +++ /dev/null @@ -1,362 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Celeste; -using Microsoft.Xna.Framework; -using Monocle; -using StudioCommunication; -using TAS.EverestInterop.Hitboxes; -using TAS.Module; -using TAS.Utils; - -namespace TAS.EverestInterop.InfoHUD; - -public enum WatchEntityType { - Position, - DeclaredOnly, - All -} - -public static class InfoWatchEntity { - // ReSharper disable UnusedMember.Local - private record struct MemberKey(Type Type, bool DeclaredOnly) { - public readonly Type Type = Type; - public readonly bool DeclaredOnly = DeclaredOnly; - } - // ReSharper restore UnusedMember.Local - - private static readonly Dictionary> CachedMemberInfos = new(); - - private static readonly WeakReference LastClickedEntity = new(null); - - // TODO FIXME: entity w/o id not work properly after retry or load state - public static List RequireWatchEntities = new(); - public static List SavedRequireWatchEntities = new(); - private static readonly HashSet RequireWatchUniqueEntityIds = new(); - public static readonly HashSet WatchingEntities = new(); - private static AreaKey requireWatchAreaKey; - - - [Load] - private static void Load() { - On.Monocle.EntityList.DebugRender += EntityListOnDebugRender; - On.Celeste.Level.Begin += LevelOnBegin; - On.Celeste.Level.End += LevelOnEnd; - On.Celeste.Level.LoadLevel += LevelOnLoadLevel; - } - - [Unload] - private static void Unload() { - On.Monocle.EntityList.DebugRender -= EntityListOnDebugRender; - On.Celeste.Level.Begin -= LevelOnBegin; - On.Celeste.Level.End -= LevelOnEnd; - On.Celeste.Level.LoadLevel -= LevelOnLoadLevel; - } - - public static void CheckMouseButtons() { - if (MouseButtons.Right.Pressed) { - ClearWatchEntities(); - } - - if (MouseButtons.Left.Pressed && !IsClickHud() && FindClickedEntity() is { } entity) { - AddOrRemoveWatching(entity); - PrintAllSimpleValues(entity); - } - } - - private static bool IsClickHud() { - Rectangle rectangle = new((int) TasSettings.InfoPosition.X, (int) TasSettings.InfoPosition.Y, (int) InfoHud.Size.X, (int) InfoHud.Size.Y); - return rectangle.Contains((int) MouseButtons.Position.X, (int) MouseButtons.Position.Y); - } - - private static List FindClickedEntities() { - if (Engine.Scene is Level level) { - Vector2 mouseWorldPosition = level.MouseToWorld(MouseButtons.Position); - Entity tempEntity = new() {Position = mouseWorldPosition, Collider = new Hitbox(1, 1)}; - List allEntities = level.Entities.Where(entity => - entity.GetType() != typeof(Entity) - && entity is not ParticleSystem).ToList(); - - List noColliderEntities = allEntities.Where(entity => - entity.Collider == null - && entity.GetEntityData() != null - ).ToList(); - - foreach (Entity entity in noColliderEntities) { - EntityData data = entity.GetEntityData(); - entity.Collider = new Hitbox(data.Width, data.Height); - } - - List result = allEntities.Where(entity => entity.CollideCheck(tempEntity)).ToList(); - - foreach (Entity entity in noColliderEntities) { - entity.Collider = null; - } - - // put trigger after entity - result.Sort((entity1, entity2) => (entity1 is Trigger ? 1 : -1) - (entity2 is Trigger ? 1 : -1)); - return result; - } else { - return new List(); - } - } - - public static Entity FindClickedEntity() { - List clickedEntities = FindClickedEntities(); - - Entity clickedEntity; - if (LastClickedEntity.TryGetTarget(out Entity lastClicked) && clickedEntities.IndexOf(lastClicked) is int index and >= 0) { - clickedEntity = clickedEntities[(index + 1) % clickedEntities.Count]; - } else { - clickedEntity = clickedEntities.FirstOrDefault(); - } - - LastClickedEntity.SetTarget(clickedEntity); - return clickedEntity; - } - - private static void AddOrRemoveWatching(Entity clickedEntity) { - requireWatchAreaKey = clickedEntity.SceneAs().Session.Area; - if (clickedEntity.GetEntityData() is { } entityData) { - UniqueEntityId uniqueEntityId = new(clickedEntity, entityData); - if (RequireWatchUniqueEntityIds.Contains(uniqueEntityId)) { - RequireWatchUniqueEntityIds.Remove(uniqueEntityId); - } else { - RequireWatchUniqueEntityIds.Add(uniqueEntityId); - } - } else { - if (RequireWatchEntities.FirstOrDefault(reference => reference.Target == clickedEntity) is { } alreadyAdded) { - RequireWatchEntities.Remove(alreadyAdded); - } else { - RequireWatchEntities.Add(new WeakReference(clickedEntity)); - } - } - - GameInfo.Update(); - } - - private static void EntityListOnDebugRender(On.Monocle.EntityList.orig_DebugRender orig, EntityList self, Camera camera) { - orig(self, camera); - - if (TasSettings.ShowHitboxes) { - foreach (Entity entity in Engine.Scene.Entities) { - if (WatchingEntities.Contains(entity)) { - Draw.Point(entity.Position, HitboxColor.EntityColorInversely); - } - } - } - } - - private static void LevelOnBegin(On.Celeste.Level.orig_Begin orig, Level self) { - orig(self); - - if (self.Session.Area != requireWatchAreaKey) { - ClearWatchEntities(); - } - } - - private static void LevelOnEnd(On.Celeste.Level.orig_End orig, Level self) { - orig(self); - WatchingEntities.Clear(); - } - - private static void LevelOnLoadLevel(On.Celeste.Level.orig_LoadLevel orig, Level self, Player.IntroTypes playerIntro, bool isFromLoader) { - orig(self, playerIntro, isFromLoader); - - RequireWatchEntities.ToList().ForEach(reference => { - if (reference.Target is Entity {Scene: null}) { - RequireWatchEntities.Remove(reference); - } - }); - } - - public static void ClearWatchEntities() { - LastClickedEntity.SetTarget(null); - RequireWatchEntities.Clear(); - SavedRequireWatchEntities.Clear(); - RequireWatchUniqueEntityIds.Clear(); - WatchingEntities.Clear(); - GameInfo.Update(); - } - - public static string GetInfo(string separator = "\n", bool alwaysUpdate = false, int? decimals = null) { - WatchingEntities.Clear(); - string watchingInfo = string.Empty; - if (Engine.Scene is not Level level || TasSettings.InfoWatchEntity == HudOptions.Off && !alwaysUpdate) { - return string.Empty; - } - - decimals ??= TasSettings.CustomInfoDecimals; - if (RequireWatchEntities.IsNotEmpty()) { - watchingInfo = string.Join(separator, RequireWatchEntities.Where(reference => reference.IsAlive).Select( - reference => { - Entity entity = (Entity) reference.Target; - WatchingEntities.Add(entity); - return GetEntityValues(entity, TasSettings.InfoWatchEntityType, separator, decimals.Value); - } - )); - } - - if (RequireWatchUniqueEntityIds.IsNotEmpty()) { - Dictionary matchEntities = GetMatchEntities(level); - if (matchEntities.IsNotEmpty()) { - if (watchingInfo.IsNotNullOrEmpty()) { - watchingInfo += separator; - } - - watchingInfo += string.Join(separator, matchEntities.Select(pair => { - Entity entity = matchEntities[pair.Key]; - WatchingEntities.Add(entity); - return GetEntityValues(entity, TasSettings.InfoWatchEntityType, separator, decimals.Value); - })); - } - } - - return watchingInfo; - } - - private static void PrintAllSimpleValues(Entity entity) { - ("Info of Clicked Entity:\n" + GetEntityValues(entity, WatchEntityType.All)).Log(true); - } - - private static string GetEntityValues(Entity entity, WatchEntityType watchEntityType, string separator = "\n", int decimals = 2) { - Type type = entity.GetType(); - string entityId = ""; - if (entity.GetEntityData() is { } entityData) { - entityId = $"[{entityData.ToEntityId().ToString()}]"; - } - - if (watchEntityType == WatchEntityType.Position) { - return GetPositionInfo(entity, entityId, decimals); - } - - List values = GetAllSimpleFields(type, watchEntityType == WatchEntityType.DeclaredOnly).Select(info => { - object value; - try { - value = info switch { - FieldInfo fieldInfo => fieldInfo.GetValue(entity), - PropertyInfo propertyInfo => propertyInfo.GetValue(entity), - _ => null - }; - } catch { - value = string.Empty; - } - - if (value is float floatValue) { - if (info.Name.EndsWith("Timer")) { - value = GameInfo.ConvertToFrames(floatValue); - } else { - value = floatValue.ToFormattedString(decimals); - } - } else if (value is Vector2 vector2) { - value = vector2.ToSimpleString(decimals); - } - - if (separator == "\t" && value != null) { - value = value.ToString().ReplaceLineBreak(" "); - } - - return $"{type.Name}{entityId}.{info.Name}: {value}"; - }).ToList(); - - values.Insert(0, GetPositionInfo(entity, entityId, decimals)); - - return string.Join(separator, values); - } - - private static string GetPositionInfo(Entity entity, string entityId, int decimals) { - return $"{entity.GetType().Name}{entityId}: {entity.ToSimplePositionString(decimals)}"; - } - - private static IEnumerable GetAllSimpleFields(Type type, bool declaredOnly = false) { - MemberKey key = new(type, declaredOnly); - - if (CachedMemberInfos.TryGetValue(key, out List result)) { - return result; - } else { - CachedMemberInfos[key] = result = new List(); - - FieldInfo[] fields; - PropertyInfo[] properties; - - if (declaredOnly) { - BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; - fields = type.GetFields(bindingFlags); - properties = type.GetProperties(bindingFlags); - } else { - fields = type.GetAllFieldInfos().ToArray(); - properties = type.GetAllProperties().ToArray(); - } - - List memberInfos = fields.Where(info => info.FieldType.IsSimpleType() && !info.Name.EndsWith("k__BackingField")) - .Cast().ToList(); - List propertyInfos = properties.Where(info => info.PropertyType.IsSimpleType()).Cast().ToList(); - memberInfos.AddRange(propertyInfos); - - foreach (IGrouping grouping in memberInfos.GroupBy(info => type == info.DeclaringType)) { - List infos = grouping.ToList(); - infos.Sort((info1, info2) => string.Compare(info1.Name, info2.Name, StringComparison.InvariantCultureIgnoreCase)); - if (grouping.Key) { - result.InsertRange(0, infos); - } else { - result.AddRange(infos); - } - } - - return result; - } - } - - private static Dictionary GetMatchEntities(Level level) { - Dictionary result = new(); - List possibleEntities = new(); - HashSet possibleTypes = new(); - - string currentRoom = level.Session.Level; - foreach (UniqueEntityId id in RequireWatchUniqueEntityIds.Where(id => id.GlobalOrPersistent || id.EntityId.Level == currentRoom)) { - possibleTypes.Add(id.Type); - } - - if (possibleTypes.IsEmpty()) { - return result; - } - - if (possibleTypes.All(type => level.Tracker.Entities.ContainsKey(type))) { - foreach (Type type in possibleTypes) { - possibleEntities.AddRange(level.Tracker.Entities[type]); - } - } else { - possibleEntities.AddRange(level.Entities.Where(entity => possibleTypes.Contains(entity.GetType()))); - } - - foreach (Entity entity in possibleEntities) { - if (entity.GetEntityData() is not { } entityData) { - continue; - } - - UniqueEntityId uniqueEntityId = new(entity, entityData); - if (RequireWatchUniqueEntityIds.Contains(uniqueEntityId) && !result.ContainsKey(uniqueEntityId)) { - result[uniqueEntityId] = entity; - - if (result.Count == RequireWatchUniqueEntityIds.Count) { - return result; - } - } - } - - return result; - } -} - -internal record UniqueEntityId { - public readonly EntityID EntityId; - public readonly bool GlobalOrPersistent; - public readonly Type Type; - - public UniqueEntityId(Entity entity, EntityData entityData) { - Type = entity.GetType(); - GlobalOrPersistent = entity.TagCheck(Tags.Global) || entity.TagCheck(Tags.Persistent) || entity.Get() != null; - EntityId = entityData.ToEntityId(); - } -} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/JetBrainsMonoFont.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/JetBrainsMonoFont.cs index 6b800183d..981df638c 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/JetBrainsMonoFont.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/JetBrainsMonoFont.cs @@ -14,13 +14,9 @@ public static class JetBrainsMonoFont { public static PixelFont Font { get { - if (Engine.Scene is Overworld) { - return null; - } else { - // try fixing a crash via use Fonts.orig_Load() instead of Fonts.Load() - // https://discord.com/channels/403698615446536203/1205319809525354537/1205319809525354537 - return Fonts.Get(FontFace) ?? Fonts.orig_Load(FontFace); - } + // try fixing a crash via use Fonts.orig_Load() instead of Fonts.Load() + // https://discord.com/channels/403698615446536203/1205319809525354537/1205319809525354537 + return Fonts.Get(FontFace) ?? Fonts.orig_Load(FontFace); } } @@ -81,4 +77,4 @@ public static void DrawOutline(string text, Vector2 position, Vector2 justify, V public static void DrawEdgeOutline(string text, Vector2 position, Vector2 justify, Vector2 scale, Color color, float edgeDepth, Color edgeColor, float stroke = 0f, Color strokeColor = default) => Draw(text, position, justify, scale, color, edgeDepth, edgeColor, stroke, strokeColor); -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/EvalLuaCommand.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/EvalLuaCommand.cs index 943ede1a3..efbf61820 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/EvalLuaCommand.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/EvalLuaCommand.cs @@ -22,7 +22,8 @@ private class Meta : ITasCommandMeta { public bool HasArguments => true; } - private static bool consoleCommandRunning; + internal static bool ConsoleCommandRunning; + private const string CommandName = "EvalLua"; private static readonly Regex commandAndSeparatorRegex = new(@$"^{CommandName}[ |,]+", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly FieldInfo DebugRClogFieldInfo = typeof(Commands).GetFieldInfo("debugRClog"); @@ -48,7 +49,7 @@ private static void HookEverestDebugRc() { method.IlHook((cursor, _) => { // insert codes after "rawCommand.Split(new[] {' ', ','}, StringSplitOptions.RemoveEmptyEntries);" - if (cursor.TryGotoNext(MoveType.After, instr => instr.MatchCallvirt("Split"))) { + if (cursor.TryGotoNext(MoveType.After, ins => ins.MatchCallvirt("Split"))) { cursor.Emit(OpCodes.Ldloc_0).EmitDelegate>( (commandAndArgs, rawCommand) => { if (commandAndArgs[0].ToLower() == CommandName && commandAndArgs.Length >= 2) { @@ -76,7 +77,7 @@ private static string ReadContent(string assetPath) { } public static void Log(object message) { - if (consoleCommandRunning) { + if (ConsoleCommandRunning) { Engine.Commands.Log(message); } @@ -91,9 +92,9 @@ private static void EvalLua(string code) { code = commandAndSeparatorRegex.Replace(firstHistory, ""); } - consoleCommandRunning = true; + ConsoleCommandRunning = true; object[] result = EvalLuaImpl(code); - consoleCommandRunning = false; + ConsoleCommandRunning = false; LogResult(result); } @@ -107,16 +108,16 @@ private static void EvalLua(CommandLine commandLine, int studioLine, string file EvalLuaImpl(commandAndSeparatorRegex.Replace(commandLine.OriginalText, "")); } - public static object[] EvalLuaImpl(string code) { + public static object?[]? EvalLuaImpl(string code) { string localCode = ReadContent("bin/env"); code = $"{localCode}\n{code}"; - object[] objects; + object?[]? objects; try { objects = Everest.LuaLoader.Run(code, null); } catch (Exception e) { e.Log(); - return new object[] {e}; + return [e]; } return objects; diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/LuaHelpers.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/LuaHelpers.cs index 2c1a7a02f..49ddf9d6f 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/LuaHelpers.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/LuaHelpers.cs @@ -3,115 +3,126 @@ using System.Linq; using System.Reflection; using Celeste; +using Celeste.Mod; +using JetBrains.Annotations; using Monocle; +using System.Diagnostics.CodeAnalysis; using TAS.EverestInterop.InfoHUD; +using TAS.InfoHUD; using TAS.Utils; namespace TAS.EverestInterop.Lua; +/// Provides helper methods for usage in Lua scripts public static class LuaHelpers { - public class ValueHolder { - public T Value; - - public ValueHolder(T value) { - Value = value; - } + public class ValueHolder(T value) { + public T Value = value; } - public static readonly object NullValue = new ValueHolder(null); + /// Used to pass a null value from Lua + [UsedImplicitly] + public static readonly object? NullValue = new ValueHolder(null); - // can omit entityId - public static Entity GetEntity(string typeNameWithId) { - if (TryGetEntityTypeWithId(typeNameWithId, out Type type, out string entityId)) { - return InfoCustom.FindEntities(type, entityId).FirstOrDefault(); + /// Resolves the first entity which matches the specified target-query, e.g. "Player" or "Celeste.Player" + [UsedImplicitly] + public static Entity? GetEntity(string targetQuery) { + if (TryGetEntityTypeWithId(targetQuery, out var type, out var entityId)) { + return (Entity?) TargetQuery.ResolveTypeInstances(type, [], entityId).FirstOrDefault(); } else { return null; } } - public static List GetEntities(string typeNameWithId) { - if (TryGetEntityTypeWithId(typeNameWithId, out Type type, out string entityId)) { - return InfoCustom.FindEntities(type, entityId); - } else { - return new List(); - } - } - - // e.g. entityTypeName can be "Player" or "Celeste.Player" - private static bool TryGetEntityTypeWithId(string entityTypeName, out Type type, out string entityId) { - if (InfoCustom.TryParseTypes(entityTypeName, out List types, out string id)) { - type = types.FirstOrDefault(t => t.IsSameOrSubclassOf(typeof(Entity))); - entityId = id; - return type != null; + /// Resolves all entities which match the specified target-query, e.g. "Player" or "Celeste.Player" + [UsedImplicitly] + public static List GetEntities(string targetQuery) { + if (TryGetEntityTypeWithId(targetQuery, out var type, out var entityId)) { + return TargetQuery.ResolveTypeInstances(type, [], entityId).Cast().ToList(); } else { - type = null; - entityId = ""; - return false; + return []; } } - private static bool TryGetType(string typeName, out Type type) { - return InfoCustom.TryParseType(typeName, out type, out _, out _); - } - - // Get field or property value - public static object GetValue(object instanceOrTypeName, string memberName) { - if (!TryGetTypeFromInstanceOrTypeName(instanceOrTypeName, out Type type, out bool staticMember)) { + /// Gets the value of a (private) field / property + [UsedImplicitly] + public static object? GetValue(object? instanceOrTargetQuery, string memberName) { + if (!TryGetInstance(instanceOrTargetQuery, out var type, out object? instance)) { + $"Failed to get instance for '{instance}".Log("Lua", EvalLuaCommand.ConsoleCommandRunning, LogLevel.Error); return null; } - object obj = staticMember ? null : instanceOrTypeName; - MemberInfo memberInfo = type.GetMemberInfo(memberName); - if (memberInfo != null) { - try { - if (memberInfo is FieldInfo fieldInfo) { - return fieldInfo.GetValue(obj); - } else if (memberInfo is PropertyInfo propertyInfo) { - return propertyInfo.GetValue(obj); + try { + if (type.GetFieldInfo(memberName) is { } fieldInfo) { + if (fieldInfo.IsStatic) { + return fieldInfo.GetValue(null); + } else { + return fieldInfo.GetValue(instance); + } + } + if (type.GetPropertyInfo(memberName) is { } propertyInfo && propertyInfo.GetGetMethod() != null) { + if (propertyInfo.IsStatic()) { + return propertyInfo.GetValue(null); + } else { + return propertyInfo.GetValue(instance); } - } catch (Exception e) { - EvalLuaCommand.Log(e); } + } catch (Exception e) { + e.LogException($"Failed getting member '{memberName}' on type '{type}'", "Lua", EvalLuaCommand.ConsoleCommandRunning); + return null; } + $"Failed finding member '{memberName}' on type '{type}'".Log("Lua", EvalLuaCommand.ConsoleCommandRunning, LogLevel.Error); return null; } - // Set field or property value - public static void SetValue(object instanceOrTypeName, string memberName, object value) { - if (!TryGetTypeFromInstanceOrTypeName(instanceOrTypeName, out Type type, out bool staticMember)) { + /// Sets the value of a (private) field / property + [UsedImplicitly] + public static void SetValue(object? instanceOrTargetQuery, string memberName, object? value) { + if (!TryGetInstance(instanceOrTargetQuery, out var type, out object? instance)) { + $"Failed to get instance for '{instance}".Log("Lua", EvalLuaCommand.ConsoleCommandRunning, LogLevel.Error); return; } - object obj = staticMember ? null : instanceOrTypeName; - MemberInfo memberInfo = type.GetMemberInfo(memberName); - if (memberInfo != null) { - try { - if (memberInfo is FieldInfo fieldInfo) { - value = ConvertType(value, type, fieldInfo.FieldType); - fieldInfo.SetValue(obj, value); - } else if (memberInfo is PropertyInfo propertyInfo) { - value = ConvertType(value, type, propertyInfo.PropertyType); - propertyInfo.SetValue(obj, value); + try { + if (type.GetFieldInfo(memberName) is { } fieldInfo) { + value = ConvertType(value, type, fieldInfo.FieldType); + if (fieldInfo.IsStatic) { + fieldInfo.SetValue(null, value); + } else { + fieldInfo.SetValue(instance, value); + } + } + if (type.GetPropertyInfo(memberName) is { } propertyInfo && propertyInfo.GetSetMethod() != null) { + value = ConvertType(value, type, propertyInfo.PropertyType); + if (propertyInfo.IsStatic()) { + propertyInfo.SetValue(null, value); + } else { + propertyInfo.SetValue(instance, value); } - } catch (Exception e) { - EvalLuaCommand.Log(e); } + } catch (Exception e) { + e.LogException($"Failed setting member '{memberName}' on type '{type}'", "Lua", EvalLuaCommand.ConsoleCommandRunning); + return; } + + $"Failed finding member '{memberName}' on type '{type}'".Log("Lua", EvalLuaCommand.ConsoleCommandRunning, LogLevel.Error); } - public static object InvokeMethod(object instanceOrTypeName, string methodName, params object[] parameters) { - if (!TryGetTypeFromInstanceOrTypeName(instanceOrTypeName, out Type type, out bool staticMethod)) { + /// Invokes a (private) method + [UsedImplicitly] + public static object? InvokeMethod(object? instanceOrTargetQuery, string methodName, params object?[] parameters) { + if (!TryGetInstance(instanceOrTargetQuery, out var type, out object? instance)) { + $"Failed to get instance for '{instance}".Log("Lua", EvalLuaCommand.ConsoleCommandRunning, LogLevel.Error); return null; } - - // TODO Overloaded methods are not supported - object obj = staticMethod ? null : instanceOrTypeName; - MethodInfo methodInfo = type.GetMethodInfo(methodName); - if (methodInfo != null) { - ParameterInfo[] parameterInfos = methodInfo.GetParameters(); + if (instance == null) { + $"Cannot get value of member '{methodName}' for null-instance".Log("Lua", EvalLuaCommand.ConsoleCommandRunning, LogLevel.Error); + return null; + } + if (type.GetMethodInfo(methodName) is { } methodInfo) { + var parameterInfos = methodInfo.GetParameters(); for (int i = 0; i < parameterInfos.Length; i++) { - ParameterInfo parameterInfo = parameterInfos[i]; + var parameterInfo = parameterInfos[i]; if (i < parameters.Length) { parameters[i] = ConvertType(parameters[i], parameters[i]?.GetType(), parameterInfo.ParameterType); } else if (parameterInfo.HasDefaultValue) { @@ -121,76 +132,115 @@ public static object InvokeMethod(object instanceOrTypeName, string methodName, } try { - return methodInfo.Invoke(obj, parameters); + return methodInfo.Invoke(instance, parameters); } catch (Exception e) { - EvalLuaCommand.Log(e); + e.LogException($"Failed invoking method '{methodName}' on type '{type}'", "Lua", EvalLuaCommand.ConsoleCommandRunning); + return null; } - } else { - $"Method '{methodName}' can't be found in type '{type.FullName}'".Log(true); } + $"Failed finding method '{methodName}' on type '{type}'".Log("Lua", EvalLuaCommand.ConsoleCommandRunning, LogLevel.Error); return null; } - public static ValueHolder ToFloat(double value) { - return new ValueHolder((float) value); - } - - private static object ConvertType(object value, Type valueType, Type type) { - if (value is ValueHolder floatHolder) { - return floatHolder.Value; - } else if (value is ValueHolder objectHolder) { - return objectHolder.Value; - } - - if (valueType != null && type.IsSameOrSubclassOf(valueType)) { - return value; - } - - try { - if (value is null) { - return type.IsValueType ? Activator.CreateInstance(type) : null; - } else { - return type.IsEnum ? Enum.Parse(type, (string) value, true) : Convert.ChangeType(value, type); - } - } catch { - return value; - } - } - - private static bool TryGetTypeFromInstanceOrTypeName(object instanceOrTypeName, out Type type, out bool staticMember) { - type = null; - staticMember = false; - if (instanceOrTypeName is string typeName && TryGetType(typeName, out type)) { - staticMember = true; - } else if (instanceOrTypeName != null) { - type = instanceOrTypeName.GetType(); - } - - return type != null; - } - - public static object GetEnum(string enumTypeName, object value) { - if (TryGetType(enumTypeName, out Type type) && type.IsEnum) { + /// Resolves the enum value for an ordinal or name + [UsedImplicitly] + public static object? GetEnum(string enumTargetQuery, object value) { + if (TargetQuery.ResolveBaseTypes(enumTargetQuery.Split('.'), out _, out _, out _) is { } types && types.IsNotEmpty() && + types.FirstOrDefault(t => t.IsEnum) is { } type) + { if (value is long longValue || long.TryParse(value.ToString(), out longValue)) { return Enum.ToObject(type, longValue); } else { try { - return Enum.Parse(type, value.ToString(), true); + return Enum.Parse(type, value.ToString() ?? string.Empty, true); } catch { + $"Failed finding enum-value '{value}' on type '{type}'".Log("Lua", EvalLuaCommand.ConsoleCommandRunning, LogLevel.Error); return null; } } } + $"Failed finding enum '{enumTargetQuery}'".Log("Lua", EvalLuaCommand.ConsoleCommandRunning, LogLevel.Error); return null; } + /// Returns the current level + [UsedImplicitly] public static Level GetLevel() { return Engine.Scene.GetLevel(); } + /// Returns the current session + [UsedImplicitly] public static Session GetSession() { return Engine.Scene.GetSession(); } -} \ No newline at end of file + + /// Casts the value to an int, for usage with setValue / invokeMethod + [UsedImplicitly] + public static ValueHolder ToInt(long value) { + return new ValueHolder((int) value); + } + /// Casts the value to a float, for usage with setValue / invokeMethod + [UsedImplicitly] + public static ValueHolder ToFloat(double value) { + return new ValueHolder((float) value); + } + + private static bool TryGetInstance(object? instanceOrTargetQuery, [NotNullWhen(true)] out Type? type, out object? instance) { + if (instanceOrTargetQuery is string targetQuery) { + if (TargetQuery.ResolveBaseTypes(targetQuery.Split('.'), out _, out _, out _) is { } types && types.IsNotEmpty()) { + type = types[0]; + instance = TargetQuery.ResolveTypeInstances(types[0], [], EntityID.None).FirstOrDefault(); + return true; + } else { + type = null; + instance = null; + return false; + } + } else { + type = instanceOrTargetQuery?.GetType()!; + instance = instanceOrTargetQuery; + return true; + } + } + + private static bool TryGetEntityTypeWithId(string targetQuery, [NotNullWhen(true)] out Type? type, out EntityID? entityId) { + if (TargetQuery.ResolveBaseTypes(targetQuery.Split('.'), out _, out _, out var id) is { } types && types.IsNotEmpty()) { + type = types.FirstOrDefault(t => t.IsSameOrSubclassOf(typeof(Entity))); + entityId = id; + return type != null; + } else { + type = null; + entityId = EntityID.None; + return false; + } + } + + /// Tries to convert the value to the target type + private static object? ConvertType(object? value, Type? valueType, Type type) { + switch (value) { + case ValueHolder intHolder: + return intHolder.Value; + case ValueHolder floatHolder: + return floatHolder.Value; + case ValueHolder objectHolder: + return objectHolder.Value; + } + + if (valueType != null && type.IsSameOrSubclassOf(valueType)) { + return value; + } + + try { + if (value is null) { + return type.IsValueType ? Activator.CreateInstance(type) : null; + } else { + return type.IsEnum ? Enum.Parse(type, (string) value, true) : Convert.ChangeType(value, type); + } + } catch { + return value; + } + } +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/env.lua b/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/env.lua index 532aec8b1..1c807b8b7 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/env.lua +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Lua/env.lua @@ -4,63 +4,71 @@ local TAS = require("#TAS") local Vector2 = require("#Microsoft.Xna.Framework.Vector2") local LuaHelpers = require("#TAS.EverestInterop.Lua.LuaHelpers") ---- use nullValue instead of nil when using setValue/invokeMethod +--- Use nullValue instead of nil when using setValue / invokeMethod local nullValue = LuaHelpers.NullValue ---- log message +--- Logs a message local function log(message, tag) - Celeste.Mod.Logger.Log(Celeste.Mod.LogLevel.Info, tag or "CelesteTAS", tostring(message)) + Celeste.Mod.Logger.Log(Celeste.Mod.LogLevel.Info, tag or "CelesteTAS/Lua", tostring(message)) end ---- getEntity("Player") or getEntity("Celeste.Player") ---- can specify entityId, like getEntity("DustStaticSpinner[s1:12]") +--- Resolves the first entity which matches the specified target-query +--- Example: getEntity("Player"), getEntity("Celeste.Player"), getEntity("DustStaticSpinner[s1:12]") local function getEntity(entityTypeName) return LuaHelpers.GetEntity(entityTypeName) end ---- getEntities("Player") or getEntities("Celeste.Player") +--- Resolves all entities which match the specified target-query, e.g. "Player" or "Celeste.Player" +--- Example: getEntities("Player"), getEntities("Celeste.Player"), getEntities("CustomSpinner@VivHelper") local function getEntities(entityTypeName) return LuaHelpers.GetEntities(entityTypeName) end ---- get field or property value +--- Gets the value of a (private) field / property local function getValue(instanceOrTypeName, memberName) return LuaHelpers.GetValue(instanceOrTypeName, memberName) end ---- set field or property value ---- use nullValue instead of nil if you want to pass null to c# +--- Sets the value of a (private) field / property +--- Use nullValue instead of nil if you want to pass null to C# local function setValue(instanceOrTypeName, memberName, value) return LuaHelpers.SetValue(instanceOrTypeName, memberName, value) end ---- parameters = parameter1, parameter2, ... ---- use nullValue instead of nil if you want to pass null to c# +--- Invokes a (private) method +--- Use nullValue instead of nil if you want to pass null to C# local function invokeMethod(instanceOrTypeName, methodName, ...) return LuaHelpers.InvokeMethod(instanceOrTypeName, methodName, ...) end ---- cast double value to float in c# side when calling invokeMethod() or setValue() ---- e.g. invokeMethod("ExtendedVariants.UI.ModOptionsEntries", "SetVariantValue", getEnum("Variant", "Gravity"), toFloat(0.1)) -local function toFloat(doubleValue) - return LuaHelpers.ToFloat(doubleValue) -end - ---- get enum value ---- getEnum('Facings', 'Right') or getEnum('Facings', 1) +--- Resolves the enum value for an ordinal or name +--- Example: getEnum('Facings', 'Right') or getEnum('Facings', 1) local function getEnum(enumTypeName, value) return LuaHelpers.GetEnum(enumTypeName, value) end +--- Returns the current level local function getLevel() return LuaHelpers.GetLevel() end +--- Returns the current session local function getSession() return LuaHelpers.GetSession() end +--- Casts the value to an int, for usage with setValue / invokeMethod +local function toInt(longValue) + return LuaHelpers.ToInt(longValue) +end + +--- Casts the value to a float, for usage with setValue / invokeMethod +--- Example: invokeMethod("ExtendedVariants.UI.ModOptionsEntries", "SetVariantValue", getEnum("Variant", "Gravity"), toFloat(0.1)) +local function toFloat(doubleValue) + return LuaHelpers.ToFloat(doubleValue) +end + local scene = Monocle.Engine.Scene local level = getLevel() local session = getSession() -local player = getEntity("Player") \ No newline at end of file +local player = getEntity("Player") diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/MonocleCommands.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/MonocleCommands.cs index e04555c32..611216ff1 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/MonocleCommands.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/MonocleCommands.cs @@ -5,6 +5,7 @@ using Celeste; using Monocle; using TAS.Input; +using TAS.ModInterop; using TAS.Utils; namespace TAS.EverestInterop; @@ -79,7 +80,7 @@ private static void CmdPlayTas(string filePath) { return; } - InputController.StudioTasFilePath = filePath; + Manager.Controller.FilePath = filePath; Manager.EnableRun(); } } diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/Pico8Fixer.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/Pico8Fixer.cs index 148f59f6a..1ab33a09f 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/Pico8Fixer.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/Pico8Fixer.cs @@ -2,7 +2,7 @@ using TAS.Module; using Emulator = Celeste.Pico8.Emulator; -namespace TAS.EverestInterop; +namespace TAS.EverestInterop; public static class Pico8Fixer { // Set Pico8Fixer.Seed when need @@ -74,4 +74,4 @@ private static void ClassicOnUpdate(On.Celeste.Pico8.Classic.orig_Update orig, C Frames = self.frames; } } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/PlayTasAtLaunch.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/PlayTasAtLaunch.cs deleted file mode 100644 index 38bd9e579..000000000 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/PlayTasAtLaunch.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Celeste; -using Celeste.Mod; -using Monocle; -using TAS.Input; -using TAS.Module; -using TAS.Utils; - -namespace TAS.EverestInterop; - -public static class PlayTasAtLaunch { - public static bool WaitToPlayTas { get; private set; } - - [Initialize] - private static void Initialize() { - On.Celeste.Celeste.OnSceneTransition += CelesteOnOnSceneTransition; - ParseArgs(); - } - - [Unload] - private static void Unload() { - On.Celeste.Celeste.OnSceneTransition -= CelesteOnOnSceneTransition; - } - - private static void CelesteOnOnSceneTransition(On.Celeste.Celeste.orig_OnSceneTransition orig, Celeste.Celeste self, Scene last, Scene next) { - orig(self, last, next); - if (WaitToPlayTas && next is Overworld) { - WaitToPlayTas = false; - Manager.EnableRun(); - } - } - - private static void ParseArgs() { - Queue queue = new(Everest.Args); - while (queue.Count > 0) { - string arg = queue.Dequeue(); - - if (arg == "--tas" && queue.Count >= 1) { - string tasPath = queue.Dequeue(); - - // fix: https://github.com/EverestAPI/CelesteTAS-EverestInterop/issues/62 - while (Environment.OSVersion.Platform != PlatformID.Win32NT && queue.Count >= 1 && !tasPath.EndsWith(".tas", StringComparison.OrdinalIgnoreCase)) { - tasPath += " " + queue.Dequeue(); - } - - if (File.Exists(tasPath)) { - InputController.StudioTasFilePath = tasPath; - WaitToPlayTas = true; - } else { - $"TAS file '{tasPath}' does not exist.".Log(LogLevel.Warn); - } - } - } - } -} \ No newline at end of file diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/PreventExitGame.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/PreventExitGame.cs index 895edf710..46792bdee 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/PreventExitGame.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/PreventExitGame.cs @@ -21,4 +21,4 @@ private static void OuiMainMenuOnOnExit(On.Celeste.OuiMainMenu.orig_OnExit orig, orig(self); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/RestoreSettings.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/RestoreSettings.cs index 00a6a9eb1..a827b94e8 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/RestoreSettings.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/RestoreSettings.cs @@ -1,20 +1,21 @@ using System.Collections.Generic; using Celeste; using Celeste.Mod; +using System; using TAS.Input.Commands; using TAS.Module; using TAS.Utils; namespace TAS.EverestInterop; -// ReSharper disable once UnusedType.Global public static class RestoreSettings { - private static Settings origSettings; + private static Settings? origSettings; private static Assists? origAssists; - private static bool backupAssists; - private static Dictionary origModSettings; + private static Dictionary? origModSettings; + + internal static readonly HashSet ignoredModules = new(); + internal static readonly Dictionary Backup, Action Restore)> customHandlers = new(); - // ReSharper disable once UnusedMember.Local [EnableRun] private static void TryBackup() { origSettings = null; @@ -26,26 +27,28 @@ private static void TryBackup() { } origSettings = Settings.Instance.ShallowClone(); - - if (SaveData.Instance != null) { - origAssists = SaveData.Instance.Assists; - } else { - backupAssists = true; - } + origAssists = SaveData.Instance?.Assists; origModSettings = new Dictionary(); - foreach (EverestModule module in Everest.Modules) { - if (module._Settings != null && module.SettingsType != null && module._Settings is not CelesteTasSettings) { - origModSettings.Add(module, module._Settings.ShallowClone()); + foreach (var module in Everest.Modules) { + if (module._Settings == null || module.SettingsType == null || module._Settings is CelesteTasSettings) { + continue; + } + + if (ignoredModules.Contains(module)) { + continue; + } + if (customHandlers.TryGetValue(module, out var handler)) { + origModSettings.Add(module, handler.Backup()); + continue; } + + origModSettings.Add(module, module._Settings.ShallowClone()); } } - // ReSharper disable once UnusedMember.Local [DisableRun] private static void TryRestore() { - backupAssists = false; - if (origSettings != null) { Settings.Instance.CopyAllFields(origSettings); Settings.Instance.ApplyVolumes(); @@ -63,12 +66,23 @@ private static void TryRestore() { if (origModSettings != null) { TasSettings.Enabled = true; TasSettings.RestoreSettings = true; - foreach (EverestModule module in Everest.Modules) { + + foreach (var module in Everest.Modules) { try { - if (module?._Settings != null && origModSettings.TryGetValue(module, out object modSettings) && modSettings != null) { - module._Settings.CopyAllProperties(modSettings, true); - module._Settings.CopyAllFields(modSettings, true); + if (module._Settings == null || !origModSettings.TryGetValue(module, out object? modSettings)) { + continue; + } + + if (ignoredModules.Contains(module)) { + continue; + } + if (customHandlers.TryGetValue(module, out var handler)) { + handler.Restore(modSettings); + continue; } + + module._Settings.CopyAllProperties(modSettings, true); + module._Settings.CopyAllFields(modSettings, true); } catch { // ignored } @@ -90,9 +104,8 @@ private static void Unload() { private static void SaveDataOnStart(On.Celeste.SaveData.orig_Start orig, SaveData data, int slot) { orig(data, slot); - if (origAssists == null && backupAssists) { - backupAssists = false; - origAssists = SaveData.Instance.Assists; - } + + // The TAS might've been started outside a save file, so backup save-data now + origAssists ??= SaveData.Instance.Assists; } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/SimplifiedGraphicsFeature.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/SimplifiedGraphicsFeature.cs index 677e1c552..7569b06e7 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/SimplifiedGraphicsFeature.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/SimplifiedGraphicsFeature.cs @@ -5,6 +5,7 @@ using System.Reflection; using Celeste; using Celeste.Mod; +using Celeste.Mod.Entities; using FMOD.Studio; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; @@ -12,13 +13,14 @@ using Monocle; using MonoMod.Cil; using MonoMod.Utils; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; namespace TAS.EverestInterop; public static class SimplifiedGraphicsFeature { - private static readonly List SolidDecals = new() { + private static readonly string[] SolidDecals = [ "3-resort/bridgecolumn", "3-resort/bridgecolumntop", "3-resort/brokenelevator", @@ -31,11 +33,9 @@ public static class SimplifiedGraphicsFeature { "3-resort/roofedge_c", "3-resort/roofedge_d", "4-cliffside/bridge_a", - }; + ]; - private static readonly List ClutteredTypes = new() { - typeof(FloatingDebris), typeof(MoonCreature), typeof(ResortLantern) - }; + private static readonly List ClutteredTypes = [typeof(FloatingDebris), typeof(MoonCreature), typeof(ResortLantern)]; private static bool lastSimplifiedGraphics = TasSettings.SimplifiedGraphics; private static SolidTilesStyle currentSolidTilesStyle; @@ -132,50 +132,49 @@ private static void Initialize() { if (ModUtils.GetType("FrostHelper", "FrostHelper.CustomSpinner") is { } customSpinnerType) { foreach (ConstructorInfo constructorInfo in customSpinnerType.GetConstructors()) { - constructorInfo.IlHook(ModCustomSpinnerColor); + constructorInfo.HookAfter(SetCustomSpinnerColor); } } - if (ModUtils.GetType("MaxHelpingHand", "Celeste.Mod.MaxHelpingHand.Entities.RainbowSpinnerColorController")?.GetMethodInfo("getModHue") is - { } getModHue) { - getModHue.IlHook(ModRainbowSpinnerColor); - } - - if (ModUtils.GetType("SpringCollab2020", "Celeste.Mod.SpringCollab2020.Entities.RainbowSpinnerColorController")?.GetMethodInfo("getModHue") is - { } getModHue2) { - getModHue2.IlHook(ModRainbowSpinnerColor); - } + ModUtils.GetType("MaxHelpingHand", "Celeste.Mod.MaxHelpingHand.Entities.RainbowSpinnerColorController") + ?.GetMethodInfo("getModHue") + ?.OverrideReturn(IsSimplifiedSpinnerColorNotNull, GetSimplifiedSpinnerColor); + ModUtils.GetType("SpringCollab2020", "Celeste.Mod.SpringCollab2020.Entities.RainbowSpinnerColorController") + ?.GetMethodInfo("getModHue") + ?.OverrideReturn(IsSimplifiedSpinnerColorNotNull, GetSimplifiedSpinnerColor); - if (ModUtils.GetType("VivHelper", "VivHelper.Entities.CustomSpinner")?.GetMethodInfo("CreateSprites") is - { } customSpinnerCreateSprites) { + if (ModUtils.GetType("VivHelper", "VivHelper.Entities.CustomSpinner")?.GetMethodInfo("CreateSprites") is { } customSpinnerCreateSprites) { customSpinnerCreateSprites.IlHook(ModVivCustomSpinnerColor); } - if (ModUtils.GetType("PandorasBox", "Celeste.Mod.PandorasBox.TileGlitcher")?.GetMethodInfo("tileGlitcher") is - { } tileGlitcher) { + if (ModUtils.GetType("PandorasBox", "Celeste.Mod.PandorasBox.TileGlitcher")?.GetMethodInfo("tileGlitcher") is { } tileGlitcher) { tileGlitcher.GetStateMachineTarget().IlHook(ModTileGlitcher); } - Type t = typeof(SimplifiedGraphicsFeature); - On.Celeste.CrystalStaticSpinner.CreateSprites += CrystalStaticSpinner_CreateSprites; IL.Celeste.CrystalStaticSpinner.GetHue += CrystalStaticSpinnerOnGetHue; - HookHelper.SkipMethod(t, nameof(IsSimplifiedGraphics), "Render", typeof(MirrorSurfaces)); + typeof(MirrorSurfaces).GetMethodInfo(nameof(MirrorSurfaces.Render)).SkipMethod(IsSimplifiedGraphics); IL.Celeste.LightingRenderer.Render += LightingRenderer_Render; On.Celeste.ColorGrade.Set_MTexture_MTexture_float += ColorGradeOnSet_MTexture_MTexture_float; IL.Celeste.BloomRenderer.Apply += BloomRendererOnApply; On.Celeste.Decal.Render += Decal_Render; - HookHelper.SkipMethod(t, nameof(IsSimplifiedDecal), "Render", typeof(CliffsideWindFlag), typeof(Flagline), typeof(FakeWall)); - HookHelper.SkipMethod(t, nameof(IsSimplifiedParticle), - typeof(ParticleSystem).GetMethod("Render", new Type[] { }), - typeof(ParticleSystem).GetMethod("Render", new[] {typeof(float)}) + HookHelper.SkipMethods(IsSimplifiedDecal, + typeof(CliffsideWindFlag).GetMethodInfo(nameof(CliffsideWindFlag.Render)), + typeof(Flagline).GetMethodInfo(nameof(Flagline.Render)), + typeof(FakeWall).GetMethodInfo(nameof(FakeWall.Render)) + ); + + HookHelper.SkipMethods(IsSimplifiedParticle, + typeof(ParticleSystem).GetMethodInfo(nameof(ParticleSystem.Render), []), + typeof(ParticleSystem).GetMethodInfo(nameof(ParticleSystem.Render), [typeof(float)]) ); - HookHelper.SkipMethod(t, nameof(IsSimplifiedDistort), "Apply", typeof(Glitch)); - HookHelper.SkipMethod(t, nameof(IsSimplifiedMiniTextbox), "Render", typeof(MiniTextbox)); + + typeof(Glitch).GetMethodInfo(nameof(Glitch.Apply)).SkipMethod(IsSimplifiedDistort); + typeof(MiniTextbox).GetMethodInfo(nameof(MiniTextbox.Render)).SkipMethod(IsSimplifiedMiniTextbox); IL.Celeste.Distort.Render += DistortOnRender; On.Celeste.SolidTiles.ctor += SolidTilesOnCtor; @@ -186,54 +185,65 @@ private static void Initialize() { IL.Celeste.LightningRenderer.Render += LightningRenderer_RenderIL; - HookHelper.ReturnZeroMethod(t, nameof(SimplifiedWavedBlock), + HookHelper.OverrideReturns(SimplifiedWavedBlock, 0.0f, typeof(DreamBlock).GetMethodInfo("Lerp"), typeof(LavaRect).GetMethodInfo("Wave") ); - HookHelper.ReturnZeroMethod( - t, - nameof(SimplifiedWavedBlock), - ModUtils.GetTypes().Where(type => type.FullName?.EndsWith("Renderer+Edge") == true) - .Select(type => type.GetMethodInfo("GetWaveAt")).ToArray() + HookHelper.OverrideReturns(SimplifiedWavedBlock, 0.0f, + ModUtils.GetTypes() + .Where(type => type.FullName?.EndsWith("Renderer+Edge") == true) + .Select(type => type.GetMethodInfo("GetWaveAt")) + .ToArray() ); + On.Celeste.LightningRenderer.Bolt.Render += BoltOnRender; IL.Celeste.Level.Render += LevelOnRender; On.Celeste.Audio.Play_string += AudioOnPlay_string; - HookHelper.SkipMethod(t, nameof(IsSimplifiedLightningStrike), "Render", - typeof(LightningStrike), - ModUtils.GetType("ContortHelper", "ContortHelper.BetterLightningStrike") + HookHelper.SkipMethods(IsSimplifiedLightningStrike, + typeof(LightningStrike).GetMethodInfo(nameof(LightningStrike.Render)), + ModUtils.GetType("ContortHelper", "ContortHelper.BetterLightningStrike")?.GetMethodInfo("Render") ); - HookHelper.SkipMethod(t, nameof(IsSimplifiedClutteredEntity), "Render", - typeof(ReflectionTentacles), typeof(SummitCloud), typeof(TempleEye), typeof(Wire), - typeof(Cobweb), typeof(HangingLamp), - typeof(DustGraphic).GetNestedType("Eyeballs", BindingFlags.NonPublic), - ModUtils.GetType("BrokemiaHelper", "BrokemiaHelper.PixelRendered.PixelComponent") + HookHelper.SkipMethods(IsSimplifiedClutteredEntity, + typeof(ReflectionTentacles).GetMethodInfo(nameof(ReflectionTentacles.Render)), + typeof(SummitCloud).GetMethodInfo(nameof(SummitCloud.Render)), + typeof(TempleEye).GetMethodInfo(nameof(TempleEye.Render)), + typeof(Wire).GetMethodInfo(nameof(Wire.Render)), + typeof(Cobweb).GetMethodInfo(nameof(Cobweb.Render)), + typeof(HangingLamp).GetMethodInfo(nameof(HangingLamp.Render)), + typeof(DustGraphic.Eyeballs).GetMethodInfo(nameof(DustGraphic.Eyeballs.Render)), + ModUtils.GetType("BrokemiaHelper", "BrokemiaHelper.PixelRendered.PixelComponent")?.GetMethodInfo("Render") ); - typeof(SinkingPlatform).GetMethodInfo("Render").IlHook((cursor, _) => { + + typeof(SinkingPlatform).GetMethodInfo(nameof(SinkingPlatform.Render)).IlHook((cursor, _) => { if (cursor.TryGotoNext(MoveType.After, ins => ins.MatchLdfld(nameof(Shaker.Value)))) { cursor.EmitDelegate(IsSimplifiedClutteredEntity); - cursor.EmitDelegate((Vector2 vec, bool b) => b ? Vector2.Zero : vec); + cursor.EmitDelegate(IgnoreShaker); } }); + static Vector2 IgnoreShaker(Vector2 amount, bool ignore) => ignore ? Vector2.Zero : amount; + On.Celeste.FloatingDebris.ctor_Vector2 += FloatingDebris_ctor; On.Celeste.MoonCreature.ctor_Vector2 += MoonCreature_ctor; On.Celeste.ResortLantern.ctor_Vector2 += ResortLantern_ctor; + if (ModUtils.GetType("FemtoHelper", "CustomMoonCreature") is { } customMoonCreatureType - && customMoonCreatureType.GetMethodInfo("Added") is { } customMoonCreatureAdded) { + && customMoonCreatureType.GetMethodInfo("Added") is { } customMoonCreatureAdded) + { customMoonCreatureAdded.HookAfter(CustomMoonCreatureAdded); ClutteredTypes.Add(customMoonCreatureType); } - HookHelper.SkipMethod( - t, - nameof(IsSimplifiedHud), - "Render", - typeof(HeightDisplay), typeof(TalkComponent.TalkComponentUI), typeof(BirdTutorialGui), typeof(CoreMessage), typeof(MemorialText), - typeof(Player).Assembly.GetType("Celeste.Mod.Entities.CustomHeightDisplay"), - ModUtils.GetType("Monika's D-Sides", "Celeste.Mod.RubysEntities.AltHeightDisplay") + HookHelper.SkipMethods(IsSimplifiedHud, + typeof(HeightDisplay).GetMethodInfo(nameof(HeightDisplay.Render)), + typeof(CustomHeightDisplay).GetMethodInfo(nameof(CustomHeightDisplay.Render)), + ModUtils.GetType("Monika's D-Sides", "Celeste.Mod.RubysEntities.AltHeightDisplay")?.GetMethodInfo("Render"), + typeof(TalkComponent.TalkComponentUI).GetMethodInfo(nameof(TalkComponent.TalkComponentUI.Render)), + typeof(BirdTutorialGui).GetMethodInfo(nameof(BirdTutorialGui.Render)), + typeof(CoreMessage).GetMethodInfo(nameof(CoreMessage.Render)), + typeof(MemorialText).GetMethodInfo(nameof(MemorialText.Render)) ); On.Celeste.Spikes.Added += SpikesOnAdded; @@ -265,28 +275,17 @@ private static void Unload() { } private static bool IsSimplifiedGraphics() => TasSettings.SimplifiedGraphics; - private static bool IsSimplifiedParticle() => TasSettings.SimplifiedGraphics && TasSettings.SimplifiedParticle; - private static bool IsSimplifiedDistort() => TasSettings.SimplifiedGraphics && TasSettings.SimplifiedDistort; - private static bool IsSimplifiedDecal() => TasSettings.SimplifiedGraphics && TasSettings.SimplifiedDecal; - private static bool IsSimplifiedMiniTextbox() => TasSettings.SimplifiedGraphics && TasSettings.SimplifiedMiniTextbox; - private static bool SimplifiedWavedBlock() => TasSettings.SimplifiedGraphics && TasSettings.SimplifiedWavedEdge; - - private static ScreenWipe SimplifiedScreenWipe(ScreenWipe wipe) => - TasSettings.SimplifiedGraphics && TasSettings.SimplifiedScreenWipe ? null : wipe; - private static bool IsSimplifiedLightningStrike() => TasSettings.SimplifiedGraphics && TasSettings.SimplifiedLightningStrike; - private static bool IsSimplifiedClutteredEntity() => TasSettings.SimplifiedGraphics && TasSettings.SimplifiedClutteredEntity; + private static bool IsSimplifiedHud() => TasSettings.SimplifiedGraphics && TasSettings.SimplifiedHud || + TasSettings.CenterCamera && Math.Abs(CenterCamera.LevelZoom - 1f) > 1e-3; - private static bool IsSimplifiedHud() { - return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedHud || - TasSettings.CenterCamera && Math.Abs(CenterCamera.LevelZoom - 1f) > 1e-3; - } + private static ScreenWipe SimplifiedScreenWipe(ScreenWipe wipe) => TasSettings.SimplifiedGraphics && TasSettings.SimplifiedScreenWipe ? null : wipe; private static void OnSimplifiedGraphicsChanged(bool simplifiedGraphics) { if (Engine.Scene is not Level level) { @@ -343,19 +342,16 @@ private static void Level_Update(On.Celeste.Level.orig_Update orig, Level self) } private static void LightingRenderer_Render(ILContext il) { - ILCursor ilCursor = new(il); - if (ilCursor.TryGotoNext( - MoveType.After, - ins => ins.MatchCall(typeof(MathHelper), "Clamp") - )) { - ilCursor.EmitDelegate>(IsSimplifiedLighting); + var cursor = new ILCursor(il); + if (cursor.TryGotoNext(MoveType.After, ins => ins.MatchCall(typeof(MathHelper), nameof(MathHelper.Clamp)))) { + cursor.EmitDelegate(SimplifyLightningAlpha); } - } - private static float IsSimplifiedLighting(float alpha) { - return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedLighting != null - ? (10 - TasSettings.SimplifiedLighting.Value) / 10f - : alpha; + static float SimplifyLightningAlpha(float alpha) { + return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedLighting != null + ? (10 - TasSettings.SimplifiedLighting.Value) / 10f + : alpha; + } } private static void ColorGradeOnSet_MTexture_MTexture_float(On.Celeste.ColorGrade.orig_Set_MTexture_MTexture_float orig, MTexture fromTex, @@ -373,34 +369,32 @@ private static void ColorGradeOnSet_MTexture_MTexture_float(On.Celeste.ColorGrad } private static void BloomRendererOnApply(ILContext il) { - ILCursor ilCursor = new(il); - while (ilCursor.TryGotoNext( - MoveType.After, - ins => ins.OpCode == OpCodes.Ldarg_0, - ins => ins.MatchLdfld("Base") - )) { - ilCursor.EmitDelegate>(IsSimplifiedBloomBase); + var cursor = new ILCursor(il); + while (cursor.TryGotoNext(MoveType.After, + ins => ins.MatchLdarg0(), + ins => ins.MatchLdfld(nameof(BloomRenderer.Base)))) + { + cursor.EmitDelegate(SimplifyBloomBase); } - while (ilCursor.TryGotoNext( - MoveType.After, - ins => ins.OpCode == OpCodes.Ldarg_0, - ins => ins.MatchLdfld("Strength") - )) { - ilCursor.EmitDelegate>(IsSimplifiedBloomStrength); + cursor.Index = 0; + while (cursor.TryGotoNext(MoveType.After, + ins => ins.MatchLdarg0(), + ins => ins.MatchLdfld(nameof(BloomRenderer.Strength)))) + { + cursor.EmitDelegate(SimplifyBloomStrength); } - } - - private static float IsSimplifiedBloomBase(float bloomValue) { - return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedBloomBase.HasValue - ? TasSettings.SimplifiedBloomBase.Value / 10f - : bloomValue; - } - private static float IsSimplifiedBloomStrength(float bloomValue) { - return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedBloomStrength.HasValue - ? TasSettings.SimplifiedBloomStrength.Value / 10f - : bloomValue; + static float SimplifyBloomBase(float bloomValue) { + return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedBloomBase.HasValue + ? TasSettings.SimplifiedBloomBase.Value / 10f + : bloomValue; + } + static float SimplifyBloomStrength(float bloomValue) { + return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedBloomStrength.HasValue + ? TasSettings.SimplifiedBloomStrength.Value / 10f + : bloomValue; + } } private static void Decal_Render(On.Celeste.Decal.orig_Render orig, Decal self) { @@ -421,14 +415,14 @@ private static void Decal_Render(On.Celeste.Decal.orig_Render orig, Decal self) } private static void DistortOnRender(ILContext il) { - ILCursor ilCursor = new(il); - if (ilCursor.TryGotoNext(MoveType.After, i => i.MatchLdsfld(typeof(GFX), "FxDistort"))) { - ilCursor.EmitDelegate>(IsSimplifiedDistort); + var cursor = new ILCursor(il); + if (cursor.TryGotoNext(MoveType.After, i => i.MatchLdsfld(typeof(GFX), nameof(GFX.FxDistort)))) { + cursor.EmitDelegate(SimplifyDistort); } - } - private static Effect IsSimplifiedDistort(Effect effect) { - return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedDistort ? null : effect; + static Effect SimplifyDistort(Effect effect) { + return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedDistort ? null : effect; + } } private static void SolidTilesOnCtor(On.Celeste.SolidTiles.orig_ctor orig, SolidTiles self, Vector2 position, VirtualMap data) { @@ -454,31 +448,38 @@ private static char AutotilerOnGetTile(On.Celeste.Autotiler.orig_GetTile orig, A } } - private static void ModTileGlitcher(ILCursor ilCursor, ILContext ilContext) { - if (ilCursor.TryGotoNext(ins => ins.OpCode == OpCodes.Callvirt && ins.Operand.ToString().Contains("Monocle.MTexture>::set_Item"))) { - if (ilCursor.TryFindPrev(out var cursors, ins => ins.OpCode == OpCodes.Ldarg_0, - ins => ins.OpCode == OpCodes.Ldfld && ins.Operand.ToString().Contains(""), - ins => ins.OpCode == OpCodes.Ldarg_0, ins => ins.OpCode == OpCodes.Ldfld, - ins => ins.OpCode == OpCodes.Ldarg_0, ins => ins.OpCode == OpCodes.Ldfld - )) { - for (int i = 0; i < 6; i++) { - ilCursor.Emit(cursors[0].Next.OpCode, cursors[0].Next.Operand); - cursors[0].Index++; - } + private static void ModTileGlitcher(ILCursor cursor, ILContext il) { + if (!cursor.TryGotoNext(ins => ins.MatchCallvirt("set_Item"))) { + return; + } - ilCursor.EmitDelegate, int, int, MTexture>>(IgnoreNewTileTexture); - } + // Try to find the instructions for 'fgTexes[x, y]' + // These 3 ldarg.0 / ldfld combos seem to be unique enough for this + if (!cursor.TryFindPrev(out var cursors, + ins => ins.MatchLdarg0(), ins => ins.OpCode == OpCodes.Ldfld, + ins => ins.MatchLdarg0(), ins => ins.OpCode == OpCodes.Ldfld, + ins => ins.MatchLdarg0(), ins => ins.OpCode == OpCodes.Ldfld)) + { + return; } - } - private static MTexture IgnoreNewTileTexture(MTexture newTexture, VirtualMap fgTiles, int x, int y) { - if (TasSettings.SimplifiedGraphics && TasSettings.SimplifiedSolidTilesStyle != default) { - if (fgTiles[x, y] is { } texture && newTexture != null) { - return texture; - } + // Repeat the instructions + for (int i = 0; i < 6; i++) { + cursor.Emit(cursors[0].Next!.OpCode, cursors[0].Next.Operand); + cursors[0].Index++; } - return newTexture; + cursor.EmitDelegate(IgnoreNewTileTexture); + + static MTexture IgnoreNewTileTexture(MTexture newTexture, VirtualMap fgTexes, int x, int y) { + if (TasSettings.SimplifiedGraphics && TasSettings.SimplifiedSolidTilesStyle != default) { + if (fgTexes[x, y] is { } texture && newTexture != null) { + return texture; + } + } + + return newTexture; + } } private static void BackgroundTilesOnRender(On.Monocle.Entity.orig_Render orig, Entity self) { @@ -490,33 +491,37 @@ private static void BackgroundTilesOnRender(On.Monocle.Entity.orig_Render orig, } private static void BackdropRenderer_Render(ILContext il) { - ILCursor c = new(il); - - Instruction methodStart = c.Next; - c.EmitDelegate(IsNotSimplifiedBackdrop); - c.Emit(OpCodes.Brtrue, methodStart); - c.Emit(OpCodes.Ret); - if (c.TryGotoNext(ins => ins.MatchLdloc(out int _), ins => ins.MatchLdfld("Visible"))) { - Instruction ldloc = c.Next; - c.Index += 2; - c.Emit(ldloc.OpCode, ldloc.Operand).EmitDelegate(IsShow9DBlackBackdrop); - } - } - - private static bool IsNotSimplifiedBackdrop() { - return !TasSettings.SimplifiedGraphics || !TasSettings.SimplifiedBackdrop; - } + var cursor = new ILCursor(il); + var start = cursor.MarkLabel(); + cursor.MoveBeforeLabels(); + + cursor.EmitDelegate(IsNotSimplifiedBackdrop); + cursor.EmitBrtrue(start); + cursor.EmitRet(); + + int backdropIdx = -1; + if (cursor.TryGotoNext(MoveType.After, + ins => ins.MatchLdloc(out backdropIdx), + ins => ins.MatchLdfld(nameof(Backdrop.Visible)))) + { + cursor.EmitLdloc(backdropIdx); + cursor.EmitDelegate(IsShow9DBlackBackdrop); + } + + static bool IsNotSimplifiedBackdrop() { + return !TasSettings.SimplifiedGraphics || !TasSettings.SimplifiedBackdrop; + } + static bool IsShow9DBlackBackdrop(bool visible, Backdrop backdrop) { + if (TasSettings.Enabled && TasSettings.Mod9DLighting && backdrop.Visible && Engine.Scene is Level level) { + bool hideBackdrop = + backdrop.Name?.StartsWith("bgs/nameguysdsides") == true && + (level.Session.Level.StartsWith("g") || level.Session.Level.StartsWith("h")) && + level.Session.Level != "hh-08"; + return !hideBackdrop; + } - private static bool IsShow9DBlackBackdrop(bool visible, Backdrop backdrop) { - if (TasSettings.Enabled && TasSettings.Mod9DLighting && backdrop.Visible && Engine.Scene is Level level) { - bool hideBackdrop = - backdrop.Name?.StartsWith("bgs/nameguysdsides") == true && - (level.Session.Level.StartsWith("g") || level.Session.Level.StartsWith("h")) && - level.Session.Level != "hh-08"; - return !hideBackdrop; + return visible; } - - return visible; } private static void CrystalStaticSpinner_CreateSprites(On.Celeste.CrystalStaticSpinner.orig_CreateSprites orig, CrystalStaticSpinner self) { @@ -528,21 +533,27 @@ private static void CrystalStaticSpinner_CreateSprites(On.Celeste.CrystalStaticS } private static void CrystalStaticSpinnerOnGetHue(ILContext il) { - ILCursor ilCursor = new(il); - if (ilCursor.TryGotoNext(MoveType.After, ins => ins.MatchCall(typeof(Calc), "HsvToColor"))) { - ilCursor.EmitDelegate>(IsSimplifiedSpinnerColor); + var cursor = new ILCursor(il); + if (cursor.TryGotoNext(MoveType.After, ins => ins.MatchCall(typeof(Calc), nameof(Calc.HsvToColor)))) { + cursor.EmitDelegate(SimplifySpinnerColor); + } + + static Color SimplifySpinnerColor(Color color) { + return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedSpinnerColor.Name == CrystalColor.Rainbow ? Color.White : color; } } - private static Color IsSimplifiedSpinnerColor(Color color) { - return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedSpinnerColor.Name == CrystalColor.Rainbow ? Color.White : color; + private static void SetCustomSpinnerColor(object self) { + if (TasSettings.SimplifiedGraphics && TasSettings.SimplifiedSpinnerColor.Value != null) { + self.SetFieldValue("Tint", TasSettings.SimplifiedSpinnerColor.Color); + } } private static DustStyles.DustStyle DustStyles_Get_Session(On.Celeste.DustStyles.orig_Get_Session orig, Session session) { if (TasSettings.SimplifiedGraphics && TasSettings.SimplifiedDustSpriteEdge) { Color color = Color.Transparent; return new DustStyles.DustStyle { - EdgeColors = new[] {color.ToVector3(), color.ToVector3(), color.ToVector3()}, + EdgeColors = [color.ToVector3(), color.ToVector3(), color.ToVector3()], EyeColor = color, EyeTextures = "danger/dustcreature/eyes" }; @@ -579,34 +590,38 @@ private static void CustomMoonCreatureAdded(Entity customMoonCreature) { } private static void LightningRenderer_RenderIL(ILContext il) { - ILCursor c = new(il); - if (c.TryGotoNext(i => i.MatchLdfld("Visible"))) { - Instruction lightningIns = c.Prev; - c.Index++; - c.Emit(lightningIns.OpCode, lightningIns.Operand).EmitDelegate>(IsSimplifiedLightning); + var cursor = new ILCursor(il); + + int lightningIdx = -1; + if (cursor.TryGotoNext(MoveType.After, + ins => ins.MatchLdloc(out lightningIdx), + ins => ins.MatchLdfld(nameof(Entity.Visible)))) + { + cursor.EmitLdloc(lightningIdx); + cursor.EmitDelegate(SimplifyLightning); } - if (c.TryGotoNext( - MoveType.After, + if (cursor.TryGotoNext(MoveType.After, ins => ins.OpCode == OpCodes.Ldarg_0, - ins => ins.MatchLdfld("DrawEdges") - )) { - c.EmitDelegate>(drawEdges => (!TasSettings.SimplifiedGraphics || !TasSettings.SimplifiedWavedEdge) && drawEdges); + ins => ins.MatchLdfld(nameof(LightningRenderer.DrawEdges)))) + { + cursor.EmitDelegate(DrawEdges); } - } - private static bool IsSimplifiedLightning(bool visible, Lightning item) { - if (TasSettings.SimplifiedGraphics && TasSettings.SimplifiedWavedEdge) { - Rectangle rectangle = new((int) item.X + 1, (int) item.Y + 1, (int) item.Width, (int) item.Height); - Draw.SpriteBatch.Draw(GameplayBuffers.Lightning, item.Position + Vector2.One, rectangle, Color.Yellow); - if (visible) { - Draw.HollowRect(rectangle, Color.LightGoldenrodYellow); + static bool SimplifyLightning(bool visible, Lightning lightning) { + if (TasSettings.SimplifiedGraphics && TasSettings.SimplifiedWavedEdge) { + Rectangle rectangle = new((int) lightning.X + 1, (int) lightning.Y + 1, (int) lightning.Width, (int) lightning.Height); + Draw.SpriteBatch.Draw(GameplayBuffers.Lightning, lightning.Position + Vector2.One, rectangle, Color.Yellow); + if (visible) { + Draw.HollowRect(rectangle, Color.LightGoldenrodYellow); + } + + return false; } - return false; + return visible; } - - return visible; + static bool DrawEdges(bool orig) => (!TasSettings.SimplifiedGraphics || !TasSettings.SimplifiedWavedEdge) && orig; } private static void BoltOnRender(On.Celeste.LightningRenderer.Bolt.orig_Render orig, object self) { @@ -654,69 +669,41 @@ private static void SpikesOnAdded(On.Celeste.Spikes.orig_Added orig, Spikes self orig(self, scene); } - private static void ModCustomSpinnerColor(ILContext il) { - ILCursor ilCursor = new(il); - if (ilCursor.TryGotoNext(MoveType.After, - i => i.OpCode == OpCodes.Ldarg_0, - i => i.OpCode == OpCodes.Ldarg_S && i.Operand.ToString() == "tint" - )) { - ilCursor.EmitDelegate>(GetSimplifiedSpinnerColor); - } - } - - private static string GetSimplifiedSpinnerColor(string color) { - return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedSpinnerColor.Value != null - ? TasSettings.SimplifiedSpinnerColor.Value - : color; - } + private static bool IsSimplifiedSpinnerColorNotNull() => TasSettings.SimplifiedGraphics && TasSettings.SimplifiedSpinnerColor.Value != null; + private static Color GetSimplifiedSpinnerColor() => TasSettings.SimplifiedSpinnerColor.Color; - private static void ModRainbowSpinnerColor(ILCursor ilCursor, ILContext ilContext) { - Instruction start = ilCursor.Next; - ilCursor.EmitDelegate>(IsSimplifiedSpinnerColorNotNull); - ilCursor.Emit(OpCodes.Brfalse, start); - ilCursor.EmitDelegate>(GetSimplifiedSpinnerColor); - ilCursor.Emit(OpCodes.Ret); - } + private static void ModVivCustomSpinnerColor(ILCursor cursor, ILContext il) { + var start = cursor.MarkLabel(); + cursor.MoveBeforeLabels(); - private static bool IsSimplifiedSpinnerColorNotNull() { - return TasSettings.SimplifiedGraphics && TasSettings.SimplifiedSpinnerColor.Value != null; - } - - private static Color GetSimplifiedSpinnerColor() { - return TasSettings.SimplifiedSpinnerColor.Color; - } - - private static void ModVivCustomSpinnerColor(ILContext il) { - ILCursor ilCursor = new(il); - Instruction start = ilCursor.Next; - ilCursor.EmitDelegate>(IsSimplifiedSpinnerColorNotNull); - ilCursor.Emit(OpCodes.Brfalse, start); + cursor.EmitDelegate(IsSimplifiedSpinnerColorNotNull); + cursor.EmitBrfalse(start); Type type = ModUtils.GetType("VivHelper", "VivHelper.Entities.CustomSpinner"); if (type.GetFieldInfo("color") is { } colorField) { - ilCursor.Emit(OpCodes.Ldarg_0).EmitDelegate>(GetSimplifiedSpinnerColor); - ilCursor.Emit(OpCodes.Stfld, colorField); + cursor.EmitLdarg0(); + cursor.EmitDelegate(GetSimplifiedSpinnerColor); + cursor.EmitStfld(colorField); } if (type.GetFieldInfo("borderColor") is { } borderColorField) { - ilCursor.Emit(OpCodes.Ldarg_0).EmitDelegate>(GetTransparentColor); - ilCursor.Emit(OpCodes.Stfld, borderColorField); + cursor.EmitLdarg0(); + cursor.EmitDelegate(GetTransparentColor); + cursor.EmitStfld(borderColorField); } } - private static Color GetTransparentColor() { - return Color.Transparent; - } + private static Color GetTransparentColor() => Color.Transparent; // ReSharper disable FieldCanBeMadeReadOnly.Global public record struct SpinnerColor { - public static readonly List All = new() { + public static readonly SpinnerColor[] All = [ new SpinnerColor((CrystalColor) (-1), null), new SpinnerColor(CrystalColor.Rainbow, "#FFFFFF"), new SpinnerColor(CrystalColor.Blue, "#639BFF"), new SpinnerColor(CrystalColor.Red, "#FF4F4F"), new SpinnerColor(CrystalColor.Purple, "#FF4FEF"), - }; + ]; public CrystalColor Name; public string Value; @@ -735,7 +722,7 @@ public override string ToString() { } public record struct SolidTilesStyle(string Name, char Value) { - public static readonly List All = new() { + public static readonly SolidTilesStyle[] All = [ default, new SolidTilesStyle("Dirt", '1'), new SolidTilesStyle("Snow", '3'), @@ -760,8 +747,8 @@ public record struct SolidTilesStyle(string Name, char Value) { new SolidTilesStyle("Deadgrass", 'l'), new SolidTilesStyle("Lost Levels", 'm'), new SolidTilesStyle("Scifi", 'n'), - new SolidTilesStyle("Template", 'z') - }; + new SolidTilesStyle("Template", 'z'), + ]; public string Name = Name; public char Value = Value; @@ -774,9 +761,7 @@ public override string ToString() { // ReSharper restore FieldCanBeMadeReadOnly.Global } -internal class RemoveSelfComponent : Component { - public RemoveSelfComponent() : base(true, false) { } - +internal class RemoveSelfComponent() : Component(active: true, visible: false) { public override void Added(Entity entity) { base.Added(entity); entity.Visible = false; diff --git a/CelesteTAS-EverestInterop/Source/EverestInterop/StudioHelper.cs b/CelesteTAS-EverestInterop/Source/EverestInterop/StudioHelper.cs index 83e645f0d..231921fa3 100644 --- a/CelesteTAS-EverestInterop/Source/EverestInterop/StudioHelper.cs +++ b/CelesteTAS-EverestInterop/Source/EverestInterop/StudioHelper.cs @@ -18,8 +18,6 @@ namespace TAS.EverestInterop; -#nullable enable - public static class StudioHelper { #region Auto-filled values @@ -50,6 +48,7 @@ public static class StudioHelper { private static string TempStudioInstallDirectory => Path.Combine(StudioDirectory, ".temp_install"); private static string VersionFile => Path.Combine(StudioDirectory, ".version"); private static string DownloadPath => Path.Combine(StudioDirectory, FileName); + private static string InnerArchivePath => Path.Combine(StudioDirectory, ".InnerArchive.zip"); private static string DownloadURL { get { @@ -230,42 +229,17 @@ private static async Task DownloadStudio() { bool skipDownload = false; if (File.Exists(DownloadPath)) { - await using (var fs = File.OpenRead(DownloadPath)) { - string hash = BitConverter.ToString(await md5.ComputeHashAsync(fs)).Replace("-", ""); - if (Checksum.Equals(hash, StringComparison.OrdinalIgnoreCase)) { - skipDownload = true; - } else { - $"Checksum for {FileName} doesn't match. Expected {Checksum}, found {hash}".Log(LogLevel.Verbose); - } - } - - if (!skipDownload) { - // Try handling double ZIPs caused by GitHub actions - if (DoubleZipArchive) { - string innerPath; - using (var zip = ZipFile.OpenRead(DownloadPath)) { - var entry = zip.Entries[0]; // There should only be a single entry in this case - innerPath = Path.Combine(StudioDirectory, entry.Name); - $"Extracting inner ZIP archive: '{entry.Name}'".Log(LogLevel.Verbose); - - entry.ExtractToFile(innerPath); - } - - File.Move(innerPath, DownloadPath, overwrite: true); - } - - await using var fs = File.OpenRead(DownloadPath); - string hash = BitConverter.ToString(await md5.ComputeHashAsync(fs)).Replace("-", ""); - if (Checksum.Equals(hash, StringComparison.OrdinalIgnoreCase)) { - skipDownload = true; - } else { - $"Checksum for inner archive of {FileName} doesn't match. Expected {Checksum}, found {hash}".Log(LogLevel.Verbose); - } + await using var fs = File.OpenRead(DownloadPath); + string hash = BitConverter.ToString(await md5.ComputeHashAsync(fs)).Replace("-", ""); + if (Checksum.Equals(hash, StringComparison.OrdinalIgnoreCase)) { + skipDownload = true; + } else { + $"Checksum for {FileName} doesn't match. Expected {Checksum}, found {hash}".Log(LogLevel.Verbose); } } if (!skipDownload) { - // Existing archive doesn't match at all + // Existing archive doesn't match if (File.Exists(DownloadPath)) { File.Delete(DownloadPath); } @@ -297,16 +271,18 @@ private static async Task DownloadStudio() { // Handle double ZIPs caused by GitHub actions if (DoubleZipArchive) { - string innerPath; + if (File.Exists(InnerArchivePath)) { + File.Delete(InnerArchivePath); + } + using (var zip = ZipFile.OpenRead(DownloadPath)) { var entry = zip.Entries[0]; // There should only be a single entry in this case - innerPath = Path.Combine(StudioDirectory, entry.Name); $"Extracting inner ZIP archive: '{entry.Name}'".Log(LogLevel.Verbose); - entry.ExtractToFile(innerPath); + entry.ExtractToFile(InnerArchivePath); } - File.Move(innerPath, DownloadPath, overwrite: true); + File.Move(InnerArchivePath, DownloadPath, overwrite: true); } } @@ -476,6 +452,7 @@ internal static void LaunchStudio() => Task.Run(async () => { private static void ReportError(string error, string? additionalInfo = null) { error.Log(LogLevel.Error); + additionalInfo?.Log(LogLevel.Error); if (!Directory.Exists(StudioDirectory)) { Directory.CreateDirectory(StudioDirectory); diff --git a/CelesteTAS-EverestInterop/Source/Gameplay/BetterInvincible.cs b/CelesteTAS-EverestInterop/Source/Gameplay/BetterInvincible.cs new file mode 100644 index 000000000..45f447020 --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/Gameplay/BetterInvincible.cs @@ -0,0 +1,36 @@ +using MonoMod.Cil; +using Celeste; +using Celeste.Mod; +using TAS.Module; +using TAS.Utils; + +namespace TAS.Gameplay; + +/// Custom invisibility setting which prevents dying, but doesn't alter any other gameplay (like bouncing of the bottom of the screen) +/// It is only active while a TAS is running and not persistant between runs +internal static class BetterInvincible { + // Manually store state, so Assists.Invincible isn't altered + public static bool Invincible = false; + + [Initialize] + private static void Initialize() { + typeof(Player).GetMethod("orig_Die")!.IlHook(il => { + var cursor = new ILCursor(il); + if (cursor.TryGotoNext(MoveType.After, ins => ins.MatchLdfld(nameof(Assists.Invincible)))) { + cursor.EmitDelegate(ModifyInvincible); + } else { + $"Failed to apply {nameof(BetterInvincible)} hook!".Log(LogLevel.Error); + } + }); + return; + + static bool ModifyInvincible(bool origValue) { + return origValue || (Manager.Running && Invincible && TasSettings.BetterInvincible); + } + } + + [EnableRun] + private static void EnableRun() { + Invincible = false; // Reset back to default + } +} diff --git a/CelesteTAS-EverestInterop/Source/Gameplay/Hitboxes/ActualCollideHitbox.cs b/CelesteTAS-EverestInterop/Source/Gameplay/Hitboxes/ActualCollideHitbox.cs new file mode 100644 index 000000000..fd141520a --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/Gameplay/Hitboxes/ActualCollideHitbox.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Celeste; +using Celeste.Mod; +using JetBrains.Annotations; +using Microsoft.Xna.Framework; +using Mono.Cecil.Cil; +using Monocle; +using MonoMod.Cil; +using StudioCommunication; +using TAS.ModInterop; +using TAS.Module; +using TAS.Utils; + +namespace TAS.Gameplay.Hitboxes; + +/// Stores and displays the state of entity colliders, during the player update instead of at the end of the frame +public static class ActualCollideHitbox { + private static readonly Color ActualPlayerHitboxColor = Color.Red.Invert(); + private static readonly Color ActualPlayerHurtboxColor = Color.Lime.Invert(); + + private static readonly Dictionary LastPositions = new(); + private static readonly Dictionary LastCollidables = new(); + + /// Special cases for entity which check additional variables, other than entity.Collidable + private static readonly Dictionary> CollidableHandlers = new(); + + /// Actual-collide hitboxes are disabled, while they aren't used + [PublicAPI] + public static bool Disabled => !TasSettings.ShowHitboxes || TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Off || Manager.FastForwarding; + + private static bool playerUpdated; + private static bool colliderListRendering; + + /// Checks if the entity is collidable, accounting for special cases for some specific entities + [PublicAPI] + public static bool IsCollidable(this Entity entity) { + var entityType = entity.GetType(); + if (CollidableHandlers.TryGetValue(entityType, out var sameClassHandler)) { + return sameClassHandler(entity); + } + if (CollidableHandlers.FirstOrNull(entry => entityType.IsSameOrSubclassOf(entry.Key)) is { } subClassHandler) { + return subClassHandler.Value(entity); + } + + return entity.Collidable; + } + + /// Resets any stored collider data + [PublicAPI] + public static void Clear() { + playerUpdated = false; + LastPositions.Clear(); + LastCollidables.Clear(); + } + + /// Returns the position of the entity, while the Player updated + [PublicAPI] + public static Vector2? LoadActualCollidePosition(this Entity entity) { + return LastPositions.TryGetValue(entity, out Vector2 result) ? result : null; + } + /// Returns the actual collidability of the entity, while the Player updated + [PublicAPI] + public static bool? LoadActualCollidable(this Entity entity) { + return LastCollidables.TryGetValue(entity, out bool result) ? result : null; + } + + /// Stores the state of the entity's collider as it was during Player.Update + [PublicAPI] + public static void StoreActualColliderState(Entity? entity) { + // If a PlayerCollider is checked multiple times, only use the first one + if (entity == null || playerUpdated || Disabled) { + return; + } + + LastPositions[entity] = entity.Position; + LastCollidables[entity] = entity.IsCollidable(); + } + + [Initialize] + private static void Initialize() { + if (ModUtils.GetType("SpirialisHelper", "Celeste.Mod.Spirialis.TimeController")?.GetMethodInfo("CustomELUpdate") is { } customELUpdate) { + customELUpdate.IlHook((cursor, _) => cursor.EmitDelegate(Clear)); + } + + CollidableHandlers.Clear(); + CollidableHandlers.Add(typeof(Lightning), e => e.Collidable && !((Lightning)e).disappearing); + if (ModUtils.GetType("ChronoHelper", "Celeste.Mod.ChronoHelper.Entities.DarkLightning") is { } chronoLightningType) { + // Not a subclass of Lightning + var disappearingField = chronoLightningType.GetFieldInfo("disappearing"); + CollidableHandlers.Add(chronoLightningType, e => { + if (!e.Collidable) { + return false; + } + if (disappearingField.GetValue(e) is bool b) { + return !b; + } + return true; + }); + } + if (ModUtils.GetType("Glyph", "Celeste.Mod.AcidHelper.Entities.AcidLightning") is { } acidLightningType) { + // Subclass of Lightning, but has it's own "toggleOffset" and "disappearing" + var disappearingField = acidLightningType.GetFieldInfo("disappearing"); + CollidableHandlers.Add(acidLightningType, e => { + if (!e.Collidable) { + return false; + } + if (disappearingField.GetValue(e) is bool b) { + return !b; + } + return true; + }); + } + } + + [Load] + private static void Load() { + typeof(Player).GetMethod("orig_Update")!.IlHook(IL_Player_origUpdate); + + On.Celeste.Player.Update += On_Player_Update; + On.Monocle.EntityList.Update += On_EntityList_Update; + On.Celeste.Player.DebugRender += On_Player_DebugRender; + On.Monocle.Hitbox.Render += On_Hitbox_Render; + On.Monocle.Circle.Render += On_Circle_Render; + On.Monocle.ColliderList.Render += On_ColliderList_Render; + On.Celeste.Level.End += On_Level_End; + } + + [Unload] + private static void Unload() { + On.Celeste.Player.Update -= On_Player_Update; + On.Monocle.EntityList.Update -= On_EntityList_Update; + On.Celeste.Player.DebugRender -= On_Player_DebugRender; + On.Monocle.Hitbox.Render -= On_Hitbox_Render; + On.Monocle.Circle.Render -= On_Circle_Render; + On.Monocle.ColliderList.Render -= On_ColliderList_Render; + On.Celeste.Level.End -= On_Level_End; + } + + private static void IL_Player_origUpdate(ILContext il) { + var cursor = new ILCursor(il); + + // Store player + if (cursor.TryGotoNext(MoveType.After, ins => ins.MatchCallvirt(typeof(Tracker).GetMethodInfo(nameof(Tracker.GetComponents)).MakeGenericMethod(typeof(PlayerCollider))))) { + cursor.Emit(OpCodes.Ldarg_0); + cursor.EmitDelegate(StoreActualColliderState); + } else { + "Failed to apply patch for storing player state during Update for actual-collide-hitboxes".Log(LogLevel.Warn); + } + + // Store entities with PlayerCollider + if (cursor.TryGotoNext(MoveType.After, ins => ins.MatchCastclass())) { + cursor.EmitDup(); + cursor.EmitCall(typeof(Component).GetPropertyInfo(nameof(Component.Entity)).GetMethod!); + cursor.EmitDelegate(StoreActualColliderState); + } else { + "Failed to apply patch for storing entity state during Update for actual-collide-hitboxes".Log(LogLevel.Warn); + } + } + + [PublicAPI] + [Obsolete("Use StoreActualColliderState instead")] + public static void StoreCollider(Entity? entity) => StoreActualColliderState(entity); + + private static void On_Player_Update(On.Celeste.Player.orig_Update orig, Player self) { + orig(self); + playerUpdated = true; + } + + private static void On_EntityList_Update(On.Monocle.EntityList.orig_Update orig, EntityList self) { + Clear(); // Prepare for this Update() chain + orig(self); + } + + private static void On_Player_DebugRender(On.Celeste.Player.orig_DebugRender orig, Player player, Camera camera) { + if (Disabled + || player.Scene is Level { Transitioning: true } + || player.LoadActualCollidePosition() is not { } actualCollidePosition + || player.Position == actualCollidePosition + ) { + orig(player, camera); + return; + } + + if (TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Override) { + DrawActualPlayerHitbox(player, actualCollidePosition); + return; + } + + orig(player, camera); + if (TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Append) { + DrawActualPlayerHitbox(player, actualCollidePosition); + } + } + + private static void On_Hitbox_Render(On.Monocle.Hitbox.orig_Render orig, Hitbox self, Camera camera, Color color) { + DrawActualCollider(self, color, hitboxColor => orig(self, camera, hitboxColor)); + } + private static void On_Circle_Render(On.Monocle.Circle.orig_Render orig, Circle self, Camera camera, Color color) { + DrawActualCollider(self, color, hitboxColor => orig(self, camera, hitboxColor)); + } + private static void On_ColliderList_Render(On.Monocle.ColliderList.orig_Render orig, ColliderList self, Camera camera, Color color) { + colliderListRendering = true; // Prevent child components from rendering themselves + DrawActualCollider(self, color, hitboxColor => orig(self, camera, hitboxColor)); + colliderListRendering = false; + } + + private static void On_Level_End(On.Celeste.Level.orig_End orig, Level self) { + Clear(); + orig(self); + } + + private static void DrawActualCollider(Collider self, Color color, Action invokeOrig) { + var entity = self.Entity; + + if (entity == null || entity is Player || Disabled + || colliderListRendering && self is not ColliderList + || entity.LoadActualCollidePosition() is not { } actualCollidePosition + || entity.Position == actualCollidePosition + ) { + invokeOrig(color); + return; + } + + var actualColliderColor = + TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Append + ? color.Invert() + : color; + + if (TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Append) { + invokeOrig(color); // Render original + } + + var currentPosition = entity.Position; + + // If the entity has a PlayerCollider with a custom hitbox, only show the actual hitbox for those and not the main Collider + var playerColliders = entity.Components.GetAll().ToArray(); + if (playerColliders.All(playerCollider => playerCollider.Collider != null)) { + if (playerColliders.Any(playerCollider => playerCollider.Collider == self)) { + entity.Position = actualCollidePosition; + invokeOrig(actualColliderColor); + entity.Position = currentPosition; + } else { + invokeOrig(color); + } + } else { + entity.Position = actualCollidePosition; + invokeOrig(actualColliderColor); + entity.Position = currentPosition; + } + } + + private static void DrawActualPlayerHitbox(Player player, Vector2 hitboxPosition) { + var origPosition = player.Position; + var origCollider = player.Collider; + + player.Position = hitboxPosition; + Draw.HollowRect(origCollider, TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Append ? ActualPlayerHitboxColor : Color.Red); + + player.Collider = player.hurtbox; + Draw.HollowRect(player.hurtbox, TasSettings.ShowActualCollideHitboxes == ActualCollideHitboxType.Append ? ActualPlayerHurtboxColor : Color.Lime); + + player.Collider = origCollider; + player.Position = origPosition; + } +} diff --git a/CelesteTAS-EverestInterop/Source/Gameplay/Hitboxes/TriggerHitbox.cs b/CelesteTAS-EverestInterop/Source/Gameplay/Hitboxes/TriggerHitbox.cs new file mode 100644 index 000000000..1e4b45c3f --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/Gameplay/Hitboxes/TriggerHitbox.cs @@ -0,0 +1,218 @@ +using Celeste; +using Celeste.Mod.Entities; +using Microsoft.Xna.Framework; +using Monocle; +using System; +using System.Collections.Generic; +using System.Linq; +using TAS.EverestInterop.Hitboxes; +using TAS.ModInterop; +using TAS.Module; +using TAS.Utils; + +namespace TAS.Gameplay.Hitboxes; + +/// Manages the hitbox rendering of Triggers (and trigger-like entities) +/// Hides unimportant hitboxes when "Simplified Hitboxes" is enabled +internal static class TriggerHitbox { + + /// List of various checks to check if a trigger is unimportant + private static readonly List> triggerChecks = []; + + // Cache triggers to avoid checking all conditions for each trigger each frame + private static readonly HashSet currentUnimportantTriggers = []; + + public static bool ShouldHideHitbox(Entity entity) { + return !TasSettings.ShowTriggerHitboxes && entity is Trigger + || TasSettings.SimplifiedHitboxes && !TasSettings.ShowCameraHitboxes && cameraTriggers.Contains(entity.GetType()) + || TasSettings.SimplifiedHitboxes && currentUnimportantTriggers.Contains(entity); + } + + public static Color GetHitboxColor(Entity entity) { + if (entity is ChangeRespawnTrigger) { + return HitboxColor.RespawnTriggerColor; + } + if (cameraTriggers.Contains(entity.GetType())) { + return HitboxColor.CameraTriggerColor; + } + + return TasSettings.TriggerHitboxColor; + } + + public static void RecacheTriggers(Scene scene) { + currentUnimportantTriggers.Clear(); + currentUnimportantTriggers.AddRange(scene.Entities.Where(CheckUnimportant)); + } + + private static bool CheckUnimportant(Entity entity) { + var entityType = entity.GetType(); + return triggerChecks.Any(check => check(entity, entityType)); + } + + [Load] + private static void Load() { + On.Monocle.EntityList.UpdateLists += On_EntityList_UpdateLists; + On.Monocle.Engine.OnSceneTransition += On_Engine_OnSceneTransition; + } + [Unload] + private static void Unload() { + On.Monocle.EntityList.UpdateLists -= On_EntityList_UpdateLists; + On.Monocle.Engine.OnSceneTransition -= On_Engine_OnSceneTransition; + } + + private static void On_EntityList_UpdateLists(On.Monocle.EntityList.orig_UpdateLists orig, EntityList self) { + if (TasSettings.SimplifiedHitboxes) { + currentUnimportantTriggers.RemoveWhere(entity => self.toRemove.Contains(entity)); + currentUnimportantTriggers.AddRange(self.toAdd.Where(CheckUnimportant)); + } + + orig(self); + } + private static void On_Engine_OnSceneTransition(On.Monocle.Engine.orig_OnSceneTransition orig, Engine self, Scene from, Scene to) { + if (TasSettings.SimplifiedHitboxes) { + RecacheTriggers(to); + } + + orig(self, from, to); + } + + // Types for unimportant triggers + private static readonly HashSet vanillaTriggers = [ + typeof(BirdPathTrigger), + typeof(BlackholeStrengthTrigger), + typeof(AmbienceParamTrigger), + typeof(MoonGlitchBackgroundTrigger), + typeof(BloomFadeTrigger), + typeof(LightFadeTrigger), + typeof(AltMusicTrigger), + typeof(MusicTrigger), + typeof(MusicFadeTrigger), + // Following types aren't _technically_ a Trigger, but are still included + typeof(SpawnFacingTrigger), + ]; + private static readonly HashSet everestTriggers = [ + typeof(AmbienceTrigger), + typeof(AmbienceVolumeTrigger), + typeof(CustomBirdTutorialTrigger), + typeof(MusicLayerTrigger), + ]; + private static readonly HashSet cameraTriggers = [ + typeof(CameraOffsetTrigger), + typeof(CameraTargetTrigger), + typeof(CameraAdvanceTargetTrigger), + typeof(SmoothCameraOffsetTrigger) + ]; + private static readonly HashSet moddedTriggers = []; + + [Initialize] + private static void Initialize() { + triggerChecks.Add((_, entityType) => vanillaTriggers.Contains(entityType)); + triggerChecks.Add((_, entityType) => everestTriggers.Contains(entityType)); + + // ExtendedVariants triggers might be unimportant, depending on the variant + if (ExtendedVariantsInterop.GetVariantsEnum() is not null) { + IEnumerable unimportantVariantNames = [ + "RoomLighting", + "RoomBloom", + "GlitchEffect", + "ColorGrading", + "ScreenShakeIntensity", + "AnxietyEffect", + "BlurLevel", + "ZoomLevel", + "BackgroundBrightness", + "DisableMadelineSpotlight", + "ForegroundEffectOpacity", + "MadelineIsSilhouette", + "DashTrailAllTheTime", + "FriendlyBadelineFollower", + "MadelineHasPonytail", + "MadelineBackpackMode", + "BackgroundBlurLevel", + "AlwaysInvisible", + "DisplaySpeedometer", + "DisableKeysSpotlight", + "SpinnerColor", + "InvisibleMotion", + "PlayAsBadeline", + ]; + var unimportantVariants = unimportantVariantNames + .Select(name => ExtendedVariantsInterop.ParseVariant(name)()) + .Where(variant => variant != null); + + ModUtils.GetTypes("ExtendedVariantMode", + "ExtendedVariants.Entities.Legacy.ExtendedVariantTrigger", + "ExtendedVariants.Entities.Legacy.ExtendedVariantFadeTrigger", + "ExtendedVariants.Entities.ForMappers.FloatExtendedVariantFadeTrigger" + ).ForEach(type => { + triggerChecks.Add((entity, entityType) => + entityType == type + && entity.GetFieldValue("variantChange") is { } variantChange + && unimportantVariants.Contains(variantChange) + ); + }); + + if (ModUtils.GetType("ExtendedVariantMode", "ExtendedVariants.Entities.ForMappers.AbstractExtendedVariantTrigger`1") is { } abstractExtendedVariantTriggerType) { + triggerChecks.Add((entity, entityType) => + entityType.BaseType is { } type + && type.IsGenericType + && type.GetGenericTypeDefinition() == abstractExtendedVariantTriggerType + && entity.GetFieldValue("variantChange") is { } variantChange + && unimportantVariants.Contains(variantChange) + ); + } + } + + // Gather camera triggers to recolor them + AddCameraTypes("ContortHelper", "ContortHelper.PatchedCameraAdvanceTargetTrigger", "ContortHelper.PatchedCameraOffsetTrigger", "ContortHelper.PatchedCameraTargetTrigger", "ContortHelper.PatchedSmoothCameraOffsetTrigger"); + AddCameraTypes("FrostHelper", "FrostHelper.EasedCameraZoomTrigger"); + AddCameraTypes("FurryHelper", "Celeste.Mod.FurryHelper.MomentumCameraOffsetTrigger"); + AddCameraTypes("HonlyHelper", "Celeste.Mod.HonlyHelper.CameraTargetCornerTrigger", "Celeste.Mod.HonlyHelper.CameraTargetCrossfadeTrigger"); + AddCameraTypes("MaxHelpingHand", "Celeste.Mod.MaxHelpingHand.Triggers.CameraCatchupSpeedTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.CameraOffsetBorder", "Celeste.Mod.MaxHelpingHand.Triggers.OneWayCameraTrigger"); + AddCameraTypes("Sardine7", "Celeste.Mod.Sardine7.Triggers.SmoothieCameraTargetTrigger"); + AddCameraTypes("VivHelper", "VivHelper.Triggers.InstantLockingCameraTrigger", "VivHelper.Triggers.MultiflagCameraTargetTrigger"); + AddCameraTypes("XaphanHelper", "Celeste.Mod.XaphanHelper.Triggers.CameraBlocker"); + + // See https://maddie480.ovh/celeste/custom-entity-catalog for reference on existing Triggers + // To reduce work, only mods with >= 5 dependencies are included + // Last update: 2023-12-21, 426 triggers + + AddModdedTypes("AurorasHelper", "Celeste.Mod.AurorasHelper.ResetMusicTrigger", "Celeste.Mod.AurorasHelper.PlayAudioTrigger", "Celeste.Mod.AurorasHelper.ShowSubtitlesTrigger"); + AddModdedTypes("AvBdayHelper2021", "Celeste.Mod.AvBdayHelper.Code.Triggers.ScreenShakeTrigger"); + AddModdedTypes("CherryHelper", "Celeste.Mod.CherryHelper.AudioPlayTrigger"); + AddModdedTypes("ColoredLights", "ColoredLights.FlashlightColorTrigger"); + AddModdedTypes("CommunalHelper", "Celeste.Mod.CommunalHelper.Triggers.AddVisualToPlayerTrigger", "Celeste.Mod.CommunalHelper.Triggers.CassetteMusicFadeTrigger", "Celeste.Mod.CommunalHelper.Triggers.CloudscapeColorTransitionTrigger", "Celeste.Mod.CommunalHelper.Triggers.CloudscapeLightningConfigurationTrigger", "Celeste.Mod.CommunalHelper.Triggers.MusicParamTrigger", "Celeste.Mod.CommunalHelper.Triggers.SoundAreaTrigger", "Celeste.Mod.CommunalHelper.Triggers.StopLightningControllerTrigger"); + AddModdedTypes("ContortHelper", "ContortHelper.AnxietyEffectTrigger", "ContortHelper.BloomRendererModifierTrigger", "ContortHelper.BurstEffectTrigger", "ContortHelper.BurstRemoverTrigger", "ContortHelper.ClearCustomEffectsTrigger", "ContortHelper.CustomConfettiTrigger", "ContortHelper.CustomEffectTrigger", "ContortHelper.EffectBooleanArrayParameterTrigger", "ContortHelper.EffectBooleanParameterTrigger", "ContortHelper.EffectColorParameterTrigger", "ContortHelper.EffectFloatArrayParameterTrigger", "ContortHelper.EffectFloatParameterTrigger", "ContortHelper.EffectIntegerArrayParameterTrigger", "ContortHelper.EffectIntegerParameterTrigger", "ContortHelper.EffectMatrixParameterTrigger", "ContortHelper.EffectQuaternionParameterTrigger", "ContortHelper.EffectStringParameterTrigger", "ContortHelper.EffectVector2ParameterTrigger", "ContortHelper.EffectVector3ParameterTrigger", "ContortHelper.EffectVector4ParameterTrigger", "ContortHelper.FlashTrigger", "ContortHelper.GlitchEffectTrigger", "ContortHelper.LightningStrikeTrigger", "ContortHelper.MadelineSpotlightModifierTrigger", "ContortHelper.RandomSoundTrigger", "ContortHelper.ReinstateParametersTrigger", "ContortHelper.RumbleTrigger", "ContortHelper.ScreenWipeModifierTrigger", "ContortHelper.ShakeTrigger", "ContortHelper.SpecificLightningStrikeTrigger"); + AddModdedTypes("CrystallineHelper", "vitmod.BloomStrengthTrigger", "Celeste.Mod.Code.Entities.RoomNameTrigger"); + AddModdedTypes("CustomPoints", "Celeste.Mod.CustomPoints.PointsTrigger"); + AddModdedTypes("DJMapHelper", "Celeste.Mod.DJMapHelper.Triggers.ChangeSpinnerColorTrigger", "Celeste.Mod.DJMapHelper.Triggers.ColorGradeTrigger"); + AddModdedTypes("FactoryHelper", "FactoryHelper.Triggers.SteamWallColorTrigger"); + AddModdedTypes("FemtoHelper", "ParticleRemoteEmit"); + AddModdedTypes("FlaglinesAndSuch", "FlaglinesAndSuch.FlagLightFade", "FlaglinesAndSuch.MusicIfFlag"); + AddModdedTypes("FrostHelper", "FrostHelper.AnxietyTrigger", "FrostHelper.BloomColorFadeTrigger", "FrostHelper.BloomColorPulseTrigger", "FrostHelper.BloomColorTrigger", "FrostHelper.DoorDisableTrigger", "FrostHelper.LightningColorTrigger", "FrostHelper.RainbowBloomTrigger", "FrostHelper.StylegroundMoveTrigger", "FrostHelper.Triggers.StylegroundBlendStateTrigger", "FrostHelper.Triggers.LightingBaseColorTrigger"); + AddModdedTypes("JungleHelper", "Celeste.Mod.JungleHelper.Triggers.GeckoTutorialTrigger", "Celeste.Mod.JungleHelper.Triggers.UIImageTrigger", "Celeste.Mod.JungleHelper.Triggers.UITextTrigger"); + AddModdedTypes("Long Name Helper by Helen, Helen's Helper, hELPER", "Celeste.Mod.hELPER.ColourChangeTrigger", "Celeste.Mod.hELPER.SpriteReplaceTrigger"); + AddModdedTypes("MaxHelpingHand", "Celeste.Mod.MaxHelpingHand.Triggers.AllBlackholesStrengthTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.FloatFadeTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.ColorGradeFadeTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.GradientDustTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.MadelinePonytailTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.MadelineSilhouetteTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.PersistentMusicFadeTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.RainbowSpinnerColorFadeTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.RainbowSpinnerColorTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.SetBloomBaseTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.SetBloomStrengthTrigger", "Celeste.Mod.MaxHelpingHand.Triggers.SetDarknessAlphaTrigger"); + AddModdedTypes("MoreDasheline", "MoreDasheline.HairColorTrigger"); + AddModdedTypes("Sardine7", "Celeste.Mod.Sardine7.Triggers.AmbienceTrigger"); + AddModdedTypes("ShroomHelper", "Celeste.Mod.ShroomHelper.Triggers.GradualChangeColorGradeTrigger", "Celeste.Mod.ShroomHelper.Triggers.MultilayerMusicFadeTrigger"); + AddModdedTypes("SkinModHelper", "SkinModHelper.SkinSwapTrigger"); + AddModdedTypes("SkinModHelperPlus", "Celeste.Mod.SkinModHelper.EntityReskinTrigger", "Celeste.Mod.SkinModHelper.SkinSwapTrigger"); + AddModdedTypes("VivHelper", "VivHelper.Triggers.ActivateCPP", "VivHelper.Triggers.ConfettiTrigger", "VivHelper.Triggers.FlameLightSwitch", "VivHelper.Triggers.FlameTravelTrigger", "VivHelper.Triggers.FollowerDistanceModifierTrigger", "VivHelper.Triggers.RefillCancelParticleTrigger", "VivHelper.Triggers.SpriteEntityActor"); + AddModdedTypes("XaphanHelper", "Celeste.Mod.XaphanHelper.Triggers.FlagMusicFadeTrigger", "Celeste.Mod.XaphanHelper.Triggers.MultiLightFadeTrigger", "Celeste.Mod.XaphanHelper.Triggers.MultiMusicTrigger"); + AddModdedTypes("YetAnotherHelper", "Celeste.Mod.YetAnotherHelper.Triggers.LightningStrikeTrigger", "Celeste.Mod.YetAnotherHelper.Triggers.RemoveLightSourcesTrigger"); + + // Following types aren't _technically_ a Trigger, but are still included + AddModdedTypes("StyleMaskHelper", "Celeste.Mod.StyleMaskHelper.Entities.Mask"); + AddModdedTypes("StrawberryJam2021", "Celeste.Mod.StrawberryJam2021.StylegroundMasks.Mask"); + + triggerChecks.Add((_, entityType) => moddedTriggers.Contains(entityType)); + + static void AddModdedTypes(string modName, params string[] fullTypeNames) { + cameraTriggers.AddRange(ModUtils.GetTypes(modName, fullTypeNames)); + } + static void AddCameraTypes(string modName, params string[] fullTypeNames) { + moddedTriggers.AddRange(ModUtils.GetTypes(modName, fullTypeNames)); + } + } +} diff --git a/CelesteTAS-EverestInterop/Source/GlobalUsings.cs b/CelesteTAS-EverestInterop/Source/GlobalUsings.cs index 5b645070b..c2afac912 100644 --- a/CelesteTAS-EverestInterop/Source/GlobalUsings.cs +++ b/CelesteTAS-EverestInterop/Source/GlobalUsings.cs @@ -1,4 +1,5 @@ global using static TAS.GlobalVariables; +global using MonocleCommand = Monocle.Command; using TAS.Entities; using TAS.Input; using TAS.Module; @@ -10,6 +11,11 @@ public static class GlobalVariables { public static bool ParsingCommand => Command.Parsing; public static void AbortTas(string message, bool log = false, float duration = 2f) { +#if DEBUG + // Always log in debug builds + log = true; +#endif + if (log) { Toast.ShowAndLog(message, duration); } else { diff --git a/CelesteTAS-EverestInterop/Source/InfoHUD/InfoCustom.cs b/CelesteTAS-EverestInterop/Source/InfoHUD/InfoCustom.cs new file mode 100644 index 000000000..5c22d96d6 --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/InfoHUD/InfoCustom.cs @@ -0,0 +1,377 @@ +using Microsoft.Xna.Framework; +using Monocle; +using StudioCommunication; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using TAS.EverestInterop; +using TAS.EverestInterop.Lua; +using TAS.Utils; +using StringExtensions = StudioCommunication.Util.StringExtensions; + +namespace TAS.InfoHUD; + +/// Handles parsing of custom Info HUD templates +public static class InfoCustom { + private static readonly Regex TargetQueryRegex = new(@"\{(.*?)\}", RegexOptions.Compiled); + private static readonly Regex TableRegex = new(@"\|\|(.*?)\|\|", RegexOptions.Compiled); + private static readonly Regex LuaRegex = new(@"\[\[(.+?)\]\]", RegexOptions.Compiled); + + /// Should return true if the value was successfully formatted, otherwise false + private delegate bool ValueFormatter(object? value, int decimals, out string formattedValue); + + // Applies custom formatting to the result of a target-query + private static readonly Dictionary CustomFormatters = new() { + { ".toFrame()", Formatter_toFrame }, + { ".toPixelPerFrame()", Formatter_toPixelPerFrame }, + }; + + /// Returns the parsed info for the current template + public static string GetInfo(int? decimals = null) { + return string.Join('\n', ParseTemplate(StringExtensions.SplitLines(TasSettings.InfoCustomTemplate), decimals ?? TasSettings.CustomInfoDecimals)); + } + + #region Parsing + + /// Parses lines of a custom Info HUD template into actual values for the current frame + public static IEnumerable ParseTemplate(IEnumerable template, int decimals) { + return template.SelectMany(line => ParseTemplateLine(line, decimals)); + } + /// Parses a single line of a custom Info HUD template into actual values for the current frame + public static IEnumerable ParseTemplateLine(string templateLine, int decimals) { + /* Replace single results inline and can format an aligned list for multiple results + * Example: + * + * Template: + * JumpThruPos: ||{JumpThru.Position=} | {JumpThru.X=} : {Player.X} # {Level.Wind=} ; {JumpThru.Y=}|| ABC={Session.SID} + * Glider Data: X={Glider.X} Y={Glider.Y} + * + * Single Entity: + * JumpThruPos: JumpThru.Position=3608.00, -2040.00 | JumpThru.X=3608 : 3872.00 # Level-Wind=400 ; JumpThru.Y=-2040.00 + * Glider Data: X=3872.00 Y=2080.00 + * + * Multiple Entities: + * JumpThruPos: # Level.Wind=400 + * [9b:0] JumpThru.Position=3608.00, -2040.00 | JumpThru.X=3608.00 ; JumpThru.Y=-2040.00 + * [9b:0] JumpThru.Position=36082.00, -2040.00 | JumpThru.X=36082.00 ; JumpThru.Y=-2040.00 + * [1:0] 3872.00 + * Glider Data: X={[9c:3] 3872.00, [9c:5] 8273.00} Y={[9c:3] 2080.00, [9c:5] 802.00} + */ + + // A table is a mapping of result types -> it's query results (including prefix) + List>> tables = []; + + // Find tables (and remove them to avoid duplicate results) + templateLine = TableRegex.Replace(templateLine, tableMatch => { + string tableQuery = tableMatch.Groups[1].Value; + Dictionary> tableResults = []; + + Type? firstResultType = null; + bool hideTypes = true; // Don't show types if all result are of the same type + + Match? lastMatch = null; + foreach (Match match in TargetQueryRegex.Matches(tableQuery)) { + string prefixText; // Text before target-queries + if (lastMatch == null) { + prefixText = tableQuery[..match.Index]; + } else { + prefixText = tableQuery[(lastMatch.Index + lastMatch.Length)..match.Index]; + } + lastMatch = match; + + string query = match.Groups[1].Value; + + // Find query prefix + string queryPrefix; + if (query[^1] == ':') { + queryPrefix = $"{query} "; // query: value + query = query[..^1]; + } else if (query[^1] == '=') { + queryPrefix = query; // query=value + query = query[..^1]; + } else { + queryPrefix = ""; + } + + // Find custom formatter + ValueFormatter? formatter = null; + foreach ((string name, var customFormatter) in CustomFormatters) { + if (query.EndsWith(name)) { + query = query[..^name.Length]; + formatter = customFormatter; + } + } + + (var queryResults, bool success, string errorMessage) = TargetQuery.GetMemberValues(query); + if (!success) { + tableResults.AddToKey("Error", $"{prefixText}{queryPrefix}<{errorMessage}>"); + continue; + } + + foreach ((object? value, object? baseInstance) in queryResults) { + var currResultType = baseInstance?.GetType(); + firstResultType ??= currResultType; + + if (firstResultType != currResultType) { + hideTypes = false; + } + + if (formatter == null || !formatter(value, decimals, out string valueStr)) { + valueStr = DefaultFormatter(value, decimals); + } + + string key = currResultType?.Name ?? ""; + string result = $"{queryPrefix}{valueStr}"; + + if (baseInstance is Entity entity && entity.GetEntityData()?.ToEntityId() is { } entityId) { + key += $"[{entityId}]"; + } + + if (tableResults.TryGetValue(key, out var results)) { + results.Add(prefixText + result); + } else { + tableResults[key] = [prefixText.TrimStart() +result]; + } + } + } + + if (hideTypes && firstResultType != null) { + tableResults = tableResults.ToDictionary(entry => entry.Key[firstResultType.Name.Length..], entry => entry.Value); + } + + tables.Add(tableResults); + + return string.Empty; + }); + + // Find main queries + string mainResult = TargetQueryRegex.Replace(templateLine, match => { + string query = match.Groups[1].Value; + + // Find query prefix + string queryPrefix; + if (query[^1] == ':') { + queryPrefix = $"{query} "; // query: value + query = query[..^1]; + } else if (query[^1] == '=') { + queryPrefix = query; // query=value + query = query[..^1]; + } else { + queryPrefix = ""; + } + + // Find custom formatter + ValueFormatter? formatter = null; + foreach ((string name, var customFormatter) in CustomFormatters) { + if (query.EndsWith(name)) { + query = query[..^name.Length]; + formatter = customFormatter; + } + } + + (var queryResults, bool success, string errorMessage) = TargetQuery.GetMemberValues(query); + if (!success) { + return $"{queryPrefix}<{errorMessage}>"; + } + + if (queryResults.Count == 0) { + return ""; + } + if (queryResults.Count == 1) { + if (formatter == null || !formatter(queryResults[0].Value, decimals, out string valueStr)) { + valueStr = DefaultFormatter(queryResults[0].Value, decimals); + } + + return $"{queryPrefix}{valueStr}"; + } + + var resultCollection = new StringBuilder("{ "); + bool firstValue = true; + foreach ((object? value, object? baseInstance) in queryResults) { + if (!firstValue) { + resultCollection.Append(", "); + } + firstValue = false; + + if (formatter == null || !formatter(value, decimals, out string valueStr)) { + valueStr = DefaultFormatter(value, decimals); + } + + if (baseInstance is Entity entity && entity.GetEntityData()?.ToEntityId() is { } entityId) { + resultCollection.Append($"[{entityId}] {queryPrefix}{valueStr}"); + } else { + resultCollection.Append($"{queryPrefix}{valueStr}"); + } + } + resultCollection.Append(" }"); + + return resultCollection.ToString(); + }); + + // Evaluate Lua code for main line + yield return LuaRegex.Replace(mainResult, match => { + if (TargetQuery.EnforceLegal) { + return ""; + } + + string code = match.Groups[1].Value; + object?[]? objects = EvalLuaCommand.EvalLuaImpl(code); + return objects == null ? "null" : string.Join(", ", objects.Select(o => o?.ToString() ?? "null")); + }); + + // Format tables + foreach (var table in tables) { + var lines = table.ToDictionary( + entry => entry.Key, + entry => new StringBuilder($" {entry.Key} ")); + + if (lines.Count == 0) { + continue; + } + + int resultIdx = 0; + bool allDone = false; + + while (!allDone) { + // Align all lines + int maxLength = lines + .Select(entry => entry.Value.Length) + .Aggregate(Math.Max); + foreach (var (_, line) in lines) { + line.Append(' ', maxLength - line.Length); + } + + // Append next parameter + allDone = true; + foreach ((string key, var results) in table) { + if (resultIdx >= results.Count) { + continue; + } + + lines[key].Append(results[resultIdx]); + allDone = false; + } + + resultIdx++; + } + + foreach (var (_, line) in lines) { + yield return line.ToString(); + } + } + } + + #endregion + #region Formatting + + /// Formats a value in seconds into frames + private static bool Formatter_toFrame(object? value, int _, out string formattedValue) { + if (value is float floatValue) { + formattedValue = GameInfo.ConvertToFrames(floatValue).ToString(); + return true; + } + + formattedValue = ""; + return false; + } + /// Formats a value in px/s into px/f + private static bool Formatter_toPixelPerFrame(object? value, int decimals, out string formattedValue) { + if (value is float floatValue) { + formattedValue = GameInfo.ConvertSpeedUnit(floatValue, SpeedUnit.PixelPerFrame).ToFormattedString(decimals); + return true; + } + if (value is Vector2 vectorValue) { + formattedValue = GameInfo.ConvertSpeedUnit(vectorValue, SpeedUnit.PixelPerFrame).ToSimpleString(decimals); + return true; + } + + formattedValue = ""; + return false; + } + + /// Fallback for when no specific formatter is applicable + private static string DefaultFormatter(object? obj, int decimals) { + switch (obj) { + case string stringValue: + return stringValue; + case Vector2 vectorValue: + return vectorValue.ToSimpleString(decimals); + case Vector2Double vectorValue: + return vectorValue.ToSimpleString(decimals); + case float floatValue: + return floatValue.ToFormattedString(decimals); + case Scene sceneValue: + return sceneValue.ToString() ?? "null"; + case Entity entity: + string id = entity.GetEntityData()?.ToEntityId().ToString() is { } value ? $"[{value}]" : ""; + return $"{entity}{id}"; + case Collider collider: + return ColliderToString(collider); + case IEnumerable enumerable: + bool compressed = enumerable is IEnumerable or IEnumerable; + return IEnumerableToString(enumerable, ", ", compressed); + + default: + return obj?.ToString() ?? "null"; + } + } + + /// Formats items of the IEnumerable, optionally compressing same values + private static string IEnumerableToString(IEnumerable enumerable, string separator, bool compressed) { + var builder = new StringBuilder(); + + if (!compressed) { + foreach (object value in enumerable) { + if (builder.Length > 0) { + builder.Append(separator); + } + + builder.Append(value); + } + + return builder.ToString(); + } + + var valueOccurrences = new Dictionary(); + foreach (object value in enumerable) { + string str = value.ToString() ?? "null"; + if (!valueOccurrences.TryAdd(str, 1)) { + valueOccurrences[str]++; + } + } + + foreach ((string key, int occurrences) in valueOccurrences) { + if (builder.Length > 0) { + builder.Append(separator); + } + + if (occurrences == 1) { + builder.Append(key); + } else { + builder.Append($"{key} * {occurrences}"); + } + } + + return builder.ToString(); + } + + /// Formats a collider with its important values + private static string ColliderToString(Collider collider, int iterationHeight = 1) { + return collider switch { + Hitbox hitbox => $"Hitbox=[{hitbox.Left},{hitbox.Right}]×[{hitbox.Top},{hitbox.Bottom}]", + Circle circle => circle.Position == Vector2.Zero + ? $"Circle=[Radius={circle.Radius}]" + : $"Circle=[Radius={circle.Radius},Offset={circle.Position}]", + ColliderList list => iterationHeight > 0 + ? "ColliderList: { " + string.Join("; ", list.colliders.Select(s => ColliderToString(s, iterationHeight - 1))) + " }" + : "ColliderList: { ... }", + + _ => collider.ToString() ?? "null" + }; + } + + #endregion +} diff --git a/CelesteTAS-EverestInterop/Source/InfoHUD/InfoWatchEntity.cs b/CelesteTAS-EverestInterop/Source/InfoHUD/InfoWatchEntity.cs new file mode 100644 index 000000000..97c66643f --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/InfoHUD/InfoWatchEntity.cs @@ -0,0 +1,428 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Celeste; +using JetBrains.Annotations; +using Microsoft.Xna.Framework; +using Monocle; +using StudioCommunication; +using TAS.EverestInterop; +using TAS.EverestInterop.Hitboxes; +using TAS.EverestInterop.InfoHUD; +using TAS.ModInterop; +using TAS.Module; +using TAS.Utils; + +namespace TAS.InfoHUD; + + +/// Displays information about specific entities inside the Info HUD +public static class InfoWatchEntity { + private readonly record struct MemberKey(Type Type, bool DeclaredOnly); + + private record UniqueEntityId { + public readonly EntityID EntityId; + public readonly bool GlobalOrPersistent; + public readonly Type Type; + + public UniqueEntityId(Entity entity, EntityData entityData) { + Type = entity.GetType(); + GlobalOrPersistent = entity.TagCheck(Tags.Global) || entity.TagCheck(Tags.Persistent) || entity.Get() != null; + EntityId = entityData.ToEntityId(); + } + } + + /// Called when an entity has been added to the watch list + [PublicAPI] + public static event Action? StartWatching; + + /// Called when an entity has been removed from the watch list + [PublicAPI] + public static event Action? StopWatching; + + /// Called when the watch list has been cleared + [PublicAPI] + public static event Action? ClearWatching; + + private static readonly Dictionary> CachedMemberInfos = new(); + private static readonly WeakReference LastClickedEntity = new(null); + + // Store the entities which should be watched in the current level + // Fallback to using weak references when a unique ID is not available + private static AreaKey currentAreaKey; + internal static List WatchedEntities = []; + internal static List WatchedEntities_Save = []; // Used for save-states + + private static readonly HashSet WatchedEntityIds = []; + + /// Entities which are actively watched for the current frame + internal static readonly HashSet CurrentlyWatchedEntities = []; + + [PublicAPI] + public static bool IsWatching(Entity entity) { + return CurrentlyWatchedEntities.Contains(entity) || (entity.GetEntityData() is EntityData entityData && WatchedEntityIds.Contains(new UniqueEntityId(entity, entityData))); + } + + internal static void CheckMouseButtons() { + if (MouseButtons.Right.Pressed) { + ClearWatchEntities(); + } + + if (MouseButtons.Left.Pressed && !MouseOverHud() && FindClickedEntity() is { } entity) { + AddOrRemoveWatching(entity); + PrintAllSimpleValues(entity); + } + } + + private static bool MouseOverHud() { + var hudRect = new Rectangle( + (int) TasSettings.InfoPosition.X, (int) TasSettings.InfoPosition.Y, + (int) InfoHud.Size.X, (int) InfoHud.Size.Y); + + return hudRect.Contains((int) MouseButtons.Position.X, (int) MouseButtons.Position.Y); + } + + /// Resolves the entity, which the mouse is currently over + internal static Entity? FindClickedEntity() { + var clickedEntities = FindEntitiesAt(MouseButtons.Position) + // Sort triggers after entities + .Sort((a, b) => (a is Trigger ? 1 : -1) - (b is Trigger ? 1 : -1)) + .ToArray(); + + Entity? clickedEntity; + if (LastClickedEntity.TryGetTarget(out var lastClicked) && Array.IndexOf(clickedEntities, lastClicked) is var index and >= 0) { + // Cycle through when clicking multiple times + clickedEntity = clickedEntities[(index + 1) % clickedEntities.Length]; + } else { + clickedEntity = clickedEntities.FirstOrDefault(); + } + + LastClickedEntity.SetTarget(clickedEntity); + return clickedEntity; + } + + /// Resolves all entities which overlay with the screen position + private static IEnumerable FindEntitiesAt(Vector2 screenPosition) { + if (Engine.Scene is not Level level) { + yield break; + } + + var worldPosition = level.MouseToWorld(screenPosition); + foreach (var entity in level.Entities.Where(e => !IgnoreEntity(e))) { + if (entity.Collider == null) { + // Attempt to reconstruct collider from entity data + if (entity.GetEntityData() is { } data) { + entity.Collider = new Hitbox(data.Width, data.Height); + + if (entity.CollidePoint(worldPosition)) { + yield return entity; + } + + entity.Collider = null; + } + + continue; + } + + if (entity.CollidePoint(worldPosition)) { + yield return entity; + } + } + + yield break; + + static bool IgnoreEntity(Entity entity) { + return entity.GetType() == typeof(Entity) + || entity is ParticleSystem + || TasHelperInterop.GetUnimportantTriggers().Contains(entity); + } + } + + private static void AddOrRemoveWatching(Entity clickedEntity) { + currentAreaKey = clickedEntity.SceneAs().Session.Area; + + if (clickedEntity.GetEntityData() is { } entityData) { + var uniqueId = new UniqueEntityId(clickedEntity, entityData); + if (!WatchedEntityIds.Add(uniqueId)) { + StopWatching?.Invoke(clickedEntity); + WatchedEntityIds.Remove(uniqueId); + } else { + StartWatching?.Invoke(clickedEntity); + } + } else { + if (WatchedEntities.FirstOrDefault(reference => reference.Target == clickedEntity) is { } alreadyAdded) { + StopWatching?.Invoke(clickedEntity); + WatchedEntities.Remove(alreadyAdded); + } else { + WatchedEntities.Add(new WeakReference(clickedEntity)); + StartWatching?.Invoke(clickedEntity); + } + } + + GameInfo.Update(); + } + + internal static void ClearWatchEntities() { + LastClickedEntity.SetTarget(null); + WatchedEntities.Clear(); + WatchedEntities_Save.Clear(); + WatchedEntityIds.Clear(); + CurrentlyWatchedEntities.Clear(); + GameInfo.Update(); + + ClearWatching?.Invoke(); + } + + internal static string GetInfo(WatchEntityType watchEntityType, string separator = "\n", bool alwaysUpdate = false, int? decimals = null) { + CurrentlyWatchedEntities.Clear(); + if (Engine.Scene is not Level level || !TasSettings.WatchEntity && !alwaysUpdate) { + return string.Empty; + } + + decimals ??= TasSettings.CustomInfoDecimals; + + var allEntities = + WatchedEntities + .Where(reference => reference.IsAlive) + .Select(reference => (Entity) reference.Target!) + .Concat(ResolveEntityIds(level).Values); + + return string.Join(separator, + allEntities + .Select(entity => { + CurrentlyWatchedEntities.Add(entity); + return GetEntityValues(entity, watchEntityType, separator, decimals.Value); + })); + } + + [PublicAPI] + public static bool ForceUpdateInfo = false; // for TasHelper.AutoWatchEntity + + internal static void UpdateInfo(string separator = "\n", int? decimals = null) { + CurrentlyWatchedEntities.Clear(); + GameInfo.HudWatchingInfo = string.Empty; + GameInfo.StudioWatchingInfo = string.Empty; + if (Engine.Scene is not Level level || !TasSettings.WatchEntity && !ForceUpdateInfo) { + return; + } + + decimals ??= TasSettings.CustomInfoDecimals; + + var allEntities = + WatchedEntities + .Where(reference => reference.IsAlive) + .Select(reference => (Entity)reference.Target!) + .Concat(ResolveEntityIds(level).Values); + + CurrentlyWatchedEntities.AddRange(allEntities); + + if (!TasSettings.HudWatchEntity) { + GameInfo.HudWatchingInfo = string.Empty; + } else { + GameInfo.HudWatchingInfo = string.Join(separator, + allEntities + .Select(entity => GetEntityValues(entity, TasSettings.InfoWatchEntityHudType, separator, decimals.Value)) + ); + } + + if (!TasSettings.StudioWatchEntity || !Communication.CommunicationWrapper.Connected) { + GameInfo.StudioWatchingInfo = string.Empty; + } else if (TasSettings.InfoWatchEntityStudioType == TasSettings.InfoWatchEntityHudType) { + GameInfo.StudioWatchingInfo = GameInfo.HudWatchingInfo; + } else { + GameInfo.StudioWatchingInfo = string.Join(separator, + allEntities + .Select(entity => GetEntityValues(entity, TasSettings.InfoWatchEntityStudioType, separator, decimals.Value)) + ); + } + } + + [Load] + private static void Load() { + On.Monocle.EntityList.DebugRender += On_EntityList_DebugRender; + On.Celeste.Level.Begin += On_Level_Begin; + On.Celeste.Level.End += On_Level_End; + On.Celeste.Level.LoadLevel += On_Level_LoadLevel; + } + + [Unload] + private static void Unload() { + On.Monocle.EntityList.DebugRender -= On_EntityList_DebugRender; + On.Celeste.Level.Begin -= On_Level_Begin; + On.Celeste.Level.End -= On_Level_End; + On.Celeste.Level.LoadLevel -= On_Level_LoadLevel; + } + + private static void On_EntityList_DebugRender(On.Monocle.EntityList.orig_DebugRender orig, EntityList self, Camera camera) { + orig(self, camera); + + if (!TasSettings.ShowHitboxes) { + return; + } + + // Highlight currently watched entities + foreach (var entity in self) { + if (CurrentlyWatchedEntities.Contains(entity)) { + Draw.Point(entity.Position, HitboxColor.EntityColorInversely); + } + } + } + + private static void On_Level_Begin(On.Celeste.Level.orig_Begin orig, Level self) { + orig(self); + + if (self.Session.Area != currentAreaKey) { + // Entity IDs are only unique per area, so need to clear them + ClearWatchEntities(); + } + } + private static void On_Level_End(On.Celeste.Level.orig_End orig, Level self) { + orig(self); + CurrentlyWatchedEntities.Clear(); + } + private static void On_Level_LoadLevel(On.Celeste.Level.orig_LoadLevel orig, Level self, Player.IntroTypes playerIntro, bool isFromLoader) { + orig(self, playerIntro, isFromLoader); + + // Clean-up + WatchedEntities.RemoveAll(reference => !reference.IsAlive || reference.Target is Entity { Scene: null }); + } + + private static void PrintAllSimpleValues(Entity entity) { + ("Info of Clicked Entity:\n" + GetEntityValues(entity, WatchEntityType.All)).Log(string.Empty, TasSettings.InfoWatchEntityLogToConsole); + } + + /// Formats all member values into a multi-line string + private static string GetEntityValues(Entity entity, WatchEntityType watchEntityType, string separator = "\n", int decimals = 2) { + if (watchEntityType == WatchEntityType.None) { + return ""; + } + + var entityType = entity.GetType(); + + string entityId = ""; + if (entity.GetEntityData() is { } entityData) { + entityId = $"[{entityData.ToEntityId().ToString()}]"; + } + + string entityPrefix = $"{entityType.Name}{entityId}"; + string positionInfo = $"{entityPrefix}: {entity.ToSimplePositionString(decimals)}"; + + if (watchEntityType == WatchEntityType.Position) { + return positionInfo; + } + + List values = [positionInfo]; + values.AddRange(ResolveAllSimpleMembers(entityType, watchEntityType == WatchEntityType.DeclaredOnly).Select(info => { + object? value; + try { + value = info switch { + FieldInfo fieldInfo => fieldInfo.GetValue(entity), + PropertyInfo propertyInfo => propertyInfo.GetValue(entity), + _ => null + }; + } catch { + value = string.Empty; + } + + if (value is float floatValue) { + if (info.Name.EndsWith("Timer")) { + value = $"{GameInfo.ConvertToFrames(floatValue)}f ({floatValue.ToFormattedString(decimals)})" ; + } else { + value = floatValue.ToFormattedString(decimals); + } + } else if (value is Vector2 vector2) { + value = vector2.ToSimpleString(decimals); + } + + if (separator == "\t" && value != null) { + value = value.ToString()?.ReplaceLineBreak(" "); + } + + return $"{entityPrefix}.{info.Name}: {value}"; + })); + + return string.Join(separator, values); + } + + /// Resolves all members with a "simple" type (see ) + private static IEnumerable ResolveAllSimpleMembers(Type type, bool declaredOnly = false) { + var key = new MemberKey(type, declaredOnly); + + if (CachedMemberInfos.TryGetValue(key, out var result)) { + return result; + } + + CachedMemberInfos[key] = result = []; + + FieldInfo[] fields; + PropertyInfo[] properties; + if (declaredOnly) { + var bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; + fields = type.GetFields(bindingFlags); + properties = type.GetProperties(bindingFlags); + } else { + fields = type.GetAllFieldInfos().ToArray(); + properties = type.GetAllProperties().ToArray(); + } + + List memberInfos = []; + memberInfos.AddRange(fields.Where(info => info.FieldType.IsSimpleType() && !info.Name.EndsWith("k__BackingField"))); + memberInfos.AddRange(properties.Where(info => info.PropertyType.IsSimpleType())); + + foreach (var grouping in memberInfos.GroupBy(info => type == info.DeclaringType)) { + var infos = grouping + .Sort((infoA, infoB) => string.Compare(infoA.Name, infoB.Name, StringComparison.InvariantCultureIgnoreCase)); + + if (grouping.Key) { + // Place declared members at the top + result.InsertRange(0, infos); + } else { + result.AddRange(infos); + } + } + + return result; + } + + /// Tries to resolve all entity instances for the currently watched entity IDs + private static Dictionary ResolveEntityIds(Level level) { + Dictionary result = new(); + string currentRoom = level.Session.Level; + + var possibleTypes = WatchedEntityIds + .Where(id => id.GlobalOrPersistent || id.EntityId.Level == currentRoom) + .Select(id => id.Type) + .ToHashSet(); + + if (possibleTypes.IsEmpty()) { + return result; + } + + List possibleEntities = []; + if (possibleTypes.All(type => level.Tracker.Entities.ContainsKey(type))) { + foreach (var type in possibleTypes) { + possibleEntities.AddRange(level.Tracker.Entities[type]); + } + } else { + possibleEntities.AddRange(level.Entities.Where(entity => possibleTypes.Contains(entity.GetType()))); + } + + foreach (var entity in possibleEntities) { + if (entity.GetEntityData() is not { } entityData) { + continue; + } + + var uniqueId = new UniqueEntityId(entity, entityData); + if (!WatchedEntityIds.Contains(uniqueId) || !result.TryAdd(uniqueId, entity)) { + continue; + } + + if (result.Count == WatchedEntityIds.Count) { + return result; + } + } + + return result; + } +} diff --git a/CelesteTAS-EverestInterop/Source/InfoHUD/TargetQuery.cs b/CelesteTAS-EverestInterop/Source/InfoHUD/TargetQuery.cs new file mode 100644 index 000000000..d881a38de --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/InfoHUD/TargetQuery.cs @@ -0,0 +1,842 @@ +using Celeste; +using Celeste.Mod; +using JetBrains.Annotations; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using Monocle; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using TAS.Input.Commands; +using TAS.ModInterop; +using TAS.Module; +using TAS.Utils; + +namespace TAS.EverestInterop; + +/// Contains all the logic for getting data from a target-query +public static class TargetQuery { + /// Prevents invocations of methods / execution of Lua code in the Custom Info + public static bool EnforceLegal => EnforceLegalCommand.EnabledWhenRunning && !AssertCommand.Running; + + private static readonly Dictionary> allTypes = new(); + private static readonly Dictionary Types, List ComponentTypes, EntityID? EntityID)> baseTypeCache = []; + + /// Searches for the target type, optional target assembly, optional component type, optional component assembly, and optional EntityID + private static readonly Regex BaseTypeRegex = new(@"^([\w.]+)(@(?:[^.:\[\]\n]*))?(?::(\w+))?(@(?:[^.:\[\]\n]*))?(?:\[(.+):(\d+)\])?$", RegexOptions.Compiled); + + [Initialize] + private static void CollectAllTypes() { + allTypes.Clear(); + baseTypeCache.Clear(); + + foreach (var type in ModUtils.GetTypes()) { + if (type.FullName is { } fullName) { + string assemblyName = type.Assembly.GetName().Name!; + string modName = ConsoleEnhancements.GetModName(type); + + // Strip namespace + int namespaceLen = type.Namespace != null + ? type.Namespace.Length + 1 + : 0; + string shortName = type.FullName[namespaceLen..]; + + // Use '.' instead of '+' for nested types + fullName = fullName.Replace('+', '.'); + shortName = shortName.Replace('+', '.'); + + allTypes.AddToKey(fullName, type); + allTypes.AddToKey($"{fullName}@{assemblyName}", type); + allTypes.AddToKey($"{fullName}@{modName}", type); + + allTypes.AddToKey(shortName, type); + allTypes.AddToKey($"{shortName}@{assemblyName}", type); + allTypes.AddToKey($"{shortName}@{modName}", type); + } + } + } + + [MonocleCommand("get", "'get Type.fieldOrProperty' -> value | Example: 'get Player.Position', 'get Level.Wind' (CelesteTAS)"), UsedImplicitly] + private static void GetCommand(string? query) { + if (query == null) { + "No target-query specified".ConsoleLog(LogLevel.Error); + return; + } + + (var results, bool success, string errorMessage) = GetMemberValues(query); + if (!success) { + errorMessage.ConsoleLog(LogLevel.Error); + return; + } + + if (results.Count == 0) { + "No instances found".ConsoleLog(LogLevel.Error); + } else if (results.Count == 1) { + results[0].Value.ConsoleLog(); + } else { + foreach ((object? value, object? baseInstance) in results) { + if (baseInstance is Entity entity && + entity.GetEntityData()?.ToEntityId().ToString() is { } id) + { + $"[{id}] {value}".ConsoleLog(); + } else { + value.ConsoleLog(); + } + } + } + } + + /// Parses a target-query and returns the results for that + public static (List<(object? Value, object? BaseInstance)> Results, bool Success, string ErrorMessage) GetMemberValues(string query) { + string[] queryArgs = query.Split('.'); + + var baseTypes = ResolveBaseTypes(queryArgs, out string[] memberArgs, out var componentTypes, out var entityId); + if (baseTypes.IsEmpty()) { + return ([(null, null)], Success: false, ErrorMessage: $"Failed to find base type for target-query '{query}'"); + } + if (memberArgs.IsEmpty()) { + return ([(null, null)], Success: false, ErrorMessage: "No members specified"); + } + + List< (object? Value, object? BaseInstance)> allResults = []; + foreach (var baseType in baseTypes) { + var instances = ResolveTypeInstances(baseType, componentTypes, entityId); + + if (componentTypes.IsEmpty()) { + if (!ProcessType(baseType, out string errorMessage)) { + return (Results: [], Success: false, ErrorMessage: errorMessage); + } + } else { + foreach (var componentType in componentTypes) { + if (!ProcessType(componentType, out string errorMessage)) { + return (Results: [], Success: false, ErrorMessage: errorMessage); + } + } + } + + bool ProcessType(Type type, out string errorMessage) { + (var values, bool success, errorMessage) = ResolveMemberValues(type, instances, memberArgs); + if (!success) { + return false; + } + + if (!instances.IsEmpty()) { + allResults.AddRange(values.Select((value, i) => (value, (object?) instances[i]))); + } + + return true; + } + } + + return (allResults, Success: true, ErrorMessage: string.Empty); + } + + /// Parses the first part of a query into types and an optional EntityID + public static List ResolveBaseTypes(string[] queryArgs, out string[] memberArgs, out List componentTypes, out EntityID? entityId) { + componentTypes = []; + entityId = null; + + // Vanilla settings don't need a prefix + if (typeof(Settings).GetFields().FirstOrDefault(f => f.Name == queryArgs[0]) != null) { + memberArgs = queryArgs; + return [typeof(Settings)]; + } + if (typeof(SaveData).GetFields().FirstOrDefault(f => f.Name == queryArgs[0]) != null) { + memberArgs = queryArgs; + return [typeof(SaveData)]; + } + if (typeof(Assists).GetFields().FirstOrDefault(f => f.Name == queryArgs[0]) != null) { + memberArgs = queryArgs; + return [typeof(Assists)]; + } + + // Check for mod settings + if (Everest.Modules.FirstOrDefault(mod => mod.SettingsType != null && mod.Metadata.Name == queryArgs[0]) is { } module) { + memberArgs = queryArgs[1..]; + return [module.SettingsType]; + } + + // Greedily increase amount of tested arguments + string currentType = string.Empty; + int currentIndex = 0; + + for (int i = 1; i <= queryArgs.Length; i++) { + string typeName = string.Join('.', queryArgs[..i]); + + if (baseTypeCache.ContainsKey(typeName)) { + currentType = typeName; + currentIndex = i; + continue; + } + + var match = BaseTypeRegex.Match(typeName); + if (!match.Success) { + break; // No further matches + } + + // Remove the entity ID from the type check + string checkTypeName = $"{match.Groups[1].Value}{match.Groups[2].Value}"; + string componentTypeName = $"{match.Groups[3].Value}{match.Groups[4].Value}"; + + if (int.TryParse(match.Groups[6].Value, out int id)) { + entityId = new EntityID(match.Groups[5].Value, id); + } + + if (!allTypes.TryGetValue(checkTypeName, out var types)) { + break; // No further existing types + } + + if (!allTypes.TryGetValue(componentTypeName, out componentTypes!)) { + componentTypes = []; + } + + baseTypeCache[typeName] = (Types: types, ComponentTypes: componentTypes, EntityID: entityId); + currentType = typeName; + currentIndex = i; + } + + if (baseTypeCache.TryGetValue(currentType, out var pair)) { + componentTypes = pair.ComponentTypes; + entityId = pair.EntityID; + memberArgs = queryArgs[currentIndex..]; + return pair.Types; + } + + // No matching type found + memberArgs = queryArgs; + return []; + } + + /// Resolves a type into all applicable instances of it + public static List ResolveTypeInstances(Type type, List componentTypes, EntityID? entityId) { + if (type == typeof(Settings)) { + return [Settings.Instance]; + } + if (type == typeof(SaveData)) { + return [Settings.Instance]; + } + if (type == typeof(Assists)) { + return [Settings.Instance]; + } + + if (type.IsSameOrSubclassOf(typeof(EverestModuleSettings))) { + return Everest.Modules.FirstOrDefault(mod => mod.SettingsType == type) is { } module ? [module._Settings] : []; + } + + if (type.IsSameOrSubclassOf(typeof(Entity))) { + IEnumerable entityInstances; + if (Engine.Scene.Tracker.Entities.TryGetValue(type, out var entities)) { + entityInstances = entities + .Where(e => entityId == null || e.GetEntityData()?.ToEntityId().Key == entityId.Value.Key); + } else { + entityInstances = Engine.Scene.Entities + .Where(e => e.GetType().IsSameOrSubclassOf(type) && (entityId == null || e.GetEntityData()?.ToEntityId().Key == entityId.Value.Key)); + } + + if (componentTypes.IsEmpty()) { + return entityInstances + .Select(e => (object) e) + .ToList(); + } else { + return entityInstances + .SelectMany(e => e.Components.Where(c => componentTypes.Any(componentType => c.GetType().IsSameOrSubclassOf(componentType)))) + .Select(c => (object) c) + .ToList(); + } + } + + if (type.IsSameOrSubclassOf(typeof(Component))) { + IEnumerable componentInstances; + if (Engine.Scene.Tracker.Components.TryGetValue(type, out var components)) { + componentInstances = components; + } else { + componentInstances = Engine.Scene.Entities + .SelectMany(e => e.Components) + .Where(c => c.GetType().IsSameOrSubclassOf(type)); + } + + return componentInstances + .Select(c => (object) c) + .ToList(); + } + + if (Engine.Scene is Level level) { + if (type == typeof(Session)) { + return [level.Session]; + } + } + if (Engine.Scene.GetType() == type) { + return [Engine.Scene]; + } + + // Nothing found + return []; + } + + /// Recursively resolves the type of the specified members + public static (Type Type, bool Success) ResolveMemberType(Type baseType, string[] memberArgs) { + var typeStack = new Stack(); + + var currentType = baseType; + foreach (string member in memberArgs) { + typeStack.Push(currentType); + + if (currentType.GetFieldInfo(member) is { } field) { + currentType = field.FieldType; + continue; + } + if (currentType.GetPropertyInfo(member) is { } property && property.GetGetMethod() != null) { + currentType = property.PropertyType; + continue; + } + + // Unable to recurse further + return (currentType, Success: false); + } + + // Special case for Actor / Platform positions, since they use subpixels + if (memberArgs[^1] is nameof(Entity.X) or nameof(Entity.Y)) { + var entityType = typeof(Entity); + if (typeStack.Count >= 1) { + // "Entity.X" + entityType = typeStack.Pop(); + } else if (typeStack.Count >= 2 && memberArgs[^2] is nameof(Entity.Position)) { + // "Entity.Position.X" + _ = typeStack.Pop(); + entityType = typeStack.Pop(); + } + + if (entityType.IsSameOrSubclassOf(typeof(Actor)) || entityType.IsSameOrSubclassOf(typeof(Platform))) { + return (typeof(SubpixelComponent), Success: true); + } + } else if (memberArgs[^1] is nameof(Entity.Position)) { + // "Entity.Position" + var entityType = typeStack.Pop(); + + if (entityType.IsSameOrSubclassOf(typeof(Actor)) || entityType.IsSameOrSubclassOf(typeof(Platform))) { + return (typeof(SubpixelPosition), Success: true); + } + } + + return (currentType, Success: true); + } + + /// Recursively resolves a method for the specified members + public static (MethodInfo? Method, bool Success) ResolveMemberMethod(Type baseType, string[] memberArgs) { + var currentType = baseType; + for (int i = 0; i < memberArgs.Length - 1; i++) { + string member = memberArgs[i]; + + if (currentType.GetFieldInfo(member) is { } field) { + currentType = field.FieldType; + continue; + } + + if (currentType.GetPropertyInfo(member) is { } property && property.GetGetMethod() != null) { + currentType = property.PropertyType; + continue; + } + + // Unable to recurse further + return (null, Success: false); + } + + // Find method + if (currentType.GetMethodInfo(memberArgs[^1]) is { } method) { + return (method, Success: true); + } + + // Couldn't find the method + return (null, Success: true); + } + + /// Recursively resolves the value of the specified members + public static (object? Value, bool Success, string ErrorMessage) ResolveMemberValue(Type baseType, object? baseObject, string[] memberArgs) { + var currentType = baseType; + var currentObject = baseObject; + foreach (string member in memberArgs) { + try { + if (currentType.GetFieldInfo(member) is { } field) { + currentType = field.FieldType; + if (field.IsStatic) { + currentObject = field.GetValue(null); + } else { + if (currentObject == null) { + // Propagate null + return (Value: null, Success: true, ErrorMessage: ""); + } + + currentObject = field.GetValue(currentObject); + } + continue; + } + if (currentType.GetPropertyInfo(member) is { } property && property.GetGetMethod() != null) { + if (EnforceLegal) { + return (Value: null, Success: false, ErrorMessage: $"Cannot safely get property '{member}' during EnforceLegal"); + } + + currentType = property.PropertyType; + if (property.IsStatic()) { + currentObject = property.GetValue(null); + } else { + if (currentObject == null) { + // Propagate null + return (Value: null, Success: true, ErrorMessage: ""); + } + + currentObject = property.GetValue(currentObject); + } + continue; + } + } catch (Exception ex) { + // Something went wrong + return (currentObject, Success: false, ErrorMessage: ex.Message); + } + + // Unable to recurse further + return (currentObject, Success: false, ErrorMessage: $"Cannot find field / property '{member}' on type {currentType}"); + } + + return (currentObject, Success: true, ErrorMessage: ""); + } + + /// Recursively resolves the value of the specified members for multiple instances at once + public static (List Values, bool Success, string ErrorMessage) ResolveMemberValues(Type baseType, List baseObjects, string[] memberArgs) { + if (baseObjects.IsEmpty()) { + (object? result, bool success, string errorMessage) = ResolveMemberValue(baseType, null, memberArgs); + return ([result], success, errorMessage); + } else { + List values = new(capacity: baseObjects.Count); + + foreach (object obj in baseObjects) { + (object? result, bool success, string errorMessage) = ResolveMemberValue(baseType, obj, memberArgs); + + if (!success) { + return (Values: [], Success: false, errorMessage); + } + values.Add(result); + } + + return (values, Success: true, ErrorMessage: ""); + } + } + + /// Recursively resolves the value of the specified members + public static bool SetMemberValue(Type baseType, object? baseObject, object? value, string[] memberArgs) { + var typeStack = new Stack(); + var objectStack = new Stack(); + + var currentType = baseType; + object? currentObject = baseObject; + for (int i = 0; i < memberArgs.Length - 1; i++) { + typeStack.Push(currentType); + objectStack.Push(currentObject); + + string member = memberArgs[i]; + + try { + if (currentType.GetFieldInfo(member) is { } field) { + currentType = field.FieldType; + if (field.IsStatic) { + currentObject = field.GetValue(null); + } else { + currentObject = field.GetValue(currentObject); + } + + continue; + } + + if (currentType.GetPropertyInfo(member) is { } property && property.GetSetMethod() != null) { + if (EnforceLegal) { + return false; // Cannot safely invoke methods during EnforceLegal + } + + currentType = property.PropertyType; + if (property.IsStatic()) { + currentObject = property.GetValue(null); + } else { + currentObject = property.GetValue(currentObject); + } + + continue; + } + } catch (Exception) { + // Something went wrong + return false; + } + + // Unable to recurse further + return false; + } + + // Set the value + try { + // Special case for Actor / Platform positions, since they use subpixels + if (memberArgs[^1] is nameof(Entity.X) or nameof(Entity.Y)) { + object? entityObject = null; + if (objectStack.Count == 0) { + // "Entity.X" + entityObject = currentObject; + } else if (objectStack.Count >= 1 && memberArgs[^2] is nameof(Entity.Position)) { + // "Entity.Position.X" + entityObject = objectStack.Peek(); + } + + if (entityObject is Actor actor) { + var subpixelValue = (SubpixelComponent) value!; + + var remainder = actor.movementCounter; + if (memberArgs[^1] == nameof(Entity.X)) { + actor.Position.X = subpixelValue.Position; + remainder.X = subpixelValue.Remainder; + } else { + actor.Position.Y = subpixelValue.Position; + remainder.Y = subpixelValue.Remainder; + } + actor.movementCounter = remainder; + return true; + } else if (entityObject is Platform platform) { + var subpixelValue = (SubpixelComponent) value!; + + var remainder = platform.movementCounter; + if (memberArgs[^1] == nameof(Entity.X)) { + platform.Position.X = subpixelValue.Position; + remainder.X = subpixelValue.Remainder; + } else { + platform.Position.Y = subpixelValue.Position; + remainder.Y = subpixelValue.Remainder; + } + platform.movementCounter = remainder; + return true; + } + } else if (memberArgs[^1] is nameof(Entity.Position)) { + if (currentObject is Actor actor) { + var subpixelValue = (SubpixelPosition) value!; + + actor.Position = new(subpixelValue.X.Position, subpixelValue.Y.Position); + actor.movementCounter = new(subpixelValue.X.Remainder, subpixelValue.Y.Remainder); + return true; + } else if (currentObject is Platform platform) { + var subpixelValue = (SubpixelPosition) value!; + + platform.Position = new(subpixelValue.X.Position, subpixelValue.Y.Position); + platform.movementCounter = new(subpixelValue.X.Remainder, subpixelValue.Y.Remainder); + return true; + } + } + + if (currentType.GetFieldInfo(memberArgs[^1]) is { } field) { + if (field.IsStatic) { + field.SetValue(null, value); + } else { + field.SetValue(currentObject, value); + } + } else if (currentType.GetPropertyInfo(memberArgs[^1]) is { } property && property.GetSetMethod() != null) { + // Special case to support binding custom keys + if (property.PropertyType == typeof(ButtonBinding) && !EnforceLegal && property.GetValue(currentObject) is ButtonBinding binding) { + var nodes = binding.Button.Nodes; + var mouseButtons = binding.Button.Binding.Mouse; + var data = (ButtonBindingData)value!; + + if (data.KeyboardKeys.IsNotEmpty()) { + foreach (var node in nodes.ToList()) { + if (node is VirtualButton.KeyboardKey) { + nodes.Remove(node); + } + } + + nodes.AddRange(data.KeyboardKeys.Select(key => new VirtualButton.KeyboardKey(key))); + } + + if (data.MouseButtons.IsNotEmpty()) { + foreach (var node in nodes.ToList()) { + switch (node) { + case VirtualButton.MouseLeftButton: + case VirtualButton.MouseRightButton: + case VirtualButton.MouseMiddleButton: + nodes.Remove(node); + break; + } + } + + if (mouseButtons != null) { + mouseButtons.Clear(); + foreach (var button in data.MouseButtons) { + mouseButtons.Add(button); + } + } else { + foreach (var button in data.MouseButtons) { + switch (button) + { + case MInput.MouseData.MouseButtons.Left: + nodes.AddRange(data.KeyboardKeys.Select(_ => new VirtualButton.MouseLeftButton())); + break; + case MInput.MouseData.MouseButtons.Right: + nodes.AddRange(data.KeyboardKeys.Select(_ => new VirtualButton.MouseRightButton())); + break; + case MInput.MouseData.MouseButtons.Middle: + nodes.AddRange(data.KeyboardKeys.Select(_ => new VirtualButton.MouseMiddleButton())); + break; + case MInput.MouseData.MouseButtons.XButton1 or MInput.MouseData.MouseButtons.XButton2: + // TODO: Error message + // AbortTas("X1 and X2 are not supported before Everest adding mouse support"); + return false; + } + } + } + } + return true; + } + + if (EnforceLegal) { + return false; // Cannot safely invoke methods during EnforceLegal + } + + if (property.IsStatic()) { + property.SetValue(null, value); + } else { + property.SetValue(currentObject, value); + } + } else { + // Couldn't find the last member + return false; + } + } catch (Exception) { + // Something went wrong + return false; + } + + // Recurse back up to properly set value-types + for (int i = memberArgs.Length - 2; i >= 0 && currentType.IsValueType; i--) { + value = currentObject; + currentType = typeStack.Pop(); + currentObject = objectStack.Pop(); + + string member = memberArgs[i]; + + try { + if (currentType.GetFieldInfo(member) is { } field) { + if (field.IsStatic) { + field.SetValue(null, value); + } else { + field.SetValue(currentObject, value); + } + } else if (currentType.GetPropertyInfo(member) is { } property && property.GetSetMethod() != null) { + if (EnforceLegal) { + return false; // Cannot safely invoke methods during EnforceLegal + } + + if (property.IsStatic()) { + property.SetValue(null, value); + } else { + property.SetValue(currentObject, value); + } + } + } catch (Exception) { + // Something went wrong + return false; + } + } + + return true; + } + + /// Recursively resolves the value of the specified members for multiple instances at once + public static bool SetMemberValues(Type baseType, List baseObjects, object? value, string[] memberArgs) { + if (baseObjects.IsEmpty()) { + return SetMemberValue(baseType, null, value, memberArgs); + } else { + return baseObjects + .Select(obj => SetMemberValue(baseType, obj, value, memberArgs)) + .All(success => success); + } + } + + /// Recursively resolves the value of the specified members + public static bool InvokeMemberMethod(Type baseType, object? baseObject, object?[] parameters, string[] memberArgs) { + if (EnforceLegal) { + return false; // Cannot safely invoke methods during EnforceLegal + } + + var currentType = baseType; + object? currentObject = baseObject; + for (int i = 0; i < memberArgs.Length - 1; i++) { + string member = memberArgs[i]; + + try { + if (currentType.GetFieldInfo(member) is { } field) { + currentType = field.FieldType; + if (field.IsStatic) { + currentObject = field.GetValue(null); + } else { + currentObject = field.GetValue(currentObject); + } + + continue; + } + + if (currentType.GetPropertyInfo(member) is { } property && property.GetSetMethod() != null) { + if (EnforceLegal) { + return false; // Cannot safely invoke methods during EnforceLegal + } + + currentType = property.PropertyType; + if (property.IsStatic()) { + currentObject = property.GetValue(null); + } else { + currentObject = property.GetValue(currentObject); + } + + continue; + } + } catch (Exception) { + // Something went wrong + return false; + } + + // Unable to recurse further + return false; + } + + // Invoke the method + try { + if (currentType.GetMethodInfo(memberArgs[^1]) is { } method) { + if (method.IsStatic) { + method.Invoke(null, parameters); + } else { + method.Invoke(currentObject, parameters); + } + return true; + } + } catch (Exception) { + // Something went wrong + return false; + } + + // Couldn't find the method + return false; + } + + /// Recursively resolves the value of the specified members for multiple instances at once + public static bool InvokeMemberMethods(Type baseType, List baseObjects, object?[] parameters, string[] memberArgs) { + if (baseObjects.IsEmpty()) { + return InvokeMemberMethod(baseType, null, parameters, memberArgs); + } else { + return baseObjects + .Select(obj => InvokeMemberMethod(baseType, obj, parameters, memberArgs)) + .All(success => success); + } + } + + /// Data-class to hold parsed ButtonBinding data, before it being set + private class ButtonBindingData { + public readonly HashSet KeyboardKeys = []; + public readonly HashSet MouseButtons = []; + } + + /// Resolves the value arguments into the specified types if possible + public static (object?[] Values, bool Success, string ErrorMessage) ResolveValues(string[] valueArgs, Type[] targetTypes) { + var values = new object?[targetTypes.Length]; + int index = 0; + + for (int i = 0; i < valueArgs.Length; i++) { + var arg = valueArgs[i]; + var targetType = targetTypes[index]; + targetType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + try { + if (arg.Contains('.') && !float.TryParse(arg, out _)) { + // The value is a target-query, which needs to be resolved + (var results, bool success, string errorMessage) = GetMemberValues(arg); + if (!success) { + return ([], Success: false, ErrorMessage: errorMessage); + } + if (results.Count != 1) { + return ([], Success: false, ErrorMessage: $"Target-query '{arg}' for type '{targetType}' needs to resolve to exactly 1 value! Got {results.Count}"); + } + if (results[0].Value != null && !results[0].Value!.GetType().IsSameOrSubclassOf(targetType)) { + return ([], Success: false, ErrorMessage: $"Expected type '{targetType}' for target-query '{arg}'! Got {results[0].GetType()}"); + } + + values[index++] = results[0].Value; + continue; + } + + if (targetType == typeof(Vector2)) { + values[index++] = new Vector2( + float.Parse(valueArgs[i + 0]), + float.Parse(valueArgs[i + 1])); + i++; // Account for second argument + continue; + } + + if (targetType == typeof(SubpixelComponent)) { + double doubleValue = double.Parse(valueArgs[i]); + + int position = (int) Math.Round(doubleValue); + float remainder = (float) (doubleValue - position); + + values[index++] = new SubpixelComponent(position, remainder); + continue; + } + if (targetType == typeof(SubpixelPosition)) { + double doubleValueX = double.Parse(valueArgs[i + 0]); + double doubleValueY = double.Parse(valueArgs[i + 1]); + + int positionX = (int) Math.Round(doubleValueX); + int positionY = (int) Math.Round(doubleValueY); + float remainderX = (float) (doubleValueX - positionX); + float remainderY = (float) (doubleValueY - positionY); + + values[index++] = new SubpixelPosition( + new SubpixelComponent(positionX, remainderX), + new SubpixelComponent(positionY, remainderY)); + i++; // Account for second argument + continue; + } + + if (targetType == typeof(Random)) { + values[index++] = new Random(int.Parse(arg)); + continue; + } + + if (targetType == typeof(ButtonBinding)) { + var data = new ButtonBindingData(); + // Parse mouse first, so Mouse.Left is not parsed as Keys.Left + if (Enum.TryParse(arg, ignoreCase: true, out var button)) { + data.MouseButtons.Add(button); + } else if (Enum.TryParse(arg, ignoreCase: true, out var key)) { + data.KeyboardKeys.Add(key); + } else { + return ([], Success: false, ErrorMessage: $"'{arg}' is not a valid keyboard key or mouse button"); + } + + values[index++] = data; + continue; + } + + if (targetType.IsEnum) { + if (Enum.TryParse(targetType, arg, ignoreCase: true, out var value) && (int) value < Enum.GetNames(targetType).Length) { + values[index++] = value; + continue; + } else { + return ([], Success: false, ErrorMessage: $"'{arg}' is not a valid enum state for '{targetType.FullName}'"); + } + } + + if (string.IsNullOrWhiteSpace(arg) || arg == "null") { + values[index++] = targetType.IsValueType ? Activator.CreateInstance(targetType) : null; + continue; + } + + values[index++] = Convert.ChangeType(arg, targetType); + } catch (Exception ex) { + return ([], Success: false, ErrorMessage: $"Failed to resolve value for type '{targetType}': {ex}"); + } + } + + return (values, Success: true, ErrorMessage: string.Empty); + } +} diff --git a/CelesteTAS-EverestInterop/Source/ModInterop/ExtendedVariantsInterop.cs b/CelesteTAS-EverestInterop/Source/ModInterop/ExtendedVariantsInterop.cs new file mode 100644 index 000000000..0c5c80993 --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/ModInterop/ExtendedVariantsInterop.cs @@ -0,0 +1,58 @@ +using System; +using Celeste.Mod; +using MonoMod.Utils; +using TAS.Utils; + +namespace TAS.ModInterop; + +internal static class ExtendedVariantsInterop { + private static readonly Lazy module = new(() => ModUtils.GetModule("ExtendedVariantMode")); + private static readonly Lazy triggerManager = new(() => module.Value?.GetFieldValue("TriggerManager")); + private static readonly Lazy variantHandlers = new(() => module.Value?.GetFieldValue("VariantHandlers")); + + private static readonly Lazy getCurrentVariantValue = new(() => + triggerManager.Value?.GetType().GetMethodInfo("GetCurrentVariantValue")?.GetFastInvoker()); + private static readonly Lazy setVariantValue = new(() => + module.Value?.GetType().Assembly.GetType("ExtendedVariants.UI.ModOptionsEntries")?.GetMethodInfo("SetVariantValue")?.GetFastInvoker()); + private static readonly Lazy dictionaryGetItem = new(() => + variantHandlers.Value?.GetType().GetMethodInfo("get_Item")?.GetFastInvoker()); + + private static readonly Lazy variantType = + new(() => module.Value?.GetType().Assembly.GetType("ExtendedVariants.Module.ExtendedVariantsModule+Variant")); + + // enum value might be different between different ExtendedVariantMode version, so we have to parse from string + private static readonly Lazy upsideDownVariant = new(ParseVariant("UpsideDown")); + private static readonly Lazy superDashingVariant = new(ParseVariant("SuperDashing")); + + public static Func ParseVariant(string value) { + return () => { + try { + return variantType.Value == null ? null : Enum.Parse(variantType.Value, value); + } catch (Exception e) { + e.LogException($"Parsing Variant.{value} Failed."); + return null; + } + }; + } + + public static bool UpsideDown => GetCurrentVariantValue(upsideDownVariant) is { } value && (bool) value; + public static bool SuperDashing => GetCurrentVariantValue(superDashingVariant) is { } value && (bool) value; + + public static Type? GetVariantsEnum() => variantType.Value; + + public static Type? GetVariantType(Lazy variant) { + if (variant.Value is null) return null; + var handler = dictionaryGetItem.Value?.Invoke(variantHandlers.Value, variant.Value); + return (Type?) handler?.GetType().GetMethodInfo("GetVariantType")?.Invoke(handler, []); + } + + public static object? GetCurrentVariantValue(Lazy variant) { + if (variant.Value is null) return null; + return getCurrentVariantValue.Value?.Invoke(triggerManager.Value, variant.Value); + } + + public static void SetVariantValue(Lazy variant, object value) { + if (variant.Value is null) return; + setVariantValue.Value?.Invoke(null, variant.Value, value); + } +} diff --git a/CelesteTAS-EverestInterop/Source/Utils/ModUtils.cs b/CelesteTAS-EverestInterop/Source/ModInterop/ModUtils.cs similarity index 54% rename from CelesteTAS-EverestInterop/Source/Utils/ModUtils.cs rename to CelesteTAS-EverestInterop/Source/ModInterop/ModUtils.cs index b7acf19be..9e074901d 100644 --- a/CelesteTAS-EverestInterop/Source/Utils/ModUtils.cs +++ b/CelesteTAS-EverestInterop/Source/ModInterop/ModUtils.cs @@ -4,17 +4,22 @@ using Celeste; using Celeste.Mod; using Celeste.Mod.Helpers; +using System.Collections.Generic; -namespace TAS.Utils; +namespace TAS.ModInterop; internal static class ModUtils { public static readonly Assembly VanillaAssembly = typeof(Player).Assembly; - public static Type GetType(string modName, string name, bool throwOnError = false, bool ignoreCase = false) { + public static Type? GetType(string modName, string name, bool throwOnError = false, bool ignoreCase = false) { return GetAssembly(modName)?.GetType(name, throwOnError, ignoreCase); } + /// Returns all specified types from the given mod + public static IEnumerable GetTypes(string modName, params string[] fullTypeNames) { + return GetAssembly(modName)?.GetTypes().Where(type => fullTypeNames.Contains(type.FullName)) ?? []; + } - public static Type GetType(string name, bool throwOnError = false, bool ignoreCase = false) { + public static Type? GetType(string name, bool throwOnError = false, bool ignoreCase = false) { return FakeAssembly.GetFakeEntryAssembly().GetType(name, throwOnError, ignoreCase); } @@ -22,7 +27,7 @@ public static Type[] GetTypes() { return FakeAssembly.GetFakeEntryAssembly().GetTypes(); } - public static EverestModule GetModule(string modName) { + public static EverestModule? GetModule(string modName) { return Everest.Modules.FirstOrDefault(module => module.Metadata?.Name == modName); } @@ -30,7 +35,7 @@ public static bool IsInstalled(string modName) { return GetModule(modName) != null; } - public static Assembly GetAssembly(string modName) { + public static Assembly? GetAssembly(string modName) { return GetModule(modName)?.GetType().Assembly; } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/Utils/SpeedrunToolUtils.cs b/CelesteTAS-EverestInterop/Source/ModInterop/SpeedrunToolInterop.cs similarity index 81% rename from CelesteTAS-EverestInterop/Source/Utils/SpeedrunToolUtils.cs rename to CelesteTAS-EverestInterop/Source/ModInterop/SpeedrunToolInterop.cs index f08b1116a..80705c58e 100644 --- a/CelesteTAS-EverestInterop/Source/Utils/SpeedrunToolUtils.cs +++ b/CelesteTAS-EverestInterop/Source/ModInterop/SpeedrunToolInterop.cs @@ -3,6 +3,7 @@ using System.Reflection; using System.Runtime.CompilerServices; using Celeste; +using Celeste.Mod; using Celeste.Mod.SpeedrunTool.Other; using Celeste.Mod.SpeedrunTool.SaveLoad; using Microsoft.Xna.Framework.Input; @@ -10,11 +11,17 @@ using TAS.EverestInterop; using TAS.EverestInterop.Hitboxes; using TAS.EverestInterop.InfoHUD; +using TAS.Gameplay; +using TAS.InfoHUD; using TAS.Input.Commands; +using TAS.Module; +using TAS.Utils; -namespace TAS.Utils; +namespace TAS.ModInterop; + +public static class SpeedrunToolInterop { + public static bool Installed { get; private set; } -internal static class SpeedrunToolUtils { private static object saveLoadAction; private static Dictionary savedEntityData; private static int groupCounter; @@ -28,14 +35,21 @@ internal static class SpeedrunToolUtils { private static long? tasStartFileTime; private static MouseState mouseState; private static Dictionary followers; - private static Dictionary insertedSlots = new(); private static bool disallowUnsafeInput; private static Random auraRandom; + private static bool betterInvincible = false; + + [Load] + private static void Load() { + Installed = ModUtils.IsInstalled("SpeedrunTool"); + Everest.Events.AssetReload.OnBeforeReload += _ => Installed = false; + Everest.Events.AssetReload.OnAfterReload += _ => Installed = ModUtils.IsInstalled("SpeedrunTool"); + } public static void AddSaveLoadAction() { Action>, Level> save = (_, _) => { savedEntityData = EntityDataHelper.CachedEntityData.DeepCloneShared(); - InfoWatchEntity.SavedRequireWatchEntities = InfoWatchEntity.RequireWatchEntities.DeepCloneShared(); + InfoWatchEntity.WatchedEntities_Save = InfoWatchEntity.WatchedEntities.DeepCloneShared(); groupCounter = CycleHitboxColor.GroupCounter; simulatePauses = StunPauseCommand.SimulatePauses; pauseOnCurrentFrame = StunPauseCommand.PauseOnCurrentFrame; @@ -47,13 +61,13 @@ public static void AddSaveLoadAction() { tasStartFileTime = MetadataCommands.TasStartFileTime; mouseState = MouseCommand.CurrentState; followers = HitboxSimplified.Followers.DeepCloneShared(); - insertedSlots = SaveAndQuitReenterCommand.InsertedSlots.DeepCloneShared(); disallowUnsafeInput = SafeCommand.DisallowUnsafeInput; auraRandom = DesyncFixer.AuraHelperSharedRandom.DeepCloneShared(); + betterInvincible = Manager.Running && BetterInvincible.Invincible; }; Action>, Level> load = (_, _) => { EntityDataHelper.CachedEntityData = savedEntityData.DeepCloneShared(); - InfoWatchEntity.RequireWatchEntities = InfoWatchEntity.SavedRequireWatchEntities.DeepCloneShared(); + InfoWatchEntity.WatchedEntities = InfoWatchEntity.WatchedEntities_Save.DeepCloneShared(); CycleHitboxColor.GroupCounter = groupCounter; StunPauseCommand.SimulatePauses = simulatePauses; StunPauseCommand.PauseOnCurrentFrame = pauseOnCurrentFrame; @@ -69,16 +83,17 @@ public static void AddSaveLoadAction() { MetadataCommands.TasStartFileTime = tasStartFileTime; MouseCommand.CurrentState = mouseState; HitboxSimplified.Followers = followers.DeepCloneShared(); - SaveAndQuitReenterCommand.InsertedSlots = insertedSlots.DeepCloneShared(); SafeCommand.DisallowUnsafeInput = disallowUnsafeInput; DesyncFixer.AuraHelperSharedRandom = auraRandom.DeepCloneShared(); + BetterInvincible.Invincible = Manager.Running && betterInvincible; }; Action clear = () => { savedEntityData = null; pressKeys = null; followers = null; - InfoWatchEntity.SavedRequireWatchEntities.Clear(); + InfoWatchEntity.WatchedEntities_Save.Clear(); auraRandom = null; + betterInvincible = false; }; ConstructorInfo constructor = typeof(SaveLoadAction).GetConstructors()[0]; @@ -108,4 +123,4 @@ public static void InputDeregister() { config.VirtualButton.Value.Deregister(); } } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/Utils/TASRecorderUtlils.cs b/CelesteTAS-EverestInterop/Source/ModInterop/TASRecorderInterop.cs similarity index 95% rename from CelesteTAS-EverestInterop/Source/Utils/TASRecorderUtlils.cs rename to CelesteTAS-EverestInterop/Source/ModInterop/TASRecorderInterop.cs index ee8944e72..2a48884f2 100644 --- a/CelesteTAS-EverestInterop/Source/Utils/TASRecorderUtlils.cs +++ b/CelesteTAS-EverestInterop/Source/ModInterop/TASRecorderInterop.cs @@ -2,9 +2,9 @@ using System.Runtime.CompilerServices; using Celeste.Mod.TASRecorder; -namespace TAS.Utils; +namespace TAS.ModInterop; -internal static class TASRecorderUtils { +public static class TASRecorderInterop { public static bool Installed => installed.Value; private static readonly Lazy installed = new(() => ModUtils.IsInstalled("TASRecorder")); @@ -17,7 +17,7 @@ public static void StopRecording() { public static void SetDurationEstimate(int frames) { if (installed.Value) setDurationEstimate(frames); } - + public static bool Recording => installed.Value && isRecording(); public static bool FFmpegInstalled => installed.Value && isFFmpegInstalled(); @@ -31,4 +31,4 @@ public static void SetDurationEstimate(int frames) { private static bool isRecording() => TASRecorderAPI.IsRecording(); [MethodImpl(MethodImplOptions.NoInlining)] private static bool isFFmpegInstalled() => TASRecorderAPI.IsFFmpegInstalled(); -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/ModInterop/TasHelperInterop.cs b/CelesteTAS-EverestInterop/Source/ModInterop/TasHelperInterop.cs new file mode 100644 index 000000000..9e911c54c --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/ModInterop/TasHelperInterop.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Monocle; +using MonoMod.ModInterop; +using TAS.Module; + +namespace TAS.ModInterop; +internal static class TasHelperInterop { + + private static bool loaded; + + public static HashSet GetUnimportantTriggers() { + return loaded ? TasHelperImport.GetUnimportantTriggers() : new HashSet(); + } + + public static bool InPrediction => loaded ? TasHelperImport.InPrediciton() : false; + + [Initialize] + private static void Initialize() { + typeof(TasHelperImport).ModInterop(); + loaded = TasHelperImport.GetUnimportantTriggers is not null; + } + + [ModImportName("TASHelper")] + private static class TasHelperImport { + + public static Func> GetUnimportantTriggers; + + public static Func InPrediciton; + } +} + diff --git a/CelesteTAS-EverestInterop/Source/Module/CelesteTasExports.cs b/CelesteTAS-EverestInterop/Source/Module/CelesteTasExports.cs new file mode 100644 index 000000000..03443e9e4 --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/Module/CelesteTasExports.cs @@ -0,0 +1,54 @@ +using Celeste.Mod; +using JetBrains.Annotations; +using MonoMod.ModInterop; +using System; +using TAS.EverestInterop; +using TAS.Utils; + +namespace TAS.Module; + +// Copy-Paste the CelesteTasImports class into your mod and call typeof(CelesteTasImports).ModInterop() in EverestModule.Initialize() +// You can omit the [PublicAPI] attribute +[PublicAPI] +public static class CelesteTasImports { + public delegate void AddSettingsRestoreHandlerDelegate(EverestModule module, (Func Backup, Action Restore)? handler); + public delegate void RemoveSettingsRestoreHandlerDelegate(EverestModule module); + + /// Registers custom delegates for backing up and restoring mod setting before / after running a TAS + /// A `null` handler causes the settings to not be backed up and later restored + public static AddSettingsRestoreHandlerDelegate AddSettingsRestoreHandler = null!; + + /// De-registers a previously registered handler for the module + public static RemoveSettingsRestoreHandlerDelegate RemoveSettingsRestoreHandler = null!; +} + +/// Official stable API for interacting with CelesteTAS +[ModExportName("CelesteTAS"), PublicAPI] +public static class CelesteTasExports { + [Load] + private static void Load() { + typeof(CelesteTasExports).ModInterop(); + } + + public static void AddSettingsRestoreHandler(EverestModule module, (Func Backup, Action Restore)? handler) { + if (RestoreSettings.ignoredModules.Contains(module) || RestoreSettings.customHandlers.ContainsKey(module)) { + $"Tried to register a custom setting-restore handler for mod '{module.Metadata.Name}', while already having a handler registered".Log(LogLevel.Warn); + return; + } + + if (handler == null) { + RestoreSettings.ignoredModules.Add(module); + } else { + RestoreSettings.customHandlers[module] = handler.Value; + } + } + public static void RemoveSettingsRestoreHandler(EverestModule module) { + if (RestoreSettings.ignoredModules.Contains(module)) { + RestoreSettings.ignoredModules.Remove(module); + } else if (RestoreSettings.customHandlers.ContainsKey(module)) { + RestoreSettings.customHandlers.Remove(module); + } else { + $"Tried to de-register a custom setting-restore handler for mod '{module.Metadata.Name}', without having a handler previously registered".Log(LogLevel.Warn); + } + } +} diff --git a/CelesteTAS-EverestInterop/Source/Module/CelesteTasMenu.cs b/CelesteTAS-EverestInterop/Source/Module/CelesteTasMenu.cs index 555298595..7dcb865a3 100644 --- a/CelesteTAS-EverestInterop/Source/Module/CelesteTasMenu.cs +++ b/CelesteTAS-EverestInterop/Source/Module/CelesteTasMenu.cs @@ -11,6 +11,7 @@ using TAS.EverestInterop; using TAS.EverestInterop.Hitboxes; using TAS.EverestInterop.InfoHUD; +using TAS.Gameplay; using TAS.Input; using TAS.Utils; @@ -57,6 +58,13 @@ private static EaseInSubMenu CreateMoreOptionsSubMenu(TextMenu menu) { TasSettings.AttemptConnectStudio = value; CommunicationWrapper.ChangeStatus(); })); + TextMenu.Item betterInvincible; + subMenu.Add(betterInvincible = new TextMenu.OnOff("Better Invincibility".ToDialogText(), TasSettings.BetterInvincible).Change(value => { + TasSettings.BetterInvincible = value; + BetterInvincible.Invincible = false; // in case that value doesn't get reset for some unknown reason... yeah i have such bug report + })); + subMenu.AddDescription(menu, betterInvincible, "Better Invincible Description".ToDialogText()); + TextMenu.Item hideFreezeFramesItem; subMenu.Add(hideFreezeFramesItem = new TextMenu.OnOff("Hide Freeze Frames".ToDialogText(), TasSettings.HideFreezeFrames).Change(value => TasSettings.HideFreezeFrames = value)); @@ -203,7 +211,7 @@ TextMenuExt.EaseInSubHeaderExt AddEnabledDescription(TextMenu.Item enabledItem, menu.Add(easeInSubMenu); } - foreach (string text in Split(InputController.TasFilePath, 60).Reverse()) { + foreach (string text in Split(Manager.Controller.FilePath, 60).Reverse()) { enabledDescriptions.Add(AddEnabledDescription(enabledItem, menu, text)); } diff --git a/CelesteTAS-EverestInterop/Source/Module/CelesteTasModule.cs b/CelesteTAS-EverestInterop/Source/Module/CelesteTasModule.cs index decb6c636..24a0332eb 100644 --- a/CelesteTAS-EverestInterop/Source/Module/CelesteTasModule.cs +++ b/CelesteTAS-EverestInterop/Source/Module/CelesteTasModule.cs @@ -2,8 +2,12 @@ using Celeste; using Celeste.Mod; using FMOD.Studio; +using JetBrains.Annotations; +using System.Collections.Generic; +using System.IO; using TAS.Communication; using TAS.EverestInterop; +using TAS.Tools; using TAS.Utils; namespace TAS.Module; @@ -12,9 +16,10 @@ namespace TAS.Module; public class CelesteTasModule : EverestModule { public CelesteTasModule() { Instance = this; - AttributeUtils.CollectMethods(); - AttributeUtils.CollectMethods(); - AttributeUtils.CollectMethods(); + + AttributeUtils.CollectOwnMethods(); + AttributeUtils.CollectOwnMethods(); + AttributeUtils.CollectOwnMethods(); } public static CelesteTasModule Instance { get; private set; } @@ -41,17 +46,40 @@ public override void Unload() { CenterCamera.Unload(); } + public override bool ParseArg(string arg, Queue args) { + switch (arg) { + case "--tas": { + if (args.TryDequeue(out string? path)) { + if (!File.Exists(path)) { + $"Specified TAS file '{path}' not found".Log(LogLevel.Error); + } else { + PlayTasAtLaunch.FilePath = path; + } + } else { + "Expected file path after --tas CLI argument".Log(LogLevel.Error); + } + return true; + } + + default: + return false; + } + } + public override void CreateModMenuSection(TextMenu menu, bool inGame, EventInstance snapshot) { CreateModMenuSectionHeader(menu, inGame, snapshot); CelesteTasMenu.CreateMenu(this, menu, inGame); } } -[AttributeUsage(AttributeTargets.Method)] -internal class LoadAttribute : Attribute { } +/// Invokes the target method when the module is loaded +[AttributeUsage(AttributeTargets.Method), MeansImplicitUse] +internal class LoadAttribute : Attribute; -[AttributeUsage(AttributeTargets.Method)] -internal class UnloadAttribute : Attribute { } +/// Invokes the target method when the module is unloaded +[AttributeUsage(AttributeTargets.Method), MeansImplicitUse] +internal class UnloadAttribute : Attribute; -[AttributeUsage(AttributeTargets.Method)] -internal class InitializeAttribute : Attribute { } +/// Invokes the target method when the module is initialized +[AttributeUsage(AttributeTargets.Method), MeansImplicitUse] +internal class InitializeAttribute : Attribute; diff --git a/CelesteTAS-EverestInterop/Source/Module/CelesteTasSettings.cs b/CelesteTAS-EverestInterop/Source/Module/CelesteTasSettings.cs index 6de1ff2cb..19dc2070f 100644 --- a/CelesteTAS-EverestInterop/Source/Module/CelesteTasSettings.cs +++ b/CelesteTAS-EverestInterop/Source/Module/CelesteTasSettings.cs @@ -8,7 +8,8 @@ using TAS.Communication; using TAS.EverestInterop; using TAS.EverestInterop.Hitboxes; -using TAS.EverestInterop.InfoHUD; +using TAS.Gameplay.Hitboxes; +using TAS.Utils; using YamlDotNet.Serialization; namespace TAS.Module; @@ -69,6 +70,10 @@ public bool SimplifiedHitboxes { set { StudioShared.SimplifiedHitboxes = value; SyncSettings(); + + if (value && Engine.Scene != null) { + TriggerHitbox.RecacheTriggers(Engine.Scene); + } } } @@ -95,68 +100,68 @@ public ActualCollideHitboxType ShowActualCollideHitboxes { #region HotKey [SettingName("TAS_KEY_START_STOP")] - [DefaultButtonBinding2(0, Keys.RightControl)] - public ButtonBinding KeyStart { get; set; } = new(0, Keys.RightControl); + [DefaultButtonBinding([0], [Keys.RightControl])] + public ButtonBinding KeyStart { get; set; } = null!; [SettingName("TAS_KEY_RESTART")] - [DefaultButtonBinding2(0, Keys.OemPlus)] - public ButtonBinding KeyRestart { get; set; } = new(0, Keys.OemPlus); + [DefaultButtonBinding([0], [Keys.OemPlus])] + public ButtonBinding KeyRestart { get; set; } = null!; [SettingName("TAS_KEY_FAST_FORWARD")] - [DefaultButtonBinding2(0, Keys.RightShift)] - public ButtonBinding KeyFastForward { get; set; } = new(0, Keys.RightShift); + [DefaultButtonBinding([0], [Keys.RightShift])] + public ButtonBinding KeyFastForward { get; set; } = null!; [SettingName("TAS_KEY_FAST_FORWARD_COMMENT")] - [DefaultButtonBinding2(0, Keys.RightAlt, Keys.RightShift)] - public ButtonBinding KeyFastForwardComment { get; set; } = new(0, Keys.RightAlt, Keys.RightShift); + [DefaultButtonBinding([0], [Keys.RightAlt, Keys.RightShift])] + public ButtonBinding KeyFastForwardComment { get; set; } = null!; [SettingName("TAS_KEY_SLOW_FORWARD")] - [DefaultButtonBinding2(0, Keys.OemPipe)] - public ButtonBinding KeySlowForward { get; set; } = new(0, Keys.OemPipe); + [DefaultButtonBinding([0], [Keys.OemPipe])] + public ButtonBinding KeySlowForward { get; set; } = null!; [SettingName("TAS_KEY_FRAME_ADVANCE")] - [DefaultButtonBinding2(0, Keys.OemOpenBrackets)] - public ButtonBinding KeyFrameAdvance { get; set; } = new(0, Keys.OemOpenBrackets); + [DefaultButtonBinding([0], [Keys.OemOpenBrackets])] + public ButtonBinding KeyFrameAdvance { get; set; } = null!; [SettingName("TAS_KEY_PAUSE_RESUME")] - [DefaultButtonBinding2(0, Keys.OemCloseBrackets)] - public ButtonBinding KeyPause { get; set; } = new(0, Keys.OemCloseBrackets); + [DefaultButtonBinding([0], [Keys.OemCloseBrackets])] + public ButtonBinding KeyPause { get; set; } = null!; [SettingName("TAS_KEY_HITBOXES")] - [DefaultButtonBinding2(0, Keys.LeftControl, Keys.B)] - public ButtonBinding KeyHitboxes { get; set; } = new(0, Keys.LeftControl, Keys.B); + [DefaultButtonBinding([0], [Keys.LeftControl, Keys.B])] + public ButtonBinding KeyHitboxes { get; set; } = null!; [SettingName("TAS_KEY_TRIGGER_HITBOXES")] - [DefaultButtonBinding2(0, Keys.LeftAlt, Keys.T)] - public ButtonBinding KeyTriggerHitboxes { get; set; } = new(0, Keys.LeftAlt, Keys.T); + [DefaultButtonBinding([0], [Keys.LeftAlt, Keys.T])] + public ButtonBinding KeyTriggerHitboxes { get; set; } = null!; [SettingName("TAS_KEY_SIMPLIFIED_GRAPHICS")] - [DefaultButtonBinding2(0, Keys.LeftControl, Keys.N)] - public ButtonBinding KeyGraphics { get; set; } = new(0, Keys.LeftControl, Keys.N); + [DefaultButtonBinding([0], [Keys.LeftControl, Keys.N])] + public ButtonBinding KeyGraphics { get; set; } = null!; [SettingName("TAS_KEY_CENTER_CAMERA")] - [DefaultButtonBinding2(0, Keys.LeftControl, Keys.M)] - public ButtonBinding KeyCamera { get; set; } = new(0, Keys.LeftControl, Keys.M); + [DefaultButtonBinding([0], [Keys.LeftControl, Keys.M])] + public ButtonBinding KeyCamera { get; set; } = null!; [SettingName("TAS_KEY_LOCK_CAMERA")] - [DefaultButtonBinding2(0, Keys.LeftControl, Keys.H)] - public ButtonBinding KeyLockCamera { get; set; } = new(0, Keys.LeftControl, Keys.H); + [DefaultButtonBinding([0], [Keys.LeftControl, Keys.H])] + public ButtonBinding KeyLockCamera { get; set; } = null!; [SettingName("TAS_KEY_SAVE_STATE")] - [DefaultButtonBinding2(0, Keys.RightAlt, Keys.OemMinus)] - public ButtonBinding KeySaveState { get; set; } = new(0, Keys.RightAlt, Keys.OemMinus); + [DefaultButtonBinding([0], [Keys.RightAlt, Keys.OemMinus])] + public ButtonBinding KeySaveState { get; set; } = null!; [SettingName("TAS_KEY_CLEAR_STATE")] - [DefaultButtonBinding2(0, Keys.RightAlt, Keys.Back)] - public ButtonBinding KeyClearState { get; set; } = new(0, Keys.RightAlt, Keys.Back); + [DefaultButtonBinding([0], [Keys.RightAlt, Keys.Back])] + public ButtonBinding KeyClearState { get; set; } = null!; [SettingName("TAS_KEY_INFO_HUD")] - [DefaultButtonBinding2(0, Keys.LeftControl)] - public ButtonBinding KeyInfoHud { get; set; } = new(0, Keys.LeftControl); + [DefaultButtonBinding([0], [Keys.LeftControl])] + public ButtonBinding KeyInfoHud { get; set; } = null!; [SettingName("TAS_KEY_FREE_CAMERA")] - [DefaultButtonBinding2(0, Keys.LeftAlt)] - public ButtonBinding KeyFreeCamera { get; set; } = new(0, Keys.LeftAlt); + [DefaultButtonBinding([0], [Keys.LeftAlt])] + public ButtonBinding KeyFreeCamera { get; set; } = null!; #endregion @@ -265,14 +270,28 @@ public HudOptions InfoCustom { SyncSettings(); } } - public HudOptions InfoWatchEntity { - get => StudioShared.InfoWatchEntity; + + internal bool WatchEntity => HudWatchEntity || StudioWatchEntity; + internal bool HudWatchEntity => InfoWatchEntityHudType != WatchEntityType.None; + internal bool StudioWatchEntity => InfoWatchEntityStudioType != WatchEntityType.None; + + public WatchEntityType InfoWatchEntityHudType { + get => StudioShared.InfoWatchEntityHudType; set { - StudioShared.InfoWatchEntity = value; + StudioShared.InfoWatchEntityHudType = value; SyncSettings(); + } } - public WatchEntityType InfoWatchEntityType { get; set; } = WatchEntityType.Position; + public WatchEntityType InfoWatchEntityStudioType { + get => StudioShared.InfoWatchEntityStudioType; + set { + StudioShared.InfoWatchEntityStudioType = value; + SyncSettings(); + } + } + + public bool InfoWatchEntityLogToConsole { get; set; } = true; [SettingIgnore] public string InfoCustomTemplate { get; set; } = @@ -436,6 +455,14 @@ public bool AttemptConnectStudio { set => _AttemptConnectStudio = value; } + [YamlMember(Alias = "BetterInvincible")] + public bool _BetterInvincible = true; + [YamlIgnore] + public bool BetterInvincible { + get => Enabled && _BetterInvincible; + set => _BetterInvincible = value; + } + public bool HideFreezeFrames { get; set; } = false; public bool Mod9DLighting { get; set; } = false; public bool IgnoreGcCollect { get; set; } = true; diff --git a/CelesteTAS-EverestInterop/Source/Playback/Core.cs b/CelesteTAS-EverestInterop/Source/Playback/Core.cs new file mode 100644 index 000000000..3e7c03a8d --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/Playback/Core.cs @@ -0,0 +1,147 @@ +using System; +using Celeste; +using Microsoft.Xna.Framework; +using Mono.Cecil.Cil; +using Monocle; +using MonoMod.Cil; +using MonoMod.RuntimeDetour; +using TAS.Module; +using TAS.Utils; +using GameInput = Celeste.Input; + +namespace TAS.Playback; + +/// Main hooks for allowing for TAS playback +internal static class Core { + [Load] + private static void Load() { + using (new DetourConfigContext(new DetourConfig("CelesteTAS", before: ["*"])).Use()) { + On.Celeste.Celeste.Update += On_Celeste_Update; + IL.Monocle.Engine.Update += IL_Engine_Update; + + typeof(GameInput) + .GetMethod(nameof(GameInput.UpdateGrab))! + .SkipMethod(IsPaused); + + // The original mod makes the MInput.Update call conditional and invokes UpdateInputs afterwards. + On.Monocle.MInput.Update += On_MInput_Update; + IL.Monocle.MInput.Update += IL_MInput_Update; + + // The original mod makes RunThread.Start run synchronously. + On.Celeste.RunThread.Start += On_RunThread_Start; + } + } + + [Unload] + private static void Unload() { + On.Celeste.Celeste.Update -= On_Celeste_Update; + IL.Monocle.Engine.Update -= IL_Engine_Update; + + On.Monocle.MInput.Update -= On_MInput_Update; + IL.Monocle.MInput.Update -= IL_MInput_Update; + + On.Celeste.RunThread.Start -= On_RunThread_Start; + } + + private static float elapsedTime = 0.0f; + private static DateTime lastMetaUpdate = DateTime.UtcNow; + + private static void On_Celeste_Update(On.Celeste.Celeste.orig_Update orig, Celeste.Celeste self, GameTime gameTime) { + if (!TasSettings.Enabled || !Manager.Running) { + Manager.UpdateMeta(); + orig(self, gameTime); + return; + } + + elapsedTime += Manager.PlaybackSpeed * Engine.RawDeltaTime; + + Manager.UpdateMeta(); + lastMetaUpdate = DateTime.UtcNow; + + while (elapsedTime >= Engine.RawDeltaTime) { + orig(self, gameTime); + elapsedTime -= Engine.RawDeltaTime; + + // Call UpdateMeta every real-time frame + var now = DateTime.UtcNow; + if ((now - lastMetaUpdate).TotalSeconds > Engine.RawDeltaTime) { + // We need to manually poll FNA events, since we don't return to the FNA game-loop while fast-forwarding + var game = Engine.Instance; + FNAPlatform.PollEvents(game, ref game.currentAdapter, game.textInputControlDown, ref game.textInputSuppress); + + Manager.UpdateMeta(); + lastMetaUpdate = now; + } + } + + if (TasSettings.HideFreezeFrames) { + while (Engine.FreezeTimer > 0.0f && !Manager.Controller.Break) { + orig(self, gameTime); + } + } + } + + private static void IL_Engine_Update(ILContext il) { + var cur = new ILCursor(il); + + if (cur.TryGotoNext(MoveType.After, ins => ins.MatchCall(typeof(MInput), nameof(MInput.Update)))) { + var label = cur.DefineLabel(); + + // Prevent further execution while the TAS is paused + cur.EmitDelegate(IsPaused); + cur.Emit(OpCodes.Brfalse, label); + cur.Emit(OpCodes.Ret); + cur.MarkLabel(label); + } + } + + private static bool IsPaused() => Manager.CurrState == Manager.State.Paused && !Manager.IsLoading(); + + private static void On_MInput_Update(On.Monocle.MInput.orig_Update orig) { + if (!TasSettings.Enabled) { + orig(); + return; + } + + if (!Manager.Running) { + orig(); + } + + Manager.Update(); + } + + // Update controllers, even if the game isn't focused + private static void IL_MInput_Update(ILContext il) { + var cur = new ILCursor(il) { + Index = il.Instrs.Count - 1, + }; + + if (cur.TryGotoPrev(MoveType.After, i => i.MatchCallvirt("UpdateNull"))) { + cur.EmitDelegate(UpdateGamePads); + } + + // Skip the orig GamePads[j].UpdateNull(); + if (cur.TryGotoNext(MoveType.After, i => i.MatchLdcI4(0))) { + cur.Emit(OpCodes.Ldc_I4_4).Emit(OpCodes.Add); + } + + static void UpdateGamePads() { + for (int i = 0; i < 4; i++) { + if (MInput.Active) { + MInput.GamePads[i].Update(); + } else { + MInput.GamePads[i].UpdateNull(); + } + } + } + } + + private static void On_RunThread_Start(On.Celeste.RunThread.orig_Start orig, Action method, string name, bool highPriority) { + if (Manager.Running && name != "USER_IO" && name != "MOD_IO") { + RunThread.RunThreadWithLogging(method); + return; + } + + orig(method, name, highPriority); + } +} diff --git a/CelesteTAS-EverestInterop/Source/TAS/AnalogHelper.cs b/CelesteTAS-EverestInterop/Source/TAS/AnalogHelper.cs index b6b645328..f1701b636 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/AnalogHelper.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/AnalogHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Globalization; using System.Text.RegularExpressions; using Microsoft.Xna.Framework; @@ -6,26 +7,26 @@ using StudioCommunication; using System.Collections.Generic; using TAS.Input; -using TAS.Input.Commands; using TAS.Utils; using GameInput = Celeste.Input; namespace TAS; +public readonly record struct Vector2Short(short X, short Y); + public enum AnalogMode { + /// Simply maps the angle onto a circle and applies the magnitude Ignore, + /// Maps the angle onto a circle and applies the magnitude, but applies a deadzone of 0.24 Circle, + /// Maps the angle onto a square, but applies a deadzone of 0.24 Square, + /// Adjusts magnitude to find an exact angle, axis are individually limited to an upper bound Precise, } -// ReSharper disable once StructCanBeMadeReadOnly -// mono explodes on loading the dll if there is a readonly struct in it on MacOS -public record struct Vector2Short(short X = 0, short Y = 0) { - public readonly short X = X; - public readonly short Y = Y; -} - +/// Game controllers only support a precision of a short for the X / Y axis +/// Depending on the AnalogMode, angle + magnitude are mapped to a valid short position public static class AnalogHelper { private const double DeadZone = 0.239532471; private const double DcMult = (1 - DeadZone) * (1 - DeadZone); @@ -35,13 +36,14 @@ public static class AnalogHelper { private static readonly Regex Fractional = new(@"\d+\.(\d*)", RegexOptions.Compiled); private static AnalogMode analogMode = AnalogMode.Ignore; - public static Vector2 ComputeAngleVector2(InputFrame input, out Vector2Short angleVector2Short) { + public static (Vector2, Vector2Short) ComputeAngleVector(float angle, float magnitude) { float precision; - if (input.Angle == 0) { + if (angle == 0.0f) { precision = 1E-6f; } else { int digits = 0; - Match match = Fractional.Match(input.Angle.ToString(CultureInfo.InvariantCulture)); + + var match = Fractional.Match(angle.ToString(CultureInfo.InvariantCulture)); if (match.Success) { digits = match.Value.Length; } @@ -49,42 +51,54 @@ public static Vector2 ComputeAngleVector2(InputFrame input, out Vector2Short ang precision = float.Parse($"0.5E-{digits + 2}"); } - float x = input.GetX(); - float y = input.GetY(); + // Exactly map cardinal directions to avoid precision loss + float x = angle switch { + 0.0f => 0.0f, + 90.0f => 1.0f, + 180.0f => 0.0f, + 270.0f => -1.0f, + 360.0f => 0.0f, + _ => (float) Math.Sin(angle / 180.0 * Math.PI) + }; + float y = angle switch { + 0.0f => 1.0f, + 90.0f => 0.0f, + 180.0f => -1.0f, + 270.0f => 0.0f, + 360.0f => 1.0f, + _ => (float) Math.Cos(angle / 180.0 * Math.PI) + }; if (analogMode != AnalogMode.Precise) { - x *= input.UpperLimit; - y *= input.UpperLimit; + x *= magnitude; + y *= magnitude; } - Vector2 angleVector2 = ComputeFeather(x, y, precision, input.UpperLimit, out Vector2Short retDirectionShort); - angleVector2Short = retDirectionShort; - return angleVector2; + return ComputeFeather(x, y, precision, magnitude); } - private static Vector2 ComputeFeather(float x, float y, float precision, float upperLimit, out Vector2Short retDirectionShort) { - short RoundToValidShort(float f) { + private static (Vector2, Vector2Short) ComputeFeather(float x, float y, float precision, float upperLimit) { + static short RoundToValidShort(float f) { return (short) Math.Round((f * (1.0 - DeadZone) + DeadZone) * 32767); } + // Assure both are positive and x >= y if (x < 0) { - Vector2 feather = ComputeFeather(-x, y, precision, upperLimit, out Vector2Short directionShort); - retDirectionShort = new Vector2Short((short) -directionShort.X, directionShort.Y); - return new Vector2(-feather.X, feather.Y); + var (direction, directionShort) = ComputeFeather(-x, y, precision, upperLimit); + return (new Vector2(-direction.X, direction.Y), new Vector2Short((short) -directionShort.X, directionShort.Y)); } - if (y < 0) { - Vector2 feather = ComputeFeather(x, -y, precision, upperLimit, out Vector2Short directionShort); - retDirectionShort = new Vector2Short(directionShort.X, (short) -directionShort.Y); - return new Vector2(feather.X, -feather.Y); + var (direction, directionShort) = ComputeFeather(x, -y, precision, upperLimit); + return (new Vector2(direction.X, -direction.Y), new Vector2Short(directionShort.X, (short) -directionShort.Y)); } - if (x < y) { - Vector2 feather = ComputeFeather(y, x, precision, upperLimit, out Vector2Short directionShort); - retDirectionShort = new Vector2Short(directionShort.Y, directionShort.X); - return new Vector2(feather.Y, feather.X); + var (direction, directionShort) = ComputeFeather(y, x, precision, upperLimit); + return (new Vector2(direction.Y, direction.X), new Vector2Short(directionShort.Y, directionShort.X)); } - // assure positive and x>=y + Debug.Assert(x >= 0.0f); + Debug.Assert(y >= 0.0f); + Debug.Assert(x >= y); + short shortX, shortY; switch (analogMode) { case AnalogMode.Ignore: @@ -92,6 +106,7 @@ short RoundToValidShort(float f) { shortX = RoundToValidShort(x); shortY = RoundToValidShort(y); break; + case AnalogMode.Square: float divisor = Math.Max(Math.Abs(x), Math.Abs(y)); x /= divisor; @@ -99,25 +114,25 @@ short RoundToValidShort(float f) { shortX = RoundToValidShort(x); shortY = RoundToValidShort(y); break; + case AnalogMode.Precise: short upperbound = (short) Math.Round(Calc.Clamp(upperLimit * 32767, Lowerbound, 32767), MidpointRounding.AwayFromZero); Vector2Short result = ComputePrecise(new Vector2(x, y), precision, upperbound); shortX = result.X; shortY = result.Y; break; + default: - throw new Exception("what the fuck"); + throw new UnreachableException(); } - retDirectionShort = new Vector2Short(shortX, shortY); - if (analogMode == AnalogMode.Ignore) { - return new Vector2(x, y); + return (new Vector2(x, y), new Vector2Short(shortX, shortY)); } x = (float) (Math.Max(shortX / 32767.0 - DeadZone, 0.0) / (1 - DeadZone)); y = (float) (Math.Max(shortY / 32767.0 - DeadZone, 0.0) / (1 - DeadZone)); - return new Vector2(x, y); + return (new Vector2(x, y), new Vector2Short(shortX, shortY)); } private static Vector2Short ComputePrecise(Vector2 direction, float precision, short upperbound) { diff --git a/CelesteTAS-EverestInterop/Source/TAS/BindingHelper.cs b/CelesteTAS-EverestInterop/Source/TAS/BindingHelper.cs index a5e2e29c6..e669a5bb7 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/BindingHelper.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/BindingHelper.cs @@ -7,6 +7,7 @@ using Microsoft.Xna.Framework.Input; using Monocle; using MonoMod.Utils; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; using GameInput = Celeste.Input; @@ -14,23 +15,6 @@ namespace TAS; public static class BindingHelper { - private static readonly Type BindingType = typeof(Engine).Assembly.GetType("Monocle.Binding"); - - static BindingHelper() { - if (typeof(GameInput).GetFieldInfo("DemoDash") == null && typeof(GameInput).GetFieldInfo("CrouchDash") == null) { - DemoDash = 0; - DemoDash2 = 0; - LeftDashOnly = 0; - RightDashOnly = 0; - UpDashOnly = 0; - DownDashOnly = 0; - LeftMoveOnly = Keys.None; - RightMoveOnly = Keys.None; - UpMoveOnly = Keys.None; - DownMoveOnly = Keys.None; - } - } - public static Buttons JumpAndConfirm => Buttons.A; public static Buttons Jump2 => Buttons.Y; public static Buttons DashAndTalkAndCancel => Buttons.B; @@ -58,23 +42,59 @@ static BindingHelper() { private static bool? origControllerHasFocus; private static bool? origKbTextInput; private static bool? origAttached; - private static int? origCrouchDashMode; - private static int? origGrabMode; + private static CrouchDashModes? origCrouchDashMode; + private static GrabModes? origGrabMode; // ReSharper disable once UnusedMember.Local [EnableRun] private static void SetTasBindings() { Settings settingsBackup = Settings.Instance.ShallowClone(); - if (BindingType == null) { - SetTasBindingsV1312(); - } else { - SetTasBindingsNew(); + { + SetBinding("Left", Buttons.LeftThumbstickLeft, Buttons.DPadLeft); + SetBinding("Right", Buttons.LeftThumbstickRight, Buttons.DPadRight); + SetBinding("Down", Buttons.LeftThumbstickDown, Buttons.DPadDown); + SetBinding("Up", Buttons.LeftThumbstickUp, Buttons.DPadUp); + + SetBinding("MenuLeft", Buttons.LeftThumbstickLeft, Buttons.DPadLeft); + SetBinding("MenuRight", Buttons.LeftThumbstickRight, Buttons.DPadRight); + SetBinding("MenuDown", Buttons.LeftThumbstickDown, Buttons.DPadDown); + SetBinding("MenuUp", Buttons.LeftThumbstickUp, Buttons.DPadUp); + + SetBinding("Grab", Grab, Grab2); + SetBinding("Jump", JumpAndConfirm, Jump2); + SetBinding("Dash", DashAndTalkAndCancel, Dash2AndCancel); + SetBinding("Talk", DashAndTalkAndCancel, JournalAndTalk); + + SetBinding("Pause", Pause); + SetBinding("Confirm", new[] {Confirm2}, JumpAndConfirm); + SetBinding("Cancel", DashAndTalkAndCancel, Dash2AndCancel); + + SetBinding("Journal", JournalAndTalk); + SetBinding("QuickRestart", QuickRestart); + + SetBinding("DemoDash", DemoDash, DemoDash2); + + SetBinding("LeftDashOnly", LeftDashOnly); + SetBinding("RightDashOnly", RightDashOnly); + SetBinding("UpDashOnly", UpDashOnly); + SetBinding("DownDashOnly", DownDashOnly); + + SetBinding("LeftMoveOnly", new[] {LeftMoveOnly}); + SetBinding("RightMoveOnly", new[] {RightMoveOnly}); + SetBinding("UpMoveOnly", new[] {UpMoveOnly}); + SetBinding("DownMoveOnly", new[] {DownMoveOnly}); + + GameInput.Initialize(); + ClearModsBindings(); + + origControllerHasFocus = MInput.ControllerHasFocus; + MInput.ControllerHasFocus = true; } CoreModule.Instance.OnInputDeregister(); - if (Savestates.SpeedrunToolInstalled) { - SpeedrunToolUtils.InputDeregister(); + if (SpeedrunToolInterop.Installed) { + SpeedrunToolInterop.InputDeregister(); } Settings.Instance.CopyAllFields(settingsBackup); @@ -87,9 +107,7 @@ private static void SetTasBindings() { origAttached = MInput.GamePads[GameInput.Gamepad].Attached; MInput.GamePads[GameInput.Gamepad].Attached = true; - if (typeof(Settings).GetFieldInfo("CrouchDashMode") != null && typeof(Settings).GetFieldInfo("GrabMode") != null) { - SetDashGrabMode(); - } + SetDashGrabMode(); } // ReSharper disable once UnusedMember.Local @@ -103,90 +121,17 @@ private static void RestorePlayerBindings() { origAttached = null; } - if (origControllerHasFocus.HasValue) { - RestoreControllerHasFocus(); - } - - if (origCrouchDashMode.HasValue) { - RestoreDashGrabMode(); - } + RestoreControllerHasFocus(); + RestoreDashGrabMode(); } private static void RestoreControllerHasFocus() { - MInput.ControllerHasFocus = origControllerHasFocus.Value; + if (origControllerHasFocus.HasValue) { + MInput.ControllerHasFocus = origControllerHasFocus.Value; + } origControllerHasFocus = null; } - private static void SetTasBindingsV1312() { - DynamicData settings = Settings.Instance.GetDynamicDataInstance(); - settings.Set("Left", Keys.None); - settings.Set("Right", Keys.None); - settings.Set("Down", Keys.None); - settings.Set("Up", Keys.None); - - settings.Set("Grab", new List()); - settings.Set("Jump", new List()); - settings.Set("Dash", new List()); - settings.Set("Talk", new List()); - settings.Set("Pause", new List()); - settings.Set("Confirm", new List {Confirm2}); - settings.Set("Cancel", new List()); - settings.Set("Journal", new List()); - settings.Set("QuickRestart", new List()); - - settings.Set("BtnGrab", new List {Grab, Grab2}); - settings.Set("BtnJump", new List {JumpAndConfirm, Jump2}); - settings.Set("BtnDash", new List {DashAndTalkAndCancel, Dash2AndCancel}); - settings.Set("BtnTalk", new List {DashAndTalkAndCancel, JournalAndTalk}); - settings.Set("BtnAltQuickRestart", new List()); - - GameInput.Initialize(); - - GameInput.QuickRestart.AddButtons(new List {QuickRestart}); - } - - private static void SetTasBindingsNew() { - SetBinding("Left", Buttons.LeftThumbstickLeft, Buttons.DPadLeft); - SetBinding("Right", Buttons.LeftThumbstickRight, Buttons.DPadRight); - SetBinding("Down", Buttons.LeftThumbstickDown, Buttons.DPadDown); - SetBinding("Up", Buttons.LeftThumbstickUp, Buttons.DPadUp); - - SetBinding("MenuLeft", Buttons.LeftThumbstickLeft, Buttons.DPadLeft); - SetBinding("MenuRight", Buttons.LeftThumbstickRight, Buttons.DPadRight); - SetBinding("MenuDown", Buttons.LeftThumbstickDown, Buttons.DPadDown); - SetBinding("MenuUp", Buttons.LeftThumbstickUp, Buttons.DPadUp); - - SetBinding("Grab", Grab, Grab2); - SetBinding("Jump", JumpAndConfirm, Jump2); - SetBinding("Dash", DashAndTalkAndCancel, Dash2AndCancel); - SetBinding("Talk", DashAndTalkAndCancel, JournalAndTalk); - - SetBinding("Pause", Pause); - SetBinding("Confirm", new[] {Confirm2}, JumpAndConfirm); - SetBinding("Cancel", DashAndTalkAndCancel, Dash2AndCancel); - - SetBinding("Journal", JournalAndTalk); - SetBinding("QuickRestart", QuickRestart); - - SetBinding("DemoDash", DemoDash, DemoDash2); - - SetBinding("LeftDashOnly", LeftDashOnly); - SetBinding("RightDashOnly", RightDashOnly); - SetBinding("UpDashOnly", UpDashOnly); - SetBinding("DownDashOnly", DownDashOnly); - - SetBinding("LeftMoveOnly", new[] {LeftMoveOnly}); - SetBinding("RightMoveOnly", new[] {RightMoveOnly}); - SetBinding("UpMoveOnly", new[] {UpMoveOnly}); - SetBinding("DownMoveOnly", new[] {DownMoveOnly}); - - GameInput.Initialize(); - ClearModsBindings(); - - origControllerHasFocus = MInput.ControllerHasFocus; - MInput.ControllerHasFocus = true; - } - private static void ClearModsBindings() { foreach (EverestModule module in Everest.Modules) { if (module.SettingsType is { } settingsType && module._Settings is { } settings and not CelesteTasSettings) { @@ -214,17 +159,21 @@ private static void SetBinding(string fieldName, Keys[] keys, params Buttons[] b } private static void SetDashGrabMode() { - origCrouchDashMode = (int?) Settings.Instance.CrouchDashMode; + origCrouchDashMode = Settings.Instance.CrouchDashMode; Settings.Instance.CrouchDashMode = CrouchDashModes.Press; - origGrabMode = (int?) Settings.Instance.GrabMode; + origGrabMode = Settings.Instance.GrabMode; Settings.Instance.GrabMode = GrabModes.Hold; } - + private static void RestoreDashGrabMode() { - Settings.Instance.CrouchDashMode = (CrouchDashModes) origCrouchDashMode.Value; - Settings.Instance.GrabMode = (GrabModes) origGrabMode.Value; + if (origCrouchDashMode.HasValue) { + Settings.Instance.CrouchDashMode = origCrouchDashMode.Value; + } + if (origGrabMode.HasValue) { + Settings.Instance.GrabMode = origGrabMode.Value; + } origCrouchDashMode = null; origGrabMode = null; } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/TAS/ExportGameInfo.cs b/CelesteTAS-EverestInterop/Source/TAS/ExportGameInfo.cs index 0e98fe94a..ed0162df4 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/ExportGameInfo.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/ExportGameInfo.cs @@ -6,9 +6,10 @@ using Microsoft.Xna.Framework; using Monocle; using StudioCommunication; +using TAS.EverestInterop; using TAS.EverestInterop.InfoHUD; +using TAS.InfoHUD; using TAS.Input; -using TAS.Input.Commands; using TAS.Module; using TAS.Utils; @@ -93,13 +94,14 @@ private static void BeginExport(string path, string[] tracked) { streamWriter.WriteLine(string.Join("\t", "Line", "Inputs", "Frames", "Time", "Position", "Speed", "State", "Statuses", "Room", "Entities")); trackedEntities = new Dictionary>>(); foreach (string typeName in tracked) { - if (!InfoCustom.TryParseTypes(typeName, out List types)) { + var types = TargetQuery.ResolveBaseTypes(typeName.Split('.'), out _, out _, out _); + if (types.IsEmpty()) { continue; } - foreach (Type type in types) { + foreach (var type in types) { if (type.IsSameOrSubclassOf(typeof(Entity)) && type.FullName != null) { - trackedEntities[type.FullName] = () => InfoCustom.FindEntities(type, string.Empty); + trackedEntities[type.FullName] = () => TargetQuery.ResolveTypeInstances(type, [], EntityID.None).Cast().ToList(); } } } @@ -157,7 +159,7 @@ private static void ExportInfo(InputFrame inputFrame) { output += $"\t{customInfo.ReplaceLineBreak(" ")}"; } - if (InfoWatchEntity.GetInfo("\t", true, GetDecimals(TasSettings.CustomInfoDecimals, GameSettings.MaxDecimals)) is { } watchInfo && + if (InfoWatchEntity.GetInfo(TasSettings.InfoWatchEntityHudType, "\t", true, GetDecimals(TasSettings.CustomInfoDecimals, GameSettings.MaxDecimals)) is { } watchInfo && watchInfo.IsNotEmpty()) { output += $"\t{watchInfo}"; } diff --git a/CelesteTAS-EverestInterop/Source/TAS/ExportRoomInfo.cs b/CelesteTAS-EverestInterop/Source/TAS/ExportRoomInfo.cs index 7ffe29213..7ad2751ef 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/ExportRoomInfo.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/ExportRoomInfo.cs @@ -101,7 +101,7 @@ private static void BeginExport(string path) { } streamWriter = new StreamWriter(path); - streamWriter.WriteLine($"TAS File: {InputController.TasFilePath}"); + streamWriter.WriteLine($"TAS File: {Manager.Controller.FilePath}"); streamWriter.WriteLine(RoomInfo.GetTableHead()); } diff --git a/CelesteTAS-EverestInterop/Source/TAS/GameInfo.cs b/CelesteTAS-EverestInterop/Source/TAS/GameInfo.cs index 5a320fee5..df9b2f194 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/GameInfo.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/GameInfo.cs @@ -14,6 +14,8 @@ using TAS.Communication; using TAS.EverestInterop; using TAS.EverestInterop.InfoHUD; +using TAS.InfoHUD; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; @@ -28,7 +30,8 @@ public static class GameInfo { public static string ExactStatusWithoutTime = string.Empty; public static string LevelName = string.Empty; public static string ChapterTime = string.Empty; - public static string WatchingInfo = string.Empty; + public static string HudWatchingInfo = string.Empty; + public static string StudioWatchingInfo = string.Empty; public static string CustomInfo = string.Empty; public static Vector2Double LastDiff; public static Vector2Double LastPos; @@ -53,8 +56,8 @@ public static string HudInfo { infos.Add(CustomInfo); } - if ((TasSettings.InfoWatchEntity & HudOptions.HudOnly) != 0 && WatchingInfo.IsNotNullOrWhiteSpace()) { - infos.Add(WatchingInfo); + if (TasSettings.HudWatchEntity && HudWatchingInfo.IsNotNullOrWhiteSpace()) { + infos.Add(HudWatchingInfo); } return string.Join("\n\n", infos); @@ -73,8 +76,8 @@ public static string StudioInfo { infos.Add(CustomInfo); } - if ((TasSettings.InfoWatchEntity & HudOptions.StudioOnly) != 0 && WatchingInfo.IsNotNullOrWhiteSpace()) { - infos.Add(WatchingInfo); + if (TasSettings.StudioWatchEntity && StudioWatchingInfo.IsNotNullOrWhiteSpace()) { + infos.Add(StudioWatchingInfo); } return string.Join("\n\n", infos); @@ -89,15 +92,15 @@ public static string ExactStudioInfo { infos.Add(InfoMouse.MouseInfo); } - WatchingInfo = InfoWatchEntity.GetInfo(alwaysUpdate: true, decimals: GameSettings.MaxDecimals); + StudioWatchingInfo = InfoWatchEntity.GetInfo(TasSettings.InfoWatchEntityStudioType, alwaysUpdate: true, decimals: GameSettings.MaxDecimals); CustomInfo = InfoCustom.GetInfo(GameSettings.MaxDecimals); if (CustomInfo.IsNotNullOrWhiteSpace()) { infos.Add(CustomInfo); } - if (WatchingInfo.IsNotNullOrWhiteSpace()) { - infos.Add(WatchingInfo); + if (StudioWatchingInfo.IsNotNullOrWhiteSpace()) { + infos.Add(StudioWatchingInfo); } return string.Join("\n\n", infos); @@ -151,9 +154,7 @@ private static void EngineOnUpdate(On.Monocle.Engine.orig_Update orig, Engine se private static void SceneOnAfterUpdate(On.Monocle.Scene.orig_AfterUpdate orig, Scene self) { orig(self); - if (Manager.UltraFastForwarding) { - return; - } + // TODO: While fast forwarding, only store required data for frame and compute string later if (self is Level level) { Update(!level.wasPaused); @@ -197,6 +198,9 @@ private static int GetTransitionFrames(Level level, LevelData nextLevelData) { } public static void Update(bool updateVel = false) { + if (TasHelperInterop.InPrediction) { + return; + } Scene scene = Engine.Scene; if (scene is Level level) { Player player = level.Tracker.GetEntity(); @@ -220,8 +224,8 @@ public static void Update(bool updateVel = false) { string analog = string.Empty; string exactAnalog = string.Empty; - if (Manager.Running && Manager.Controller.Previous is { } inputFrame && inputFrame.HasActions(Actions.Feather)) { - analog = GetAdjustedAnalog(inputFrame.AngleVector2, out exactAnalog); + if (Manager.Running && Manager.Controller.Previous is { } inputFrame && EnumExtensions.Has(inputFrame.Actions, Actions.Feather)) { + analog = GetAdjustedAnalog(inputFrame.StickPosition, out exactAnalog); } string retainedSpeed = GetAdjustedRetainedSpeed(player, out string exactRetainedSpeed); @@ -277,7 +281,7 @@ public static void Update(bool updateVel = false) { timers += $"DashCD({dashCooldown}) "; } - if ((FramesPerGameSecond != 60 || SaveData.Instance.Assists.SuperDashing || ExtendedVariantsUtils.SuperDashing) && + if ((FramesPerGameSecond != 60 || SaveData.Instance.Assists.SuperDashing || ExtendedVariantsInterop.SuperDashing) && DashTime.ToCeilingFrames() >= 1 && player.StateMachine.State == Player.StDash) { DashTime = player.StateMachine.currentCoroutine.waitTimer; timers += $"Dash({DashTime.ToCeilingFrames()}) "; @@ -352,7 +356,8 @@ public static void Update(bool updateVel = false) { } else { LevelName = string.Empty; ChapterTime = string.Empty; - WatchingInfo = string.Empty; + HudWatchingInfo = string.Empty; + StudioWatchingInfo = string.Empty; CustomInfo = string.Empty; if (scene is SummitVignette summit) { Status = ExactStatus = $"SummitVignette {summit.ready}"; @@ -362,7 +367,7 @@ public static void Update(bool updateVel = false) { ouiName = $"{oui.GetType().Name} "; } - Status = ExactStatus = $"Overworld {ouiName}{overworld.ShowInputUI}"; + Status = ExactStatus = ouiName; } else if (scene != null) { Status = ExactStatus = scene.GetType().Name; } @@ -370,12 +375,7 @@ public static void Update(bool updateVel = false) { } private static void UpdateAdditionInfo() { - if (TasSettings.InfoHud && (TasSettings.InfoWatchEntity & HudOptions.HudOnly) != 0 || - (TasSettings.InfoWatchEntity & HudOptions.StudioOnly) != 0 && CommunicationWrapper.Connected) { - WatchingInfo = InfoWatchEntity.GetInfo(); - } else { - WatchingInfo = string.Empty; - } + InfoWatchEntity.UpdateInfo(); if (TasSettings.InfoHud && (TasSettings.InfoCustom & HudOptions.HudOnly) != 0 || (TasSettings.InfoCustom & HudOptions.StudioOnly) != 0 && CommunicationWrapper.Connected) { @@ -458,7 +458,7 @@ private static string GetStatusWithoutTime(string pos, string speed, string velo || playerSeeker != null || SaveData.Instance.Assists.ThreeSixtyDashing || SaveData.Instance.Assists.SuperDashing - || ExtendedVariantsUtils.SuperDashing) { + || ExtendedVariantsInterop.SuperDashing) { builder.AppendLine(polarVel); } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Command.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Command.cs index 5e567edfa..7dc608f4b 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Command.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Command.cs @@ -1,5 +1,6 @@ using Celeste.Mod; using Celeste.Mod.Helpers; +using JetBrains.Annotations; using StudioCommunication; using System; using System.Collections.Generic; @@ -12,8 +13,6 @@ namespace TAS.Input; -#nullable enable - [Flags] public enum ExecuteTiming : byte { /// Executes the command while parsing inputs, like Read commands @@ -24,7 +23,7 @@ public enum ExecuteTiming : byte { /// Creates a command which can be used inside TAS files /// The signature of the target method **must** match -[AttributeUsage(AttributeTargets.Method)] +[AttributeUsage(AttributeTargets.Method), MeansImplicitUse] public class TasCommandAttribute(string name) : Attribute { /// Name of this command inside the TAS file public readonly string Name = name; @@ -70,6 +69,7 @@ public bool IsName(string name) { } } +/// Represents a fully parsed command-line in a TAS file public readonly record struct Command( CommandLine CommandLine, TasCommandAttribute Attribute, @@ -90,7 +90,7 @@ int Frame public string[] Args => CommandLine.Arguments; public string LineText => CommandLine.Arguments.Length == 0 ? Attribute.Name : $"{Attribute.Name}{DefaultSeparator}{string.Join(DefaultSeparator, CommandLine.Arguments)}"; - public static bool TryParse(InputController inputController, string filePath, int fileLine, string lineText, int frame, int studioLine, out Command command) { + public static bool TryParse(string filePath, int fileLine, string lineText, int frame, int studioLine, out Command command) { command = default; if (string.IsNullOrWhiteSpace(lineText) || !char.IsLetter(lineText[0]) || !CommandLine.TryParse(lineText, out var commandLine)) { @@ -114,11 +114,6 @@ public static bool TryParse(InputController inputController, string filePath, in } command = new Command(commandLine, info, filePath, fileLine, studioLine, frame); - if (!inputController.Commands.TryGetValue(frame, out var commands)) { - inputController.Commands[frame] = commands = new List(); - } - commands.Add(command); - return true; } catch (Exception e) { e.LogException(error); diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/AssertCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/AssertCommand.cs index fb8e70f8a..a62001dca 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/AssertCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/AssertCommand.cs @@ -2,7 +2,7 @@ using StudioCommunication; using System.Collections.Generic; using System.IO; -using TAS.EverestInterop.InfoHUD; +using TAS.InfoHUD; using TAS.Utils; namespace TAS.Input.Commands; @@ -63,7 +63,7 @@ private static void Assert(CommandLine commandLine, int studioLine, string fileP string? failureMessage = args.Length >= 4 ? args[3] : null; Running = true; - string actual = InfoCustom.ParseTemplate(actualTemplate, 0, [], false); + string actual = string.Join("\n", InfoCustom.ParseTemplateLine(actualTemplate, 0)); Running = false; switch (condition) { @@ -71,7 +71,7 @@ private static void Assert(CommandLine commandLine, int studioLine, string fileP if (actual != expected) { failureMessage ??= $""" Expected equal: {expected} - But was: {actual}" + But was: {actual} """; AbortTas($"{prefix}{failureMessage}", true, 4f); } @@ -81,7 +81,7 @@ private static void Assert(CommandLine commandLine, int studioLine, string fileP if (actual == expected) { failureMessage ??= $""" Expected not equal: {expected} - But was: {actual}" + But was: {actual} """; AbortTas($"{prefix}{failureMessage}", true, 4f); } @@ -91,7 +91,7 @@ private static void Assert(CommandLine commandLine, int studioLine, string fileP if (!actual.Contains(expected)) { failureMessage ??= $""" Expected contain: {expected} - But was: {actual}" + But was: {actual} """; AbortTas($"{prefix}{failureMessage}", true, 4f); } @@ -100,7 +100,7 @@ private static void Assert(CommandLine commandLine, int studioLine, string fileP if (actual.Contains(expected)) { failureMessage ??= $""" Expected not contain: {expected} - But was: {actual}" + But was: {actual} """; AbortTas($"{prefix}{failureMessage}", true, 4f); } @@ -109,7 +109,7 @@ private static void Assert(CommandLine commandLine, int studioLine, string fileP if (!actual.StartsWith(expected)) { failureMessage ??= $""" Expected starts with: {expected} - But was: {actual}" + But was: {actual} """; AbortTas($"{prefix}{failureMessage}", true, 4f); } @@ -118,7 +118,7 @@ private static void Assert(CommandLine commandLine, int studioLine, string fileP if (actual.StartsWith(expected)) { failureMessage ??= $""" Expected not starts with: {expected} - But was: {actual}" + But was: {actual} """; AbortTas($"{prefix}{failureMessage}", true, 4f); } @@ -127,7 +127,7 @@ private static void Assert(CommandLine commandLine, int studioLine, string fileP if (!actual.EndsWith(expected)) { failureMessage ??= $""" Expected ends with: {expected} - But was: {actual}" + But was: {actual} """; AbortTas($"{prefix}{failureMessage}", true, 4f); } @@ -136,7 +136,7 @@ private static void Assert(CommandLine commandLine, int studioLine, string fileP if (actual.EndsWith(expected)) { failureMessage ??= $""" Expected not ends with: {expected} - But was: {actual}" + But was: {actual} """; AbortTas($"{prefix}{failureMessage}", true, 4f); } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/AutoInputCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/AutoInputCommand.cs index 32bd08f14..22658c372 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/AutoInputCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/AutoInputCommand.cs @@ -178,7 +178,7 @@ public static bool TryInsert(string filePath, string lineText, int studioLine, i return false; } - bool mainFile = filePath == InputController.TasFilePath; + bool mainFile = filePath == Manager.Controller.FilePath; int frames = 0; int parsedFrames = 0; @@ -201,8 +201,14 @@ public static bool TryInsert(string filePath, string lineText, int studioLine, i arguments.CycleOffset--; if (arguments.CycleOffset == 0 || i == inputFrame.Frames - 1) { - Manager.Controller.AddFrames(frames + inputFrame.ToActionsString(), studioLine, repeatIndex, repeatCount, - mainFile ? parsedFrames : 0); + Manager.Controller.AddFrames(inputFrame with { + Frames = frames, + Line = studioLine, + RepeatCount = repeatCount, + RepeatIndex = repeatIndex, + FrameOffset = mainFile ? parsedFrames : 0, + }); + parsedFrames += frames; frames = 0; } @@ -221,7 +227,7 @@ public static void ParseInsertedLines(Arguments arguments, string filePath, int arguments.Inputs, filePath, arguments.StartLine, - filePath == InputController.TasFilePath ? arguments.StartLine - 1 : studioLine, + filePath == Manager.Controller.FilePath ? arguments.StartLine - 1 : studioLine, repeatIndex, repeatCount, arguments.LockStudioLine diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/ConsoleCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/ConsoleCommand.cs index 321e87cea..2ca79f676 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/ConsoleCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/ConsoleCommand.cs @@ -11,6 +11,7 @@ using Monocle; using MonoMod.Cil; using StudioCommunication; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/GunCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/GunCommand.cs index f72972aa9..77e0a9b1e 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/GunCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/GunCommand.cs @@ -4,6 +4,7 @@ using Microsoft.Xna.Framework; using Monocle; using StudioCommunication; +using TAS.ModInterop; using TAS.Utils; namespace TAS.Input.Commands; diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/InvokeCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/InvokeCommand.cs index 906c95e46..46ae10434 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/InvokeCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/InvokeCommand.cs @@ -4,13 +4,14 @@ using System.Reflection; using Celeste; using Celeste.Mod; -using Microsoft.Xna.Framework; +using JetBrains.Annotations; using Monocle; using StudioCommunication; using StudioCommunication.Util; using System.Runtime.CompilerServices; +using TAS.Entities; using TAS.EverestInterop; -using TAS.EverestInterop.InfoHUD; +using TAS.ModInterop; using TAS.Utils; namespace TAS.Input.Commands; @@ -43,7 +44,7 @@ public IEnumerator GetAutoCompleteEntries(string[] arg var allTypes = ModUtils.GetTypes(); foreach ((string typeName, var type) in allTypes .Select(type => (type.CSharpName(), type)) - .Order(new SetCommand.SetMeta.NamespaceComparer())) + .Order(new NamespaceComparer())) { if ( // Filter-out types which probably aren't useful @@ -77,8 +78,8 @@ public IEnumerator GetAutoCompleteEntries(string[] arg yield return new CommandAutoCompleteEntry { Name = $"{uniqueTypeName}.", Extra = type.Namespace ?? string.Empty, IsDone = false }; } - } else if (targetArgs.Length >= 1 && InfoCustom.TryParseTypes(targetArgs[0], out var types, out _, out _)) { - // Let's just assume the first type + } else if (targetArgs.Length >= 1 && TargetQuery.ResolveBaseTypes(targetArgs, out _, out _, out _) is { } types && types.IsNotEmpty()) { + // Assume the first type foreach (var entry in GetInvokeTypeAutoCompleteEntries(types[0], targetArgs.Length == 1)) { yield return entry with { Name = entry.Name + (entry.IsDone ? "" : "."), Prefix = string.Join('.', targetArgs) + ".", HasNext = true }; } @@ -101,10 +102,11 @@ private static IEnumerable GetInvokeTypeAutoCompleteEn } } + [MustDisposeResource] private static IEnumerator GetParameterAutoCompleteEntries(string[] targetArgs, int parameterIndex) { - if (targetArgs.Length == 2 && InfoCustom.TryParseTypes(targetArgs[0], out var types, out _, out _)) { - // Let's just assume the first type - var parameters = types[0].GetMethodInfo(targetArgs[1]).GetParameters(); + if (targetArgs.Length >= 1 && TargetQuery.ResolveBaseTypes(targetArgs, out string[] memberArgs, out _, out _) is { } types && types.IsNotEmpty() && memberArgs.Length == 1) { + // Assume the first type + var parameters = types[0].GetMethodInfo(memberArgs[0]).GetParameters(); if (parameterIndex >= 0 && parameterIndex < parameters.Length) { // End arguments if further parameters aren't settable anymore bool final = parameterIndex == parameters.Length - 1 || @@ -138,228 +140,98 @@ private static bool IsInvokableMethod(MethodInfo info) { } } - private static bool consolePrintLog; - private const string logPrefix = "Invoke Command Failed: "; - private static readonly object nonReturnObject = new(); + private static (string Name, int Line)? activeFile; - private static readonly List errorLogs = new List(); - private static bool suspendLog = false; + private static void ReportError(string message) { + if (activeFile == null) { + $"Invoke Command Failed: {message}".ConsoleLog(LogLevel.Error); + } else { + Toast.ShowAndLog($""" + Invoke '{activeFile.Value.Name}' line {activeFile.Value.Line} failed: + {message} + """); + } + } - [Monocle.Command("invoke", "Invoke level/session/entity method. eg invoke Level.Pause; invoke Player.Jump (CelesteTAS)")] - private static void Invoke(string arg1, string arg2, string arg3, string arg4, string arg5, string arg6, string arg7, string arg8, - string arg9) { - string[] args = {arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9}; - consolePrintLog = true; - Invoke(args.TakeWhile(arg => arg != null).ToArray()); - consolePrintLog = false; + [Monocle.Command("invoke", "Invoke level/session/entity method. eg invoke Level.Pause; invoke Player.Jump (CelesteTAS)"), UsedImplicitly] + private static void ConsoleInvoke(string? arg1, string? arg2, string? arg3, string? arg4, string? arg5, string? arg6, string? arg7, string? arg8, string? arg9) { + // TODO: Support arbitrary amounts of arguments + string?[] args = [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9]; + Invoke(args.TakeWhile(arg => arg != null).ToArray()!); } - // Invoke, Type.StaticMethod, Parameters... // Invoke, Level.Method, Parameters... // Invoke, Session.Method, Parameters... // Invoke, Entity.Method, Parameters... + // Invoke, Type.StaticMethod, Parameters... [TasCommand("Invoke", LegalInFullGame = false, MetaDataProvider = typeof(InvokeMeta))] private static void Invoke(CommandLine commandLine, int studioLine, string filePath, int fileLine) { + activeFile = (filePath, fileLine); Invoke(commandLine.Arguments); + activeFile = null; } private static void Invoke(string[] args) { if (args.Length < 1) { + ReportError("Target-query required"); return; } - try { - if (args[0].Contains(".")) { - string[] parameters = args.Skip(1).ToArray(); - if (InfoCustom.TryParseMemberNames(args[0], out string typeText, out List memberNames, out string errorMessage) - && InfoCustom.TryParseTypes(typeText, out List types, out string entityId, out errorMessage)) { - bool existSuccess = false; - bool forSpecific = entityId.IsNotNullOrEmpty(); - suspendLog = true; - foreach (Type type in types) { - object result = FindObjectAndInvoke(type, entityId, memberNames, parameters); - bool hasReturned = result != nonReturnObject; - if (hasReturned) { - result ??= "null"; - result.Log(consolePrintLog); - } - existSuccess |= hasReturned; - if (forSpecific && hasReturned) { - break; - } - } - suspendLog = false; - if (!forSpecific || !existSuccess) { - errorLogs.Where(text => !existSuccess || !text.EndsWith(" entity is not found") && !text.EndsWith(" object is not found")).ToList().ForEach(Log); - } - errorLogs.Clear(); - } else { - errorMessage.Log(consolePrintLog, LogLevel.Warn); - } - } - } catch (Exception e) { - e.Log(consolePrintLog, LogLevel.Warn); - } - } - - private static object FindObjectAndInvoke(Type type, string entityId, List memberNames, string[] parameters) { - if (memberNames.IsEmpty()) { - return nonReturnObject; - } - - string lastMemberName = memberNames.Last(); - memberNames = memberNames.SkipLast(1).ToList(); - - Type objType; - object obj = null; - if (memberNames.IsEmpty() && type.GetMethodInfo(lastMemberName) is {IsStatic: true}) { - objType = type; - } else if (memberNames.IsNotEmpty() && InfoCustom.GetMemberValue(type, null, memberNames) is { } value) { - obj = value; - if (TryPrintErrorLog()) { - return nonReturnObject; - } - - objType = obj.GetType(); - } else { - if (memberNames.IsEmpty() && type.GetMethodInfo(lastMemberName, null) == null) { - Log($"{type.FullName}.{lastMemberName} method is not found"); - return nonReturnObject; - } - - obj = SetCommand.FindSpecialObject(type, entityId); - if (obj == null) { - Log($"{type.FullName}{entityId.LogId()} object is not found"); - return nonReturnObject; - } else { - if (type.IsSameOrSubclassOf(typeof(Entity)) && obj is List entities) { - if (entities.IsEmpty()) { - Log($"{type.FullName}{entityId.LogId()} entity is not found"); - return nonReturnObject; - } else { - List memberValues = new(); - foreach (Entity entity in entities) { - object memberValue = InfoCustom.GetMemberValue(type, entity, memberNames); - if (TryPrintErrorLog()) { - return nonReturnObject; - } - - if (memberValue != null) { - memberValues.Add(memberValue); - } - } - - if (memberValues.IsEmpty()) { - return nonReturnObject; - } - - obj = memberValues; - objType = memberValues.First().GetType(); - } - } else { - obj = InfoCustom.GetMemberValue(type, obj, memberNames); - if (TryPrintErrorLog()) { - return null; - } + string query = args[0]; + string[] queryArgs = query.Split('.'); - objType = obj.GetType(); - } - } + var baseTypes = TargetQuery.ResolveBaseTypes(queryArgs, out string[] memberArgs, out var componentTypes, out var entityId); + if (baseTypes.IsEmpty()) { + ReportError($"Failed to find base type for query '{query}'"); + return; } - - if (type.IsSameOrSubclassOf(typeof(Entity)) && obj is List objects) { - List result = new(); - foreach (object o in objects) { - if (TryInvokeMethod(o, out object r)) { - r ??= "null"; - result.Add(r); - } - } - - return result.IsEmpty() ? nonReturnObject : string.Join("\n", result); - } else { - if (TryInvokeMethod(obj, out object r)) { - return r; - } else { - return nonReturnObject; - } + if (memberArgs.IsEmpty()) { + ReportError("No members specified"); + return; } - bool TryInvokeMethod(object @object, out object returnObject) { - if (objType.GetMethodInfo(lastMemberName) is { } methodInfo) { - List parameterInfos = methodInfo.GetParameters().ToList(); - object[] p = new object[parameterInfos.Count]; - for (int i = 0; i < parameterInfos.Count; i++) { - object convertedObj; - ParameterInfo parameterInfo = parameterInfos[i]; - Type parameterType = parameterInfo.ParameterType; - - if (parameters.IsEmpty()) { - p[i] = parameterInfo.HasDefaultValue ? parameterInfo.DefaultValue : SetCommand.Convert(null, parameterType); - continue; + foreach (var type in baseTypes) { + if (componentTypes.IsNotEmpty()) { + foreach (var componentType in componentTypes) { + (var method, bool success) = TargetQuery.ResolveMemberMethod(componentType, memberArgs); + if (!success) { + ReportError($"Failed to find method '{string.Join('.', memberArgs)}' on type '{type}'"); + return; } - if (parameterType == typeof(Vector2)) { - string[] array = parameters.Take(2).ToArray(); - float.TryParse(array.GetValueOrDefault(0), out float x); - float.TryParse(array.GetValueOrDefault(1), out float y); - convertedObj = new Vector2(x, y); - parameters = parameters.Skip(2).ToArray(); - } else if (parameterType.IsSameOrSubclassOf(typeof(Entity))) { - if (InfoCustom.TryParseType(parameters[0], out Type entityType, out string id, out string errorMessage)) { - convertedObj = ((List) SetCommand.FindSpecialObject(entityType, id)).FirstOrDefault(); - } else { - Log(errorMessage); - convertedObj = null; - } - } else if (parameterType == typeof(Level)) { - convertedObj = Engine.Scene.GetLevel(); - } else if (parameterType == typeof(Session)) { - convertedObj = Engine.Scene.GetSession(); - } else { - convertedObj = SetCommand.Convert(parameters.FirstOrDefault(), parameterType); - parameters = parameters.Skip(1).ToArray(); + (object?[] values, success, string errorMessage) = TargetQuery.ResolveValues(args[1..], method!.GetParameters().Select(param => param.ParameterType).ToArray()); + if (!success) { + ReportError(errorMessage); + return; } - p[i] = convertedObj; + var instances = TargetQuery.ResolveTypeInstances(type, [componentType], entityId); + success = TargetQuery.InvokeMemberMethods(componentType, instances, values, memberArgs); + if (!success) { + ReportError($"Failed to invoke method '{string.Join('.', memberArgs)}' on type '{componentType}' to with parameters '{string.Join(';', values)}'"); + return; + } } - - returnObject = methodInfo.Invoke(@object, p); - return methodInfo.ReturnType != typeof(void); } else { - Log($"{objType.FullName}.{lastMemberName} member not found"); - returnObject = nonReturnObject; - return false; - } - } - - bool TryPrintErrorLog() { - if (obj == null) { - Log($"{type.FullName}{entityId.LogId()} member value is null"); - return true; - } else if (obj is string errorMsg && errorMsg.EndsWith(" not found")) { - Log(errorMsg); - return true; - } - - return false; - } - - } + (var method, bool success) = TargetQuery.ResolveMemberMethod(type, memberArgs); + if (!success) { + ReportError($"Failed to find method '{string.Join('.', memberArgs)}' on type '{type}'"); + return; + } - private static string LogId(this string entityId) { - return entityId.IsNullOrEmpty() ? "" : $"[{entityId}]"; - } + (object?[] values, success, string errorMessage) = TargetQuery.ResolveValues(args[1..], method!.GetParameters().Select(param => param.ParameterType).ToArray()); + if (!success) { + ReportError(errorMessage); + return; + } - private static void Log(string text) { - if (suspendLog) { - errorLogs.Add(text); - return; - } - if (!consolePrintLog) { - text = $"{logPrefix}{text}"; + var instances = TargetQuery.ResolveTypeInstances(type, componentTypes, entityId); + success = TargetQuery.InvokeMemberMethods(type, instances, values, memberArgs); + if (!success) { + ReportError($"Failed to invoke method '{string.Join('.', memberArgs)}' on type '{type}' to with parameters '{string.Join(';', values)}'"); + return; + } + } } - - text.Log(consolePrintLog, LogLevel.Warn); } } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/MetadataCommands.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/MetadataCommands.cs index 44b56e2e5..49734e7ed 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/MetadataCommands.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/MetadataCommands.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.IO; using System.Linq; using Celeste; @@ -109,25 +108,26 @@ public static void UpdateRecordCount(InputController inputController) { } private static void UpdateAllMetadata(string commandName, Func getMetadata, Func predicate = null) { - InputController inputController = Manager.Controller; - string tasFilePath = InputController.TasFilePath; - IEnumerable metadataCommands = inputController.Commands.SelectMany(pair => pair.Value) - .Where(command => command.Is(commandName) && command.FilePath == InputController.TasFilePath) + string tasFilePath = Manager.Controller.FilePath; + var metadataCommands = Manager.Controller.Commands.SelectMany(pair => pair.Value) + .Where(command => command.Is(commandName) && command.FilePath == Manager.Controller.FilePath) .Where(predicate ?? (_ => true)) .ToList(); - Dictionary updateLines = metadataCommands.Where(command => { - string metadata = getMetadata(command); - if (metadata.IsNullOrEmpty()) { - return false; - } + var updateLines = metadataCommands + .Where(command => { + string metadata = getMetadata(command); + if (metadata.IsNullOrEmpty()) { + return false; + } - if (command.Args.Length > 0 && command.Args[0] == metadata) { - return false; - } + if (command.Args.Length > 0 && command.Args[0] == metadata) { + return false; + } - return true; - }).ToDictionary(command => command.StudioLine, command => $"{command.Attribute.Name}: {getMetadata(command)}"); + return true; + }) + .ToDictionary(command => command.StudioLine, command => $"{command.Attribute.Name}: {getMetadata(command)}"); if (updateLines.IsEmpty()) { return; @@ -135,15 +135,17 @@ private static void UpdateAllMetadata(string commandName, Func string[] allLines = File.ReadAllLines(tasFilePath); int allLinesLength = allLines.Length; - foreach (int lineNumber in updateLines.Keys) { + foreach ((int lineNumber, string replacement) in updateLines) { if (lineNumber >= 0 && lineNumber < allLinesLength) { - allLines[lineNumber] = updateLines[lineNumber]; + allLines[lineNumber] = replacement; } } + // Prevent a reload from being triggered by the file-system change bool needsReload = Manager.Controller.NeedsReload; File.WriteAllLines(tasFilePath, allLines); Manager.Controller.NeedsReload = needsReload; + CommunicationWrapper.SendUpdateLines(updateLines); } } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/PlayCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/PlayCommand.cs index 78d7cf22b..e6dab95e6 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/PlayCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/PlayCommand.cs @@ -25,7 +25,7 @@ public IEnumerator GetAutoCompleteEntries(string[] arg // Don't include labels before the current line foreach (string line in File.ReadAllText(filePath).ReplaceLineEndings("\n").Split('\n').Skip(fileLine)) { - if (!StudioCommunication.Comment.IsLabel(line)) { + if (!StudioCommunication.CommentLine.IsLabel(line)) { continue; } @@ -40,7 +40,7 @@ public IEnumerator GetAutoCompleteEntries(string[] arg [TasCommand("Play", ExecuteTiming = ExecuteTiming.Parse, MetaDataProvider = typeof(PlayMeta))] private static void Play(CommandLine commandLine, int studioLine, string filePath, int fileLine) { string[] args = commandLine.Arguments; - if (!ReadCommand.TryGetLine(args[0], InputController.TasFilePath, out int startLine)) { + if (!ReadCommand.TryGetLine(args[0], Manager.Controller.FilePath, out int startLine)) { AbortTas($"\"Play, {string.Join(", ", args)}\" failed\n{args[0]} is invalid", true); return; } @@ -54,6 +54,6 @@ private static void Play(CommandLine commandLine, int studioLine, string filePat return; } - Manager.Controller.ReadFile(InputController.TasFilePath, startLine, int.MaxValue, startLine - 1); + Manager.Controller.ReadFile(Manager.Controller.FilePath, startLine, int.MaxValue, startLine - 1); } } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/ReadCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/ReadCommand.cs index 0ff8e988a..13b661622 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/ReadCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/ReadCommand.cs @@ -20,7 +20,7 @@ public int GetHash(string[] args, string filePath, int fileLine) { .Aggregate(17, (current, arg) => 31 * current + 17 * arg.GetStableHashCode()); // Auto-complete entries are based on current file path - hash = 31 * hash + 17 * InputController.StudioTasFilePath.GetStableHashCode(); + hash = 31 * hash + 17 * Manager.Controller.FilePath.GetStableHashCode(); if (args.Length >= 1 && !string.IsNullOrWhiteSpace(args[0])) { if (Path.GetDirectoryName(filePath) is not { } fileDir) { @@ -89,7 +89,7 @@ public IEnumerator GetAutoCompleteEntries(string[] arg // Don't include labels before the starting one for the ending label bool afterStartingLabel = args.Length == 2; foreach (string line in File.ReadAllText(fullPath).ReplaceLineEndings("\n").Split('\n')) { - if (!StudioCommunication.Comment.IsLabel(line)) { + if (!StudioCommunication.CommentLine.IsLabel(line)) { continue; } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/RecordingCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/RecordingCommand.cs index 942225dd5..1a9600807 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/RecordingCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/RecordingCommand.cs @@ -3,6 +3,7 @@ using System.Linq; using StudioCommunication; using TAS.Communication; +using TAS.ModInterop; using TAS.Utils; namespace TAS.Input.Commands; @@ -19,7 +20,7 @@ internal record RecordingTime { // workaround the first few frames get skipped when there is a breakpoint after StartRecording command public static bool StopFastForward { get { - if (Manager.Recording) { + if (TASRecorderInterop.Recording) { return true; } @@ -35,18 +36,18 @@ public static bool StopFastForward { private static void StartRecording(CommandLine commandLine, int studioLine, string filePath, int fileLine) { if (ParsingCommand) { if (CommunicationWrapper.Connected && Manager.Running) { - if (!TASRecorderUtils.Installed) { + if (!TASRecorderInterop.Installed) { CommunicationWrapper.SendRecordingFailed(RecordingFailedReason.TASRecorderNotInstalled); - } else if (!TASRecorderUtils.FFmpegInstalled) { + } else if (!TASRecorderInterop.FFmpegInstalled) { CommunicationWrapper.SendRecordingFailed(RecordingFailedReason.FFmpegNotInstalled); } } - if (!TASRecorderUtils.Installed) { + if (!TASRecorderInterop.Installed) { AbortTas("TAS Recorder isn't installed"); return; } - if (!TASRecorderUtils.FFmpegInstalled) { + if (!TASRecorderInterop.FFmpegInstalled) { AbortTas("FFmpeg libraries aren't properly installed"); return; } @@ -63,19 +64,18 @@ private static void StartRecording(CommandLine commandLine, int studioLine, stri RecordingTime time = new() { StartFrame = Manager.Controller.Inputs.Count }; RecordingTimes[time.StartFrame] = time; } else { - if (Manager.Recording) { + if (TASRecorderInterop.Recording) { AbortTas("Tried to start recording, while already recording"); return; } - TASRecorderUtils.StartRecording(); + TASRecorderInterop.StartRecording(); if (RecordingTimes.TryGetValue(Manager.Controller.CurrentFrameInTas, out RecordingTime time) && time.StartFrame != int.MaxValue && time.StopFrame != int.MaxValue) { - TASRecorderUtils.SetDurationEstimate(time.Duration); + TASRecorderInterop.SetDurationEstimate(time.Duration); } - Manager.States &= ~States.FrameStep; - Manager.NextStates &= ~States.FrameStep; + Manager.CurrState = Manager.NextState = Manager.State.Running; } } @@ -100,7 +100,7 @@ private static void StopRecording(CommandLine commandLine, int studioLine, strin last.StopFrame = Manager.Controller.Inputs.Count; } else { - TASRecorderUtils.StopRecording(); + TASRecorderInterop.StopRecording(); } } @@ -123,8 +123,8 @@ private static void Clear() { [DisableRun] private static void DisableRun() { - if (Manager.Recording) { - TASRecorderUtils.StopRecording(); + if (TASRecorderInterop.Recording) { + TASRecorderInterop.StopRecording(); } } } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/RepeatCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/RepeatCommand.cs index 26e8722b6..263c00f36 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/RepeatCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/RepeatCommand.cs @@ -68,13 +68,11 @@ private static void Repeat(CommandLine commandLine, int studioLine, string fileP private static void EndRepeat(CommandLine commandLine, int studioLine, string filePath, int fileLine) { string[] args = commandLine.Arguments; string errorText = $"{Path.GetFileName(filePath)} line {fileLine}\n"; - if (!RepeatArgs.TryGetValue(filePath, out var arguments)) { + if (!RepeatArgs.Remove(filePath, out var arguments)) { AbortTas($"{errorText}EndRepeat command does not have a paired Repeat command"); return; } - RepeatArgs.Remove(filePath); - int endLine = fileLine - 1; int startLine = arguments.StartLine; int count = arguments.Count; @@ -85,7 +83,7 @@ private static void EndRepeat(CommandLine commandLine, int studioLine, string fi } InputController inputController = Manager.Controller; - bool mainFile = filePath == InputController.TasFilePath; + bool mainFile = filePath == inputController.FilePath; // first loop needs to set repeat index and repeat count if (mainFile) { diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/SaveAndQuitReenterCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/SaveAndQuitReenterCommand.cs index f321beaf0..c1713e9f4 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/SaveAndQuitReenterCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/SaveAndQuitReenterCommand.cs @@ -1,11 +1,8 @@ -using System.Collections.Generic; using System.Reflection; using Celeste; -using Celeste.Mod; -using Mono.Cecil.Cil; using Monocle; -using MonoMod.Utils; using StudioCommunication; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; @@ -20,7 +17,7 @@ private static int ActiveFileSlot { return 0; } - if (Engine.Scene is Overworld {Current: OuiFileSelect select}) { + if (Engine.Scene is Overworld { Current: OuiFileSelect select }) { return select.SlotIndex; } @@ -28,39 +25,24 @@ private static int ActiveFileSlot { } } - // Contains which slot was used for each command, to ensure that inputs before the current frame stay the same - public static Dictionary InsertedSlots = new(); - [Load] private static void Load() { - FieldInfo fieldInfo = typeof(SaveAndQuitReenterCommand).GetFieldInfo(nameof(justPressedSnQ)); + var f_justPressedSnQ = typeof(SaveAndQuitReenterCommand).GetFieldInfo(nameof(justPressedSnQ)); - // v1400 - MethodInfo pauseMethod = typeof(Level) + // Set justPressedSnQ to true when button is pressed + typeof(Level) .GetNestedType("<>c__DisplayClass149_0", BindingFlags.NonPublic) - ?.GetMethodInfo("b__8"); - - // v1312 - if (pauseMethod == null) { - pauseMethod = typeof(Level) - .GetNestedType("<>c__DisplayClass146_5", BindingFlags.NonPublic) - ?.GetMethodInfo("b__11"); - } - - if (pauseMethod == null) { - "[SaveAndQuitReenterCommand] Failed to hook pause action".Log(LogLevel.Warn); - return; - } - - pauseMethod.IlHook((cursor, _) => cursor.Emit(OpCodes.Ldc_I4_1).Emit(OpCodes.Stsfld,fieldInfo)); - - typeof(Level).GetMethod("Update").IlHook((cursor, _) => cursor.Emit(OpCodes.Ldc_I4_0) - .Emit(OpCodes.Stsfld, fieldInfo)); - } - - [ClearInputs] - private static void Clear() { - InsertedSlots.Clear(); + .GetMethodInfo("b__8") + .IlHook((cursor, _) => cursor + .EmitLdcI4(/*true*/ 1) + .EmitStsfld(f_justPressedSnQ)); + + // Reset justPressedSnQ back to false + typeof(Level) + .GetMethod("Update") + .IlHook((cursor, _) => cursor + .EmitLdcI4(/*false*/ 0) + .EmitStsfld(f_justPressedSnQ)); } [DisableRun] @@ -74,15 +56,9 @@ private static void SaveAndQuitReenter(CommandLine commandLine, int studioLine, if (ParsingCommand) { int slot = ActiveFileSlot; - if (InsertedSlots.TryGetValue(studioLine, out int prevSlot)) { - slot = prevSlot; - } else { - InsertedSlots[studioLine] = slot; - } - bool safe = SafeCommand.DisallowUnsafeInputParsing; if (safe) { - Command.TryParse(controller, filePath, fileLine, "Unsafe", controller.CurrentParsingFrame, studioLine, out _); + controller.ReadLine("Unsafe", filePath, fileLine, studioLine); } LibTasHelper.AddInputFrame("58"); @@ -116,29 +92,20 @@ private static void SaveAndQuitReenter(CommandLine commandLine, int studioLine, } if (safe) { - Command.TryParse(controller, filePath, fileLine, "Safe", controller.CurrentParsingFrame, studioLine, out _); + controller.ReadLine("Safe", filePath, fileLine, studioLine); } } else { if (!justPressedSnQ) { AbortTas("SaveAndQuitReenter must be exactly after pressing the \"Save & Quit\" button"); return; } - - if (Engine.Scene is not Level level) { + if (Engine.Scene is not Level) { AbortTas("SaveAndQuitReenter can't be used outside levels"); return; } - // Re-insert inputs of the save file slot changed - if (InsertedSlots.TryGetValue(studioLine, out int slot) && slot != ActiveFileSlot) { - InsertedSlots[studioLine] = ActiveFileSlot; - // Avoid clearing our InsertedSlots info when RefreshInputs() - Dictionary backup = new(InsertedSlots); - controller.NeedsReload = true; - controller.RefreshInputs(enableRun: false); - InsertedSlots.Clear(); - InsertedSlots.AddRange(backup); - } + // Ensure the inputs are for the current save slot + controller.RefreshInputs(forceRefresh: true); } } } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/SetCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/SetCommand.cs index 918e7463d..6d202f113 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/SetCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/SetCommand.cs @@ -1,54 +1,55 @@ using System; -using System.Collections; -using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Reflection; using Celeste; using Celeste.Mod; +using JetBrains.Annotations; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; using Monocle; using StudioCommunication; using StudioCommunication.Util; +using System.Collections.Generic; +using System.IO; using System.Runtime.CompilerServices; +using TAS.Entities; using TAS.EverestInterop; using TAS.EverestInterop.InfoHUD; +using TAS.Gameplay; +using TAS.ModInterop; using TAS.Utils; namespace TAS.Input.Commands; -// ReSharper disable once UnusedType.Global -public static class SetCommand { - internal class SetMeta : ITasCommandMeta { - // Sorts types by namespace into Celeste -> Monocle -> other (alphabetically) - // Inside the namespace it's sorted alphabetically - internal class NamespaceComparer : IComparer<(string Name, Type Type)> { - public int Compare((string Name, Type Type) x, (string Name, Type Type) y) { - if (x.Type == null || y.Type == null || x.Type.Namespace == null || y.Type.Namespace == null) { - // Should never happen to use anyway - return 0; - } +// Sorts types by namespace into Celeste -> Monocle -> other (alphabetically) +// Inside the namespace it's sorted alphabetically +internal class NamespaceComparer : IComparer<(string Name, Type Type)> { + public int Compare((string Name, Type Type) x, (string Name, Type Type) y) { + if (x.Type.Namespace == null || y.Type.Namespace == null) { + return 0; + } - int namespaceCompare = CompareNamespace(x.Type.Namespace, y.Type.Namespace); - if (namespaceCompare != 0) { - return namespaceCompare; - } + int namespaceCompare = CompareNamespace(x.Type.Namespace, y.Type.Namespace); + if (namespaceCompare != 0) { + return namespaceCompare; + } - return StringComparer.Ordinal.Compare(x.Name, y.Name); - } + return StringComparer.Ordinal.Compare(x.Name, y.Name); + } - private int CompareNamespace(string x, string y) { - if (x.StartsWith("Celeste") && y.StartsWith("Celeste")) return 0; - if (x.StartsWith("Celeste")) return -1; - if (y.StartsWith("Celeste")) return 1; - if (x.StartsWith("Monocle") && y.StartsWith("Monocle")) return 0; - if (x.StartsWith("Monocle")) return -1; - if (y.StartsWith("Monocle")) return 1; - return StringComparer.Ordinal.Compare(x, y); - } - } + private int CompareNamespace(string x, string y) { + if (x.StartsWith("Celeste") && y.StartsWith("Celeste")) return 0; + if (x.StartsWith("Celeste")) return -1; + if (y.StartsWith("Celeste")) return 1; + if (x.StartsWith("Monocle") && y.StartsWith("Monocle")) return 0; + if (x.StartsWith("Monocle")) return -1; + if (y.StartsWith("Monocle")) return 1; + return StringComparer.Ordinal.Compare(x, y); + } +} +public static class SetCommand { + internal class SetMeta : ITasCommandMeta { internal static readonly string[] ignoredNamespaces = ["System", "StudioCommunication", "TAS", "SimplexNoise", "FMOD", "MonoMod", "Snowberry"]; public string Insert => $"Set{CommandInfo.Separator}[0;(Mod).Setting]{CommandInfo.Separator}[1;Value]"; @@ -79,13 +80,13 @@ public IEnumerator GetAutoCompleteEntries(string[] arg var vanillaSaveData = ((string[])["CheatMode", "AssistMode", "VariantMode", "UnlockedAreas", "RevealedChapter9", "DebugMode"]).Select(e => typeof(SaveData).GetField(e)!); foreach (var f in vanillaSettings) { - yield return new CommandAutoCompleteEntry { Name = f.Name, Extra = $"{f.FieldType.CSharpName()} (Settings)", IsDone = true };; + yield return new CommandAutoCompleteEntry { Name = f.Name, Extra = $"{f.FieldType.CSharpName()} (Settings)", IsDone = true }; } foreach (var f in vanillaSaveData) { - yield return new CommandAutoCompleteEntry { Name = f.Name, Extra = $"{f.FieldType.CSharpName()} (Save Data)", IsDone = true };; + yield return new CommandAutoCompleteEntry { Name = f.Name, Extra = $"{f.FieldType.CSharpName()} (Save Data)", IsDone = true }; } foreach (var f in typeof(Assists).GetFields()) { - yield return new CommandAutoCompleteEntry { Name = f.Name, Extra = $"{f.FieldType.CSharpName()} (Assists)", IsDone = true };; + yield return new CommandAutoCompleteEntry { Name = f.Name, Extra = $"{f.FieldType.CSharpName()} (Assists)", IsDone = true }; } // Mod settings @@ -138,11 +139,11 @@ public IEnumerator GetAutoCompleteEntries(string[] arg } } else if (targetArgs.Length == 1 && targetArgs[0] == "ExtendedVariantMode") { // Special case for setting extended variants - if (ExtendedVariantsUtils.GetVariantsEnum() is { } variantsEnum) { + if (ExtendedVariantsInterop.GetVariantsEnum() is { } variantsEnum) { foreach (object variant in Enum.GetValues(variantsEnum)) { string typeName = string.Empty; try { - var variantType = ExtendedVariantsUtils.GetVariantType(new Lazy(variant)); + var variantType = ExtendedVariantsInterop.GetVariantType(new Lazy(variant)); if (variantType != null) { typeName = variantType.CSharpName(); } @@ -157,9 +158,9 @@ public IEnumerator GetAutoCompleteEntries(string[] arg foreach (var entry in GetSetTypeAutoCompleteEntries(RecurseSetType(mod.SettingsType, args), isRootType: targetArgs.Length == 1)) { yield return entry with { Name = entry.Name + (entry.IsDone ? "" : "."), Prefix = string.Join('.', targetArgs) + ".", HasNext = true }; } - } else if (targetArgs.Length >= 1 && InfoCustom.TryParseTypes(targetArgs[0], out var types, out _, out _)) { - // Let's just assume the first type - foreach (var entry in GetSetTypeAutoCompleteEntries(RecurseSetType(types[0], targetArgs), isRootType: targetArgs.Length == 1)) { + } else if (targetArgs.Length >= 1 && TargetQuery.ResolveBaseTypes(targetArgs, out string[] memberArgs, out _, out _) is { } types && types.IsNotEmpty()) { + // Assume the first type + foreach (var entry in GetSetTypeAutoCompleteEntries(RecurseSetType(types[0], memberArgs), isRootType: targetArgs.Length == 1)) { yield return entry with { Name = entry.Name + (entry.IsDone ? "" : "."), Prefix = string.Join('.', targetArgs) + ".", HasNext = true }; } } @@ -176,7 +177,7 @@ private static IEnumerable GetSetTypeAutoCompleteEntri if (property.GetCustomAttributes().IsEmpty() && !property.Name.Contains('<') && !property.Name.Contains('>') && IsSettableType(property.PropertyType) && property.GetSetMethod() != null) { - yield return new CommandAutoCompleteEntry { Name = property.Name, Extra = property.PropertyType.CSharpName(), IsDone = IsFinalTarget(property.PropertyType), };; + yield return new CommandAutoCompleteEntry { Name = property.Name, Extra = property.PropertyType.CSharpName(), IsDone = IsFinalTarget(property.PropertyType), }; } } foreach (var property in type.GetFields(bindingFlags).OrderBy(p => p.Name)) { @@ -185,11 +186,12 @@ private static IEnumerable GetSetTypeAutoCompleteEntri IsSettableType(property.FieldType)) { bool done = IsFinalTarget(property.FieldType); - yield return new CommandAutoCompleteEntry { Name = done ? property.Name : $"{property.Name}.", Extra = property.FieldType.CSharpName(), IsDone = done };; + yield return new CommandAutoCompleteEntry { Name = done ? property.Name : $"{property.Name}.", Extra = property.FieldType.CSharpName(), IsDone = done }; } } } + [MustDisposeResource] private static IEnumerator GetParameterAutoCompleteEntries(string[] targetArgs) { if (targetArgs.Length == 1) { // Vanilla setting / session / assist @@ -205,8 +207,8 @@ private static IEnumerator GetParameterAutoCompleteEnt } if (targetArgs.Length == 1 && targetArgs[0] == "ExtendedVariantMode") { // Special case for setting extended variants - var variant = ExtendedVariantsUtils.ParseVariant(targetArgs[1]); - var variantType = ExtendedVariantsUtils.GetVariantType(new(variant)); + var variant = ExtendedVariantsInterop.ParseVariant(targetArgs[1]); + var variantType = ExtendedVariantsInterop.GetVariantType(new(variant)); if (variantType != null) { return GetParameterTypeAutoCompleteEntries(variantType); @@ -215,9 +217,9 @@ private static IEnumerator GetParameterAutoCompleteEnt if (targetArgs.Length >= 1 && Everest.Modules.FirstOrDefault(m => m.Metadata.Name == targetArgs[0] && m.SettingsType != null) is { } mod) { return GetParameterTypeAutoCompleteEntries(RecurseSetType(mod.SettingsType, targetArgs)); } - if (targetArgs.Length >= 1 && InfoCustom.TryParseTypes(targetArgs[0], out var types, out _, out _)) { - // Let's just assume the first type - return GetParameterTypeAutoCompleteEntries(RecurseSetType(types[0], targetArgs)); + if (targetArgs.Length >= 1 && TargetQuery.ResolveBaseTypes(targetArgs, out string[] memberArgs, out _, out _) is { } types && types.IsNotEmpty()) { + // Assume the first type + return GetParameterTypeAutoCompleteEntries(RecurseSetType(types[0], memberArgs)); } return Enumerable.Empty().GetEnumerator(); @@ -245,14 +247,14 @@ internal static IEnumerator GetParameterTypeAutoComple } } - private static Type RecurseSetType(Type baseType, string[] targetArgs) { + private static Type RecurseSetType(Type baseType, string[] memberArgs) { var type = baseType; - for (int i = 1; i < targetArgs.Length; i++) { - if (type.GetFieldInfo(targetArgs[i]) is { } field) { + foreach (string member in memberArgs) { + if (type.GetFieldInfo(member) is { } field) { type = field.FieldType; continue; } - if (type.GetPropertyInfo(targetArgs[i]) is { } property && property.GetSetMethod() != null) { + if (type.GetPropertyInfo(member) is { } property && property.GetSetMethod() != null) { type = property.PropertyType; continue; } @@ -276,74 +278,119 @@ internal static IEnumerable GetTargetArgs(string[] args) { } } - private static bool consolePrintLog; - private const string logPrefix = "Set Command Failed: "; - private static List errorLogs = new List(); - private static bool suspendLog = false; - - [Monocle.Command("set", "Set settings/level/session/entity field. eg set DashMode Infinite; set Player.Speed 325 -52.5 (CelesteTAS)")] - private static void ConsoleSet(string arg1, string arg2, string arg3, string arg4, string arg5, string arg6, string arg7, string arg8, - string arg9) { - string[] args = {arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9}; - consolePrintLog = true; - Set(args.TakeWhile(arg => arg != null).ToArray()); - consolePrintLog = false; + private static (string Name, int Line)? activeFile; + + private static void ReportError(string message) { + if (activeFile == null) { + $"Set Command Failed: {message}".ConsoleLog(LogLevel.Error); + } else { + Toast.ShowAndLog($""" + Set '{activeFile.Value.Name}' line {activeFile.Value.Line} failed: + {message} + """); + } + } + + [Monocle.Command("set", "'set Settings/Level/Session/Entity value' | Example: 'set DashMode Infinite', 'set Player.Speed 325 -52.5' (CelesteTAS)"), UsedImplicitly] + private static void ConsoleSet(string? arg1, string? arg2, string? arg3, string? arg4, string? arg5, string? arg6, string? arg7, string? arg8, string? arg9) { + // TODO: Support arbitrary amounts of arguments + string?[] args = [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9]; + Set(args.TakeWhile(arg => arg != null).ToArray()!); } // Set, Setting, Value // Set, Mod.Setting, Value // Set, Entity.Field, Value + // Set, Type.StaticMember, Value [TasCommand("Set", LegalInFullGame = false, MetaDataProvider = typeof(SetMeta))] private static void Set(CommandLine commandLine, int studioLine, string filePath, int fileLine) { + activeFile = (filePath, fileLine); Set(commandLine.Arguments); + activeFile = null; } private static void Set(string[] args) { if (args.Length < 2) { + ReportError("Target-query and value required"); return; } - try { - if (args[0].Contains(".")) { - string[] parameters = args.Skip(1).ToArray(); - if (TrySetModSetting(args[0], parameters)) { - return; - } + string query = args[0]; + string[] queryArgs = query.Split('.'); - if (InfoCustom.TryParseMemberNames(args[0], out string typeText, out List memberNames, out string errorMessage) - && InfoCustom.TryParseTypes(typeText, out List types, out string entityId, out errorMessage)) { - bool existSuccess = false; - bool forSpecific = entityId.IsNotNullOrEmpty(); - suspendLog = true; - foreach (Type type in types) { - bool hasSet = FindObjectAndSetMember(type, entityId, memberNames, parameters); - existSuccess |= hasSet; - if (forSpecific && hasSet) { - break; - } + var baseTypes = TargetQuery.ResolveBaseTypes(queryArgs, out string[]? memberArgs, out var componentTypes, out var entityId); + if (baseTypes.IsEmpty()) { + ReportError($"Failed to find base type for query '{query}'"); + return; + } + if (memberArgs.IsEmpty()) { + ReportError("No members specified"); + return; + } + + // Handle special cases + if (baseTypes.Count == 1 && (baseTypes[0] == typeof(Settings) || baseTypes[0] == typeof(SaveData) || baseTypes[0] == typeof(Assists))) { + SetGameSetting(memberArgs[0], args[1..]); + return; + } + if (baseTypes.Count == 1 && + baseTypes[0].IsSameOrSubclassOf(typeof(EverestModuleSettings)) && + Everest.Modules.FirstOrDefault(mod => mod.SettingsType == baseTypes[0]) is { } module && + module.Metadata.Name == "ExtendedVariantMode") + { + SetExtendedVariant(memberArgs[0], args[1..]); + return; + } + + foreach (var type in baseTypes) { + if (componentTypes.IsNotEmpty()) { + foreach (var componentType in componentTypes) { + (var targetType, bool success) = TargetQuery.ResolveMemberType(componentType, memberArgs); + if (!success) { + ReportError($"Failed to find members '{string.Join('.', memberArgs)}' on type '{componentType}'"); + return; } - suspendLog = false; - if (!forSpecific || !existSuccess) { - errorLogs.Where(text => !existSuccess || !text.EndsWith(" entity is not found") && !text.EndsWith(" object is not found")).ToList().ForEach(Log); + + (object?[] values, success, string errorMessage) = TargetQuery.ResolveValues(args[1..], [targetType]); + if (!success) { + ReportError(errorMessage); + return; + } + + var instances = TargetQuery.ResolveTypeInstances(type, [componentType], entityId); + success = TargetQuery.SetMemberValues(componentType, instances, values[0], memberArgs); + if (!success) { + ReportError($"Failed to set members '{string.Join('.', memberArgs)}' of type '{targetType}' on type '{componentType}' to '{values[0]}'"); + return; } - errorLogs.Clear(); - } else { - errorMessage.Log(consolePrintLog, LogLevel.Warn); } } else { - SetGameSetting(args); + (var targetType, bool success) = TargetQuery.ResolveMemberType(type, memberArgs); + if (!success) { + ReportError($"Failed to find members '{string.Join('.', memberArgs)}' on type '{type}'"); + return; + } + + (object?[] values, success, string errorMessage) = TargetQuery.ResolveValues(args[1..], [targetType]); + if (!success) { + ReportError(errorMessage); + return; + } + + var instances = TargetQuery.ResolveTypeInstances(type, componentTypes, entityId); + success = TargetQuery.SetMemberValues(type, instances, values[0], memberArgs); + if (!success) { + ReportError($"Failed to set members '{string.Join('.', memberArgs)}' of type '{targetType}' on type '{type}' to '{values[0]}'"); + return; + } } - } catch (Exception e) { - e.Log(consolePrintLog, LogLevel.Warn); } } - private static void SetGameSetting(string[] args) { - object settings = null; - string settingName = args[0]; - string[] parameters = args.Skip(1).ToArray(); + private static void SetGameSetting(string settingName, string[] valueArgs) { + object? settings = null; - FieldInfo field; + FieldInfo? field; if ((field = typeof(Settings).GetField(settingName)) != null) { settings = Settings.Instance; } else if ((field = typeof(SaveData).GetField(settingName)) != null) { @@ -352,15 +399,20 @@ private static void SetGameSetting(string[] args) { settings = SaveData.Instance.Assists; } - if (settings == null) { + if (settings == null || field == null) { return; } - object value = ConvertType(parameters, field.FieldType); + (object?[] values, bool success, string errorMessage) = TargetQuery.ResolveValues(valueArgs, [field.FieldType]); + if (!success) { + ReportError(errorMessage); + return; + } - if (!SettingsSpecialCases(settingName, value)) { - field.SetValue(settings, value); + if (!HandleSpecialCases(settingName, values[0])) { + field.SetValue(settings, values[0]); + // Assists is a struct, so it needs to be re-assign if (settings is Assists assists) { SaveData.Instance.Assists = assists; } @@ -371,374 +423,47 @@ private static void SetGameSetting(string[] args) { SaveData.Instance.AssistMode = false; } } - - private static bool TrySetModSetting(string moduleSetting, string[] values) { - int index = moduleSetting.IndexOf(".", StringComparison.Ordinal); - string moduleName = moduleSetting.Substring(0, index); - string settingName = moduleSetting.Substring(index + 1); - foreach (EverestModule module in Everest.Modules) { - if (module.Metadata.Name == moduleName && module.SettingsType is { } settingsType) { - bool success = TrySetMember(settingsType, module._Settings, settingName, values); - - // Allow setting extended variants - if (!success && moduleName == "ExtendedVariantMode") { - if (!TrySetExtendedVariant(settingName, values)) { - Log($"Setting or extended variant {moduleName}.{settingName} not found"); - } - } else if (!success) { - Log($"{settingsType.FullName}.{settingName} member not found"); - } - - return true; - } - } - - return false; - } - - private static bool FindObjectAndSetMember(Type type, string entityId, List memberNames, string[] values, object structObj = null) { - if (memberNames.IsEmpty() || values.IsEmpty() && structObj == null) { - return false; - } - - string lastMemberName = memberNames.Last(); - memberNames = memberNames.SkipLast(1).ToList(); - - Type objType; - object obj = null; - if (memberNames.IsEmpty() && - (type.GetGetMethod(lastMemberName) is {IsStatic: true} || type.GetFieldInfo(lastMemberName) is {IsStatic: true})) { - objType = type; - } else if (memberNames.IsNotEmpty() && - (type.GetGetMethod(memberNames.First()) is {IsStatic: true} || - type.GetFieldInfo(memberNames.First()) is {IsStatic: true})) { - obj = InfoCustom.GetMemberValue(type, null, memberNames, true); - if (TryPrintErrorLog()) { - return false; - } - - objType = obj.GetType(); - } else { - obj = FindSpecialObject(type, entityId); - if (obj == null) { - Log($"{type.FullName}{entityId.LogId()} object is not found"); - return false; - } else { - if (type.IsSameOrSubclassOf(typeof(Entity)) && obj is List entities) { - if (entities.IsEmpty()) { - Log($"{type.FullName}{entityId.LogId()} entity is not found"); - return false; - } else { - List memberValues = new(); - foreach (Entity entity in entities) { - object memberValue = InfoCustom.GetMemberValue(type, entity, memberNames, true); - if (TryPrintErrorLog()) { - return false; - } - - if (memberValue != null) { - memberValues.Add(memberValue); - } - } - - if (memberValues.IsEmpty()) { - return false; - } - - obj = memberValues; - objType = memberValues.First().GetType(); - } - } else { - obj = InfoCustom.GetMemberValue(type, obj, memberNames, true); - if (TryPrintErrorLog()) { - return false; - } - - objType = obj.GetType(); - } - } - } - - if (type.IsSameOrSubclassOf(typeof(Entity)) && obj is List objects) { - bool success = false; - objects.ForEach(_ => success |= SetMember(_)); - return success; - } else { - return SetMember(obj); - } - - bool SetMember(object @object) { - if (!TrySetMember(objType, @object, lastMemberName, values, structObj)) { - Log($"{objType.FullName}.{lastMemberName} member not found"); - return false; - } - - // after modifying the struct - // we also need to update the object own the struct - if (memberNames.IsNotEmpty() && objType.IsStructType()) { - string[] position = @object switch { - Vector2 vector2 => new[] {vector2.X.ToString(CultureInfo.InvariantCulture), vector2.Y.ToString(CultureInfo.InvariantCulture)}, - Vector2Double vector2Double => new[] { - vector2Double.X.ToString(CultureInfo.InvariantCulture), vector2Double.Y.ToString(CultureInfo.InvariantCulture) - }, - _ => new string[] { } - }; - - return FindObjectAndSetMember(type, entityId, memberNames, position, position.IsEmpty() ? @object : null); - } - - return true; - } - - bool TryPrintErrorLog() { - if (obj == null) { - Log($"{type.FullName}{entityId.LogId()} member value is null"); - return true; - } else if (obj is string errorMsg && errorMsg.EndsWith(" not found")) { - Log(errorMsg); - return true; - } - - return false; - } - } - - private static bool TrySetMember(Type objType, object obj, string lastMemberName, string[] values, object structObj = null) { - if (objType.GetPropertyInfo(lastMemberName) is { } property && property.GetSetMethod(true) is { } setMethod) { - if (obj is Actor actor && lastMemberName is "X" or "Y") { - double.TryParse(values[0], out double value); - Vector2 remainder = actor.movementCounter; - if (lastMemberName == "X") { - actor.Position.X = (int) Math.Round(value); - remainder.X = (float) (value - actor.Position.X); - } else { - actor.Position.Y = (int) Math.Round(value); - remainder.Y = (float) (value - actor.Position.Y); - } - - actor.movementCounter = remainder; - } else if (obj is Platform platform && lastMemberName is "X" or "Y") { - double.TryParse(values[0], out double value); - Vector2 remainder = platform.movementCounter; - if (lastMemberName == "X") { - platform.Position.X = (int) Math.Round(value); - remainder.X = (float) (value - platform.Position.X); - } else { - platform.Position.Y = (int) Math.Round(value); - remainder.Y = (float) (value - platform.Position.Y); - } - - platform.movementCounter = remainder; - } else if (property.PropertyType == typeof(ButtonBinding) && property.GetValue(obj) is ButtonBinding buttonBinding) { - HashSet keys = new(); - HashSet mButtons = new(); - IList mouseButtons = buttonBinding.Button.GetFieldValue("Binding")?.GetFieldValue("Mouse"); - foreach (string str in values) { - // parse mouse first, so Mouse.Left is not parsed as Keys.Left - if (Enum.TryParse(str, true, out MButtons mButton)) { - if (mouseButtons == null && mButton is MButtons.X1 or MButtons.X2) { - AbortTas("X1 and X2 are not supported before Everest adding mouse support"); - return false; - } - - mButtons.Add(mButton); - } else if (Enum.TryParse(str, true, out Keys key)) { - keys.Add(key); - } else { - AbortTas($"{str} is not a valid key"); - return false; - } - } - - List nodes = buttonBinding.Button.Nodes; - - if (keys.IsNotEmpty()) { - foreach (VirtualButton.Node node in nodes.ToList()) { - if (node is VirtualButton.KeyboardKey) { - nodes.Remove(node); - } - } - - nodes.AddRange(keys.Select(key => new VirtualButton.KeyboardKey(key))); - } - - if (mButtons.IsNotEmpty()) { - foreach (VirtualButton.Node node in nodes.ToList()) { - switch (node) { - case VirtualButton.MouseLeftButton: - case VirtualButton.MouseRightButton: - case VirtualButton.MouseMiddleButton: - nodes.Remove(node); - break; - } - } - - if (mouseButtons != null) { - mouseButtons.Clear(); - foreach (MButtons mButton in mButtons) { - mouseButtons.Add(mButton); - } - } else { - foreach (MButtons mButton in mButtons) { - if (mButton == MButtons.Left) { - nodes.AddRange(keys.Select(key => new VirtualButton.MouseLeftButton())); - } else if (mButton == MButtons.Right) { - nodes.AddRange(keys.Select(key => new VirtualButton.MouseRightButton())); - } else if (mButton == MButtons.Middle) { - nodes.AddRange(keys.Select(key => new VirtualButton.MouseMiddleButton())); - } - } - } - } - } else { - object value = structObj ?? ConvertType(values, property.PropertyType); - setMethod.Invoke(obj, new[] {value}); - } - } else if (objType.GetFieldInfo(lastMemberName) is { } field) { - if (obj is Actor actor && lastMemberName == "Position" && values.Length == 2) { - double.TryParse(values[0], out double x); - double.TryParse(values[1], out double y); - Vector2 position = new((int) Math.Round(x), (int) Math.Round(y)); - Vector2 remainder = new((float) (x - position.X), (float) (y - position.Y)); - actor.Position = position; - actor.movementCounter = remainder; - } else if (obj is Platform platform && lastMemberName == "Position" && values.Length == 2) { - double.TryParse(values[0], out double x); - double.TryParse(values[1], out double y); - Vector2 position = new((int) Math.Round(x), (int) Math.Round(y)); - Vector2 remainder = new((float) (x - position.X), (float) (y - position.Y)); - platform.Position = position; - platform.movementCounter = remainder; - } else { - object value = structObj ?? ConvertType(values, field.FieldType); - if (lastMemberName.Equals("Speed", StringComparison.OrdinalIgnoreCase) && value is Vector2 speed && - Math.Abs(Engine.TimeRateB - 1f) > 1e-10) { - field.SetValue(obj, speed / Engine.TimeRateB); - } else { - field.SetValue(obj, value); - } - } - } else { - return false; - } - - return true; - } - - private static bool TrySetExtendedVariant(string variantName, string[] values) { - Lazy variant = new(ExtendedVariantsUtils.ParseVariant(variantName)); - Type type = ExtendedVariantsUtils.GetVariantType(variant); - if (type is null) return false; - - object value = ConvertType(values, type); - ExtendedVariantsUtils.SetVariantValue(variant, value); - - return true; - } - - public static object FindSpecialObject(Type type, string entityId) { - if (type.IsSameOrSubclassOf(typeof(Entity))) { - return InfoCustom.FindEntities(type, entityId); - } else if (type == typeof(Level)) { - return Engine.Scene.GetLevel(); - } else if (type == typeof(Session)) { - return Engine.Scene.GetSession(); - } else { - return null; - } - } - - private static string LogId(this string entityId) { - return entityId.IsNullOrEmpty() ? "" : $"[{entityId}]"; - } - - private static void Log(string text) { - if (suspendLog) { - errorLogs.Add(text); + private static void SetExtendedVariant(string variantName, string[] valueArgs) { + var variant = new Lazy(ExtendedVariantsInterop.ParseVariant(variantName)); + var variantType = ExtendedVariantsInterop.GetVariantType(variant); + if (variantType is null) { + ReportError($"Failed to resolve type for extended variant '{variantName}'"); return; } - if (!consolePrintLog) { - text = $"{logPrefix}{text}"; + (object?[] values, bool success, string errorMessage) = TargetQuery.ResolveValues(valueArgs, [variantType]); + if (!success) { + ReportError(errorMessage); + return; } - text.Log(consolePrintLog, LogLevel.Warn); - } - - public static object Convert(object value, Type type) { - try { - if (value is null or string and ("" or "null")) { - return type.IsValueType ? Activator.CreateInstance(type) : null; - } else if (type == typeof(string) && value is "\"\"") { - return string.Empty; - } else { - return type.IsEnum ? Enum.Parse(type, (string) value, true) : System.Convert.ChangeType(value, type); - } - } catch { - return type.IsValueType ? Activator.CreateInstance(type) : null; - } + ExtendedVariantsInterop.SetVariantValue(variant, values[0]); } - private static object ConvertType(string[] values, Type type) { - Type nullableType = type; - type = Nullable.GetUnderlyingType(type) ?? type; - - if (type == typeof(string)) { - return string.Join(" ", values); - } if (values.Length == 2 && type == typeof(Vector2)) { - float.TryParse(values[0], out float x); - float.TryParse(values[1], out float y); - return new Vector2(x, y); - } else if (values.Length == 1) { - if (type == typeof(Random)) { - if (int.TryParse(values[0], out int seed)) { - return new Random(seed); - } else { - return new Random(values[0].GetHashCode()); - } - } else { - return Convert(values[0], nullableType); - } - } else if (values.Length >= 2) { - object instance = Activator.CreateInstance(type); - MemberInfo[] members = type.GetMembers().Where(info => (info.MemberType & (MemberTypes.Field | MemberTypes.Property)) != 0).ToArray(); - for (int i = 0; i < members.Length && i < values.Length; i++) { - string memberName = members[i].Name; - if (type.GetField(memberName) is { } fieldInfo) { - fieldInfo.SetValue(instance, Convert(values[i], fieldInfo.FieldType)); - } else if (type.GetProperty(memberName) is { } propertyInfo) { - propertyInfo.SetValue(instance, Convert(values[i], propertyInfo.PropertyType)); - } - } - - return instance; - } + /// Applies the setting, while handing special cases + private static bool HandleSpecialCases(string settingName, object? value) { + var player = Engine.Scene.Tracker.GetEntity(); + var saveData = SaveData.Instance; + var settings = Settings.Instance; - return default; - } - - private static bool SettingsSpecialCases(string settingName, object value) { - Player player = (Engine.Scene as Level)?.Tracker.GetEntity(); - SaveData saveData = SaveData.Instance; - Settings settings = Settings.Instance; switch (settingName) { // Assists - case "GameSpeed": - saveData.Assists.GameSpeed = (int) value; + case nameof(Assists.Invincible) when Manager.Running && TasSettings.BetterInvincible: + BetterInvincible.Invincible = (bool) value!; + break; + case nameof(Assists.GameSpeed): + saveData.Assists.GameSpeed = (int) value!; Engine.TimeRateB = saveData.Assists.GameSpeed / 10f; break; - case "MirrorMode": - saveData.Assists.MirrorMode = (bool) value; - Celeste.Input.MoveX.Inverted = Celeste.Input.Aim.InvertedX = (bool) value; - if (typeof(Celeste.Input).GetFieldValue("Feather") is { } featherJoystick) { - featherJoystick.InvertedX = (bool) value; - } - + case nameof(Assists.MirrorMode): + saveData.Assists.MirrorMode = (bool) value!; + Celeste.Input.MoveX.Inverted = Celeste.Input.Aim.InvertedX = saveData.Assists.MirrorMode; + Celeste.Input.Feather.InvertedX = saveData.Assists.MirrorMode; break; - case "PlayAsBadeline": - saveData.Assists.PlayAsBadeline = (bool) value; + case nameof(Assists.PlayAsBadeline): + saveData.Assists.PlayAsBadeline = (bool) value!; if (player != null) { - PlayerSpriteMode mode = saveData.Assists.PlayAsBadeline + var mode = saveData.Assists.PlayAsBadeline ? PlayerSpriteMode.MadelineAsBadeline : player.DefaultSpriteMode; if (player.Active) { @@ -747,73 +472,52 @@ private static bool SettingsSpecialCases(string settingName, object value) { player.ResetSprite(mode); } } - break; - case "DashMode": - saveData.Assists.DashMode = (Assists.DashModes) value; + case nameof(Assists.DashMode): + saveData.Assists.DashMode = (Assists.DashModes) value!; if (player != null) { player.Dashes = Math.Min(player.Dashes, player.MaxDashes); } - break; // SaveData - case "VariantMode": - saveData.VariantMode = (bool) value; + case nameof(SaveData.VariantMode): + saveData.VariantMode = (bool) value!; saveData.AssistMode = false; if (!saveData.VariantMode) { Assists assists = default; assists.GameSpeed = 10; ResetVariants(assists); } - break; - case "AssistMode": - saveData.AssistMode = (bool) value; + case nameof(SaveData.AssistMode): + saveData.AssistMode = (bool) value!; saveData.VariantMode = false; if (!saveData.AssistMode) { Assists assists = default; assists.GameSpeed = 10; ResetVariants(assists); } - break; // Settings - case "Rumble": - settings.Rumble = (RumbleAmount) value; + case nameof(Settings.Rumble): + settings.Rumble = (RumbleAmount) value!; Celeste.Input.Rumble(RumbleStrength.Medium, RumbleLength.Medium); break; - case "GrabMode": - settings.SetFieldValue("GrabMode", value); - typeof(Celeste.Celeste).InvokeMethod("ResetGrab"); - break; - // case "Fullscreen": - // game get stuck when toggle fullscreen - // typeof(MenuOptions).InvokeMethod("SetFullscreen", value); - // break; - case "WindowScale": - typeof(MenuOptions).InvokeMethod("SetWindow", value); - break; - case "VSync": - typeof(MenuOptions).InvokeMethod("SetVSync", value); + case nameof(Settings.GrabMode): + settings.GrabMode = (GrabModes) value!; + Celeste.Input.ResetGrab(); break; - case "MusicVolume": - typeof(MenuOptions).InvokeMethod("SetMusic", value); - break; - case "SFXVolume": - typeof(MenuOptions).InvokeMethod("SetSfx", value); - break; - case "Language": - string language = value.ToString(); - if (settings.Language != language && Dialog.Languages.ContainsKey(language)) { - if (settings.Language != "english") { - Fonts.Unload(Dialog.Languages[Settings.Instance.Language].FontFace); - } - settings.Language = language; - settings.ApplyLanguage(); - } + case nameof(Settings.Fullscreen): + case nameof(Settings.WindowScale): + case nameof(Settings.VSync): + case nameof(Settings.MusicVolume): + case nameof(Settings.SFXVolume): + case nameof(Settings.Language): + // Intentional no-op. A TAS should not modify these user preferences break; + default: return false; } @@ -823,9 +527,9 @@ private static bool SettingsSpecialCases(string settingName, object value) { public static void ResetVariants(Assists assists) { SaveData.Instance.Assists = assists; - SettingsSpecialCases("DashMode", assists.DashMode); - SettingsSpecialCases("GameSpeed", assists.GameSpeed); - SettingsSpecialCases("MirrorMode", assists.MirrorMode); - SettingsSpecialCases("PlayAsBadeline", assists.PlayAsBadeline); + HandleSpecialCases(nameof(Assists.DashMode), assists.DashMode); + HandleSpecialCases(nameof(Assists.GameSpeed), assists.GameSpeed); + HandleSpecialCases(nameof(Assists.MirrorMode), assists.MirrorMode); + HandleSpecialCases(nameof(Assists.PlayAsBadeline), assists.PlayAsBadeline); } } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/StunPauseCommand.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/StunPauseCommand.cs index 34c824c2c..ad4c9d916 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/StunPauseCommand.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Commands/StunPauseCommand.cs @@ -72,10 +72,10 @@ private static StunPauseMode Mode { } } - // hook after CycleHitboxColor.Load, so that the grouping color does not change [Initialize] private static void Initialize() { - using (new DetourContext {After = new List {"*"}}) { + // Hook after CycleHitboxColor.Load, so that the grouping color does not change + using (new DetourConfigContext(new DetourConfig("CelesteTAS", before: ["*"])).Use()) { On.Monocle.Scene.BeforeUpdate += DoublePauses; } } @@ -175,11 +175,11 @@ public static void UpdatePauseInputs(AutoInputCommand.Arguments arguments) { inputs.RemoveAt(inputs.Count - 1); if (Manager.Controller.Inputs.LastOrDefault() is { } input) { - if (input.HasActions(Actions.Jump) && input.HasActions(Actions.Jump2)) { + if (input.Actions.Has(Actions.Jump) && input.Actions.Has(Actions.Jump2)) { inputs.Add("10,J,K"); - } else if (input.HasActions(Actions.Jump)) { + } else if (input.Actions.Has(Actions.Jump)) { inputs.Add("10,J"); - } else if (input.HasActions(Actions.Jump2)) { + } else if (input.Actions.Has(Actions.Jump2)) { inputs.Add("10,K,O"); } else { inputs.Add("10,O"); diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/Comment.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/Comment.cs index 996576cc8..78c9630b3 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/Comment.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/Comment.cs @@ -2,21 +2,21 @@ namespace TAS.Input; -public record Comment { - public readonly string FilePath; +/// /// Represents a commented line in a TAS file +public readonly record struct Comment { public readonly int Frame; - public readonly int Line; - public readonly string Text; - public Comment(string filePath, int frame, int line, string text) { - FilePath = filePath; - Frame = frame; - Line = line; + public readonly string FilePath; + public readonly int FileLine; - if (text.IsNotNullOrEmpty()) { - text = text.Substring(1, text.Length - 1).Trim(); - } + public readonly string Text; - Text = text; + public Comment(int frame, string filePath, int fileLine, string text) { + Frame = frame; + FilePath = filePath; + FileLine = fileLine; + Text = string.IsNullOrEmpty(text) + ? string.Empty + : text["#".Length..].Trim(); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/FastForward.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/FastForward.cs index ef854cfda..c472d57d3 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/FastForward.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/FastForward.cs @@ -2,9 +2,10 @@ namespace TAS.Input; +/// A breakpoint to which the TAS will fast-forward at a high speed public record FastForward { - private const float DefaultSpeed = 400f; - public const float MinSpeed = 1f / 60f; + private const float DefaultSpeed = 400.0f; + public readonly int Frame; public readonly int Line; public readonly bool SaveState; @@ -21,11 +22,6 @@ public FastForward(int frame, string modifiers, int line) { } Speed = float.TryParse(modifiers, out float speed) ? speed : DefaultSpeed; - if (Speed < MinSpeed) { - Speed = MinSpeed; - } else if (Speed > 1f) { - Speed = (int) Math.Round(Speed); - } } public override string ToString() { diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/InputController.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/InputController.cs index 14b4c9cbf..35eaea6e3 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/InputController.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/InputController.cs @@ -1,267 +1,164 @@ using System; -using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; using Celeste.Mod; -using MonoMod.Utils; +using JetBrains.Annotations; using StudioCommunication; -using TAS.EverestInterop; using TAS.Input.Commands; -using TAS.Utils; -#if DEBUG using TAS.Module; -using Monocle; -#endif +using TAS.Utils; namespace TAS.Input; -public class InputController { - static InputController() { - AttributeUtils.CollectMethods(); - AttributeUtils.CollectMethods(); - } - - private static readonly Dictionary watchers = new(); - private static string studioTasFilePath = string.Empty; - - public readonly SortedDictionary> Commands = new(); - public readonly SortedDictionary FastForwards = new(); - public readonly SortedDictionary FastForwardComments = new(); - public readonly Dictionary> Comments = new(); - public readonly List Inputs = new(); - private readonly Dictionary UsedFiles = new(); - - public bool NeedsReload = true; - public FastForward NextCommentFastForward; - - private string checksum; - private int initializationFrameCount; - private string savestateChecksum; +[AttributeUsage(AttributeTargets.Method), MeansImplicitUse] +public class ClearInputsAttribute : Attribute; - public int CurrentParsingFrame => initializationFrameCount; +[AttributeUsage(AttributeTargets.Method), MeansImplicitUse] +public class ParseFileEndAttribute : Attribute; - private static readonly string DefaultTasFilePath = Path.Combine(Directory.GetCurrentDirectory(), "Celeste.tas"); +[AttributeUsage(AttributeTargets.Method), MeansImplicitUse] +public class TasFileChangedAttribute : Attribute; - public static string StudioTasFilePath { - get => studioTasFilePath; - set { - if (studioTasFilePath == value || PlayTasAtLaunch.WaitToPlayTas) { - return; - } - - Manager.AddMainThreadAction(() => { - if (string.IsNullOrEmpty(value)) { - studioTasFilePath = value; - } else { - studioTasFilePath = Path.GetFullPath(value); - } - - string path = string.IsNullOrEmpty(value) ? DefaultTasFilePath : value; - try { - if (!File.Exists(path)) { - File.WriteAllText(path, string.Empty); - } - } catch { - studioTasFilePath = DefaultTasFilePath; - } - - if (Manager.Running) { - Manager.DisableRunLater(); - } - - Manager.Controller.Clear(); - - // preload tas file - Manager.Controller.RefreshInputs(true); - }); - } +/// Manages inputs, commands, etc. for the current TAS file +public class InputController { + [Initialize] + private static void Initialize() { + AttributeUtils.CollectAllMethods(); + AttributeUtils.CollectAllMethods(); + AttributeUtils.CollectAllMethods(); } - public static string TasFilePath => string.IsNullOrEmpty(StudioTasFilePath) ? DefaultTasFilePath : StudioTasFilePath; + private readonly Dictionary watchers = new(); - // start from 1 - public int CurrentFrameInInput { get; private set; } + public readonly List Inputs = []; + public readonly SortedDictionary> Commands = new(); + public readonly SortedDictionary> Comments = new(); + public readonly SortedDictionary FastForwards = new(); + public readonly SortedDictionary FastForwardLabels = new(); - // start from 1 - public int CurrentFrameInInputForHud { get; private set; } + public InputFrame? Previous => Inputs!.GetValueOrDefault(CurrentFrameInTas - 1); + public InputFrame Current => Inputs!.GetValueOrDefault(CurrentFrameInTas)!; + public InputFrame? Next => Inputs!.GetValueOrDefault(CurrentFrameInTas + 1); - // start from 0 - public int CurrentFrameInTas { get; private set; } + public int CurrentFrameInTas { get; set; } = 0; + public int CurrentFrameInInput { get; set; } = 0; + public int CurrentParsingFrame => Inputs.Count; - public InputFrame Previous => Inputs.GetValueOrDefault(CurrentFrameInTas - 1); - public InputFrame Current => Inputs.GetValueOrDefault(CurrentFrameInTas); - public InputFrame Next => Inputs.GetValueOrDefault(CurrentFrameInTas + 1); - public List CurrentCommands => Commands.GetValueOrDefault(CurrentFrameInTas); - public bool CanPlayback => CurrentFrameInTas < Inputs.Count; - public bool NeedsToWait => Manager.IsLoading(); + public List CurrentCommands => Commands.GetValueOrDefault(CurrentFrameInTas) ?? []; + public List CurrentComments => Comments.GetValueOrDefault(CurrentFrameInTas) ?? []; - private FastForward CurrentFastForward => NextCommentFastForward ?? + public FastForward? CurrentFastForward => NextLabelFastForward ?? FastForwards.FirstOrDefault(pair => pair.Key > CurrentFrameInTas).Value ?? FastForwards.LastOrDefault().Value; - public bool HasFastForward => CurrentFastForward is { } forward && forward.Frame > CurrentFrameInTas; - public float FastForwardSpeed => RecordingCommand.StopFastForward ? 1 : CurrentFastForward is { } forward && forward.Frame > CurrentFrameInTas - ? Math.Min(forward.Frame - CurrentFrameInTas, forward.Speed) - : 1f; + public FastForward? NextLabelFastForward; - public bool Break => CurrentFastForward?.Frame == CurrentFrameInTas; - private string Checksum => string.IsNullOrEmpty(checksum) ? checksum = CalcChecksum(Inputs.Count - 1) : checksum; + /// Indicates whether the current TAS file needs to be reparsed before running + public bool NeedsReload = true; - public string SavestateChecksum { - get => string.IsNullOrEmpty(savestateChecksum) ? savestateChecksum = CalcChecksum(CurrentFrameInTas) : savestateChecksum; - private set => savestateChecksum = value; - } + /// All files involved in the current TAS + public readonly HashSet UsedFiles = []; - public void RefreshInputs(bool enableRun) { - if (enableRun) { - Stop(); - } + private const int InvalidChecksum = -1; + private int checksum = InvalidChecksum; - string lastChecksum = Checksum; - bool firstRun = UsedFiles.IsEmpty(); - if (NeedsReload) { - Clear(); - int tryCount = 5; - while (tryCount > 0) { - if (ReadFile(TasFilePath)) { - if (Manager.NextStates.Has(States.Disable)) { - Clear(); - Manager.DisableRun(); - } else { - NeedsReload = false; - ParseFileEnd(); - if (!firstRun && lastChecksum != Checksum) { - MetadataCommands.UpdateRecordCount(this); - } - } - - break; - } else { - System.Threading.Thread.Sleep(50); - tryCount--; - Clear(); - } - } + /// Current checksum of the TAS, used to increment RecordCount + public int Checksum => checksum == InvalidChecksum ? checksum = CalcChecksum(Inputs.Count - 1) : checksum; - CurrentFrameInTas = Math.Min(Inputs.Count, CurrentFrameInTas); - } - } + /// Whether the controller can be advanced to a next frame + public bool CanPlayback => CurrentFrameInTas < Inputs.Count; - public void Stop() { - CurrentFrameInInput = 0; - CurrentFrameInInputForHud = 0; - CurrentFrameInTas = 0; - NextCommentFastForward = null; - } + /// Whether the TAS should be paused on this frame + public bool Break => CurrentFastForward?.Frame == CurrentFrameInTas; - public void Clear() { - initializationFrameCount = 0; - checksum = string.Empty; - savestateChecksum = string.Empty; - Inputs.Clear(); - Commands.Clear(); - FastForwards.Clear(); - FastForwardComments.Clear(); - Comments.Clear(); - UsedFiles.Clear(); - NeedsReload = true; - StopWatchers(); - AttributeUtils.Invoke(); - } + private static readonly string DefaultFilePath = Path.Combine(Everest.PathEverest, "Celeste.tas"); - private void StartWatchers() { - foreach (KeyValuePair pair in UsedFiles) { - string filePath = Path.GetFullPath(pair.Key); - // watch tas file - CreateWatcher(filePath); + private string filePath = string.Empty; + public string FilePath { + get { + var path = !string.IsNullOrEmpty(filePath) ? filePath : DefaultFilePath; - // watch parent folder, since watched folder's change is not detected - while (filePath != null && Directory.GetParent(filePath) != null) { - CreateWatcher(Path.GetDirectoryName(filePath)); - filePath = Directory.GetParent(filePath)?.FullName; + // Ensure path exists + if (!File.Exists(path)) { + File.WriteAllText(path, string.Empty); } + return path; } - - void CreateWatcher(string filePath) { - if (watchers.ContainsKey(filePath)) { + set { + if (filePath == value) { return; } - - FileSystemWatcher watcher; - if (File.GetAttributes(filePath).Has(FileAttributes.Directory)) { - if (Directory.GetParent(filePath) is { } parentDir) { - watcher = new FileSystemWatcher(); - watcher.Path = parentDir.FullName; - watcher.Filter = new DirectoryInfo(filePath).Name; - watcher.NotifyFilter = NotifyFilters.DirectoryName; - } else { - return; - } - } else { - watcher = new FileSystemWatcher(); - watcher.Path = Path.GetDirectoryName(filePath); - watcher.Filter = Path.GetFileName(filePath); + if (string.IsNullOrWhiteSpace(value)) { + filePath = string.Empty; + return; } - watcher.Changed += OnTasFileChanged; - watcher.Created += OnTasFileChanged; - watcher.Deleted += OnTasFileChanged; - watcher.Renamed += OnTasFileChanged; - - try { - watcher.EnableRaisingEvents = true; - } catch (Exception e) { - e.LogException($"Failed watching folder: {watcher.Path}, filter: {watcher.Filter}"); - watcher.Dispose(); - return; + filePath = Path.GetFullPath(value); + if (!File.Exists(filePath)) { + filePath = DefaultFilePath; } - watchers[filePath] = watcher; - } + if (Manager.Running) { + Manager.DisableRunLater(); + } - void OnTasFileChanged(object sender, FileSystemEventArgs e) { - NeedsReload = true; + // Preload the TAS file + Stop(); + Clear(); + RefreshInputs(); } } - private void StopWatchers() { - foreach (FileSystemWatcher fileSystemWatcher in watchers.Values) { - fileSystemWatcher.Dispose(); + /// Re-parses the TAS file if necessary + public void RefreshInputs(bool forceRefresh = false) { + if (!NeedsReload && !forceRefresh) { + return; // Already up-to-date } - watchers.Clear(); - } - - private void ParseFileEnd() { - StartWatchers(); - AttributeUtils.Invoke(); - } + "Refreshing inputs...".Log(LogLevel.Debug); - public void AdvanceFrame(out bool canPlayback) { - RefreshInputs(false); + int lastChecksum = Checksum; + bool firstRun = UsedFiles.IsEmpty(); - canPlayback = CanPlayback; + Clear(); + if (ReadFile(FilePath)) { + if (Manager.NextState == Manager.State.Disabled) { + // The TAS contains something invalid + Clear(); + Manager.DisableRun(); + } else { + NeedsReload = false; + StartWatchers(); + AttributeUtils.Invoke(); - if (NeedsToWait) { - return; + if (!firstRun && lastChecksum != Checksum) { + MetadataCommands.UpdateRecordCount(this); + } + } + } else { + // Something failed while trying to parse + Clear(); } - if (CurrentCommands != null) { - foreach (var command in CurrentCommands) { - if (command.Attribute.ExecuteTiming.Has(ExecuteTiming.Runtime) && - (!EnforceLegalCommand.EnabledWhenRunning || command.Attribute.LegalInFullGame)) { - command.Invoke(); - } + CurrentFrameInTas = Math.Min(Inputs.Count, CurrentFrameInTas); + } - // SaveAndQuitReenter inserts inputs, so we can't continue executing the commands - // It already handles the moving of all following commands - if (command.Attribute.Name == "SaveAndQuitReenter") { - break; - } + /// Moves the controller 1 frame forward, updating inputs and triggering commands + public void AdvanceFrame() { + RefreshInputs(); + + foreach (var command in CurrentCommands) { + if (command.Attribute.ExecuteTiming.Has(ExecuteTiming.Runtime) && + (!EnforceLegalCommand.EnabledWhenRunning || command.Attribute.LegalInFullGame)) + { + command.Invoke(); } + + // SaveAndQuitReenter inserts inputs, so we can't continue executing the commands + // It already handles the moving of all following commands + if (command.Attribute.Name == "SaveAndQuitReenter") break; } if (!CanPlayback) { @@ -272,225 +169,253 @@ public void AdvanceFrame(out bool canPlayback) { StunPauseCommand.UpdateSimulateSkipInput(); InputHelper.FeedInputs(Current); - if (CurrentFrameInInput == 0 || Current.Line == Previous.Line && Current.RepeatIndex == Previous.RepeatIndex && - Current.FrameOffset == Previous.FrameOffset) { + // Increment if it's still the same input + if (CurrentFrameInInput == 0 || Current.Line == Previous!.Line && Current.RepeatIndex == Previous.RepeatIndex && Current.FrameOffset == Previous.FrameOffset) { CurrentFrameInInput++; } else { CurrentFrameInInput = 1; } - if (CurrentFrameInInputForHud == 0 || Current == Previous) { - CurrentFrameInInputForHud++; - } else { - CurrentFrameInInputForHud = 1; - } - CurrentFrameInTas++; } - // studioLine start from 0, startLine start from 1; - public bool ReadFile(string filePath, int startLine = 0, int endLine = int.MaxValue, int studioLine = 0, int repeatIndex = 0, - int repeatCount = 0) { + /// Parses the file and adds the inputs / commands to the TAS + public bool ReadFile(string path, int startLine = 0, int endLine = int.MaxValue, int studioLine = 0, int repeatIndex = 0, int repeatCount = 0) { try { - if (!File.Exists(filePath)) { + if (!File.Exists(path)) { return false; } - UsedFiles[filePath] = default; - IEnumerable lines = File.ReadLines(filePath).Take(endLine); - ReadLines(lines, filePath, startLine, studioLine, repeatIndex, repeatCount); + UsedFiles.Add(path); + ReadLines(File.ReadLines(path).Take(endLine), path, startLine, studioLine, repeatIndex, repeatCount); + return true; } catch (Exception e) { - e.Log(LogLevel.Warn); + e.Log(LogLevel.Error); return false; } } - public void ReadLines(IEnumerable lines, string filePath, int startLine, int studioLine, int repeatIndex, int repeatCount, - bool lockStudioLine = false) { - int subLine = 0; + /// Parses the lines and adds the inputs / commands to the TAS + public void ReadLines(IEnumerable lines, string path, int startLine, int studioLine, int repeatIndex, int repeatCount, bool lockStudioLine = false) { + int fileLine = 0; foreach (string readLine in lines) { - subLine++; - if (subLine < startLine) { + fileLine++; + if (fileLine < startLine) { continue; } - string lineText = readLine.Trim(); - - if (Command.TryParse(this, filePath, subLine, lineText, initializationFrameCount, studioLine, out Command command) && - command.Is("Play")) { - // workaround for the play command - // the play command needs to stop reading the current file when it's done to prevent recursion + if (!ReadLine(readLine, path, fileLine, studioLine, repeatIndex, repeatCount)) { return; } - if (lineText.StartsWith("***")) { - FastForward fastForward = new(initializationFrameCount, lineText.Substring(3), studioLine); - if (FastForwards.TryGetValue(initializationFrameCount, out FastForward oldFastForward) && oldFastForward.SaveState && - !fastForward.SaveState) { - // ignore - } else { - FastForwards[initializationFrameCount] = fastForward; - } - } else if (lineText.StartsWith("#")) { - FastForwardComments[initializationFrameCount] = new FastForward(initializationFrameCount, "", studioLine); - if (!Comments.TryGetValue(filePath, out var comments)) { - Comments[filePath] = comments = new List(); - } + if (path == FilePath && !lockStudioLine) { + studioLine++; + } + } + + // Add a hidden label at the of the text block + if (path == FilePath) { + FastForwardLabels[CurrentParsingFrame] = new FastForward(CurrentParsingFrame, "", studioLine); + } + } + + /// Parses the line and adds the inputs / commands to the TAS + public bool ReadLine(string line, string path, int fileLine, int studioLine, int repeatIndex = 0, int repeatCount = 0) { + string lineText = line.Trim(); - comments.Add(new Comment(filePath, initializationFrameCount, subLine, lineText)); - } else if (!AutoInputCommand.TryInsert(filePath, lineText, studioLine, repeatIndex, repeatCount)) { - AddFrames(lineText, studioLine, repeatIndex, repeatCount); + // Commands might insert inputs, which would offset CurrentParsingFrame to after the command, instead of before + int commandParsingFrame = CurrentParsingFrame; + if (Command.TryParse(path, fileLine, lineText, commandParsingFrame, studioLine, out Command command)) { + if (!Commands.TryGetValue(commandParsingFrame, out var commands)) { + Commands[commandParsingFrame] = commands = new List(); } + commands.Add(command); - if (filePath == TasFilePath && !lockStudioLine) { - studioLine++; + if (command.Is("Play")) { + // Workaround for the 'Play' command: + // It needs to stop reading the current file when it's done to prevent recursion + return false; + } + } else if (lineText.StartsWith("***")) { + var fastForward = new FastForward(CurrentParsingFrame, lineText.Substring("***".Length), studioLine); + if (FastForwards.TryGetValue(CurrentParsingFrame, out var oldFastForward) && oldFastForward.SaveState && !fastForward.SaveState) { + // ignore + } else { + FastForwards[CurrentParsingFrame] = fastForward; + } + } else if (lineText.StartsWith("#")) { + if (CommentLine.IsLabel(lineText)) { + FastForwardLabels[CurrentParsingFrame] = new FastForward(CurrentParsingFrame, "", studioLine); } - } - if (filePath == TasFilePath) { - FastForwardComments[initializationFrameCount] = new FastForward(initializationFrameCount, "", studioLine); + if (!Comments.TryGetValue(CurrentParsingFrame, out var comments)) { + Comments[CurrentParsingFrame] = comments = []; + } + comments.Add(new Comment(CurrentParsingFrame, path, fileLine, lineText)); + } else if (!AutoInputCommand.TryInsert(path, lineText, studioLine, repeatIndex, repeatCount)) { + AddFrames(lineText, studioLine, repeatIndex, repeatCount); } + + return true; } + /// Parses the input line and adds it to the TAS public void AddFrames(string line, int studioLine, int repeatIndex = 0, int repeatCount = 0, int frameOffset = 0) { - if (!InputFrame.TryParse(line, studioLine, Inputs.LastOrDefault(), out InputFrame inputFrame, repeatIndex, repeatCount, frameOffset)) { - return; + if (InputFrame.TryParse(line, studioLine, Inputs.LastOrDefault(), out var inputFrame, repeatIndex, repeatCount, frameOffset)) { + AddFrames(inputFrame); } + } + /// Adds the inputs to the TAS + public void AddFrames(InputFrame inputFrame) { for (int i = 0; i < inputFrame.Frames; i++) { Inputs.Add(inputFrame); } LibTasHelper.WriteLibTasFrame(inputFrame); - initializationFrameCount += inputFrame.Frames; } - public InputController Clone() { - InputController clone = new(); - - clone.Inputs.AddRange(Inputs); - clone.FastForwards.AddRange((IDictionary) FastForwards); - clone.FastForwardComments.AddRange((IDictionary) FastForwardComments); - - foreach (string filePath in Comments.Keys) { - clone.Comments[filePath] = new List(Comments[filePath]); - } + /// Fast-forwards to the next label / breakpoint + public void FastForwardToNextLabel() { + NextLabelFastForward = null; + RefreshInputs(); - foreach (int frame in Commands.Keys) { - clone.Commands[frame] = new List(Commands[frame]); + var next = FastForwardLabels.FirstOrDefault(pair => pair.Key > CurrentFrameInTas).Value; + if (next != null && HasFastForward && CurrentFastForward is { } last && next.Frame > last.Frame) { + // Forward to another breakpoint in-between instead + NextLabelFastForward = last; + } else { + NextLabelFastForward = next; } - clone.NeedsReload = NeedsReload; - clone.UsedFiles.AddRange((IDictionary) UsedFiles); - clone.CurrentFrameInTas = CurrentFrameInTas; - clone.CurrentFrameInInput = CurrentFrameInInput; - clone.CurrentFrameInInputForHud = CurrentFrameInInputForHud; - clone.SavestateChecksum = clone.CalcChecksum(CurrentFrameInTas); - - clone.checksum = checksum; - clone.initializationFrameCount = initializationFrameCount; + Manager.NextState = Manager.State.Running; + } - return clone; + /// Stops execution of the current TAS and resets state + public void Stop() { + CurrentFrameInTas = 0; + CurrentFrameInInput = 0; + NextLabelFastForward = null; } - public void CopyFrom(InputController controller) { + /// Clears all parsed data for the current TAS + public void Clear() { Inputs.Clear(); - Inputs.AddRange(controller.Inputs); - + Commands.Clear(); FastForwards.Clear(); - FastForwards.AddRange((IDictionary) controller.FastForwards); - FastForwardComments.Clear(); - FastForwardComments.AddRange((IDictionary) controller.FastForwardComments); + FastForwardLabels.Clear(); - Comments.Clear(); - foreach (string filePath in controller.Comments.Keys) { - Comments[filePath] = new List(controller.Comments[filePath]); + foreach (var watcher in watchers.Values) { + watcher.Dispose(); } - - Comments.Clear(); - foreach (int frame in controller.Commands.Keys) { - Commands[frame] = new List(controller.Commands[frame]); - } - + watchers.Clear(); UsedFiles.Clear(); - UsedFiles.AddRange((IDictionary) controller.UsedFiles); - NeedsReload = controller.NeedsReload; - CurrentFrameInTas = controller.CurrentFrameInTas; - CurrentFrameInInput = controller.CurrentFrameInInput; - CurrentFrameInInputForHud = controller.CurrentFrameInInputForHud; + checksum = InvalidChecksum; + NeedsReload = true; - checksum = controller.checksum; - initializationFrameCount = controller.initializationFrameCount; - savestateChecksum = controller.savestateChecksum; + AttributeUtils.Invoke(); } - public void CopyProgressFrom(InputController controller) { - CurrentFrameInInput = controller.CurrentFrameInInput; - CurrentFrameInInputForHud = controller.CurrentFrameInInputForHud; - CurrentFrameInTas = controller.CurrentFrameInTas; - } + /// Create file-system-watchers for all TAS-files used, to detect changes + public void StartWatchers() { + foreach (var path in UsedFiles) { + string fullPath = Path.GetFullPath(path); - public void FastForwardToNextComment() { - if (Manager.Running && Hotkeys.FastForwardComment.Pressed) { - NextCommentFastForward = null; - RefreshInputs(false); - FastForward next = FastForwardComments.FirstOrDefault(pair => pair.Key > CurrentFrameInTas).Value; - if (next != null && HasFastForward && CurrentFastForward is { } last && next.Frame > last.Frame) { - // NextCommentFastForward = last; - } else { - NextCommentFastForward = next; + // Watch TAS file + CreateWatcher(fullPath); + } + + void CreateWatcher(string path) { + if (watchers.ContainsKey(path)) { + return; } - Manager.States &= ~States.FrameStep; - Manager.NextStates &= ~States.FrameStep; + var watcher = new FileSystemWatcher { + Path = Path.GetDirectoryName(path)!, + Filter = Path.GetFileName(path), + NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite, + }; + + watcher.Changed += OnTasFileChanged; + watcher.Created += OnTasFileChanged; + watcher.Deleted += OnTasFileChanged; + watcher.Renamed += OnTasFileChanged; + + try { + watcher.EnableRaisingEvents = true; + $"Started watching '{path}' for changes...".Log(LogLevel.Verbose); + } catch (Exception e) { + e.LogException($"Failed watching folder: {watcher.Path}, filter: {watcher.Filter}"); + watcher.Dispose(); + return; + } + + watchers[path] = watcher; } - } - private string CalcChecksum(int toInputFrame) { - StringBuilder result = new(TasFilePath); - result.AppendLine(); + void OnTasFileChanged(object sender, FileSystemEventArgs e) { + $"TAS file changed: {e.FullPath} - {e.ChangeType}".Log(LogLevel.Verbose); + NeedsReload = true; + + AttributeUtils.Invoke(); + } + } - int checkInputFrame = 0; + /// Calculate a checksum until the specified frame + public int CalcChecksum(int upToFrame) { + var hash = new HashCode(); + hash.Add(filePath); - while (checkInputFrame < toInputFrame) { - InputFrame currentInput = Inputs[checkInputFrame]; - result.AppendLine(currentInput.ToActionsString()); + for (int i = 0; i < upToFrame; i++) { + hash.Add(Inputs[i]); - if (Commands.GetValueOrDefault(checkInputFrame) is { } commands) { - foreach (Command command in commands.Where(command => command.Attribute.CalcChecksum)) { - result.AppendLine(command.LineText); + if (Commands.GetValueOrDefault(i) is { } commands) { + foreach (var command in commands.Where(command => command.Attribute.CalcChecksum)) { + hash.Add(command.LineText); } } - - checkInputFrame++; } - return HashHelper.ComputeHash(result.ToString()); + return hash.ToHashCode(); } - public string CalcChecksum(InputController controller) => CalcChecksum(controller.CurrentFrameInTas); + public InputController Clone() { + var clone = new InputController { + filePath = filePath, + checksum = checksum, + + NeedsReload = NeedsReload, + CurrentFrameInTas = CurrentFrameInTas, + CurrentFrameInInput = CurrentFrameInInput + }; + + clone.Inputs.AddRange(Inputs); + + foreach (int frame in Commands.Keys) { + clone.Commands[frame] = [..Commands[frame]]; + } + foreach (int frame in Comments.Keys) { + clone.Comments[frame] = [..Comments[frame]]; + } + + foreach ((int line, var fastForward) in FastForwards) { + clone.FastForwards.Add(line, fastForward); + } + foreach ((int line, var fastForward) in FastForwardLabels) { + clone.FastForwardLabels.Add(line, fastForward); + } -#if DEBUG - // ReSharper disable once UnusedMember.Local - [Load] - private static void RestoreStudioTasFilePath() { - studioTasFilePath = Engine.Instance.GetDynamicDataInstance().Get(nameof(studioTasFilePath)); + foreach (var file in UsedFiles) { + clone.UsedFiles.Add(file); + } + + return clone; } - // for hot loading - // ReSharper disable once UnusedMember.Local - [Unload] - private static void SaveStudioTasFilePath() { - Engine.Instance.GetDynamicDataInstance().Set(nameof(studioTasFilePath), studioTasFilePath); - Manager.Controller.StopWatchers(); + public void CopyProgressFrom(InputController other) { + CurrentFrameInTas = other.CurrentFrameInTas; + CurrentFrameInInput = other.CurrentFrameInInput; } -#endif } - -[AttributeUsage(AttributeTargets.Method)] -internal class ClearInputsAttribute : Attribute { } - -[AttributeUsage(AttributeTargets.Method)] -internal class ParseFileEndAttribute : Attribute { } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Input/InputFrame.cs b/CelesteTAS-EverestInterop/Source/TAS/Input/InputFrame.cs index 49b7b57f7..a06727892 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Input/InputFrame.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Input/InputFrame.cs @@ -1,212 +1,106 @@ -using System; using System.Collections.Generic; -using System.Globalization; -using System.Text; -using System.Text.RegularExpressions; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; -using Monocle; using StudioCommunication; using TAS.Utils; namespace TAS.Input; +/// Represents a fully parsed input-line in a TAS file public record InputFrame { - private static readonly Regex DashOnlyDirectionRegex = new(@"[A]([LRUD]+$)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex MoveOnlyDirectionRegex = new(@"[M]([LRUD]+$)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex PressedKeyRegex = new(@"[P]([A-Z]+$)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - public Actions Actions { get; private set; } - public float Angle { get; private set; } - public float UpperLimit { get; private set; } = 1f; - public Vector2 AngleVector2 { get; private set; } - - public Vector2 DashOnlyVector2 { - get { - Vector2 result = Vector2.Zero; - if (Actions.Has(Actions.LeftDashOnly)) { - result.X = -1; - } + // Controller state + public readonly Actions Actions; + public readonly Vector2 StickPosition; + public readonly Vector2 DashOnlyStickPosition; + public readonly Vector2 MoveOnlyStickPosition; + public readonly HashSet PressedKeys = []; + + // libTAS state + public readonly Vector2Short StickPositionShort; + public readonly Vector2Short DashOnlyStickPositionShort; + public readonly Vector2Short MoveOnlyStickPositionShort; + + // Metadata + public int Frames; + public int Line; + public int FrameOffset; + + public int RepeatCount; + public int RepeatIndex; + public string RepeatString => RepeatCount > 1 ? $" {RepeatIndex}/{RepeatCount}" : ""; - if (Actions.Has(Actions.RightDashOnly)) { - result.X = 1; - } + public InputFrame? Previous; + public InputFrame? Next; - if (Actions.Has(Actions.UpDashOnly)) { - result.Y = 1; - } + private readonly string actionLineString; + private readonly int checksum; - if (Actions.Has(Actions.DownDashOnly)) { - result.Y = -1; - } + private InputFrame(ActionLine actionLine, int studioLine, int repeatIndex, int repeatCount, int frameOffset) { + Actions = actionLine.Actions; + Frames = actionLine.FrameCount; + PressedKeys = actionLine.CustomBindings.Select(c => (Keys)c).ToHashSet(); - return result; - } - } + Line = studioLine; + FrameOffset = frameOffset; - public Vector2Short AngleVector2Short { get; private set; } - public Vector2Short DashOnlyVector2Short { get; private set; } - public HashSet PressedKeys { get; } = new(); + RepeatIndex = repeatIndex; + RepeatCount = repeatCount; - public int Frames { get; private set; } - public int Line { get; private set; } - public InputFrame Previous { get; private set; } - public InputFrame Next { get; private set; } - public int RepeatCount { get; set; } - public int RepeatIndex { get; set; } - public string RepeatString => RepeatCount > 1 ? $" {RepeatIndex}/{RepeatCount}" : ""; - public int FrameOffset { get; private set; } - - public bool HasActions(Actions actions) => - (Actions & actions) != 0; - - public float GetX() { - return Angle switch { - 0f => 0, - 90f => 1, - 180f => 0, - 270f => -1, - 360f => 0, - _ => (float) Math.Sin(Angle * Math.PI / 180.0) - }; - } - - public float GetY() { - return Angle switch { - 0f => 1, - 90f => 0, - 180f => -1, - 270f => 0, - 360f => 1, - _ => (float) Math.Cos(Angle * Math.PI / 180.0) - }; - } + actionLineString = actionLine.ToString(); + checksum = actionLineString.GetHashCode(); - public override string ToString() { - return Frames + ToActionsString(); - } - - public string ToActionsString() { - StringBuilder sb = new(); - - foreach (KeyValuePair pair in ActionsUtils.Chars) { - Actions actions = pair.Value; - if (HasActions(actions)) { - sb.Append($",{pair.Key}"); - - if (actions == Actions.DashOnly) { - foreach (KeyValuePair dashOnlyPair in ActionsUtils.DashOnlyChars) { - if (HasActions(dashOnlyPair.Value)) { - sb.Append($"{dashOnlyPair.Key}"); - } - } - } else if (actions == Actions.MoveOnly) { - foreach (KeyValuePair moveOnlyPair in ActionsUtils.MoveOnlyChars) { - if (HasActions(moveOnlyPair.Value)) { - sb.Append($"{moveOnlyPair.Key}"); - } - } - } else if (actions == Actions.PressedKey) { - foreach (Keys key in PressedKeys) { - sb.Append((char) key); - } - } else if (actions == Actions.Feather) { - sb.Append(",").Append(Angle == 0 ? string.Empty : Angle.ToString(CultureInfo.InvariantCulture)); - if (Math.Abs(UpperLimit - 1f) > 1e-10) { - sb.Append($",{UpperLimit}"); - } - } + if (Actions.Has(Actions.Feather)) { + if (float.TryParse(actionLine.FeatherAngle, out float angle)) { + float magnitude = float.TryParse(actionLine.FeatherMagnitude, out float m) ? m : 1.0f; + (StickPosition, StickPositionShort) = AnalogHelper.ComputeAngleVector(angle, magnitude); + } else { + // Fallback to 0° + (StickPosition, StickPositionShort) = AnalogHelper.ComputeAngleVector(0.0f, 1.0f); } } - return sb.ToString(); - } - - public static bool TryParse(string line, int studioLine, InputFrame prevInputFrame, out InputFrame inputFrame, int repeatIndex = 0, - int repeatCount = 0, int frameOffset = 0) { - int index = line.IndexOf(",", StringComparison.Ordinal); - string framesStr; - if (index == -1) { - framesStr = line; - index = 0; - } else { - framesStr = line.Substring(0, index); + if (Actions.Has(Actions.LeftDashOnly)) { + DashOnlyStickPosition.X = -1.0f; + } else if (Actions.Has(Actions.RightDashOnly)) { + DashOnlyStickPosition.X = 1.0f; } - - if (!int.TryParse(framesStr, out int frames) || frames <= 0) { - inputFrame = null; - return false; + if (Actions.Has(Actions.DownDashOnly)) { + DashOnlyStickPosition.Y = -1.0f; + } else if (Actions.Has(Actions.UpDashOnly)) { + DashOnlyStickPosition.Y = 1.0f; } + DashOnlyStickPositionShort = new Vector2Short((short) (DashOnlyStickPosition.X * 32767), (short) (DashOnlyStickPosition.Y * 32767)); - frames = Math.Min(frames, 9999); - inputFrame = new InputFrame { - Line = studioLine, - Frames = frames, - RepeatIndex = repeatIndex, - RepeatCount = repeatCount, - FrameOffset = frameOffset, - }; - - while (index < line.Length) { - char c = char.ToUpper(line[index]); - - if (c is >= 'A' and <= 'Z' && IsPressedKey()) { - inputFrame.PressedKeys.Add((Keys) c); // enum values for letter keys match ASCII uppercase letters - } else if (ActionsUtils.TryParse(c, out Actions actions)) { - if (IsDashOnlyDirection()) { - actions = actions.ToDashOnlyActions(); - } else if (IsMoveOnlyDirection()) { - actions = actions.ToMoveOnlyActions(); - } else if (actions == Actions.Feather) { - inputFrame.Actions ^= Actions.Feather; - index++; - string angleAndUpperLimit = line.Substring(index + 1).Trim(); - if (angleAndUpperLimit.IsNotNullOrEmpty()) { - string[] args = angleAndUpperLimit.Split(','); - string angle = args[0]; - if (float.TryParse(angle, out float angleFloat)) { - inputFrame.Angle = angleFloat; - } - - if (args.Length >= 2 && float.TryParse(args[1], out float upperLimitFloat)) { - inputFrame.UpperLimit = Calc.Clamp(upperLimitFloat, 0.26f, 1f); - } - } - - inputFrame.AngleVector2 = AnalogHelper.ComputeAngleVector2(inputFrame, out Vector2Short angleVector2Short); - inputFrame.AngleVector2Short = angleVector2Short; - continue; - } - - inputFrame.Actions ^= actions; - } + if (Actions.Has(Actions.LeftMoveOnly)) { + MoveOnlyStickPosition.X = -1.0f; + } else if (Actions.Has(Actions.RightMoveOnly)) { + MoveOnlyStickPosition.X = 1.0f; + } + if (Actions.Has(Actions.DownMoveOnly)) { + MoveOnlyStickPosition.Y = -1.0f; + } else if (Actions.Has(Actions.UpMoveOnly)) { + MoveOnlyStickPosition.Y = 1.0f; + } + MoveOnlyStickPositionShort = new Vector2Short((short) (MoveOnlyStickPosition.X * 32767), (short) (MoveOnlyStickPosition.Y * 32767)); + } - index++; + public static bool TryParse(string line, int studioLine, InputFrame? prevInputFrame, [NotNullWhen(true)] out InputFrame? inputFrame, int repeatIndex = 0, int repeatCount = 0, int frameOffset = 0) { + inputFrame = null; + if (!ActionLine.TryParse(line, out var actionLine)) { + return false; } - Vector2 v = inputFrame.DashOnlyVector2; - inputFrame.DashOnlyVector2Short = new Vector2Short((short) (v.X * 32767), (short) (v.Y * 32767)); + inputFrame = new InputFrame(actionLine, studioLine, repeatIndex, repeatCount, frameOffset); - if (prevInputFrame != null) { + inputFrame.Previous = prevInputFrame; + if (prevInputFrame != null) prevInputFrame.Next = inputFrame; - inputFrame.Previous = prevInputFrame; - } return true; - - bool IsDashOnlyDirection() { - string subLine = line.Substring(0, index + 1); - return DashOnlyDirectionRegex.IsMatch(subLine); - } - - bool IsMoveOnlyDirection() { - string subLine = line.Substring(0, index + 1); - return MoveOnlyDirectionRegex.IsMatch(subLine); - } - - bool IsPressedKey() { - string subLine = line.Substring(0, index + 1); - return PressedKeyRegex.IsMatch(subLine); - } } -} \ No newline at end of file + + public override string ToString() => actionLineString; + public override int GetHashCode() => checksum; +} diff --git a/CelesteTAS-EverestInterop/Source/TAS/InputHelper.cs b/CelesteTAS-EverestInterop/Source/TAS/InputHelper.cs index afaa6d1b1..4cf176634 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/InputHelper.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/InputHelper.cs @@ -6,6 +6,7 @@ using StudioCommunication; using TAS.Input; using TAS.Input.Commands; +using TAS.Utils; namespace TAS; @@ -14,7 +15,7 @@ public static void FeedInputs(InputFrame input) { GamePadDPad pad = default; GamePadThumbSticks sticks = default; GamePadState gamePadState = default; - if (input.HasActions(Actions.Feather)) { + if (input.Actions.Has(Actions.Feather)) { SetFeather(input, ref pad, ref sticks); } else { SetDPad(input, ref pad, ref sticks); @@ -36,23 +37,23 @@ private static void SetKeyboardState(InputFrame input) { MInput.Keyboard.PreviousState = MInput.Keyboard.CurrentState; HashSet keys = PressCommand.GetKeys(); - if (input.HasActions(Actions.Confirm)) { + if (input.Actions.Has(Actions.Confirm)) { keys.Add(BindingHelper.Confirm2); } - if (input.HasActions(Actions.LeftMoveOnly)) { + if (input.Actions.Has(Actions.LeftMoveOnly)) { keys.Add(BindingHelper.LeftMoveOnly); } - if (input.HasActions(Actions.RightMoveOnly)) { + if (input.Actions.Has(Actions.RightMoveOnly)) { keys.Add(BindingHelper.RightMoveOnly); } - if (input.HasActions(Actions.UpMoveOnly)) { + if (input.Actions.Has(Actions.UpMoveOnly)) { keys.Add(BindingHelper.UpMoveOnly); } - if (input.HasActions(Actions.DownMoveOnly)) { + if (input.Actions.Has(Actions.DownMoveOnly)) { keys.Add(BindingHelper.DownMoveOnly); } @@ -63,39 +64,39 @@ private static void SetKeyboardState(InputFrame input) { private static void SetFeather(InputFrame input, ref GamePadDPad pad, ref GamePadThumbSticks sticks) { pad = new GamePadDPad(ButtonState.Released, ButtonState.Released, ButtonState.Released, ButtonState.Released); - sticks = new GamePadThumbSticks(input.AngleVector2, input.DashOnlyVector2); + sticks = new GamePadThumbSticks(input.StickPosition, input.DashOnlyStickPosition); } private static void SetDPad(InputFrame input, ref GamePadDPad pad, ref GamePadThumbSticks sticks) { pad = new GamePadDPad( - input.HasActions(Actions.Up) ? ButtonState.Pressed : ButtonState.Released, - input.HasActions(Actions.Down) ? ButtonState.Pressed : ButtonState.Released, - input.HasActions(Actions.Left) ? ButtonState.Pressed : ButtonState.Released, - input.HasActions(Actions.Right) ? ButtonState.Pressed : ButtonState.Released + input.Actions.Has(Actions.Up) ? ButtonState.Pressed : ButtonState.Released, + input.Actions.Has(Actions.Down) ? ButtonState.Pressed : ButtonState.Released, + input.Actions.Has(Actions.Left) ? ButtonState.Pressed : ButtonState.Released, + input.Actions.Has(Actions.Right) ? ButtonState.Pressed : ButtonState.Released ); - sticks = new GamePadThumbSticks(new Vector2(0, 0), input.DashOnlyVector2); + sticks = new GamePadThumbSticks(new Vector2(0, 0), input.DashOnlyStickPosition); } private static void SetGamePadState(InputFrame input, ref GamePadState state, ref GamePadDPad pad, ref GamePadThumbSticks sticks) { state = new GamePadState( sticks, - new GamePadTriggers(input.HasActions(Actions.Journal) ? 1f : 0f, 0), + new GamePadTriggers(input.Actions.Has(Actions.Journal) ? 1f : 0f, 0), new GamePadButtons( - (input.HasActions(Actions.Jump) ? BindingHelper.JumpAndConfirm : 0) - | (input.HasActions(Actions.Jump2) ? BindingHelper.Jump2 : 0) - | (input.HasActions(Actions.DemoDash) ? BindingHelper.DemoDash : 0) - | (input.HasActions(Actions.DemoDash2) ? BindingHelper.DemoDash2 : 0) - | (input.HasActions(Actions.Dash) ? BindingHelper.DashAndTalkAndCancel : 0) - | (input.HasActions(Actions.Dash2) ? BindingHelper.Dash2AndCancel : 0) - | (input.HasActions(Actions.Grab) ? BindingHelper.Grab : 0) - | (input.HasActions(Actions.Grab2) ? BindingHelper.Grab2 : 0) - | (input.HasActions(Actions.Start) ? BindingHelper.Pause : 0) - | (input.HasActions(Actions.Restart) ? BindingHelper.QuickRestart : 0) - | (input.HasActions(Actions.Up) ? BindingHelper.Up : 0) - | (input.HasActions(Actions.Down) ? BindingHelper.Down : 0) - | (input.HasActions(Actions.Left) ? BindingHelper.Left : 0) - | (input.HasActions(Actions.Right) ? BindingHelper.Right : 0) - | (input.HasActions(Actions.Journal) ? BindingHelper.JournalAndTalk : 0) + (input.Actions.Has(Actions.Jump) ? BindingHelper.JumpAndConfirm : 0) + | (input.Actions.Has(Actions.Jump2) ? BindingHelper.Jump2 : 0) + | (input.Actions.Has(Actions.DemoDash) ? BindingHelper.DemoDash : 0) + | (input.Actions.Has(Actions.DemoDash2) ? BindingHelper.DemoDash2 : 0) + | (input.Actions.Has(Actions.Dash) ? BindingHelper.DashAndTalkAndCancel : 0) + | (input.Actions.Has(Actions.Dash2) ? BindingHelper.Dash2AndCancel : 0) + | (input.Actions.Has(Actions.Grab) ? BindingHelper.Grab : 0) + | (input.Actions.Has(Actions.Grab2) ? BindingHelper.Grab2 : 0) + | (input.Actions.Has(Actions.Start) ? BindingHelper.Pause : 0) + | (input.Actions.Has(Actions.Restart) ? BindingHelper.QuickRestart : 0) + | (input.Actions.Has(Actions.Up) ? BindingHelper.Up : 0) + | (input.Actions.Has(Actions.Down) ? BindingHelper.Down : 0) + | (input.Actions.Has(Actions.Left) ? BindingHelper.Left : 0) + | (input.Actions.Has(Actions.Right) ? BindingHelper.Right : 0) + | (input.Actions.Has(Actions.Journal) ? BindingHelper.JournalAndTalk : 0) ), pad ); diff --git a/CelesteTAS-EverestInterop/Source/TAS/LibTasHelper.cs b/CelesteTAS-EverestInterop/Source/TAS/LibTasHelper.cs index fcf39614d..9815a6c3b 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/LibTasHelper.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/LibTasHelper.cs @@ -77,8 +77,8 @@ public static void WriteLibTasFrame(InputFrame inputFrame) { for (int i = 0; i < inputFrame.Frames; ++i) { WriteLibTasFrame(LibTasKeys(inputFrame), - $"{inputFrame.AngleVector2Short.X}:{-inputFrame.AngleVector2Short.Y}", - $"{inputFrame.DashOnlyVector2Short.X}:{-inputFrame.DashOnlyVector2Short.Y}", + $"{inputFrame.StickPositionShort.X}:{-inputFrame.StickPositionShort.Y}", + $"{inputFrame.DashOnlyStickPositionShort.X}:{-inputFrame.DashOnlyStickPositionShort.Y}", LibTasButtons(inputFrame)); } } @@ -103,8 +103,7 @@ public static void ConvertToLibTas(string path) { Manager.DisableRun(); StartExport(path); - Manager.Controller.NeedsReload = true; - Manager.Controller.RefreshInputs(true); + Manager.Controller.RefreshInputs(forceRefresh: true); Manager.DisableRun(); } @@ -121,37 +120,37 @@ private static void WriteLibTasFrame(string outputKeys, string outputAxesLeft, s private static string LibTasKeys(InputFrame inputFrame) { keys.Clear(); - if (inputFrame.HasActions(Actions.Confirm)) { + if (inputFrame.Actions.Has(Actions.Confirm)) { // Keys.C keys.Add("63"); } - if (inputFrame.HasActions(Actions.Restart)) { + if (inputFrame.Actions.Has(Actions.Restart)) { // Keys.R keys.Add("72"); } - if (inputFrame.HasActions(Actions.UpMoveOnly)) { + if (inputFrame.Actions.Has(Actions.UpMoveOnly)) { // Keys.I keys.Add("69"); } - if (inputFrame.HasActions(Actions.LeftMoveOnly)) { + if (inputFrame.Actions.Has(Actions.LeftMoveOnly)) { // Keys.J keys.Add("6a"); } - if (inputFrame.HasActions(Actions.DownMoveOnly)) { + if (inputFrame.Actions.Has(Actions.DownMoveOnly)) { // Keys.K keys.Add("6b"); } - if (inputFrame.HasActions(Actions.RightMoveOnly)) { + if (inputFrame.Actions.Has(Actions.RightMoveOnly)) { // Keys.L keys.Add("6c"); } - if (inputFrame.HasActions(Actions.Journal)) { + if (inputFrame.Actions.Has(Actions.Journal)) { // Keys.Tab keys.Add("ff09"); } @@ -180,55 +179,55 @@ private static string LibTasButtons(InputFrame inputFrame) { buttons[i] = '.'; } - if (inputFrame.HasActions(Actions.Left)) { + if (inputFrame.Actions.Has(Actions.Left)) { buttons[13] = 'l'; } - if (inputFrame.HasActions(Actions.Right)) { + if (inputFrame.Actions.Has(Actions.Right)) { buttons[14] = 'r'; } - if (inputFrame.HasActions(Actions.Up)) { + if (inputFrame.Actions.Has(Actions.Up)) { buttons[11] = 'u'; } - if (inputFrame.HasActions(Actions.Down)) { + if (inputFrame.Actions.Has(Actions.Down)) { buttons[12] = 'd'; } - if (inputFrame.HasActions(Actions.Jump)) { + if (inputFrame.Actions.Has(Actions.Jump)) { buttons[0] = 'A'; } - if (inputFrame.HasActions(Actions.Jump2)) { + if (inputFrame.Actions.Has(Actions.Jump2)) { buttons[3] = 'Y'; } - if (inputFrame.HasActions(Actions.DemoDash)) { + if (inputFrame.Actions.Has(Actions.DemoDash)) { buttons[10] = ']'; } - if (inputFrame.HasActions(Actions.DemoDash2)) { + if (inputFrame.Actions.Has(Actions.DemoDash2)) { buttons[8] = ')'; } - if (inputFrame.HasActions(Actions.Dash)) { + if (inputFrame.Actions.Has(Actions.Dash)) { buttons[1] = 'B'; } - if (inputFrame.HasActions(Actions.Dash2)) { + if (inputFrame.Actions.Has(Actions.Dash2)) { buttons[2] = 'X'; } - if (inputFrame.HasActions(Actions.Start)) { + if (inputFrame.Actions.Has(Actions.Start)) { buttons[6] = 's'; } - if (inputFrame.HasActions(Actions.Grab)) { + if (inputFrame.Actions.Has(Actions.Grab)) { buttons[9] = '['; } - if (inputFrame.HasActions(Actions.Grab2)) { + if (inputFrame.Actions.Has(Actions.Grab2)) { buttons[7] = '('; } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Manager.cs b/CelesteTAS-EverestInterop/Source/TAS/Manager.cs index 1c64032ba..05085ad51 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Manager.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Manager.cs @@ -1,244 +1,302 @@ using System; using System.Collections.Concurrent; using System.Linq; -using System.Threading; using Celeste; using Celeste.Mod; using Celeste.Pico8; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Input; +using JetBrains.Annotations; using Monocle; using StudioCommunication; using TAS.Communication; using TAS.EverestInterop; using TAS.Input; -using TAS.Input.Commands; +using TAS.Module; using TAS.Utils; namespace TAS; +[AttributeUsage(AttributeTargets.Method), MeansImplicitUse] +public class EnableRunAttribute : Attribute; + +[AttributeUsage(AttributeTargets.Method), MeansImplicitUse] +public class DisableRunAttribute : Attribute; + +/// Main controller, which manages how the TAS is played back public static class Manager { - private static readonly ConcurrentQueue mainThreadActions = new(); + public enum State { + /// No TAS is currently active + Disabled, + /// Plays the current TAS back at the specified PlaybackSpeed + Running, + /// Pauses the current TAS + Paused, + /// Advances the current TAS by 1 frame and resets back to Paused + FrameAdvance, + /// Forwards the TAS while paused + SlowForward, + } + + [Initialize] + private static void Initialize() { + AttributeUtils.CollectAllMethods(); + AttributeUtils.CollectAllMethods(); + } - public static bool Running; - public static bool Recording => TASRecorderUtils.Recording; + public static bool Running => CurrState != State.Disabled; + public static bool FastForwarding => Running && PlaybackSpeed >= 5.0f; + public static float PlaybackSpeed { get; private set; } = 1.0f; + + public static State CurrState, NextState; public static readonly InputController Controller = new(); - public static States LastStates, States, NextStates; - public static float FrameLoops { get; private set; } = 1f; - public static bool UltraFastForwarding => FrameLoops >= 100 && Running; - public static bool SlowForwarding => FrameLoops < 1f; - public static bool AdvanceThroughHiddenFrame; - private static bool SkipSlowForwardingFrame => - FrameLoops < 1f && (int) ((Engine.FrameCounter + 1) * FrameLoops) == (int) (Engine.FrameCounter * FrameLoops); + private static readonly ConcurrentQueue mainThreadActions = new(); - public static bool SkipFrame => (States.Has(States.FrameStep) || SkipSlowForwardingFrame) && !AdvanceThroughHiddenFrame; +#if DEBUG + // Hot-reloading support + [Load] + private static void RestoreStudioTasFilePath() { + if (Engine.Instance.GetDynamicDataInstance().Get("CelesteTAS_FilePath") is { } filePath) { + Controller.FilePath = filePath; + } - static Manager() { - AttributeUtils.CollectMethods(); - AttributeUtils.CollectMethods(); + // Stop TAS to avoid blocking reload + typeof(AssetReloadHelper) + .GetMethodInfo(nameof(AssetReloadHelper.Do)) + .HookBefore(DisableRun); } - private static bool ShouldForceState => - NextStates.Has(States.FrameStep) && !Hotkeys.FastForward.OverrideCheck && !Hotkeys.SlowForward.OverrideCheck; + [Unload] + private static void SaveStudioTasFilePath() { + Engine.Instance.GetDynamicDataInstance().Set("CelesteTAS_FilePath", Controller.FilePath); - public static void AddMainThreadAction(Action action) { - if (Thread.CurrentThread == MainThreadHelper.MainThread) { - action(); - } else { - mainThreadActions.Enqueue(action); + Controller.Stop(); + Controller.Clear(); + } +#endif + + public static void EnableRun() { + if (Running) { + return; } + + $"Starting TAS: {Controller.FilePath}".Log(); + + CurrState = NextState = State.Running; + PlaybackSpeed = 1.0f; + + Controller.Stop(); + Controller.RefreshInputs(); + AttributeUtils.Invoke(); + + // This needs to happen after EnableRun, otherwise the input state will be reset in BindingHelper.SetTasBindings + Savestates.EnableRun(); } - private static void ExecuteMainThreadActions() { - while (mainThreadActions.TryDequeue(out Action action)) { - action.Invoke(); + public static void DisableRun() { + if (!Running) { + return; } + + "Stopping TAS".Log(); + + CurrState = NextState = State.Disabled; + Controller.Stop(); + AttributeUtils.Invoke(); } + /// Will start the TAS on the next update cycle + public static void EnableRunLater() => NextState = State.Running; + /// Will stop the TAS on the next update cycle + public static void DisableRunLater() => NextState = State.Disabled; + + /// Updates the TAS itself public static void Update() { - LastStates = States; - ExecuteMainThreadActions(); - Hotkeys.Update(); - Savestates.HandleSaveStates(); - HandleFrameRates(); - CheckToEnable(); - FrameStepping(); - - if (States.Has(States.Enable)) { - Running = true; - - if (!SkipFrame) { - Controller.AdvanceFrame(out bool canPlayback); - - // stop TAS if breakpoint is not placed at the end - if (Controller.Break && Controller.CanPlayback && !Recording) { - Controller.NextCommentFastForward = null; - NextStates |= States.FrameStep; - FrameLoops = 1; - } + if (!Running && NextState == State.Running) { + EnableRun(); + } + if (Running && NextState == State.Disabled) { + DisableRun(); + } - if (!canPlayback) { - DisableRun(); - } else if (SafeCommand.DisallowUnsafeInput && Controller.CurrentFrameInTas > 1) { - if (Engine.Scene is not (Level or LevelLoader or LevelExit or Emulator or LevelEnter)) { - DisableRun(); - } else if (Engine.Scene is Level level && level.Tracker.GetEntity() is { } menu) { - TextMenu.Item item = menu.Items.FirstOrDefault(); - if (item is TextMenu.Header {Title: { } title} && - (title == Dialog.Clean("OPTIONS_TITLE") || title == Dialog.Clean("MENU_VARIANT_TITLE") || - title == Dialog.Clean("MODOPTIONS_EXTENDEDVARIANTS_PAUSEMENU_BUTTON").ToUpperInvariant()) || - item is TextMenuExt.HeaderImage {Image: "menu/everest"}) { - DisableRun(); - } - } - } - } - } else { - Running = false; + CurrState = NextState; + + while (mainThreadActions.TryDequeue(out var action)) { + action.Invoke(); } - SendStateToStudio(); - } + Savestates.Update(); + + if (!Running || CurrState == State.Paused || IsLoading()) { + return; + } - private static void HandleFrameRates() { - FrameLoops = 1; + if (Controller.HasFastForward) { + NextState = State.Running; + } - // Keep frame rate consistant while recording - if (Recording) { + if (!Controller.CanPlayback) { + DisableRun(); return; } - if (States.Has(States.Enable) && !States.Has(States.FrameStep) && !NextStates.Has(States.FrameStep)) { - if (Controller.HasFastForward) { - FrameLoops = Controller.FastForwardSpeed; - } + Controller.AdvanceFrame(); - if (Hotkeys.FastForward.Check) { - FrameLoops = TasSettings.FastForwardSpeed; - } else if (Hotkeys.SlowForward.Check) { - FrameLoops = TasSettings.SlowForwardSpeed; - } else if (Math.Round(Hotkeys.RightThumbSticksX * TasSettings.FastForwardSpeed) is var fastForwardSpeed and >= 2) { - FrameLoops = (int) fastForwardSpeed; - } else if (Hotkeys.RightThumbSticksX < 0f && - (1 + Hotkeys.RightThumbSticksX) * TasSettings.SlowForwardSpeed is var slowForwardSpeed and <= 0.9f) { - FrameLoops = Math.Max(slowForwardSpeed, FastForward.MinSpeed); - } + // Auto-pause at end of drafts + if (!Controller.CanPlayback && IsDraft()) { + NextState = State.Paused; + } + // Pause the TAS if breakpoint is hit + else if (Controller.Break) { + Controller.NextLabelFastForward = null; + NextState = State.Paused; } } - private static void FrameStepping() { - bool frameAdvance = Hotkeys.FrameAdvance.Check && !Hotkeys.StartStop.Check; - bool pause = Hotkeys.PauseResume.Check && !Hotkeys.StartStop.Check; + /// Updates everything around the TAS itself, like hotkeys, studio-communication, etc. + public static void UpdateMeta() { + if (!Hotkeys.Initialized) { + return; // Still loading + } - if (States.Has(States.Enable)) { - if (NextStates.Has(States.FrameStep)) { - States |= States.FrameStep; - NextStates &= ~States.FrameStep; - } + Hotkeys.UpdateMeta(); + Savestates.UpdateMeta(); + ConsoleEnhancements.UpdateMeta(); - if (frameAdvance && !Hotkeys.FrameAdvance.LastCheck && !Recording) { - if (!States.Has(States.FrameStep)) { - States |= States.FrameStep; - NextStates &= ~States.FrameStep; - } else { - States &= ~States.FrameStep; - NextStates |= States.FrameStep; - } - } else if (pause && !Hotkeys.PauseResume.LastCheck && !Recording) { - if (!States.Has(States.FrameStep)) { - States |= States.FrameStep; - NextStates &= ~States.FrameStep; - } else { - States &= ~States.FrameStep; - NextStates &= ~States.FrameStep; - } - } else if (LastStates.Has(States.FrameStep) && States.Has(States.FrameStep) && - (Hotkeys.FastForward.Check || Hotkeys.SlowForward.Check && Engine.FrameCounter % Math.Round(4 / TasSettings.SlowForwardSpeed) == 0) && - !Hotkeys.FastForwardComment.Check) { - States &= ~States.FrameStep; - NextStates |= States.FrameStep; + SendStudioState(); + + // Check if the TAS should be enabled / disabled + if (Hotkeys.StartStop.Pressed) { + if (Running) { + DisableRun(); + } else { + EnableRun(); } + return; } - } - private static void CheckToEnable() { - // Do not use Hotkeys.Restart.Pressed unless the fast forwarding optimization in Hotkeys.Update() is removed - if (!Savestates.SpeedrunToolInstalled && Hotkeys.Restart.Released) { + if (Hotkeys.Restart.Pressed) { DisableRun(); EnableRun(); return; } - if (Hotkeys.StartStop.Check) { - if (States.Has(States.Enable)) { - NextStates |= States.Disable; - } else { - NextStates |= States.Enable; - } - } else if (NextStates.Has(States.Enable)) { - EnableRun(); - } else if (NextStates.Has(States.Disable)) { - DisableRun(); - } - } - - public static void EnableRun() { - if (Engine.Scene is GameLoader || CriticalErrorHandlerFixer.Handling) { - Running = false; - LastStates = States.None; - States = States.None; - NextStates = States.None; + if (Running && Hotkeys.FastForwardComment.Pressed) { + Controller.FastForwardToNextLabel(); return; } - Running = true; - States |= States.Enable; - States &= ~States.FrameStep; - NextStates &= ~States.Enable; - AttributeUtils.Invoke(); - Controller.RefreshInputs(true); - } + switch (CurrState) { + case State.Running: + if (Hotkeys.PauseResume.Pressed || Hotkeys.FrameAdvance.Pressed) { + NextState = State.Paused; + } + break; + + case State.FrameAdvance: + NextState = State.Paused; + break; + + case State.Paused: + if (Hotkeys.PauseResume.Pressed) { + NextState = State.Running; + } else if (Hotkeys.FrameAdvance.Repeated || Hotkeys.FastForward.Check) { + // Prevent frame-advancing into the end of the TAS + if (!Controller.CanPlayback) { + Controller.RefreshInputs(); // Ensure there aren't any new inputs + } + if (Controller.CanPlayback) { + NextState = State.FrameAdvance; + } else { + // TODO: Display toast "Reached end-of-file". Currently not possible due to them not being updated + } + } + break; - public static void DisableRun() { - Running = false; + case State.Disabled: + default: + break; + } + + // Allow altering the playback speed with the right thumb-stick + float normalSpeed = Hotkeys.RightThumbSticksX switch { + >= 0.001f => Hotkeys.RightThumbSticksX * TasSettings.FastForwardSpeed, + <= -0.001f => (1 + Hotkeys.RightThumbSticksX) * TasSettings.SlowForwardSpeed, + _ => 1.0f, + }; - LastStates = States.None; - States = States.None; - NextStates = States.None; + // Apply fast / slow forwarding + switch (NextState) { + case State.Running when Hotkeys.FastForward.Check: + PlaybackSpeed = TasSettings.FastForwardSpeed; + break; + case State.Running when Hotkeys.SlowForward.Check: + PlaybackSpeed = TasSettings.SlowForwardSpeed; + break; + + case State.Paused or State.SlowForward when Hotkeys.SlowForward.Check: + PlaybackSpeed = TasSettings.SlowForwardSpeed; + NextState = State.SlowForward; + break; + case State.Paused or State.SlowForward: + PlaybackSpeed = normalSpeed; + NextState = State.Paused; + break; + + case State.FrameAdvance: + PlaybackSpeed = normalSpeed; + break; - // fix the input that was last held stays for a frame when it ends - if (MInput.GamePads != null && MInput.GamePads.FirstOrDefault(data => data.Attached) is { } gamePadData) { - gamePadData.CurrentState = new GamePadState(); + default: + PlaybackSpeed = Controller.HasFastForward ? Controller.CurrentFastForward!.Speed : normalSpeed; + break; } + } - AttributeUtils.Invoke(); - Controller.Stop(); + /// Queues an action to be performed on the main thread + public static void AddMainThreadAction(Action action) { + mainThreadActions.Enqueue(action); + } + + /// TAS-execution is paused during loading screens + public static bool IsLoading() { + return Engine.Scene switch { + Level level => level.IsAutoSaving() && level.Session.Level == "end-cinematic", + SummitVignette summit => !summit.ready, + Overworld overworld => overworld.Current is OuiFileSelect { SlotIndex: >= 0 } slot && slot.Slots[slot.SlotIndex].StartingGame || + overworld.Next is OuiChapterSelect && UserIO.Saving || + overworld.Next is OuiMainMenu && (UserIO.Saving || Everest._SavingSettings), + Emulator emulator => emulator.game == null, + _ => Engine.Scene is LevelExit or LevelLoader or GameLoader || Engine.Scene.GetType().Name == "LevelExitToLobby", + }; } - public static void DisableRunLater() { - NextStates |= States.Disable; + /// Determine if current TAS file is a draft + private static bool IsDraft() { + // Require any FileTime or ChapterTime, alternatively MidwayFileTime or MidwayChapterTime at the end for the TAS to be counted as finished + return Controller.Commands.Values + .SelectMany(commands => commands) + .All(command => !command.Is("FileTime") && !command.Is("ChapterTime")) + && Controller.Commands.GetValueOrDefault(Controller.Inputs.Count, []) + .All(command => !command.Is("MidwayFileTime") && !command.Is("MidwayChapterTime")); } - public static void SendStateToStudio() { - if (UltraFastForwarding && Engine.FrameCounter % 23 > 0) { + public static bool PreventSendStudioState = false; // a cursed demand of tas helper's predictor + + internal static void SendStudioState() { + if (PreventSendStudioState) { return; } - var previous = Controller.Previous; var state = new StudioState { CurrentLine = previous?.Line ?? -1, CurrentLineSuffix = $"{Controller.CurrentFrameInInput + (previous?.FrameOffset ?? 0)}{previous?.RepeatString ?? ""}", CurrentFrameInTas = Controller.CurrentFrameInTas, - CurrentFrameInInput = Controller.CurrentFrameInInput, TotalFrames = Controller.Inputs.Count, SaveStateLine = Savestates.StudioHighlightLine, - - tasStates = States, + tasStates = 0, GameInfo = GameInfo.StudioInfo, LevelName = GameInfo.LevelName, ChapterTime = GameInfo.ChapterTime, - ShowSubpixelIndicator = TasSettings.InfoSubpixelIndicator && Engine.Scene is Level or Emulator, }; @@ -254,28 +312,4 @@ public static void SendStateToStudio() { CommunicationWrapper.SendState(state); } - - public static bool IsLoading() { - switch (Engine.Scene) { - case Level level: - return level.IsAutoSaving() && level.Session.Level == "end-cinematic"; - case SummitVignette summit: - return !summit.ready; - case Overworld overworld: - return overworld.Current is OuiFileSelect {SlotIndex: >= 0} slot && slot.Slots[slot.SlotIndex].StartingGame || - overworld.Next is OuiChapterSelect && UserIO.Saving || - overworld.Next is OuiMainMenu && (UserIO.Saving || Everest._SavingSettings); - case Emulator emulator: - return emulator.game == null; - default: - bool isLoading = Engine.Scene is LevelExit or LevelLoader or GameLoader || Engine.Scene.GetType().Name == "LevelExitToLobby"; - return isLoading; - } - } } - -[AttributeUsage(AttributeTargets.Method)] -internal class EnableRunAttribute : Attribute { } - -[AttributeUsage(AttributeTargets.Method)] -internal class DisableRunAttribute : Attribute { } diff --git a/CelesteTAS-EverestInterop/Source/TAS/Savestates.cs b/CelesteTAS-EverestInterop/Source/TAS/Savestates.cs index ed216d23d..4e824c050 100644 --- a/CelesteTAS-EverestInterop/Source/TAS/Savestates.cs +++ b/CelesteTAS-EverestInterop/Source/TAS/Savestates.cs @@ -1,178 +1,177 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using Celeste; using Celeste.Mod.SpeedrunTool.SaveLoad; using Monocle; +using System; +using System.Diagnostics.CodeAnalysis; using TAS.EverestInterop; using TAS.Input; +using TAS.ModInterop; using TAS.Module; using TAS.Utils; -using static TAS.Manager; -using TasStates = StudioCommunication.States; namespace TAS; +/// Handles saving / loading game state with SpeedrunTool public static class Savestates { - private static InputController savedController; - - private static readonly Dictionary SavedGameInfo = new() { - {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.Status)), null}, - {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.ExactStatus)), null}, - {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.StatusWithoutTime)), null}, - {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.ExactStatusWithoutTime)), null}, - {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.LevelName)), null}, - {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.ChapterTime)), null}, - {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.WatchingInfo)), null}, - {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.CustomInfo)), null}, + // These fields can't just be pulled from the current frame and therefore need to be saved too + private static readonly Dictionary SavedGameInfo = new() { {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.LastPos)), null}, {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.LastDiff)), null}, {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.LastPlayerSeekerPos)), null}, {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.LastPlayerSeekerDiff)), null}, - {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.DashTime)), null}, - {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.Frozen)), null}, - {typeof(GameInfo).GetFieldInfo(nameof(GameInfo.TransitionFrames)), null}, }; private static bool savedByBreakpoint; - private static string savedTasFilePath; - - private static readonly Lazy SpeedrunToolInstalledLazy = new(() => ModUtils.IsInstalled("SpeedrunTool")); + private static int savedChecksum; + private static InputController? savedController; private static int SavedLine => (savedByBreakpoint - ? Controller.FastForwards.GetValueOrDefault(SavedCurrentFrame)?.Line - : Controller.Inputs.GetValueOrDefault(SavedCurrentFrame)?.Line) ?? -1; + ? Manager.Controller.FastForwards.GetValueOrDefault(SavedCurrentFrame)?.Line + : Manager.Controller.Inputs!.GetValueOrDefault(SavedCurrentFrame)?.Line) ?? -1; - private static int SavedCurrentFrame => IsSaved() ? savedController.CurrentFrameInTas : -1; + public static int StudioHighlightLine => IsSaved_Safe ? SavedLine : -1; + private static int SavedCurrentFrame => IsSaved ? savedController.CurrentFrameInTas : -1; - public static int StudioHighlightLine => IsSaved_Safe() ? SavedLine : -1; - public static bool SpeedrunToolInstalled => SpeedrunToolInstalledLazy.Value; + private static bool BreakpointHasBeenDeleted => IsSaved && + savedByBreakpoint && + Manager.Controller.FastForwards.GetValueOrDefault(SavedCurrentFrame)?.SaveState != true; - private static bool BreakpointHasBeenDeleted => - IsSaved() && savedByBreakpoint && Controller.FastForwards.GetValueOrDefault(SavedCurrentFrame)?.SaveState != true; + public static bool IsSaved_Safe => SpeedrunToolInterop.Installed && IsSaved; - private static bool IsSaved() { - return StateManager.Instance.IsSaved && StateManager.Instance.SavedByTas && savedController != null && - savedTasFilePath == InputController.TasFilePath; + [MemberNotNullWhen(true, nameof(savedController))] + private static bool IsSaved => StateManager.Instance.IsSaved && + StateManager.Instance.SavedByTas && + savedController != null && + savedController.FilePath == Manager.Controller.FilePath; + + [Load] + private static void Load() { + if (SpeedrunToolInterop.Installed) { + SpeedrunToolInterop.AddSaveLoadAction(); + } } - public static bool IsSaved_Safe() { - return SpeedrunToolInstalled && IsSaved(); + [Unload] + private static void Unload() { + if (IsSaved_Safe) { + ClearState(); + } + + if (SpeedrunToolInterop.Installed) { + SpeedrunToolInterop.ClearSaveLoadAction(); + } } - public static void HandleSaveStates() { - if (!SpeedrunToolInstalled) { + /// Update for each TAS frame + public static void Update() { + if (!SpeedrunToolInterop.Installed) { return; } - if (!Running && IsSaved() && Engine.Scene is Level && Hotkeys.StartStop.Released) { - Load(); + // Only save-state when the current breakpoint is the last save-state one + if (Manager.Controller.Inputs.Count > Manager.Controller.CurrentFrameInTas + && Manager.Controller.FastForwards.GetValueOrDefault(Manager.Controller.CurrentFrameInTas) is { SaveState: true } currentFastForward + && Manager.Controller.FastForwards.Last(pair => pair.Value.SaveState).Value == currentFastForward + && SavedCurrentFrame != currentFastForward.Frame + ) { + SaveState(byBreakpoint: true); return; } - if (Running && Hotkeys.SaveState.Pressed) { - Save(false); - return; + // Autoload state after entering the level, if the TAS was started outside the level + if (Manager.Running && IsSaved + && Engine.Scene is Level + && Manager.Controller.CurrentFrameInTas < savedController.CurrentFrameInTas + ) { + LoadState(); } + } - // Do not use Hotkeys.Restart.Pressed unless the fast forwarding optimization in Hotkeys.Update() is removed - if (Hotkeys.Restart.Released) { - Load(); + /// Update for checking hotkeys + internal static void UpdateMeta() { + if (!SpeedrunToolInterop.Installed) { return; } - if (Hotkeys.ClearState.Pressed) { - Clear(); - DisableRun(); + if (Manager.Running && Hotkeys.SaveState.Pressed) { + SaveState(byBreakpoint: false); return; } - - if (Running && BreakpointHasBeenDeleted) { - Clear(); + if (Hotkeys.ClearState.Pressed) { + ClearState(); + Manager.DisableRun(); + return; } - // save state when tas run to the last savestate breakpoint - if (Running - && Controller.Inputs.Count > Controller.CurrentFrameInTas - && Controller.FastForwards.GetValueOrDefault(Controller.CurrentFrameInTas) is {SaveState: true} currentFastForward && - Controller.FastForwards.Last(pair => pair.Value.SaveState).Value == currentFastForward && - SavedCurrentFrame != currentFastForward.Frame) { - Save(true); - return; + if (Manager.Running && BreakpointHasBeenDeleted) { + ClearState(); } + } - // auto load state after entering the level if tas is started from outside the level. - if (Running && IsSaved() && Engine.Scene is Level && Controller.CurrentFrameInTas < savedController.CurrentFrameInTas) { - Load(); + // Called explicitly to ensure correct execution order + internal static void EnableRun() { + if (SpeedrunToolInterop.Installed && IsSaved && Engine.Scene is Level) { + LoadState(); } } - private static void Save(bool breakpoint) { - if (IsSaved()) { - if (Controller.CurrentFrameInTas == savedController.CurrentFrameInTas) { - if (savedController.SavestateChecksum == Controller.CalcChecksum(savedController)) { - Manager.States &= ~TasStates.FrameStep; - NextStates &= ~TasStates.FrameStep; - return; - } - } + public static void SaveState(bool byBreakpoint) { + if (IsSaved && + Manager.Controller.CurrentFrameInTas == savedController.CurrentFrameInTas && + savedChecksum == Manager.Controller.CalcChecksum(savedController.CurrentFrameInTas)) + { + return; // Already saved } if (!StateManager.Instance.SaveState()) { return; } - savedByBreakpoint = breakpoint; - savedTasFilePath = InputController.TasFilePath; + savedByBreakpoint = byBreakpoint; + savedChecksum = Manager.Controller.CalcChecksum(Manager.Controller.CurrentFrameInTas); + savedController = Manager.Controller.Clone(); SaveGameInfo(); - savedController = Controller.Clone(); SetTasState(); } - private static void Load() { - // Don't load save states while recording - if (Manager.Recording) { + public static void LoadState() { + // Don't load save-states while recording + if (TASRecorderInterop.Recording) { return; } - if (IsSaved()) { - Controller.RefreshInputs(false); - if (!BreakpointHasBeenDeleted && savedController.SavestateChecksum == Controller.CalcChecksum(savedController)) { - if (Running && Controller.CurrentFrameInTas == savedController.CurrentFrameInTas) { - // Don't repeat load state, just play - Manager.States &= ~TasStates.FrameStep; - NextStates &= ~TasStates.FrameStep; + if (IsSaved) { + if (!BreakpointHasBeenDeleted && savedChecksum == Manager.Controller.CalcChecksum(savedController.CurrentFrameInTas)) { + if (Manager.Controller.CurrentFrameInTas == savedController.CurrentFrameInTas) { + // Don't repeat loading the state, just play + Manager.NextState = Manager.State.Running; return; } if (Engine.Scene is Level) { - if (!Running) { - EnableRun(); - } - - // make sure LoadState is after EnableRun, otherwise the input state will be reset in BindingHelper.SetTasBindings StateManager.Instance.LoadState(); + Manager.Controller.CopyProgressFrom(savedController); - LoadStateRoutine(); - return; + LoadGameInfo(); + UpdateStudio(); + SetTasState(); } } else { - Clear(); + ClearState(); } } - - // If load state failed just playback normally - PlayTas(); } - private static void Clear() { + public static void ClearState() { StateManager.Instance.ClearState(); - savedController = null; ClearGameInfo(); savedByBreakpoint = false; - savedTasFilePath = null; + savedChecksum = -1; + savedController = null; UpdateStudio(); } @@ -195,48 +194,16 @@ private static void ClearGameInfo() { } } - private static void PlayTas() { - DisableRun(); - EnableRun(); - } - - private static void LoadStateRoutine() { - Controller.CopyProgressFrom(savedController); - SetTasState(); - LoadGameInfo(); - UpdateStudio(); - } - private static void SetTasState() { - if (Controller.HasFastForward) { - Manager.States &= ~TasStates.FrameStep; + if (Manager.Controller.HasFastForward) { + Manager.CurrState = Manager.NextState = Manager.State.Running; } else { - Manager.States |= TasStates.FrameStep; + Manager.CurrState = Manager.NextState = Manager.State.Paused; } - - NextStates &= ~TasStates.FrameStep; } private static void UpdateStudio() { GameInfo.Update(); - SendStateToStudio(); - } - - [Load] - private static void OnLoad() { - if (SpeedrunToolInstalled) { - SpeedrunToolUtils.AddSaveLoadAction(); - } - } - - [Unload] - private static void OnUnload() { - if (IsSaved_Safe()) { - Clear(); - } - - if (SpeedrunToolInstalled) { - SpeedrunToolUtils.ClearSaveLoadAction(); - } + Manager.SendStudioState(); } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/Tools/PlayTasAtLaunch.cs b/CelesteTAS-EverestInterop/Source/Tools/PlayTasAtLaunch.cs new file mode 100644 index 000000000..600929e6a --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/Tools/PlayTasAtLaunch.cs @@ -0,0 +1,31 @@ +using Celeste; +using Monocle; +using TAS.Module; + +namespace TAS.Tools; + +/// Runs a TAS specified by the "--tas" CLI flag at launch +internal static class PlayTasAtLaunch { + [Load] + private static void Load() { + On.Celeste.Celeste.OnSceneTransition += On_Celeste_OnScreenTransition; + } + [Unload] + private static void Unload() { + On.Celeste.Celeste.OnSceneTransition -= On_Celeste_OnScreenTransition; + } + + /// Pending file which should be played + public static string? FilePath; + + private static void On_Celeste_OnScreenTransition(On.Celeste.Celeste.orig_OnSceneTransition orig, Celeste.Celeste self, Scene last, Scene next) { + orig(self, last, next); + + if (FilePath != null && next is Overworld) { + Manager.Controller.FilePath = FilePath; + Manager.EnableRun(); + + FilePath = null; + } + } +} diff --git a/CelesteTAS-EverestInterop/Source/Utils/AttributeUtils.cs b/CelesteTAS-EverestInterop/Source/Utils/AttributeUtils.cs index 54748a067..7eb56c723 100644 --- a/CelesteTAS-EverestInterop/Source/Utils/AttributeUtils.cs +++ b/CelesteTAS-EverestInterop/Source/Utils/AttributeUtils.cs @@ -3,24 +3,44 @@ using System.Linq; using System.Reflection; using Celeste.Mod; +using Celeste.Mod.Helpers; +using TAS.Module; namespace TAS.Utils; -internal static class AttributeUtils { - private static readonly object[] Parameterless = { }; - private static readonly IDictionary> MethodInfos = new Dictionary>(); +public static class AttributeUtils { + private static readonly Dictionary attributeMethods = new(); - public static void CollectMethods() where T : Attribute { - MethodInfos[typeof(T)] = typeof(AttributeUtils).Assembly.GetTypesSafe().SelectMany(type => type - .GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic) - .Where(info => info.GetParameters().Length == 0 && info.GetCustomAttribute() != null)); + /// Gathers all static, parameterless methods with attribute T + /// Only searches through CelesteTAS itself + public static void CollectOwnMethods() where T : Attribute { + attributeMethods[typeof(T)] = typeof(CelesteTasModule).Assembly + .GetTypesSafe() + .SelectMany(type => type + .GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic) + .Where(info => info.GetCustomAttribute() != null && info.GetParameters().Length == 0)) + .ToArray(); } + /// Gathers all static, parameterless methods with attribute T + /// Only searches through all mods - Should only be called after Load() + public static void CollectAllMethods() where T : Attribute { + attributeMethods[typeof(T)] = FakeAssembly.GetFakeEntryAssembly() + .GetTypesSafe() + .SelectMany(type => type + .GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic) + .Where(info => info.GetCustomAttribute() != null && info.GetParameters().Length == 0)) + .ToArray(); + } + + /// Invokes all previously gathered methods for attribute T public static void Invoke() where T : Attribute { - if (MethodInfos.TryGetValue(typeof(T), out IEnumerable methodInfos)) { - foreach (MethodInfo methodInfo in methodInfos) { - methodInfo.Invoke(null, Parameterless); - } + if (!attributeMethods.TryGetValue(typeof(T), out var methods)) { + return; + } + + foreach (var method in methods) { + method.Invoke(null, []); } } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/Utils/EntityTypeHelper.cs b/CelesteTAS-EverestInterop/Source/Utils/EntityTypeHelper.cs index f9d6ba1b7..dc52ccd87 100644 --- a/CelesteTAS-EverestInterop/Source/Utils/EntityTypeHelper.cs +++ b/CelesteTAS-EverestInterop/Source/Utils/EntityTypeHelper.cs @@ -221,13 +221,9 @@ private static void CreateCache() { ["birdPathTrigger"] = typeof(BirdPathTrigger), ["spawnFacingTrigger"] = typeof(SpawnFacingTrigger), ["detachFollowersTrigger"] = typeof(DetachStrawberryTrigger), + ["powerSourceNumber"] = typeof(PowerSourceNumber), }); - // add from Celeste v1.4 - if (typeof(Player).Assembly.GetType("Celeste.PowerSourceNumber") is { } powerSourceNumber) { - vanillaEntityNameToType["powerSourceNumber"] = powerSourceNumber; - } - foreach (Type type in FakeAssembly.GetFakeEntryAssembly().GetTypesSafe()) { CheckCustomEntity(type); } diff --git a/CelesteTAS-EverestInterop/Source/Utils/ExtendedVariantsUtils.cs b/CelesteTAS-EverestInterop/Source/Utils/ExtendedVariantsUtils.cs deleted file mode 100644 index 0d854b7d8..000000000 --- a/CelesteTAS-EverestInterop/Source/Utils/ExtendedVariantsUtils.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using Celeste.Mod; -using MonoMod.Utils; - -namespace TAS.Utils; - -internal static class ExtendedVariantsUtils { - private static readonly Lazy module = new(() => ModUtils.GetModule("ExtendedVariantMode")); - private static readonly Lazy triggerManager = new(() => module.Value?.GetFieldValue("TriggerManager")); - private static readonly Lazy variantHandlers = new(() => module.Value?.GetFieldValue("VariantHandlers")); - - private static readonly Lazy getCurrentVariantValue = new(() => - triggerManager.Value?.GetType().GetMethodInfo("GetCurrentVariantValue")?.GetFastDelegate()); - private static readonly Lazy setVariantValue = new(() => - module.Value?.GetType().Assembly.GetType("ExtendedVariants.UI.ModOptionsEntries").GetMethodInfo("SetVariantValue")?.GetFastDelegate()); - private static readonly Lazy dictionareGetItem = new(() => - variantHandlers.Value?.GetType().GetMethodInfo("get_Item")?.GetFastDelegate()); - - private static readonly Lazy variantType = - new(() => module.Value?.GetType().Assembly.GetType("ExtendedVariants.Module.ExtendedVariantsModule+Variant")); - - // enum value might be different between different ExtendedVariantMode version, so we have to parse from string - private static readonly Lazy upsideDownVariant = new(ParseVariant("UpsideDown")); - private static readonly Lazy superDashingVariant = new(ParseVariant("SuperDashing")); - private static readonly object[] parameterless = { }; - - public static Func ParseVariant(string value) { - return () => { - try { - return variantType.Value == null ? null : Enum.Parse(variantType.Value, value); - } catch (Exception e) { - e.LogException($"Parsing Variant.{value} Failed."); - return null; - } - }; - } - - public static bool UpsideDown => GetCurrentVariantValue(upsideDownVariant) is { } value && (bool) value; - public static bool SuperDashing => GetCurrentVariantValue(superDashingVariant) is { } value && (bool) value; - - public static Type GetVariantsEnum() => variantType.Value; - - public static Type GetVariantType(Lazy variant) { - if (variant.Value is null) return null; - object handler = dictionareGetItem.Value?.Invoke(variantHandlers.Value, variant.Value); - return (Type) handler?.GetType().GetMethodInfo("GetVariantType")?.Invoke(handler, parameterless); - } - - public static object GetCurrentVariantValue(Lazy variant) { - if (variant.Value is null) return null; - return getCurrentVariantValue.Value?.Invoke(triggerManager.Value, variant.Value); - } - - public static void SetVariantValue(Lazy variant, object value) { - if (variant.Value is null) return; - setVariantValue.Value?.Invoke(null, variant.Value, value); - } -} \ No newline at end of file diff --git a/CelesteTAS-EverestInterop/Source/Utils/Extensions.cs b/CelesteTAS-EverestInterop/Source/Utils/Extensions.cs index 84129e1e2..4d5d105f1 100644 --- a/CelesteTAS-EverestInterop/Source/Utils/Extensions.cs +++ b/CelesteTAS-EverestInterop/Source/Utils/Extensions.cs @@ -12,6 +12,7 @@ using MonoMod.Utils; using StudioCommunication; using System.Diagnostics; +using TAS.ModInterop; using Platform = Celeste.Platform; namespace TAS.Utils; @@ -487,6 +488,14 @@ public static TKey LastKeyOrDefault(this SortedDictionary(this SortedDictionary dict) { return dict.Count > 0 ? dict.Last().Value : default; } + + public static void AddToKey(this IDictionary> dict, TKey key, TValue value) { + if (dict.TryGetValue(key, out var list)) { + list.Add(value); + return; + } + dict[key] = [value]; + } } internal static class DynamicDataExtensions { @@ -608,7 +617,7 @@ public static Vector2 ScreenToWorld(this Level level, Vector2 position) { position.X = 1920f - position.X; } - if (ExtendedVariantsUtils.UpsideDown) { + if (ExtendedVariantsInterop.UpsideDown) { position.Y = 1080f - position.Y; } @@ -635,7 +644,7 @@ public static Vector2 WorldToScreen(this Level level, Vector2 position) { position.X = 1920f - position.X; } - if (ExtendedVariantsUtils.UpsideDown) { + if (ExtendedVariantsInterop.UpsideDown) { position.Y = 1080f - position.Y; } @@ -741,6 +750,45 @@ public static void CopyAllProperties(this object to, object from, bool onlyDiffe } } +internal static class EnumerableExtension { + /// Iterates each entry of the IEnumerable and invokes the callback Action + public static void ForEach(this IEnumerable enumerable, Action action) { + foreach (var item in enumerable) { + action(item); + } + } + + /// Returns the first matching element; otherwise null + public static T? FirstOrNull(this IEnumerable enumerable, Func predicate) where T : struct { + foreach (var item in enumerable) { + if (predicate(item)) { + return item; + } + } + + return null; + } + + private readonly struct DynamicComparer(Func compare) : IComparer { + public int Compare(T? x, T? y) => compare(x!, y!); + } + + /// Sorts the elements according to the comparision function + /// Value Meaning Less than zero is less than . Zero equals . Greater than zero is greater than . + public static IEnumerable Sort(this IEnumerable enumerable, Func compare) { + return enumerable.Order(new DynamicComparer(compare)); + } +} + +internal static class CollectionExtension { + /// Adds all items from the collection to the HashSet + public static void AddRange(this HashSet hashSet, params IEnumerable items) { + foreach (var item in items) { + hashSet.Add(item); + } + } +} + internal static class GameStateExtension { public static GameState.Vec2 ToGameStateVec2(this Vector2 vec) => new(vec.X, vec.Y); public static GameState.RectI ToGameStateRectI(this Rectangle rect) => new(rect.X, rect.Y, rect.Width, rect.Height); diff --git a/CelesteTAS-EverestInterop/Source/Utils/HookHelper.cs b/CelesteTAS-EverestInterop/Source/Utils/HookHelper.cs index 05f85aa15..953b4ac92 100644 --- a/CelesteTAS-EverestInterop/Source/Utils/HookHelper.cs +++ b/CelesteTAS-EverestInterop/Source/Utils/HookHelper.cs @@ -1,102 +1,350 @@ -using System; +using Celeste; +using Celeste.Mod; +using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Numerics; using System.Reflection; +using JetBrains.Annotations; +using Mono.Cecil; using Mono.Cecil.Cil; using MonoMod.Cil; using MonoMod.RuntimeDetour; +using MonoMod.Utils; +using System.Linq; +using System.Runtime.Loader; using TAS.Module; namespace TAS.Utils; +/// Helper class for registering and automatically unregistering (IL)-hooks internal static class HookHelper { - private static readonly List Hooks = new(); + private static readonly List onHooks = []; + private static readonly List ilHooks = []; [Unload] private static void Unload() { - foreach (IDetour detour in Hooks) { - detour.Dispose(); + foreach (var hook in onHooks) { + hook.Dispose(); + } + foreach (var hook in ilHooks) { + hook.Dispose(); } - Hooks.Clear(); + onHooks.Clear(); + ilHooks.Clear(); } - public static void OnHook(this MethodBase from, Delegate to) { - Hooks.Add(new Hook(from, to)); - } + /// Creates an On-hook to the specified method, which will automatically be unregistered + public static void OnHook(this MethodBase from, Delegate to) => onHooks.Add(new Hook(from, to)); - public static void IlHook(this MethodBase from, ILContext.Manipulator manipulator) { - Hooks.Add(new ILHook(from, manipulator)); - } + /// Creates an IL-hook to the specified method, which will automatically be unregistered + public static void IlHook(this MethodBase from, ILContext.Manipulator manipulator) => ilHooks.Add(new ILHook(from, manipulator)); + /// Creates an IL-hook to the specified method, which will automatically be unregistered public static void IlHook(this MethodBase from, Action manipulator) { from.IlHook(il => { - ILCursor ilCursor = new(il); - manipulator(ilCursor, il); + var cursor = new ILCursor(il); + manipulator(cursor, il); }); } - public static void HookBefore(this MethodBase methodInfo, Action action) { + /// Creates a callback before the original method is called + public static void HookBefore(this MethodBase methodInfo, Action action) { methodInfo.IlHook((cursor, _) => { - cursor.Emit(OpCodes.Ldarg_0); cursor.EmitDelegate(action); }); } - public static void HookBefore(this MethodBase methodInfo, Action action) { + /// Creates a callback before the original method is called + public static void HookBefore(this MethodBase methodInfo, Action action) { +#if DEBUG + if (methodInfo.IsStatic) { + var parameters = methodInfo.GetParameters(); + Debug.Assert(parameters.Length >= 1 && parameters[0].ParameterType.IsSameOrSubclassOf(typeof(T))); + } else { + Debug.Assert(methodInfo.DeclaringType?.IsSameOrSubclassOf(typeof(T)) ?? false); + } +#endif methodInfo.IlHook((cursor, _) => { + cursor.EmitLdarg0(); cursor.EmitDelegate(action); }); } - public static void HookAfter(this MethodBase methodInfo, Action action) { + /// Creates a callback after the original method was called + public static void HookAfter(this MethodBase methodInfo, Action action) { methodInfo.IlHook((cursor, _) => { - while (cursor.TryGotoNext(MoveType.AfterLabel, i => i.OpCode == OpCodes.Ret)) { - cursor.Emit(OpCodes.Ldarg_0); + while (cursor.TryGotoNext(MoveType.AfterLabel, ins => ins.MatchRet())) { cursor.EmitDelegate(action); cursor.Index++; } }); } - public static void HookAfter(this MethodBase methodInfo, Action action) { + /// Creates a callback after the original method was called + public static void HookAfter(this MethodBase methodInfo, Action action) { +#if DEBUG + if (methodInfo.IsStatic) { + var parameters = methodInfo.GetParameters(); + Debug.Assert(parameters.Length >= 1 && parameters[0].ParameterType.IsSameOrSubclassOf(typeof(T))); + } else { + Debug.Assert(methodInfo.DeclaringType?.IsSameOrSubclassOf(typeof(T)) ?? false); + } +#endif methodInfo.IlHook((cursor, _) => { - while (cursor.TryGotoNext(MoveType.AfterLabel, i => i.OpCode == OpCodes.Ret)) { + while (cursor.TryGotoNext(MoveType.AfterLabel, ins => ins.MatchRet())) { + cursor.EmitLdarg0(); cursor.EmitDelegate(action); cursor.Index++; } }); } - public static void SkipMethod(Type conditionType, string conditionMethodName, string methodName, params Type[] types) { - foreach (Type type in types) { - if (type?.GetMethodInfo(methodName) is { } method) { - SkipMethod(conditionType, conditionMethodName, method); + /// Creates a callback to conditionally call the original method + public static void SkipMethod(this MethodInfo method, Func condition) { +#if DEBUG + Debug.Assert(method.ReturnType == typeof(void)); +#endif + method.IlHook((cursor, _) => { + var start = cursor.MarkLabel(); + cursor.MoveBeforeLabels(); + + cursor.EmitDelegate(condition); + cursor.EmitBrfalse(start); + cursor.EmitRet(); + }); + } + /// Creates a callback to conditionally call the original method + public static void SkipMethod(this MethodInfo method, Func condition) { +#if DEBUG + if (method.IsStatic) { + var parameters = method.GetParameters(); + Debug.Assert(parameters.Length >= 1 && parameters[0].ParameterType.IsSameOrSubclassOf(typeof(T))); + } else { + Debug.Assert(method.DeclaringType?.IsSameOrSubclassOf(typeof(T)) ?? false); + } + + Debug.Assert(method.ReturnType == typeof(void)); +#endif + method.IlHook((cursor, _) => { + var start = cursor.MarkLabel(); + cursor.MoveBeforeLabels(); + + cursor.EmitLdarg0(); + cursor.EmitDelegate(condition); + cursor.EmitBrfalse(start); + cursor.EmitRet(); + }); + } + + /// Creates a callback to conditionally call the original methods + public static void SkipMethods(Func condition, params MethodInfo?[] methods) { + foreach (var method in methods) { + method?.SkipMethod(condition); + } + } + + /// Creates a callback to conditionally override the return value of the original method without ever even calling it + public static void OverrideReturn(this MethodInfo method, Func condition, T value) { +#if DEBUG + Debug.Assert(typeof(T).IsSameOrSubclassOf(method.ReturnType)); +#endif + method.IlHook((cursor, _) => { + var start = cursor.MarkLabel(); + cursor.MoveBeforeLabels(); + + cursor.EmitDelegate(condition); + cursor.EmitBrfalse(start); + + // Put the return value onto the stack + switch (value) { + case int v: + cursor.EmitLdcI4(v); + break; + case long v: + cursor.EmitLdcI8(v); + break; + case float v: + cursor.EmitLdcR4(v); + break; + case double v: + cursor.EmitLdcR8(v); + break; + + default: + // The type doesn't have a specific IL-instruction, so we have to use a lambda +#pragma warning disable CL0001 + cursor.EmitDelegate(() => value); +#pragma warning restore CL0001 + break; } + + cursor.EmitRet(); + }); + } + + /// Creates a callback to conditionally override the return value of the original method without ever even calling it + public static void OverrideReturn(this MethodInfo method, Func condition, Func valueProvider) { +#if DEBUG + Debug.Assert(typeof(T).IsSameOrSubclassOf(method.ReturnType)); +#endif + method.IlHook((cursor, _) => { + var start = cursor.MarkLabel(); + cursor.MoveBeforeLabels(); + + cursor.EmitDelegate(condition); + cursor.EmitBrfalse(start); + + // Put the return value onto the stack + cursor.EmitDelegate(valueProvider); + cursor.EmitRet(); + }); + } + + /// Creates a callback to conditionally override the return value of the original methods without ever even calling them + public static void OverrideReturns(Func condition, T value, params MethodInfo?[] methods) { + foreach (var method in methods) { + method?.OverrideReturn(condition, value); } } - public static void SkipMethod(Type conditionType, string conditionMethodName, params MethodInfo[] methodInfos) { - foreach (MethodInfo methodInfo in methodInfos) { - methodInfo.IlHook(il => { - ILCursor ilCursor = new(il); - Instruction start = ilCursor.Next; - ilCursor.Emit(OpCodes.Call, conditionType.GetMethodInfo(conditionMethodName)); - ilCursor.Emit(OpCodes.Brfalse, start).Emit(OpCodes.Ret); - }); + /// Creates a callback to conditionally override the return value of the original methods without ever even calling them + public static void OverrideReturns(Func condition, Func valueProvider, params MethodInfo?[] methods) { + foreach (var method in methods) { + method?.OverrideReturn(condition, valueProvider); } } - public static void ReturnZeroMethod(Type conditionType, string conditionMethodName, params MethodInfo[] methods) { - foreach (MethodInfo methodInfo in methods) { - if (methodInfo != null && !methodInfo.IsGenericMethod && methodInfo.DeclaringType?.IsGenericType != true && - methodInfo.ReturnType == typeof(float)) { - methodInfo.IlHook(il => { - ILCursor ilCursor = new(il); - Instruction start = ilCursor.Next; - ilCursor.Emit(OpCodes.Call, conditionType.GetMethodInfo(conditionMethodName)); - ilCursor.Emit(OpCodes.Brfalse, start).Emit(OpCodes.Ldc_R4, 0f).Emit(OpCodes.Ret); - }); + /// Emits a call to a static delegate function. + /// Accessing captures is not allowed + public static void EmitStaticDelegate(this ILCursor cursor, T cb) where T : Delegate + => cursor.EmitStaticDelegate("Delegate", cb); + + /// Emits a call to a static delegate function. + /// Accessing captures is not allowed + public static void EmitStaticDelegate(this ILCursor cursor, string methodName, T cb) where T : Delegate { + // Simple static method group + if (cb.GetInvocationList().Length == 1 && cb.Target == null) { + cursor.EmitCall(cb.Method); + return; + } + + var methodDef = cb.Method.ResolveDefinition(); + + // Extract hook name from delegate + string hookName = cb.Method.Name.Split('>')[0][1..]; + string name = $"{hookName}_{methodName}"; + + var parameters = cb.Method.GetParameters(); + + var dynamicMethod = new DynamicMethodDefinition(name, + cb.Method.ReturnType, + parameters + .Select(p => p.ParameterType) + .ToArray()); + dynamicMethod.Definition.Body = methodDef.Body; + for (int i = 0; i < dynamicMethod.Definition.Parameters.Count; i++) { + dynamicMethod.Definition.Parameters[i].Name = parameters[i].Name; + } + + // Shift over arguments, since "this" was removed + var processor = dynamicMethod.GetILProcessor(); + foreach (var instr in processor.Body.Instructions) { + if (!instr.MatchLdarg(out int index)) { + continue; + } + + switch (index) { + case 0: + throw new Exception("Using captured variables inside a static delegate is not allowed"); + + case 1: + instr.OpCode = OpCodes.Ldarg_0; + break; + case 2: + instr.OpCode = OpCodes.Ldarg_1; + break; + case 3: + instr.OpCode = OpCodes.Ldarg_2; + break; + case 4: + instr.OpCode = OpCodes.Ldarg_3; + break; + + default: + instr.OpCode = OpCodes.Ldarg; + instr.Operand = index - 1; + break; } } + + var targetMethod = dynamicMethod.Generate(); + var targetReference = cursor.Context.Import(targetMethod); + targetReference.Name = name; + targetReference.DeclaringType = cb.Method.DeclaringType?.DeclaringType.ResolveDefinition(); + targetReference.ReturnType = dynamicMethod.Definition.ReturnType; + targetReference.Parameters.AddRange(dynamicMethod.Definition.Parameters); + + cursor.EmitCall(targetReference); + } + + /// Resolves the TypeDefinition of a runtime TypeInfo + public static TypeDefinition ResolveDefinition(this Type type) { + var asm = type.Assembly; + var asmName = type.Assembly.GetName(); + + // Find assembly path + string asmPath; + if (AssemblyLoadContext.GetLoadContext(asm) is EverestModuleAssemblyContext asmCtx) { + asmPath = Everest.Relinker.GetCachedPath(asmCtx.ModuleMeta, asmName.Name); + } else { + asmPath = asm.Location; + } + + var asmDef = AssemblyDefinition.ReadAssembly(asmPath, new ReaderParameters { ReadSymbols = false }); + var typeDef = asmDef.MainModule.GetType(type.FullName, runtimeName: true).Resolve(); + + return typeDef; + } + + /// Resolves the MethodDefinition of a runtime MethodBase + public static MethodDefinition ResolveDefinition(this MethodBase method) { + var asm = method.DeclaringType!.Assembly; + var asmName = method.DeclaringType!.Assembly.GetName(); + + // Find assembly path + string asmPath; + if (AssemblyLoadContext.GetLoadContext(asm) is EverestModuleAssemblyContext asmCtx) { + asmPath = Everest.Relinker.GetCachedPath(asmCtx.ModuleMeta, asmName.Name); + } else { + asmPath = asm.Location; + } + + var asmDef = AssemblyDefinition.ReadAssembly(asmPath, new ReaderParameters { ReadSymbols = false }); + var typeDef = asmDef.MainModule.GetType(method.DeclaringType!.FullName, runtimeName: true).Resolve(); + var methodDef = typeDef.Methods.Single(m => { + if (method.Name != m.Name) { + return false; + } + + var runtimeParams = method.GetParameters(); + if (runtimeParams.Length != m.Parameters.Count) { + return false; + } + + for (int i = 0; i < runtimeParams.Length; i++) { + var runtimeParam = runtimeParams[i]; + var asmParam = m.Parameters[i]; + + if (runtimeParam.ParameterType.FullName != asmParam.ParameterType.FullName) { + return false; + } + } + + return true; + }); + + return methodDef; } -} \ No newline at end of file +} diff --git a/CelesteTAS-EverestInterop/Source/Utils/LogUtil.cs b/CelesteTAS-EverestInterop/Source/Utils/LogUtil.cs index 2c26431f1..df3ae9f31 100644 --- a/CelesteTAS-EverestInterop/Source/Utils/LogUtil.cs +++ b/CelesteTAS-EverestInterop/Source/Utils/LogUtil.cs @@ -1,71 +1,65 @@ -// ReSharper disable RedundantUsingDirective - using System; using Celeste.Mod; using Microsoft.Xna.Framework; using Monocle; -using MonoMod.Utils; -// ReSharper disable HeuristicUnreachableCode namespace TAS.Utils; internal static class LogUtil { private const string Tag = "CelesteTAS"; #if DEBUG - // ReSharper disable once UnusedMember.Global - public static void DebugLog(this object text, LogLevel logLevel = LogLevel.Info) { - text.DebugLog(true, logLevel); - } - - // ReSharper disable once MemberCanBePrivate.Global - public static void DebugLog(this object text, bool outputToCommands, LogLevel logLevel = LogLevel.Info) { - text.Log(outputToCommands, logLevel); - } + public static void DebugLog(this object text, LogLevel logLevel = LogLevel.Debug) => text.DebugLog(true, logLevel); + public static void DebugLog(this object text, bool outputToCommands, LogLevel logLevel = LogLevel.Debug) => text.Log(string.Empty, outputToCommands, logLevel); #endif - public static void LogException(this Exception e) { + public static void LogException(this Exception e) => e.LogException(false); + public static void LogException(this Exception e, bool outputToCommands) { Logger.LogDetailed(e, Tag); + + if (outputToCommands) { + Engine.Commands.Log(e.Message, Color.Yellow); + Engine.Commands.LogStackTrace(e.StackTrace); + } } - public static void LogException(this Exception e, string header, LogLevel logLevel = LogLevel.Error) { - header.Log(logLevel); + public static void LogException(this Exception e, string header, LogLevel logLevel = LogLevel.Error) => e.LogException(header, string.Empty, false, logLevel); + public static void LogException(this Exception e, string header, bool outputToCommands, LogLevel logLevel = LogLevel.Error) => e.LogException(header, string.Empty, outputToCommands, logLevel); + public static void LogException(this Exception e, string header, string category, LogLevel logLevel = LogLevel.Error) => e.LogException(header, category, false, logLevel); + public static void LogException(this Exception e, string header, string category, bool outputToCommands, LogLevel logLevel = LogLevel.Error) { + header.Log(category, outputToCommands, logLevel); Logger.LogDetailed(e, Tag); - } - public static void Log(this object text, LogLevel logLevel = LogLevel.Info) { - text.Log(false, logLevel); + if (outputToCommands) { + Engine.Commands.Log(e.Message, Color.Yellow); + Engine.Commands.LogStackTrace(e.StackTrace); + } } - // ReSharper disable once RedundantAssignment - public static void Log(this object text, bool outputToCommands, LogLevel logLevel = LogLevel.Info) { - text = text == null ? "null" : text.ToString(); - Logger.Log(logLevel, Tag, text.ToString()); + public static void Log(this object? text, LogLevel logLevel = LogLevel.Info) => text.Log(string.Empty, false, logLevel); + public static void Log(this object? text, string category, LogLevel logLevel = LogLevel.Info) => text.Log(category, false, logLevel); + public static void Log(this object? text, string category, bool outputToCommands, LogLevel logLevel = LogLevel.Info) { + string tag = category == string.Empty + ? Tag + : $"{Tag}/{category}"; + + string textStr = text?.ToString() ?? "null"; + Logger.Log(logLevel, tag, textStr); - // ReSharper disable once ConditionIsAlwaysTrueOrFalse if (outputToCommands) { - text = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{Tag}] {logLevel}: {text}"; - ConsoleLog(text, logLevel); + ConsoleLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{tag}] {logLevel}: {textStr}", logLevel); } } - public static void ConsoleLog(this object text, LogLevel logLevel = LogLevel.Verbose) { - text = text == null ? "null" : text.ToString(); - Color color; - switch (logLevel) { - case LogLevel.Warn: - color = Color.Yellow; - break; - case LogLevel.Error: - color = Color.Red; - break; - default: - color = Color.Cyan; - break; - } + public static void ConsoleLog(this object? text, LogLevel logLevel = LogLevel.Verbose) { + var color = logLevel switch { + LogLevel.Warn => Color.Yellow, + LogLevel.Error => Color.Red, + _ => Color.Cyan + }; try { - Engine.Commands?.Log(text, color); + Engine.Commands?.Log(text?.ToString() ?? "null", color); } catch (Exception) { // ignored } diff --git a/CelesteTAS-EverestInterop/Source/Utils/SubpixelPosition.cs b/CelesteTAS-EverestInterop/Source/Utils/SubpixelPosition.cs new file mode 100644 index 000000000..34b6cf2c1 --- /dev/null +++ b/CelesteTAS-EverestInterop/Source/Utils/SubpixelPosition.cs @@ -0,0 +1,7 @@ +namespace TAS.Utils; + +/// Holds a position with integer and fractional part separated +internal record struct SubpixelPosition(SubpixelComponent X, SubpixelComponent Y); + +/// Holds a single axis with integer and fractional part separated +internal record struct SubpixelComponent(int Position, float Remainder); diff --git a/CelesteTAS-EverestInterop/everest.yaml b/CelesteTAS-EverestInterop/everest.yaml index 01ee7eb81..a9ec73a8e 100644 --- a/CelesteTAS-EverestInterop/everest.yaml +++ b/CelesteTAS-EverestInterop/everest.yaml @@ -3,7 +3,7 @@ DLL: bin/CelesteTAS-EverestInterop.dll Dependencies: - Name: EverestCore - Version: 1.5034.0 + Version: 1.5105.0 OptionalDependencies: - Name: SpeedrunTool Version: 3.16.4 diff --git a/CelesteTAS-EverestInterop/lib-stripped/Celeste.dll b/CelesteTAS-EverestInterop/lib-stripped/Celeste.dll index 432c38730..81a46b4a5 100644 Binary files a/CelesteTAS-EverestInterop/lib-stripped/Celeste.dll and b/CelesteTAS-EverestInterop/lib-stripped/Celeste.dll differ diff --git a/CelesteTAS-EverestInterop/lib-stripped/FNA.dll b/CelesteTAS-EverestInterop/lib-stripped/FNA.dll index 75c77163e..b9e43e533 100644 Binary files a/CelesteTAS-EverestInterop/lib-stripped/FNA.dll and b/CelesteTAS-EverestInterop/lib-stripped/FNA.dll differ diff --git a/CelesteTAS-EverestInterop/lib-stripped/MMHOOK_Celeste.dll b/CelesteTAS-EverestInterop/lib-stripped/MMHOOK_Celeste.dll index 61d1dab0e..46c39332a 100644 Binary files a/CelesteTAS-EverestInterop/lib-stripped/MMHOOK_Celeste.dll and b/CelesteTAS-EverestInterop/lib-stripped/MMHOOK_Celeste.dll differ diff --git a/Studio/CelesteStudio/CelesteStudio.csproj b/Studio/CelesteStudio/CelesteStudio.csproj index d53e7933b..1cba0d38a 100644 --- a/Studio/CelesteStudio/CelesteStudio.csproj +++ b/Studio/CelesteStudio/CelesteStudio.csproj @@ -4,6 +4,7 @@ net7.0 latest enable + true 3.6.2 diff --git a/Studio/CelesteStudio/Communication/CommunicationAdapterStudio.cs b/Studio/CelesteStudio/Communication/CommunicationAdapterStudio.cs index ad2c4564a..a12d9940f 100644 --- a/Studio/CelesteStudio/Communication/CommunicationAdapterStudio.cs +++ b/Studio/CelesteStudio/Communication/CommunicationAdapterStudio.cs @@ -22,7 +22,6 @@ public sealed class CommunicationAdapterStudio( { private readonly EnumDictionary gameData = new(); private readonly EnumDictionary gameDataPending = new(); - private Type? rawInfoTargetType; public void ForceReconnect() { if (Connected) { @@ -98,10 +97,6 @@ protected override void HandleMessage(MessageID messageId, BinaryReader reader) gameData[gameDataType] = reader.ReadString(); break; - case GameDataType.RawInfo: - gameData[gameDataType] = reader.ReadObject(rawInfoTargetType!); - break; - case GameDataType.GameState: gameData[gameDataType] = reader.ReadObject(); break; @@ -206,10 +201,6 @@ public void WriteRecordTAS(string fileName) { // Block other requests of this type until this is done gameDataPending[gameDataType] = true; - if (gameDataType == GameDataType.RawInfo) { - rawInfoTargetType = type; - } - QueueMessage(MessageID.RequestGameData, writer => { writer.Write((byte)gameDataType); if (arg != null) { diff --git a/Studio/CelesteStudio/Communication/CommunicationWrapper.cs b/Studio/CelesteStudio/Communication/CommunicationWrapper.cs index bbde2a294..2dce1370e 100644 --- a/Studio/CelesteStudio/Communication/CommunicationWrapper.cs +++ b/Studio/CelesteStudio/Communication/CommunicationWrapper.cs @@ -235,14 +235,6 @@ private static void OnCommandAutoCompleteResponse(int hash, CommandAutoCompleteE autoCompleteEntryCache[hash] = result; } - public static T? GetRawData(string template, bool alwaysList = false) { - if (!Connected) { - return default; - } - - return (T?)comm!.RequestGameData(GameDataType.RawInfo, (template, alwaysList), TimeSpan.FromSeconds(15), typeof(T)).Result ?? default; - } - public static async Task GetGameState() { if (!Connected) { return null; diff --git a/Studio/CelesteStudio/Dialog/GoToDialog.cs b/Studio/CelesteStudio/Dialog/GoToDialog.cs index 32e71e541..cdacd8580 100644 --- a/Studio/CelesteStudio/Dialog/GoToDialog.cs +++ b/Studio/CelesteStudio/Dialog/GoToDialog.cs @@ -18,7 +18,7 @@ private GoToDialog(Document document) { var labels = document.Lines .Select((line, row) => (line, row)) - .Where(pair => Comment.IsLabel(pair.line)) + .Where(pair => CommentLine.IsLabel(pair.line)) .Select(pair => pair with { line = pair.line[1..] }) // Remove the # .ToArray(); diff --git a/Studio/CelesteStudio/Editing/CalculationState.cs b/Studio/CelesteStudio/Editing/CalculationState.cs index 5c7302886..688aaa520 100644 --- a/Studio/CelesteStudio/Editing/CalculationState.cs +++ b/Studio/CelesteStudio/Editing/CalculationState.cs @@ -1,6 +1,6 @@ using System; using System.Diagnostics; -using CelesteStudio.Data; +using StudioCommunication; namespace CelesteStudio.Editing; diff --git a/Studio/CelesteStudio/Editing/ContextActions/CreateRepeatCommand.cs b/Studio/CelesteStudio/Editing/ContextActions/CreateRepeatCommand.cs index 17cb5bdd1..545dcc5d2 100644 --- a/Studio/CelesteStudio/Editing/ContextActions/CreateRepeatCommand.cs +++ b/Studio/CelesteStudio/Editing/ContextActions/CreateRepeatCommand.cs @@ -1,6 +1,6 @@ -using System.Diagnostics; -using CelesteStudio.Data; +using CelesteStudio.Data; using CelesteStudio.Util; +using StudioCommunication; namespace CelesteStudio.Editing.ContextActions; diff --git a/Studio/CelesteStudio/Editing/ContextActions/InlineReadCommand.cs b/Studio/CelesteStudio/Editing/ContextActions/InlineReadCommand.cs index 2b2c6b555..6f40ec844 100644 --- a/Studio/CelesteStudio/Editing/ContextActions/InlineReadCommand.cs +++ b/Studio/CelesteStudio/Editing/ContextActions/InlineReadCommand.cs @@ -70,7 +70,7 @@ private static bool TryGetLine(string labelOrLineNumber, string[] lines, out int var labels = lines .Select((line, row) => (line, row)) - .Where(pair => Comment.IsLabel(pair.line)) + .Where(pair => CommentLine.IsLabel(pair.line)) .Select(pair => pair with { line = pair.line[1..] }) // Remove the # .ToArray(); diff --git a/Studio/CelesteStudio/Editing/ContextActions/SplitFrames.cs b/Studio/CelesteStudio/Editing/ContextActions/SplitFrames.cs index d64345194..2fc381b11 100644 --- a/Studio/CelesteStudio/Editing/ContextActions/SplitFrames.cs +++ b/Studio/CelesteStudio/Editing/ContextActions/SplitFrames.cs @@ -1,5 +1,6 @@ using System.Linq; using CelesteStudio.Data; +using StudioCommunication; namespace CelesteStudio.Editing.ContextActions; diff --git a/Studio/CelesteStudio/Editing/Document.cs b/Studio/CelesteStudio/Editing/Document.cs index 565a8ae46..7ae70e72e 100644 --- a/Studio/CelesteStudio/Editing/Document.cs +++ b/Studio/CelesteStudio/Editing/Document.cs @@ -3,9 +3,9 @@ using System.IO; using System.Linq; using CelesteStudio.Communication; -using CelesteStudio.Data; using CelesteStudio.Util; using Eto.Forms; +using StudioCommunication; using StudioCommunication.Util; using System.Diagnostics; using System.Threading.Tasks; diff --git a/Studio/CelesteStudio/Editing/Editor.cs b/Studio/CelesteStudio/Editing/Editor.cs index 79f156af6..71c9a0f29 100644 --- a/Studio/CelesteStudio/Editing/Editor.cs +++ b/Studio/CelesteStudio/Editing/Editor.cs @@ -1434,7 +1434,7 @@ protected override void OnKeyDown(KeyEventArgs e) { { // Rename label string line = Document.Lines[Document.Caret.Row]; - if (Comment.IsLabel(line)) { + if (CommentLine.IsLabel(line)) { string oldLabel = line["#".Length..]; string newLabel = RenameLabelDialog.Show(oldLabel); @@ -2668,7 +2668,10 @@ private void OnEnter(bool splitLines, bool up) { if (!Document.Selection.Empty) { RemoveRange(Document.Selection.Min, Document.Selection.Max); Document.Caret.Col = Document.Selection.Min.Col; + line = Document.Lines[Document.Caret.Row]; + lineTrimmedStart = line.TrimStart(); + leadingSpaces = line.Length - lineTrimmedStart.Length; } else if (line.Trim() == "#") { // Replace empty comment Document.ReplaceLine(Document.Caret.Row, string.Empty); @@ -2678,18 +2681,23 @@ private void OnEnter(bool splitLines, bool up) { // Auto-insert # for multiline comments (not labels, not folds!) // Additionally don't auto-multiline when caret is before # - string prefix = Settings.Instance.AutoMultilineComments && Document.Caret.Col > leadingSpaces && lineTrimmedStart.StartsWith("# ") ? "# " : ""; - Document.Caret.Col = Math.Max(Document.Caret.Col, prefix.Length); + if (Settings.Instance.AutoMultilineComments && Document.Caret.Col > leadingSpaces && lineTrimmedStart.StartsWith("# ")) { + const string prefix = "# "; - string beforeCaret = line[(prefix.Length + leadingSpaces)..Document.Caret.Col]; - string afterCaret = line[Document.Caret.Col..]; + Document.Caret.Col = Math.Max(Document.Caret.Col, prefix.Length); - int newRow = Document.Caret.Row + offset; + string beforeCaret = line[(prefix.Length + leadingSpaces)..Document.Caret.Col]; + string afterCaret = line[Document.Caret.Col..]; - Document.ReplaceLine(Document.Caret.Row, prefix + (up ? afterCaret : beforeCaret)); - Document.InsertLine(newRow, prefix + (up ? beforeCaret : afterCaret)); - Document.Caret.Row = newRow; - Document.Caret.Col = desiredVisualCol = prefix.Length + (up ? beforeCaret.Length : 0); + int newRow = Document.Caret.Row + offset; + + Document.ReplaceLine(Document.Caret.Row, prefix + (up ? afterCaret : beforeCaret)); + Document.InsertLine(newRow, prefix + (up ? beforeCaret : afterCaret)); + Document.Caret.Row = newRow; + Document.Caret.Col = desiredVisualCol = prefix.Length + (up ? beforeCaret.Length : 0); + } else { + Document.Insert($"{Document.NewLine}"); + } } else { int newRow = Document.Caret.Row + offset; if (GetCollapse(Document.Caret.Row) is { } collapse) { @@ -2957,7 +2965,7 @@ private void OnToggleCommentInputs() { } if (lineTrimmed.StartsWith('#')) { - if ((!Comment.IsLabel(lineTrimmed) && !lineTrimmed.StartsWith("#***") && !ActionLine.TryParse(lineTrimmed[1..], out _)) || lineTrimmed.StartsWith("#lvl_") || TimestampRegex.IsMatch(lineTrimmed)) { + if ((!CommentLine.IsLabel(lineTrimmed) && !lineTrimmed.StartsWith("#***") && !ActionLine.TryParse(lineTrimmed[1..], out _)) || lineTrimmed.StartsWith("#lvl_") || TimestampRegex.IsMatch(lineTrimmed)) { // Ignore non-input comments and special labels continue; } @@ -3424,7 +3432,7 @@ private CaretPosition GetLabelPosition(int dir) { string line = Document.Lines[row]; // Go to the next label / breakpoint - if (Comment.IsLabel(Document.Lines[row]) || line.TrimStart().StartsWith("***")) { + if (CommentLine.IsLabel(Document.Lines[row]) || line.TrimStart().StartsWith("***")) { break; } diff --git a/Studio/CelesteStudio/Editing/GameInfoPanel.cs b/Studio/CelesteStudio/Editing/GameInfo.cs similarity index 91% rename from Studio/CelesteStudio/Editing/GameInfoPanel.cs rename to Studio/CelesteStudio/Editing/GameInfo.cs index 89dc57ab4..631d11572 100644 --- a/Studio/CelesteStudio/Editing/GameInfoPanel.cs +++ b/Studio/CelesteStudio/Editing/GameInfo.cs @@ -7,12 +7,41 @@ using Eto.Drawing; using Eto.Forms; using SkiaSharp; +using StudioCommunication; using StudioCommunication.Util; using System.ComponentModel; namespace CelesteStudio.Editing; public sealed class GameInfo : Panel { + /// Label specifically optimized for displaying monospaced text in the game-info + private sealed class InfoLabel(Func textProvider) : SkiaDrawable { + public override unsafe void Draw(SKSurface surface) { + var text = textProvider().AsMemory(); + + var canvas = surface.Canvas; + var font = FontManager.SKStatusFont; + + float maxWidth = 0.0f; + float height = 0.0f; + + foreach (var line in text.SplitLines()) { + using var handle = line.Pin(); + using var blob = SKTextBlob.Create((IntPtr)handle.Pointer, line.Length * sizeof(char), SKTextEncoding.Utf16, font); // C# strings are UTF16 + + if (blob != null) { + canvas.DrawText(blob, 0.0f, height + font.Offset(), Settings.Instance.Theme.StatusFgPaint); + } + + maxWidth = Math.Max(font.CharWidth() * line.Length, maxWidth); + height += font.LineHeight(); + } + + Width = (int) maxWidth; + Height = (int) height; + } + } + private sealed class SubpixelIndicator : SkiaDrawable { public override void Draw(SKSurface surface) { var canvas = surface.Canvas; @@ -87,8 +116,8 @@ public int AvailableWidth { set => infoTemplateArea.Width = Math.Max(0, value); } - private readonly Label frameInfo; - private readonly Label gameStatus; + private readonly InfoLabel frameInfo; + private readonly InfoLabel gameStatus; private readonly SubpixelIndicator subpixelIndicator; private readonly TextArea infoTemplateArea; @@ -103,20 +132,10 @@ public int AvailableWidth { public GameInfo() { Padding = 0; - frameInfo = new Label { - Text = string.Empty, - TextColor = Settings.Instance.Theme.StatusFg, - Font = FontManager.StatusFont, - Wrap = WrapMode.None, - }; + frameInfo = new InfoLabel(() => frameInfoBuilder.ToString()); + gameStatus = new InfoLabel(() => CommunicationWrapper.GameInfo is { } gameInfo && !string.IsNullOrEmpty(gameInfo) ? gameInfo : DisconnectedText); RecalcFrameInfo(); - gameStatus = new Label { - Text = string.Empty, - TextColor = Settings.Instance.Theme.StatusFg, - Font = FontManager.StatusFont, - Wrap = WrapMode.None, - }; - RecalcGameStatus(); + subpixelIndicator = new SubpixelIndicator { Width = 100, Height = 100 }; subpixelIndicator.Visible = CommunicationWrapper.ShowSubpixelIndicator && Settings.Instance.ShowSubpixelIndicator; subpixelIndicator.Invalidate(); @@ -166,13 +185,13 @@ public GameInfo() { }; CommunicationWrapper.ConnectionChanged += () => { RecalcFrameInfo(); - RecalcGameStatus(); + gameStatus.Invalidate(); subpixelIndicator.Visible = CommunicationWrapper.ShowSubpixelIndicator && Settings.Instance.ShowSubpixelIndicator; subpixelIndicator.Invalidate(); }; CommunicationWrapper.StateUpdated += (_, _) => { RecalcFrameInfo(); - RecalcGameStatus(); + gameStatus.Invalidate(); subpixelIndicator.Visible = CommunicationWrapper.ShowSubpixelIndicator && Settings.Instance.ShowSubpixelIndicator; subpixelIndicator.Invalidate(); }; @@ -193,14 +212,14 @@ void ForwardSize(object? _1, EventArgs _2) { // React to settings changes Settings.ThemeChanged += () => { - frameInfo.TextColor = Settings.Instance.Theme.StatusFg; - gameStatus.TextColor = Settings.Instance.Theme.StatusFg; + frameInfo.Invalidate(); + gameStatus.Invalidate(); infoTemplateArea.TextColor = Settings.Instance.Theme.StatusFg; subpixelIndicator.Invalidate(); }; Settings.FontChanged += () => { - frameInfo.Font = FontManager.StatusFont; - gameStatus.Font = FontManager.StatusFont; + frameInfo.Invalidate(); + gameStatus.Invalidate(); infoTemplateArea.Font = FontManager.StatusFont; subpixelIndicator.Invalidate(); }; @@ -284,13 +303,7 @@ private void RecalcFrameInfo() { frameInfoBuilder.Append(selectedFrames); } - frameInfo.Text = frameInfoBuilder.ToString(); - } - - private void RecalcGameStatus() { - gameStatus.Text = CommunicationWrapper.Connected && CommunicationWrapper.GameInfo is { } gameInfo && !string.IsNullOrEmpty(gameInfo) - ? gameInfo - : DisconnectedText; + frameInfo.Invalidate(); } } @@ -386,17 +399,11 @@ public GameInfoPanel() { Padding = 10; Content = layout; - Load += (_, _) => { - UpdateGameInfoStatus(); - }; + Load += (_, _) => UpdateGameInfoStatus(); + Settings.Changed += UpdateGameInfoStatus; BackgroundColor = Settings.Instance.Theme.StatusBg; - Settings.Changed += () => { - UpdateGameInfoStatus(); - }; - Settings.ThemeChanged += () => { - BackgroundColor = Settings.Instance.Theme.StatusBg; - }; + Settings.ThemeChanged += () => BackgroundColor = Settings.Instance.Theme.StatusBg; return; diff --git a/Studio/CelesteStudio/Editing/SyntaxHighlighter.cs b/Studio/CelesteStudio/Editing/SyntaxHighlighter.cs index 010315644..5f4074585 100644 --- a/Studio/CelesteStudio/Editing/SyntaxHighlighter.cs +++ b/Studio/CelesteStudio/Editing/SyntaxHighlighter.cs @@ -1,10 +1,9 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using CelesteStudio.Data; -using CelesteStudio.Util; using Eto.Drawing; using SkiaSharp; +using StudioCommunication; namespace CelesteStudio.Editing; @@ -124,8 +123,9 @@ private LineStyle ComputeLineStyle(string line) { } } - if (!ActionLine.TryParse(line, out _)) + if (!ActionLine.TryParse(line, out _)) { return new LineStyle { Segments = [new LineStyle.Segment { StartIdx = 0, EndIdx = line.Length - 1, Type = StyleType.Command } ] }; + } var segments = new List { new() { StartIdx = 0, EndIdx = Math.Min(line.Length - 1, ActionLine.MaxFramesDigits - 1), Type = StyleType.Frame } diff --git a/Studio/CelesteStudio/Studio.cs b/Studio/CelesteStudio/Studio.cs index 9a2e6f72a..75dd3a670 100644 --- a/Studio/CelesteStudio/Studio.cs +++ b/Studio/CelesteStudio/Studio.cs @@ -719,7 +719,8 @@ private MenuBar CreateMenu() { MenuUtils.CreateGameSettingToggle("Game Info", nameof(GameSettings.InfoGame)), MenuUtils.CreateGameSettingToggle("Subpixel Indicator", nameof(GameSettings.InfoSubpixelIndicator)), MenuUtils.CreateGameSettingEnum("Custom Info", nameof(GameSettings.InfoCustom), ["Off", "HUD Only", "Studio Only", "Both"]), - MenuUtils.CreateGameSettingEnum("Watch Entity Info", nameof(GameSettings.InfoWatchEntity), ["Off", "HUD Only", "Studio Only", "Both"]), + MenuUtils.CreateGameSettingEnum("Watch Entity Info (HUD)", nameof(GameSettings.InfoWatchEntityHudType), ["None", "Position", "Declared Only", "All"]), + MenuUtils.CreateGameSettingEnum("Watch Entity Info (Studio)", nameof(GameSettings.InfoWatchEntityStudioType), ["None", "Position", "Declared Only", "All"]), new SeparatorMenuItem(), MenuUtils.CreateGameSettingNumberInput("Position Decimals", nameof(GameSettings.PositionDecimals), minDecimals, maxDecimals, 1), MenuUtils.CreateGameSettingNumberInput("Speed Decimals", nameof(GameSettings.SpeedDecimals), minDecimals, maxDecimals, 1), diff --git a/Studio/CelesteStudio/Tool/IntegrateReadFiles.cs b/Studio/CelesteStudio/Tool/IntegrateReadFiles.cs index 4c558e6dc..6f42972fe 100644 --- a/Studio/CelesteStudio/Tool/IntegrateReadFiles.cs +++ b/Studio/CelesteStudio/Tool/IntegrateReadFiles.cs @@ -1,9 +1,7 @@ -using CelesteStudio.Data; using CelesteStudio.Editing; -using System; +using StudioCommunication; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text.RegularExpressions; namespace CelesteStudio.Tool; diff --git a/Studio/CelesteStudio/Data/ActionLine.cs b/StudioCommunication/ActionLine.cs similarity index 99% rename from Studio/CelesteStudio/Data/ActionLine.cs rename to StudioCommunication/ActionLine.cs index 5b27864a5..52cecbf1b 100644 --- a/Studio/CelesteStudio/Data/ActionLine.cs +++ b/StudioCommunication/ActionLine.cs @@ -1,11 +1,9 @@ -using CelesteStudio.Util; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; -using StudioCommunication; -namespace CelesteStudio.Data; +namespace StudioCommunication; public struct ActionLine() { public const char Delimiter = ','; diff --git a/StudioCommunication/Comment.cs b/StudioCommunication/CommentLine.cs similarity index 92% rename from StudioCommunication/Comment.cs rename to StudioCommunication/CommentLine.cs index be3e9a165..58fe5a357 100644 --- a/StudioCommunication/Comment.cs +++ b/StudioCommunication/CommentLine.cs @@ -1,6 +1,6 @@ namespace StudioCommunication; -public class Comment { +public static class CommentLine { /// A comment is considered a label, if it's a single # immediately followed by the label name /// For example: "#lvl_1", "#Start", "#cycle_a" diff --git a/StudioCommunication/CommunicationAdapterBase.cs b/StudioCommunication/CommunicationAdapterBase.cs index c14c22ae7..995ef4da3 100644 --- a/StudioCommunication/CommunicationAdapterBase.cs +++ b/StudioCommunication/CommunicationAdapterBase.cs @@ -55,7 +55,7 @@ private set { private readonly List<(MessageID, Action)> queuedWrites = []; /// Indicates ABI compatibility between two adapters - protected const ushort ProtocolVersion = 2; + protected const ushort ProtocolVersion = 3; private const int PingMessageSize = sizeof(ushort); private const int MessageCountOffset = 4; diff --git a/StudioCommunication/GameSettings.cs b/StudioCommunication/GameSettings.cs index 3bf02a0f3..6d94b3071 100644 --- a/StudioCommunication/GameSettings.cs +++ b/StudioCommunication/GameSettings.cs @@ -27,7 +27,8 @@ public partial class GameSettings { public bool InfoTasInput = true; public bool InfoSubpixelIndicator = true; public HudOptions InfoCustom = HudOptions.Off; - public HudOptions InfoWatchEntity = HudOptions.Both; + public WatchEntityType InfoWatchEntityHudType = WatchEntityType.Position; + public WatchEntityType InfoWatchEntityStudioType = WatchEntityType.All; public int PositionDecimals = 2; public int SpeedDecimals = 2; @@ -57,6 +58,13 @@ public enum HudOptions { Both = HudOnly | StudioOnly } +public enum WatchEntityType { + None, + Position, + DeclaredOnly, + All +} + public enum SpeedUnit { PixelPerSecond, PixelPerFrame diff --git a/StudioCommunication/HotkeyID.cs b/StudioCommunication/HotkeyID.cs index 03138519b..62b715663 100644 --- a/StudioCommunication/HotkeyID.cs +++ b/StudioCommunication/HotkeyID.cs @@ -22,5 +22,6 @@ public enum HotkeyID : byte { CameraLeft, CameraRight, CameraZoomIn, - CameraZoomOut -} \ No newline at end of file + CameraZoomOut, + OpenConsole +} diff --git a/StudioCommunication/MessageID.cs b/StudioCommunication/MessageID.cs index 3800cbfa1..675db4b08 100644 --- a/StudioCommunication/MessageID.cs +++ b/StudioCommunication/MessageID.cs @@ -77,7 +77,6 @@ public enum GameDataType : byte { ModUrl, ExactGameInfo, CustomInfoTemplate, - RawInfo, GameState, CommandHash, LevelInfo, diff --git a/StudioCommunication/Util/CommonExtensions.cs b/StudioCommunication/Util/CommonExtensions.cs index dd9fc6ec3..e26a2a648 100644 --- a/StudioCommunication/Util/CommonExtensions.cs +++ b/StudioCommunication/Util/CommonExtensions.cs @@ -129,6 +129,34 @@ public static IEnumerable SplitLines(this string str) { yield return str[startIdx..]; } } + + /// Splits each line into its own slice, accounting for LF, CRLF and CR line endings + public static IEnumerable> SplitLines(this ReadOnlyMemory str) { + int startIdx = 0; + for (int i = 0; i < str.Length; i++) { + // \n is always a newline + if (str.Span[i] == '\n') { + yield return str[startIdx..i]; + startIdx = i + 1; + continue; + } + + // \r is either alone or a \r\n + if (str.Span[i] == '\r') { + yield return str[startIdx..i]; + + if (i + 1 < str.Length && str.Span[i + 1] == '\n') { + i++; + } + + startIdx = i + 1; + } + } + + if (startIdx != str.Length) { + yield return str[startIdx..]; + } + } } public static class TypeExtensions {