From 89d56ea1d089f6641407d99d592d53c897292ab4 Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 20:38:00 +0000 Subject: [PATCH 1/2] feat(policy): let maps suppress Akron features --- CHANGELOG.md | 2 + Loenn/entities/feature_restrictions.lua | 30 ++ Source/Actions/akron-actions.cs | 49 ++- Source/Actions/akron-backup-actions.cs | 27 +- Source/Actions/akron-startpos-actions.cs | 37 +- Source/Actions/akron-startpos-input-wait.cs | 6 +- Source/Akron.csproj | 1 + Source/Automation/akron-automation-runtime.cs | 7 +- Source/Commands/akron-gameplay-commands.cs | 3 + Source/Commands/akron-qa-commands.cs | 89 ++++- Source/Commands/akron-status-commands.cs | 9 + Source/Core/AkronFeatureRegistry.cs | 19 + Source/Core/AkronPolicy.cs | 78 +++- Source/Core/AkronProof.cs | 170 ++++----- Source/Core/akron-log.cs | 6 +- Source/Core/akron-map-feature-restrictions.cs | 78 ++++ Source/Hud/AkronHudRenderer.cs | 31 +- Source/Hud/akron-custom-hud-labels.cs | 2 +- Source/Hud/akron-hud-label-obstruction.cs | 4 +- Source/Hud/akron-hud-label-stack.cs | 2 +- Source/Hud/akron-hud-resource-renderer.cs | 4 +- Source/Hud/akron-hud-trajectory-renderer.cs | 2 +- .../Hud/akron-hud-world-overlay-renderer.cs | 4 +- Source/Interop/AkronMotionSmoothingInterop.cs | 89 +++-- Source/Interop/AkronSpeedrunToolBroker.cs | 8 + Source/Interop/akron-extended-variants.cs | 337 +++++++++++++++++- Source/Module/AkronModule.cs | 65 +++- Source/Module/akron-module-actions.cs | 31 +- Source/Module/akron-module-map-policy.cs | 160 +++++++++ Source/Module/akron-module-overlay-input.cs | 6 +- Source/Module/akron-module-player-runtime.cs | 67 ++-- Source/Module/akron-module-visual-runtime.cs | 47 ++- .../Overlay/akron-overlay-bindable-actions.cs | 6 +- Source/Overlay/akron-overlay-entries.cs | 8 +- Source/Overlay/akron-overlay-imgui-popups.cs | 9 + .../Overlay/akron-overlay-imgui-renderer.cs | 6 +- .../Overlay/akron-overlay-interop-entries.cs | 20 +- .../Overlay/akron-overlay-sprite-renderer.cs | 2 +- .../Recorder/akron-internal-audio-recorder.cs | 8 + .../akron-internal-recorder-replay.cs | 12 + Source/Recorder/akron-internal-recorder.cs | 27 ++ Source/Runtime/akron-autosave.cs | 2 +- Source/Runtime/akron-deload-simulator.cs | 2 +- Source/Runtime/akron-ear-aid.cs | 32 +- Source/Runtime/akron-frozen-death-sprite.cs | 4 +- Source/Runtime/akron-madeline-visuals.cs | 40 ++- Source/Runtime/akron-noclip-runtime.cs | 6 +- Source/Runtime/akron-practice-stats.cs | 2 +- Source/Runtime/akron-runtime-guards.cs | 16 +- Source/Runtime/akron-runtime-options.cs | 81 ++++- Source/Runtime/akron-visual-noise-runtime.cs | 8 +- Source/SaveLoad/AkronSaveLoad.cs | 27 +- Source/SaveLoad/akron-reconstruction-graph.cs | 7 +- Source/SaveLoad/akron-save-load-broker.cs | 13 + Source/SaveLoad/akron-save-load-models.cs | 8 + Source/Tools/akron-capture.cs | 29 +- Source/Tools/akron-entity-inspector-pin.cs | 10 +- Source/Tools/akron-entity-inspector.cs | 8 +- Source/Tools/akron-screenshot-scanner.cs | 117 ++++-- docs/concepts/akr-files-and-setup-state.mdx | 4 +- docs/contributing/feature-policy.mdx | 43 +++ docs/reference/akr-archives.mdx | 9 +- docs/reference/snapshot-bundle.md | 2 +- docs/troubleshooting/startpos-recovery.mdx | 8 +- tests/feature-registry-tests.cs | 251 ++++++++++++- tests/startpos-persistence-tests.cs | 39 +- tests/startpos-reconstruction-tests.cs | 1 - 67 files changed, 1945 insertions(+), 392 deletions(-) create mode 100644 Loenn/entities/feature_restrictions.lua create mode 100644 Source/Core/akron-map-feature-restrictions.cs create mode 100644 Source/Module/akron-module-map-policy.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 677520bb..006e6eea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,14 @@ This project uses version tags that match the mod version in `everest.yaml`, whi - Add **Menu Mouse** to the Interface tab. Hold `Left Alt` (or the bind in its popup) in a Celeste menu to point and click: hover selects, left click confirms or steps the value under the cursor, right click goes back, and the wheel scrolls. Works in the pause menu, options and mod options, the main menu, file select, chapter select, the chapter panel, and the journal. On by default. - Add Automatic Error Reports for selected Akron startup, overlay, settings and StartPos failures. Reporting is on by default when a Sentry destination is configured, with an opt-out in the Interface tab. Existing saved Off choices are preserved. Reports send only bounded Akron stack details and the mod version. Local logging and Send diagnostics keep their existing behavior. +- Add map-authored Akron feature restrictions through the Lönn `Akron/featureRestrictions` marker. Maps can suppress selected registered features across all rooms without replacing saved preferences. Runtime effects and actions obey the same policy, the overlay and status command identify suppression, and map exit cancels pending training/capture work and releases Akron-owned timing. ### Changed - Trim Akron's entry in Celeste's mod options to an **Enabled** switch and the menu bind. Streamer Mode, Safe Mode, and Send diagnostics stay in the overlay, and action binds are set by right-clicking overlay rows. - Turning **Enabled** off removes Akron's overlay, HUD, hotkeys, interop, error reports, and debug console commands on the next launch. Only its mod options entry stays, so it can be turned back on there. - Add a **Hold** bind row to the Fast Lookout popup, set with its **Bind** button. The hold starts unbound, and Everest's keyboard and controller config used to be the only place to set it. Keyboard keys in this hold now act as one chord, like Akron's other binds: a hold saved as two separate keys needs both held, so bind it again if you meant either key. Controller and mouse buttons still work on their own. +- Move StartPos snapshots to `akron-reconstruction-v12` to distinguish Akron's saved timescale from the map's own clock. Loading a StartPos cannot restore an Akron timescale that the map forbids. Capture older slots again, then re-export their StartPos packs; positions, spawn settings, and keybinds are unchanged. ### Fixed diff --git a/Loenn/entities/feature_restrictions.lua b/Loenn/entities/feature_restrictions.lua new file mode 100644 index 00000000..7aa5388b --- /dev/null +++ b/Loenn/entities/feature_restrictions.lua @@ -0,0 +1,30 @@ +local drawableRectangle = require("structs.drawable_rectangle") +local utils = require("utils") + +local restrictions = {} +restrictions.name = "Akron/featureRestrictions" +restrictions.depth = -1000000 +restrictions.placements = { + { + name = "map_features", + data = { + features = "Freeze,FrameAdvance,Timescale" + } + } +} +restrictions.fieldInformation = { + features = { + fieldType = "string" + } +} + +function restrictions.sprite(room, entity) + return drawableRectangle.fromRectangle("bordered", entity.x - 8, entity.y - 8, 16, 16, + {0.15, 0.35, 0.55, 0.6}, {0.4, 0.8, 1.0, 1.0}) +end + +function restrictions.selection(room, entity) + return utils.rectangle(entity.x - 8, entity.y - 8, 16, 16) +end + +return restrictions diff --git a/Source/Actions/akron-actions.cs b/Source/Actions/akron-actions.cs index a513f073..4288f26b 100644 --- a/Source/Actions/akron-actions.cs +++ b/Source/Actions/akron-actions.cs @@ -15,6 +15,11 @@ public static partial class AkronActions { private const string ImplicitStartCheckpointName = "Start"; private static bool autoDeafenActive; + private static bool lowVolumeApplied; + private static float previousMusicVolume; + private static float previousSfxVolume; + private static float appliedMusicVolume; + private static float appliedSfxVolume; public static bool AutoDeafenActive => autoDeafenActive; @@ -364,11 +369,7 @@ public static void AdjustTimescale(float delta) { } ConfigureTimescaleMultiplier(session, next); - if (next == 1f && session.TimescaleEnabled) { -#pragma warning disable CS0618 - Engine.TimeRate = 1f; -#pragma warning restore CS0618 - } + AkronModule.ApplyTimescale(); Engine.Scene?.Add(new AkronToast("Timescale value: " + next.ToString("0.0x"))); } @@ -410,25 +411,45 @@ public static void SetLowVolumeSfx(float volume) { } public static void ApplyLowVolumeBypass() { - if (!AkronModule.Settings.AllowLowVolume) { + if (!AkronModule.Settings.AllowLowVolume || !AkronPolicy.CanUse(AkronFeatureKind.LowVolumeBypass).Allowed) { + RestoreLowVolumeBypass(); return; } - Audio.MusicVolume = AkronModuleSettings.ClampLowVolumeLevel(AkronModule.Settings.LowVolumeMusic) / 10f; - Audio.SfxVolume = AkronModuleSettings.ClampLowVolumeLevel(AkronModule.Settings.LowVolumeSfx) / 10f; + if (!lowVolumeApplied || Audio.MusicVolume != appliedMusicVolume) { + previousMusicVolume = Audio.MusicVolume; + } + if (!lowVolumeApplied || Audio.SfxVolume != appliedSfxVolume) { + previousSfxVolume = Audio.SfxVolume; + } + appliedMusicVolume = AkronModuleSettings.ClampLowVolumeLevel(AkronModule.Settings.LowVolumeMusic) / 10f; + appliedSfxVolume = AkronModuleSettings.ClampLowVolumeLevel(AkronModule.Settings.LowVolumeSfx) / 10f; + Audio.MusicVolume = appliedMusicVolume; + Audio.SfxVolume = appliedSfxVolume; + lowVolumeApplied = true; } public static void RestoreLowVolumeBypass() { - if (Settings.Instance == null) { + if (!lowVolumeApplied) { return; } - Settings.Instance.ApplyMusicVolume(); - Settings.Instance.ApplySFXVolume(); + if (Audio.MusicVolume == appliedMusicVolume) { + Audio.MusicVolume = previousMusicVolume; + } + if (Audio.SfxVolume == appliedSfxVolume) { + Audio.SfxVolume = previousSfxVolume; + } + lowVolumeApplied = false; } public static bool ActivateAutoDeafen(out string error) { error = string.Empty; + AkronPolicyDecision policy = AkronPolicy.CanUse(AkronFeatureKind.AutoDeafen); + if (!policy.Allowed) { + error = policy.Message; + return false; + } if (autoDeafenActive) { return true; } @@ -516,7 +537,7 @@ public static void ApplyCameraOffset(Level level) { return; } - if (!AkronModule.TryUse(AkronFeatureKind.CameraOffset)) { + if (!AkronModule.TryUseRuntime(AkronFeatureKind.CameraOffset)) { return; } @@ -824,7 +845,7 @@ private static string BuildJournalSnapshotSummary(Level level) { } public static string WriteDebugSnapshot(Level level, string tag = "manual") { - if (level == null) { + if (level == null || !AkronModule.TryUse(AkronFeatureKind.Logging)) { return string.Empty; } @@ -997,7 +1018,7 @@ private static void WarpToRoom(Level level, string roomName) { } level.OnEndOfFrame += () => { - if (Engine.Scene != level) { + if (Engine.Scene != level || !AkronPolicy.CanUse(AkronFeatureKind.RoomWarp).Allowed) { return; } diff --git a/Source/Actions/akron-backup-actions.cs b/Source/Actions/akron-backup-actions.cs index d4966b13..4e31b714 100644 --- a/Source/Actions/akron-backup-actions.cs +++ b/Source/Actions/akron-backup-actions.cs @@ -226,6 +226,9 @@ internal static bool TryCreateBackup( out IReadOnlyList skipped, string protectedBackupPath = null) { skipped = Array.Empty(); + if (!AkronPolicy.CanUse(AkronFeatureKind.Backups).Allowed) { + return false; + } lock (Sync) { try { string savesFolder = GetSavesFolder(); @@ -529,6 +532,9 @@ internal static string DescribeRestoreRefusal(string backupPath) { } public static void OpenBackupFolder() { + if (!AkronModule.TryUse(AkronFeatureKind.Backups)) { + return; + } try { Directory.CreateDirectory(BackupFolder); StartShellOpen(BackupFolder); @@ -540,6 +546,9 @@ public static void OpenBackupFolder() { } public static IReadOnlyList ListBackups() { + if (!AkronPolicy.CanUse(AkronFeatureKind.Backups).Allowed) { + return Array.Empty(); + } lock (Sync) { if (!backupListDirty) { return cachedBackups; @@ -596,6 +605,9 @@ internal static void SetBackupFolderForQa(string backupFolder, AkronModuleSettin } public static void RestoreBackup(AkronBackupEntry backup) { + if (!AkronModule.TryUse(AkronFeatureKind.Backups)) { + return; + } if (backup == null || string.IsNullOrWhiteSpace(backup.Path) || !File.Exists(backup.Path)) { LastStatus = "Restore failed: backup file missing."; Toast(LastStatus); @@ -607,7 +619,11 @@ public static void RestoreBackup(AkronBackupEntry backup) { level, "Restore Backup", "Restore " + backup.FileName + "?\nA pre-restore backup will be created first.", - new AkronPromptOption("Restore", () => RestoreBackupConfirmed(backup))); + new AkronPromptOption("Restore", () => { + if (Engine.Scene == level) { + RestoreBackupConfirmed(backup); + } + })); return; } @@ -619,6 +635,9 @@ internal static void RestoreBackupForQa(AkronBackupEntry backup) { } internal static void ApplyRetentionForQa() { + if (!AkronPolicy.CanUse(AkronFeatureKind.Backups).Allowed) { + return; + } lock (Sync) { InvalidateBackupList(); ApplyRetention(); @@ -626,6 +645,9 @@ internal static void ApplyRetentionForQa() { } public static void SetPinned(AkronBackupEntry backup, bool pinned) { + if (!AkronModule.TryUse(AkronFeatureKind.Backups)) { + return; + } if (backup == null || string.IsNullOrWhiteSpace(backup.Path)) { return; } @@ -700,6 +722,9 @@ public static int ClampBackupMaxSizeMb(int value) { } private static void RestoreBackupConfirmed(AkronBackupEntry backup) { + if (!AkronPolicy.CanUse(AkronFeatureKind.Backups).Allowed) { + return; + } lock (Sync) { try { string savesFolder = GetSavesFolder(); diff --git a/Source/Actions/akron-startpos-actions.cs b/Source/Actions/akron-startpos-actions.cs index f37c5a4a..ed08ea2c 100644 --- a/Source/Actions/akron-startpos-actions.cs +++ b/Source/Actions/akron-startpos-actions.cs @@ -26,6 +26,14 @@ public static partial class AkronActions { // render before Celeste advances the room simulation. internal static ulong StartPosFrameGeneration { get; private set; } private static bool startPosCaptureInProgress; + private static ulong startPosActionGeneration; + + internal static void CancelPendingStartPosActions() { + startPosActionGeneration++; + startPosCaptureInProgress = false; + ClearStartPosInputWait(); + AkronStartPosPersistence.CancelPrewarm(); + } internal static bool IsStartPosCapturePending => startPosCaptureInProgress; private static readonly Dictionary> PendingStartPositionsByFileAndMap = new Dictionary>(StringComparer.Ordinal); @@ -292,9 +300,14 @@ Action completion // Hold the requested frame while update hooks unwind. The stable boundary // also waits for any outer mod's Calc.PushRandom scope to be popped. StartPosFrameGeneration++; + ulong generation = startPosActionGeneration; AkronModule.ScheduleAfterStableEngineUpdate(() => { bool captured = false; try { + if (generation != startPosActionGeneration || + !AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed) { + return; + } if (!ReferenceEquals(Engine.Scene, level) || !ReferenceEquals(level.Session, requestedSession) || !ReferenceEquals(level.Tracker.GetEntity(), requestedPlayer) || @@ -306,7 +319,9 @@ Action completion } captured = CompleteStartPosCapture(level, startPos, fileSlot, slot, toast); } finally { - startPosCaptureInProgress = false; + if (generation == startPosActionGeneration) { + startPosCaptureInProgress = false; + } completion?.Invoke(captured); } }); @@ -888,9 +903,16 @@ public static void LoadStartPos(Level level, Action completion = null) { return; } + ulong generation = startPosActionGeneration; + Session requestedSession = level.Session; AkronModule.ScheduleAfterStableEngineUpdate(() => { bool loaded = false; try { + if (generation != startPosActionGeneration || + !ReferenceEquals(level.Session, requestedSession) || + !AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed) { + return; + } if (Engine.Scene != level) { Engine.Scene?.Add(new AkronToast("StartPos " + slot + " was not loaded: the scene changed.")); return; @@ -1092,12 +1114,17 @@ public static void ApplyStartPosConfiguration(AkronStartPos startPos) { } internal static void RestoreStartPosAfterDeath(Level level, AkronStartPos startPos) { - if (level == null || startPos == null || startPosCaptureInProgress) { + if (level == null || startPos == null || startPosCaptureInProgress || + !AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed) { return; } + ulong generation = startPosActionGeneration; + Session requestedSession = level.Session; AkronModule.ScheduleAfterStableEngineUpdate(() => { - if (Engine.Scene != level) { + if (Engine.Scene != level || generation != startPosActionGeneration || + !ReferenceEquals(level.Session, requestedSession) || + !AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed) { return; } if (startPosCaptureInProgress) { @@ -1200,6 +1227,10 @@ private static string DescribeRestoreFailure(AkronSaveLoadResult result) { } private static bool RestoreStartPosCore(Level level, AkronStartPos startPos, string toast, int loadedSlot, bool endPlacementForLoad) { + if (!AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed || + !IsStartPosInArea(startPos, GetAreaSid(level))) { + return false; + } ClearStartPosInputWait(); bool restoreRespawnAtStartPos = AkronModule.Settings.RespawnAtStartPos; AkronModule.Settings.RespawnAtStartPos = false; diff --git a/Source/Actions/akron-startpos-input-wait.cs b/Source/Actions/akron-startpos-input-wait.cs index adf3570d..0a15c49f 100644 --- a/Source/Actions/akron-startpos-input-wait.cs +++ b/Source/Actions/akron-startpos-input-wait.cs @@ -72,7 +72,8 @@ public static partial class AkronActions { internal static void BeginStartPosInputWait(Level level, bool waitingForWipe) { ClearStartPosInputWait(); - if (level == null || !AkronModule.Settings.StartPosWaitForInput) { + if (level == null || !AkronModule.Settings.StartPosWaitForInput || + !AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed) { return; } @@ -96,7 +97,8 @@ internal static bool UpdateStartPosInputWait(Level level) { if (!StartPosInputWait.Active) { return false; } - if (!AkronModule.Settings.StartPosWaitForInput || !ReferenceEquals(level, startPosInputWaitLevel)) { + if (!AkronModule.Settings.StartPosWaitForInput || !ReferenceEquals(level, startPosInputWaitLevel) || + !AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed) { ClearStartPosInputWait(); return false; } diff --git a/Source/Akron.csproj b/Source/Akron.csproj index 9e0ba26a..0de59b14 100644 --- a/Source/Akron.csproj +++ b/Source/Akron.csproj @@ -65,6 +65,7 @@ Dialog/English.txt + ThirdPartyNotices.txt diff --git a/Source/Automation/akron-automation-runtime.cs b/Source/Automation/akron-automation-runtime.cs index 0d670113..09cdde19 100644 --- a/Source/Automation/akron-automation-runtime.cs +++ b/Source/Automation/akron-automation-runtime.cs @@ -29,6 +29,7 @@ private static List CurrentMapAutoKillAreas() { private static void ApplyAutoKill(Level level, Player player) { if (!Settings.AutoKill || + !AkronPolicy.CanUse(AkronFeatureKind.AutoKill).Allowed || player == null || player.Dead || level.Transitioning || @@ -70,7 +71,7 @@ private static void ApplyAutoKill(Level level, Player player) { } private static void ApplyAutoDeafen(Level level, Player player) { - if (!Settings.AutoDeafen) { + if (!Settings.AutoDeafen || !AkronPolicy.CanUse(AkronFeatureKind.AutoDeafen).Allowed) { AkronActions.RestoreAutoDeafen(); return; } @@ -97,9 +98,9 @@ private static void ApplyAutoDeafen(Level level, Player player) { // Celeste's own Assist invincibility counts: Player.Die(evenIfInvincible: false) is a no-op // under it, so without this check Auto Kill would keep asking for a death that never happens. private static bool IsAutoKillBlockedByPlayerProtection() { - return Settings.Noclip && TryUse(AkronFeatureKind.Noclip) || + return Settings.Noclip && TryUseRuntime(AkronFeatureKind.Noclip) || Settings.NoclipAccuracy && AkronPolicy.CanUse(AkronFeatureKind.HazardAccuracy).Allowed || - Settings.Invincibility && TryUse(AkronFeatureKind.Invincibility) || + Settings.Invincibility && TryUseRuntime(AkronFeatureKind.Invincibility) || global::Celeste.SaveData.Instance?.Assists.Invincible == true; } diff --git a/Source/Commands/akron-gameplay-commands.cs b/Source/Commands/akron-gameplay-commands.cs index c5e841f4..43b3b3a2 100644 --- a/Source/Commands/akron-gameplay-commands.cs +++ b/Source/Commands/akron-gameplay-commands.cs @@ -265,6 +265,9 @@ public static void StepFrame(string _ = "") { return; } + if (!AkronModule.TryUse(AkronFeatureKind.Freeze) || !AkronModule.TryUse(AkronFeatureKind.FrameAdvance)) { + return; + } session.StepFrameRequested = true; Log("step-frame: requested"); } diff --git a/Source/Commands/akron-qa-commands.cs b/Source/Commands/akron-qa-commands.cs index 2ea52752..0be957c8 100644 --- a/Source/Commands/akron-qa-commands.cs +++ b/Source/Commands/akron-qa-commands.cs @@ -85,6 +85,9 @@ public static void QaSnapshotReport() { [Command("akron_qa_area_complete", "trigger Level.RegisterAreaComplete for Akron proof automation")] public static void QaAreaComplete(string _ = "") { + if (!AkronModule.TryUse(AkronFeatureKind.InstantComplete)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -263,6 +266,9 @@ public static void QaStartPosDeathCandidate(string xText = "", string yText = "" [Command("akron_qa_screenshake", "set a deterministic screenshake vector for visual tuning QA: [x] [y]")] public static void QaScreenshake(string intensityText = "100", string xText = "6", string yText = "0") { + if (!AkronModule.TryUse(AkronFeatureKind.Screenshake)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -372,6 +378,12 @@ public static void QaClickTeleport(string xText = "160", string yText = "90") { [Command("akron_qa_player_state", "set controlled player/session state for StartPos QA: [deaths] [time] [speed-x] [speed-y] [dashes] [stamina] [facing]")] public static void QaPlayerState(string xText = "", string yText = "", string deathsText = "", string timeText = "", string speedXText = "0", string speedYText = "0", string dashesText = "", string staminaText = "", string facingText = "") { + if (!AkronModule.TryUse(AkronFeatureKind.StartPosTools) || + !AkronModule.TryUse(AkronFeatureKind.MovementStatMutation) || + !AkronModule.TryUse(AkronFeatureKind.RoomWarp) || + !AkronModule.TryUse(AkronFeatureKind.SafeModeStats)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -435,6 +447,9 @@ public static void QaPlayerState(string xText = "", string yText = "", string de [Command("akron_qa_session_state", "set controlled room/session state for StartPos QA: ")] public static void QaSessionState(string flag = "akron_qa_flag", string counter = "akron_qa_counter", string valueText = "1") { + if (!AkronModule.TryUse(AkronFeatureKind.FlagInspector)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -454,6 +469,12 @@ public static void QaSessionState(string flag = "akron_qa_flag", string counter [Command("akron_qa_startpos_edge_capture", "set player/session edge state and capture StartPos in one frame: [flag] [counter] [value] [pixel-tag]")] public static void QaStartPosEdgeCapture(string slotText = "1", string xText = "", string yText = "", string speedXText = "0", string speedYText = "0", string dashesText = "1", string staminaText = "110", string facingText = "right", string flag = "akron_qa_flag", string counter = "akron_qa_counter", string valueText = "1", string pixelTag = "") { + if (!AkronModule.TryUse(AkronFeatureKind.StartPosTools) || + !AkronModule.TryUse(AkronFeatureKind.MovementStatMutation) || + !AkronModule.TryUse(AkronFeatureKind.RoomWarp) || + !AkronModule.TryUse(AkronFeatureKind.FlagInspector)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -1030,6 +1051,9 @@ public static void QaCutsceneState(string action = "active") { ";skip-callback=" + (callbackField?.GetValue(level) != null)); return; } + if (!AkronModule.TryUse(AkronFeatureKind.CutsceneSkip)) { + return; + } if (NormalizeToken(action) == "noskip") { // A cutscene flag with nothing stored to end it, the state Skip Cutscene must leave alone. @@ -1077,6 +1101,9 @@ public static void QaCutsceneState(string action = "active") { [Command("akron_qa_enter_level", "enter a vanilla level for Akron live automation: [area-id] [normal|b|c] [save-slot]")] public static void QaEnterLevel(string areaIdText = "0", string modeText = "normal", string slotText = "") { + if (!AkronModule.TryUse(AkronFeatureKind.RoomWarp)) { + return; + } if (!int.TryParse(areaIdText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int areaId)) { Log("qa-enter-level: invalid-area area=" + areaIdText); return; @@ -1109,6 +1136,9 @@ public static void QaEnterLevel(string areaIdText = "0", string modeText = "norm [Command("akron_qa_reenter_level", "enter a vanilla level with the current SaveData: [area-id] [normal|b|c]")] public static void QaReenterLevel(string areaIdText = "0", string modeText = "normal") { + if (!AkronModule.TryUse(AkronFeatureKind.RoomWarp)) { + return; + } if (SaveData.Instance == null) { Log("qa-reenter-level: missing-save-data"); return; @@ -1141,6 +1171,9 @@ public static void QaReenterLevel(string areaIdText = "0", string modeText = "no [Command("akron_qa_enter_map", "enter a loaded map for Akron live automation: [sid] [normal|b|c]")] public static void QaEnterMap(string sid = "", string modeText = "normal") { + if (!AkronModule.TryUse(AkronFeatureKind.RoomWarp)) { + return; + } if (string.IsNullOrWhiteSpace(sid)) { Log("qa-enter-map: missing sid"); return; @@ -1169,6 +1202,9 @@ public static void QaEnterMap(string sid = "", string modeText = "normal") { [Command("akron_qa_warp_room", "warp to a room in the current loaded map for Akron live automation: [room-name]")] public static void QaWarpRoom(string roomName = "") { + if (!AkronModule.TryUse(AkronFeatureKind.RoomWarp)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -1186,7 +1222,7 @@ public static void QaWarpRoom(string roomName = "") { } level.OnEndOfFrame += () => { - if (Engine.Scene != level) { + if (Engine.Scene != level || !AkronPolicy.CanUse(AkronFeatureKind.RoomWarp).Allowed) { return; } @@ -1210,6 +1246,9 @@ public static void QaWarpRoom(string roomName = "") { [Command("akron_qa_inspector_pin_world", "pin the entity inspector at a world coordinate for Akron live automation: x y")] public static void QaInspectorPinWorld(string x = "", string y = "") { + if (!AkronModule.TryUse(AkronFeatureKind.EntityInspector)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -1246,6 +1285,9 @@ public static void QaInspectorStackState(string _ = "") { [Command("akron_qa_inspector_controls", "exercise Entity Inspector copy/properties/close controls for Akron live automation")] public static void QaInspectorControls(string _ = "") { + if (!AkronModule.TryUse(AkronFeatureKind.EntityInspector)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -1256,6 +1298,9 @@ public static void QaInspectorControls(string _ = "") { [Command("akron_qa_inspector_scan_targets", "scan visible Entity Inspector targets for Akron live automation: [step-pixels] [limit] [max-per-type]")] public static void QaInspectorScanTargets(string stepPixels = "40", string limit = "80", string maxPerType = "8") { + if (!AkronModule.TryUse(AkronFeatureKind.EntityInspector)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -1275,6 +1320,9 @@ public static void QaInspectorScanTargets(string stepPixels = "40", string limit [Command("akron_qa_inspector_probe_screen", "show Entity Inspector hit-test diagnostics at screen coordinates: [x] [y]")] public static void QaInspectorProbeScreen(string x = "", string y = "") { + if (!AkronModule.TryUse(AkronFeatureKind.EntityInspector)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -1297,6 +1345,9 @@ public static void QaInspectorProbeScreen(string x = "", string y = "") { [Command("akron_qa_refill_clarity_probe", "spawn a custom refill-like QA probe and enable Refill Clarity: [x] [y] [one-use|reusable]")] public static void QaRefillClarityProbe(string x = "", string y = "", string mode = "one-use") { + if (!AkronModule.TryUse(AkronFeatureKind.EntitySpawn) || !AkronModule.TryUse(AkronFeatureKind.RefillClarity)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -1321,6 +1372,9 @@ public static void QaRefillClarityProbe(string x = "", string y = "", string mod [Command("akron_qa_refill_clarity_dash_crystal", "spawn a real dash crystal and enable Refill Clarity: [x] [y] [one-use|reusable] [one-dash|two-dash]")] public static void QaRefillClarityDashCrystal(string x = "", string y = "", string mode = "one-use", string dashMode = "one-dash") { + if (!AkronModule.TryUse(AkronFeatureKind.EntitySpawn) || !AkronModule.TryUse(AkronFeatureKind.RefillClarity)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -1620,6 +1674,9 @@ public static void QaFindMapEntities(string filter = "", string limitText = "80" [Command("akron_qa_invincibility_hazard", "invoke loaded hazard contact for QA: rising-lava|sandwich-lava|crush")] public static void QaInvincibilityHazard(string hazard = "rising-lava") { + if (!AkronModule.TryUse(AkronFeatureKind.RetryHotkey)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -2063,6 +2120,9 @@ public static void InputState(string _ = "") { [Command("akron_qa_cursor_zoom_frame", "apply one Cursor Zoom frame for live QA: [percent] [screen-x] [screen-y]")] public static void QaCursorZoomFrame(string percentText = "50", string screenXText = "640", string screenYText = "360") { + if (!AkronModule.TryUse(AkronFeatureKind.CursorZoom)) { + return; + } Level level = RequireLevel(); if (level == null) { return; @@ -2245,6 +2305,9 @@ public static void QaDeathVisualState(string _ = "") { [Command("akron_qa_freeze_frame", "trigger a Celeste freeze frame and report Engine.FreezeTimer: [seconds]")] public static void QaFreezeFrame(string secondsText = "0.2") { + if (!AkronModule.TryUse(AkronFeatureKind.FreezeFrames)) { + return; + } if (!float.TryParse(secondsText, NumberStyles.Float, CultureInfo.InvariantCulture, out float seconds)) { Log("usage: akron_qa_freeze_frame "); return; @@ -2310,18 +2373,27 @@ public static void QaProbe(string action = "") { switch (NormalizeToken(action)) { case "lowstamina": + if (!AkronModule.TryUse(AkronFeatureKind.MovementStatMutation)) { + return; + } // This intentionally creates the drain state that Infinite Stamina // should repair on the next normal Level.Update. player.Stamina = 1f; Log("qa-probe: low-stamina"); break; case "zerodash": + if (!AkronModule.TryUse(AkronFeatureKind.MovementStatMutation)) { + return; + } // This intentionally creates the depleted dash state that Infinite // Dash should repair on the next normal Level.Update. player.Dashes = 0; Log("qa-probe: zero-dash"); break; case "startdash": + if (!AkronModule.TryUse(AkronFeatureKind.MovementStatMutation) || !AkronModule.TryUse(AkronFeatureKind.InputAssistShortcut)) { + return; + } // Physical key injection is unreliable on the remote Xorg/Steam // stack, so this invokes Celeste's own dash entrypoint directly. // It still exercises the same Player.StartDash resource transition @@ -2333,12 +2405,18 @@ public static void QaProbe(string action = "") { Log("qa-probe-dash-state: " + dashState.ToString(CultureInfo.InvariantCulture)); break; case "rightspeed": + if (!AkronModule.TryUse(AkronFeatureKind.MovementStatMutation)) { + return; + } // This creates deterministic horizontal motion so freeze/pause // tests can compare player position instead of relying on timers. player.Speed = new Vector2(180f, 0f); Log("qa-probe: right-speed"); break; case "nearestspike": + if (!AkronModule.TryUse(AkronFeatureKind.RoomWarp)) { + return; + } if (!TryMovePlayerToNearestSpikes(level, player)) { Log("qa-probe: no spikes found"); return; @@ -2346,16 +2424,25 @@ public static void QaProbe(string action = "") { Log("qa-probe: nearest-spike"); break; case "forcedeath": + if (!AkronModule.TryUse(AkronFeatureKind.RetryHotkey)) { + return; + } // This exercises the same Player.Die hook used by spike hazards, // without depending on a specific room's spike geometry. player.Die(Vector2.UnitY, evenIfInvincible: false, registerDeathInStats: false); Log("qa-probe: force-death"); break; case "forcedeathstats": + if (!AkronModule.TryUse(AkronFeatureKind.RetryHotkey)) { + return; + } player.Die(Vector2.UnitY, evenIfInvincible: false, registerDeathInStats: true); Log("qa-probe: force-death-stats"); break; case "visualnoise": + if (!AkronModule.TryUse(AkronFeatureKind.ReducedVisualNoise)) { + return; + } Glitch.Value = 1f; Distort.Anxiety = 1f; Distort.GameRate = 0.5f; diff --git a/Source/Commands/akron-status-commands.cs b/Source/Commands/akron-status-commands.cs index 75c91249..ebbba076 100644 --- a/Source/Commands/akron-status-commands.cs +++ b/Source/Commands/akron-status-commands.cs @@ -13,6 +13,7 @@ public static void Status() { Player player = level?.Tracker.GetEntity(); AkronModuleSession session = AkronModule.Session; Log("scene: " + (Engine.Scene?.GetType().Name ?? "null")); + LogMapRestrictions(); Log("overlay: " + (AkronModule.IsOverlayVisible ? "visible" : "hidden")); Log("overlays: " + SafeStatusValue(() => AkronModule.Settings.DescribePresentationOverlays(), "unavailable")); Log("attempt: " + (session == null ? "unavailable" : AkronModuleSettings.FormatStatus(session.AttemptStatus))); @@ -428,4 +429,12 @@ public static void Status() { Log("dashes: " + player.Dashes.ToString(CultureInfo.InvariantCulture)); } } + + private static void LogMapRestrictions() { + Log("map-restriction-version: " + AkronPolicy.MapRestrictionVersion.ToString(CultureInfo.InvariantCulture)); + Log("map-restrictions: " + (AkronPolicy.HasMapRestrictions ? string.Join(",", AkronPolicy.RestrictedFeatures) : "none")); + Log("map-restriction-settings: configured values below are preserved; restricted effects are suppressed"); + Log("freeze-map-suppressed: " + AkronPolicy.IsMapRestricted(AkronFeatureKind.Freeze).ToString().ToLowerInvariant()); + Log("timescale-map-suppressed: " + AkronPolicy.IsMapRestricted(AkronFeatureKind.Timescale).ToString().ToLowerInvariant()); + } } diff --git a/Source/Core/AkronFeatureRegistry.cs b/Source/Core/AkronFeatureRegistry.cs index 511a869e..21364240 100644 --- a/Source/Core/AkronFeatureRegistry.cs +++ b/Source/Core/AkronFeatureRegistry.cs @@ -114,6 +114,25 @@ public static class AkronFeatureRegistry private static readonly FeatureDefinition?[] DefinitionByKind = BuildDefinitionByKind(); private static readonly AkronStatus[] ClassificationByKind = BuildClassificationByKind(); + private static readonly Dictionary KindByIdentity = BuildKindByIdentity(); + + // Map declarations use stable enum identities, never translated UI labels or numeric enum values. + public static bool TryResolveIdentity(string identity, out AkronFeatureKind kind) + { + return KindByIdentity.TryGetValue(identity ?? string.Empty, out kind); + } + + internal static int FeatureCapacity => DefinitionByKind.Length; + + private static Dictionary BuildKindByIdentity() + { + Dictionary identities = new(StringComparer.Ordinal); + foreach (AkronFeatureKind kind in Definitions.Keys) + { + identities.Add(kind.ToString(), kind); + } + return identities; + } // Row classifications come from the row's feature kind, so a row cannot show a class its // feature never records. Popup checkboxes have no kind of their own, which is why this diff --git a/Source/Core/AkronPolicy.cs b/Source/Core/AkronPolicy.cs index 1477842a..0d2cea5e 100644 --- a/Source/Core/AkronPolicy.cs +++ b/Source/Core/AkronPolicy.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Celeste; using Monocle; @@ -37,16 +38,79 @@ public static class AkronPolicy public const int UnclassifiedColorRgb = 0x909090; public const int SafeModeRedactedCleanColorRgb = 0x6495ED; - public static AkronPolicyDecision CanUse(AkronFeatureKind feature) + public const int MapRestrictionVersion = 1; + private const string MapRestrictionMessage = "Suppressed by this map. Your saved preference is unchanged."; + private static ConditionalWeakTable mapRestrictions = new(); + private static volatile AkronMapFeatureRestrictions activeMapRestrictions = AkronMapFeatureRestrictions.Empty; + [System.ThreadStatic] + private static AkronMapFeatureRestrictions loadingMapRestrictions; + private static AkronMapFeatureRestrictions CurrentMapRestrictions => loadingMapRestrictions ?? activeMapRestrictions; + + public static bool HasMapRestrictions => CurrentMapRestrictions.Features.Count != 0; + public static IReadOnlyList RestrictedFeatures => CurrentMapRestrictions.Features; + public static bool IsMapRestricted(AkronFeatureKind feature) => CurrentMapRestrictions.Contains(feature); + + internal static bool EnterMap(MapData map) + { + return SetMapRestrictions(GetMapRestrictions(map)); + } + + private static AkronMapFeatureRestrictions GetMapRestrictions(MapData map) => map == null + ? AkronMapFeatureRestrictions.Empty + : mapRestrictions.GetValue(map, CompileMapRestrictions); + + internal static AkronMapFeatureRestrictions EnterMapLoadScope(MapData map) + { + // Everest can construct the incoming room while the outgoing level still updates. + // Loader effects use the target declaration without replacing the live scene's policy. + AkronMapFeatureRestrictions previous = loadingMapRestrictions; + loadingMapRestrictions = GetMapRestrictions(map); + return previous; + } + + internal static void ExitMapLoadScope(AkronMapFeatureRestrictions previous) + { + loadingMapRestrictions = previous; + } + + private static AkronMapFeatureRestrictions CompileMapRestrictions(MapData map) + { + AkronMapFeatureRestrictions restrictions = new(map.Levels); + foreach (string identity in restrictions.UnknownIdentities) + { + Logger.Log(LogLevel.Warn, nameof(AkronModule), "Unknown Akron/featureRestrictions identity '" + identity + "'. Use an exact AkronFeatureKind name; this entry was ignored."); + } + return restrictions; + } + + private static bool SetMapRestrictions(AkronMapFeatureRestrictions restrictions) + { + if (ReferenceEquals(activeMapRestrictions, restrictions)) + { + return false; + } + activeMapRestrictions = restrictions; + return true; + } + + internal static bool LeaveMap() => SetMapRestrictions(AkronMapFeatureRestrictions.Empty); + + internal static void UnloadMapRestrictions() { - AkronModuleSettings settings = AkronModule.Settings; + LeaveMap(); + mapRestrictions = new(); + } - return new AkronPolicyDecision(true, AkronFeatureRegistry.Get(feature).Reason); + public static AkronPolicyDecision CanUse(AkronFeatureKind feature) + { + return IsMapRestricted(feature) + ? new AkronPolicyDecision(false, MapRestrictionMessage) + : new AkronPolicyDecision(true, AkronFeatureRegistry.Get(feature).Reason); } public static void RecordFeatureUse(AkronFeatureKind feature) { - if (AkronModule.Session == null) + if (AkronModule.Session == null || !CanUse(feature).Allowed) { return; } @@ -229,7 +293,7 @@ internal static bool IsLabelFeatureActive(bool labelsVisible, bool labelEnabled) private static void AddIfCheat(List contributors, bool enabled, string label, AkronFeatureKind feature) { - if (enabled && AkronFeatureRegistry.Classify(feature) == AkronStatus.Cheat) + if (enabled && CanUse(feature).Allowed && AkronFeatureRegistry.Classify(feature) == AkronStatus.Cheat) { contributors.Add(new AkronActiveCheatContributor(label, "Turn off " + label, feature)); } @@ -237,7 +301,7 @@ private static void AddIfCheat(List contributors, b private static void AddMotionSmoothingCheatContributor(List contributors, bool enabled, string label, string disableCommand) { - if (enabled) + if (enabled && CanUse(AkronFeatureKind.FpsBypass).Allowed) { contributors.Add(new AkronActiveCheatContributor(label, disableCommand, AkronFeatureKind.FpsBypass)); } @@ -245,7 +309,7 @@ private static void AddMotionSmoothingCheatContributor(List contributors) { - if (!AkronExtendedVariants.Available) + if (!AkronExtendedVariants.Available || !CanUse(AkronFeatureKind.ExtendedVariantMode).Allowed) { return; } diff --git a/Source/Core/AkronProof.cs b/Source/Core/AkronProof.cs index 1555e659..70d44307 100644 --- a/Source/Core/AkronProof.cs +++ b/Source/Core/AkronProof.cs @@ -35,8 +35,8 @@ public static string BuildSummaryJson(Level level, string eventName) { AkronModuleSession session = AkronModule.Session; List overlays = new List(); if (settings.StreamerMode) overlays.Add("Streamer Mode"); - if (settings.ProofModeOverlay) overlays.Add("Proof-mode"); - if (settings.IsLowDistractionActive()) overlays.Add("Low-distraction"); + if (settings.ProofModeOverlay && (!settings.SubmissionMode || AkronPolicy.CanUse(AkronFeatureKind.SubmissionMode).Allowed)) overlays.Add("Proof-mode"); + if (Effective(settings.IsLowDistractionActive(), AkronFeatureKind.ReducedVisualNoise)) overlays.Add("Low-distraction"); List activeFeatures = GetActiveFeatures(level).ToList(); StringBuilder builder = new StringBuilder(); @@ -45,7 +45,7 @@ public static string BuildSummaryJson(Level level, string eventName) { AppendJson(builder, "mapSid", level?.Session?.Area.GetSID() ?? "unknown", true); AppendJson(builder, "room", level?.Session?.Level ?? "unknown", true); AppendJson(builder, "classification", AkronPolicy.GetLegitimacySensitiveStatusLabel(session.AttemptStatus), true); - AppendJson(builder, "submissionMode", settings.SubmissionMode.ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "submissionMode", Effective(settings.SubmissionMode, AkronFeatureKind.SubmissionMode).ToString().ToLowerInvariant(), true, true); builder.Append(" \"presentationOverlays\": ["); for (int overlayIndex = 0; overlayIndex < overlays.Count; overlayIndex++) { if (overlayIndex > 0) builder.Append(", "); @@ -55,59 +55,59 @@ public static string BuildSummaryJson(Level level, string eventName) { AppendJson(builder, "reason", session.AttemptReason, true); builder.AppendLine(" \"activeOverrides\": {"); AppendJson(builder, "safeMode", settings.SafeMode.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "proofRecorderGuard", settings.ProofRecorderGuard.ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "proofRecorderGuard", Effective(settings.ProofRecorderGuard, AkronFeatureKind.ProofRecorderGuard).ToString().ToLowerInvariant(), true, true); AppendJson(builder, "recorderActive", AkronInternalRecorder.IsRecording.ToString().ToLowerInvariant(), true, true); AppendJson(builder, "replayBufferActive", AkronInternalRecorder.IsReplayBuffering.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "endScreenHelper", settings.EndScreenHelper.ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "endScreenHelper", Effective(settings.EndScreenHelper, AkronFeatureKind.EndScreenHelper).ToString().ToLowerInvariant(), true, true); AppendJson(builder, "recordingEndscreenDurationSeconds", settings.RecordingEndscreenDurationSeconds.ToString("0.00", CultureInfo.InvariantCulture), true, true); AppendJson(builder, "cleanLegitimacyAvailable", AkronPolicy.CanExposeCleanLegitimacy().ToString().ToLowerInvariant(), false, true); builder.AppendLine(" },"); builder.AppendLine(" \"activeFeatures\": {"); - AppendJson(builder, "roomLabels", settings.RoomLabels.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "staminaWidget", settings.StaminaWidget.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "speedWidget", settings.SpeedWidget.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "dashWidget", settings.DashWidget.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "inputViewer", settings.InputViewer.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "roomTimerWidget", settings.RoomTimerWidget.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "deathStatsWidget", settings.DeathStatsWidget.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "reducedVisualNoise", settings.ReducedVisualNoise.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "noParticles", settings.NoParticles.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "noTrails", settings.NoTrails.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "noGlitch", settings.NoGlitch.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "noAnxiety", settings.NoAnxiety.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "noDistortion", settings.NoDistortion.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "hideSnow", settings.HideSnow.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "hideWindSnow", settings.HideWindSnow.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "hideWaterfalls", settings.HideWaterfalls.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "hideTentacles", settings.HideTentacles.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "hideHeatDistortion", settings.HideHeatDistortion.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "hitboxViewer", settings.HitboxViewer.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "entityInspector", settings.EntityInspector.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "infiniteStamina", settings.InfiniteStamina.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "infiniteDash", settings.InfiniteDash.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "noclip", settings.Noclip.ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "roomLabels", Effective(settings.RoomLabels, AkronFeatureKind.RoomLabelOverlay).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "staminaWidget", Effective(settings.StaminaWidget, AkronFeatureKind.StaminaWidget).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "speedWidget", Effective(settings.SpeedWidget, AkronFeatureKind.SpeedWidget).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "dashWidget", Effective(settings.DashWidget, AkronFeatureKind.DashWidget).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "inputViewer", Effective(settings.InputViewer, AkronFeatureKind.InputViewer).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "roomTimerWidget", Effective(settings.RoomTimerWidget, AkronFeatureKind.RoomTimer).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "deathStatsWidget", Effective(settings.DeathStatsWidget, AkronFeatureKind.DeathStats).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "reducedVisualNoise", Effective(settings.ReducedVisualNoise, AkronFeatureKind.ReducedVisualNoise).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "noParticles", Effective(settings.NoParticles, AkronFeatureKind.ReducedVisualNoise).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "noTrails", Effective(settings.NoTrails, AkronFeatureKind.ReducedVisualNoise).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "noGlitch", Effective(settings.NoGlitch, AkronFeatureKind.ReducedVisualNoise).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "noAnxiety", Effective(settings.NoAnxiety, AkronFeatureKind.ReducedVisualNoise).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "noDistortion", Effective(settings.NoDistortion, AkronFeatureKind.ReducedVisualNoise).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "hideSnow", Effective(settings.HideSnow, AkronFeatureKind.ReducedVisualNoise).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "hideWindSnow", Effective(settings.HideWindSnow, AkronFeatureKind.ReducedVisualNoise).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "hideWaterfalls", Effective(settings.HideWaterfalls, AkronFeatureKind.ReducedVisualNoise).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "hideTentacles", Effective(settings.HideTentacles, AkronFeatureKind.ReducedVisualNoise).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "hideHeatDistortion", Effective(settings.HideHeatDistortion, AkronFeatureKind.ReducedVisualNoise).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "hitboxViewer", Effective(settings.HitboxViewer, AkronFeatureKind.HitboxViewer).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "entityInspector", Effective(settings.EntityInspector, AkronFeatureKind.EntityInspector).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "infiniteStamina", Effective(settings.InfiniteStamina, AkronFeatureKind.InfiniteStamina).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "infiniteDash", Effective(settings.InfiniteDash, AkronFeatureKind.InfiniteDash).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "noclip", Effective(settings.Noclip, AkronFeatureKind.Noclip).ToString().ToLowerInvariant(), true, true); AppendJson(builder, "noclipSpeed", settings.NoclipSpeed.ToString(), true, true); AppendJson(builder, "noclipFloatSpeed", settings.NoclipFloatSpeed.ToString(), true, true); - AppendJson(builder, "noclipDrawOnTop", settings.NoclipDrawOnTop.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "invincibility", settings.Invincibility.ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "noclipDrawOnTop", Effective(settings.NoclipDrawOnTop, AkronFeatureKind.Noclip).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "invincibility", Effective(settings.Invincibility, AkronFeatureKind.Invincibility).ToString().ToLowerInvariant(), true, true); AppendJson(builder, "invincibilityMode", AkronModuleSettings.NormalizeInvincibilityMode(settings.InvincibilityMode).ToString(), true); - AppendJson(builder, "invincibilityBottomlessFallRescue", settings.InvincibilityBottomlessFallRescue.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "invincibilityCrushCollisionChanges", settings.InvincibilityCrushCollisionChanges.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "invincibilityLavaIcePushback", settings.InvincibilityLavaIcePushback.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "invincibilitySpikeGroundRefills", settings.InvincibilitySpikeGroundRefills.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "freezeGameplay", session.FreezeGameplay.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "timescaleMultiplier", session.TimescaleMultiplier.ToString("0.0", CultureInfo.InvariantCulture), true, true); - AppendJson(builder, "respawnAtStartPos", settings.RespawnAtStartPos.ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "invincibilityBottomlessFallRescue", Effective(settings.InvincibilityBottomlessFallRescue, AkronFeatureKind.Invincibility).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "invincibilityCrushCollisionChanges", Effective(settings.InvincibilityCrushCollisionChanges, AkronFeatureKind.Invincibility).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "invincibilityLavaIcePushback", Effective(settings.InvincibilityLavaIcePushback, AkronFeatureKind.Invincibility).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "invincibilitySpikeGroundRefills", Effective(settings.InvincibilitySpikeGroundRefills, AkronFeatureKind.Invincibility).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "freezeGameplay", Effective(session.FreezeGameplay, AkronFeatureKind.Freeze).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "timescaleMultiplier", (AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed ? session.TimescaleMultiplier : 1f).ToString("0.0", CultureInfo.InvariantCulture), true, true); + AppendJson(builder, "respawnAtStartPos", Effective(settings.RespawnAtStartPos, AkronFeatureKind.StartPosTools).ToString().ToLowerInvariant(), true, true); AppendJson(builder, "tasFileConfigured", (!string.IsNullOrWhiteSpace(settings.TasFilePath)).ToString().ToLowerInvariant(), true, true); // Last entry in the block, so no trailing comma: the sidecar has to parse as JSON. AppendJson(builder, "brokeredStartPosState", session.UsedBrokeredSavestate.ToString().ToLowerInvariant(), false, true); builder.AppendLine(" },"); builder.AppendLine(" \"proofTelemetry\": {"); - AppendJson(builder, "pauseTrackerEnabled", settings.PauseTracker.ToString().ToLowerInvariant(), true, true); - AppendJson(builder, "pauseCount", session.PauseTrackerPauseCount.ToString(CultureInfo.InvariantCulture), true, true); - AppendJson(builder, "rapidPauseCount", session.PauseTrackerRapidPauseCount.ToString(CultureInfo.InvariantCulture), true, true); - AppendJson(builder, "pausedSeconds", session.PauseTrackerPausedSeconds.ToString("0.000", CultureInfo.InvariantCulture), true, true); - AppendJson(builder, "lagPauserEnabled", settings.LagPauser.ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "pauseTrackerEnabled", Effective(settings.PauseTracker, AkronFeatureKind.PauseTracker).ToString().ToLowerInvariant(), true, true); + AppendJson(builder, "pauseCount", (AkronPolicy.CanUse(AkronFeatureKind.PauseTracker).Allowed ? session.PauseTrackerPauseCount : 0).ToString(CultureInfo.InvariantCulture), true, true); + AppendJson(builder, "rapidPauseCount", (AkronPolicy.CanUse(AkronFeatureKind.PauseTracker).Allowed ? session.PauseTrackerRapidPauseCount : 0).ToString(CultureInfo.InvariantCulture), true, true); + AppendJson(builder, "pausedSeconds", (AkronPolicy.CanUse(AkronFeatureKind.PauseTracker).Allowed ? session.PauseTrackerPausedSeconds : 0f).ToString("0.000", CultureInfo.InvariantCulture), true, true); + AppendJson(builder, "lagPauserEnabled", Effective(settings.LagPauser, AkronFeatureKind.LagPauser).ToString().ToLowerInvariant(), true, true); AppendJson(builder, "lagPauserThresholdMs", settings.LagPauserThresholdMs.ToString(CultureInfo.InvariantCulture), true, true); AppendJson(builder, "lagPauserRecoveryGraceMs", settings.LagPauserRecoveryGraceMs.ToString(CultureInfo.InvariantCulture), true, true); AppendJson(builder, "lagPauserRepeatCooldownMs", settings.LagPauserRepeatCooldownMs.ToString(CultureInfo.InvariantCulture), true, true); @@ -118,7 +118,7 @@ public static string BuildSummaryJson(Level level, string eventName) { AppendJson(builder, "journalSnapshot", string.IsNullOrWhiteSpace(session.LastJournalSnapshotPath) ? "" : Path.GetFileName(session.LastJournalSnapshotPath), true); AppendJson(builder, "journalCompare", session.LastJournalCompareSummary, false); builder.AppendLine(" },"); - if (settings.MapVersionStamp) { + if (Effective(settings.MapVersionStamp, AkronFeatureKind.MapVersionStamp)) { builder.AppendLine(" \"mapVersionStamp\": {"); AppendJson(builder, "mapSid", level?.Session?.Area.GetSID() ?? "unknown", true); AppendJson(builder, "room", level?.Session?.Level ?? "unknown", true); @@ -181,53 +181,57 @@ private static string Escape(string value) { return value.Replace("\\", "\\\\").Replace("\"", "\\\""); } + private static bool Effective(bool enabled, AkronFeatureKind feature) { + return enabled && AkronPolicy.CanUse(feature).Allowed; + } + private static IEnumerable GetActiveFeatures(Level level) { AkronModuleSettings settings = AkronModule.Settings; AkronModuleSession session = AkronModule.Session; - if (settings.RoomLabels) yield return "RoomLabels"; - if (settings.SubmissionMode) yield return "SubmissionMode"; - if (settings.ProofRecorderGuard) yield return "ProofRecorderGuard"; - if (settings.EndScreenHelper) yield return "EndScreenHelper"; - if (settings.PauseTracker) yield return "PauseTracker"; - if (settings.MapVersionStamp) yield return "MapVersionStamp"; + if (Effective(settings.RoomLabels, AkronFeatureKind.RoomLabelOverlay)) yield return "RoomLabels"; + if (Effective(settings.SubmissionMode, AkronFeatureKind.SubmissionMode)) yield return "SubmissionMode"; + if (Effective(settings.ProofRecorderGuard, AkronFeatureKind.ProofRecorderGuard)) yield return "ProofRecorderGuard"; + if (Effective(settings.EndScreenHelper, AkronFeatureKind.EndScreenHelper)) yield return "EndScreenHelper"; + if (Effective(settings.PauseTracker, AkronFeatureKind.PauseTracker)) yield return "PauseTracker"; + if (Effective(settings.MapVersionStamp, AkronFeatureKind.MapVersionStamp)) yield return "MapVersionStamp"; if (session.UsedGoldenStartHelper) yield return "GoldenStartHelper"; - if (settings.GoldenTransparency) yield return "GoldenTransparency"; - if (settings.LagPauser) yield return "LagPauser"; + if (Effective(settings.GoldenTransparency, AkronFeatureKind.GoldenTransparency)) yield return "GoldenTransparency"; + if (Effective(settings.LagPauser, AkronFeatureKind.LagPauser)) yield return "LagPauser"; if (session.UsedJournalSnapshotCompare) yield return "JournalSnapshotCompare"; - if (settings.StaminaWidget) yield return "StaminaWidget"; - if (settings.SpeedWidget) yield return "SpeedWidget"; - if (settings.DashWidget) yield return "DashWidget"; - if (settings.InputViewer) yield return "InputViewer"; - if (settings.RoomTimerWidget) yield return "RoomTimerWidget"; - if (settings.DeathStatsWidget) yield return "DeathStatsWidget"; - if (settings.ReducedVisualNoise) yield return "ReducedVisualNoise"; - if (settings.NoParticles) yield return "NoParticles"; - if (settings.NoTrails) yield return "NoTrails"; - if (settings.NoGlitch) yield return "NoGlitch"; - if (settings.NoAnxiety) yield return "NoAnxiety"; - if (settings.NoDistortion) yield return "NoDistortion"; - if (settings.HideSnow) yield return "HideSnow"; - if (settings.HideWindSnow) yield return "HideWindSnow"; - if (settings.HideWaterfalls) yield return "HideWaterfalls"; - if (settings.HideTentacles) yield return "HideTentacles"; - if (settings.HideHeatDistortion) yield return "HideHeatDistortion"; - if (settings.HitboxViewer) yield return "HitboxViewer"; - if (settings.EntityInspector) yield return "EntityInspector"; - if (settings.InfiniteStamina) yield return "InfiniteStamina"; - if (settings.InfiniteDash) yield return "InfiniteDash"; - if (settings.Noclip) yield return "Noclip"; - if (settings.Invincibility) yield return "Invincibility"; - if (session.FreezeGameplay) yield return "FreezeGameplay"; - if (session.TimescaleEnabled && session.TimescaleMultiplier != 1f) yield return "Timescale"; - if (settings.DisablePlayback) yield return "DisablePlayback"; - if (settings.NoStaminaFlash) yield return "NoStaminaFlash"; - if (settings.JumpHack) yield return "AirJumps"; - if (settings.DashRedirectEnabled) yield return "DashRedirect"; - if (settings.GrabModeOverrideEnabled) yield return "GrabMode"; - if (settings.RespawnAtStartPos) yield return "StartPosRespawn"; + if (Effective(settings.StaminaWidget, AkronFeatureKind.StaminaWidget)) yield return "StaminaWidget"; + if (Effective(settings.SpeedWidget, AkronFeatureKind.SpeedWidget)) yield return "SpeedWidget"; + if (Effective(settings.DashWidget, AkronFeatureKind.DashWidget)) yield return "DashWidget"; + if (Effective(settings.InputViewer, AkronFeatureKind.InputViewer)) yield return "InputViewer"; + if (Effective(settings.RoomTimerWidget, AkronFeatureKind.RoomTimer)) yield return "RoomTimerWidget"; + if (Effective(settings.DeathStatsWidget, AkronFeatureKind.DeathStats)) yield return "DeathStatsWidget"; + if (Effective(settings.ReducedVisualNoise, AkronFeatureKind.ReducedVisualNoise)) yield return "ReducedVisualNoise"; + if (Effective(settings.NoParticles, AkronFeatureKind.ReducedVisualNoise)) yield return "NoParticles"; + if (Effective(settings.NoTrails, AkronFeatureKind.ReducedVisualNoise)) yield return "NoTrails"; + if (Effective(settings.NoGlitch, AkronFeatureKind.ReducedVisualNoise)) yield return "NoGlitch"; + if (Effective(settings.NoAnxiety, AkronFeatureKind.ReducedVisualNoise)) yield return "NoAnxiety"; + if (Effective(settings.NoDistortion, AkronFeatureKind.ReducedVisualNoise)) yield return "NoDistortion"; + if (Effective(settings.HideSnow, AkronFeatureKind.ReducedVisualNoise)) yield return "HideSnow"; + if (Effective(settings.HideWindSnow, AkronFeatureKind.ReducedVisualNoise)) yield return "HideWindSnow"; + if (Effective(settings.HideWaterfalls, AkronFeatureKind.ReducedVisualNoise)) yield return "HideWaterfalls"; + if (Effective(settings.HideTentacles, AkronFeatureKind.ReducedVisualNoise)) yield return "HideTentacles"; + if (Effective(settings.HideHeatDistortion, AkronFeatureKind.ReducedVisualNoise)) yield return "HideHeatDistortion"; + if (Effective(settings.HitboxViewer, AkronFeatureKind.HitboxViewer)) yield return "HitboxViewer"; + if (Effective(settings.EntityInspector, AkronFeatureKind.EntityInspector)) yield return "EntityInspector"; + if (Effective(settings.InfiniteStamina, AkronFeatureKind.InfiniteStamina)) yield return "InfiniteStamina"; + if (Effective(settings.InfiniteDash, AkronFeatureKind.InfiniteDash)) yield return "InfiniteDash"; + if (Effective(settings.Noclip, AkronFeatureKind.Noclip)) yield return "Noclip"; + if (Effective(settings.Invincibility, AkronFeatureKind.Invincibility)) yield return "Invincibility"; + if (Effective(session.FreezeGameplay, AkronFeatureKind.Freeze)) yield return "FreezeGameplay"; + if (Effective(session.TimescaleEnabled && session.TimescaleMultiplier != 1f, AkronFeatureKind.Timescale)) yield return "Timescale"; + if (Effective(settings.DisablePlayback, AkronFeatureKind.DisablePlayback)) yield return "DisablePlayback"; + if (Effective(settings.NoStaminaFlash, AkronFeatureKind.ReducedVisualNoise)) yield return "NoStaminaFlash"; + if (Effective(settings.JumpHack, AkronFeatureKind.MovementStatMutation)) yield return "AirJumps"; + if (Effective(settings.DashRedirectEnabled, AkronFeatureKind.InputAssistShortcut)) yield return "DashRedirect"; + if (Effective(settings.GrabModeOverrideEnabled, AkronFeatureKind.GrabModeHotkey)) yield return "GrabMode"; + if (Effective(settings.RespawnAtStartPos, AkronFeatureKind.StartPosTools)) yield return "StartPosRespawn"; if (session.UsedBrokeredSavestate) yield return "BrokeredStartPosState"; - if (!string.IsNullOrWhiteSpace(settings.TasFilePath)) yield return "TasHandoff"; + if (Effective(!string.IsNullOrWhiteSpace(settings.TasFilePath), AkronFeatureKind.TasHandoff)) yield return "TasHandoff"; if (!string.IsNullOrWhiteSpace(session.LastScreenshotPath)) yield return "ScreenshotTool"; } diff --git a/Source/Core/akron-log.cs b/Source/Core/akron-log.cs index b6ab10f6..a6e981a2 100644 --- a/Source/Core/akron-log.cs +++ b/Source/Core/akron-log.cs @@ -170,7 +170,7 @@ public static void RecordPolicyCheck(AkronFeatureKind feature, AkronPolicyDecisi // Runs once per rendered frame per feature, so the settings lookup is one property read and the // early return allocates nothing. AkronModuleSettings settings = ResolveSettings(); - if (settings == null) { + if (settings == null || !AkronPolicy.CanUse(AkronFeatureKind.Logging).Allowed) { return; } @@ -193,7 +193,7 @@ public static void RecordPolicyCheck(AkronFeatureKind feature, AkronPolicyDecisi public static void RecordFeatureUse(AkronFeatureKind feature) { AkronModuleSettings settings = ResolveSettings(); - if (settings == null) { + if (settings == null || !AkronPolicy.CanUse(AkronFeatureKind.Logging).Allowed) { return; } @@ -263,7 +263,7 @@ private static string FormatDiagnosticFeatureCounts(string label, IReadOnlyDicti private static void Write(AkronLoggingLevel level, string source, string message, LogLevel? mirrorLogLevel) { AkronModuleSettings settings = ResolveSettings(); - if (settings == null) { + if (settings == null || !AkronPolicy.CanUse(AkronFeatureKind.Logging).Allowed) { return; } diff --git a/Source/Core/akron-map-feature-restrictions.cs b/Source/Core/akron-map-feature-restrictions.cs new file mode 100644 index 00000000..58b6927f --- /dev/null +++ b/Source/Core/akron-map-feature-restrictions.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using Celeste.Mod.Entities; +using Microsoft.Xna.Framework; +using Monocle; + +namespace Celeste.Mod.Akron; + +// The entity is only an authoring marker. Policy reads every room's map data before +// entities are constructed, so its position, spawn flags and current room cannot bypass it. +[CustomEntity(AkronMapFeatureRestrictions.EntityName)] +public sealed class AkronFeatureRestrictionsEntity : Entity +{ + public AkronFeatureRestrictionsEntity(EntityData data, Vector2 offset) : base(data.Position + offset) + { + Active = false; + Visible = false; + } +} + +internal sealed class AkronMapFeatureRestrictions +{ + internal const string EntityName = "Akron/featureRestrictions"; + internal static readonly AkronMapFeatureRestrictions Empty = new(Array.Empty()); + private readonly bool[] restricted = new bool[AkronFeatureRegistry.FeatureCapacity]; + + internal IReadOnlyList Features { get; } + internal IReadOnlyList UnknownIdentities { get; } + + internal AkronMapFeatureRestrictions(IEnumerable rooms) + { + List features = new(); + List unknown = new(); + foreach (LevelData room in rooms) + { + if (room.Entities == null) + { + continue; + } + foreach (EntityData entity in room.Entities) + { + if (entity.Name != EntityName) + { + continue; + } + string declaration = entity.Values != null && entity.Values.TryGetValue("features", out object value) + ? value as string ?? string.Empty + : string.Empty; + foreach (string entry in declaration.Split(',')) + { + string identity = entry.Trim(); + if (identity.Length == 0) + { + continue; + } + if (!AkronFeatureRegistry.TryResolveIdentity(identity, out AkronFeatureKind feature)) + { + unknown.Add(identity); + continue; + } + if (!restricted[(int)feature]) + { + restricted[(int)feature] = true; + features.Add(feature); + } + } + } + } + Features = features.AsReadOnly(); + UnknownIdentities = unknown.AsReadOnly(); + } + + internal bool Contains(AkronFeatureKind feature) + { + int index = (int)feature; + return index >= 0 && index < restricted.Length && restricted[index]; + } +} diff --git a/Source/Hud/AkronHudRenderer.cs b/Source/Hud/AkronHudRenderer.cs index 3fbf435a..e1a7832f 100644 --- a/Source/Hud/AkronHudRenderer.cs +++ b/Source/Hud/AkronHudRenderer.cs @@ -60,7 +60,7 @@ public static void Render(Level level, bool ignoreDeathWipeSuppression = false) RenderPresentationOverlayChips(level); - if (settings.HideAkronHud) { + if (settings.HideAkronHud && AkronPolicy.CanUse(AkronFeatureKind.HudVisibility).Allowed) { return; } @@ -71,12 +71,12 @@ public static void Render(Level level, bool ignoreDeathWipeSuppression = false) // bars and numbers have no Labels-tab row and keep a fixed place above the ordered // stack, which WalkLabelStack draws in the player's row order. if (player != null && (settings.StaminaBar || settings.ResourceBars && settings.ResourceStaminaBar) && - AkronModule.TryUse(AkronFeatureKind.ResourceBars)) { + AkronModule.TryUseRuntime(AkronFeatureKind.ResourceBars)) { RenderStaminaBars(level, player, HudEdgePadding, ref y); } if (player != null && (settings.DashBar || settings.ResourceBars && settings.ResourceDashPips) && - AkronModule.TryUse(AkronFeatureKind.ResourceBars)) { + AkronModule.TryUseRuntime(AkronFeatureKind.ResourceBars)) { RenderDashBar(level, player, HudEdgePadding, ref y); } @@ -88,34 +88,34 @@ public static void Render(Level level, bool ignoreDeathWipeSuppression = false) // "Displays already-current player speed above Madeline" - on installs with the // speed number off. Every other element on this screen short-circuits the same way. if (player != null && settings.DashNumber && - ShouldRenderDashNumber(settings, AkronModule.TryUse(AkronFeatureKind.ResourceBars))) { + ShouldRenderDashNumber(settings, AkronModule.TryUseRuntime(AkronFeatureKind.ResourceBars))) { RenderDashNumber(level, player); } if (player != null && settings.SpeedNumber && - ShouldRenderSpeedNumber(settings, AkronModule.TryUse(AkronFeatureKind.SpeedNumber))) { + ShouldRenderSpeedNumber(settings, AkronModule.TryUseRuntime(AkronFeatureKind.SpeedNumber))) { RenderSpeedNumber(level, player); } - if (settings.ShowTaps && AkronModule.TryUse(AkronFeatureKind.ShowTaps)) { + if (settings.ShowTaps && AkronModule.TryUseRuntime(AkronFeatureKind.ShowTaps)) { RenderTapDisplay(ref y); } if (labelsVisible) { - WalkLabelStack(level, player, settings, AkronModule.TryUse, new DrawLabelStackSink(level, player), ref y); + WalkLabelStack(level, player, settings, AkronModule.TryUseRuntime, new DrawLabelStackSink(level, player), ref y); } - if (labelsVisible && AkronSaveLoadService.HasSlot(settings.ActiveSavestateSlot)) { + if (labelsVisible && AkronPolicy.CanUse(AkronFeatureKind.Savestates).Allowed && AkronSaveLoadService.HasSlot(settings.ActiveSavestateSlot)) { DrawText("SRT slot " + settings.ActiveSavestateSlot + ": saved", HudEdgePadding, ref y, Color.White); } - if (labelsVisible && settings.EntityInspector && AkronModule.TryUse(AkronFeatureKind.EntityInspector)) { + if (labelsVisible && settings.EntityInspector && AkronModule.TryUseRuntime(AkronFeatureKind.EntityInspector)) { DrawText("Entity: " + AkronEntityInspector.Describe(level), HudEdgePadding, ref y, Color.White); } if (labelsVisible && (settings.InputHistoryPanel || settings.InputHistoryShowOnDeath && AkronInputHistory.DeathPinned) && - AkronModule.TryUse(AkronFeatureKind.InputHistory)) { + AkronModule.TryUseRuntime(AkronFeatureKind.InputHistory)) { RenderInputHistory(ref y); } } finally { @@ -170,7 +170,7 @@ private static IEnumerable FormatRoomStatTracker(Level level) { private static void RenderTriggerViewer(Level level) { if (level == null || !AkronModule.Settings.ShowTriggers || - !AkronModule.TryUse(AkronFeatureKind.TriggerViewer)) { + !AkronModule.TryUseRuntime(AkronFeatureKind.TriggerViewer)) { return; } @@ -182,7 +182,7 @@ private static void RenderTriggerViewer(Level level) { } private static void RenderPauseCountdown() { - if (!AkronModule.IsPauseCountdownActive) { + if (!AkronModule.IsPauseCountdownActive || !AkronPolicy.CanUse(AkronFeatureKind.PauseCountdown).Allowed) { return; } @@ -206,7 +206,7 @@ private static bool ShouldShowIndicator() { AkronModuleSettings settings = AkronModule.Settings; if (settings == null || !settings.LabelSystemVisible || - settings.HideAkronHud || + settings.HideAkronHud && AkronPolicy.CanUse(AkronFeatureKind.HudVisibility).Allowed || !settings.HudCheatIndicator) { return false; } @@ -294,11 +294,12 @@ private static float TopLeftIndicatorReservedHeight() { private static void RenderPresentationOverlayChips(Level level) { List<(string Label, Color Color)> chips = new List<(string Label, Color Color)>(); - if (AkronModule.Settings.ProofModeOverlay) { + if (AkronModule.Settings.ProofModeOverlay && + (!AkronModule.Settings.SubmissionMode || AkronPolicy.CanUse(AkronFeatureKind.SubmissionMode).Allowed)) { chips.Add(("Proof-mode", Color.CornflowerBlue)); } - if (AkronModule.Settings.IsLowDistractionActive()) { + if (AkronModule.Settings.IsLowDistractionActive() && AkronPolicy.CanUse(AkronFeatureKind.ReducedVisualNoise).Allowed) { chips.Add(("Low-distraction", Color.SlateBlue)); } diff --git a/Source/Hud/akron-custom-hud-labels.cs b/Source/Hud/akron-custom-hud-labels.cs index 063cfc8f..d04a36e8 100644 --- a/Source/Hud/akron-custom-hud-labels.cs +++ b/Source/Hud/akron-custom-hud-labels.cs @@ -35,7 +35,7 @@ public static void Render(Level level, Player player, ref float y, float? screen if (!AkronModule.Settings.LabelSystemVisible || !AkronModule.Settings.CustomHudLabels || AkronModule.IsOverlayVisible || - !AkronModule.TryUse(AkronFeatureKind.CustomHudLabels)) { + !AkronModule.TryUseRuntime(AkronFeatureKind.CustomHudLabels)) { return; } diff --git a/Source/Hud/akron-hud-label-obstruction.cs b/Source/Hud/akron-hud-label-obstruction.cs index 0799681a..2fa825ad 100644 --- a/Source/Hud/akron-hud-label-obstruction.cs +++ b/Source/Hud/akron-hud-label-obstruction.cs @@ -119,11 +119,11 @@ private static List BuildHudLabelObstructionPlans(Level List plans = sink.Plans; WalkLabelStack(level, player, settings, kind => AkronPolicy.CanUse(kind).Allowed, sink, ref y); - if (AkronSaveLoadService.HasSlot(settings.ActiveSavestateSlot)) { + if (AkronPolicy.CanUse(AkronFeatureKind.Savestates).Allowed && AkronSaveLoadService.HasSlot(settings.ActiveSavestateSlot)) { plans.Add(BuildTextPlan("SRT slot " + settings.ActiveSavestateSlot + ": saved", HudEdgePadding, ref y, null)); } - if (settings.EntityInspector) { + if (settings.EntityInspector && AkronPolicy.CanUse(AkronFeatureKind.EntityInspector).Allowed) { plans.Add(BuildTextPlan("Entity: " + AkronEntityInspector.Describe(level), HudEdgePadding, ref y, null)); } diff --git a/Source/Hud/akron-hud-label-stack.cs b/Source/Hud/akron-hud-label-stack.cs index e76496dc..e0ef0a0d 100644 --- a/Source/Hud/akron-hud-label-stack.cs +++ b/Source/Hud/akron-hud-label-stack.cs @@ -19,7 +19,7 @@ private interface ILabelStackSink { void CustomLabels(ref float y); } - // The renderer passes AkronModule.TryUse so every drawn frame records; the planner passes + // The renderer passes AkronModule.TryUseRuntime so every drawn frame records; the planner passes // a CanUse check so measuring a label never records a use. private delegate bool LabelUseGate(AkronFeatureKind kind); diff --git a/Source/Hud/akron-hud-resource-renderer.cs b/Source/Hud/akron-hud-resource-renderer.cs index e075146f..e7220ff5 100644 --- a/Source/Hud/akron-hud-resource-renderer.cs +++ b/Source/Hud/akron-hud-resource-renderer.cs @@ -21,7 +21,7 @@ public static partial class AkronHudRenderer { private static float frameCurrentStamina = MaxStamina; private static void RenderStaminaBars(Level level, Player player, float x, ref float y) { - if (!AkronModule.TryUse(AkronFeatureKind.ResourceBars)) { + if (!AkronModule.TryUseRuntime(AkronFeatureKind.ResourceBars)) { return; } @@ -178,7 +178,7 @@ private static void DrawRingArcLines(Vector2 center, float radius, float thickne } private static void RenderDashBar(Level level, Player player, float x, ref float y) { - if (!AkronModule.TryUse(AkronFeatureKind.ResourceBars)) { + if (!AkronModule.TryUseRuntime(AkronFeatureKind.ResourceBars)) { return; } diff --git a/Source/Hud/akron-hud-trajectory-renderer.cs b/Source/Hud/akron-hud-trajectory-renderer.cs index 4310095f..8ac12d71 100644 --- a/Source/Hud/akron-hud-trajectory-renderer.cs +++ b/Source/Hud/akron-hud-trajectory-renderer.cs @@ -13,7 +13,7 @@ private static void RenderTrajectory(Level level, Player player) { player.Dead || AkronRuntimeOptions.IsFreeCameraActive(level) || !AkronModule.Settings.ShowTrajectory || - !AkronModule.TryUse(AkronFeatureKind.ShowTrajectory)) { + !AkronModule.TryUseRuntime(AkronFeatureKind.ShowTrajectory)) { return; } diff --git a/Source/Hud/akron-hud-world-overlay-renderer.cs b/Source/Hud/akron-hud-world-overlay-renderer.cs index 04dd00e8..a7674765 100644 --- a/Source/Hud/akron-hud-world-overlay-renderer.cs +++ b/Source/Hud/akron-hud-world-overlay-renderer.cs @@ -23,7 +23,7 @@ public static partial class AkronHudRenderer { private static void RenderAutoKillArea(Level level) { AkronModuleSettings settings = AkronModule.Settings; - if (AkronCapture.IsCapturingGameFrame) { + if (AkronCapture.IsCapturingGameFrame || !AkronPolicy.CanUse(AkronFeatureKind.AutoKill).Allowed) { return; } @@ -70,7 +70,7 @@ private static void RenderAutoKillArea(Level level) { private static void RenderAutoDeafenArea(Level level) { AkronModuleSettings settings = AkronModule.Settings; - if (AkronCapture.IsCapturingGameFrame || level == null) { + if (AkronCapture.IsCapturingGameFrame || level == null || !AkronPolicy.CanUse(AkronFeatureKind.AutoDeafen).Allowed) { return; } diff --git a/Source/Interop/AkronMotionSmoothingInterop.cs b/Source/Interop/AkronMotionSmoothingInterop.cs index 3ff8d4d8..60a49df7 100644 --- a/Source/Interop/AkronMotionSmoothingInterop.cs +++ b/Source/Interop/AkronMotionSmoothingInterop.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; using Celeste.Mod; @@ -17,6 +18,7 @@ public static class AkronMotionSmoothingInterop { private static bool warnedApplyFailure; private static bool modulesProbeFailed; private static bool? loaded; + private static readonly Dictionary overrides = new(); public static bool Loaded => loaded ??= IsModLoaded(); @@ -32,23 +34,26 @@ public static void ApplyAkronSettings() { } AkronFrameBypassRates rates = AkronRuntimeOptions.ResolveCurrentFrameBypassRates(); - Set(settings, "Enabled", rates.Active); - Set(settings, "FrameRate", rates.DrawRate); + bool fpsAllowed = AkronPolicy.CanUse(AkronFeatureKind.FpsBypass).Allowed; + bool tpsAllowed = AkronPolicy.CanUse(AkronFeatureKind.TpsBypass).Allowed; + bool ownsEnabled = fpsAllowed && AkronModule.Settings.FpsBypass || tpsAllowed && AkronModule.Settings.TpsBypass; + Set(settings, "Enabled", rates.Active, fpsAllowed && tpsAllowed || ownsEnabled); + Set(settings, "FrameRate", rates.DrawRate, fpsAllowed); SetEnum(settings, "UnlockCameraStrategy", AkronModule.Settings.FrameBypassCameraSmoothing switch { AkronCameraSmoothingMode.Fancy => "Hires", AkronCameraSmoothingMode.Fast => "Unlock", _ => "Off" - }); - Set(settings, "RenderMadelineWithSubpixels", AkronModule.Settings.FrameBypassSubpixelMadeline); - Set(settings, "RenderBackgroundHires", AkronModule.Settings.FrameBypassSmoothBackground); - Set(settings, "RenderForegroundHires", AkronModule.Settings.FrameBypassSmoothForeground); - Set(settings, "HideStretchedEdges", AkronModule.Settings.FrameBypassHideStretchedEdges); - SetEnum(settings, "ObjectSmoothing", AkronModule.Settings.FrameBypassObjectSmoothing.ToString()); - SetEnum(settings, "FramerateIncreaseMethod", AkronModule.Settings.FrameBypassMethod.ToString()); - Set(settings, "TasMode", AkronModule.Settings.FrameBypassTasMode); - Set(settings, "SillyMode", AkronModule.Settings.FrameBypassSillyMode); - Set(settings, "GameSpeed", (double) rates.UpdateRate); - Set(settings, "GameSpeedInLevelOnly", true); + }, fpsAllowed); + Set(settings, "RenderMadelineWithSubpixels", AkronModule.Settings.FrameBypassSubpixelMadeline, fpsAllowed); + Set(settings, "RenderBackgroundHires", AkronModule.Settings.FrameBypassSmoothBackground, fpsAllowed); + Set(settings, "RenderForegroundHires", AkronModule.Settings.FrameBypassSmoothForeground, fpsAllowed); + Set(settings, "HideStretchedEdges", AkronModule.Settings.FrameBypassHideStretchedEdges, fpsAllowed); + SetEnum(settings, "ObjectSmoothing", AkronModule.Settings.FrameBypassObjectSmoothing.ToString(), fpsAllowed); + SetEnum(settings, "FramerateIncreaseMethod", AkronModule.Settings.FrameBypassMethod.ToString(), fpsAllowed); + Set(settings, "TasMode", AkronModule.Settings.FrameBypassTasMode, fpsAllowed); + Set(settings, "SillyMode", AkronModule.Settings.FrameBypassSillyMode, fpsAllowed); + Set(settings, "GameSpeed", (double) rates.UpdateRate, tpsAllowed); + Set(settings, "GameSpeedInLevelOnly", true, tpsAllowed); ApplySettings(); } catch (Exception exception) { if (warnedApplyFailure) { @@ -60,6 +65,48 @@ public static void ApplyAkronSettings() { } } + public static void RestoreOriginalSettings() { + if (overrides.Count == 0) { + return; + } + object settings = GetSettings(); + if (settings == null) { + return; + } + foreach (var saved in overrides.Values) { + if (Equals(saved.Property.GetValue(settings), saved.Applied)) { + saved.Property.SetValue(settings, saved.Original); + } + } + overrides.Clear(); + ApplySettings(); + } + + private static void Set(object settings, string propertyName, object value, bool allowed) { + settingsType ??= settings.GetType(); + PropertyInfo property = settingsType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + if (property == null || !property.CanRead || !property.CanWrite) { + return; + } + if (!allowed) { + if (overrides.Remove(propertyName, out var saved) && Equals(property.GetValue(settings), saved.Applied)) { + property.SetValue(settings, saved.Original); + } + return; + } + object original = overrides.TryGetValue(propertyName, out var previous) ? previous.Original : property.GetValue(settings); + overrides[propertyName] = (property, original, value); + property.SetValue(settings, value); + } + + private static void SetEnum(object settings, string propertyName, string valueName, bool allowed) { + settingsType ??= settings.GetType(); + PropertyInfo property = settingsType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + if (property != null) { + Set(settings, propertyName, allowed ? Enum.Parse(property.PropertyType, valueName) : null, allowed); + } + } + private static object GetSettings() { if (!Loaded) { return null; @@ -106,22 +153,6 @@ private static object FindLoadedModuleInstance() { } } - private static void Set(object settings, string propertyName, object value) { - settingsType ??= settings.GetType(); - PropertyInfo property = settingsType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); - property?.SetValue(settings, value); - } - - private static void SetEnum(object settings, string propertyName, string valueName) { - settingsType ??= settings.GetType(); - PropertyInfo property = settingsType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); - if (property == null) { - return; - } - - object value = Enum.Parse(property.PropertyType, valueName); - property.SetValue(settings, value); - } private static bool IsModLoaded() { return AppDomain.CurrentDomain.GetAssemblies().Any(assembly => string.Equals(assembly.GetName().Name, "MotionSmoothing", StringComparison.OrdinalIgnoreCase)) || diff --git a/Source/Interop/AkronSpeedrunToolBroker.cs b/Source/Interop/AkronSpeedrunToolBroker.cs index fe541f55..2717ab05 100644 --- a/Source/Interop/AkronSpeedrunToolBroker.cs +++ b/Source/Interop/AkronSpeedrunToolBroker.cs @@ -33,6 +33,10 @@ public static AkronSaveLoadResult Save(int slot) { } public static AkronSaveLoadResult Save(string slotName) { + if (!AkronPolicy.CanUse(AkronFeatureKind.BrokeredSavestates).Allowed) { + return AkronSaveLoadResult.Blocked; + } + if (!Available) { return AkronSaveLoadResult.BrokerUnavailable; } @@ -48,6 +52,10 @@ public static AkronSaveLoadResult Load(int slot) { } public static AkronSaveLoadResult Load(string slotName) { + if (!AkronPolicy.CanUse(AkronFeatureKind.BrokeredSavestates).Allowed) { + return AkronSaveLoadResult.Blocked; + } + if (!Available) { return AkronSaveLoadResult.BrokerUnavailable; } diff --git a/Source/Interop/akron-extended-variants.cs b/Source/Interop/akron-extended-variants.cs index 6d78d4dd..62542acb 100644 --- a/Source/Interop/akron-extended-variants.cs +++ b/Source/Interop/akron-extended-variants.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Reflection; using Celeste.Mod; +using MonoMod.RuntimeDetour; using Monocle; namespace Celeste.Mod.Akron; @@ -42,6 +43,274 @@ public static class AkronExtendedVariants { private static Dictionary optionMetadataByName; private static Dictionary optionMetadataByLabel; private static IReadOnlyList optionMetadata; + private static ExternalState originalState; + private static ExternalState lastAppliedState; + private static Hook randomizerHook; + private static ExternalState suppressedState; + private static readonly HashSet ownedSettings = new(); + private static readonly HashSet ownedVariants = new(); + private static readonly HashSet ownedAssistFields = new(); + private static readonly FieldInfo[] AssistFields = typeof(global::Celeste.Assists).GetFields(BindingFlags.Instance | BindingFlags.Public); + + // These are the user-owned EVM surfaces Akron writes. Trigger dictionaries + // belong to the map and are deliberately absent from the snapshot. + private static readonly string[] OwnedSettingNames = { + "MasterSwitch", "ChangeVariantsRandomly", "RerollMode", + "DisplayEnabledVariantsToScreen", "ChangeVariantsInterval", "MaxEnabledVariants" + }; + + public static void ApplyMapRestrictions() { + if (originalState == null) { + return; + } + if (!AkronPolicy.CanUse(AkronFeatureKind.ExtendedVariantMode).Allowed) { + if (suppressedState == null) { + suppressedState = CaptureExternalState(); + RestoreExternalState(originalState); + } + } else if (suppressedState != null) { + RestoreExternalState(suppressedState); + suppressedState = null; + } + } + + public static void RestoreOriginalSettings() { + randomizerHook?.Dispose(); + randomizerHook = null; + if (originalState != null && suppressedState == null) { + RestoreExternalState(originalState); + } + originalState = null; + lastAppliedState = null; + suppressedState = null; + ownedSettings.Clear(); + ownedAssistFields.Clear(); + ownedVariants.Clear(); + } + + private static ExternalState BeginExternalChange() { + // Only randomized writes need an observer. This mutation boundary runs when + // EVM rolls variants, not on every player update or on map-authored triggers. + if (randomizerHook == null) { + Type randomizer = moduleType.Assembly.GetType("ExtendedVariants.VariantRandomizer", true); + MethodInfo change = randomizer.GetMethod("changeVariantNow", BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new MissingMethodException(randomizer.FullName, "changeVariantNow"); + randomizerHook = new Hook(change, (Action, bool>)ChangeRandomVariants); + } + return CaptureExternalState(); + } + + private static void ChangeRandomVariants(Action orig, bool disableOnly) { + if (!ownedSettings.Contains("ChangeVariantsRandomly") || suppressedState != null) { + orig(disableOnly); + return; + } + ExternalState before = CaptureExternalState(); + try { + orig(disableOnly); + } finally { + TrackExternalChange(before); + } + } + + private static void TrackExternalChange(ExternalState before) { + ExternalState after = CaptureExternalState(); + originalState ??= before; + lastAppliedState ??= after; + foreach (var entry in after.Settings) { + PropertyInfo property = entry.Key; + if (ValuesMatch(before.Settings[property], entry.Value)) continue; + if (!ownedSettings.Contains(property.Name) || !ValuesMatch(lastAppliedState.Settings[property], before.Settings[property])) { + originalState.Settings[property] = before.Settings[property]; + } + ownedSettings.Add(property.Name); + lastAppliedState.Settings[property] = entry.Value; + } + + HashSet changedCandidates = new(before.Variants.Keys); + changedCandidates.UnionWith(after.Variants.Keys); + changedCandidates.UnionWith(before.UserOverrides); + changedCandidates.UnionWith(after.UserOverrides); + foreach (object variant in changedCandidates) { + if (VariantStateMatches(before, after, variant)) continue; + if (!ownedVariants.Contains(variant) || !VariantStateMatches(lastAppliedState, before, variant)) { + CopyVariantState(originalState, before, variant); + } + ownedVariants.Add(variant); + CopyVariantState(lastAppliedState, after, variant); + } + + if (after.SaveData == null) return; + if (!ReferenceEquals(lastAppliedState.SaveData, after.SaveData)) { + ownedAssistFields.Clear(); + } + object originalAssists = originalState.Assists; + object appliedAssists = lastAppliedState.Assists; + foreach (FieldInfo field in AssistFields) { + if (Equals(field.GetValue(before.Assists), field.GetValue(after.Assists))) continue; + if (!ownedAssistFields.Contains(field) || !Equals(field.GetValue(appliedAssists), field.GetValue(before.Assists))) { + field.SetValue(originalAssists, field.GetValue(before.Assists)); + if (before.VanillaAssists != null) { + originalState.VanillaAssists ??= before.VanillaAssists; + field.SetValue(originalState.VanillaAssists, field.GetValue(before.VanillaAssists)); + } + } + ownedAssistFields.Add(field); + field.SetValue(appliedAssists, field.GetValue(after.Assists)); + if (after.VanillaAssists != null) { + lastAppliedState.VanillaAssists ??= after.VanillaAssists; + field.SetValue(lastAppliedState.VanillaAssists, field.GetValue(after.VanillaAssists)); + } + } + originalState.SaveData = lastAppliedState.SaveData = after.SaveData; + originalState.Assists = (global::Celeste.Assists)originalAssists; + lastAppliedState.Assists = (global::Celeste.Assists)appliedAssists; + } + + private static bool VariantStateMatches(ExternalState expected, ExternalState actual, object variant) { + return expected.Variants.TryGetValue(variant, out object expectedValue) == actual.Variants.TryGetValue(variant, out object actualValue) && + ValuesMatch(expectedValue, actualValue) && + (!ReferenceEquals(VariantSession(expected, variant), VariantSession(actual, variant)) || + expected.UserOverrides.Contains(variant) == actual.UserOverrides.Contains(variant)); + } + + private static object VariantSession(ExternalState state, object variant) => + state.VariantSessions.TryGetValue(variant, out object session) ? session : state.Session; + + private static void CopyVariantState(ExternalState target, ExternalState source, object variant) { + if (source.Variants.TryGetValue(variant, out object value)) target.Variants[variant] = value; + else target.Variants.Remove(variant); + if (source.UserOverrides.Contains(variant)) target.UserOverrides.Add(variant); + else target.UserOverrides.Remove(variant); + target.VariantSessions[variant] = VariantSession(source, variant); + } + + private static ExternalState CaptureExternalState() { + object settings = GetSettings(); + ExternalState state = new ExternalState(); + foreach (string name in OwnedSettingNames) { + PropertyInfo property = settings.GetType().GetProperty(name); + if (property?.CanRead == true && property.CanWrite) { + state.Settings[property] = property.GetValue(settings); + } + } + if (settings.GetType().GetProperty("EnabledVariants")?.GetValue(settings) is IDictionary variants) { + foreach (DictionaryEntry entry in variants) { + state.Variants[entry.Key] = entry.Value is bool[][] matrix + ? matrix.Select(row => (bool[])row.Clone()).ToArray() + : entry.Value; + } + } + state.Session = moduleType.GetProperty("Session", BindingFlags.Public | BindingFlags.Static)?.GetValue(null); + if (state.Session?.GetType().GetField("VariantsOverridenByUser")?.GetValue(state.Session) is IEnumerable overridden) { + foreach (object variant in overridden) { + state.UserOverrides.Add(variant); + } + } + CaptureVanillaState(state); + return state; + } + + private static void RestoreExternalState(ExternalState state) { + ExternalState current = CaptureExternalState(); + object settings = GetSettings(); + object instance = GetInstance(); + object session = moduleType.GetProperty("Session", BindingFlags.Public | BindingFlags.Static)?.GetValue(null); + object userOverrides = session?.GetType().GetField("VariantsOverridenByUser")?.GetValue(session); + MethodInfo removeOverride = userOverrides?.GetType().GetMethod("Remove"); + MethodInfo addOverride = userOverrides?.GetType().GetMethod("Add"); + RestoreOwnedAssists(state, current); + if (settings.GetType().GetProperty("EnabledVariants")?.GetValue(settings) is IDictionary variants) { + foreach (object variant in ownedVariants.ToArray()) { + // An independent EVM edit takes ownership, both before suppression + // and while the map has temporarily restored the previous value. + if (!VariantStateMatches(lastAppliedState, current, variant)) { + ownedVariants.Remove(variant); + continue; + } + if (state.Variants.TryGetValue(variant, out object value)) { + variants[variant] = value; + } else { + variants.Remove(variant); + } + removeOverride?.Invoke(userOverrides, new[] { variant }); + if (ReferenceEquals(session, VariantSession(state, variant)) && state.UserOverrides.Contains(variant)) { + addOverride?.Invoke(userOverrides, new[] { variant }); + } + object handler = GetHandler(variant); + handler?.GetType().GetMethod("VariantValueChanged")?.Invoke(handler, Array.Empty()); + } + } + foreach (var entry in state.Settings) { + if (ownedSettings.Contains(entry.Key.Name) && !ValuesMatch(current.Settings[entry.Key], lastAppliedState.Settings[entry.Key])) { + ownedSettings.Remove(entry.Key.Name); + } + if (ownedSettings.Contains(entry.Key.Name)) { + entry.Key.SetValue(settings, entry.Value); + } + } + bool mapRequiresHooks = moduleType.GetField("forceEnabled", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(instance) is true; + bool hooksEnabled = ReadSettingsProperty("MasterSwitch") || mapRequiresHooks; + moduleType.GetMethod(hooksEnabled ? "HookStuff" : "UnhookStuff", BindingFlags.Instance | BindingFlags.Public)?.Invoke(instance, Array.Empty()); + Type randomizer = moduleType.Assembly.GetType("ExtendedVariants.VariantRandomizer"); + randomizer?.GetMethod("UpdateCountersFromSettings", BindingFlags.Public | BindingFlags.Static)?.Invoke(null, Array.Empty()); + randomizer?.GetMethod("RefreshEnabledVariantsDisplayList", BindingFlags.Public | BindingFlags.Static)?.Invoke(null, Array.Empty()); + lastAppliedState = CaptureExternalState(); + } + + private sealed class ExternalState { + public readonly Dictionary Settings = new(); + public readonly Dictionary Variants = new(); + public readonly HashSet UserOverrides = new(); + public readonly Dictionary VariantSessions = new(); + public object Session; + public global::Celeste.SaveData SaveData; + public global::Celeste.Assists Assists; + public object VanillaAssists; + } + + private static FieldInfo VanillaAssistsField() { + return moduleType.Assembly.GetType("ExtendedVariants.Variants.Vanilla.AbstractVanillaVariant") + ?.GetField("vanillaAssists", BindingFlags.NonPublic | BindingFlags.Static); + } + + private static void CaptureVanillaState(ExternalState state) { + state.SaveData = global::Celeste.SaveData.Instance; + if (state.SaveData != null) { + state.Assists = state.SaveData.Assists; + state.VanillaAssists = VanillaAssistsField()?.GetValue(null); + } + } + + private static void RestoreOwnedAssists(ExternalState target, ExternalState current) { + if (current.SaveData == null || !ReferenceEquals(current.SaveData, target.SaveData) || + !ReferenceEquals(current.SaveData, lastAppliedState.SaveData)) { + ownedAssistFields.Clear(); + return; + } + object assists = current.Assists; + foreach (FieldInfo field in ownedAssistFields.ToArray()) { + // EVM rebuilds vanillaAssists each update/render. A cache refresh is + // not an independent preference edit and must not release ownership. + if (!Equals(field.GetValue(assists), field.GetValue(lastAppliedState.Assists))) { + ownedAssistFields.Remove(field); + continue; + } + field.SetValue(assists, field.GetValue(target.Assists)); + if (current.VanillaAssists != null && target.VanillaAssists != null) { + field.SetValue(current.VanillaAssists, field.GetValue(target.VanillaAssists)); + } + } + current.SaveData.Assists = (global::Celeste.Assists)assists; + VanillaAssistsField()?.SetValue(null, current.VanillaAssists); + } + + private static bool CanChange(out string message) { + AkronPolicyDecision decision = AkronPolicy.CanUse(AkronFeatureKind.ExtendedVariantMode); + message = decision.Message; + return decision.Allowed; + } + static AkronExtendedVariants() { // A missing optional mod is the common path. Keep that failure cheap, but @@ -64,26 +333,41 @@ public static string StatusSummary { } } + internal static string DescribeSavedSetting(string name) { + if (suppressedState != null && ownedSettings.Contains(name)) { + foreach (var entry in suppressedState.Settings) { + if (entry.Key.Name == name) { + return FormatValue(entry.Value); + } + } + } + return Available ? FormatValue(ReadSettingsProperty(name)) : "EVM missing"; + } + public static bool MasterSwitch { get => Available && ReadSettingsProperty("MasterSwitch"); set { - if (!Available) { + if (!AkronPolicy.CanUse(AkronFeatureKind.ExtendedVariantMode).Allowed || !Available) { return; } - object settings = GetSettings(); - PropertyInfo property = settings?.GetType().GetProperty("MasterSwitch", BindingFlags.Instance | BindingFlags.Public); - property?.SetValue(settings, value, null); - - // EVM only loads the expensive hooks while its master switch is on. - // Calling the same public hook toggles its own menu uses keeps Akron - // from maintaining a second physics implementation. - object instance = GetInstance(); - MethodInfo method = moduleType.GetMethod(value ? "HookStuff" : "UnhookStuff", BindingFlags.Instance | BindingFlags.Public); - method?.Invoke(instance, Array.Empty()); + ExternalState before = BeginExternalChange(); + SetMasterSwitch(value); + TrackExternalChange(before); } } + private static void SetMasterSwitch(bool value) { + object settings = GetSettings(); + PropertyInfo masterSwitch = settings.GetType().GetProperty("MasterSwitch"); + if (Equals(masterSwitch.GetValue(settings), value)) return; + masterSwitch.SetValue(settings, value); + // Reuse EVM's own hook lifecycle instead of maintaining a second set of physics hooks. + object instance = GetInstance(); + moduleType.GetMethod(value ? "HookStuff" : "UnhookStuff", BindingFlags.Instance | BindingFlags.Public) + .Invoke(instance, Array.Empty()); + } + public static bool RandomizerEnabled { get => Available && ReadSettingsProperty("ChangeVariantsRandomly"); set => WriteSettingsProperty("ChangeVariantsRandomly", value); @@ -148,6 +432,7 @@ public static AkronExtendedVariantOption GetOptionByLabel(string label) { } public static bool TryToggleBoolean(string name, out string message) { + if (!CanChange(out message)) return false; AkronExtendedVariantOption option = GetOption(name); if (option?.CurrentValue is bool current) { SetVariantValue(option.Name, !current); @@ -160,6 +445,7 @@ public static bool TryToggleBoolean(string name, out string message) { } public static bool TryToggleConfigured(string name, out string message) { + if (!CanChange(out message)) return false; AkronExtendedVariantOption option = GetOption(name); if (option == null) { message = "Unknown variant: " + name; @@ -193,6 +479,7 @@ public static bool TryToggleConfigured(string name, out string message) { } public static bool TrySetFromText(string name, string rawValue, out string message) { + if (!CanChange(out message)) return false; AkronExtendedVariantOption option = GetOption(name); if (option == null) { message = "Unknown variant: " + name; @@ -209,6 +496,7 @@ public static bool TrySetFromText(string name, string rawValue, out string messa } public static bool TrySetValue(string name, object value, out string message) { + if (!CanChange(out message)) return false; AkronExtendedVariantOption option = GetOption(name); if (option == null) { message = "Unknown variant: " + name; @@ -221,6 +509,7 @@ public static bool TrySetValue(string name, object value, out string message) { } public static bool TrySetConfiguredValue(string name, object value, out string message) { + if (!CanChange(out message)) return false; AkronExtendedVariantOption option = GetOption(name); if (option == null) { message = "Unknown variant: " + name; @@ -239,6 +528,7 @@ public static bool TrySetConfiguredValue(string name, object value, out string m } public static bool TrySetConfiguredFromText(string name, string rawValue, out string message) { + if (!CanChange(out message)) return false; AkronExtendedVariantOption option = GetOption(name); if (option == null) { message = "Unknown variant: " + name; @@ -261,6 +551,7 @@ public static bool TrySetConfiguredFromText(string name, string rawValue, out st } public static bool TryResetConfigured(string name, out string message) { + if (!CanChange(out message)) return false; AkronExtendedVariantOption option = GetOption(name); if (option == null) { message = "Unknown variant: " + name; @@ -285,6 +576,12 @@ public static string DescribeConfiguredState(AkronExtendedVariantOption option) if (option == null) { return string.Empty; } + if (suppressedState != null && optionMetadataByName != null && + optionMetadataByName.TryGetValue(option.Name, out VariantMetadata metadata) && ownedVariants.Contains(metadata.Variant)) { + object saved = suppressedState.Variants.TryGetValue(metadata.Variant, out object value) ? value : option.DefaultValue; + return saved is bool ? FormatValue(saved) : + (ValuesMatch(saved, option.DefaultValue) ? "Off" : "On") + " | " + FormatValue(saved); + } if (option.CurrentValue is bool) { return FormatValue(option.CurrentValue); @@ -313,14 +610,18 @@ public static void RecordRandomizerCheatUseIfEnabled() { } public static void ResetExtended() { - if (Available) { + if (AkronPolicy.CanUse(AkronFeatureKind.ExtendedVariantMode).Allowed && Available) { + ExternalState before = BeginExternalChange(); resetExtendedVariantsMethod?.Invoke(GetInstance(), Array.Empty()); + TrackExternalChange(before); } } public static void ResetVanilla() { - if (Available) { + if (AkronPolicy.CanUse(AkronFeatureKind.ExtendedVariantMode).Allowed && Available) { + ExternalState before = BeginExternalChange(); resetVanillaVariantsMethod?.Invoke(GetInstance(), Array.Empty()); + TrackExternalChange(before); } } @@ -353,13 +654,15 @@ public static string FormatValue(object value) { } private static void SetVariantValue(string name, object value) { - if (!Available) { + if (!AkronPolicy.CanUse(AkronFeatureKind.ExtendedVariantMode).Allowed || !Available) { return; } - MasterSwitch = true; + ExternalState before = BeginExternalChange(); + SetMasterSwitch(true); object variant = ParseVariant(name); setVariantValueMethod?.Invoke(null, new[] { variant, value }); + TrackExternalChange(before); } private static bool TryConvertText(AkronExtendedVariantOption option, string rawValue, out object converted, out string message) { @@ -704,13 +1007,15 @@ private static T ReadSettingsProperty(string name) { } private static void WriteSettingsProperty(string name, object value) { - if (!Available) { + if (!AkronPolicy.CanUse(AkronFeatureKind.ExtendedVariantMode).Allowed || !Available) { return; } + ExternalState before = BeginExternalChange(); object settings = GetSettings(); PropertyInfo property = settings?.GetType().GetProperty(name, BindingFlags.Instance | BindingFlags.Public); property?.SetValue(settings, value, null); + TrackExternalChange(before); } private static bool ResolveTypes() { diff --git a/Source/Module/AkronModule.cs b/Source/Module/AkronModule.cs index 8990b5cf..bd16bd1f 100644 --- a/Source/Module/AkronModule.cs +++ b/Source/Module/AkronModule.cs @@ -179,6 +179,8 @@ public override void Load() { // Drain restart copies while the game and save APIs are still alive. Engine.Instance.Exiting += EngineOnExiting; } + On.Celeste.Level.LoadLevel += LevelOnLoadLevelForMapPolicy; + On.Celeste.LevelEnter.ctor += LevelEnterOnConstructForMapPolicy; On.Celeste.Level.Begin += LevelOnBegin; On.Celeste.Level.End += LevelOnEnd; On.Celeste.Level.UpdateTime += LevelOnUpdateTime; @@ -335,6 +337,10 @@ public override void Unload() { AkronScreenshotScanner.Unload(); AkronNativeSavestateSupport.Reset(); AkronSaveLoadService.ClearRuntimeState(); + LeaveMapPolicy(); + AkronPolicy.UnloadMapRestrictions(); + On.Celeste.Level.LoadLevel -= LevelOnLoadLevelForMapPolicy; + On.Celeste.LevelEnter.ctor -= LevelEnterOnConstructForMapPolicy; On.Celeste.Level.Begin -= LevelOnBegin; On.Celeste.Level.End -= LevelOnEnd; On.Celeste.Level.UpdateTime -= LevelOnUpdateTime; @@ -417,6 +423,8 @@ public override void Unload() { AkronActions.RestoreAutoDeafen(); AkronActions.RestoreLowVolumeBypass(); AkronRuntimeOptions.Reset(); + AkronMotionSmoothingInterop.RestoreOriginalSettings(); + AkronExtendedVariants.RestoreOriginalSettings(); AkronOverlayBlur.Unload(); AkronImGuiRenderer.Shutdown(); deferredScreenWipeAction = null; @@ -424,9 +432,7 @@ public override void Unload() { if (AkronInternalRecorder.IsRecording) { AkronInternalRecorder.Stop(); } -#pragma warning disable CS0618 - Engine.TimeRate = 1f; -#pragma warning restore CS0618 + ReleaseTimescale(); // AkronLog holds the log file open for the whole session, so unload has to release the handle. // Reloading the mod would otherwise leave a second appender on the same file. AkronLog.CloseLogFile(); @@ -452,7 +458,9 @@ internal static void ApplyMotionSmoothingSettings() { } private static void LevelOnBegin(On.Celeste.Level.orig_Begin orig, Level self) { - AkronActions.ClearStartPosInputWait(); + EnterMapPolicy(self.Session); + ApplyPendingMapPolicyEffects(); + ClearPendingPolicyActions(); try { orig(self); } catch (NullReferenceException ex) when (ex.StackTrace?.IndexOf("DustEdges.BeforeRender", StringComparison.Ordinal) >= 0) { @@ -494,6 +502,7 @@ private static void LevelOnEnd(On.Celeste.Level.orig_End orig, Level self) { // them from the first frame of the next level, so restoring here costs nothing. RestoreNativeAssistInvincibility(); AkronActions.RestoreAutoDeafen(); + LeaveMapPolicy(self.Session); orig(self); } @@ -502,6 +511,8 @@ private static void LevelOnExit(Level level, LevelExit levelExit, LevelExit.Mode } private static void LevelOnUpdate(On.Celeste.Level.orig_Update orig, Level self) { + EnterMapPolicy(self.Session); + ApplyPendingMapPolicyEffects(); if (freshRoomInitializationUpdateDepth > 0) { orig(self); return; @@ -532,10 +543,13 @@ private static void LevelOnUpdate(On.Celeste.Level.orig_Update orig, Level self) if (AkronActions.StartPosFrameGeneration != startPosFrameGeneration) { return; } - if (Settings.InputViewer || Settings.InputHistoryPanel || Settings.InputHistoryShowOnDeath || Settings.ShowTaps) { + if ((Settings.InputViewer && AkronPolicy.CanUse(AkronFeatureKind.InputViewer).Allowed) || + ((Settings.InputHistoryPanel || Settings.InputHistoryShowOnDeath) && AkronPolicy.CanUse(AkronFeatureKind.InputHistory).Allowed) || + (Settings.ShowTaps && AkronPolicy.CanUse(AkronFeatureKind.ShowTaps).Allowed)) { AkronInputHistory.RecordFrame(); } - if (Settings.InputsPerSecondCounter || Settings.CustomHudLabels) { + if ((Settings.InputsPerSecondCounter && AkronPolicy.CanUse(AkronFeatureKind.InputsPerSecondCounter).Allowed) || + (Settings.CustomHudLabels && AkronPolicy.CanUse(AkronFeatureKind.CustomHudLabels).Allowed)) { AkronInputHistory.RecordInputsPerSecondFrame(); } UpdateDeathStatsTimer(); @@ -551,11 +565,11 @@ private static void LevelOnUpdate(On.Celeste.Level.orig_Update orig, Level self) if (AkronActions.StartPosFrameGeneration != startPosFrameGeneration) { return; } - if (Overlay.SearchOwnsGameplayInputThisFrame) { + if (Overlay.SearchOwnsGameplayInputThisFrame && AkronPolicy.CanUse(AkronFeatureKind.Freeze).Allowed) { AkronRuntimeOptions.HoldSceneClockForSkippedLevelUpdate(self); return; } - if (Settings.PauseGameplayInMenu) { + if (Settings.PauseGameplayInMenu && AkronPolicy.CanUse(AkronFeatureKind.Freeze).Allowed) { AkronRuntimeOptions.HoldSceneClockForSkippedLevelUpdate(self); return; } @@ -588,7 +602,7 @@ private static void LevelOnUpdate(On.Celeste.Level.orig_Update orig, Level self) return; } - if (Session.FreezeGameplay && !Session.StepFrameRequested) { + if (IsGameplayFreezeEffective && !(CanStepGameplay && Session.StepFrameRequested)) { AkronRuntimeOptions.HoldSceneClockForSkippedLevelUpdate(self); if (!overlayUpdated) { Overlay?.Update(); @@ -599,7 +613,8 @@ private static void LevelOnUpdate(On.Celeste.Level.orig_Update orig, Level self) // MInput.Disabled makes every virtual input read as released for this update, so the // level still runs (timers, entities) while Madeline gets no input behind the open // overlay. The overlay itself read the keyboard and mouse directly before this point. - bool blockGameplayInput = overlayUpdated && Overlay?.Visible == true && Settings.ConsumeGameplayInputInMenu; + bool blockGameplayInput = overlayUpdated && Overlay?.Visible == true && + (Settings.ConsumeGameplayInputInMenu || Overlay.SearchOwnsGameplayInputThisFrame); bool previousInputDisabled = MInput.Disabled; if (blockGameplayInput) { MInput.Disabled = true; @@ -675,7 +690,7 @@ private static void LevelOnUpdateTime(On.Celeste.Level.orig_UpdateTime orig, Lev bool freezeTimerDuringPause = ShouldFreezeTimerDuringPause(self); bool canFreezeTimer = freezeTimerEnabled && freezeTimerDuringPause && - TryUse(AkronFeatureKind.PauseTimerFreeze); + TryUseRuntime(AkronFeatureKind.PauseTimerFreeze); if (ShouldReleasePauseTimerFreezeStop(pauseTimerFreezeStoppedTimer, freezeTimerEnabled, canFreezeTimer, freezeTimerDuringPause)) { self.TimerStopped = false; pauseTimerFreezeStoppedTimer = false; @@ -841,6 +856,17 @@ private static AreaModeStats TryGetAreaModeStats(AreaKey area) { } private static void EngineOnUpdate(On.Monocle.Engine.orig_Update orig, Engine self, GameTime gameTime) { + if (Engine.Scene is Level policyLevel) { + EnterMapPolicy(policyLevel.Session); + } else if (Engine.Scene is not LevelLoader && Engine.Scene is not LevelEnter && AkronPolicy.HasMapRestrictions) { + LeaveMapPolicy(); + } + ApplyPendingMapPolicyEffects(); + if (Engine.Scene is Level) { + ApplyTimescale(); + } else { + ReleaseTimescale(); + } AkronDiagnosticsMenu.CloseIfSceneChanged(Engine.Scene); // First thing in the hook, so the recorded interval spans a whole engine // update including everything Akron itself adds to the frame. @@ -882,7 +908,7 @@ private static void EngineOnUpdate(On.Monocle.Engine.orig_Update orig, Engine se Overlay.Active = false; Overlay.Update(); UpdateOverlayCursorState(); - if (Overlay.SearchOwnsGameplayInputThisFrame || Settings.PauseGameplayInMenu) { + if ((Overlay.SearchOwnsGameplayInputThisFrame || Settings.PauseGameplayInMenu) && AkronPolicy.CanUse(AkronFeatureKind.Freeze).Allowed) { return; } } else { @@ -1579,7 +1605,7 @@ internal static float CalculateSafeTextMenuX( } private static void AutoSavingNoticeOnUpdate(On.Celeste.AutoSavingNotice.orig_Update orig, AutoSavingNotice self, Scene scene) { - if (ShouldSuppressSavingNotice(AkronCapture.IsCapturingGameFrame, Settings.AutosaveHideSavingIcon)) { + if (ShouldSuppressSavingNotice(AkronCapture.IsCapturingGameFrame, Settings.AutosaveHideSavingIcon && AkronPolicy.CanUse(AkronFeatureKind.Autosave).Allowed)) { self.Display = false; self.StillVisible = false; return; @@ -1589,7 +1615,7 @@ private static void AutoSavingNoticeOnUpdate(On.Celeste.AutoSavingNotice.orig_Up } private static void AutoSavingNoticeOnRender(On.Celeste.AutoSavingNotice.orig_Render orig, AutoSavingNotice self, Scene scene) { - if (ShouldSuppressSavingNotice(AkronCapture.IsCapturingGameFrame, Settings.AutosaveHideSavingIcon)) { + if (ShouldSuppressSavingNotice(AkronCapture.IsCapturingGameFrame, Settings.AutosaveHideSavingIcon && AkronPolicy.CanUse(AkronFeatureKind.Autosave).Allowed)) { return; } @@ -1601,7 +1627,7 @@ internal static bool ShouldSuppressSavingNotice(bool isCapturingGameFrame, bool } private static void SaveLoadIconOnShow(On.Celeste.SaveLoadIcon.orig_Show orig, Scene scene) { - if (ShouldSuppressSaveLoadIcon(AkronCapture.IsCapturingGameFrame, Settings.AutosaveHideSavingIcon)) { + if (ShouldSuppressSaveLoadIcon(AkronCapture.IsCapturingGameFrame, Settings.AutosaveHideSavingIcon && AkronPolicy.CanUse(AkronFeatureKind.Autosave).Allowed)) { return; } @@ -1609,7 +1635,7 @@ private static void SaveLoadIconOnShow(On.Celeste.SaveLoadIcon.orig_Show orig, S } private static void SaveLoadIconOnRender(On.Celeste.SaveLoadIcon.orig_Render orig, SaveLoadIcon self) { - if (ShouldSuppressSaveLoadIcon(AkronCapture.IsCapturingGameFrame, Settings.AutosaveHideSavingIcon)) { + if (ShouldSuppressSaveLoadIcon(AkronCapture.IsCapturingGameFrame, Settings.AutosaveHideSavingIcon && AkronPolicy.CanUse(AkronFeatureKind.Autosave).Allowed)) { return; } @@ -1693,7 +1719,7 @@ private static void EngineOnRenderCore(On.Monocle.Engine.orig_RenderCore orig, E Draw.SpriteBatch.End(); } } - } else if (inspectorPinVisible && TryUse(AkronFeatureKind.EntityInspector)) { + } else if (inspectorPinVisible && TryUseRuntime(AkronFeatureKind.EntityInspector)) { AkronEntityInspector.RenderInspectorPinImGui(inspectorPinLevel); } } @@ -1725,14 +1751,15 @@ private static void LevelOnRegisterAreaComplete(On.Celeste.Level.orig_RegisterAr AkronLog.Normal(nameof(AkronModule), "proof sidecar write failed: " + exception.Message); } - if (Settings.ProofModeOverlay || Settings.EndScreenHelper || Session.AttemptStatus != AkronStatus.GoldberryHardlistClean) { + if (AkronPolicy.CanUse(AkronFeatureKind.EndScreenHelper).Allowed && + (Settings.ProofModeOverlay || Settings.EndScreenHelper || Session.AttemptStatus != AkronStatus.GoldberryHardlistClean)) { AkronProof.ShowProofPanel(self, "area-complete", path); } } private static void RenderVisualTuningTint() { - if (!Settings.ScreenTint || !TryUse(AkronFeatureKind.VisualTuning)) { + if (!Settings.ScreenTint || !TryUseRuntime(AkronFeatureKind.VisualTuning)) { return; } diff --git a/Source/Module/akron-module-actions.cs b/Source/Module/akron-module-actions.cs index a598edeb..1faed188 100644 --- a/Source/Module/akron-module-actions.cs +++ b/Source/Module/akron-module-actions.cs @@ -11,7 +11,7 @@ namespace Celeste.Mod.Akron; public partial class AkronModule { private static void Retry(Level level, bool confirmed = false) { - if (!TryUse(AkronFeatureKind.RetryHotkey)) { + if (level == null || Engine.Scene != level || !TryUse(AkronFeatureKind.RetryHotkey)) { return; } @@ -140,7 +140,7 @@ private static void ForceRetryDeath(Level level) { } private static void ReloadRoom(Level level, bool confirmed = false) { - if (!TryUse(AkronFeatureKind.RoomReload)) { + if (level == null || Engine.Scene != level || !TryUse(AkronFeatureKind.RoomReload)) { return; } @@ -150,7 +150,7 @@ private static void ReloadRoom(Level level, bool confirmed = false) { } level.OnEndOfFrame += () => { - if (Engine.Scene != level) { + if (Engine.Scene != level || !AkronPolicy.CanUse(AkronFeatureKind.RoomReload).Allowed) { return; } @@ -167,7 +167,7 @@ private static void OpenDebugMap(Level level) { } level.OnEndOfFrame += () => { - if (Engine.Scene is Level) { + if (Engine.Scene == level && AkronPolicy.CanUse(AkronFeatureKind.DebugMapLauncher).Allowed) { Engine.Scene = new MapEditor(level.Session.Area); Engine.Commands.Open = false; } @@ -175,7 +175,7 @@ private static void OpenDebugMap(Level level) { } private static void ReloadChapter(Level level, bool confirmed = false) { - if (!TryUse(AkronFeatureKind.ChapterReload)) { + if (level == null || Engine.Scene != level || !TryUse(AkronFeatureKind.ChapterReload)) { return; } @@ -187,6 +187,9 @@ private static void ReloadChapter(Level level, bool confirmed = false) { AkronPolicy.ResetAttempt("Chapter reload ended the previous attempt."); AkronPracticeStats.ResetAttemptTimer(); level.OnEndOfFrame += () => { + if (Engine.Scene != level || !AkronPolicy.CanUse(AkronFeatureKind.ChapterReload).Allowed) { + return; + } Engine.Scene = new LevelLoader(level.Session.Restart()); }; } @@ -245,6 +248,18 @@ public static bool TryUse(AkronFeatureKind feature) { return true; } + internal static bool TryUseRuntime(AkronFeatureKind feature) { + AkronPolicyDecision decision = AkronPolicy.CanUse(feature); + if (!decision.Allowed) { + return false; + } + + AkronLog.RecordPolicyCheck(feature, decision); + AkronPolicy.RecordFeatureUse(feature); + AkronLog.RecordFeatureUse(feature); + return true; + } + private static void ShowConfirmPrompt(Level level, string title, string body, Action confirmedAction) { if (level == null) { return; @@ -254,7 +269,11 @@ private static void ShowConfirmPrompt(Level level, string title, string body, Ac level, title, body, - new AkronPromptOption("Confirm", confirmedAction) + new AkronPromptOption("Confirm", () => { + if (Engine.Scene == level) { + confirmedAction(); + } + }) ); } diff --git a/Source/Module/akron-module-map-policy.cs b/Source/Module/akron-module-map-policy.cs new file mode 100644 index 00000000..5bfacd8b --- /dev/null +++ b/Source/Module/akron-module-map-policy.cs @@ -0,0 +1,160 @@ +using Monocle; + +namespace Celeste.Mod.Akron; + +public partial class AkronModule +{ + private static bool mapRestrictionEffectsPending; + private static Session mapPolicySession; + private static bool ownsTimescale; + private static float timescaleBeforeAkron; + private static float lastAkronTimescale; + + private static void EnterMapPolicy(Session session) + { + mapPolicySession = session; + if (AkronPolicy.EnterMap(session?.MapData)) + { + mapRestrictionEffectsPending = true; + } + } + + private static void LevelEnterOnConstructForMapPolicy(On.Celeste.LevelEnter.orig_ctor orig, LevelEnter self, Session session, bool fromSaveData) + { + EnterMapPolicy(session); + ClearPendingPolicyActions(); + ApplyPendingMapPolicyEffects(); + orig(self, session, fromSaveData); + } + + private static void LevelOnLoadLevelForMapPolicy(On.Celeste.Level.orig_LoadLevel orig, Level self, Player.IntroTypes intro, bool isFromLoader) + { + // Only policy is scoped here. Global render/audio/recorder cleanup belongs to + // the engine thread when Level.Begin installs the incoming scene's policy. + AkronMapFeatureRestrictions previous = AkronPolicy.EnterMapLoadScope(self.Session.MapData); + try + { + orig(self, intro, isFromLoader); + } + finally + { + AkronPolicy.ExitMapLoadScope(previous); + } + } + + private static void LeaveMapPolicy(Session expectedSession = null) + { + // LevelEnter/LevelLoader can install the next session before the old Level.End. + bool ownsPolicy = expectedSession == null || ReferenceEquals(mapPolicySession, expectedSession); + if (ownsPolicy) + { + mapPolicySession = null; + } + if (ownsPolicy && AkronPolicy.LeaveMap()) + { + mapRestrictionEffectsPending = true; + } + ClearPendingPolicyActions(); + ReleaseTimescale(); + AkronRuntimeOptions.Reset(); + AkronActions.RestoreLowVolumeBypass(); + } + + private static void ClearPendingPolicyActions() + { + AkronActions.CancelPendingStartPosActions(); + AkronScreenshotScanner.CancelForMapChange(); + AkronCapture.CancelPendingCapture(); + if (Session != null) + { + Session.StepFrameRequested = false; + Session.StepFrameHoldFrames = 0; + Session.StepFrameRepeatCountdown = 0; + Session.LevelEnterSkipHoldSeconds = 0f; + } + } + + private static void ApplyPendingMapPolicyEffects() + { + if (!mapRestrictionEffectsPending) + { + return; + } + mapRestrictionEffectsPending = false; + ClearPendingPolicyActions(); + AkronRuntimeOptions.Reset(); + if (AkronPolicy.IsMapRestricted(AkronFeatureKind.Invincibility)) + { + RestoreNativeAssistInvincibility(); + } + AkronActions.ApplyLowVolumeBypass(); + if (!AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed) + { + ReleaseTimescale(); + } + AkronInternalRecorder.ApplyMapRestrictions(); + AkronExtendedVariants.ApplyMapRestrictions(); + ApplyMotionSmoothingSettings(); + } + + internal static bool IsGameplayFreezeEffective => Session != null && Session.FreezeGameplay && + AkronPolicy.CanUse(AkronFeatureKind.Freeze).Allowed; + + internal static bool CanStepGameplay => IsGameplayFreezeEffective && Settings.FrameStepper && + AkronPolicy.CanUse(AkronFeatureKind.FrameAdvance).Allowed; + +#pragma warning disable CS0618 + internal static bool OwnsCurrentTimescale => ownsTimescale && Engine.TimeRate == lastAkronTimescale; +#pragma warning restore CS0618 + internal static float TimescaleBeforeAkron => timescaleBeforeAkron; + + internal static void RestoreSnapshotTimescale(float value, bool ownedByAkron, float beforeAkron) + { + ReleaseTimescale(); + if (ownedByAkron && !AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed) + { + return; + } +#pragma warning disable CS0618 + Engine.TimeRate = value; +#pragma warning restore CS0618 + ownsTimescale = ownedByAkron; + timescaleBeforeAkron = beforeAkron; + lastAkronTimescale = value; + } + + internal static void ApplyTimescale() + { + if (Session == null || !Session.TimescaleEnabled || !AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed) + { + ReleaseTimescale(); + return; + } +#pragma warning disable CS0618 + if (!ownsTimescale) + { + timescaleBeforeAkron = Engine.TimeRate; + ownsTimescale = true; + } + lastAkronTimescale = Session.TimescaleMultiplier; + Engine.TimeRate = lastAkronTimescale; +#pragma warning restore CS0618 + } + + internal static void ReleaseTimescale() + { + if (!ownsTimescale) + { + return; + } +#pragma warning disable CS0618 + // Once another mod has written the clock, it owns that value. In particular a + // restricted map may provide its own trainer; never keep resetting its clock. + if (Engine.TimeRate == lastAkronTimescale) + { + Engine.TimeRate = timescaleBeforeAkron; + } +#pragma warning restore CS0618 + ownsTimescale = false; + } +} diff --git a/Source/Module/akron-module-overlay-input.cs b/Source/Module/akron-module-overlay-input.cs index f7f175fd..d6b09c8d 100644 --- a/Source/Module/akron-module-overlay-input.cs +++ b/Source/Module/akron-module-overlay-input.cs @@ -112,7 +112,7 @@ private static void HandleHotkeys(Level level) { AkronActions.ToggleFreeze(); } - if (IsBindingPressed(Settings.StepFrame, keyboard, previousKeyboard) && Settings.FrameStepper && Session.FreezeGameplay) { + if (IsBindingPressed(Settings.StepFrame, keyboard, previousKeyboard) && CanStepGameplay) { Session.StepFrameRequested = true; } @@ -185,7 +185,7 @@ internal static bool CanExecuteLevelActionBindings(Level level) { // The setting is a Cheat only at the moment it does something: a binding read while // Celeste is paused. Recording here rather than on the toggle keeps an attempt that // never paused with it on unmarked, and re-marks every attempt that used it. - return !level.Paused || TryUse(AkronFeatureKind.PauseBuffering); + return !level.Paused || TryUseRuntime(AkronFeatureKind.PauseBuffering); } internal static bool CanExecuteLevelActionBindings( @@ -367,7 +367,7 @@ private static void RefreshOverlayToggleKeyboardState() { } private static void UpdateStepHoldRepeat(KeyboardState keyboard, KeyboardState previousKeyboard) { - if (!Settings.FrameStepper || !Session.FreezeGameplay || !Settings.StepHoldRepeat || !TryGetButtonBindingKeys(Settings.StepFrame, out IReadOnlyCollection stepKeys) || !IsKeyboardBindingHeld(stepKeys)) { + if (!CanStepGameplay || !Settings.StepHoldRepeat || !TryGetButtonBindingKeys(Settings.StepFrame, out IReadOnlyCollection stepKeys) || !IsKeyboardBindingHeld(stepKeys)) { Session.StepFrameHoldFrames = 0; Session.StepFrameRepeatCountdown = 0; return; diff --git a/Source/Module/akron-module-player-runtime.cs b/Source/Module/akron-module-player-runtime.cs index a8bfd31b..9dea34b0 100644 --- a/Source/Module/akron-module-player-runtime.cs +++ b/Source/Module/akron-module-player-runtime.cs @@ -101,16 +101,16 @@ private static PlayerDeadBody PlayerOnDie(On.Celeste.Player.orig_Die orig, Playe } PlayerDeadBody deadBody = orig(self, direction, evenIfInvincible, registerDeathInStats); bool goldenDeath = level?.Entities.OfType().Any(strawberry => strawberry.Golden && strawberry.Follower.Leader != null) == true; - if (deadBody != null && Settings.NoDeathEffect && TryUse(AkronFeatureKind.DeathVisuals) && level != null) { + if (deadBody != null && Settings.NoDeathEffect && TryUseRuntime(AkronFeatureKind.DeathVisuals) && level != null) { noDeathEffectBodies.Add(deadBody); SuppressDeathEffects(level, deadBody, frozenDeathSprite); } - if (deadBody != null && Settings.NoRespawnAnimation && TryUse(AkronFeatureKind.RespawnAnimation)) { + if (deadBody != null && Settings.NoRespawnAnimation && TryUseRuntime(AkronFeatureKind.RespawnAnimation)) { deadBody.Visible = false; deadBody.ActionDelay = Math.Min(deadBody.ActionDelay, 0.05f); respawnTimeElapsed[deadBody] = 0f; } - if (deadBody != null && Settings.RespawnTimeModifier && TryUse(AkronFeatureKind.RespawnTime)) { + if (deadBody != null && Settings.RespawnTimeModifier && TryUseRuntime(AkronFeatureKind.RespawnTime)) { deadBody.ActionDelay = AkronModuleSettings.ClampRespawnTimeSeconds(Settings.RespawnTimeSeconds); respawnTimeElapsed[deadBody] = 0f; } @@ -132,7 +132,7 @@ private static PlayerDeadBody PlayerOnDie(On.Celeste.Player.orig_Die orig, Playe } else { AkronEntityInspector.RecordLastDeath(level, deathPosition, deathHazard); } - Session.LastDeathDuringNoclip = Settings.Noclip; + Session.LastDeathDuringNoclip = Settings.Noclip && AkronPolicy.CanUse(AkronFeatureKind.Noclip).Allowed; AkronPracticeStats.ResetAttemptTimer(); } } @@ -169,11 +169,13 @@ private static void PlayerOnSquish(On.Celeste.Player.orig_OnSquish orig, Player private static void PlayerDeadBodyOnUpdate(On.Celeste.PlayerDeadBody.orig_Update orig, PlayerDeadBody self) { orig(self); - if (Settings.NoDeathEffect && noDeathEffectBodies.Contains(self) && self.Scene is Level level) { + if (Settings.NoDeathEffect && AkronPolicy.CanUse(AkronFeatureKind.DeathVisuals).Allowed && noDeathEffectBodies.Contains(self) && self.Scene is Level level) { SuppressDeathEffects(level, self, null); } - if ((!Settings.RespawnTimeModifier && !Settings.NoRespawnAnimation) || !respawnTimeElapsed.ContainsKey(self)) { + bool respawnTimeAllowed = Settings.RespawnTimeModifier && AkronPolicy.CanUse(AkronFeatureKind.RespawnTime).Allowed; + bool respawnAnimationAllowed = Settings.NoRespawnAnimation && AkronPolicy.CanUse(AkronFeatureKind.RespawnAnimation).Allowed; + if ((!respawnTimeAllowed && !respawnAnimationAllowed) || !respawnTimeElapsed.ContainsKey(self)) { return; } @@ -183,10 +185,10 @@ private static void PlayerDeadBodyOnUpdate(On.Celeste.PlayerDeadBody.orig_Update return; } - float elapsedStep = Settings.RespawnTimeIgnoreSpeedhack ? Engine.RawDeltaTime : Engine.DeltaTime; + float elapsedStep = respawnTimeAllowed && Settings.RespawnTimeIgnoreSpeedhack ? Engine.RawDeltaTime : Engine.DeltaTime; float elapsed = respawnTimeElapsed[self] + Math.Max(0f, elapsedStep); respawnTimeElapsed[self] = elapsed; - float target = Settings.NoRespawnAnimation ? 0.05f : AkronModuleSettings.ClampRespawnTimeSeconds(Settings.RespawnTimeSeconds); + float target = respawnAnimationAllowed ? 0.05f : AkronModuleSettings.ClampRespawnTimeSeconds(Settings.RespawnTimeSeconds); if (elapsed >= target) { PlayerDeadBodyEndMethod?.Invoke(self, Array.Empty()); respawnTimeElapsed.Remove(self); @@ -246,7 +248,7 @@ private static AkronStartPos ApplyPracticeRespawnPoint(Player player) { AkronStartPos startPos = AkronActions.GetDeathRespawnStartPos(level, player.Position); if (startPos != null && (string.IsNullOrWhiteSpace(startPos.AreaSid) || string.Equals(startPos.AreaSid, level.Session.Area.GetSID())) && - TryUse(AkronFeatureKind.StartPosTools)) { + TryUseRuntime(AkronFeatureKind.StartPosTools)) { // Keep vanilla death accounting and visuals, then reload the // StartPos state at frame end so entities, sounds, and player // state match the latest loaded practice snapshot. @@ -261,7 +263,7 @@ private static AkronStartPos ApplyPracticeRespawnPoint(Player player) { } private static void PlayerOnUpdate(On.Celeste.Player.orig_Update orig, Player self) { - if (!Settings.Noclip || !TryUse(AkronFeatureKind.Noclip)) { + if (!Settings.Noclip || !TryUseRuntime(AkronFeatureKind.Noclip)) { RestoreNoclipDepth(); bool wasGrounded = self.OnGround(); int dashesBefore = self.Dashes; @@ -281,7 +283,7 @@ private static void CelesteOnFreeze(On.Celeste.Celeste.orig_Freeze orig, float t if (Instance?._Settings != null && Settings.NoFreezeFrames && ShouldSuppressFreezeFrames(time) && - TryUse(AkronFeatureKind.FreezeFrames)) { + TryUseRuntime(AkronFeatureKind.FreezeFrames)) { return; } @@ -315,7 +317,7 @@ private static void EnsureOverlay(Scene scene) { private static void ApplyEnabledRuntimeFeatures(Level level) { - if (!Settings.Noclip) { + if (!Settings.Noclip || !AkronPolicy.CanUse(AkronFeatureKind.Noclip).Allowed) { RestoreNoclipDepth(); RestorePlayerVisibilityOverride(); } @@ -333,19 +335,14 @@ private static void ApplyEnabledRuntimeFeatures(Level level) { return; } -#pragma warning disable CS0618 - if (Session.TimescaleEnabled) { - Engine.TimeRate = Session.TimescaleMultiplier; - } -#pragma warning restore CS0618 - if (Settings.InfiniteStamina && TryUse(AkronFeatureKind.InfiniteStamina)) { + if (Settings.InfiniteStamina && TryUseRuntime(AkronFeatureKind.InfiniteStamina)) { player.Stamina = 110f; } ApplyDashCountOverride(player, false); - if (Settings.InfiniteDash && TryUse(AkronFeatureKind.InfiniteDash)) { + if (Settings.InfiniteDash && TryUseRuntime(AkronFeatureKind.InfiniteDash)) { int maxDashes = EffectiveDashCountLimit(player); if (maxDashes <= 0) { player.Dashes = 0; @@ -373,7 +370,7 @@ private static void ApplyEnabledRuntimeFeatures(Level level) { ApplyTransitionSpeed(level); ApplyVisualPlayerOverrides(player); - if (ShouldApplyAnyVisualNoiseSuppression() && TryUse(AkronFeatureKind.ReducedVisualNoise)) { + if (ShouldApplyAnyVisualNoiseSuppression() && TryUseRuntime(AkronFeatureKind.ReducedVisualNoise)) { ApplyReducedVisualNoise(level); } } @@ -386,15 +383,15 @@ private static void RecordActiveFrameBypassUse() { return; } - if (Settings.FpsBypass) { - TryUse(AkronFeatureKind.FpsBypass); + if (Settings.FpsBypass && AkronPolicy.CanUse(AkronFeatureKind.FpsBypass).Allowed) { + TryUseRuntime(AkronFeatureKind.FpsBypass); } - if (Settings.TpsBypass) { - TryUse(AkronFeatureKind.TpsBypass); + if (Settings.TpsBypass && AkronPolicy.CanUse(AkronFeatureKind.TpsBypass).Allowed) { + TryUseRuntime(AkronFeatureKind.TpsBypass); } - if (!Settings.FpsBypass) { + if (!Settings.FpsBypass || !AkronPolicy.CanUse(AkronFeatureKind.FpsBypass).Allowed) { return; } @@ -418,7 +415,7 @@ private static void ApplyJumpHackAfterPlayerUpdate(Level level) { return; } - if (!Settings.JumpHack) { + if (!Settings.JumpHack || !AkronPolicy.CanUse(AkronFeatureKind.MovementStatMutation).Allowed) { jumpHackAirJumpsUsed = 0; return; } @@ -447,7 +444,7 @@ private static void ApplyJumpHackAfterPlayerUpdate(Level level) { return; } - if (!TryUse(AkronFeatureKind.MovementStatMutation)) { + if (!TryUseRuntime(AkronFeatureKind.MovementStatMutation)) { return; } @@ -515,7 +512,7 @@ private static void ApplyDashCountOverride(Player player, bool refillCurrent) { return; } - if (!TryUse(AkronFeatureKind.DashCountOverride)) { + if (!TryUseRuntime(AkronFeatureKind.DashCountOverride)) { return; } @@ -539,7 +536,7 @@ private static void ApplyGroundRefillRulesAfterPlayerUpdate(Player player, bool } bool grounded = wasGrounded || player.OnGround(); - if (!grounded || !TryUse(AkronFeatureKind.GroundRefillRules)) { + if (!grounded || !TryUseRuntime(AkronFeatureKind.GroundRefillRules)) { return; } @@ -567,7 +564,7 @@ private static void ApplyAkronSpikeGroundRefill(Player player) { } private static int EffectiveDashCountLimit(Player player) { - if (Settings.DashCountOverride) { + if (Settings.DashCountOverride && AkronPolicy.CanUse(AkronFeatureKind.DashCountOverride).Allowed) { return AkronModuleSettings.ClampDashCountOverride(Settings.DashCountOverrideValue); } @@ -579,7 +576,7 @@ private static bool PlayerOnRefillDash(On.Celeste.Player.orig_RefillDash orig, P return orig(self); } - if (!TryUse(AkronFeatureKind.DashCountOverride)) { + if (!TryUseRuntime(AkronFeatureKind.DashCountOverride)) { return orig(self); } @@ -595,13 +592,13 @@ private static bool PlayerOnRefillDash(On.Celeste.Player.orig_RefillDash orig, P private static bool IsNativeAssistInvincibilityAllowed() { return Settings.Invincibility && AkronModuleSettings.NormalizeInvincibilityMode(Settings.InvincibilityMode) == AkronInvincibilityMode.Native && - TryUse(AkronFeatureKind.Invincibility); + TryUseRuntime(AkronFeatureKind.Invincibility); } private static bool IsAkronInvincibilityAllowed() { return Settings.Invincibility && AkronModuleSettings.NormalizeInvincibilityMode(Settings.InvincibilityMode) == AkronInvincibilityMode.Akron && - TryUse(AkronFeatureKind.Invincibility); + TryUseRuntime(AkronFeatureKind.Invincibility); } private static void RisingLavaOnPlayer(On.Celeste.RisingLava.orig_OnPlayer orig, RisingLava self, Player player) { @@ -696,7 +693,7 @@ private static void PlayerOnTransition(On.Celeste.Player.orig_OnTransition orig, } private static void PlayerOnRender(On.Celeste.Player.orig_Render orig, Player self) { - if (Settings.NoStaminaFlash && TryUse(AkronFeatureKind.ReducedVisualNoise)) { + if (Settings.NoStaminaFlash && TryUseRuntime(AkronFeatureKind.ReducedVisualNoise)) { self.flash = true; } @@ -745,7 +742,7 @@ private static Vector2 ApplyDashRedirectGuard(Vector2 redirectedAim) { return redirectedAim; } - return TryUse(AkronFeatureKind.InputAssistShortcut) + return TryUseRuntime(AkronFeatureKind.InputAssistShortcut) ? preRedirectDashAim.EightWayNormal() : redirectedAim; } diff --git a/Source/Module/akron-module-visual-runtime.cs b/Source/Module/akron-module-visual-runtime.cs index 44bd652c..0100dfee 100644 --- a/Source/Module/akron-module-visual-runtime.cs +++ b/Source/Module/akron-module-visual-runtime.cs @@ -9,6 +9,9 @@ namespace Celeste.Mod.Akron; public partial class AkronModule { + private static Level transitionSpeedLevel; + private static float previousTransitionDuration; + // Cursor Tools and Menu Mouse share the Left Alt default. While Menu Mouse drives a // menu it owns the cursor, so the hold must not also pan, zoom or freeze the level // behind it (freezing would stop the pause menu from updating at all). @@ -17,7 +20,7 @@ internal static bool ShouldUseCursorToolsHold(bool cursorTools, bool cursorTools } internal static bool IsCursorToolsHoldActive() { - return ShouldUseCursorToolsHold( + return AkronPolicy.CanUse(AkronFeatureKind.CursorTools).Allowed && ShouldUseCursorToolsHold( Settings.CursorTools, Settings.CursorToolsHold?.Check ?? false, IsOverlayVisible, @@ -151,7 +154,7 @@ private static void ApplyClickTeleport(Level level, Player player) { Vector2? capturedTarget = pendingClickTeleportTarget; pendingClickTeleportTarget = null; - if (!capturedTarget.HasValue || !TryUse(AkronFeatureKind.ClickTeleport)) { + if (!capturedTarget.HasValue || !TryUseRuntime(AkronFeatureKind.ClickTeleport)) { return; } @@ -167,7 +170,7 @@ internal static bool ApplyClickTeleportForQa(Level level, Vector2 mouseScreenPos player == null || player.Dead || IsOverlayVisible || - !TryUse(AkronFeatureKind.ClickTeleport)) { + !TryUseRuntime(AkronFeatureKind.ClickTeleport)) { return false; } @@ -243,16 +246,17 @@ internal static void MoveHairForTeleport(Player player, Vector2 delta) { private static void UpdateCursorZoom(Level level) { bool cursorToolsHoldActive = IsCursorToolsHoldActive(); - bool cursorZoomEnabled = IsCursorZoomEffectiveEnabled(); + bool cursorZoomAllowed = AkronPolicy.CanUse(AkronFeatureKind.CursorZoom).Allowed; + bool cursorZoomEnabled = IsCursorZoomEffectiveEnabled() && cursorZoomAllowed; bool bindDown = Settings.CursorZoomHold?.Check ?? false; // Menu Mouse reads the same wheel; like the overlay, it pauses zoom input. - bool gated = !cursorZoomEnabled || IsOverlayVisible || AkronMenuMouse.ShowsCursor || !AkronPolicy.CanUse(AkronFeatureKind.CursorZoom).Allowed; + bool gated = !cursorZoomEnabled || IsOverlayVisible || AkronMenuMouse.ShowsCursor; bool bindPressed = TrackFreshPress(Settings.CursorZoom && bindDown, gated, ref cursorZoomLastBindDown); if (gated) { cursorZoomHadScrollSample = false; if (!cursorZoomEnabled) { cursorZoomToggleActive = false; - DeactivateCursorZoom(level); + DeactivateCursorZoom(level, resetPreference: cursorZoomAllowed); } return; } @@ -272,7 +276,7 @@ private static void UpdateCursorZoom(Level level) { return; } - if (!TryUse(AkronFeatureKind.CursorZoom)) { + if (!TryUseRuntime(AkronFeatureKind.CursorZoom)) { return; } @@ -294,7 +298,7 @@ private static void UpdateCursorZoom(Level level) { } internal static void ApplyCursorZoomFrame(Level level, Vector2 mouseScreenPosition) { - if (level == null) { + if (level == null || !AkronPolicy.CanUse(AkronFeatureKind.CursorZoom).Allowed) { return; } @@ -322,13 +326,13 @@ internal static void ApplyCursorZoomFrame(Level level, Vector2 mouseScreenPositi cursorZoomOwnedByExtendedCamera = false; } - private static void DeactivateCursorZoom(Level level) { + private static void DeactivateCursorZoom(Level level, bool resetPreference = true) { cursorZoomHadScrollSample = false; if (!cursorZoomApplied) { return; } - if (Settings.CursorZoomResetOnDeactivate) { + if (resetPreference && Settings.CursorZoomResetOnDeactivate) { Settings.CursorZoomPercent = 100; } @@ -435,18 +439,32 @@ internal static string DescribeCursorZoomOwner() { } private static void ApplyTransitionSpeed(Level level) { - if (!IsTransitionSpeedEffective(Settings)) { + if (!IsTransitionSpeedEffective(Settings) || !AkronPolicy.CanUse(AkronFeatureKind.TransitionSpeed).Allowed) { + RestoreTransitionSpeed(); return; } float multiplier = AkronModuleSettings.ClampTransitionSpeedMultiplier(Settings.TransitionSpeedMultiplier); - if (!TryUse(AkronFeatureKind.TransitionSpeed)) { + if (!TryUseRuntime(AkronFeatureKind.TransitionSpeed)) { return; } + if (transitionSpeedLevel != level) { + RestoreTransitionSpeed(); + transitionSpeedLevel = level; + previousTransitionDuration = level.NextTransitionDuration; + } + level.NextTransitionDuration = TransitionDurationForSpeedMultiplier(multiplier); } + internal static void RestoreTransitionSpeed() { + if (transitionSpeedLevel != null) { + transitionSpeedLevel.NextTransitionDuration = previousTransitionDuration; + transitionSpeedLevel = null; + } + } + internal static float TransitionDurationForSpeedMultiplier(float multiplier) { return 0.65f / AkronModuleSettings.ClampTransitionSpeedMultiplier(multiplier); } @@ -467,10 +485,10 @@ private static void ApplyVisualPlayerOverrides(Player player) { ApplyPlayerVisibilityOverride(player); - if (Settings.TrailVisibility == AkronTrailVisibility.Hidden && TryUse(AkronFeatureKind.CustomTrail)) { + if (Settings.TrailVisibility == AkronTrailVisibility.Hidden && TryUseRuntime(AkronFeatureKind.CustomTrail)) { forcedTrailFrame = 0; TrailManager.Clear(); - } else if ((Settings.TrailVisibility == AkronTrailVisibility.Always || Settings.CustomTrail) && TryUse(AkronFeatureKind.CustomTrail)) { + } else if ((Settings.TrailVisibility == AkronTrailVisibility.Always || Settings.CustomTrail) && TryUseRuntime(AkronFeatureKind.CustomTrail)) { int cuttingRate = AkronModuleSettings.ClampTrailCuttingRate(Settings.TrailCuttingRate); forcedTrailFrame = (forcedTrailFrame + 1) % cuttingRate; if (forcedTrailFrame == 0) { @@ -535,6 +553,7 @@ private static Color ColorFromRgb(int rgb) { } private static void PlayerOnUpdateHair(On.Celeste.Player.orig_UpdateHair orig, Player self, bool applyGravity) { + RestoreMadelineVisualOverrides(); orig(self, applyGravity); ApplyMadelineVisualOverrides(self); } diff --git a/Source/Overlay/akron-overlay-bindable-actions.cs b/Source/Overlay/akron-overlay-bindable-actions.cs index 79b683ff..84e8e060 100644 --- a/Source/Overlay/akron-overlay-bindable-actions.cs +++ b/Source/Overlay/akron-overlay-bindable-actions.cs @@ -87,15 +87,13 @@ private static IEnumerable BuildPopupBindableActions(Level level yield return new BindableAction(PopupActionKey("Timescale", "Increase"), "Timescale / Increase", () => ApplyOptionsPopupDelta("Timescale", 1)); yield return new BindableAction(PopupActionKey("Timescale", "Reset"), "Timescale / Reset", () => { AkronModuleSession session = AkronModule.Session; - if (session == null) { + if (session == null || !AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed) { return; } session.TimescaleMultiplier = 1f; session.TimescaleEnabled = false; -#pragma warning disable CS0618 - Engine.TimeRate = 1f; -#pragma warning restore CS0618 + AkronModule.ReleaseTimescale(); Engine.Scene?.Add(new AkronToast("Timescale reset.")); }); diff --git a/Source/Overlay/akron-overlay-entries.cs b/Source/Overlay/akron-overlay-entries.cs index b952aeaf..9551905b 100644 --- a/Source/Overlay/akron-overlay-entries.cs +++ b/Source/Overlay/akron-overlay-entries.cs @@ -917,10 +917,16 @@ private bool MatchesSearch(string tab, ActionEntry entry) { } private string DescribeRenderedEntryValue(ActionEntry entry) { - return entry.Value(); + return !entry.IsToggle && entry.FeatureKind.HasValue && !AkronPolicy.CanUse(entry.FeatureKind.Value).Allowed + ? "Map-suppressed" + : entry.Value(); } private static string DescribeSelectedFlag(Level level) { + if (!AkronPolicy.CanUse(AkronFeatureKind.FlagInspector).Allowed) { + return "Map-suppressed"; + } + if (level == null) { return "No level"; } diff --git a/Source/Overlay/akron-overlay-imgui-popups.cs b/Source/Overlay/akron-overlay-imgui-popups.cs index a1503d73..bd5ab5f6 100644 --- a/Source/Overlay/akron-overlay-imgui-popups.cs +++ b/Source/Overlay/akron-overlay-imgui-popups.cs @@ -385,6 +385,12 @@ private static string DescribeTooltipMeta(ActionEntry entry) { string state = SafeDescribeEntryValue(entry); string control = DescribeEntryControl(entry); string classification = DescribeEntryClassification(entry); + if (entry.FeatureKind.HasValue) { + AkronPolicyDecision decision = AkronPolicy.CanUse(entry.FeatureKind.Value); + if (!decision.Allowed) { + state = "Saved: " + state + " / Effective: suppressed. " + decision.Message; + } + } if (string.IsNullOrWhiteSpace(classification)) { return control + " / " + state; } @@ -434,6 +440,9 @@ private static string DescribeEntryClassification(ActionEntry entry) { private static string SafeDescribeEntryValue(ActionEntry entry) { try { + if (!entry.IsToggle && entry.FeatureKind.HasValue && !AkronPolicy.CanUse(entry.FeatureKind.Value).Allowed) { + return "Map-suppressed"; + } if (entry.Control == OverlayEntryControl.NumericInput) { return (entry.Value?.Invoke() ?? "Ready") + " / " + FormatNumericEntryValue(entry); } diff --git a/Source/Overlay/akron-overlay-imgui-renderer.cs b/Source/Overlay/akron-overlay-imgui-renderer.cs index dd950cac..7b632d1d 100644 --- a/Source/Overlay/akron-overlay-imgui-renderer.cs +++ b/Source/Overlay/akron-overlay-imgui-renderer.cs @@ -293,10 +293,10 @@ private void DrawImGuiActionEntry(ActionEntry entry, int index, int tabIndex) { bool selected = selectedPanel == SelectionPanel.Actions && selectedActionIndex == index && selectedTabIndex == tabIndex; - bool entryEnabled = entry.Enabled(); + bool entryEnabled = entry.Enabled() && (!entry.FeatureKind.HasValue || AkronPolicy.CanUse(entry.FeatureKind.Value).Allowed); bool hasOptionsPopup = entry.HasOptionsPopup; - bool activeState = entry.Active?.Invoke() ?? - (ShouldReadEntryValueForActiveState(entry) && IsOnState(entry.Value())); + bool activeState = entryEnabled && (entry.Active?.Invoke() ?? + (ShouldReadEntryValueForActiveState(entry) && IsOnState(entry.Value()))); bool searchMatch = !string.IsNullOrWhiteSpace(searchQuery) && entry.Control != OverlayEntryControl.SearchInput && MatchesSearch(entry.Tab, entry); activeState = activeState || searchMatch; string id = "##akron_" + entry.Tab + "_" + entry.ActionKey + "_" + index; diff --git a/Source/Overlay/akron-overlay-interop-entries.cs b/Source/Overlay/akron-overlay-interop-entries.cs index ce60cd15..1bd8bbcf 100644 --- a/Source/Overlay/akron-overlay-interop-entries.cs +++ b/Source/Overlay/akron-overlay-interop-entries.cs @@ -132,7 +132,8 @@ private static List BuildExtendedVariantEntries() { new OverlayEntry( "Extended Variants Master", () => AkronExtendedVariants.Available, - () => AkronExtendedVariants.Available ? AkronExtendedVariants.StatusSummary : "EVM missing", + () => !AkronExtendedVariants.Available ? "EVM missing" : AkronPolicy.CanUse(AkronFeatureKind.ExtendedVariantMode).Allowed + ? AkronExtendedVariants.StatusSummary : AkronExtendedVariants.DescribeSavedSetting("MasterSwitch"), () => { if (!AkronExtendedVariants.Available) { Engine.Scene?.Add(new AkronToast("Extended Variant Mode is not loaded.")); @@ -154,15 +155,18 @@ private static List BuildExtendedVariantEntries() { new OverlayEntry( "Extended Variants Randomizer", () => AkronExtendedVariants.Available, - () => AkronExtendedVariants.RandomizerEnabled ? "On" : "Off", + () => AkronExtendedVariants.DescribeSavedSetting("ChangeVariantsRandomly"), () => ApplyOptionsPopupDelta("Extended Variants Randomizer", 1), BuildSearchTerms("Extended Variants Randomizer", new[] { "extended variant mode", "randomizer" }), - true), - Action("Reset Extended", () => AkronExtendedVariants.Available, () => "Defaults", () => { + true, + featureKind: AkronFeatureKind.ExtendedVariantMode), + Action("Reset Extended", AkronFeatureKind.ExtendedVariantMode, () => AkronExtendedVariants.Available, () => "Defaults", () => { + if (!AkronPolicy.CanUse(AkronFeatureKind.ExtendedVariantMode).Allowed) return; AkronExtendedVariants.ResetExtended(); Engine.Scene?.Add(new AkronToast("Extended variants reset.")); }, "extended variant mode", "reset"), - Action("Reset Vanilla", () => AkronExtendedVariants.Available, () => "Defaults", () => { + Action("Reset Vanilla", AkronFeatureKind.ExtendedVariantMode, () => AkronExtendedVariants.Available, () => "Defaults", () => { + if (!AkronPolicy.CanUse(AkronFeatureKind.ExtendedVariantMode).Allowed) return; AkronExtendedVariants.ResetVanilla(); Engine.Scene?.Add(new AkronToast("Vanilla variants reset.")); }, "extended variant mode", "reset") @@ -189,7 +193,8 @@ private static List BuildExtendedVariantEntries() { } }, BuildSearchTerms(label, new[] { "extended variant mode", option.Name, option.TypeName }), - true)); + true, + featureKind: AkronFeatureKind.ExtendedVariantMode)); } else { entries.Add(new OverlayEntry( label, @@ -197,7 +202,8 @@ private static List BuildExtendedVariantEntries() { () => AkronExtendedVariants.DescribeConfiguredState(AkronExtendedVariants.GetOption(option.Name)), () => ApplyOptionsPopupDelta(label, 1), BuildSearchTerms(label, new[] { "extended variant mode", option.Name, option.TypeName }), - true)); + true, + featureKind: AkronFeatureKind.ExtendedVariantMode)); } } diff --git a/Source/Overlay/akron-overlay-sprite-renderer.cs b/Source/Overlay/akron-overlay-sprite-renderer.cs index 6b65a79c..833bbbeb 100644 --- a/Source/Overlay/akron-overlay-sprite-renderer.cs +++ b/Source/Overlay/akron-overlay-sprite-renderer.cs @@ -41,7 +41,7 @@ private void RenderActionEntry(ActionLayout action) { bool keybindInput = action.Entry.Control == OverlayEntryControl.Keybind || action.Entry.Control == OverlayEntryControl.KeybindReadOnly; bool searchInput = action.Entry.Control == OverlayEntryControl.SearchInput; bool buttonOnly = !stateOnly && !hasOptionsPopup && ShouldDrawActionSideBars(action.Entry); - bool entryEnabled = action.Entry.Enabled(); + bool entryEnabled = action.Entry.Enabled() && (!action.Entry.FeatureKind.HasValue || AkronPolicy.CanUse(action.Entry.FeatureKind.Value).Allowed); Color activeColor = ResolveActionActiveColor(action.Entry, selected || hovered); if (hovered) { diff --git a/Source/Recorder/akron-internal-audio-recorder.cs b/Source/Recorder/akron-internal-audio-recorder.cs index 18441c03..3aee83f8 100644 --- a/Source/Recorder/akron-internal-audio-recorder.cs +++ b/Source/Recorder/akron-internal-audio-recorder.cs @@ -56,6 +56,11 @@ public static bool IsReplayBufferCapturing { private static bool TryStart(AkronModuleSettings settings, AudioCapturePurpose purpose, out string warning) { warning = string.Empty; + AkronPolicyDecision decision = AkronPolicy.CanUse(AkronFeatureKind.InternalRecorder); + if (!decision.Allowed) { + warning = decision.Message; + return false; + } List trackRequests = BuildTrackRequests(settings); if (trackRequests.Count == 0) { return false; @@ -255,6 +260,9 @@ private static RESULT ReadAudio(CapturedTrack track, ref DSP_STATE dspState, Int float[] samples = new float[sampleCount]; Marshal.Copy(inputBuffer, samples, 0, sampleCount); Marshal.Copy(samples, 0, outputBuffer, sampleCount); + if (!AkronPolicy.CanUse(AkronFeatureKind.InternalRecorder).Allowed) { + return RESULT.OK; + } byte[] pcm = new byte[sampleCount * 2]; float currentGain; diff --git a/Source/Recorder/akron-internal-recorder-replay.cs b/Source/Recorder/akron-internal-recorder-replay.cs index 471ac4ab..e02e248a 100644 --- a/Source/Recorder/akron-internal-recorder-replay.cs +++ b/Source/Recorder/akron-internal-recorder-replay.cs @@ -263,6 +263,10 @@ public static IEnumerable SelectReplaySegmentsForTesting(string director } private static void QueueClipSave(string kind, DateTime startUtc, DateTime eventUtc, int postRollSeconds) { + if (!AkronPolicy.CanUse(AkronFeatureKind.InternalRecorder).Allowed) { + return; + } + if (AkronModuleSettings.ClampRecordingReplayBufferSeconds(AkronModule.Settings.RecordingReplayBufferSeconds) <= 0) { return; } @@ -275,6 +279,10 @@ private static void QueueClipSave(string kind, DateTime startUtc, DateTime event } private static void SaveReplayWindow(Scene scene, DateTime startUtc, DateTime endUtc, string kind) { + if (!AkronPolicy.CanUse(AkronFeatureKind.InternalRecorder).Allowed) { + return; + } + string directory = replayDirectory; if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory)) { Engine.Scene?.Add(new AkronToast("Replay buffer has no captured segments yet.")); @@ -408,6 +416,10 @@ private static void EnsureReplayBufferDurationConfigured(AkronModuleSettings set } private static void EnsureReplayBuffer(Scene scene, AkronModuleSettings settings, RecorderSpec spec) { + if (!AkronPolicy.CanUse(AkronFeatureKind.InternalRecorder).Allowed) { + return; + } + lock (Sync) { if (replayProcess != null && activeSpec.Equals(spec)) { return; diff --git a/Source/Recorder/akron-internal-recorder.cs b/Source/Recorder/akron-internal-recorder.cs index fc0b0e74..e1ade39b 100644 --- a/Source/Recorder/akron-internal-recorder.cs +++ b/Source/Recorder/akron-internal-recorder.cs @@ -286,7 +286,29 @@ public static void BuildCompletionVideo(Scene scene) { } } + public static void ApplyMapRestrictions() { + if (AkronPolicy.CanUse(AkronFeatureKind.InternalRecorder).Allowed) { + return; + } + + lock (Sync) { + PendingClipSaves.Clear(); + manualReplayBufferEnabled = false; + } + if (IsRecording) { + Stop(); + } + if (IsReplayBuffering) { + StopReplayBuffer(false); + } + } + public static void Update(Scene scene) { + if (!AkronPolicy.CanUse(AkronFeatureKind.InternalRecorder).Allowed) { + ApplyMapRestrictions(); + return; + } + if (scene == null) { return; } @@ -326,6 +348,11 @@ public static void CaptureFrame(Scene scene) { } private static void CaptureFrame(Scene scene, bool captureManualRecording) { + if (!AkronPolicy.CanUse(AkronFeatureKind.InternalRecorder).Allowed) { + ApplyMapRestrictions(); + return; + } + if (scene == null) { return; } diff --git a/Source/Runtime/akron-autosave.cs b/Source/Runtime/akron-autosave.cs index 429196b2..9cd477cb 100644 --- a/Source/Runtime/akron-autosave.cs +++ b/Source/Runtime/akron-autosave.cs @@ -62,7 +62,7 @@ private static void Request(string reason, bool force) { return; } - if (!AkronModule.TryUse(AkronFeatureKind.Autosave)) { + if (!(force ? AkronModule.TryUse(AkronFeatureKind.Autosave) : AkronModule.TryUseRuntime(AkronFeatureKind.Autosave))) { return; } diff --git a/Source/Runtime/akron-deload-simulator.cs b/Source/Runtime/akron-deload-simulator.cs index db0a2deb..b9ff84a6 100644 --- a/Source/Runtime/akron-deload-simulator.cs +++ b/Source/Runtime/akron-deload-simulator.cs @@ -51,7 +51,7 @@ public static bool IsUsed(Level level) { public static bool TrySimulate(Level level, float beforeDeloadSeconds, out int steps) { steps = 0; - if (!Claims.TryClaim(level)) { + if (!AkronPolicy.CanUse(AkronFeatureKind.DeloadSimulation).Allowed || !Claims.TryClaim(level)) { return false; } diff --git a/Source/Runtime/akron-ear-aid.cs b/Source/Runtime/akron-ear-aid.cs index 8a08b6dc..b8d792ea 100644 --- a/Source/Runtime/akron-ear-aid.cs +++ b/Source/Runtime/akron-ear-aid.cs @@ -5,6 +5,8 @@ namespace Celeste.Mod.Akron; public static class AkronEarAid { + private static readonly List<(EventInstance Instance, string Key, float OriginalVolume, bool Applied)> VolumeOverrides = new(); + public sealed class SoundDefinition { public SoundDefinition(string key, string label, params string[] eventFragments) { Key = key; @@ -101,13 +103,35 @@ public static void ApplyVolume(EventDescription description, EventInstance insta } SoundDefinition sound = FindBestMatch(eventName); - if (sound == null) { + if (sound == null || !OverrideEnabled(sound.Key)) { return; } - if (OverrideEnabled(sound.Key) && AkronModule.TryUse(AkronFeatureKind.SoundVolumeOverride)) { - int volume = VolumeFor(sound.Key); - instance.setVolume(volume / 100f); + if (instance.getVolume(out float originalVolume, out _) != FMOD.RESULT.OK) { + return; + } + + bool applied = AkronModule.TryUseRuntime(AkronFeatureKind.SoundVolumeOverride); + VolumeOverrides.Add((instance, sound.Key, originalVolume, applied)); + if (applied) { + instance.setVolume(VolumeFor(sound.Key) / 100f); + } + } + + internal static void ReconcileVolumeOverrides(bool restore = false) { + bool allowed = !restore && AkronPolicy.CanUse(AkronFeatureKind.SoundVolumeOverride).Allowed; + for (int index = VolumeOverrides.Count - 1; index >= 0; index--) { + var entry = VolumeOverrides[index]; + if (!entry.Instance.isValid()) { + VolumeOverrides.RemoveAt(index); + continue; + } + + bool apply = allowed && OverrideEnabled(entry.Key); + if (apply != entry.Applied) { + entry.Instance.setVolume(apply ? VolumeFor(entry.Key) / 100f : entry.OriginalVolume); + VolumeOverrides[index] = (entry.Instance, entry.Key, entry.OriginalVolume, apply); + } } } diff --git a/Source/Runtime/akron-frozen-death-sprite.cs b/Source/Runtime/akron-frozen-death-sprite.cs index c94c0f26..6135d021 100644 --- a/Source/Runtime/akron-frozen-death-sprite.cs +++ b/Source/Runtime/akron-frozen-death-sprite.cs @@ -95,7 +95,7 @@ public static AkronFrozenDeathSprite Capture(Player player) { public override void Update() { base.Update(); - if (Scene is not Level level) { + if (Scene is not Level level || !AkronPolicy.CanUse(AkronFeatureKind.DeathVisuals).Allowed) { RemoveSelf(); return; } @@ -108,7 +108,7 @@ public override void Update() { } public override void Render() { - if (AkronModule.ShouldHideAkronRenderSurfacesBehindDeathWipe()) { + if (AkronModule.ShouldHideAkronRenderSurfacesBehindDeathWipe() || !AkronPolicy.CanUse(AkronFeatureKind.DeathVisuals).Allowed) { return; } diff --git a/Source/Runtime/akron-madeline-visuals.cs b/Source/Runtime/akron-madeline-visuals.cs index 8bbf527d..371b114d 100644 --- a/Source/Runtime/akron-madeline-visuals.cs +++ b/Source/Runtime/akron-madeline-visuals.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Celeste; using Microsoft.Xna.Framework; using Monocle; @@ -6,11 +7,32 @@ namespace Celeste.Mod.Akron; public partial class AkronModule { + private static Player madelineVisualPlayer; + private static Color? previousMadelineHairColor; + private static int? previousMadelineHairCount; + private static readonly Dictionary PreviousCrownColors = new(); + + internal static void RestoreMadelineVisualOverrides() { + if (madelineVisualPlayer?.Hair != null && previousMadelineHairColor.HasValue) { + madelineVisualPlayer.Hair.Color = previousMadelineHairColor.Value; + } + if (madelineVisualPlayer?.Sprite != null && previousMadelineHairCount.HasValue) { + madelineVisualPlayer.Sprite.HairCount = previousMadelineHairCount.Value; + } + foreach (KeyValuePair entry in PreviousCrownColors) { + entry.Key.SetColor(entry.Value); + } + PreviousCrownColors.Clear(); + madelineVisualPlayer = null; + previousMadelineHairColor = null; + previousMadelineHairCount = null; + } + private static bool TryRenderMadelineSyncedDeathEffect(Player player) { if (!ShouldSyncMadelineEffect(Settings.MadelineDeathEffectSync) || player?.StateMachine?.State != 14 || global::Celeste.SaveData.Instance?.Assists.InvisibleMotion == true || - !TryUse(AkronFeatureKind.MadelineEffectSync) || + !TryUseRuntime(AkronFeatureKind.MadelineEffectSync) || !TryResolveMadelineSyncColor(player, out Color color)) { return false; } @@ -21,7 +43,7 @@ private static bool TryRenderMadelineSyncedDeathEffect(Player player) { private static bool TryRunMadelineSyncedDashParticles(On.Celeste.Player.orig_DashBegin orig, Player player) { if (!ShouldSyncMadelineEffect(Settings.MadelineDashParticleSync) || - !TryUse(AkronFeatureKind.MadelineEffectSync) || + !TryUseRuntime(AkronFeatureKind.MadelineEffectSync) || !TryResolveMadelineSyncColor(player, out Color color)) { return false; } @@ -53,7 +75,7 @@ private static Color ResolveForcedTrailColor(Player player) { } if (ShouldSyncMadelineEffect(Settings.MadelineDashTrailSync) && - TryUse(AkronFeatureKind.MadelineEffectSync) && + TryUseRuntime(AkronFeatureKind.MadelineEffectSync) && TryResolveMadelineSyncColor(player, out Color syncedColor)) { return syncedColor; } @@ -62,13 +84,14 @@ private static Color ResolveForcedTrailColor(Player player) { } private static void ApplyMadelineVisualOverrides(Player player) { + madelineVisualPlayer = player; ApplyMadelineColors(player); ApplyMadelineHairLength(player); ApplyMadelineCrownSync(player); } private static void ApplyMadelineColors(Player player) { - if (player == null || !Settings.MadelineColors || !TryUse(AkronFeatureKind.MadelineColors)) { + if (player == null || !Settings.MadelineColors || !TryUseRuntime(AkronFeatureKind.MadelineColors)) { return; } @@ -86,6 +109,7 @@ private static void ApplyMadelineColors(Player player) { } if (TryResolveMadelineConfiguredColor(player, out Color color)) { + previousMadelineHairColor = player.Hair.Color; player.Hair.Color = color; } } @@ -93,7 +117,7 @@ private static void ApplyMadelineColors(Player player) { private static void ApplyMadelineHairLength(Player player) { if (player?.Sprite == null || !Settings.MadelineHairLength || - !TryUse(AkronFeatureKind.MadelineHairLength)) { + !TryUseRuntime(AkronFeatureKind.MadelineHairLength)) { return; } @@ -103,6 +127,7 @@ private static void ApplyMadelineHairLength(Player player) { return; } + previousMadelineHairCount = player.Sprite.HairCount; player.Sprite.HairCount = ResolveMadelineHairLength(player.Dashes, player.MaxDashes); } @@ -125,13 +150,14 @@ public static int ResolveMadelineHairLength(int dashes, int maxDashes) { private static void ApplyMadelineCrownSync(Player player) { if (!ShouldSyncMadelineEffect(Settings.MadelineCrownColorSync) || - !TryUse(AkronFeatureKind.MadelineEffectSync) || + !TryUseRuntime(AkronFeatureKind.MadelineEffectSync) || !TryResolveMadelineSyncColor(player, out Color color)) { return; } foreach (Sprite sprite in player.Components.GetAll()) { if (sprite?.Animations?.ContainsKey("crown") == true) { + PreviousCrownColors[sprite] = sprite.Color; sprite.SetColor(color); } } @@ -148,7 +174,7 @@ private static bool TryResolveMadelineSyncColor(Player player, out Color color) return false; } - if (Settings.MadelineColors && TryUse(AkronFeatureKind.MadelineColors) && TryResolveMadelineConfiguredColor(player, out color)) { + if (Settings.MadelineColors && TryUseRuntime(AkronFeatureKind.MadelineColors) && TryResolveMadelineConfiguredColor(player, out color)) { return true; } diff --git a/Source/Runtime/akron-noclip-runtime.cs b/Source/Runtime/akron-noclip-runtime.cs index 18eb3bf9..50c54deb 100644 --- a/Source/Runtime/akron-noclip-runtime.cs +++ b/Source/Runtime/akron-noclip-runtime.cs @@ -156,7 +156,7 @@ private static void UpdateNoclipAccuracyTintTimer() { } private static void RenderNoclipAccuracyTint() { - if (!Settings.NoclipAccuracy || !Settings.NoclipAccuracyTint || noclipAccuracyTintTimer <= 0f) { + if (!IsHazardAccuracyAllowed() || !Settings.NoclipAccuracyTint || noclipAccuracyTintTimer <= 0f) { return; } @@ -232,7 +232,9 @@ private static void RestoreNoclipDepth(Player player) { private static void ApplyPlayerVisibilityOverride(Player player) { bool shouldHide = Settings.HidePlayer && AkronPolicy.CanUse(AkronFeatureKind.HidePlayer).Allowed || - Settings.Noclip && Settings.NoclipHidePlayer; + Settings.Noclip && Settings.NoclipHidePlayer && + AkronPolicy.CanUse(AkronFeatureKind.Noclip).Allowed && + AkronPolicy.CanUse(AkronFeatureKind.HidePlayer).Allowed; if (!shouldHide) { RestorePlayerVisibilityOverride(); return; diff --git a/Source/Runtime/akron-practice-stats.cs b/Source/Runtime/akron-practice-stats.cs index 2f7f7151..4e303281 100644 --- a/Source/Runtime/akron-practice-stats.cs +++ b/Source/Runtime/akron-practice-stats.cs @@ -133,7 +133,7 @@ public static void ResetAttemptTimer() { private static void FinalizeRoom(Level level, string roomName) { long roomTime = GetCurrentRoomStatTime(level); - if (string.IsNullOrWhiteSpace(roomName) || roomTime <= 0) { + if (string.IsNullOrWhiteSpace(roomName) || roomTime <= 0 || !AkronPolicy.CanUse(AkronFeatureKind.RoomStatTracker).Allowed) { return; } diff --git a/Source/Runtime/akron-runtime-guards.cs b/Source/Runtime/akron-runtime-guards.cs index ea35c8b6..f2580bbe 100644 --- a/Source/Runtime/akron-runtime-guards.cs +++ b/Source/Runtime/akron-runtime-guards.cs @@ -26,7 +26,7 @@ public partial class AkronModule { private static bool lagPauserRepeatCooldownPending; private static void LevelOnPause(Level level, int startIndex, bool minimal, bool quickReset) { - if (level == null || !Settings.PauseTracker || !TryUse(AkronFeatureKind.PauseTracker)) { + if (level == null || !Settings.PauseTracker || !TryUseRuntime(AkronFeatureKind.PauseTracker)) { return; } @@ -54,14 +54,14 @@ private static void LevelOnUnpause(Level level) { lagPauserRepeatCooldownPending = false; SuppressLagPauserForWindow(Settings.LagPauserRepeatCooldownMs); } - if (level != null && Settings.PauseTracker && Session.PauseTrackerCurrentPauseStartedAt >= 0f) { + if (level != null && Settings.PauseTracker && AkronPolicy.CanUse(AkronFeatureKind.PauseTracker).Allowed && Session.PauseTrackerCurrentPauseStartedAt >= 0f) { Session.PauseTrackerPausedSeconds += Math.Max(0f, level.RawTimeActive - Session.PauseTrackerCurrentPauseStartedAt); Session.PauseTrackerCurrentPauseStartedAt = -1f; } if (level == null || !Settings.PauseCountdown || - !TryUse(AkronFeatureKind.PauseCountdown)) { + !TryUseRuntime(AkronFeatureKind.PauseCountdown)) { ClearPauseCountdown(); return; } @@ -79,6 +79,11 @@ internal static void NotifyUnpauseForQa(Level level) { } private static bool UpdatePauseCountdown(Level level) { + if (!Settings.PauseCountdown || !AkronPolicy.CanUse(AkronFeatureKind.PauseCountdown).Allowed) { + ClearPauseCountdown(); + return false; + } + float remaining = GetPauseCountdownRemaining(); if (remaining <= 0f) { ClearPauseCountdown(); @@ -235,7 +240,8 @@ private static void UpdateProofRecorderGuard(Level level) { if (proofRecorderGuardWarningShown || !Settings.ProofRecorderGuard || !Settings.SubmissionMode || - !AkronPolicy.CanUse(AkronFeatureKind.ProofRecorderGuard).Allowed) { + !AkronPolicy.CanUse(AkronFeatureKind.ProofRecorderGuard).Allowed || + !AkronPolicy.CanUse(AkronFeatureKind.SubmissionMode).Allowed) { return; } @@ -274,7 +280,7 @@ private static void MaybeShowDeathPbLossPrompt(Level level) { if (!Settings.DeathPbLossPrompt || Session.DeathPbLossPromptShown || level?.Session == null || - !TryUse(AkronFeatureKind.DeathPbLossRestart)) { + !TryUseRuntime(AkronFeatureKind.DeathPbLossRestart)) { return; } diff --git a/Source/Runtime/akron-runtime-options.cs b/Source/Runtime/akron-runtime-options.cs index 229c94c1..385a9498 100644 --- a/Source/Runtime/akron-runtime-options.cs +++ b/Source/Runtime/akron-runtime-options.cs @@ -12,6 +12,9 @@ namespace Celeste.Mod.Akron; public static class AkronRuntimeOptions { private const float FreeCameraMouseDeadzone = 0.08f; private static bool vanillaHudHidden; + private static bool audioPitchApplied; + private static Level screenshakeLevel; + private static bool previousScreenshakeDisabled; private static Player freeCameraLockedPlayer; private static bool freeCameraPlayerStateCaptured; private static int previousFreeCameraPlayerState; @@ -33,13 +36,18 @@ public static void Reset() { RestoreHudVisibility(); RestoreFreeCameraPlayerControl(); RestoreVisualTuning(); - SetAudioPitch(1f); + RestoreScreenshake(); + RestoreAudioPitch(); + AkronEarAid.ReconcileVolumeOverrides(restore: true); + AkronModule.RestoreTransitionSpeed(); + AkronModule.RestoreMadelineVisualOverrides(); SafeModeStatSnapshot.Clear(); safeModeSnapshotArea = string.Empty; } public static void Apply(Level level, Player player) { ApplyAudioSpeedAndPitch(); + AkronEarAid.ReconcileVolumeOverrides(); ApplyHudVisibility(level); ApplyPauseMenuVisibility(level); ApplyScreenshake(level); @@ -74,7 +82,14 @@ public static string DescribeFpsBypass() { return "Missing"; } - return AkronModule.Settings.FpsBypass ? ResolveCurrentFrameBypassRates().Describe() : "Off"; + return AkronModule.Settings.FpsBypass + ? AkronModuleSettings.ResolveFrameBypassRates( + AkronModule.Settings.FpsBypass, + AkronModule.Settings.FpsBypassTarget, + AkronModule.Settings.TpsBypass, + AkronModule.Settings.TpsBypassTarget, + AkronModule.Settings.FrameBypassMethod).Describe() + : "Off"; } public static string DescribeTpsBypass() { @@ -89,9 +104,9 @@ public static string DescribeTpsBypass() { public static AkronFrameBypassRates ResolveCurrentFrameBypassRates() { return AkronModuleSettings.ResolveFrameBypassRates( - AkronModule.Settings.FpsBypass, + AkronModule.Settings.FpsBypass && AkronPolicy.CanUse(AkronFeatureKind.FpsBypass).Allowed, AkronModule.Settings.FpsBypassTarget, - AkronModule.Settings.TpsBypass, + AkronModule.Settings.TpsBypass && AkronPolicy.CanUse(AkronFeatureKind.TpsBypass).Allowed, AkronModule.Settings.TpsBypassTarget, AkronModule.Settings.FrameBypassMethod); } @@ -157,7 +172,8 @@ public static string DescribeFreeCamera() { } public static bool ShouldFreezeGameplayForFreeCamera(Level level) { - return level != null && + return IsFreeCameraActive(level) && + AkronPolicy.CanUse(AkronFeatureKind.Freeze).Allowed && (AkronModule.Settings.FreeCamera && AkronModule.Settings.FreeCameraFreezeGameplay || AkronModule.IsCursorToolsFreezeGameplayEffectiveEnabled()); } @@ -180,7 +196,8 @@ public static bool ShouldSuppressPauseHud(Scene scene) { public static bool ShouldSuppressPauseBackgroundFade(Scene scene) { return scene is Level && AkronModule.IsPauseCountdownActive && - AkronModule.Settings.PauseCountdownHidePauseTint; + AkronModule.Settings.PauseCountdownHidePauseTint && + AkronPolicy.CanUse(AkronFeatureKind.PauseCountdown).Allowed; } public static void HoldSceneClockForSkippedLevelUpdate(Level level) { @@ -199,17 +216,24 @@ public static void HoldSceneClockForSkippedLevelUpdate(Level level) { } private static void ApplyAudioSpeedAndPitch() { + bool audioSpeedAllowed = AkronModule.Settings.AudioSpeed && AkronPolicy.CanUse(AkronFeatureKind.AudioSpeed).Allowed; + bool pitchShiftAllowed = AkronModule.Settings.PitchShift && AkronPolicy.CanUse(AkronFeatureKind.PitchShift).Allowed; + if (!audioSpeedAllowed && !pitchShiftAllowed) { + RestoreAudioPitch(); + return; + } + float speed = 1f; - if (AkronModule.Settings.AudioSpeed && AkronModule.TryUse(AkronFeatureKind.AudioSpeed)) { + if (audioSpeedAllowed && AkronModule.TryUseRuntime(AkronFeatureKind.AudioSpeed)) { speed = AkronModule.Settings.AudioSpeedPolicy switch { - AkronAudioSpeedPolicy.SyncTimescale => AkronModule.Session?.TimescaleEnabled == true ? AkronModule.Session.TimescaleMultiplier : 1f, + AkronAudioSpeedPolicy.SyncTimescale => AkronModule.Session?.TimescaleEnabled == true && AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed ? AkronModule.Session.TimescaleMultiplier : 1f, AkronAudioSpeedPolicy.Independent => AkronModuleSettings.ClampAudioMultiplier(AkronModule.Settings.AudioSpeedMultiplier), _ => 1f }; } float pitch = 1f; - if (AkronModule.Settings.PitchShift && AkronModule.TryUse(AkronFeatureKind.PitchShift)) { + if (pitchShiftAllowed && AkronModule.TryUseRuntime(AkronFeatureKind.PitchShift)) { pitch = AkronModule.Settings.PitchShiftPolicy switch { AkronPitchPolicy.FollowSpeed => speed, AkronPitchPolicy.Independent => AkronModuleSettings.ClampAudioMultiplier(AkronModule.Settings.PitchShiftMultiplier), @@ -218,6 +242,14 @@ private static void ApplyAudioSpeedAndPitch() { } SetAudioPitch(Calc.Clamp(speed * pitch, 0.1f, 4f)); + audioPitchApplied = true; + } + + private static void RestoreAudioPitch() { + if (audioPitchApplied) { + SetAudioPitch(1f); + audioPitchApplied = false; + } } private static void SetAudioPitch(float pitch) { @@ -244,7 +276,8 @@ private static void ApplyHudVisibility(Level level) { return; } - if (!AkronModule.TryUse(AkronFeatureKind.HudVisibility)) { + if (!AkronModule.TryUseRuntime(AkronFeatureKind.HudVisibility)) { + RestoreHudVisibility(); return; } @@ -299,14 +332,21 @@ internal static void RestorePauseMenuVisibility() { } private static void ApplyScreenshake(Level level, bool afterLevelUpdate = false) { - if (level == null) { + if (level == null || !AkronModule.Settings.Screenshake || !AkronPolicy.CanUse(AkronFeatureKind.Screenshake).Allowed) { + RestoreScreenshake(); return; } + if (screenshakeLevel != level) { + RestoreScreenshake(); + screenshakeLevel = level; + previousScreenshakeDisabled = GetMember(level, "DisableScreenShake") is true; + } + int intensity = AkronModuleSettings.ClampScreenshakeIntensity(AkronModule.Settings.ScreenshakeIntensity); bool disabled = AkronModule.Settings.Screenshake && intensity <= 0 && - AkronModule.TryUse(AkronFeatureKind.Screenshake); + AkronModule.TryUseRuntime(AkronFeatureKind.Screenshake); SetMember(level, "DisableScreenShake", disabled); if (disabled) { SetMember(level, "ShakeVector", Vector2.Zero); @@ -316,7 +356,7 @@ private static void ApplyScreenshake(Level level, bool afterLevelUpdate = false) if (!afterLevelUpdate || !AkronModule.Settings.Screenshake || intensity >= 100 || - !AkronModule.TryUse(AkronFeatureKind.Screenshake) || + !AkronModule.TryUseRuntime(AkronFeatureKind.Screenshake) || !TryGetMemberValue(level, "ShakeVector", out object shakeVector) || shakeVector is not Vector2 vector) { return; @@ -325,6 +365,13 @@ private static void ApplyScreenshake(Level level, bool afterLevelUpdate = false) SetMember(level, "ShakeVector", vector * (intensity / 100f)); } + private static void RestoreScreenshake() { + if (screenshakeLevel != null) { + SetMember(screenshakeLevel, "DisableScreenShake", previousScreenshakeDisabled); + screenshakeLevel = null; + } + } + private static void ApplyVisualTuning(Level level) { if (level == null || !ShouldApplyVisualTuning()) { RestoreVisualTuning(); @@ -356,7 +403,7 @@ private static bool ShouldApplyVisualTuning() { return false; } - return AkronModule.TryUse(AkronFeatureKind.VisualTuning); + return AkronModule.TryUseRuntime(AkronFeatureKind.VisualTuning); } private static void CaptureVisualTuningBaseline(Level level) { @@ -440,7 +487,7 @@ private static void ApplyFreeCamera(Level level, Player player) { return; } - if (!AkronModule.TryUse(AkronFeatureKind.FreeCamera)) { + if (!AkronModule.TryUseRuntime(AkronFeatureKind.FreeCamera)) { RestoreFreeCameraPlayerControl(); return; } @@ -574,7 +621,9 @@ private static void ApplySafeModeStatFreeze(Level level) { return; } - if (!AkronModule.TryUse(AkronFeatureKind.SafeModeStats)) { + if (!AkronModule.TryUseRuntime(AkronFeatureKind.SafeModeStats)) { + SafeModeStatSnapshot.Clear(); + safeModeSnapshotArea = string.Empty; return; } diff --git a/Source/Runtime/akron-visual-noise-runtime.cs b/Source/Runtime/akron-visual-noise-runtime.cs index 0f8fb89c..4faea291 100644 --- a/Source/Runtime/akron-visual-noise-runtime.cs +++ b/Source/Runtime/akron-visual-noise-runtime.cs @@ -30,7 +30,7 @@ private static void BackdropRendererOnRender(On.Celeste.BackdropRenderer.orig_Re private static bool ShouldSuppressBackdropVisuals() { return (Settings.HideSnow || Settings.HideWindSnow) && - TryUse(AkronFeatureKind.ReducedVisualNoise); + TryUseRuntime(AkronFeatureKind.ReducedVisualNoise); } private static bool ShouldHideBackdrop(Backdrop backdrop) { @@ -76,11 +76,11 @@ private static void BigWaterfallOnRenderDisplacement(On.Celeste.BigWaterfall.ori } private static bool ShouldHideWaterfalls() { - return Settings.HideWaterfalls && TryUse(AkronFeatureKind.ReducedVisualNoise); + return Settings.HideWaterfalls && TryUseRuntime(AkronFeatureKind.ReducedVisualNoise); } private static void ReflectionTentaclesOnRender(On.Celeste.ReflectionTentacles.orig_Render orig, ReflectionTentacles self) { - if (!Settings.HideTentacles || !TryUse(AkronFeatureKind.ReducedVisualNoise)) { + if (!Settings.HideTentacles || !TryUseRuntime(AkronFeatureKind.ReducedVisualNoise)) { orig(self); } } @@ -135,7 +135,7 @@ float trimEnd } private static void HeatWaveOnRenderDisplacement(On.Celeste.HeatWave.orig_RenderDisplacement orig, HeatWave self, Level level) { - if (!Settings.HideHeatDistortion || !TryUse(AkronFeatureKind.ReducedVisualNoise)) { + if (!Settings.HideHeatDistortion || !TryUseRuntime(AkronFeatureKind.ReducedVisualNoise)) { orig(self, level); } } diff --git a/Source/SaveLoad/AkronSaveLoad.cs b/Source/SaveLoad/AkronSaveLoad.cs index 1987fe69..f48df525 100644 --- a/Source/SaveLoad/AkronSaveLoad.cs +++ b/Source/SaveLoad/AkronSaveLoad.cs @@ -794,6 +794,9 @@ public static AkronSaveLoadResult SaveRuntimeState(Level level, string slotName, } string normalizedSlotName = NormalizeRuntimeSlotName(slotName); + if (!CanUseRuntimeState(normalizedSlotName)) { + return AkronSaveLoadResult.Blocked; + } CurrentSlotName = normalizedSlotName; if (ShouldBrokerRuntimeState(normalizedSlotName)) { AkronSaveLoadResult brokerResult = AkronSpeedrunToolBroker.Save(normalizedSlotName); @@ -1458,8 +1461,10 @@ out bool usedSnapshot if (level == null) { return AkronSaveLoadResult.Failed; } - string normalizedSlotName = NormalizeRuntimeSlotName(slotName); + if (!CanUseRuntimeState(normalizedSlotName)) { + return AkronSaveLoadResult.Blocked; + } CurrentSlotName = normalizedSlotName; if (ShouldBrokerRuntimeState(normalizedSlotName)) { AkronSaveLoadResult brokerResult = AkronSpeedrunToolBroker.Load(normalizedSlotName); @@ -1867,6 +1872,7 @@ private static bool ApplyPersistentRuntimeState(Level level, AkronPersistentRunt return false; } if (!float.IsFinite(state.EngineTimeRate) || + state.EngineTimeRateOwnedByAkron && !float.IsFinite(state.EngineTimeRateBeforeAkron) || !float.IsFinite(state.GlitchValue) || !float.IsFinite(state.DistortAnxiety) || !float.IsFinite(state.DistortGameRate)) { @@ -1875,9 +1881,7 @@ private static bool ApplyPersistentRuntimeState(Level level, AkronPersistentRunt } Settings.Instance.GrabMode = state.GrabMode; Settings.Instance.CrouchDashMode = state.CrouchDashMode; -#pragma warning disable CS0618 - Engine.TimeRate = state.EngineTimeRate; -#pragma warning restore CS0618 + AkronModule.RestoreSnapshotTimescale(state.EngineTimeRate, state.EngineTimeRateOwnedByAkron, state.EngineTimeRateBeforeAkron); Glitch.Value = state.GlitchValue; Distort.Anxiety = state.DistortAnxiety; Distort.GameRate = state.DistortGameRate; @@ -2221,6 +2225,13 @@ private static bool ShouldBrokerRuntimeState(string slotName) { return !slotName.StartsWith(AkronActions.StartPosStateSlotPrefix, StringComparison.Ordinal); } + private static bool CanUseRuntimeState(string slotName) { + return ShouldBrokerRuntimeState(slotName) + ? AkronPolicy.CanUse(AkronFeatureKind.Savestates).Allowed && + AkronPolicy.CanUse(AkronFeatureKind.BrokeredSavestates).Allowed + : AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed; + } + internal static void SaveStaticMembers(Dictionary> savedValues, Type type, params string[] memberNames) { SaveStaticMemberValues(savedValues, type, memberNames); } @@ -2286,6 +2297,8 @@ private static AkronSaveLoadSlot BuildNativeSlot(Level level, string slotName, b #pragma warning disable CS0618 saveSlot.EngineTimeRate = Engine.TimeRate; #pragma warning restore CS0618 + saveSlot.EngineTimeRateOwnedByAkron = AkronModule.OwnsCurrentTimescale; + saveSlot.EngineTimeRateBeforeAkron = AkronModule.TimescaleBeforeAkron; saveSlot.GlitchValue = Glitch.Value; saveSlot.DistortAnxiety = Distort.Anxiety; saveSlot.DistortGameRate = Distort.GameRate; @@ -2329,6 +2342,8 @@ private static AkronSaveLoadSlot BuildPersistentBaselineSlot(Level level, string #pragma warning disable CS0618 saveSlot.EngineTimeRate = Engine.TimeRate; #pragma warning restore CS0618 + saveSlot.EngineTimeRateOwnedByAkron = AkronModule.OwnsCurrentTimescale; + saveSlot.EngineTimeRateBeforeAkron = AkronModule.TimescaleBeforeAkron; saveSlot.GlitchValue = Glitch.Value; saveSlot.DistortAnxiety = Distort.Anxiety; saveSlot.DistortGameRate = Distort.GameRate; @@ -2483,9 +2498,7 @@ private static bool RestoreNativeSlot( Settings.Instance.GrabMode = saveSlot.GrabMode; Settings.Instance.CrouchDashMode = saveSlot.CrouchDashMode; -#pragma warning disable CS0618 - Engine.TimeRate = saveSlot.EngineTimeRate; -#pragma warning restore CS0618 + AkronModule.RestoreSnapshotTimescale(saveSlot.EngineTimeRate, saveSlot.EngineTimeRateOwnedByAkron, saveSlot.EngineTimeRateBeforeAkron); Glitch.Value = saveSlot.GlitchValue; Distort.Anxiety = saveSlot.DistortAnxiety; Distort.GameRate = saveSlot.DistortGameRate; diff --git a/Source/SaveLoad/akron-reconstruction-graph.cs b/Source/SaveLoad/akron-reconstruction-graph.cs index baba39c0..8159527e 100644 --- a/Source/SaveLoad/akron-reconstruction-graph.cs +++ b/Source/SaveLoad/akron-reconstruction-graph.cs @@ -77,7 +77,10 @@ internal sealed class AkronReconstructionDocument { // room graph. Older documents cannot distinguish an absent style from // one never captured, or preserve aliases between it and mod controllers. // They are refused rather than restoring a partial room. - public const string CurrentFormat = "akron-reconstruction-v11"; + // v11 -> v12: records global-clock ownership so an allowed StartPos load + // cannot re-enable a map-denied Akron timescale. Older snapshots do not + // distinguish Akron timing from independently owned timing. + public const string CurrentFormat = "akron-reconstruction-v12"; public string Format { get; set; } = CurrentFormat; // Every distinct type name in this document, in first-use order, shared by the @@ -12487,7 +12490,7 @@ internal static string GetSnapshotPath(string slotName, string directory = null) // Tracks AkronReconstructionDocument.CurrentFormat. A snapshot written against a // different fresh-room baseline gets a different path, so no read can reach it and // no write can replace it in place. - private const string SnapshotFileNamePrefix = "v11-"; + private const string SnapshotFileNamePrefix = "v12-"; // Internal so the snapshot-report command can glob the same files this writes. internal const string SnapshotFileNameSuffix = ".json.gz"; diff --git a/Source/SaveLoad/akron-save-load-broker.cs b/Source/SaveLoad/akron-save-load-broker.cs index bd3f02bb..706f2ada 100644 --- a/Source/SaveLoad/akron-save-load-broker.cs +++ b/Source/SaveLoad/akron-save-load-broker.cs @@ -6,6 +6,11 @@ namespace Celeste.Mod.Akron; public static partial class AkronSaveLoadService { private static bool CanAccessNativeState(Level level, out string reason, bool allowDeadPlayer = false) { + AkronPolicyDecision policy = AkronPolicy.CanUse(AkronFeatureKind.StartPosTools); + if (!policy.Allowed) { + reason = policy.Message; + return false; + } Player player = level.Tracker.GetEntity(); if (level.Paused) { reason = "Native StartPos restores are blocked while paused."; @@ -25,6 +30,10 @@ private static bool CanAccessNativeState(Level level, out string reason, bool al } private static AkronSaveLoadResult TryBrokerSave(int slot) { + if (!AkronPolicy.CanUse(AkronFeatureKind.Savestates).Allowed || + !AkronPolicy.CanUse(AkronFeatureKind.BrokeredSavestates).Allowed) { + return AkronSaveLoadResult.Blocked; + } if (AkronSpeedrunToolBroker.Available) { AkronPolicy.RecordFeatureUse(AkronFeatureKind.BrokeredSavestates); return AkronSpeedrunToolBroker.Save(slot); @@ -36,6 +45,10 @@ private static AkronSaveLoadResult TryBrokerSave(int slot) { } private static AkronSaveLoadResult TryBrokerLoad(Level level, int slot) { + if (!AkronPolicy.CanUse(AkronFeatureKind.Savestates).Allowed || + !AkronPolicy.CanUse(AkronFeatureKind.BrokeredSavestates).Allowed) { + return AkronSaveLoadResult.Blocked; + } if (AkronSpeedrunToolBroker.Available) { long currentSessionTime = level?.Session?.Time ?? 0L; int currentDeaths = level?.Session?.Deaths ?? 0; diff --git a/Source/SaveLoad/akron-save-load-models.cs b/Source/SaveLoad/akron-save-load-models.cs index 91795016..8f2dd9d6 100644 --- a/Source/SaveLoad/akron-save-load-models.cs +++ b/Source/SaveLoad/akron-save-load-models.cs @@ -94,6 +94,8 @@ public AkronSaveLoadSlot(string slotName, string levelName, string mapSid, bool public GrabModes GrabMode { get; set; } public CrouchDashModes CrouchDashMode { get; set; } public float EngineTimeRate { get; set; } + public bool EngineTimeRateOwnedByAkron { get; set; } + public float EngineTimeRateBeforeAkron { get; set; } public float GlitchValue { get; set; } public float DistortAnxiety { get; set; } public float DistortGameRate { get; set; } @@ -309,6 +311,8 @@ internal sealed class AkronPersistentRuntimeState { public GrabModes GrabMode { get; set; } public CrouchDashModes CrouchDashMode { get; set; } public float EngineTimeRate { get; set; } + public bool EngineTimeRateOwnedByAkron { get; set; } + public float EngineTimeRateBeforeAkron { get; set; } public float GlitchValue { get; set; } public float DistortAnxiety { get; set; } public float DistortGameRate { get; set; } @@ -322,6 +326,8 @@ public static AkronPersistentRuntimeState CaptureSaved(AkronSaveLoadSlot slot) { GrabMode = slot.GrabMode, CrouchDashMode = slot.CrouchDashMode, EngineTimeRate = slot.EngineTimeRate, + EngineTimeRateOwnedByAkron = slot.EngineTimeRateOwnedByAkron, + EngineTimeRateBeforeAkron = slot.EngineTimeRateBeforeAkron, GlitchValue = slot.GlitchValue, DistortAnxiety = slot.DistortAnxiety, DistortGameRate = slot.DistortGameRate, @@ -339,6 +345,8 @@ public static AkronPersistentRuntimeState CaptureCurrent(Level level) { #pragma warning disable CS0618 EngineTimeRate = Engine.TimeRate, #pragma warning restore CS0618 + EngineTimeRateOwnedByAkron = AkronModule.OwnsCurrentTimescale, + EngineTimeRateBeforeAkron = AkronModule.TimescaleBeforeAkron, GlitchValue = Glitch.Value, DistortAnxiety = Distort.Anxiety, DistortGameRate = Distort.GameRate, diff --git a/Source/Tools/akron-capture.cs b/Source/Tools/akron-capture.cs index 0815e946..e0c82896 100644 --- a/Source/Tools/akron-capture.cs +++ b/Source/Tools/akron-capture.cs @@ -13,6 +13,16 @@ public static class AkronCapture { private static string pendingPath = string.Empty; private static string pendingGameplayBufferQaTag = string.Empty; private static Action pendingGameplayBufferQaCompletion; + private static Scene pendingGameplayBufferQaScene; + + internal static void CancelPendingCapture() { + pendingPath = string.Empty; + pendingGameplayBufferQaTag = string.Empty; + pendingGameplayBufferQaScene = null; + Action completion = pendingGameplayBufferQaCompletion; + pendingGameplayBufferQaCompletion = null; + completion?.Invoke(); + } internal static bool IsCapturingGameFrame { get; private set; } @@ -22,7 +32,7 @@ internal static bool RequestGameplayBufferQaCapture( Action completion = null ) { normalizedTag = NormalizeQaTag(tag); - if (string.IsNullOrWhiteSpace(normalizedTag)) { + if (string.IsNullOrWhiteSpace(normalizedTag) || !AkronPolicy.CanUse(AkronFeatureKind.ScreenshotTool).Allowed) { return false; } @@ -38,6 +48,7 @@ internal static bool RequestGameplayBufferQaCapture( pendingGameplayBufferQaTag = normalizedTag; pendingGameplayBufferQaCompletion = completion; + pendingGameplayBufferQaScene = Engine.Instance == null ? null : Engine.Scene; return true; } @@ -48,9 +59,18 @@ internal static void CapturePendingGameplayBufferQaFrame() { string tag = pendingGameplayBufferQaTag; Action completion = pendingGameplayBufferQaCompletion; + Scene requestedScene = pendingGameplayBufferQaScene; pendingGameplayBufferQaTag = string.Empty; pendingGameplayBufferQaCompletion = null; + pendingGameplayBufferQaScene = null; try { + // A queued QA capture still owes its completion if the scene or + // engine has ended before the render boundary consumes it. + Scene currentScene = Engine.Instance == null ? null : Engine.Scene; + if (!ReferenceEquals(requestedScene, currentScene) || + !AkronPolicy.CanUse(AkronFeatureKind.ScreenshotTool).Allowed) { + return; + } CaptureGameplayBufferQaFrame(tag); } finally { completion?.Invoke(); @@ -63,6 +83,9 @@ internal static bool CaptureGameplayBufferQaFrameNow(string tag, out string norm } private static bool CaptureGameplayBufferQaFrame(string tag) { + if (!AkronPolicy.CanUse(AkronFeatureKind.ScreenshotTool).Allowed) { + return false; + } RenderTarget2D texture = GameplayBuffers.Level?.Target; if (texture == null || texture.IsDisposed) { ReportGameplayBufferQaResult("qa-pixel-checkpoint: failed;tag=" + tag + ";reason=gameplay-buffer-unavailable"); @@ -129,6 +152,10 @@ private static string NormalizeQaTag(string tag) { } public static string Capture(Level level) { + if (!AkronPolicy.CanUse(AkronFeatureKind.ScreenshotTool).Allowed) { + pendingPath = string.Empty; + return string.Empty; + } if (string.IsNullOrWhiteSpace(pendingPath)) { return string.Empty; } diff --git a/Source/Tools/akron-entity-inspector-pin.cs b/Source/Tools/akron-entity-inspector-pin.cs index 206aaec4..f17faf93 100644 --- a/Source/Tools/akron-entity-inspector-pin.cs +++ b/Source/Tools/akron-entity-inspector-pin.cs @@ -131,7 +131,8 @@ public static void UnloadInspectorPin() public static void UpdateInspectorPin(Level level) { AkronInspectorPinFilter filter = NormalizeInspectorPinFilter(AkronModule.Settings.InspectorPinFilter); - if (level == null || (!AkronModule.Settings.EntityInspector && !AkronModule.Settings.CursorTools)) + if (level == null || !AkronPolicy.CanUse(AkronFeatureKind.EntityInspector).Allowed || + (!AkronModule.Settings.EntityInspector && !(AkronModule.Settings.CursorTools && AkronPolicy.CanUse(AkronFeatureKind.CursorTools).Allowed))) { inspectorPinLastLeftDown = false; ClearInspectorPinPreview(); @@ -330,7 +331,7 @@ internal static List ScanInspectorTargetsForQa(Level level, int stepPixe public static void RenderInspectorPinOutlinesToGameplayBuffer(Level level) { - if (level == null) + if (level == null || !AkronPolicy.CanUse(AkronFeatureKind.EntityInspector).Allowed) { return; } @@ -501,7 +502,8 @@ private static void DrawInspectorWorldRect(Level level, Rectangle worldBounds, C public static bool ShouldRenderInspectorPinImGui(Level level) { - return (AkronModule.Settings.EntityInspector || AkronModule.Settings.CursorTools) && + return AkronPolicy.CanUse(AkronFeatureKind.EntityInspector).Allowed && + (AkronModule.Settings.EntityInspector || (AkronModule.Settings.CursorTools && AkronPolicy.CanUse(AkronFeatureKind.CursorTools).Allowed)) && level != null && inspectorPinSelectedEntity != null && currentStack.Count > 0; @@ -734,7 +736,7 @@ private static void UpdateInspectorPinHoverPreview(Level level, AkronInspectorPi { if (!AkronModule.Settings.EntityInspectorPinHoverPreview || !AkronModule.ShouldShowEntityInspectorCursor() || - !AkronModule.TryUse(AkronFeatureKind.EntityInspector)) + !AkronModule.TryUseRuntime(AkronFeatureKind.EntityInspector)) { ClearInspectorPinPreview(); return; diff --git a/Source/Tools/akron-entity-inspector.cs b/Source/Tools/akron-entity-inspector.cs index 6883425a..ed08e8df 100644 --- a/Source/Tools/akron-entity-inspector.cs +++ b/Source/Tools/akron-entity-inspector.cs @@ -216,7 +216,7 @@ public static string Describe(Level level) { public static void RenderHitboxes(Level level, Player player) { AkronModuleSettings settings = AkronModule.Settings; - if (!AkronModule.TryUse(AkronFeatureKind.HitboxViewer)) { + if (!AkronModule.TryUseRuntime(AkronFeatureKind.HitboxViewer)) { return; } @@ -227,7 +227,7 @@ public static void RenderHitboxes(Level level, Player player) { player ??= FindPlayer(level); try { - bool deathHitboxVisible = settings.HitboxShowLastDeath && HasVisibleLastDeathObjectHitbox(AkronModule.Session) && AkronModule.TryUse(AkronFeatureKind.DeathHitboxes); + bool deathHitboxVisible = settings.HitboxShowLastDeath && HasVisibleLastDeathObjectHitbox(AkronModule.Session) && AkronModule.TryUseRuntime(AkronFeatureKind.DeathHitboxes); if (ShouldRenderLiveHitboxes(settings.HitboxViewer, deathHitboxVisible, settings.HitboxShowAllOnDeath)) { if (settings.HitboxShowSolids) { DrawVisibleSolidTiles(level); @@ -265,7 +265,7 @@ public static void RenderHitboxes(Level level, Player player) { } } - if (settings.HitboxShowLastDeath && HasVisibleLastDeathHitbox() && AkronModule.TryUse(AkronFeatureKind.DeathHitboxes)) { + if (settings.HitboxShowLastDeath && HasVisibleLastDeathHitbox() && AkronModule.TryUseRuntime(AkronFeatureKind.DeathHitboxes)) { if (!settings.HitboxShowAllOnDeath) { Color deathColor = ColorFromRgb(settings.HitboxDeathColor); if (CanRenderDeathColliderSnapshot( @@ -620,7 +620,7 @@ private static bool ShouldRenderHitboxEntity(Entity entity, AkronModuleSettings } if (entity is Trigger) { - return settings.HitboxShowTriggers; + return settings.HitboxShowTriggers && AkronPolicy.CanUse(AkronFeatureKind.TriggerViewer).Allowed; } if (IsHazard(entity)) { diff --git a/Source/Tools/akron-screenshot-scanner.cs b/Source/Tools/akron-screenshot-scanner.cs index 25436efa..420f268d 100644 --- a/Source/Tools/akron-screenshot-scanner.cs +++ b/Source/Tools/akron-screenshot-scanner.cs @@ -133,6 +133,32 @@ public static class AkronScreenshotScanner { private const int MapWorldPadding = 32; private const int ScannerExportMarkerBorder = 1; private static Entity scannerHost; + private static Level scanLevel; + private static Session scanSession; + private static Action restoreCurrentRoom; + private static ulong scanGeneration; + + internal static void CancelForMapChange() { + scanGeneration++; + scanCancelled = true; + restoreCurrentRoom?.Invoke(); + restoreCurrentRoom = null; + RestoreActivePlayerScanState(scanLevel); + scannerHost?.RemoveSelf(); + scannerHost = null; + scanLevel = null; + scanSession = null; + isScanning = false; + allowScanRoomSetupTriggers = false; + ClearInitialPlayerState(); + } + + private static bool CanContinueScan(Level level, bool allowCancelled = false) { + return isScanning && (allowCancelled || !scanCancelled) && ReferenceEquals(level, scanLevel) && + ReferenceEquals(Engine.Scene, level) && ReferenceEquals(level?.Session, scanSession) && + AkronPolicy.CanUse(AkronFeatureKind.ScreenshotTool).Allowed && + AkronPolicy.CanUse(AkronFeatureKind.CaptureCheatOptions).Allowed; + } private static bool isScanning; private static bool scanCancelled; private static bool allowScanRoomSetupTriggers; @@ -404,7 +430,7 @@ public static void Stop() { } scanCancelled = true; - RestoreActivePlayerScanState(Engine.Scene as Level); + RestoreActivePlayerScanState(scanLevel); Engine.Scene?.Add(new AkronToast("Stopping screenshot scan...")); } @@ -431,6 +457,10 @@ internal static void MaintainActiveScanHost(Level level) { if (!isScanning || level == null || scannerHost == null) { return; } + if (!CanContinueScan(level, allowCancelled: true)) { + CancelForMapChange(); + return; + } // Room reloads can remove the persistent scanner host from the active // entity list while leaving its Scene reference pointed at the level. @@ -454,9 +484,16 @@ private static bool TryStart(Level level, out Player player) { if (!AkronModule.TryUse(AkronFeatureKind.ScreenshotTool)) { return false; } + AkronPolicyDecision capturePolicy = AkronPolicy.CanUse(AkronFeatureKind.CaptureCheatOptions); + if (!capturePolicy.Allowed) { + level.Add(new AkronToast(capturePolicy.Message)); + return false; + } isScanning = true; scanCancelled = false; + scanLevel = level; + scanSession = level.Session; lastScanCompletedSuccessfully = false; if (scannerHost?.Scene != level) { scannerHost?.RemoveSelf(); @@ -496,7 +533,7 @@ private static IEnumerator ScanRooms(Player initialPlayer, Queue rooms, while (rooms.Count > 0 && isScanning && !scanCancelled) { Level level = Engine.Scene as Level; Player player = level?.Tracker.GetEntity(); - if (level == null || player == null) { + if (!CanContinueScan(level) || player == null) { AkronLog.Warn(nameof(AkronScreenshotScanner), "Cancelling screenshot scan because the active level or player disappeared."); scanCancelled = true; break; @@ -507,7 +544,7 @@ private static IEnumerator ScanRooms(Player initialPlayer, Queue rooms, yield return ChangeRoom(level, room); level = Engine.Scene as Level; player = level?.Tracker.GetEntity(); - if (level == null || player == null || !string.Equals(level.Session.Level, room, StringComparison.Ordinal)) { + if (!CanContinueScan(level) || player == null || !string.Equals(level.Session.Level, room, StringComparison.Ordinal)) { continue; } } @@ -530,13 +567,13 @@ private static IEnumerator ScanRooms(Player initialPlayer, Queue rooms, Level current = Engine.Scene as Level; Player currentPlayer = current?.Tracker.GetEntity(); - if (current != null && currentPlayer != null && !string.Equals(current.Session.Level, initialRoom, StringComparison.Ordinal)) { - yield return ChangeRoom(current, initialRoom); + if (CanContinueScan(current, allowCancelled: true) && currentPlayer != null && !string.Equals(current.Session.Level, initialRoom, StringComparison.Ordinal)) { + yield return ChangeRoom(current, initialRoom, returningToStart: true); current = Engine.Scene as Level; currentPlayer = current?.Tracker.GetEntity(); } - if (currentPlayer != null) { + if (CanContinueScan(current, allowCancelled: true) && currentPlayer != null) { currentPlayer.Position = initialPosition; currentPlayer.Speed = initialSpeed; currentPlayer.Visible = initialVisible; @@ -547,7 +584,7 @@ private static IEnumerator ScanRooms(Player initialPlayer, Queue rooms, } } - if (!scanCancelled) { + if (CanContinueScan(current)) { if (buildMapComposite && mergedRooms != null && mergedRooms.Count > 0) { if (mergedRooms.Count != scannedRoomCount) { AkronLog.Warn(nameof(AkronScreenshotScanner), "Writing map collage with " + mergedRooms.Count.ToString(System.Globalization.CultureInfo.InvariantCulture) + " of " + scannedRoomCount.ToString(System.Globalization.CultureInfo.InvariantCulture) + " scanned room collages."); @@ -562,6 +599,8 @@ private static IEnumerator ScanRooms(Player initialPlayer, Queue rooms, scanCancelled = false; allowScanRoomSetupTriggers = false; scannerHost = null; + scanLevel = null; + scanSession = null; ClearInitialPlayerState(); } } @@ -600,7 +639,13 @@ private static void RestoreActivePlayerScanState(Level level) { } } - private static IEnumerator ChangeRoom(Level level, string roomName) { + private static IEnumerator ChangeRoom(Level level, string roomName, bool returningToStart = false) { + ulong generation = scanGeneration; + if (!CanContinueScan(level, returningToStart)) { + yield break; + } + bool freezeTime = AkronModule.Settings.ScreenshotScannerFreezeTime && + AkronPolicy.CanUse(AkronFeatureKind.CaptureCheatOptions).Allowed; LevelData nextRoom = GetScanMapData(level)?.Get(roomName); if (!CanScanChapterRoom(nextRoom)) { yield break; @@ -629,7 +674,7 @@ private static IEnumerator ChangeRoom(Level level, string roomName) { allowScanRoomSetupTriggers = true; try { level.OnEndOfFrame += () => { - if (Engine.Scene != level) { + if (generation != scanGeneration || !CanContinueScan(level, returningToStart)) { return; } @@ -652,7 +697,10 @@ private static IEnumerator ChangeRoom(Level level, string roomName) { }; for (int i = 0; i < 30 && !roomLoaded; i++) { - if (AkronModule.Settings.ScreenshotScannerFreezeTime) { + if (generation != scanGeneration || !CanContinueScan(level, returningToStart)) { + yield break; + } + if (freezeTime) { level.TimeActive = previousTime; level.RawTimeActive = previousRawTime; } @@ -660,7 +708,10 @@ private static IEnumerator ChangeRoom(Level level, string roomName) { } for (int i = 0; i < RoomLoadSettleFrames; i++) { - if (AkronModule.Settings.ScreenshotScannerFreezeTime) { + if (generation != scanGeneration || !CanContinueScan(level, returningToStart)) { + yield break; + } + if (freezeTime) { level.TimeActive = previousTime; level.RawTimeActive = previousRawTime; } @@ -746,6 +797,9 @@ private static bool LevelHasScannerHost(Level level) { } private static IEnumerator ScanCurrentRoom(Level level, Player player, List mergedRooms, List markedRoomCaptures, string markedRoomOutputDirectory) { + if (!CanContinueScan(level)) { + yield break; + } Rectangle bounds = level.Bounds; float cameraWidth = level.Camera.Right - level.Camera.Left; float cameraHeight = level.Camera.Bottom - level.Camera.Top; @@ -783,10 +837,26 @@ private static IEnumerator ScanCurrentRoom(Level level, Player player, List writtenTiles = new List(); // Both options change what the level does while the scan runs, which is what the // Cheat class on their checkboxes promises to record. - if ((suppressMadeline || freezeTime) && !AkronModule.TryUse(AkronFeatureKind.CaptureCheatOptions)) { + if ((suppressMadeline || freezeTime) && !AkronModule.TryUseRuntime(AkronFeatureKind.CaptureCheatOptions)) { suppressMadeline = false; freezeTime = false; } + restoreCurrentRoom = () => { + player.Position = previousPlayerPosition; + player.Speed = previousPlayerSpeed; + player.Visible = previousPlayerVisible; + player.Collidable = previousPlayerCollidable; + player.Collider = previousPlayerCollider; + if (player.Scene != null && player.StateMachine.State == Player.StDummy) { + player.StateMachine.State = previousPlayerState; + } + level.Background = previousBackground; + level.Foreground = previousForeground; + level.CameraLockMode = previousCameraLockMode; + timeStopEntity?.RemoveSelf(); + level.TimeActive = previousTime; + level.RawTimeActive = previousRawTime; + }; try { // Keep capture suppression local. Reusing global Hide Player/Noclip @@ -809,7 +879,7 @@ private static IEnumerator ScanCurrentRoom(Level level, Player player, List.json.gz` | +| `akron-setup-v10` | `akron-reconstruction-v12` | `startpos/snapshots.bin.br` | `Saves/AkronStartPos/v12-.json.gz` | A StartPos snapshot records an object graph with map identities, ownership edges, live-resource identities, and structural paths into a clean room reload. @@ -116,8 +116,9 @@ refused by the reader, before the reconstruction path sees it. | Change | Effect on existing data | |---|---| +| `akron-reconstruction-v11` -> `akron-reconstruction-v12` | Set StartPos slots again, then export their packs again. Snapshots now record whether Akron owned the saved global timescale and the clock value it replaced. Older snapshots lack that ownership and cannot safely restore timing under map restrictions. The `akron-setup-v10` container is unchanged. | | `akron-reconstruction-v10` -> `akron-reconstruction-v11` | Set StartPos slots again, then export their packs again. Snapshots now include the active area's room-owned dust style in the same graph as its controllers. Older snapshots lack that state and cannot be restored exactly. The `akron-setup-v10` container is unchanged. | -| `akron-setup-v9` -> `akron-setup-v10` | Export packs again to use the shared Brotli bundle. This container change alone did not invalidate local snapshots; the separate v11 snapshot change above does. | +| `akron-setup-v9` -> `akron-setup-v10` | Export packs again to use the shared Brotli bundle. This container change alone did not invalidate local snapshots; snapshot format changes are separate. | | `akron-setup-v8` -> `akron-setup-v9` | Every `.akr` setup pack made by an earlier build must be exported again. Auto Kill and Auto Deafen areas now name the map they were drawn on, and the Core Mode override's enabled flag left the setup state. | | `akron-setup-v7` -> `akron-setup-v8` | Every `.akr` setup pack made by an earlier build must be exported again. The overlay animation duration, floating button, and Deload Spinners flag left the setup state. | | `akron-overlay-theme-v1` -> `akron-overlay-theme-v2` | Every `.akr` theme pack made by an earlier build must be exported again. The animation duration left the theme. | @@ -164,7 +165,7 @@ manifest `createdAt` value. The setup payload size limit is 2 MiB. The compressed bundle is limited to 509 MiB, leaving room under the 512 MiB -community-download limit. Each expanded v11 snapshot is limited to 384 MiB, +community-download limit. Each expanded v12 snapshot is limited to 384 MiB, and all expanded snapshot documents together are limited to 1 GiB. Export accepts local gzip sources up to 128 MiB each and 509 MiB in total. A `StartPos` or `Whole` archive can contain at most 99 StartPos entries. @@ -277,7 +278,7 @@ flowchart TD payloadCheck -->|Yes| apply["Apply section"] ``` -Akron rejects archives with unsupported formats or versions, incorrect kinds, missing or extra entries, oversized payloads, invalid JSON, or unsupported payload formats. Setup packs created before `akron-setup-v10` cannot be imported. Export a new pack with the current build. Current v11 local StartPos saves do not need to be recreated. +Akron rejects archives with unsupported formats or versions, incorrect kinds, missing or extra entries, oversized payloads, invalid JSON, or unsupported payload formats. Setup packs created before `akron-setup-v10` cannot be imported. Older StartPos snapshots, including v11, must be captured again, then their packs re-exported with the current build. Akron verifies the compressed bundle hash and each document\'s checksum, map, and room before applying the setup. It then binds the snapshot to the recipient's diff --git a/docs/reference/snapshot-bundle.md b/docs/reference/snapshot-bundle.md index bf428234..88ba762f 100644 --- a/docs/reference/snapshot-bundle.md +++ b/docs/reference/snapshot-bundle.md @@ -2,7 +2,7 @@ The setup v10 transport keeps each reconstruction document byte-for-byte intact. It does not parse and rewrite JSON numbers, strings, object order, or graph nodes. -Local saves use gzip with `akron-reconstruction-v11`. Recapture older StartPos +Local saves use gzip with `akron-reconstruction-v12`. Recapture older StartPos slots, then re-export their setup packs. ## Wire contract diff --git a/docs/troubleshooting/startpos-recovery.mdx b/docs/troubleshooting/startpos-recovery.mdx index 335307ea..d3efde78 100644 --- a/docs/troubleshooting/startpos-recovery.mdx +++ b/docs/troubleshooting/startpos-recovery.mdx @@ -11,7 +11,7 @@ Akron has two separate state-restore workflows: ## StartPos capabilities Setting a StartPos captures an in-memory native clone for same-process loads -while a worker builds and validates the restart-safe v11 snapshot. After a +while a worker builds and validates the restart-safe v12 snapshot. After a restart, or when the in-memory copy is unavailable, Load reconstructs the state from that snapshot file. It contains room entities and components, their reference relationships, movement, timers, state machines, @@ -33,7 +33,7 @@ Persistent StartPos Set or Load may fail if: - A Set capture is still finishing. - Celeste is paused, transitioning rooms, or running or skipping a cutscene. - The saved StartPos belongs to a different map SID or save-file slot. -- The v11 snapshot file is missing, damaged, or incomplete. +- The v12 snapshot file is missing, damaged, or incomplete. - The map, save file, mod set, runtime, or snapshot format differs from the capture environment. - The slot was set by an earlier Akron that built fresh rooms differently, so its restart copy uses a superseded snapshot format. @@ -52,7 +52,7 @@ Attempt status does not block ordinary persistent StartPos actions. Invoking Set Confirm that the active StartPos slot is set, belongs to the current map and save-file slot, and names the intended destination room. The room you load from can differ. - If the in-memory state is unavailable, confirm that the matching v11 snapshot still exists and was created with the current map, save file, mod set, runtime, and snapshot format. + If the in-memory state is unavailable, confirm that the matching v12 snapshot still exists and was created with the current map, save file, mod set, runtime, and snapshot format. A slot the format move emptied leaves the slot list, so loading it reports that it was saved by an older Akron that built rooms differently. That slot predates a snapshot format change and cannot be recovered. Set the StartPos again. Akron does not convert older snapshots: an older snapshot describes a fresh room that this build no longer produces, so there is nothing correct to convert it into. @@ -64,7 +64,7 @@ Attempt status does not block ordinary persistent StartPos actions. Invoking Set ## Snapshot format changes -A StartPos snapshot records map identities, ownership relationships, and structural paths into a clean room reload. Reconstruction checks that evidence before applying the saved graph; a matching list index alone does not establish entity identity. The snapshot format changes when those assumptions change or a snapshot needs additional required state. The current format is `akron-reconstruction-v11`, which includes the active area's room-owned dust style alongside its controllers. Older snapshots lack that state and are refused rather than partially restored. +A StartPos snapshot records map identities, ownership relationships, and structural paths into a clean room reload. Reconstruction checks that evidence before applying the saved graph; a matching list index alone does not establish entity identity. The snapshot format changes when those assumptions change or a snapshot needs additional required state. The current format is `akron-reconstruction-v12`, which records whether Akron owned the saved global timescale and the clock value it replaced. Older snapshots lack that ownership and cannot safely restore timing under map restrictions. Akron refuses them rather than partially restoring them. Slot positions, spawn settings, keybinds and every other setting survive a format change. The saved room state behind each slot does not, and has to be captured again with Set. `.akr` setup packs carry the same snapshots, so a pack made before the change has to be exported again as well. diff --git a/tests/feature-registry-tests.cs b/tests/feature-registry-tests.cs index fd8a4464..d82108ab 100644 --- a/tests/feature-registry-tests.cs +++ b/tests/feature-registry-tests.cs @@ -1,13 +1,261 @@ using System; using System.Collections.Generic; using System.Reflection; +using System.Runtime.CompilerServices; +using Monocle; using Celeste.Mod.Akron; using Xunit; namespace Celeste.Mod.Akron.Tests; +[Collection(AkronSharedStateCollection.Name)] public sealed class FeatureRegistryTests { + [Fact] + public void EveryRegisteredIdentityCanBeRestrictedWithoutChangingClassification() + { + foreach (AkronFeatureKind kind in Enum.GetValues()) + { + AkronStatus classification = AkronFeatureRegistry.Classify(kind); + MapData map = MapWithDeclarations(kind.ToString()); + try + { + AkronPolicy.EnterMap(map); + Assert.False(AkronPolicy.CanUse(kind).Allowed); + Assert.False(AkronModule.TryUseRuntime(kind)); + Assert.True(AkronPolicy.IsMapRestricted(kind)); + Assert.Equal(classification, AkronFeatureRegistry.Classify(kind)); + Assert.Equal(new[] { kind }, AkronPolicy.RestrictedFeatures); + } + finally + { + AkronPolicy.UnloadMapRestrictions(); + } + Assert.True(AkronPolicy.CanUse(kind).Allowed); + } + } + + [Fact] + public void DeclarationsUnionAcrossRoomsAndCannotUseNumbersLabelsOrWrongCase() + { + MapData map = MapWithDeclarations(" Freeze,FrameAdvance,Freeze, ", "Timescale,freeze,0,Room labels"); + AkronMapFeatureRestrictions restrictions = new(map.Levels); + Assert.Equal(new[] { AkronFeatureKind.Freeze, AkronFeatureKind.FrameAdvance, AkronFeatureKind.Timescale }, restrictions.Features); + Assert.Equal(new[] { "freeze", "0", "Room labels" }, restrictions.UnknownIdentities); + Assert.False(restrictions.Contains(AkronFeatureKind.StartPosTools)); + } + + [Fact] + public void EmptyMapsDoNotRestrictFeaturesAndReentryKeepsTheDeclaredPolicy() + { + MapData restricted = MapWithDeclarations("StartPosTools,RespawnTime,InfiniteStamina"); + MapData unrestricted = MapWithDeclarations(); + try + { + Assert.True(AkronPolicy.EnterMap(restricted)); + Assert.False(AkronPolicy.EnterMap(restricted)); + Assert.False(AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed); + AkronPolicy.LeaveMap(); + Assert.False(AkronPolicy.HasMapRestrictions); + AkronPolicy.EnterMap(restricted); + Assert.False(AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed); + AkronPolicy.EnterMap(unrestricted); + foreach (AkronFeatureKind kind in Enum.GetValues()) + { + Assert.True(AkronPolicy.CanUse(kind).Allowed); + } + } + finally + { + AkronPolicy.UnloadMapRestrictions(); + } + } + + [Fact] + public void LoadingAnotherMapDoesNotReplaceTheLiveThreadsPolicy() + { + AkronPolicy.EnterMap(MapWithDeclarations("Timescale")); + try + { + AkronMapFeatureRestrictions previous = AkronPolicy.EnterMapLoadScope(MapWithDeclarations("Freeze")); + try + { + Assert.False(AkronPolicy.CanUse(AkronFeatureKind.Freeze).Allowed); + Assert.True(AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed); + bool otherThreadHasLivePolicy = false; + System.Threading.Thread otherThread = new(() => + { + otherThreadHasLivePolicy = AkronPolicy.CanUse(AkronFeatureKind.Freeze).Allowed && + !AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed; + }); + otherThread.Start(); + otherThread.Join(); + Assert.True(otherThreadHasLivePolicy); + } + finally + { + AkronPolicy.ExitMapLoadScope(previous); + } + Assert.True(AkronPolicy.CanUse(AkronFeatureKind.Freeze).Allowed); + Assert.False(AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed); + } + finally + { + AkronPolicy.UnloadMapRestrictions(); + } + } + + [Fact] + public void LivePreferenceAndSessionChangesCannotEnableRestrictedFreezeOrStepping() + { + using PolicyModuleState state = new(); + state.Settings.FrameStepper = true; + state.Settings.Noclip = true; + state.Session.FreezeGameplay = true; + state.Session.StepFrameRequested = true; + AkronPolicy.EnterMap(MapWithDeclarations("Freeze,FrameAdvance,Noclip")); + + Assert.False(AkronModule.IsGameplayFreezeEffective); + Assert.False(AkronModule.CanStepGameplay); + Assert.Empty(AkronPolicy.GetActiveCheatContributors(state.Settings, state.Session)); + state.Session.FreezeGameplay = false; + state.Session.FreezeGameplay = true; + Assert.False(AkronModule.IsGameplayFreezeEffective); + Assert.True(state.Settings.Noclip); + Assert.True(state.Settings.FrameStepper); + Assert.True(state.Session.FreezeGameplay); + + AkronPolicy.EnterMap(MapWithDeclarations("FrameAdvance")); + Assert.True(AkronModule.IsGameplayFreezeEffective); + Assert.False(AkronModule.CanStepGameplay); + AkronPolicy.LeaveMap(); + Assert.True(AkronModule.CanStepGameplay); + Assert.Contains(AkronPolicy.GetActiveCheatContributors(state.Settings, state.Session), item => item.Feature == AkronFeatureKind.Noclip); + } + + [Fact] + public void RestrictedTimescaleReleasesOnlyItsOwnClockAndPreservesSessionPreference() + { + using PolicyModuleState state = new(); +#pragma warning disable CS0618 + float original = Engine.TimeRate; + try + { + Engine.TimeRate = 0.8f; + state.Session.TimescaleEnabled = true; + state.Session.TimescaleMultiplier = 0.25f; + AkronModule.ApplyTimescale(); + Assert.Equal(0.25f, Engine.TimeRate); + + AkronPolicy.EnterMap(MapWithDeclarations("Timescale")); + AkronModule.ApplyTimescale(); + Assert.Equal(0.8f, Engine.TimeRate); + Assert.True(state.Session.TimescaleEnabled); + Assert.Equal(0.25f, state.Session.TimescaleMultiplier); + + Engine.TimeRate = 0.4f; + state.Session.TimescaleMultiplier = 0.1f; + AkronModule.ApplyTimescale(); + Assert.Equal(0.4f, Engine.TimeRate); + AkronPolicy.LeaveMap(); + AkronModule.ApplyTimescale(); + Assert.Equal(0.1f, Engine.TimeRate); + Engine.TimeRate = 0.6f; + AkronModule.ReleaseTimescale(); + Assert.Equal(0.6f, Engine.TimeRate); + } + finally + { + AkronModule.ReleaseTimescale(); + Engine.TimeRate = original; + } +#pragma warning restore CS0618 + } + + [Fact] + public void SnapshotTimescaleOwnershipCannotRestoreDeniedTimingOrLoseItsOriginalClock() + { + using PolicyModuleState state = new(); +#pragma warning disable CS0618 + float original = Engine.TimeRate; + try + { + Engine.TimeRate = 0.8f; + state.Session.TimescaleEnabled = true; + state.Session.TimescaleMultiplier = 0.25f; + AkronModule.ApplyTimescale(); + AkronSaveLoadSlot slot = new("timing", "room", "Tests/Timing", true) + { + EngineTimeRate = Engine.TimeRate, + EngineTimeRateOwnedByAkron = AkronModule.OwnsCurrentTimescale, + EngineTimeRateBeforeAkron = AkronModule.TimescaleBeforeAkron + }; + AkronPersistentRuntimeState saved = AkronPersistentRuntimeState.CaptureSaved(slot); + AkronModule.ReleaseTimescale(); + AkronPolicy.EnterMap(MapWithDeclarations("Timescale")); + Engine.TimeRate = 0.6f; + AkronModule.RestoreSnapshotTimescale(saved.EngineTimeRate, saved.EngineTimeRateOwnedByAkron, saved.EngineTimeRateBeforeAkron); + Assert.Equal(0.6f, Engine.TimeRate); + Assert.False(AkronModule.OwnsCurrentTimescale); + + // A snapshot's independent simulation clock is still restorable. + AkronModule.RestoreSnapshotTimescale(0.4f, false, 1f); + Assert.Equal(0.4f, Engine.TimeRate); + AkronPolicy.LeaveMap(); + AkronModule.RestoreSnapshotTimescale(saved.EngineTimeRate, saved.EngineTimeRateOwnedByAkron, saved.EngineTimeRateBeforeAkron); + Assert.Equal(0.25f, Engine.TimeRate); + AkronModule.ReleaseTimescale(); + Assert.Equal(0.8f, Engine.TimeRate); + } + finally + { + AkronModule.ReleaseTimescale(); + Engine.TimeRate = original; + } +#pragma warning restore CS0618 + } + + private static MapData MapWithDeclarations(params string[] declarations) + { + MapData map = (MapData)RuntimeHelpers.GetUninitializedObject(typeof(MapData)); + map.Levels = new List(); + // A normal spawn room with no marker must not hide a later room's declaration. + LevelData start = (LevelData)RuntimeHelpers.GetUninitializedObject(typeof(LevelData)); + start.Entities = new List(); + map.Levels.Add(start); + foreach (string declaration in declarations) + { + LevelData room = (LevelData)RuntimeHelpers.GetUninitializedObject(typeof(LevelData)); + room.Entities = new List { + new() { Name = "Akron/featureRestrictions", Values = new Dictionary { ["features"] = declaration } } + }; + map.Levels.Add(room); + } + return map; + } + + private sealed class PolicyModuleState : IDisposable + { + private readonly AkronModule previous = AkronModule.Instance; + internal readonly AkronModuleSettings Settings = new(); + internal readonly AkronModuleSession Session = new(); + + internal PolicyModuleState() + { + AkronModule module = (AkronModule)RuntimeHelpers.GetUninitializedObject(typeof(AkronModule)); + module._Settings = Settings; + module._Session = Session; + typeof(AkronModule).GetProperty(nameof(AkronModule.Instance))!.SetValue(null, module); + } + + public void Dispose() + { + AkronModule.ReleaseTimescale(); + AkronPolicy.UnloadMapRestrictions(); + typeof(AkronModule).GetProperty(nameof(AkronModule.Instance))!.SetValue(null, previous); + } + } + [Fact] public void EveryFeatureKindHasACompleteDefinition() { @@ -477,7 +725,7 @@ public void ActiveCheatContributorsIgnoreEnabledGreenQualityOfLifeOptions() [InlineData("Extended Camera Dynamics", "ECD Zoom Out", AkronFeatureKind.CursorZoom)] [InlineData("Extended Camera Dynamics", "ECD Restore Zooming", null)] [InlineData("Extended Variant Mode", "Extended Variants Master", AkronFeatureKind.ExtendedVariantMode)] - [InlineData("Extended Variant Mode", "Reset Extended", null)] + [InlineData("Extended Variant Mode", "Reset Extended", AkronFeatureKind.ExtendedVariantMode)] public void RowsClassifyThroughTheFeatureKindThatRecordsThem(string tab, string label, AkronFeatureKind? expectedKind) { Dictionary kinds = BuildOverlayEntryFeatureKinds(tab); @@ -509,6 +757,7 @@ public void TheLabelClassificationTableIsGone() return kinds; } + [Theory] [InlineData("Safe Mode", "Freeze deaths", AkronStatus.Cheat)] [InlineData("Death Stats", "PB loss prompt", AkronStatus.RegularClean)] diff --git a/tests/startpos-persistence-tests.cs b/tests/startpos-persistence-tests.cs index 63522861..805f061a 100644 --- a/tests/startpos-persistence-tests.cs +++ b/tests/startpos-persistence-tests.cs @@ -2888,17 +2888,34 @@ public void DeferredAutomationCompletionKeepsLaterCommandsQueued() { completionMethod.IndexOf("FinalizeRun();", StringComparison.Ordinal)); } - [Fact] - public void PersistentRestoreRejectsNonFiniteProcessGlobalFloats() { - string source = File.ReadAllText(GetSaveLoadSourcePath()); - int apply = source.IndexOf("private static bool ApplyPersistentRuntimeState", StringComparison.Ordinal); - int timeRateAssignment = source.IndexOf("Engine.TimeRate = state.EngineTimeRate;", apply, StringComparison.Ordinal); - int finiteCheck = source.IndexOf("float.IsFinite(state.EngineTimeRate)", apply, StringComparison.Ordinal); - - Assert.True(apply >= 0 && finiteCheck > apply && timeRateAssignment > finiteCheck); - Assert.Contains("float.IsFinite(state.GlitchValue)", SourceSlice(source, apply, timeRateAssignment - apply)); - Assert.Contains("float.IsFinite(state.DistortAnxiety)", SourceSlice(source, apply, timeRateAssignment - apply)); - Assert.Contains("float.IsFinite(state.DistortGameRate)", SourceSlice(source, apply, timeRateAssignment - apply)); + [Theory] + [InlineData(nameof(AkronPersistentRuntimeState.EngineTimeRate))] + [InlineData(nameof(AkronPersistentRuntimeState.EngineTimeRateBeforeAkron))] + [InlineData(nameof(AkronPersistentRuntimeState.GlitchValue))] + [InlineData(nameof(AkronPersistentRuntimeState.DistortAnxiety))] + [InlineData(nameof(AkronPersistentRuntimeState.DistortGameRate))] + public void PersistentRestoreRejectsNonFiniteProcessGlobalsWithoutMutation(string field) { + // Validation uses the Level only as an identity token and must reject + // invalid process globals before touching its runtime dependencies. + Level level = (Level)RuntimeHelpers.GetUninitializedObject(typeof(Level)); + AkronPersistentRuntimeState state = new() { + Level = level, + EngineTimeRate = 1f, + EngineTimeRateOwnedByAkron = true, + EngineTimeRateBeforeAkron = 1f, + DistortGameRate = 1f + }; + PropertyInfo property = typeof(AkronPersistentRuntimeState).GetProperty(field)!; + MethodInfo apply = typeof(AkronSaveLoadService).GetMethod( + "ApplyPersistentRuntimeState", BindingFlags.Static | BindingFlags.NonPublic)!; +#pragma warning disable CS0618 + var original = (Engine.TimeRate, Glitch.Value, Distort.Anxiety, Distort.GameRate); + foreach (float invalid in new[] { float.NaN, float.PositiveInfinity, float.NegativeInfinity }) { + property.SetValue(state, invalid); + Assert.False((bool)apply.Invoke(null, new object[] { level, state })!); + Assert.Equal(original, (Engine.TimeRate, Glitch.Value, Distort.Anxiety, Distort.GameRate)); + } +#pragma warning restore CS0618 } [Fact] diff --git a/tests/startpos-reconstruction-tests.cs b/tests/startpos-reconstruction-tests.cs index 1d0953d5..1233adc7 100644 --- a/tests/startpos-reconstruction-tests.cs +++ b/tests/startpos-reconstruction-tests.cs @@ -9098,7 +9098,6 @@ public void CompressedSnapshotFileRoundTripsGraphAndIdentity() { Assert.Equal(AkronReconstructionDocument.CurrentFormat, document.Format); Assert.Equal("LightBuffer", Assert.Single(document.GameplayBuffers).FieldName); Assert.Equal(new byte[] { 1, 2, 3, 4 }, document.GameplayBuffers[0].Payload.Bytes); - Assert.Contains("v11-", Path.GetFileName(AkronStartPosReconstruction.GetSnapshotPath("Akron StartPos test 1", directory))); Assert.True(File.Exists(AkronStartPosReconstruction.GetSnapshotPath("Akron StartPos test 1", directory))); } finally { if (Directory.Exists(directory)) { From 215263e4e75fbd174e6de219bf8f0644928ae1c7 Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 22:50:24 +0000 Subject: [PATCH 2/2] fix(policy): preserve clock and reload ownership --- Source/Actions/akron-actions.cs | 48 +++++- Source/Actions/akron-startpos-actions.cs | 22 +-- Source/Commands/akron-qa-commands.cs | 6 +- Source/Core/akron-log.cs | 5 +- Source/Interop/akron-extended-variants.cs | 17 +- Source/Module/AkronModule.cs | 12 +- Source/Module/akron-module-map-policy.cs | 51 ++++-- Source/Module/akron-module-overlay-input.cs | 4 +- Source/Module/akron-module-player-runtime.cs | 3 +- .../Overlay/akron-overlay-bindable-actions.cs | 19 +-- Source/Overlay/akron-overlay-bypass-popups.cs | 3 +- Source/Overlay/akron-overlay-entries.cs | 35 +---- Source/Tools/akron-capture.cs | 6 +- docs/contributing/feature-policy.mdx | 6 + tests/community-pack-tests.cs | 5 +- tests/feature-registry-tests.cs | 145 +++++++++++++++--- tests/screenshot-scanner-tests.cs | 13 +- tests/startpos-persistence-tests.cs | 60 +++----- 18 files changed, 287 insertions(+), 173 deletions(-) diff --git a/Source/Actions/akron-actions.cs b/Source/Actions/akron-actions.cs index 4288f26b..1e68edfd 100644 --- a/Source/Actions/akron-actions.cs +++ b/Source/Actions/akron-actions.cs @@ -359,18 +359,52 @@ public static void AdjustTimescale(float delta) { return; } - float next = NormalizeTimescaleMultiplier(session.TimescaleMultiplier + delta); + if (TrySetTimescaleMultiplier(session, session.TimescaleMultiplier + delta)) { + Engine.Scene?.Add(new AkronToast("Timescale value: " + session.TimescaleMultiplier.ToString("0.0x"))); + } + } + + internal static bool TrySetTimescaleMultiplier(AkronModuleSession session, float multiplier) { + if (session == null || AkronPolicy.IsMapRestricted(AkronFeatureKind.Timescale)) { + return false; + } - // Resetting to canonical speed must stay available even when Cheat - // speed changes are blocked, but blocked policy decisions must not allow moving away - // from 1.0x. + float next = NormalizeTimescaleMultiplier(multiplier); if (session.TimescaleEnabled && next != 1f && !AkronModule.TryUse(AkronFeatureKind.Timescale)) { - return; + return false; } ConfigureTimescaleMultiplier(session, next); - AkronModule.ApplyTimescale(); - Engine.Scene?.Add(new AkronToast("Timescale value: " + next.ToString("0.0x"))); + AkronModule.ApplyTimescale(session); + return true; + } + + internal static bool TrySetTimescaleEnabled(AkronModuleSession session, bool enabled) { + if (session == null || AkronPolicy.IsMapRestricted(AkronFeatureKind.Timescale)) { + return false; + } + + if (enabled && session.TimescaleMultiplier != 1f && !AkronModule.TryUse(AkronFeatureKind.Timescale)) { + return false; + } + + session.TimescaleEnabled = enabled; + if (!enabled) { + AkronModule.ReleaseTimescale(); + } + return true; + } + + internal static bool TryResetTimescale(AkronModuleSession session) { + if (session == null || AkronPolicy.IsMapRestricted(AkronFeatureKind.Timescale)) { + return false; + } + + // Reset configuration without claiming the shared clock before releasing it. + session.TimescaleMultiplier = 1f; + session.TimescaleEnabled = false; + AkronModule.ReleaseTimescale(); + return true; } internal static void ConfigureTimescaleMultiplier(AkronModuleSession session, float multiplier) { diff --git a/Source/Actions/akron-startpos-actions.cs b/Source/Actions/akron-startpos-actions.cs index ed08ea2c..61978b4d 100644 --- a/Source/Actions/akron-startpos-actions.cs +++ b/Source/Actions/akron-startpos-actions.cs @@ -1113,21 +1113,21 @@ public static void ApplyStartPosConfiguration(AkronStartPos startPos) { startPos.Grab = AkronModule.Settings.StartPosConfiguredGrab; } - internal static void RestoreStartPosAfterDeath(Level level, AkronStartPos startPos) { - if (level == null || startPos == null || startPosCaptureInProgress || - !AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed) { - return; - } - + internal static void RestoreStartPosAfterDeath(Level level, Session requestedSession, PlayerDeadBody deadBody, AkronStartPos startPos) { ulong generation = startPosActionGeneration; - Session requestedSession = level.Session; AkronModule.ScheduleAfterStableEngineUpdate(() => { - if (Engine.Scene != level || generation != startPosActionGeneration || - !ReferenceEquals(level.Session, requestedSession) || - !AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed) { + // A wipe can outlive its room or a newer restore in the same Level/Session. + // Only the exact body still present in the live room owns this completion. + if (level == null || deadBody == null || Engine.Scene != level || Engine.NextScene != level || + !ReferenceEquals(level.Session, requestedSession) || deadBody.Scene != level || + !level.Entities.Contains(deadBody)) { return; } - if (startPosCaptureInProgress) { + if (startPos == null || generation != startPosActionGeneration || startPosCaptureInProgress || + !AkronPolicy.CanUse(AkronFeatureKind.StartPosTools).Allowed) { + // Replacing DeathAction also replaced Celeste's default level.Reload. + // Cancelling a StartPos operation must still finish this live death. + level.Reload(); return; } diff --git a/Source/Commands/akron-qa-commands.cs b/Source/Commands/akron-qa-commands.cs index 0be957c8..3cb0f08b 100644 --- a/Source/Commands/akron-qa-commands.cs +++ b/Source/Commands/akron-qa-commands.cs @@ -187,11 +187,12 @@ public static void QaStartPosAction(string action = "restore") { // it, and not at all in the other two, which is recorded in that script's own header. [Command("akron_qa_pixel_checkpoint", "capture and hash Celeste's next rendered 320x180 room buffer: ")] public static void QaPixelCheckpoint(string tag = "checkpoint") { - if (RequireLevel() == null) { + Level level = RequireLevel(); + if (level == null) { return; } - if (!AkronCapture.RequestGameplayBufferQaCapture(tag, out string normalizedTag)) { + if (!AkronCapture.RequestGameplayBufferQaCapture(level, tag, out string normalizedTag)) { Log("usage: akron_qa_pixel_checkpoint "); return; } @@ -613,6 +614,7 @@ public static void QaStartPosLoadProbe(string slotText = "1", string flag = "akr AkronAutomationService.RecordOutput("qa-session-time: " + currentLevel.Session.Time.ToString(CultureInfo.InvariantCulture)); if (!string.IsNullOrWhiteSpace(pixelTag)) { if (AkronCapture.RequestGameplayBufferQaCapture( + currentLevel, pixelTag, out string normalizedTag, AkronAutomationService.CompleteDeferredRun)) { diff --git a/Source/Core/akron-log.cs b/Source/Core/akron-log.cs index a6e981a2..040c9fa0 100644 --- a/Source/Core/akron-log.cs +++ b/Source/Core/akron-log.cs @@ -263,11 +263,12 @@ private static string FormatDiagnosticFeatureCounts(string label, IReadOnlyDicti private static void Write(AkronLoggingLevel level, string source, string message, LogLevel? mirrorLogLevel) { AkronModuleSettings settings = ResolveSettings(); - if (settings == null || !AkronPolicy.CanUse(AkronFeatureKind.Logging).Allowed) { + if (settings == null) { return; } - bool writeToFile = settings.Logging && ShouldWrite(level, settings.LoggingLevel); + bool writeToFile = settings.Logging && ShouldWrite(level, settings.LoggingLevel) && + AkronPolicy.CanUse(AkronFeatureKind.Logging).Allowed; bool mirrorToEverest = mirrorLogLevel.HasValue && settings.LoggingMirrorWarningsToEverest; if (!writeToFile && !mirrorToEverest) { return; diff --git a/Source/Interop/akron-extended-variants.cs b/Source/Interop/akron-extended-variants.cs index 62542acb..9f7d3a5e 100644 --- a/Source/Interop/akron-extended-variants.cs +++ b/Source/Interop/akron-extended-variants.cs @@ -36,6 +36,7 @@ public static class AkronExtendedVariants { private static MethodInfo setVariantValueMethod; private static MethodInfo resetExtendedVariantsMethod; private static MethodInfo resetVanillaVariantsMethod; + private static MethodInfo changeRandomVariantMethod; private static FieldInfo variantHandlersField; private static FieldInfo instanceField; private static PropertyInfo settingsProperty; @@ -92,10 +93,7 @@ private static ExternalState BeginExternalChange() { // Only randomized writes need an observer. This mutation boundary runs when // EVM rolls variants, not on every player update or on map-authored triggers. if (randomizerHook == null) { - Type randomizer = moduleType.Assembly.GetType("ExtendedVariants.VariantRandomizer", true); - MethodInfo change = randomizer.GetMethod("changeVariantNow", BindingFlags.Static | BindingFlags.NonPublic) - ?? throw new MissingMethodException(randomizer.FullName, "changeVariantNow"); - randomizerHook = new Hook(change, (Action, bool>)ChangeRandomVariants); + randomizerHook = new Hook(changeRandomVariantMethod, (Action, bool>)ChangeRandomVariants); } return CaptureExternalState(); } @@ -1027,7 +1025,8 @@ private static bool ResolveTypes() { settingsProperty != null && variantHandlersField != null && getCurrentVariantValueMethod != null && - setVariantValueMethod != null) { + setVariantValueMethod != null && + changeRandomVariantMethod != null) { return true; } @@ -1059,6 +1058,14 @@ private static bool ResolveTypes() { setVariantValueMethod = uiEntriesType.GetMethod("SetVariantValue", BindingFlags.Static | BindingFlags.Public); resetExtendedVariantsMethod = moduleType.GetMethod("ResetExtendedVariantsToDefaultSettings", BindingFlags.Instance | BindingFlags.Public); resetVanillaVariantsMethod = moduleType.GetMethod("ResetVanillaVariantsToDefaultSettings", BindingFlags.Instance | BindingFlags.Public); + changeRandomVariantMethod = assembly.GetType("ExtendedVariants.VariantRandomizer")?.GetMethod( + "changeVariantNow", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(bool) }, null); + if (changeRandomVariantMethod?.ReturnType != typeof(void)) { + changeRandomVariantMethod = null; + failedResolveAssemblyCount = assemblies.Length; + Logger.Log(LogLevel.Warn, nameof(AkronExtendedVariants), "EVM integration unavailable: this assembly lacks the required changeVariantNow(bool) observer. No EVM settings were changed."); + return false; + } bool resolved = GetInstance() != null && GetSettings() != null && variantHandlersField != null && diff --git a/Source/Module/AkronModule.cs b/Source/Module/AkronModule.cs index bd16bd1f..34f03bd1 100644 --- a/Source/Module/AkronModule.cs +++ b/Source/Module/AkronModule.cs @@ -181,6 +181,7 @@ public override void Load() { } On.Celeste.Level.LoadLevel += LevelOnLoadLevelForMapPolicy; On.Celeste.LevelEnter.ctor += LevelEnterOnConstructForMapPolicy; + On.Monocle.Engine.OnSceneTransition += EngineOnSceneTransitionForMapPolicy; On.Celeste.Level.Begin += LevelOnBegin; On.Celeste.Level.End += LevelOnEnd; On.Celeste.Level.UpdateTime += LevelOnUpdateTime; @@ -341,6 +342,7 @@ public override void Unload() { AkronPolicy.UnloadMapRestrictions(); On.Celeste.Level.LoadLevel -= LevelOnLoadLevelForMapPolicy; On.Celeste.LevelEnter.ctor -= LevelEnterOnConstructForMapPolicy; + On.Monocle.Engine.OnSceneTransition -= EngineOnSceneTransitionForMapPolicy; On.Celeste.Level.Begin -= LevelOnBegin; On.Celeste.Level.End -= LevelOnEnd; On.Celeste.Level.UpdateTime -= LevelOnUpdateTime; @@ -502,7 +504,9 @@ private static void LevelOnEnd(On.Celeste.Level.orig_End orig, Level self) { // them from the first frame of the next level, so restoring here costs nothing. RestoreNativeAssistInvincibility(); AkronActions.RestoreAutoDeafen(); - LeaveMapPolicy(self.Session); + // Keep restrictions until the engine installs its next scene. In particular, + // a same-session LevelLoader restart must not open an unrestricted interval. + ResetMapPolicyEffects(); orig(self); } @@ -602,7 +606,7 @@ private static void LevelOnUpdate(On.Celeste.Level.orig_Update orig, Level self) return; } - if (IsGameplayFreezeEffective && !(CanStepGameplay && Session.StepFrameRequested)) { + if (IsGameplayFreezeEffective(Session) && !(CanStepGameplay(Session, Settings) && Session.StepFrameRequested)) { AkronRuntimeOptions.HoldSceneClockForSkippedLevelUpdate(self); if (!overlayUpdated) { Overlay?.Update(); @@ -863,7 +867,7 @@ private static void EngineOnUpdate(On.Monocle.Engine.orig_Update orig, Engine se } ApplyPendingMapPolicyEffects(); if (Engine.Scene is Level) { - ApplyTimescale(); + ApplyTimescale(Session); } else { ReleaseTimescale(); } @@ -1660,7 +1664,7 @@ private static void EngineOnRenderCore(On.Monocle.Engine.orig_RenderCore orig, E // Read the completed 320x180 room buffer before Akron draws its HUD. // StartPos restoration tests need exact game pixels without timer text or // desktop compositor noise. - AkronCapture.CapturePendingGameplayBufferQaFrame(); + AkronCapture.CapturePendingGameplayBufferQaFrame(level); renderedStartPosFrameGeneration = AkronActions.StartPosFrameGeneration; AkronInternalRecorder.CaptureFrame(level); if (deathWipeRenderSuppressionActive && level.Transitioning) { diff --git a/Source/Module/akron-module-map-policy.cs b/Source/Module/akron-module-map-policy.cs index 5bfacd8b..06ca3f75 100644 --- a/Source/Module/akron-module-map-policy.cs +++ b/Source/Module/akron-module-map-policy.cs @@ -5,14 +5,12 @@ namespace Celeste.Mod.Akron; public partial class AkronModule { private static bool mapRestrictionEffectsPending; - private static Session mapPolicySession; private static bool ownsTimescale; private static float timescaleBeforeAkron; private static float lastAkronTimescale; private static void EnterMapPolicy(Session session) { - mapPolicySession = session; if (AkronPolicy.EnterMap(session?.MapData)) { mapRestrictionEffectsPending = true; @@ -27,6 +25,30 @@ private static void LevelEnterOnConstructForMapPolicy(On.Celeste.LevelEnter.orig orig(self, session, fromSaveData); } + private static void EngineOnSceneTransitionForMapPolicy(On.Monocle.Engine.orig_OnSceneTransition orig, Engine self, Scene from, Scene to) + { + // Constructors also run for detached snapshot loaders. Adopt their policy only + // when the engine installs them, after the outgoing scene ends and before Begin. + if (to is LevelLoader loader) + { + EnterMapPolicy(loader.session); + } + else if (to is Level level) + { + EnterMapPolicy(level.Session); + } + else if (to is LevelEnter levelEnter) + { + EnterMapPolicy(levelEnter.session); + } + else + { + LeaveMapPolicy(); + } + ApplyPendingMapPolicyEffects(); + orig(self, from, to); + } + private static void LevelOnLoadLevelForMapPolicy(On.Celeste.Level.orig_LoadLevel orig, Level self, Player.IntroTypes intro, bool isFromLoader) { // Only policy is scoped here. Global render/audio/recorder cleanup belongs to @@ -42,18 +64,17 @@ private static void LevelOnLoadLevelForMapPolicy(On.Celeste.Level.orig_LoadLevel } } - private static void LeaveMapPolicy(Session expectedSession = null) + private static void LeaveMapPolicy() { - // LevelEnter/LevelLoader can install the next session before the old Level.End. - bool ownsPolicy = expectedSession == null || ReferenceEquals(mapPolicySession, expectedSession); - if (ownsPolicy) - { - mapPolicySession = null; - } - if (ownsPolicy && AkronPolicy.LeaveMap()) + if (AkronPolicy.LeaveMap()) { mapRestrictionEffectsPending = true; } + ResetMapPolicyEffects(); + } + + private static void ResetMapPolicyEffects() + { ClearPendingPolicyActions(); ReleaseTimescale(); AkronRuntimeOptions.Reset(); @@ -97,10 +118,10 @@ private static void ApplyPendingMapPolicyEffects() ApplyMotionSmoothingSettings(); } - internal static bool IsGameplayFreezeEffective => Session != null && Session.FreezeGameplay && + internal static bool IsGameplayFreezeEffective(AkronModuleSession session) => session != null && session.FreezeGameplay && AkronPolicy.CanUse(AkronFeatureKind.Freeze).Allowed; - internal static bool CanStepGameplay => IsGameplayFreezeEffective && Settings.FrameStepper && + internal static bool CanStepGameplay(AkronModuleSession session, AkronModuleSettings settings) => IsGameplayFreezeEffective(session) && settings.FrameStepper && AkronPolicy.CanUse(AkronFeatureKind.FrameAdvance).Allowed; #pragma warning disable CS0618 @@ -123,9 +144,9 @@ internal static void RestoreSnapshotTimescale(float value, bool ownedByAkron, fl lastAkronTimescale = value; } - internal static void ApplyTimescale() + internal static void ApplyTimescale(AkronModuleSession session) { - if (Session == null || !Session.TimescaleEnabled || !AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed) + if (session == null || !session.TimescaleEnabled || !AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed) { ReleaseTimescale(); return; @@ -136,7 +157,7 @@ internal static void ApplyTimescale() timescaleBeforeAkron = Engine.TimeRate; ownsTimescale = true; } - lastAkronTimescale = Session.TimescaleMultiplier; + lastAkronTimescale = session.TimescaleMultiplier; Engine.TimeRate = lastAkronTimescale; #pragma warning restore CS0618 } diff --git a/Source/Module/akron-module-overlay-input.cs b/Source/Module/akron-module-overlay-input.cs index d6b09c8d..a0220a4c 100644 --- a/Source/Module/akron-module-overlay-input.cs +++ b/Source/Module/akron-module-overlay-input.cs @@ -112,7 +112,7 @@ private static void HandleHotkeys(Level level) { AkronActions.ToggleFreeze(); } - if (IsBindingPressed(Settings.StepFrame, keyboard, previousKeyboard) && CanStepGameplay) { + if (IsBindingPressed(Settings.StepFrame, keyboard, previousKeyboard) && CanStepGameplay(Session, Settings)) { Session.StepFrameRequested = true; } @@ -367,7 +367,7 @@ private static void RefreshOverlayToggleKeyboardState() { } private static void UpdateStepHoldRepeat(KeyboardState keyboard, KeyboardState previousKeyboard) { - if (!CanStepGameplay || !Settings.StepHoldRepeat || !TryGetButtonBindingKeys(Settings.StepFrame, out IReadOnlyCollection stepKeys) || !IsKeyboardBindingHeld(stepKeys)) { + if (!CanStepGameplay(Session, Settings) || !Settings.StepHoldRepeat || !TryGetButtonBindingKeys(Settings.StepFrame, out IReadOnlyCollection stepKeys) || !IsKeyboardBindingHeld(stepKeys)) { Session.StepFrameHoldFrames = 0; Session.StepFrameRepeatCountdown = 0; return; diff --git a/Source/Module/akron-module-player-runtime.cs b/Source/Module/akron-module-player-runtime.cs index 9dea34b0..8733f5d4 100644 --- a/Source/Module/akron-module-player-runtime.cs +++ b/Source/Module/akron-module-player-runtime.cs @@ -148,7 +148,8 @@ private static PlayerDeadBody PlayerOnDie(On.Celeste.Player.orig_Die orig, Playe // Keep Celeste's death effect, delay, and wipe. The native // DeathAction normally reloads the room; substitute the full // StartPos snapshot only after that presentation completes. - deadBody.DeathAction = () => AkronActions.RestoreStartPosAfterDeath(level, startPosRespawn); + Session deathSession = level.Session; + deadBody.DeathAction = () => AkronActions.RestoreStartPosAfterDeath(level, deathSession, deadBody, startPosRespawn); } } diff --git a/Source/Overlay/akron-overlay-bindable-actions.cs b/Source/Overlay/akron-overlay-bindable-actions.cs index 84e8e060..63743ea5 100644 --- a/Source/Overlay/akron-overlay-bindable-actions.cs +++ b/Source/Overlay/akron-overlay-bindable-actions.cs @@ -76,26 +76,11 @@ private static IEnumerable BuildPopupBindableActions(Level level return; } - bool next = !session.TimescaleEnabled; - if (next && session.TimescaleMultiplier != 1f && !AkronModule.TryUse(AkronFeatureKind.Timescale)) { - return; - } - - session.TimescaleEnabled = next; + AkronActions.TrySetTimescaleEnabled(session, !session.TimescaleEnabled); }); yield return new BindableAction(PopupActionKey("Timescale", "Decrease"), "Timescale / Decrease", () => ApplyOptionsPopupDelta("Timescale", -1)); yield return new BindableAction(PopupActionKey("Timescale", "Increase"), "Timescale / Increase", () => ApplyOptionsPopupDelta("Timescale", 1)); - yield return new BindableAction(PopupActionKey("Timescale", "Reset"), "Timescale / Reset", () => { - AkronModuleSession session = AkronModule.Session; - if (session == null || !AkronPolicy.CanUse(AkronFeatureKind.Timescale).Allowed) { - return; - } - - session.TimescaleMultiplier = 1f; - session.TimescaleEnabled = false; - AkronModule.ReleaseTimescale(); - Engine.Scene?.Add(new AkronToast("Timescale reset.")); - }); + yield return new BindableAction(PopupActionKey("Timescale", "Reset"), "Timescale / Reset", ResetTimescale); yield return new BindableAction(PopupActionKey("SRT Slot", "Previous"), "SRT Slot / Previous", () => ApplyOptionsPopupDelta("SRT Slot", -1)); yield return new BindableAction(PopupActionKey("SRT Slot", "Next"), "SRT Slot / Next", () => ApplyOptionsPopupDelta("SRT Slot", 1)); diff --git a/Source/Overlay/akron-overlay-bypass-popups.cs b/Source/Overlay/akron-overlay-bypass-popups.cs index e8522a8c..b6eff4bf 100644 --- a/Source/Overlay/akron-overlay-bypass-popups.cs +++ b/Source/Overlay/akron-overlay-bypass-popups.cs @@ -38,8 +38,7 @@ private void DrawTimescalePopupControls(string popupId) if (ImGui.Button("Reset##" + popupId, new NumericsVector2(112f, 0f))) { - SetTimescaleMultiplier(1f); - SetTimescaleEnabled(false); + ResetTimescale(); } DrawPopupActionBindingContext("Timescale", "Reset"); DrawPopupTooltip("Restore normal gameplay speed and turn Timescale off."); diff --git a/Source/Overlay/akron-overlay-entries.cs b/Source/Overlay/akron-overlay-entries.cs index 9551905b..d491d78e 100644 --- a/Source/Overlay/akron-overlay-entries.cs +++ b/Source/Overlay/akron-overlay-entries.cs @@ -1018,39 +1018,18 @@ private static string FormatNoclipFloatMultiplier(int speed) { private static void SetTimescaleMultiplier(float multiplier) { AkronModuleSession session = AkronModule.Session; - if (session == null) { - return; - } - - float next = Calc.Clamp((float) Math.Round(multiplier, 1), 0.1f, 2f); - if (session.TimescaleEnabled && next != 1f && !AkronModule.TryUse(AkronFeatureKind.Timescale)) { - return; + if (AkronActions.TrySetTimescaleMultiplier(session, multiplier)) { + Engine.Scene?.Add(new AkronToast("Timescale value: " + session.TimescaleMultiplier.ToString("0.0x"))); } - - AkronActions.ConfigureTimescaleMultiplier(session, next); - if (next == 1f && session.TimescaleEnabled) { -#pragma warning disable CS0618 - Engine.TimeRate = 1f; -#pragma warning restore CS0618 - } - Engine.Scene?.Add(new AkronToast("Timescale value: " + next.ToString("0.0x"))); } private static void SetTimescaleEnabled(bool enabled) { - AkronModuleSession session = AkronModule.Session; - if (session == null) { - return; - } - - if (enabled && session.TimescaleMultiplier != 1f && !AkronModule.TryUse(AkronFeatureKind.Timescale)) { - return; - } + AkronActions.TrySetTimescaleEnabled(AkronModule.Session, enabled); + } - session.TimescaleEnabled = enabled; - if (!enabled) { -#pragma warning disable CS0618 - Engine.TimeRate = 1f; -#pragma warning restore CS0618 + private static void ResetTimescale() { + if (AkronActions.TryResetTimescale(AkronModule.Session)) { + Engine.Scene?.Add(new AkronToast("Timescale reset.")); } } diff --git a/Source/Tools/akron-capture.cs b/Source/Tools/akron-capture.cs index e0c82896..e69b757a 100644 --- a/Source/Tools/akron-capture.cs +++ b/Source/Tools/akron-capture.cs @@ -27,6 +27,7 @@ internal static void CancelPendingCapture() { internal static bool IsCapturingGameFrame { get; private set; } internal static bool RequestGameplayBufferQaCapture( + Scene scene, string tag, out string normalizedTag, Action completion = null @@ -48,11 +49,11 @@ internal static bool RequestGameplayBufferQaCapture( pendingGameplayBufferQaTag = normalizedTag; pendingGameplayBufferQaCompletion = completion; - pendingGameplayBufferQaScene = Engine.Instance == null ? null : Engine.Scene; + pendingGameplayBufferQaScene = scene; return true; } - internal static void CapturePendingGameplayBufferQaFrame() { + internal static void CapturePendingGameplayBufferQaFrame(Scene currentScene) { if (string.IsNullOrWhiteSpace(pendingGameplayBufferQaTag)) { return; } @@ -66,7 +67,6 @@ internal static void CapturePendingGameplayBufferQaFrame() { try { // A queued QA capture still owes its completion if the scene or // engine has ended before the render boundary consumes it. - Scene currentScene = Engine.Instance == null ? null : Engine.Scene; if (!ReferenceEquals(requestedScene, currentScene) || !AkronPolicy.CanUse(AkronFeatureKind.ScreenshotTool).Allowed) { return; diff --git a/docs/contributing/feature-policy.mdx b/docs/contributing/feature-policy.mdx index 0cdcde56..2329256e 100644 --- a/docs/contributing/feature-policy.mdx +++ b/docs/contributing/feature-policy.mdx @@ -124,14 +124,20 @@ The declaration has no region, nodes, flag conditions, or executable commands. A Restrictions cover the current map side. They persist through room transitions, death, room reload, chapter restart and save-and-quit/reentry. Leaving for a menu, entering another map, or unloading Akron removes the old map's restrictions. Pending frame steps, StartPos actions and captures do not transfer into the next map. Restrictions are compiled from map data rather than stored in Akron's savestate/session payload. Restart the map after editing its declaration. +Direct chapter reloads retain policy through the outgoing level's end and every loader frame, including reloads that reuse the same session. Constructing a detached loader does not change the live map's policy. A cancelled StartPos death restore falls back to ordinary respawn only while its original dead body still belongs to the current room; stale callbacks cannot reload a replacement room or player. + StartPos snapshots record whether Akron owned the saved global timescale. Loading a snapshot cannot restore a denied Akron timescale or replace the active map's clock with it. Independently owned saved timing remains part of the restored simulation. This changes the snapshot format to v12; capture older StartPos snapshots again. The overlay retains the configured value and marks restricted rows as map-suppressed. `akron_status` lists the policy version and restricted identities separately from configured values. Commands and hotkeys cannot override map policy. Ongoing effects use the same policy, so a pre-enabled feature does not bypass the declaration. Suppression never saves replacement preferences. Akron releases timing and other reversible state it owns; it does not repeatedly reset another mod's clock. +The `Logging` identity suppresses Akron's own log-file writes. Warnings and errors still reach the Everest log when warning/error mirroring is enabled, so map restrictions cannot hide diagnostics needed to report a failure. + Each identity is independent. To suppress a preset's individual effects, list those effects as well as any bundle action you want to deny. For example, use `MapVersionStamp`, `PauseTracker`, `ProofRecorderGuard` and `EndScreenHelper` when those independently configured features must be off, rather than assuming `SubmissionMode` recursively suppresses them. For independent freeze and stepping control, `Freeze` suppresses the freeze itself and `FrameAdvance` suppresses stepping while leaving an allowed freeze intact. `CaptureCheatOptions` denies room and chapter scanners because they move the player and restore simulation clocks, even with their optional freeze/hide settings off. Ordinary screenshots remain available; use `ScreenshotTool` to deny those too. This is an Akron contract, not a sandbox for other mods. It governs Akron's effect and integration entry points, not independent commands or features in Speedrun Tool, CelesteTAS or other helpers. It does not undo irreversible actions performed before entering the map, such as save unlocks or previously spawned entities. +Akron's EVM integration requires the randomizer mutation observer so it can track and release randomized changes. If the installed EVM assembly lacks that capability, Akron logs a warning and leaves EVM state untouched rather than making untracked writes. + ### Integration API Code integrations can qualify support without private-field access: diff --git a/tests/community-pack-tests.cs b/tests/community-pack-tests.cs index 0b93f4f2..e55ede2d 100644 --- a/tests/community-pack-tests.cs +++ b/tests/community-pack-tests.cs @@ -343,7 +343,8 @@ public void FilterSearchesAuthorDescriptionAndTags() { [Fact] public void BeginRefreshLoadsFileIndexWithoutBlockingCaller() { string path = Path.Combine(Path.GetTempPath(), "akron-community-index-" + Guid.NewGuid().ToString("N") + ".json"); - File.WriteAllText(path, """ + string downloadUrl = new Uri(Path.Combine(Path.GetTempPath(), "file-refresh.akr")).AbsoluteUri; + File.WriteAllText(path, $$""" { "format": "akron-community-pack-index-v3", "version": 3, @@ -353,7 +354,7 @@ public void BeginRefreshLoadsFileIndexWithoutBlockingCaller() { "title": "File refresh pack", "section": "AutoKill", "mapSid": "Maps/Current", - "downloadUrl": "file:///tmp/file-refresh.akr", + "downloadUrl": {{JsonSerializer.Serialize(downloadUrl)}}, "sha256": "0000000000000000000000000000000000000000000000000000000000000000", "sizeBytes": 1234 } diff --git a/tests/feature-registry-tests.cs b/tests/feature-registry-tests.cs index d82108ab..0345b322 100644 --- a/tests/feature-registry-tests.cs +++ b/tests/feature-registry-tests.cs @@ -108,35 +108,35 @@ public void LoadingAnotherMapDoesNotReplaceTheLiveThreadsPolicy() [Fact] public void LivePreferenceAndSessionChangesCannotEnableRestrictedFreezeOrStepping() { - using PolicyModuleState state = new(); + using PolicyState state = new(); state.Settings.FrameStepper = true; state.Settings.Noclip = true; state.Session.FreezeGameplay = true; state.Session.StepFrameRequested = true; AkronPolicy.EnterMap(MapWithDeclarations("Freeze,FrameAdvance,Noclip")); - Assert.False(AkronModule.IsGameplayFreezeEffective); - Assert.False(AkronModule.CanStepGameplay); + Assert.False(AkronModule.IsGameplayFreezeEffective(state.Session)); + Assert.False(AkronModule.CanStepGameplay(state.Session, state.Settings)); Assert.Empty(AkronPolicy.GetActiveCheatContributors(state.Settings, state.Session)); state.Session.FreezeGameplay = false; state.Session.FreezeGameplay = true; - Assert.False(AkronModule.IsGameplayFreezeEffective); + Assert.False(AkronModule.IsGameplayFreezeEffective(state.Session)); Assert.True(state.Settings.Noclip); Assert.True(state.Settings.FrameStepper); Assert.True(state.Session.FreezeGameplay); AkronPolicy.EnterMap(MapWithDeclarations("FrameAdvance")); - Assert.True(AkronModule.IsGameplayFreezeEffective); - Assert.False(AkronModule.CanStepGameplay); + Assert.True(AkronModule.IsGameplayFreezeEffective(state.Session)); + Assert.False(AkronModule.CanStepGameplay(state.Session, state.Settings)); AkronPolicy.LeaveMap(); - Assert.True(AkronModule.CanStepGameplay); + Assert.True(AkronModule.CanStepGameplay(state.Session, state.Settings)); Assert.Contains(AkronPolicy.GetActiveCheatContributors(state.Settings, state.Session), item => item.Feature == AkronFeatureKind.Noclip); } [Fact] public void RestrictedTimescaleReleasesOnlyItsOwnClockAndPreservesSessionPreference() { - using PolicyModuleState state = new(); + using PolicyState state = new(); #pragma warning disable CS0618 float original = Engine.TimeRate; try @@ -144,21 +144,21 @@ public void RestrictedTimescaleReleasesOnlyItsOwnClockAndPreservesSessionPrefere Engine.TimeRate = 0.8f; state.Session.TimescaleEnabled = true; state.Session.TimescaleMultiplier = 0.25f; - AkronModule.ApplyTimescale(); + AkronModule.ApplyTimescale(state.Session); Assert.Equal(0.25f, Engine.TimeRate); AkronPolicy.EnterMap(MapWithDeclarations("Timescale")); - AkronModule.ApplyTimescale(); + AkronModule.ApplyTimescale(state.Session); Assert.Equal(0.8f, Engine.TimeRate); Assert.True(state.Session.TimescaleEnabled); Assert.Equal(0.25f, state.Session.TimescaleMultiplier); Engine.TimeRate = 0.4f; state.Session.TimescaleMultiplier = 0.1f; - AkronModule.ApplyTimescale(); + AkronModule.ApplyTimescale(state.Session); Assert.Equal(0.4f, Engine.TimeRate); AkronPolicy.LeaveMap(); - AkronModule.ApplyTimescale(); + AkronModule.ApplyTimescale(state.Session); Assert.Equal(0.1f, Engine.TimeRate); Engine.TimeRate = 0.6f; AkronModule.ReleaseTimescale(); @@ -172,10 +172,115 @@ public void RestrictedTimescaleReleasesOnlyItsOwnClockAndPreservesSessionPrefere #pragma warning restore CS0618 } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void DisableAndResetTimescaleReleaseOnlyTheClockAkronStillOwns(bool reset, bool replacedByAnotherMod) + { + using PolicyState state = new(); +#pragma warning disable CS0618 + float original = Engine.TimeRate; + try + { + Engine.TimeRate = 0.8f; + state.Session.TimescaleEnabled = true; + state.Session.TimescaleMultiplier = 0.5f; + AkronModule.ApplyTimescale(state.Session); + Assert.Equal(0.5f, Engine.TimeRate); + if (replacedByAnotherMod) + { + Engine.TimeRate = 0.6f; + } + + Assert.True(reset + ? AkronActions.TryResetTimescale(state.Session) + : AkronActions.TrySetTimescaleEnabled(state.Session, false)); + + Assert.Equal(replacedByAnotherMod ? 0.6f : 0.8f, Engine.TimeRate); + Assert.False(AkronModule.OwnsCurrentTimescale); + Assert.False(state.Session.TimescaleEnabled); + Assert.Equal(reset ? 1f : 0.5f, state.Session.TimescaleMultiplier); + Assert.Empty(AkronPolicy.GetActiveCheatContributors(state.Settings, state.Session)); + AkronModule.ApplyTimescale(state.Session); + Assert.Equal(replacedByAnotherMod ? 0.6f : 0.8f, Engine.TimeRate); + } + finally + { + AkronModule.ReleaseTimescale(); + Engine.TimeRate = original; + } +#pragma warning restore CS0618 + } + + [Fact] + public void SettingNormalMultiplierKeepsClockOwnershipUntilTimescaleIsDisabled() + { + using PolicyState state = new(); +#pragma warning disable CS0618 + float original = Engine.TimeRate; + try + { + Engine.TimeRate = 0.8f; + state.Session.TimescaleEnabled = true; + state.Session.TimescaleMultiplier = 0.5f; + AkronModule.ApplyTimescale(state.Session); + + Assert.True(AkronActions.TrySetTimescaleMultiplier(state.Session, 1f)); + Assert.Equal(1f, Engine.TimeRate); + Assert.True(state.Session.TimescaleEnabled); + Assert.True(AkronModule.OwnsCurrentTimescale); + Assert.Empty(AkronPolicy.GetActiveCheatContributors(state.Settings, state.Session)); + Assert.True(AkronActions.TrySetTimescaleEnabled(state.Session, false)); + Assert.Equal(0.8f, Engine.TimeRate); + } + finally + { + AkronModule.ReleaseTimescale(); + Engine.TimeRate = original; + } +#pragma warning restore CS0618 + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RestrictedTimescaleCommandsCannotChangePreferencesOrAnotherModsClock(bool enabled) + { + using PolicyState state = new(); + state.Session.TimescaleEnabled = enabled; + state.Session.TimescaleMultiplier = 0.5f; + AkronPolicy.EnterMap(MapWithDeclarations("Timescale")); +#pragma warning disable CS0618 + float original = Engine.TimeRate; + try + { + Engine.TimeRate = 0.6f; + Assert.False(AkronActions.TrySetTimescaleEnabled(state.Session, false)); + Assert.False(AkronActions.TrySetTimescaleEnabled(state.Session, true)); + Assert.False(AkronActions.TrySetTimescaleMultiplier(state.Session, 1f)); + Assert.False(AkronActions.TrySetTimescaleMultiplier(state.Session, 0.2f)); + Assert.False(AkronActions.TryResetTimescale(state.Session)); + + Assert.Equal(enabled, state.Session.TimescaleEnabled); + Assert.Equal(0.5f, state.Session.TimescaleMultiplier); + Assert.Equal(0.6f, Engine.TimeRate); + Assert.False(AkronModule.OwnsCurrentTimescale); + Assert.Empty(AkronPolicy.GetActiveCheatContributors(state.Settings, state.Session)); + } + finally + { + AkronModule.ReleaseTimescale(); + Engine.TimeRate = original; + } +#pragma warning restore CS0618 + } + [Fact] public void SnapshotTimescaleOwnershipCannotRestoreDeniedTimingOrLoseItsOriginalClock() { - using PolicyModuleState state = new(); + using PolicyState state = new(); #pragma warning disable CS0618 float original = Engine.TimeRate; try @@ -183,7 +288,7 @@ public void SnapshotTimescaleOwnershipCannotRestoreDeniedTimingOrLoseItsOriginal Engine.TimeRate = 0.8f; state.Session.TimescaleEnabled = true; state.Session.TimescaleMultiplier = 0.25f; - AkronModule.ApplyTimescale(); + AkronModule.ApplyTimescale(state.Session); AkronSaveLoadSlot slot = new("timing", "room", "Tests/Timing", true) { EngineTimeRate = Engine.TimeRate, @@ -234,25 +339,15 @@ private static MapData MapWithDeclarations(params string[] declarations) return map; } - private sealed class PolicyModuleState : IDisposable + private sealed class PolicyState : IDisposable { - private readonly AkronModule previous = AkronModule.Instance; internal readonly AkronModuleSettings Settings = new(); internal readonly AkronModuleSession Session = new(); - internal PolicyModuleState() - { - AkronModule module = (AkronModule)RuntimeHelpers.GetUninitializedObject(typeof(AkronModule)); - module._Settings = Settings; - module._Session = Session; - typeof(AkronModule).GetProperty(nameof(AkronModule.Instance))!.SetValue(null, module); - } - public void Dispose() { AkronModule.ReleaseTimescale(); AkronPolicy.UnloadMapRestrictions(); - typeof(AkronModule).GetProperty(nameof(AkronModule.Instance))!.SetValue(null, previous); } } diff --git a/tests/screenshot-scanner-tests.cs b/tests/screenshot-scanner-tests.cs index 3e1be73d..975270e6 100644 --- a/tests/screenshot-scanner-tests.cs +++ b/tests/screenshot-scanner-tests.cs @@ -325,17 +325,18 @@ public void PendingGameplayBufferQaCaptureAlwaysRunsItsCompletion() { int completions = 0; try { Assert.True(AkronCapture.RequestGameplayBufferQaCapture( + null, "completion-test", out string normalizedTag, () => completions++)); - _ = Record.Exception(AkronCapture.CapturePendingGameplayBufferQaFrame); - _ = Record.Exception(AkronCapture.CapturePendingGameplayBufferQaFrame); + _ = Record.Exception(() => AkronCapture.CapturePendingGameplayBufferQaFrame(null)); + _ = Record.Exception(() => AkronCapture.CapturePendingGameplayBufferQaFrame(null)); Assert.Equal("completion-test", normalizedTag); Assert.Equal(1, completions); } finally { - AkronCapture.CapturePendingGameplayBufferQaFrame(); + AkronCapture.CapturePendingGameplayBufferQaFrame(null); } } @@ -345,10 +346,12 @@ public void ReplacingPendingGameplayBufferQaCaptureCompletesTheDisplacedRequest( int secondCompletions = 0; try { Assert.True(AkronCapture.RequestGameplayBufferQaCapture( + null, "first-capture", out _, () => firstCompletions++)); Assert.True(AkronCapture.RequestGameplayBufferQaCapture( + null, "second-capture", out _, () => secondCompletions++)); @@ -356,10 +359,10 @@ public void ReplacingPendingGameplayBufferQaCaptureCompletesTheDisplacedRequest( Assert.Equal(1, firstCompletions); Assert.Equal(0, secondCompletions); - AkronCapture.CapturePendingGameplayBufferQaFrame(); + AkronCapture.CapturePendingGameplayBufferQaFrame(null); Assert.Equal(1, secondCompletions); } finally { - AkronCapture.CapturePendingGameplayBufferQaFrame(); + AkronCapture.CapturePendingGameplayBufferQaFrame(null); } } diff --git a/tests/startpos-persistence-tests.cs b/tests/startpos-persistence-tests.cs index 805f061a..eef02472 100644 --- a/tests/startpos-persistence-tests.cs +++ b/tests/startpos-persistence-tests.cs @@ -2819,18 +2819,6 @@ public void StartPosHotkeyCaptureKeepsTheExistingPreUpdateBoundary() { Assert.True(gameplayUpdate > hotkeys); } - [Fact] - public void GameplayBufferQaProbeReadsTheFinishedRoomBeforeAkronHud() { - string moduleSource = File.ReadAllText(GetModuleSourcePath()); - int baseRender = moduleSource.IndexOf("orig(self);", moduleSource.IndexOf("private static void EngineOnRenderCore", StringComparison.Ordinal), StringComparison.Ordinal); - int pixelProbe = moduleSource.IndexOf("AkronCapture.CapturePendingGameplayBufferQaFrame();", baseRender, StringComparison.Ordinal); - int akronHud = moduleSource.IndexOf("RenderAkronLevelHud(postRenderLevel);", baseRender, StringComparison.Ordinal); - - Assert.True(baseRender >= 0); - Assert.True(pixelProbe > baseRender); - Assert.True(akronHud > pixelProbe); - } - [Fact] public void ExactReferenceCommandCapturesTheFirstRenderedSetFrame() { string qaSource = File.ReadAllText(GetQaCommandsSourcePath()); @@ -2908,12 +2896,17 @@ public void PersistentRestoreRejectsNonFiniteProcessGlobalsWithoutMutation(strin PropertyInfo property = typeof(AkronPersistentRuntimeState).GetProperty(field)!; MethodInfo apply = typeof(AkronSaveLoadService).GetMethod( "ApplyPersistentRuntimeState", BindingFlags.Static | BindingFlags.NonPublic)!; + // CI keeps field metadata but strips the game's property bodies. + FieldInfo[] distortionFields = typeof(Distort).GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic) + .Where(candidate => candidate.FieldType == typeof(float)).ToArray(); + object?[] originalDistortion = distortionFields.Select(candidate => candidate.GetValue(null)).ToArray(); #pragma warning disable CS0618 - var original = (Engine.TimeRate, Glitch.Value, Distort.Anxiety, Distort.GameRate); + var original = (Engine.TimeRate, Glitch.Value); foreach (float invalid in new[] { float.NaN, float.PositiveInfinity, float.NegativeInfinity }) { property.SetValue(state, invalid); Assert.False((bool)apply.Invoke(null, new object[] { level, state })!); - Assert.Equal(original, (Engine.TimeRate, Glitch.Value, Distort.Anxiety, Distort.GameRate)); + Assert.Equal(original, (Engine.TimeRate, Glitch.Value)); + Assert.Equal(originalDistortion, distortionFields.Select(candidate => candidate.GetValue(null)).ToArray()); } #pragma warning restore CS0618 } @@ -3706,23 +3699,6 @@ public void StartPosSnapshotKeysDoNotUseLossyAreaSidSanitization() { } - [Fact] - public void EnabledStartPosRespawnUsesTheLastLoadedSlotAfterDeath() { - string source = File.ReadAllText(GetActionsSourcePath()); - string playerRuntimeSource = File.ReadAllText(GetPlayerRuntimeSourcePath()); - - Assert.Contains("if (loadedSlot > 0)", source); - Assert.Contains("AkronModule.Session.LastLoadedStartPosSlot = loadedSlot;", source); - Assert.Contains("RestoreStartPosAfterDeath(Level level, AkronStartPos startPos)", source); - Assert.Contains("deadBody.DeathAction = () =>", playerRuntimeSource); - Assert.Contains("deadBody.DeathAction == null", playerRuntimeSource); - Assert.Contains("!deadBody.HasGolden", playerRuntimeSource); - Assert.Contains("if (Engine.Scene != level)", source); - Assert.Contains("SpotlightWipe.FocusPoint = respawnPoint - restoredLevel.Camera.Position;", source); - Assert.Contains("restoredLevel.DoScreenWipe(wipeIn: true, () => CompleteStartPosInputWaitWipe(restoredLevel));", source); - Assert.Contains("level.Reload();", source); - Assert.Equal(1, playerRuntimeSource.Split("AkronActions.RestoreStartPosAfterDeath(level, startPosRespawn)").Length - 1); - } [Fact] public void SuccessfulStartPosLoadsWaitForFreshInputAndKeepBackdropPresentationRunning() { @@ -4650,14 +4626,18 @@ public void PacingRedistributesASnapshotsCostWithoutManufacturingAnyMore() { RunPacedSnapshot(directory, "free", out long freeAllocated); - // Now hold the gate shut and open it in bursts, so the same job - // stops and restarts many times over. + // Wait until the worker actually parks, then open short work windows. + // A fast worker may finish before all six windows are needed. AkronSnapshotPacing.GameplayActive = true; long suspendedAllocated = 0; - Stopwatch suspendedTimer = Stopwatch.StartNew(); - Thread worker = new Thread(() => RunPacedSnapshot(directory, "suspended", out suspendedAllocated)); + using ManualResetEventSlim parked = new ManualResetEventSlim(); + Thread worker = new Thread(() => RunPacedSnapshot(directory, "suspended", out suspendedAllocated, () => { + parked.Set(); + return false; + })); worker.IsBackground = true; worker.Start(); + bool observedPark = parked.Wait(TimeSpan.FromSeconds(10)); for (int cycle = 0; cycle < 6 && worker.IsAlive; cycle++) { Thread.Sleep(30); AkronSnapshotPacing.GameplayActive = false; @@ -4666,7 +4646,6 @@ public void PacingRedistributesASnapshotsCostWithoutManufacturingAnyMore() { } AkronSnapshotPacing.GameplayActive = false; Assert.True(worker.Join(TimeSpan.FromSeconds(60)), "the suspended snapshot never finished"); - suspendedTimer.Stop(); Assert.True(freeAllocated > 0); // Five percent covers jitter in the shared reflection caches. A @@ -4676,10 +4655,7 @@ public void PacingRedistributesASnapshotsCostWithoutManufacturingAnyMore() { Assert.True(ratio < 1.05, "suspending the worker allocated " + suspendedAllocated + " bytes against " + freeAllocated + " for an uninterrupted run"); - // And it really did stop: the gate was shut for at least six 30 ms - // stretches, none of which can overlap the work. - Assert.True(suspendedTimer.ElapsedMilliseconds >= 180, - "the suspended run took " + suspendedTimer.ElapsedMilliseconds + " ms, so it never waited"); + Assert.True(observedPark, "the snapshot worker never entered its pacing wait"); } finally { AkronSnapshotPacing.GameplayActive = previousActive; AkronSnapshotPacing.ForcedOpen = previousForcedOpen; @@ -4689,13 +4665,13 @@ public void PacingRedistributesASnapshotsCostWithoutManufacturingAnyMore() { // One whole paced job: the capture walk and the snapshot write, both of // which call Pace, measured on the thread that runs them. - private static void RunPacedSnapshot(string directory, string slotName, out long allocatedBytes) { + private static void RunPacedSnapshot(string directory, string slotName, out long allocatedBytes, Func? isAbandoned = null) { AkronReconstructionGraph graph = new AkronReconstructionGraph(_ => false); PacingChainNode saved = BuildPacingChain(600, valueOffset: 10); PacingChainNode baseline = BuildPacingChain(600, valueOffset: 0); long before = GC.GetAllocatedBytesForCurrentThread(); - AkronSnapshotPacing.BeginPacedWork(); + AkronSnapshotPacing.BeginPacedWork(isAbandoned); try { AkronReconstructionCapture capture = graph.Capture(saved, baseline); Assert.True(capture.Success, capture.Error);