Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions Loenn/entities/feature_restrictions.lua
Original file line number Diff line number Diff line change
@@ -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
93 changes: 74 additions & 19 deletions Source/Actions/akron-actions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -354,22 +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);
if (next == 1f && session.TimescaleEnabled) {
#pragma warning disable CS0618
Engine.TimeRate = 1f;
#pragma warning restore CS0618
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();
}
Engine.Scene?.Add(new AkronToast("Timescale value: " + next.ToString("0.0x")));
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) {
Expand Down Expand Up @@ -410,25 +445,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;
}
Expand Down Expand Up @@ -516,7 +571,7 @@ public static void ApplyCameraOffset(Level level) {
return;
}

if (!AkronModule.TryUse(AkronFeatureKind.CameraOffset)) {
if (!AkronModule.TryUseRuntime(AkronFeatureKind.CameraOffset)) {
return;
}

Expand Down Expand Up @@ -824,7 +879,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;
}

Expand Down Expand Up @@ -997,7 +1052,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;
}

Expand Down
27 changes: 26 additions & 1 deletion Source/Actions/akron-backup-actions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,9 @@ internal static bool TryCreateBackup(
out IReadOnlyList<AkronBackupSkippedFile> skipped,
string protectedBackupPath = null) {
skipped = Array.Empty<AkronBackupSkippedFile>();
if (!AkronPolicy.CanUse(AkronFeatureKind.Backups).Allowed) {
return false;
}
lock (Sync) {
try {
string savesFolder = GetSavesFolder();
Expand Down Expand Up @@ -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);
Expand All @@ -540,6 +546,9 @@ public static void OpenBackupFolder() {
}

public static IReadOnlyList<AkronBackupEntry> ListBackups() {
if (!AkronPolicy.CanUse(AkronFeatureKind.Backups).Allowed) {
return Array.Empty<AkronBackupEntry>();
}
lock (Sync) {
if (!backupListDirty) {
return cachedBackups;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}

Expand All @@ -619,13 +635,19 @@ internal static void RestoreBackupForQa(AkronBackupEntry backup) {
}

internal static void ApplyRetentionForQa() {
if (!AkronPolicy.CanUse(AkronFeatureKind.Backups).Allowed) {
return;
}
lock (Sync) {
InvalidateBackupList();
ApplyRetention();
}
}

public static void SetPinned(AkronBackupEntry backup, bool pinned) {
if (!AkronModule.TryUse(AkronFeatureKind.Backups)) {
return;
}
if (backup == null || string.IsNullOrWhiteSpace(backup.Path)) {
return;
}
Expand Down Expand Up @@ -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();
Expand Down
47 changes: 39 additions & 8 deletions Source/Actions/akron-startpos-actions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Dictionary<int, AkronStartPos>> PendingStartPositionsByFileAndMap =
new Dictionary<string, Dictionary<int, AkronStartPos>>(StringComparer.Ordinal);
Expand Down Expand Up @@ -292,9 +300,14 @@ Action<bool> 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<Player>(), requestedPlayer) ||
Expand All @@ -306,7 +319,9 @@ Action<bool> completion
}
captured = CompleteStartPosCapture(level, startPos, fileSlot, slot, toast);
} finally {
startPosCaptureInProgress = false;
if (generation == startPosActionGeneration) {
startPosCaptureInProgress = false;
}
completion?.Invoke(captured);
}
});
Expand Down Expand Up @@ -888,9 +903,16 @@ public static void LoadStartPos(Level level, Action<bool> 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;
Expand Down Expand Up @@ -1091,16 +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) {
return;
}

internal static void RestoreStartPosAfterDeath(Level level, Session requestedSession, PlayerDeadBody deadBody, AkronStartPos startPos) {
ulong generation = startPosActionGeneration;
AkronModule.ScheduleAfterStableEngineUpdate(() => {
if (Engine.Scene != level) {
// 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;
}

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading