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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ This project uses version tags that match the mod version in `everest.yaml`, whi

## Unreleased

### Changed

- Move setup packs to `akron-setup-v10`, with shared, lossless Brotli compression for exact StartPos snapshots. Export older packs again.
- Compress setup exports and uploads in the background, keep the previous export if writing fails, and reuse downloaded packs whose catalog checksum still matches.
- Move StartPos snapshots to `akron-reconstruction-v11` to include room-owned registry state. Capture older slots again, then re-export their StartPos packs; positions, spawn settings, and keybinds are unchanged.

### Fixed

- Reconstruct nested grids, helper-owned UI, backdrop particles, retained entities, running coroutines, and component-owned callbacks through their identified owners without requiring identical fresh-room populations.
- Preserve scene-owned camera identity for detached components, including grids first reached through callbacks, and restore map metadata cached by mod sessions after leaving a room.
- Avoid worker-stack overflow when indexing and saving deeply linked StartPos graphs.
- Stop boxed native pointers from trapping StartPos resource indexing in an infinite walk. Refuse unsupported Lua state before copying native VM handles.
- Treat process caches as live boundaries during capture and resource indexing, and snapshot mutable blend settings without copying GPU handles.
- Report custom or disposed blend states through the normal capture-failure path instead of letting Set throw.
- Restore the active room's dust style with its controllers so repeated StartPos loads do not leave their shared registry empty.
- Preserve readonly fields and shared references when copying native StartPos state back into an existing room.
- Report the actual cause of refused StartPos captures. The map sweep waits for capture, load, and export completion, respects cutscene guards, preserves exact room names, and marks checks after a command timeout as blocked.
- Keep the bulk sweep's recovery archive outside `Saves` so startup backups do not include it again on each launch.
- Restart the bulk sweep after SSH recovery, reject stale startup logs, and keep failed or blocked maps in its aggregate results, including unreported sides after a timeout.
- Preserve quotes, backslashes, and line breaks in automation arguments and room-warp acknowledgements. Quoted command-file arguments now use JSON string escaping.
- Reject setup packs whose snapshot documents expand beyond 1 GiB in total, on both export and import.
- Refuse heavily fragmented snapshot bundles before tiny commands or frames can stall setup import.

## Akron Beta 80

### Fixed
Expand Down
88 changes: 53 additions & 35 deletions Source/Actions/akron-startpos-actions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,11 @@ Action<bool> completion
if (ownsRollback) {
RestoreStartPosRollback(fileSlot, slot, null, stateSlotName, reason: null);
}
Engine.Scene?.Add(new AkronToast("StartPos capture failed: " + saveResult + "."));
string captureFailure = string.IsNullOrWhiteSpace(AkronSaveLoadService.LastPersistentSnapshotError)
? saveResult.ToString()
: AkronSaveLoadService.LastPersistentSnapshotError;
AkronLog.Warn(nameof(AkronActions), "StartPos " + slot + " capture failed: " + captureFailure);
Engine.Scene?.Add(new AkronToast(TruncateStartPosFailureToast("StartPos capture failed: " + captureFailure)));
completion?.Invoke(false);
return;
}
Expand Down Expand Up @@ -839,49 +843,63 @@ private static int ReadSnapshotFormatVersion(string format) {
: 0;
}

public static void LoadStartPos(Level level) {
if (level == null || !AkronModule.TryUse(AkronFeatureKind.StartPosTools)) {
return;
}
if (startPosCaptureInProgress) {
Engine.Scene?.Add(new AkronToast("StartPos capture is still finishing."));
return;
}

int slot = AkronModule.Settings.ActiveStartPosSlot;
AkronStartPos startPos = GetStartPos(slot);
if (startPos == null) {
Engine.Scene?.Add(new AkronToast(DescribeMissingStartPos(level, slot)));
return;
}
if (!IsStartPosInArea(startPos, level.Session.Area.GetSID())) {
Engine.Scene?.Add(new AkronToast("StartPos " + AkronModule.Settings.ActiveStartPosSlot + " belongs to " + startPos.AreaSid + "."));
return;
}

AkronModule.ScheduleAfterStableEngineUpdate(() => {
// The load runs one engine boundary after the key press. Both of these
// used to return silently, which is indistinguishable from a dead hotkey.
if (Engine.Scene != level) {
Engine.Scene?.Add(new AkronToast("StartPos " + slot + " was not loaded: the scene changed."));
public static void LoadStartPos(Level level, Action<bool> completion = null) {
bool scheduled = false;
try {
if (level == null || !AkronModule.TryUse(AkronFeatureKind.StartPosTools)) {
return;
}
if (startPosCaptureInProgress) {
Engine.Scene?.Add(new AkronToast("StartPos " + slot + " was not loaded: a capture is still finishing."));
Engine.Scene?.Add(new AkronToast("StartPos capture is still finishing."));
return;
}

if (!RestoreStartPos(
level,
startPos,
"Loaded StartPos " + slot + ".",
slot)) {
int slot = AkronModule.Settings.ActiveStartPosSlot;
AkronStartPos startPos = GetStartPos(slot);
if (startPos == null) {
Engine.Scene?.Add(new AkronToast(DescribeMissingStartPos(level, slot)));
return;
}
if (!IsStartPosInArea(startPos, level.Session.Area.GetSID())) {
Engine.Scene?.Add(new AkronToast("StartPos " + slot + " belongs to " + startPos.AreaSid + "."));
return;
}

Level currentLevel = Engine.Scene as Level ?? level;
BeginStartPosInputWait(currentLevel, waitingForWipe: false);
});
AkronModule.ScheduleAfterStableEngineUpdate(() => {
bool loaded = false;
try {
if (Engine.Scene != level) {
Engine.Scene?.Add(new AkronToast("StartPos " + slot + " was not loaded: the scene changed."));
return;
}
if (startPosCaptureInProgress) {
Engine.Scene?.Add(new AkronToast("StartPos " + slot + " was not loaded: a capture is still finishing."));
return;
}

if (!RestoreStartPos(
level,
startPos,
"Loaded StartPos " + slot + ".",
slot)) {
return;
}

Level currentLevel = Engine.Scene as Level ?? level;
BeginStartPosInputWait(currentLevel, waitingForWipe: false);
loaded = true;
} finally {
completion?.Invoke(loaded);
}
});
scheduled = true;
} finally {
// The deferred action owns completion only after it has been queued.
// An observer must never mistake scheduling (or a missing slot) for Load.
if (!scheduled) {
completion?.Invoke(false);
}
}
}

public static void LoadStartPosSlot(Level level, int slot) {
Expand Down
63 changes: 39 additions & 24 deletions Source/Automation/akron-automation-service.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Celeste;
using Monocle;

Expand Down Expand Up @@ -131,6 +132,7 @@ public static class AkronAutomationService {
"akron_qa_label_number",
"akron_qa_label_row_order",
"akron_qa_list_maps",
"akron_qa_list_rooms",
"akron_qa_messages",
"akron_qa_pause",
"akron_qa_pause_event",
Expand Down Expand Up @@ -377,38 +379,45 @@ private static void ExecuteCommand(string commandLine) {
Engine.Commands.ExecuteCommand(command, args);
}

private static string[] Tokenize(string input) {
internal static string[] Tokenize(string input) {
List<string> tokens = new List<string>();
StringBuilder current = new StringBuilder();
bool insideQuotes = false;

foreach (char character in input) {
if (character == '"') {
insideQuotes = !insideQuotes;
int index = 0;
while (index < input.Length) {
if (char.IsWhiteSpace(input[index])) {
index++;
continue;
}

if (char.IsWhiteSpace(character) && !insideQuotes) {
FlushToken(tokens, current);
continue;
int start = index;
if (input[index] == '"') {
index++;
while (index < input.Length && input[index] != '"') {
if (input[index] == '\\') {
index++;
}
index++;
}
if (index >= input.Length) {
throw new JsonException("Quoted argument is not terminated.");
}
index++;
if (index < input.Length && !char.IsWhiteSpace(input[index])) {
throw new JsonException("Quoted arguments must be separated by whitespace.");
}
tokens.Add(JsonSerializer.Deserialize<string>(input.AsSpan(start, index - start)));
} else {
while (index < input.Length && !char.IsWhiteSpace(input[index])) {
if (input[index] == '"') {
throw new JsonException("Quoted arguments must start at a token boundary.");
}
index++;
}
tokens.Add(input.Substring(start, index - start));
}

current.Append(character);
}

FlushToken(tokens, current);
return tokens.ToArray();
}

private static void FlushToken(List<string> tokens, StringBuilder current) {
if (current.Length == 0) {
return;
}

tokens.Add(current.ToString());
current.Clear();
}

private static void AppendOutput(string line) {
string safeLine = (line ?? string.Empty).Replace('\r', ' ').Replace('\n', ' ');
if (safeLine.Length > MaxOutputLineCharacters) {
Expand Down Expand Up @@ -503,7 +512,13 @@ private static bool TryParseCommandFile(string content, string expectedToken, ou
error = "Command file exceeds command limits.";
return false;
}
string command = Tokenize(commandLine).FirstOrDefault() ?? string.Empty;
string command;
try {
command = Tokenize(commandLine).FirstOrDefault() ?? string.Empty;
} catch (JsonException) {
error = "Command file contains an invalid quoted argument.";
return false;
}
if (!IsAllowedCommand(command)) {
error = "Automation command is not allowlisted: " + command;
return false;
Expand Down
104 changes: 66 additions & 38 deletions Source/Commands/akron-qa-commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -570,43 +570,47 @@ public static void QaStartPosLoadProbe(string slotText = "1", string flag = "akr
}

AkronActions.SetStartPosSlot(slot);
AkronActions.LoadStartPos(level);
Func<Level, bool> recordProbe = currentLevel => {
bool waitForPixelCapture = false;
AkronAutomationService.RecordOutput("qa-startpos-load-probe: end-of-frame");
RecordControlledPlayerProbe(currentLevel, "qa-startpos-load-probe");
RecordQaStartPosBackdropState(currentLevel, slot, "qa-startpos-load-probe");
AkronAutomationService.RecordOutput("qa-session-flag: " + flag + "=" + currentLevel.Session.GetFlag(flag).ToString().ToLowerInvariant());
AkronAutomationService.RecordOutput("qa-session-counter: " + counter + "=" + currentLevel.Session.GetCounter(counter).ToString(CultureInfo.InvariantCulture));
AkronAutomationService.RecordOutput("qa-session-deaths: " + currentLevel.Session.Deaths.ToString(CultureInfo.InvariantCulture));
AkronAutomationService.RecordOutput("qa-session-room-deaths: " + currentLevel.Session.DeathsInCurrentLevel.ToString(CultureInfo.InvariantCulture));
AkronAutomationService.RecordOutput("qa-session-time: " + currentLevel.Session.Time.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(pixelTag)) {
if (AkronCapture.RequestGameplayBufferQaCapture(
pixelTag,
out string normalizedTag,
AkronAutomationService.CompleteDeferredRun)) {
waitForPixelCapture = true;
AkronAutomationService.RecordOutput("qa-startpos-load-probe-pixel: armed;tag=" + normalizedTag);
} else {
AkronAutomationService.RecordOutput("qa-startpos-load-probe-pixel: rejected");
}
}
return waitForPixelCapture;
};
AkronModule.ScheduleAfterStableEngineUpdate(() => {
bool waitForPixelCapture = false;
try {
Level currentLevel = Engine.Scene as Level ?? level;
waitForPixelCapture = recordProbe(currentLevel);
} finally {
if (!waitForPixelCapture) {
AkronAutomationService.CompleteDeferredRun();
}
}
});
AkronAutomationService.DeferRunCompletion();
Log("qa-startpos-load-probe: scheduled");
try {
AkronActions.LoadStartPos(level, loaded => {
bool waitForPixelCapture = false;
try {
if (!loaded) {
Log("qa-startpos-load-probe: failed;slot=" + slot.ToString(CultureInfo.InvariantCulture));
return;
}

Level currentLevel = (Level) Engine.Scene;
Log("qa-startpos-load-probe: loaded;slot=" + slot.ToString(CultureInfo.InvariantCulture));
RecordControlledPlayerProbe(currentLevel, "qa-startpos-load-probe");
RecordQaStartPosBackdropState(currentLevel, slot, "qa-startpos-load-probe");
AkronAutomationService.RecordOutput("qa-session-flag: " + flag + "=" + currentLevel.Session.GetFlag(flag).ToString().ToLowerInvariant());
AkronAutomationService.RecordOutput("qa-session-counter: " + counter + "=" + currentLevel.Session.GetCounter(counter).ToString(CultureInfo.InvariantCulture));
AkronAutomationService.RecordOutput("qa-session-deaths: " + currentLevel.Session.Deaths.ToString(CultureInfo.InvariantCulture));
AkronAutomationService.RecordOutput("qa-session-room-deaths: " + currentLevel.Session.DeathsInCurrentLevel.ToString(CultureInfo.InvariantCulture));
AkronAutomationService.RecordOutput("qa-session-time: " + currentLevel.Session.Time.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(pixelTag)) {
if (AkronCapture.RequestGameplayBufferQaCapture(
pixelTag,
out string normalizedTag,
AkronAutomationService.CompleteDeferredRun)) {
waitForPixelCapture = true;
AkronAutomationService.RecordOutput("qa-startpos-load-probe-pixel: armed;tag=" + normalizedTag);
} else {
AkronAutomationService.RecordOutput("qa-startpos-load-probe-pixel: rejected");
}
}
} finally {
if (!waitForPixelCapture) {
AkronAutomationService.CompleteDeferredRun();
}
}
});
} catch {
AkronAutomationService.CompleteDeferredRun();
throw;
}
}

private static void LogQaStartPosBackdropState(Level level, int slot, string prefix) {
Expand Down Expand Up @@ -1170,12 +1174,12 @@ public static void QaWarpRoom(string roomName = "") {
return;
}

if (string.IsNullOrWhiteSpace(roomName)) {
if (string.IsNullOrEmpty(roomName)) {
Log("qa-warp-room: missing room");
return;
}

LevelData room = level.Session.MapData.Get(roomName.Trim());
LevelData room = level.Session.MapData.Get(roomName);
if (room == null) {
Log("qa-warp-room: not-found room=" + roomName);
return;
Expand All @@ -1201,7 +1205,7 @@ public static void QaWarpRoom(string roomName = "") {
level.Entities.UpdateLists();
AkronLevelRenderState.RelinkRendererCameras(level);
};
Log("qa-warp-room: room=" + room.Name);
Log("qa-warp-room: room-json=" + Newtonsoft.Json.JsonConvert.SerializeObject(room.Name));
}

[Command("akron_qa_inspector_pin_world", "pin the entity inspector at a world coordinate for Akron live automation: x y")]
Expand Down Expand Up @@ -1552,6 +1556,30 @@ public static void QaListMaps(string filter = "") {
Log("qa-list-maps: count=" + count.ToString(CultureInfo.InvariantCulture));
}

[Command("akron_qa_list_rooms", "list exact room names without entity-output truncation: [offset] [limit]")]
public static void QaListRooms(string offsetText = "0", string limitText = "100") {
Level level = RequireLevel();
if (level == null) {
return;
}
if (!int.TryParse(offsetText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int offset) ||
!int.TryParse(limitText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int limit) ||
offset < 0 || limit < 1 || limit > 100) {
Log("usage: akron_qa_list_rooms [offset >= 0] [limit 1..100]");
return;
}

List<LevelData> rooms = level.Session.MapData.Levels;
int end = offset >= rooms.Count ? rooms.Count : offset + Math.Min(limit, rooms.Count - offset);
for (int index = offset; index < end; index++) {
LevelData room = rooms[index];
if (room != null && !room.Dummy) {
Log("qa-map-room: " + Newtonsoft.Json.JsonConvert.SerializeObject(room.Name));
}
}
Log("qa-map-rooms-next: " + (end < rooms.Count ? end : -1).ToString(CultureInfo.InvariantCulture));
}

[Command("akron_qa_find_map_entities", "list loaded map entity data by name filter: [filter] [limit]")]
public static void QaFindMapEntities(string filter = "", string limitText = "80") {
Level level = RequireLevel();
Expand Down
3 changes: 2 additions & 1 deletion Source/Commands/akron-setup-commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public static void Setup(string value = "", string part2 = "", string part3 = ""
if (string.IsNullOrWhiteSpace(value)) {
Log("setup-section: " + AkronSetupPacks.FormatSection(AkronModule.Settings.SetupPackSection));
Log("setup-directory: " + AkronSetupPacks.GetSetupDirectory());
Log("setup-export-in-progress: " + AkronSetupPacks.ExportInProgress.ToString().ToLowerInvariant());
return;
}

Expand All @@ -24,7 +25,7 @@ public static void Setup(string value = "", string part2 = "", string part3 = ""
}

string path = AkronSetupPacks.ExportCurrent(exportName, section);
Log("setup-export: " + path);
Log("setup-export-started: " + path);
Log("setup-section: " + AkronSetupPacks.FormatSection(section));
return;
}
Expand Down
Loading
Loading