diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf07757..21496220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Source/Actions/akron-startpos-actions.cs b/Source/Actions/akron-startpos-actions.cs index f4e45db0..067a0f2b 100644 --- a/Source/Actions/akron-startpos-actions.cs +++ b/Source/Actions/akron-startpos-actions.cs @@ -357,7 +357,11 @@ Action 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; } @@ -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 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) { diff --git a/Source/Automation/akron-automation-service.cs b/Source/Automation/akron-automation-service.cs index 4a480410..b9e57423 100644 --- a/Source/Automation/akron-automation-service.cs +++ b/Source/Automation/akron-automation-service.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Celeste; using Monocle; @@ -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", @@ -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 tokens = new List(); - 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(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 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) { @@ -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; diff --git a/Source/Commands/akron-qa-commands.cs b/Source/Commands/akron-qa-commands.cs index e2c46383..d7aac74f 100644 --- a/Source/Commands/akron-qa-commands.cs +++ b/Source/Commands/akron-qa-commands.cs @@ -570,43 +570,47 @@ public static void QaStartPosLoadProbe(string slotText = "1", string flag = "akr } AkronActions.SetStartPosSlot(slot); - AkronActions.LoadStartPos(level); - Func 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) { @@ -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; @@ -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")] @@ -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 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(); diff --git a/Source/Commands/akron-setup-commands.cs b/Source/Commands/akron-setup-commands.cs index 1c6ca1ff..a6c37bb5 100644 --- a/Source/Commands/akron-setup-commands.cs +++ b/Source/Commands/akron-setup-commands.cs @@ -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; } @@ -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; } diff --git a/Source/Community/akron-community-pack-uploads.cs b/Source/Community/akron-community-pack-uploads.cs index 970a47dd..87fdda57 100644 --- a/Source/Community/akron-community-pack-uploads.cs +++ b/Source/Community/akron-community-pack-uploads.cs @@ -182,13 +182,14 @@ public static string WriteTempArchive(AkronSetupSection section, string title, s throw new InvalidOperationException("Only StartPos, Auto Kill, and Auto Deafen packs can be uploaded."); } + AkronSetupPack pack = BuildScopedUploadPack(AkronModule.Settings, AkronModule.Session, title, section, mapSid); + return WriteTempArchive(pack, mapSid, CancellationToken.None); + } + + private static string WriteTempArchive(AkronSetupPack pack, string mapSid, CancellationToken cancellationToken) { Directory.CreateDirectory(GetTempUploadDirectory()); - string fileName = SanitizeFileName(string.IsNullOrWhiteSpace(title) ? GenerateTitle("Akron", section) : title) - + "-" - + DateTime.UtcNow.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture) - + AkronArchive.Extension; + string fileName = "upload-" + Guid.NewGuid().ToString("N") + AkronArchive.Extension; string path = Path.Combine(GetTempUploadDirectory(), fileName); - AkronSetupPack pack = BuildScopedUploadPack(AkronModule.Settings, AkronModule.Session, title, section, mapSid); AkronSetupPacks.WriteArchive( path, pack, @@ -200,7 +201,7 @@ public static string WriteTempArchive(AkronSetupSection section, string title, s Game = "Celeste", MapSid = mapSid?.Trim() ?? string.Empty } - }); + }, cancellationToken); return path; } @@ -249,6 +250,7 @@ private static AkronSetupState BuildSectionOnlyUploadState(AkronModuleSettings s private static void CopyStartPosUploadState(AkronSetupState target, AkronSetupState source) { target.SmartStartPos = source.SmartStartPos; target.RespawnAtStartPos = source.RespawnAtStartPos; + target.StartPosWaitForInput = source.StartPosWaitForInput; target.StartPosShowLabel = source.StartPosShowLabel; target.StartPosLabelColor = source.StartPosLabelColor; target.StartPosLabelAnchor = source.StartPosLabelAnchor; @@ -884,6 +886,7 @@ private sealed class AkronCommunityPackUploadHost : Entity private readonly CancellationTokenSource uploadCancellation = new CancellationTokenSource(); private AkronCommunityPackUploadCaptureSettings captureSettings; private string packPath = string.Empty; + private Task archiveTask; private DateTime captureStartedUtc; private bool ownsCaptureScan; private bool cleanedUp; @@ -943,8 +946,9 @@ private IEnumerator Run() } try { - SetUploadStatus("Creating .akr pack...", 0.04f); - packPath = WriteTempArchive(draft.Section, draft.Title, draft.MapSid); + SetUploadStatus("Compressing .akr pack...", 0.04f); + AkronSetupPack pack = BuildScopedUploadPack(AkronModule.Settings, AkronModule.Session, draft.Title, draft.Section, draft.MapSid); + archiveTask = Task.Run(() => WriteTempArchive(pack, draft.MapSid, uploadCancellation.Token)); } catch (Exception exception) when (exception is IOException || exception is InvalidDataException || exception is UnauthorizedAccessException || exception is InvalidOperationException) { FailUpload("Could not create temp upload archive: " + exception.Message, exception, "Could not create the .akr file."); yield break; @@ -995,6 +999,17 @@ private IEnumerator Run() Engine.Scene?.Add(new AkronToast("Upload Pack attached the first " + captures.Count.ToString(CultureInfo.InvariantCulture) + " marked rooms.")); } + SetUploadStatus("Compressing .akr pack...", 0.74f); + while (!archiveTask.IsCompleted) { + yield return null; + } + if (!archiveTask.IsCompletedSuccessfully) { + FailUpload("Could not compress upload: " + archiveTask.Exception?.GetBaseException().Message, + archiveTask.Exception, "Could not compress the .akr file."); + yield break; + } + packPath = archiveTask.GetAwaiter().GetResult(); + Task uploadTask = null; try { SetUploadStatus("Preparing upload...", 0.76f); @@ -1079,6 +1094,18 @@ private void CleanupPack() } cleanedUp = true; + // Removal can happen while compression is still running. Observe its + // outcome and remove its unique temp file after the writer has finished. + if (archiveTask != null && string.IsNullOrWhiteSpace(packPath)) { + _ = archiveTask.ContinueWith(completed => { + _ = completed.Exception; + if (completed.IsCompletedSuccessfully) { + try { File.Delete(completed.Result); } + catch (IOException exception) { AkronLog.Warn(nameof(AkronCommunityPackUploads), exception.Message); } + catch (UnauthorizedAccessException exception) { AkronLog.Warn(nameof(AkronCommunityPackUploads), exception.Message); } + } + }, TaskScheduler.Default); + } try { if (!string.IsNullOrWhiteSpace(packPath) && File.Exists(packPath)) { File.Delete(packPath); diff --git a/Source/Community/akron-community-packs.cs b/Source/Community/akron-community-packs.cs index 9e4d1f32..c1468a86 100644 --- a/Source/Community/akron-community-packs.cs +++ b/Source/Community/akron-community-packs.cs @@ -426,6 +426,15 @@ private static AkronCommunityPackIndex LoadIndex(string indexUrl) { private static void DownloadPack(AkronCommunityPackEntry entry, string destinationPath) { ValidateCatalogEntry(entry); Uri uri = ResolveCatalogResourceUri(entry, entry.DownloadUrl, "Pack"); + // The catalog binds the exact size and digest. Recheck the local file so a + // changed or damaged cache never bypasses the normal download verification. + if (File.Exists(destinationPath)) { + using FileStream cached = File.OpenRead(destinationPath); + if (cached.Length == entry.SizeBytes && CryptographicOperations.FixedTimeEquals( + SHA256.HashData(cached), Convert.FromHexString(entry.Sha256))) { + return; + } + } if (uri.Scheme == Uri.UriSchemeFile) { using FileStream source = new FileStream(uri.LocalPath, FileMode.Open, FileAccess.Read, FileShare.Read); WriteVerifiedPack(source, destinationPath, entry); diff --git a/Source/Core/AkronDeepClone.cs b/Source/Core/AkronDeepClone.cs index 9a9715d7..dd3443cd 100644 --- a/Source/Core/AkronDeepClone.cs +++ b/Source/Core/AkronDeepClone.cs @@ -107,6 +107,9 @@ private static void EnsureSharedState() { } private static bool? ShouldUseOriginalObject(Type type) { + if (type == typeof(BlendState) || type == typeof(GraphicsResource)) { + return false; + } if (type.FullName == "Celeste.Celeste" || type == typeof(Settings) || type == typeof(Type) || @@ -119,6 +122,7 @@ private static void EnsureSharedState() { type == typeof(Monocle.Commands) || type == typeof(BitTag) || type == typeof(Atlas) || + AkronStartPosReconstruction.IsDynamicDataCache(type) || type.IsSubclassOf(typeof(GraphicsResource)) || typeof(MTexture).IsAssignableFrom(type) || string.Equals(type.Name, "ILHook", StringComparison.Ordinal) || @@ -137,6 +141,10 @@ private static object CloneSpecialRuntimeObject(object source, DeepCloneState st } lock (source) { + if (source.GetType() == typeof(BlendState)) { + return AkronBlendStateSnapshot.Clone((BlendState) source); + } + if (AkronStartPosReconstruction.IsLiveHookOwner(source)) { // The Set-frame hook-owner registry identifies this process // singleton. Keeping its exact target here gives reconstruction @@ -144,6 +152,15 @@ private static object CloneSpecialRuntimeObject(object source, DeepCloneState st // a cloned iterator later. return source; } + Type sourceType = source.GetType(); + if (AkronReconstructionGraph.IsNativeLuaStateType(sourceType)) { + // Copying a Lua wrapper duplicates its native ownership without + // rewinding the VM. Refuse before any handles are cloned. + throw new AkronReconstructionException( + "$", + AkronReconstructionGraph.NativeLuaSnapshotRefusal, + sourceType.AssemblyQualifiedName); + } if (source is VirtualAsset virtualAsset) { AkronVirtualAssetReloadTracker.Add(virtualAsset); @@ -248,6 +265,18 @@ private static object RepairClonedCollection(object source, object clone, DeepCl return clone; } + internal static bool HasCustomDynamicData(object source) { + if (DynamicDataMap.HasCustomValues(source)) { + return true; + } + foreach (DynamicDataMapAccessor map in GetGenericDynamicDataMaps(source.GetType())) { + if (map.HasCustomValues(source)) { + return true; + } + } + return false; + } + private static void CloneDynamicDataIfPresent(object source, object clone, DeepCloneState state) { if (ReferenceEquals(source, clone)) { return; @@ -298,14 +327,17 @@ private sealed class DynamicDataMapAccessor { value = null; return false; }, - (_, _) => { }); + (_, _) => { }, + Array.Empty()); private readonly TryGetValue tryGetValue; private readonly Action replace; + private readonly FieldInfo[] sidecarFields; - private DynamicDataMapAccessor(TryGetValue tryGetValue, Action replace) { + private DynamicDataMapAccessor(TryGetValue tryGetValue, Action replace, FieldInfo[] sidecarFields) { this.tryGetValue = tryGetValue; this.replace = replace; + this.sidecarFields = sidecarFields; } public static DynamicDataMapAccessor Create(Type sidecarType) { @@ -342,7 +374,27 @@ private static DynamicDataMapAccessor CreateTyped(object mapObject) wher (key, value) => { typedMap.Remove(key); typedMap.Add(key, (TValue) value); - }); + }, + typeof(TValue).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)); + } + + public bool HasCustomValues(object source) { + if (!tryGetValue(source, out object sidecar)) { + return false; + } + foreach (FieldInfo field in sidecarFields) { + object value = field.GetValue(sidecar); + bool populated = value switch { + null => false, + IDictionary dictionary => dictionary.Count != 0, + ICollection names => names.Count != 0, + _ => true + }; + if (populated) { + return true; + } + } + return false; } public void CloneEntry(object source, object clone, DeepCloneState state) { diff --git a/Source/Packs/akron-archive.cs b/Source/Packs/akron-archive.cs index c6400417..068080b9 100644 --- a/Source/Packs/akron-archive.cs +++ b/Source/Packs/akron-archive.cs @@ -34,7 +34,7 @@ public static class AkronArchive { private const int MaxMapSidLength = 256; private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { - WriteIndented = true, + WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; @@ -68,15 +68,19 @@ IReadOnlyDictionary attachmentPaths } Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "."); - if (File.Exists(path)) { - File.Delete(path); - } - - using ZipArchive archive = ZipFile.Open(path, ZipArchiveMode.Create); - WriteEntry(archive, ManifestEntryName, JsonSerializer.Serialize(manifest, JsonOptions)); - WriteEntry(archive, payloadEntryName, payloadJson ?? string.Empty); - foreach (KeyValuePair attachment in attachmentPaths.OrderBy(pair => pair.Key, StringComparer.Ordinal)) { - WriteFileEntry(archive, attachment.Key, attachment.Value); + // A long export must not expose a partial archive or destroy the last good one. + string temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + using (ZipArchive archive = ZipFile.Open(temporaryPath, ZipArchiveMode.Create)) { + WriteEntry(archive, ManifestEntryName, JsonSerializer.Serialize(manifest, JsonOptions)); + WriteEntry(archive, payloadEntryName, payloadJson ?? string.Empty); + foreach (KeyValuePair attachment in attachmentPaths.OrderBy(pair => pair.Key, StringComparer.Ordinal)) { + WriteFileEntry(archive, attachment.Key, attachment.Value); + } + } + File.Move(temporaryPath, path, overwrite: true); + } finally { + if (File.Exists(temporaryPath)) File.Delete(temporaryPath); } } @@ -325,13 +329,15 @@ private static void ValidateArchiveEntryName(string entryName) { } private static void WriteEntry(ZipArchive archive, string name, string content) { - ZipArchiveEntry entry = archive.CreateEntry(name, CompressionLevel.Optimal); + ZipArchiveEntry entry = archive.CreateEntry(name, CompressionLevel.SmallestSize); using Stream stream = entry.Open(); using StreamWriter writer = new StreamWriter(stream); writer.Write(content); } private static void WriteFileEntry(ZipArchive archive, string name, string path) { + // Snapshot attachments already use maximum Brotli compression. Preserve their + // exact bytes and checksums instead of compressing the same content twice. ZipArchiveEntry entry = archive.CreateEntry(name, CompressionLevel.NoCompression); using Stream destination = entry.Open(); using FileStream source = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); @@ -358,3 +364,6 @@ private static byte[] ReadEntryBytes(ZipArchiveEntry entry, int maxBytes) { return buffer.ToArray(); } } + +// Local saves favor quick writes. Portable packs favor size: transcode the exact +// JSON bytes without building a room graph or changing the local snapshot file. diff --git a/Source/Packs/akron-snapshot-bundle.cs b/Source/Packs/akron-snapshot-bundle.cs new file mode 100644 index 00000000..310f3b71 --- /dev/null +++ b/Source/Packs/akron-snapshot-bundle.cs @@ -0,0 +1,444 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Buffers.Text; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; + +namespace Celeste.Mod.Akron; + +// An opaque byte transport: graph serialization and local save files stay unchanged. +internal static class AkronSnapshotBundle { + internal const string EntryName = "startpos/snapshots.bin.br"; + internal const int BlockBytes = 65536; + private const int MinChunkBytes = 4096; + private const int MinPackedRunBytes = 128; + internal const int MaxDictionaryBytes = 16 * 1024 * 1024; + internal const int MaxDocumentBytes = 384 * 1024 * 1024; + internal const long MaxPackDocumentBytes = 1024L * 1024 * 1024; + private const int MaxDictionaryItems = 4096; + private const int MaxDiscoveryItems = 65536; + private const long MaxDiscoveryBytes = 128L * 1024 * 1024; + private const long MaxEncodedBytes = 509L * 1024 * 1024; + private static readonly byte[] Magic = Encoding.ASCII.GetBytes("AKRSB001"); + private static readonly uint[] Gear = CreateGear(); + + internal sealed record Source(int Slot, string Path, string Sha256); + private sealed record Document(Source Source, int Length, string Sha256); + private sealed class Chunk { + internal string Hash; + internal long Offset; + internal int Length; + internal int Count; + internal double Score; + } + + // Discovery is bounded independently of pack size. Candidate bytes live on disk, + // rather than retaining every raw snapshot or every unique chunk in memory. + internal static Dictionary Write(string destination, IReadOnlyList sources, + CancellationToken cancellationToken = default) { + if (sources.Count is < 1 or > 99 || sources.Any(source => source.Slot is < 1 or > 99) || + sources.Select(source => source.Slot).Distinct().Count() != sources.Count) + throw new InvalidDataException("Invalid snapshot bundle slots."); + string staging = destination + "." + Guid.NewGuid().ToString("N"); + Directory.CreateDirectory(staging); + try { + using var spool = new FileStream(Path.Combine(staging, "chunks"), FileMode.CreateNew, + FileAccess.ReadWrite, FileShare.None); + var chunks = new Dictionary(StringComparer.Ordinal); + var documents = new List(); + long documentBytes = 0; + foreach (Source source in sources.OrderBy(source => source.Slot)) { + using FileStream file = OpenSource(source); + using var gzip = new GZipStream(file, CompressionMode.Decompress); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var reader = new ChunkReader(gzip); + int length = 0; + while (reader.Read() is int count && count > 0) { + cancellationToken.ThrowIfCancellationRequested(); + length = checked(length + count); + if (length > MaxDocumentBytes) throw new InvalidDataException("Snapshot is too large."); + documentBytes = checked(documentBytes + count); + if (documentBytes > MaxPackDocumentBytes) throw new InvalidDataException("Snapshot bundle is too large."); + ReadOnlySpan bytes = reader.Bytes; + hash.AppendData(bytes); + string key = Convert.ToHexString(SHA256.HashData(bytes)); + if (chunks.TryGetValue(key, out Chunk chunk)) { + chunk.Count++; + } else if (chunks.Count < MaxDiscoveryItems && spool.Length + count <= MaxDiscoveryBytes) { + chunks.Add(key, new Chunk { Hash = key, Offset = spool.Length, Length = count, Count = 1 }); + spool.Position = spool.Length; + spool.Write(bytes); + } + } + if (length == 0) throw new InvalidDataException("Snapshot is empty."); + documents.Add(new Document(source, length, Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant())); + } + var buffer = new byte[BlockBytes]; + foreach (Chunk chunk in chunks.Values.Where(chunk => chunk.Count > 1)) { + cancellationToken.ThrowIfCancellationRequested(); + spool.Position = chunk.Offset; + spool.ReadExactly(buffer.AsSpan(0, chunk.Length)); + using var estimate = new MemoryStream(); + using (var deflate = new DeflateStream(estimate, CompressionLevel.Fastest, leaveOpen: true)) + deflate.Write(buffer, 0, chunk.Length); + if (estimate.Length > 128) + chunk.Score = (double)estimate.Length * (chunk.Count - 1) / chunk.Length; + } + var dictionary = new List(); + var indexes = new Dictionary(StringComparer.Ordinal); + int dictionaryBytes = 0; + foreach (Chunk chunk in chunks.Values.Where(chunk => chunk.Score > 0) + .OrderByDescending(chunk => chunk.Score).ThenBy(chunk => chunk.Hash, StringComparer.Ordinal)) { + if (dictionary.Count == MaxDictionaryItems) break; + if (dictionaryBytes + chunk.Length > MaxDictionaryBytes) continue; + var bytes = new byte[chunk.Length]; + spool.Position = chunk.Offset; + spool.ReadExactly(bytes); + indexes.Add(chunk.Hash, dictionary.Count); + dictionary.Add(bytes); + dictionaryBytes += bytes.Length; + } + string plainPath = Path.Combine(staging, "plain.br"); + WriteCandidate(plainPath, documents, Array.Empty(), new Dictionary(), cancellationToken); + string selectedPath = plainPath; + if (dictionary.Count > 0) { + string sharedPath = Path.Combine(staging, "shared.br"); + WriteCandidate(sharedPath, documents, dictionary, indexes, cancellationToken); + if (new FileInfo(sharedPath).Length < new FileInfo(plainPath).Length) selectedPath = sharedPath; + } + cancellationToken.ThrowIfCancellationRequested(); + File.Move(selectedPath, destination); + return documents.ToDictionary(document => document.Source.Slot, document => document.Sha256); + } finally { + Directory.Delete(staging, recursive: true); + } + } + + private static FileStream OpenSource(Source source) { + var file = new FileStream(source.Path, FileMode.Open, FileAccess.Read, FileShare.Read); + try { + string actual = Convert.ToHexString(SHA256.HashData(file)); + if (!actual.Equals(source.Sha256, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Snapshot changed since capture. Export it again."); + file.Position = 0; + return file; + } catch { + file.Dispose(); + throw; + } + } + + private static void WriteCandidate(string path, IReadOnlyList documents, + IReadOnlyList dictionary, Dictionary indexes, CancellationToken token) { + using var file = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); + using var brotli = new BrotliWriter(file); + using var frames = new FrameWriter(brotli); + using var writer = new BinaryWriter(frames, Encoding.UTF8, leaveOpen: true); + writer.Write(Magic); + writer.Write(dictionary.Count); + foreach (byte[] bytes in dictionary) { writer.Write(bytes.Length); writer.Write(bytes); } + writer.Write(documents.Count); + foreach (Document document in documents) { + token.ThrowIfCancellationRequested(); + writer.Write(document.Source.Slot); + writer.Write(document.Length); + using FileStream source = OpenSource(document.Source); + using var gzip = new GZipStream(source, CompressionMode.Decompress); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var reader = new ChunkReader(gzip); + int length = 0; + while (reader.Read() is int count && count > 0) { + token.ThrowIfCancellationRequested(); + length = checked(length + count); + if (length > document.Length) throw new InvalidDataException("Snapshot changed while exporting."); + hash.AppendData(reader.Bytes); + if (indexes.Count > 0 && indexes.TryGetValue(Convert.ToHexString(SHA256.HashData(reader.Bytes)), out int index)) { + writer.Write((byte)1); + writer.Write(index); + } else { + writer.Write((byte)0); + writer.Write(count); + writer.Write(reader.Bytes); + } + } + if (length != document.Length || !Convert.ToHexString(hash.GetHashAndReset()) + .Equals(document.Sha256, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Snapshot changed while exporting."); + } + frames.Complete(); + brotli.Complete(); + } + + // The callback must consume each document before returning. The caller can stage + // its normal local save, then commit only after all metadata and hashes agree. + internal static Dictionary Read(Stream source, Action readDocument) { + using var brotli = new BrotliReader(source); + using var frames = new FrameReader(brotli); + using var reader = new BinaryReader(frames, Encoding.UTF8, leaveOpen: true); + Span magic = stackalloc byte[8]; + frames.ReadExactly(magic); + if (!magic.SequenceEqual(Magic)) throw new InvalidDataException("Unsupported snapshot bundle."); + int count = ReadNumber(reader, 0, MaxDictionaryItems); + var dictionary = new byte[count][]; + int dictionaryBytes = 0; + for (int i = 0; i < count; i++) { + int length = ReadNumber(reader, 1, BlockBytes); + dictionaryBytes += length; + if (dictionaryBytes > MaxDictionaryBytes) throw new InvalidDataException("Snapshot dictionary is too large."); + dictionary[i] = new byte[length]; + frames.ReadExactly(dictionary[i]); + } + int documentCount = ReadNumber(reader, 1, 99); + var hashes = new Dictionary(); + int previousSlot = 0; + long documentBytes = 0; + for (int i = 0; i < documentCount; i++) { + int slot = ReadNumber(reader, previousSlot + 1, 99); + int length = ReadNumber(reader, 1, MaxDocumentBytes); + documentBytes = checked(documentBytes + length); + if (documentBytes > MaxPackDocumentBytes) throw new InvalidDataException("Snapshot bundle is too large."); + using var document = new DocumentReader(reader, dictionary, length); + readDocument(slot, document); + if (document.Remaining != 0) throw new InvalidDataException("Snapshot reader did not consume the document."); + hashes.Add(slot, document.FinishHash()); + previousSlot = slot; + } + if (frames.ReadByte() != -1) throw new InvalidDataException("Trailing snapshot bundle bytes."); + return hashes; + } + + private static int ReadNumber(BinaryReader reader, int minimum, int maximum) { + uint value = reader.ReadUInt32(); + if (value < minimum || value > maximum) throw new InvalidDataException("Invalid snapshot bundle length or index."); + return (int)value; + } + + private sealed class ChunkReader(Stream source) { + private readonly byte[] buffer = new byte[BlockBytes]; + private int available; + private int previous; + internal ReadOnlySpan Bytes => buffer.AsSpan(0, previous); + internal int Read() { + buffer.AsSpan(previous, available - previous).CopyTo(buffer); + available -= previous; + while (available < buffer.Length) { + int count = source.Read(buffer, available, buffer.Length - available); + if (count == 0) break; + available += count; + } + uint hash = 0; + previous = 0; + while (previous < available) { + hash = unchecked((hash << 1) + Gear[buffer[previous++]]); + if (previous >= MinChunkBytes && ((hash & 16383) == 0 || previous == BlockBytes)) break; + } + return previous; + } + } + + private static uint[] CreateGear() { + var gear = new uint[256]; + uint seed = 0x9e3779b9; + for (int i = 0; i < gear.Length; i++) { + seed ^= seed << 13; seed ^= seed >> 17; seed ^= seed << 5; + gear[i] = seed; + } + return gear; + } + + private abstract class ForwardStream : Stream { + public override bool CanSeek => false; + public override bool CanRead => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + public override int Read(Span buffer) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); + public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(); + } + + private sealed class BrotliWriter(Stream destination) : ForwardStream { + private BrotliEncoder encoder = new BrotliEncoder(11, 24); + private readonly byte[] buffer = new byte[BlockBytes]; + private long written; + public override bool CanWrite => true; + public override void Write(ReadOnlySpan bytes) => Encode(bytes, false); + internal void Complete() => Encode(ReadOnlySpan.Empty, true); + private void Encode(ReadOnlySpan bytes, bool final) { + OperationStatus status; + do { + status = encoder.Compress(bytes, buffer, out int consumed, out int produced, final); + if (status == OperationStatus.InvalidData) throw new InvalidDataException("Snapshot compression failed."); + written += produced; + if (written > MaxEncodedBytes) throw new InvalidDataException("Snapshot bundle is too large."); + destination.Write(buffer, 0, produced); + bytes = bytes[consumed..]; + } while (status == OperationStatus.DestinationTooSmall || (final && status != OperationStatus.Done)); + } + protected override void Dispose(bool disposing) { if (disposing) encoder.Dispose(); base.Dispose(disposing); } + } + + private sealed class BrotliReader(Stream source) : ForwardStream { + private BrotliDecoder decoder = new BrotliDecoder(); + private readonly byte[] input = new byte[BlockBytes]; + private int offset; + private int available; + private bool ended; + public override bool CanRead => true; + public override int Read(Span bytes) { + if (bytes.IsEmpty || ended) return 0; + while (true) { + if (offset == available) { available = source.Read(input); offset = 0; } + OperationStatus status = decoder.Decompress(input.AsSpan(offset, available - offset), bytes, + out int consumed, out int produced); + offset += consumed; + if (status == OperationStatus.InvalidData) throw new InvalidDataException("Invalid Brotli snapshot bundle."); + if (status == OperationStatus.Done) { + ended = true; + if (offset != available || source.ReadByte() != -1) + throw new InvalidDataException("Trailing Brotli snapshot bytes."); + } else if (status == OperationStatus.NeedMoreData && available == 0) { + throw new EndOfStreamException("Truncated Brotli snapshot bundle."); + } + if (produced > 0 || ended) return produced; + } + } + protected override void Dispose(bool disposing) { if (disposing) decoder.Dispose(); base.Dispose(disposing); } + } + + // Buffer runs across Write boundaries. A full run is divisible by four, so its + // binary frame needs no padding and can be expanded without losing any bytes. + private sealed class FrameWriter(Stream destination) : ForwardStream { + private readonly byte[] literal = new byte[BlockBytes]; + private readonly byte[] run = new byte[BlockBytes]; + private readonly byte[] binary = new byte[BlockBytes * 3 / 4]; + private int literalCount; + private int runCount; + public override bool CanWrite => true; + public override void Write(ReadOnlySpan bytes) { + foreach (byte value in bytes) { + if (value is >= (byte)'A' and <= (byte)'Z' or >= (byte)'a' and <= (byte)'z' or + >= (byte)'0' and <= (byte)'9' or (byte)'+' or (byte)'/') { + run[runCount++] = value; + if (runCount == run.Length) FlushRun(); + } else { FlushRun(); Literal(value); } + } + } + private void Literal(byte value) { + literal[literalCount++] = value; + if (literalCount == literal.Length) FlushLiteral(); + } + private void FlushRun() { + int packed = runCount >= MinPackedRunBytes ? runCount / 4 * 4 : 0; + if (packed > 0) { + FlushLiteral(); + Base64.DecodeFromUtf8(run.AsSpan(0, packed), binary, out _, out int produced); + Frame(1, binary.AsSpan(0, produced)); + } + for (int i = packed; i < runCount; i++) Literal(run[i]); + runCount = 0; + } + private void FlushLiteral() { + if (literalCount > 0) Frame(0, literal.AsSpan(0, literalCount)); + literalCount = 0; + } + private void Frame(byte tag, ReadOnlySpan bytes) { + Span header = stackalloc byte[5]; + header[0] = tag; + BinaryPrimitives.WriteUInt32LittleEndian(header[1..], (uint)bytes.Length); + destination.Write(header); + destination.Write(bytes); + } + internal void Complete() { FlushRun(); FlushLiteral(); } + } + + private sealed class FrameReader(Stream source) : ForwardStream { + private readonly byte[] buffer = new byte[BlockBytes]; + private readonly byte[] binary = new byte[BlockBytes * 3 / 4]; + private int offset; + private int available; + private int frameWorkBudget = BlockBytes; + public override bool CanRead => true; + public override int Read(Span bytes) { + if (bytes.IsEmpty) return 0; + if (offset == available) { + int tag = source.ReadByte(); + if (tag == -1) return 0; + Span header = stackalloc byte[4]; + source.ReadExactly(header); + uint length = BinaryPrimitives.ReadUInt32LittleEndian(header); + if (length == 0 || length > BlockBytes || tag is not (0 or 1)) + throw new InvalidDataException("Invalid snapshot frame."); + if (tag == 1 && (length > binary.Length || length % 3 != 0)) + throw new InvalidDataException("Invalid base64 frame."); + int decodedLength = (int)(tag == 1 ? length / 3 * 4 : length); + // A packed run pays for its preceding short literal frame. Cap credit + // so earlier large frames cannot fund an unbounded run of tiny frames. + frameWorkBudget = Math.Min(BlockBytes, frameWorkBudget + decodedLength - MinPackedRunBytes / 2); + if (frameWorkBudget < 0) + throw new InvalidDataException("Snapshot bundle uses too many small frames. Export the pack again."); + if (tag == 1) { + source.ReadExactly(binary.AsSpan(0, (int)length)); + Base64.EncodeToUtf8(binary.AsSpan(0, (int)length), buffer, out _, out available); + } else { + available = (int)length; + source.ReadExactly(buffer.AsSpan(0, available)); + } + offset = 0; + } + int count = Math.Min(bytes.Length, available - offset); + buffer.AsSpan(offset, count).CopyTo(bytes); + offset += count; + return count; + } + } + + private sealed class DocumentReader(BinaryReader reader, byte[][] dictionary, int length) : ForwardStream { + private readonly IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + private byte[] reference; + private int commandRemaining; + private int referenceOffset; + // Writer chunks reach MinChunkBytes except at EOF; allow 1,024 extra + // commands for small independently assembled documents. + private readonly int maxCommands = 1024 + (length + MinChunkBytes - 1) / MinChunkBytes; + private int commandsRead; + internal int Remaining { get; private set; } = length; + public override bool CanRead => true; + public override int Read(Span bytes) { + if (bytes.IsEmpty || Remaining == 0) return 0; + if (commandRemaining == 0) { + if (++commandsRead > maxCommands) + throw new InvalidDataException("Snapshot bundle uses too many data commands. Export the pack again."); + int tag = reader.ReadByte(); + if (tag == 0) { + commandRemaining = ReadNumber(reader, 1, BlockBytes); + reference = null; + } else if (tag == 1 && dictionary.Length > 0) { + reference = dictionary[ReadNumber(reader, 0, dictionary.Length - 1)]; + referenceOffset = 0; + commandRemaining = reference.Length; + } else throw new InvalidDataException("Invalid snapshot command."); + if (commandRemaining > Remaining) throw new InvalidDataException("Snapshot command exceeds document length."); + } + int count = Math.Min(bytes.Length, commandRemaining); + if (reference == null) reader.BaseStream.ReadExactly(bytes[..count]); + else { reference.AsSpan(referenceOffset, count).CopyTo(bytes); referenceOffset += count; } + hash.AppendData(bytes[..count]); + commandRemaining -= count; + Remaining -= count; + return count; + } + internal string FinishHash() => Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + protected override void Dispose(bool disposing) { if (disposing) hash.Dispose(); base.Dispose(disposing); } + } +} diff --git a/Source/SaveLoad/AkronSaveLoad.cs b/Source/SaveLoad/AkronSaveLoad.cs index 3fb6e6b0..5f8c2f6c 100644 --- a/Source/SaveLoad/AkronSaveLoad.cs +++ b/Source/SaveLoad/AkronSaveLoad.cs @@ -687,7 +687,13 @@ public static AkronSaveLoadSlot CaptureRuntimeState( bool capturePersistentResources = true, bool prepareForRestore = true ) { - if (level == null || !CanAccessNativeState(level, out _)) { + LastPersistentSnapshotError = string.Empty; + if (level == null) { + LastPersistentSnapshotError = "no level is active"; + return null; + } + if (!CanAccessNativeState(level, out string accessError)) { + LastPersistentSnapshotError = accessError; return null; } @@ -761,6 +767,10 @@ public static AkronSaveLoadSlot CaptureRuntimeState( AkronVirtualAssetReloadTracker.GetRegistrationsSince(virtualAssetMarker); retainsTrackedVirtualAssets = true; return saveSlot; + } catch (AkronReconstructionException exception) { + LastPersistentSnapshotError = exception.Message; + ReleaseDormantEventInstances(saveSlot); + return null; } catch { ReleaseDormantEventInstances(saveSlot); throw; @@ -1779,6 +1789,7 @@ private static bool ApplyPersistentRuntimeState(Level level, AkronPersistentRunt Glitch.Value = state.GlitchValue; Distort.Anxiety = state.DistortAnxiety; Distort.GameRate = state.DistortGameRate; + AkronPersistentRuntimeState.RestoreDustStyle(DustStyles.Styles, level.Session.Area.ID, state.DustStyle); foreach (EverestModule module in Everest.Modules.Where(module => module is not AkronModule && module.GetType().Name != "NullModule")) { @@ -2186,6 +2197,10 @@ private static AkronSaveLoadSlot BuildNativeSlot(Level level, string slotName, b saveSlot.GlitchValue = Glitch.Value; saveSlot.DistortAnxiety = Distort.Anxiety; saveSlot.DistortGameRate = Distort.GameRate; + // Capture in the Level's clone context: controllers can own the same + // EdgeColors array as the active-area registry entry. + saveSlot.DustStyle = (DustStyles.DustStyle?) DeepClone( + AkronPersistentRuntimeState.CaptureDustStyle(DustStyles.Styles, level.Session.Area.ID)); foreach (EverestModule module in Everest.Modules.Where(module => module.GetType().Name != "NullModule")) { if (module._Session != null) { @@ -2225,6 +2240,8 @@ private static AkronSaveLoadSlot BuildPersistentBaselineSlot(Level level, string saveSlot.GlitchValue = Glitch.Value; saveSlot.DistortAnxiety = Distort.Anxiety; saveSlot.DistortGameRate = Distort.GameRate; + saveSlot.DustStyle = (DustStyles.DustStyle?) DeepClone( + AkronPersistentRuntimeState.CaptureDustStyle(DustStyles.Styles, level.Session.Area.ID)); foreach (EverestModule module in Everest.Modules.Where(module => module.GetType().Name != "NullModule")) { if (module._Session != null) { saveSlot.ModuleSessions[module.GetType().FullName ?? module.GetType().Name] = @@ -2340,6 +2357,10 @@ private static bool RestoreNativeSlot( level.Entities.UpdateLists(); } } + AkronPersistentRuntimeState.RestoreDustStyle( + DustStyles.Styles, + level.Session.Area.ID, + (DustStyles.DustStyle?) DeepClone(saveSlot.DustStyle)); Player player = level.Tracker.GetEntity(); if (player != null && savedLevel == null) { diff --git a/Source/SaveLoad/akron-native-savestate-support.cs b/Source/SaveLoad/akron-native-savestate-support.cs index 7a74d029..ee240e04 100644 --- a/Source/SaveLoad/akron-native-savestate-support.cs +++ b/Source/SaveLoad/akron-native-savestate-support.cs @@ -255,9 +255,8 @@ private static void RegisterCoreRuntimeSupport() { ["PauseGameplaySfx"] = Audio.PauseGameplaySfx }; - // DustStyles is static visual support for vanilla DustEdges. Restoring - // a cloned copy can leave DustEdges with invalid style references on - // the next render; keep the live table instead of snapshotting it. + // Active-area DustStyles state is captured alongside the Level, + // not here: its arrays can alias controller fields in the room graph. }, loadState: (savedValues, level) => { AkronSaveLoadService.LoadStaticMembers(savedValues, typeof(Engine), diff --git a/Source/SaveLoad/akron-reconstruction-graph.cs b/Source/SaveLoad/akron-reconstruction-graph.cs index 3a87018b..baba39c0 100644 --- a/Source/SaveLoad/akron-reconstruction-graph.cs +++ b/Source/SaveLoad/akron-reconstruction-graph.cs @@ -73,7 +73,11 @@ internal sealed class AkronReconstructionDocument { // for those nodes rather than to a wrong restore. The key half has no such // limit: it is read off the saved object and applies to every node in both // documents. - public const string CurrentFormat = "akron-reconstruction-v10"; + // v10 -> v11: captures the active area's DustStyles registry value with the + // 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"; public string Format { get; set; } = CurrentFormat; // Every distinct type name in this document, in first-use order, shared by the @@ -400,10 +404,149 @@ internal sealed class AkronGameplayBufferSnapshot { internal interface IAkronReconstructionResourceAdapter { bool CanPersist(Type type); AkronReconstructionResourcePayload Capture(object resource); - object Restore(AkronReconstructionResourcePayload payload, object freshResource); + object Restore(Type resourceType, AkronReconstructionResourcePayload payload, object freshResource); bool Verify(AkronReconstructionResourcePayload payload, object resource); } +// GPU objects remain live resources. BlendState is the exception: its complete +// rendering state is a small managed descriptor, independent of a device. +internal sealed class AkronRoomResourceAdapter : IAkronReconstructionResourceAdapter { + private readonly AkronVirtualRenderTargetResourceAdapter renderTargets = new AkronVirtualRenderTargetResourceAdapter(); + + public bool CanPersist(Type type) => type == typeof(BlendState) || renderTargets.CanPersist(type); + + public AkronReconstructionResourcePayload Capture(object resource) => + resource.GetType() == typeof(BlendState) + ? AkronBlendStateSnapshot.Capture((BlendState)resource) + : renderTargets.Capture(resource); + + public object Restore(Type resourceType, AkronReconstructionResourcePayload payload, object freshResource) => + resourceType == typeof(BlendState) + ? AkronBlendStateSnapshot.Restore(payload) + : renderTargets.Restore(resourceType, payload, freshResource); + + public bool Verify(AkronReconstructionResourcePayload payload, object resource) => + resource?.GetType() == typeof(BlendState) + ? AkronBlendStateSnapshot.Verify(payload, (BlendState)resource) + : renderTargets.Verify(payload, resource); +} + +internal static class AkronBlendStateSnapshot { + internal const string PayloadKind = "blend-state-v1"; + private const int DescriptorWords = 12; + private const int DescriptorBytes = DescriptorWords * sizeof(int); + + internal static BlendState Clone(BlendState source) { + ValidateSource(source); + // Even FNA's static readonly instances have mutable properties. Copy + // at Set, not later on the persistence worker, and never copy their + // GraphicsResource handles, device, or disposal callbacks. + return new BlendState { + Name = source.Name, + AlphaBlendFunction = source.AlphaBlendFunction, + AlphaDestinationBlend = source.AlphaDestinationBlend, + AlphaSourceBlend = source.AlphaSourceBlend, + ColorBlendFunction = source.ColorBlendFunction, + ColorDestinationBlend = source.ColorDestinationBlend, + ColorSourceBlend = source.ColorSourceBlend, + ColorWriteChannels = source.ColorWriteChannels, + ColorWriteChannels1 = source.ColorWriteChannels1, + ColorWriteChannels2 = source.ColorWriteChannels2, + ColorWriteChannels3 = source.ColorWriteChannels3, + BlendFactor = source.BlendFactor, + MultiSampleMask = source.MultiSampleMask + }; + } + + internal static AkronReconstructionResourcePayload Capture(BlendState source) { + ValidateSource(source); + byte[] bytes = new byte[DescriptorBytes]; + WriteDescriptor(source, bytes); + return new AkronReconstructionResourcePayload { + Kind = PayloadKind, + Name = source.Name ?? string.Empty, + Bytes = bytes + }; + } + + internal static BlendState Restore(AkronReconstructionResourcePayload payload) { + Span values = stackalloc int[DescriptorWords]; + ReadDescriptor(payload, values); + // Always allocate a new wrapper. Mutating a fresh or globally shared + // instance would affect other owners and can bypass FNA's binding cache. + return new BlendState { + Name = payload.Name, + AlphaBlendFunction = (BlendFunction)values[0], + AlphaDestinationBlend = (Blend)values[1], + AlphaSourceBlend = (Blend)values[2], + ColorBlendFunction = (BlendFunction)values[3], + ColorDestinationBlend = (Blend)values[4], + ColorSourceBlend = (Blend)values[5], + ColorWriteChannels = (ColorWriteChannels)values[6], + ColorWriteChannels1 = (ColorWriteChannels)values[7], + ColorWriteChannels2 = (ColorWriteChannels)values[8], + ColorWriteChannels3 = (ColorWriteChannels)values[9], + BlendFactor = new Color { PackedValue = unchecked((uint)values[10]) }, + MultiSampleMask = values[11] + }; + } + + internal static bool Verify(AkronReconstructionResourcePayload payload, BlendState resource) { + if (resource.IsDisposed || resource.Tag != null || AkronDeepClone.HasCustomDynamicData(resource) || + payload?.Kind != PayloadKind || + payload.Bytes?.Length != DescriptorBytes || + !string.Equals(payload.Name, resource.Name ?? string.Empty, StringComparison.Ordinal)) { + return false; + } + Span current = stackalloc byte[DescriptorBytes]; + WriteDescriptor(resource, current); + return current.SequenceEqual(payload.Bytes); + } + + private static void ValidateSource(BlendState source) { + if (source.GetType() != typeof(BlendState) || source.IsDisposed || source.Tag != null || + AkronDeepClone.HasCustomDynamicData(source)) { + throw new AkronReconstructionException( + "$", + "Custom or disposed blend state cannot be saved safely. Capture in another room.", + source.GetType().AssemblyQualifiedName); + } + } + + private static void WriteDescriptor(BlendState source, Span bytes) { + Span values = stackalloc int[] { + (int) source.AlphaBlendFunction, (int) source.AlphaDestinationBlend, (int) source.AlphaSourceBlend, + (int) source.ColorBlendFunction, (int) source.ColorDestinationBlend, (int) source.ColorSourceBlend, + (int) source.ColorWriteChannels, (int) source.ColorWriteChannels1, + (int) source.ColorWriteChannels2, (int) source.ColorWriteChannels3, + unchecked((int) source.BlendFactor.PackedValue), source.MultiSampleMask + }; + for (int i = 0; i < values.Length; i++) { + BinaryPrimitives.WriteInt32LittleEndian(bytes.Slice(i * sizeof(int), sizeof(int)), values[i]); + } + } + + private static void ReadDescriptor(AkronReconstructionResourcePayload payload, Span values) { + if (payload?.Kind != PayloadKind || payload.Bytes?.Length != DescriptorBytes || + payload.Width != 0 || payload.Height != 0 || payload.MultiSampleCount != 0 || payload.Depth || payload.Preserve) { + throw new InvalidOperationException("BlendState descriptor is invalid."); + } + for (int i = 0; i < values.Length; i++) { + values[i] = BinaryPrimitives.ReadInt32LittleEndian(payload.Bytes.AsSpan(i * sizeof(int), sizeof(int))); + } + if (!Enum.IsDefined((BlendFunction)values[0]) || !Enum.IsDefined((BlendFunction)values[3]) || + !Enum.IsDefined((Blend)values[1]) || !Enum.IsDefined((Blend)values[2]) || + !Enum.IsDefined((Blend)values[4]) || !Enum.IsDefined((Blend)values[5])) { + throw new InvalidOperationException("BlendState descriptor has an unknown blend operation."); + } + for (int i = 6; i <= 9; i++) { + if ((values[i] & ~(int)ColorWriteChannels.All) != 0) { + throw new InvalidOperationException("BlendState descriptor has unknown color-write channels."); + } + } + } +} + // VirtualRenderTarget is process-owned, but some room effects create targets // only after they have run. Persist those room-owned pixels because a normal // fresh-room load cannot provide an equivalent object to rebind. @@ -417,7 +560,7 @@ public bool CanPersist(Type type) { } public AkronReconstructionResourcePayload Capture(object resource) { - VirtualRenderTarget renderTarget = (VirtualRenderTarget) resource; + VirtualRenderTarget renderTarget = (VirtualRenderTarget)resource; IReadOnlyDictionary captured = CapturedPayloads.Value; if (captured != null) { if (!captured.TryGetValue(renderTarget, out AkronReconstructionResourcePayload payload)) { @@ -491,7 +634,10 @@ public void Dispose() { } } - public object Restore(AkronReconstructionResourcePayload payload, object freshResource) { + public object Restore(Type resourceType, AkronReconstructionResourcePayload payload, object freshResource) { + if (!CanPersist(resourceType)) { + throw new InvalidOperationException("Unexpected render-target resource type."); + } ValidatePayload(payload); VirtualRenderTarget renderTarget = freshResource as VirtualRenderTarget; bool created = false; @@ -635,7 +781,7 @@ public static void ArmLevelPresentation(Level level, IReadOnlyList maxStringChars) { throw new InvalidOperationException( $"Reconstruction JSON string length exceeds the supported limit of {maxStringChars:N0} characters."); @@ -1063,7 +1209,7 @@ private void RecordBase64Bytes(string encoded) { if (encoded.Length > 1 && encoded[encoded.Length - 2] == '=') { padding++; } - RecordBinaryBytes(checked((long) (encoded.Length / 4) * 3L - padding)); + RecordBinaryBytes(checked((long)(encoded.Length / 4) * 3L - padding)); } private void RecordBinaryBytes(long count) { @@ -1099,7 +1245,7 @@ AkronReconstructionTags.DelegateCalls or recordArrayKindsByDepth[Depth] = RecordArrayKind.None; } } else if (TokenType == JsonToken.StartObject && Depth > 0 && - Depth - 1 < recordArrayKindsByDepth.Length) { + Depth - 1 < recordArrayKindsByDepth.Length) { kind = recordArrayKindsByDepth[Depth - 1]; } @@ -1213,20 +1359,32 @@ internal sealed class AkronReconstructionGraph { private static readonly ConcurrentDictionary<(string DeclaringTypeName, string FieldName), FieldInfo> ResolvedFields = new ConcurrentDictionary<(string DeclaringTypeName, string FieldName), FieldInfo>(); private static readonly ConcurrentDictionary InstanceFields = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary SafeManagedReconstructionTypes = + new ConcurrentDictionary(); private static readonly ConcurrentDictionary InertBuiltInEntityMarkerTypes = new ConcurrentDictionary(); private static readonly ConcurrentDictionary PassiveDataObjectTypes = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary NativeLuaStateTypes = + new ConcurrentDictionary(); + internal const string NativeLuaSnapshotRefusal = + "Lua state cannot be saved safely. Capture in another room."; private const BindingFlags RuntimeInstanceFields = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; private static readonly FieldInfo EntitySourceIdField = typeof(Entity).GetField("k__BackingField", RuntimeInstanceFields); + private static readonly FieldInfo EntitySceneField = + typeof(Entity).GetField("k__BackingField", RuntimeInstanceFields); private static readonly FieldInfo EntityComponentsField = typeof(Entity).GetField("k__BackingField", RuntimeInstanceFields); private static readonly FieldInfo ComponentEntityField = typeof(Component).GetField("k__BackingField", RuntimeInstanceFields); private static readonly FieldInfo SceneEntitiesField = typeof(Scene).GetField("k__BackingField", RuntimeInstanceFields); + private static readonly FieldInfo SceneRendererListField = + typeof(Scene).GetField("k__BackingField", RuntimeInstanceFields); + private static readonly FieldInfo RendererListSceneField = + typeof(RendererList).GetField("scene", RuntimeInstanceFields); private static readonly FieldInfo EntityListEntitiesField = typeof(EntityList).GetField("entities", RuntimeInstanceFields); private static readonly FieldInfo ComponentListComponentsField = @@ -1438,6 +1596,7 @@ public AkronReconstructionCapture Capture(object savedRoot, object freshBaseline CaptureContext context = new CaptureContext(this, freshBaselineRoot); try { AkronReconstructionValue root = context.CaptureValue(savedRoot, freshBaselineRoot, "$"); + context.CompleteCapture(); if (root.Kind != ReferenceValueKind) { return AkronReconstructionCapture.Failed("$", "root must be a reference node"); } @@ -1702,7 +1861,7 @@ private static void ValidateTypeNameIndex(string name, int index, List t return; } } else if (index >= 0 && index < table.Count && - string.Equals(table[index], name, StringComparison.Ordinal)) { + string.Equals(table[index], name, StringComparison.Ordinal)) { return; } throw new InvalidOperationException("Reconstruction type name index differs from its name."); @@ -1937,7 +2096,7 @@ int depth depth + 1); } IReadOnlyList items = - node.ItemsOrNull ?? (IReadOnlyList) Array.Empty(); + node.ItemsOrNull ?? (IReadOnlyList)Array.Empty(); for (int index = 0; index < items.Count; index++) { bool weakTarget = string.Equals(node.Kind, WeakReferenceKind, StringComparison.Ordinal) && index == 0; VisitReference( @@ -1952,7 +2111,7 @@ int depth } IReadOnlyList calls = node.DelegateCallsOrNull ?? - (IReadOnlyList) Array.Empty(); + (IReadOnlyList)Array.Empty(); for (int index = 0; index < calls.Count; index++) { VisitReference( calls[index]?.Target, @@ -2536,17 +2695,17 @@ private static void ValidateNodeParentEdges( (parent.Id, node.ParentDeclaringTypeName, node.ParentFieldName), out parentValue); } else if (node.ParentKind == "array" && - TryGetFlatArrayIndex(parent, node.ParentArrayIndicesOrNull, out int itemIndex) && - parent.ItemsOrNull != null && itemIndex < parent.ItemsOrNull.Count) { + TryGetFlatArrayIndex(parent, node.ParentArrayIndicesOrNull, out int itemIndex) && + parent.ItemsOrNull != null && itemIndex < parent.ItemsOrNull.Count) { parentValue = parent.ItemsOrNull[itemIndex]; } else if (node.ParentKind == "delegate" && - node.ParentDelegateIndex >= 0 && - parent.DelegateCallsOrNull != null && - node.ParentDelegateIndex < parent.DelegateCallsOrNull.Count) { + node.ParentDelegateIndex >= 0 && + parent.DelegateCallsOrNull != null && + node.ParentDelegateIndex < parent.DelegateCallsOrNull.Count) { parentValue = parent.DelegateCallsOrNull[node.ParentDelegateIndex]?.Target; } else if (node.ParentKind == "weak-target" && - string.Equals(parent.Kind, WeakReferenceKind, StringComparison.Ordinal) && - parent.ItemsOrNull is { Count: > 0 }) { + string.Equals(parent.Kind, WeakReferenceKind, StringComparison.Ordinal) && + parent.ItemsOrNull is { Count: > 0 }) { parentValue = parent.ItemsOrNull[0]; } if (parentValue?.Kind != ReferenceValueKind || parentValue.NodeId != node.Id) { @@ -2574,7 +2733,7 @@ out int flatIndex for (int dimension = 0; dimension < indices.Count; dimension++) { int length = arrayNode.ArrayLengthsOrNull[dimension]; int lowerBound = arrayNode.ArrayLowerBoundsOrNull[dimension]; - long relativeIndex = (long) indices[dimension] - lowerBound; + long relativeIndex = (long)indices[dimension] - lowerBound; if (length < 0 || relativeIndex < 0 || relativeIndex >= length) { return false; } @@ -2583,7 +2742,7 @@ out int flatIndex return false; } } - flatIndex = (int) offset; + flatIndex = (int)offset; return true; } @@ -2837,7 +2996,7 @@ IReadOnlyList indices parentPathLength > MaxDiagnosticPathChars - suffixLength) { throw new InvalidOperationException("Reconstruction diagnostic path exceeds the supported limit."); } - return parentPathLength + (int) suffixLength; + return parentPathLength + (int)suffixLength; } private static string BuildArrayDiagnosticPath( @@ -2898,7 +3057,7 @@ private static string BuildDelegateDiagnosticPath(string parentPath, int delegat } private static int Int32FormattedLength(int value) { - uint magnitude = value < 0 ? (uint) -(long) value : (uint) value; + uint magnitude = value < 0 ? (uint)-(long)value : (uint)value; int length = value < 0 ? 2 : 1; while (magnitude >= 10) { magnitude /= 10; @@ -2918,6 +3077,23 @@ private static IEnumerable GetInstanceFields(Type type) { return InstanceFields.GetOrAdd(type, BuildInstanceFields); } + private static bool IsSafeManagedReconstructionType(Type type) { + return SafeManagedReconstructionTypes.GetOrAdd(type, candidate => { + if (candidate.IsAbstract || typeof(IDisposable).IsAssignableFrom(candidate) || + GetInstanceFields(candidate).Any(field => IsProcessPointerType(field.FieldType))) { + return false; + } + for (Type current = candidate; current != null && current != typeof(object); + current = current.BaseType) { + if (current.GetMethod( + "Finalize", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) != null) { + return false; + } + } + return true; + }); + } + internal static bool IsTransientRuntimeField(Type ownerType, FieldInfo field) { // Player.temp is scratch storage for collision queries. Player.OnGround and // UpdateSprite can fill it while a placed StartPos pose is derived, but the @@ -3083,6 +3259,25 @@ private static bool IsPersistablePrimitive(Type type) { type != typeof(UIntPtr); } + private static bool IsProcessPointerType(Type type) { + // Reflection reboxes Pointer._ptr on every read; it is a native leaf, + // not a traversable object even when its address is zero. + return type == typeof(IntPtr) || type == typeof(UIntPtr) || + type == typeof(Pointer) || type.IsPointer || type.IsByRefLike; + } + + internal static bool IsNativeLuaStateType(Type type) { + return NativeLuaStateTypes.GetOrAdd(type, static candidate => { + for (Type current = candidate; current != null; current = current.BaseType) { + if (current == typeof(LuaCoroutine) || + current.FullName is "NLua.Lua" or "NLua.LuaBase" or "KeraLua.Lua") { + return true; + } + } + return false; + }); + } + private static bool IsScalarType(Type type) { return type.IsEnum || IsPersistablePrimitive(type) || @@ -3105,66 +3300,66 @@ private static bool IsScalarType(Type type) { private static string EncodeScalar(object value, Type type, string path) { if (type == typeof(string)) { - return (string) value; + return (string)value; } if (type == typeof(bool)) { - return (bool) value ? "true" : "false"; + return (bool)value ? "true" : "false"; } if (type == typeof(char)) { - return ((int) (char) value).ToString(CultureInfo.InvariantCulture); + return ((int)(char)value).ToString(CultureInfo.InvariantCulture); } if (type == typeof(float)) { - return ((float) value).ToString("R", CultureInfo.InvariantCulture); + return ((float)value).ToString("R", CultureInfo.InvariantCulture); } if (type == typeof(double)) { - return ((double) value).ToString("R", CultureInfo.InvariantCulture); + return ((double)value).ToString("R", CultureInfo.InvariantCulture); } if (type == typeof(decimal)) { - return ((decimal) value).ToString(CultureInfo.InvariantCulture); + return ((decimal)value).ToString(CultureInfo.InvariantCulture); } if (type == typeof(DateTime)) { - DateTime dateTime = (DateTime) value; - return dateTime.Ticks.ToString(CultureInfo.InvariantCulture) + ":" + ((int) dateTime.Kind).ToString(CultureInfo.InvariantCulture); + DateTime dateTime = (DateTime)value; + return dateTime.Ticks.ToString(CultureInfo.InvariantCulture) + ":" + ((int)dateTime.Kind).ToString(CultureInfo.InvariantCulture); } if (type == typeof(DateTimeOffset)) { - DateTimeOffset valueWithOffset = (DateTimeOffset) value; + DateTimeOffset valueWithOffset = (DateTimeOffset)value; return valueWithOffset.Ticks.ToString(CultureInfo.InvariantCulture) + ":" + valueWithOffset.Offset.Ticks.ToString(CultureInfo.InvariantCulture); } if (type == typeof(TimeSpan)) { - return ((TimeSpan) value).Ticks.ToString(CultureInfo.InvariantCulture); + return ((TimeSpan)value).Ticks.ToString(CultureInfo.InvariantCulture); } if (type == typeof(Guid)) { - return ((Guid) value).ToString("N"); + return ((Guid)value).ToString("N"); } if (type == typeof(Point)) { - Point point = (Point) value; + Point point = (Point)value; return JoinScalar(point.X, point.Y); } if (type == typeof(Rectangle)) { - Rectangle rectangle = (Rectangle) value; + Rectangle rectangle = (Rectangle)value; return JoinScalar(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height); } if (type == typeof(Color)) { - return GetPackedColor((Color) value).ToString("x8", CultureInfo.InvariantCulture); + return GetPackedColor((Color)value).ToString("x8", CultureInfo.InvariantCulture); } if (type == typeof(Vector2)) { - Vector2 vector = (Vector2) value; + Vector2 vector = (Vector2)value; return JoinScalar(EncodeFloat(vector.X), EncodeFloat(vector.Y)); } if (type == typeof(Vector3)) { - Vector3 vector = (Vector3) value; + Vector3 vector = (Vector3)value; return JoinScalar(EncodeFloat(vector.X), EncodeFloat(vector.Y), EncodeFloat(vector.Z)); } if (type == typeof(Vector4)) { - Vector4 vector = (Vector4) value; + Vector4 vector = (Vector4)value; return JoinScalar(EncodeFloat(vector.X), EncodeFloat(vector.Y), EncodeFloat(vector.Z), EncodeFloat(vector.W)); } if (type == typeof(Quaternion)) { - Quaternion quaternion = (Quaternion) value; + Quaternion quaternion = (Quaternion)value; return JoinScalar(EncodeFloat(quaternion.X), EncodeFloat(quaternion.Y), EncodeFloat(quaternion.Z), EncodeFloat(quaternion.W)); } if (type == typeof(Matrix)) { - Matrix matrix = (Matrix) value; + Matrix matrix = (Matrix)value; return JoinScalar( EncodeFloat(matrix.M11), EncodeFloat(matrix.M12), EncodeFloat(matrix.M13), EncodeFloat(matrix.M14), EncodeFloat(matrix.M21), EncodeFloat(matrix.M22), EncodeFloat(matrix.M23), EncodeFloat(matrix.M24), @@ -3172,7 +3367,7 @@ private static string EncodeScalar(object value, Type type, string path) { EncodeFloat(matrix.M41), EncodeFloat(matrix.M42), EncodeFloat(matrix.M43), EncodeFloat(matrix.M44)); } if (type == typeof(VertexPositionColor)) { - VertexPositionColor vertex = (VertexPositionColor) value; + VertexPositionColor vertex = (VertexPositionColor)value; return JoinScalar( EncodeFloat(vertex.Position.X), EncodeFloat(vertex.Position.Y), @@ -3200,7 +3395,7 @@ private static object DecodeScalar(AkronReconstructionValue value, string path) return string.Equals(scalar, "true", StringComparison.Ordinal); } if (type == typeof(char)) { - return (char) int.Parse(scalar, CultureInfo.InvariantCulture); + return (char)int.Parse(scalar, CultureInfo.InvariantCulture); } if (type == typeof(float)) { return float.Parse(scalar, NumberStyles.Float, CultureInfo.InvariantCulture); @@ -3213,7 +3408,7 @@ private static object DecodeScalar(AkronReconstructionValue value, string path) } if (type == typeof(DateTime)) { string[] parts = scalar.Split(':'); - return new DateTime(long.Parse(parts[0], CultureInfo.InvariantCulture), (DateTimeKind) int.Parse(parts[1], CultureInfo.InvariantCulture)); + return new DateTime(long.Parse(parts[0], CultureInfo.InvariantCulture), (DateTimeKind)int.Parse(parts[1], CultureInfo.InvariantCulture)); } if (type == typeof(DateTimeOffset)) { string[] parts = scalar.Split(':'); @@ -3401,7 +3596,7 @@ private static string EncodeFloat(float value) { } private static float DecodeFloat(string value) { - return BitConverter.Int32BitsToSingle(unchecked((int) uint.Parse( + return BitConverter.Int32BitsToSingle(unchecked((int)uint.Parse( value, NumberStyles.HexNumber, CultureInfo.InvariantCulture))); @@ -3496,6 +3691,10 @@ private static EntityID GetEntitySourceId(Entity entity) { return EntitySourceIdField?.GetValue(entity) is EntityID sourceId ? sourceId : default; } + private static Scene GetEntityScene(Entity entity) { + return EntitySceneField?.GetValue(entity) as Scene; + } + private static ComponentList GetEntityComponents(Entity entity) { return EntityComponentsField?.GetValue(entity) as ComponentList; } @@ -3524,8 +3723,25 @@ internal static bool IsDerivedCollectionVersionField(Type ownerType, string fiel typeof(IEnumerable).IsAssignableFrom(ownerType); } + // Each frame yields after scheduling one child, preserving depth-first + // identity and alias order without consuming the worker's native stack. + private static void DrainTraversal(Stack> frames) { + try { + while (frames.Count > 0) { + if (!frames.Peek().MoveNext()) { + frames.Pop().Dispose(); + } + } + } finally { + while (frames.Count > 0) { + frames.Pop().Dispose(); + } + } + } + private sealed class CaptureContext { private readonly AkronReconstructionGraph owner; + private readonly Stack> traversalFrames = new Stack>(); private readonly Dictionary savedNodeIds = new Dictionary(ReferenceEqualityComparer.Instance); private readonly Dictionary pairedFreshObjects = new Dictionary(ReferenceEqualityComparer.Instance); private readonly Dictionary> freshResources = new Dictionary>(StringComparer.Ordinal); @@ -3549,9 +3765,14 @@ public CaptureContext(AkronReconstructionGraph owner, object freshRoot) { public AkronReconstructionDocument Document { get; } = new AkronReconstructionDocument(); + public void CompleteCapture() { + DrainTraversal(traversalFrames); + } + private void IndexFreshResources(object freshRoot) { HashSet visited = new HashSet(ReferenceEqualityComparer.Instance); IndexFreshValue(freshRoot, new List(), visited); + DrainTraversal(traversalFrames); } private void IndexFreshValue( @@ -3564,13 +3785,16 @@ HashSet visited } Type type = value.GetType(); - if (IsScalarType(type) || type == typeof(IntPtr) || type == typeof(UIntPtr) || - type.IsPointer || type.IsByRefLike || value is Delegate) { + if (IsScalarType(type) || IsProcessPointerType(type)) { return; } if (!type.IsValueType && !visited.Add(value)) { return; } + bool liveAnchor = owner.isLiveResource(type) || owner.isAdditionalLiveResource?.Invoke(value) == true; + if (value is Delegate && !liveAnchor) { + return; + } if (value is Entity || value is Component) { if (!freshRoomObjects.TryGetValue(type, out HashSet roomObjects)) { roomObjects = new HashSet(); @@ -3578,7 +3802,7 @@ HashSet visited } roomObjects.Add(GetFreshCandidate(value, path)); } - if (owner.isLiveResource(type)) { + if (liveAnchor) { string key = ResourceKey(value); if (!string.IsNullOrWhiteSpace(key)) { if (!freshResources.TryGetValue(key, out HashSet matches)) { @@ -3589,6 +3813,13 @@ HashSet visited } return; } + if (IsNativeLuaStateType(type)) { + return; + } + // Scalar grids cannot contain resources or room objects to index. + if (type.IsArray && IsScalarType(type.GetElementType())) { + return; + } // This index pass runs before a single document node exists and walks // the whole fresh room, so without its own stop point the worker would @@ -3596,6 +3827,15 @@ HashSet visited // and never look at it. Once per object about to have its children // walked is the same granularity the capture walk uses. AkronSnapshotPacing.Pace(); + traversalFrames.Push(IndexFreshChildren(value, type, path, visited).GetEnumerator()); + } + + private IEnumerable IndexFreshChildren( + object value, + Type type, + List path, + HashSet visited + ) { // One path list is pushed and popped across the whole walk instead of // copying the ancestor chain at every step, which was quadratic in @@ -3608,9 +3848,10 @@ HashSet visited ArrayIndices = indices.ToList() }); IndexFreshValue(array.GetValue(indices), path, visited); + yield return true; path.RemoveAt(path.Count - 1); } - return; + yield break; } foreach (FieldInfo field in GetInstanceFields(type)) { @@ -3623,6 +3864,7 @@ HashSet visited FieldName = field.Name }); IndexFreshValue(field.GetValue(value), path, visited); + yield return true; path.RemoveAt(path.Count - 1); } } @@ -3673,7 +3915,7 @@ private FreshResource FindFreshRoomObject(object savedValue) { EntityID savedSourceId = GetEntitySourceId(savedEntity); List sourceMatches = matches .Where(candidate => candidate.Value is Entity freshEntity && - GetEntitySourceId(freshEntity).Equals(savedSourceId)) + EntityIdsMatch(GetEntitySourceId(freshEntity), savedSourceId)) .ToList(); return sourceMatches.Count == 1 ? sourceMatches[0] : null; } @@ -3688,7 +3930,7 @@ private void RemoveFreshCandidate(object value) { if (freshRoomObjects.TryGetValue(type, out HashSet roomObjects)) { roomObjects.Remove(candidate); } - if (owner.isLiveResource(type)) { + if (owner.isLiveResource(type) || owner.isAdditionalLiveResource?.Invoke(value) == true) { string key = ResourceKey(value); if (freshResources.TryGetValue(key, out HashSet resources)) { resources.Remove(candidate); @@ -3737,7 +3979,7 @@ public AkronReconstructionValue CaptureValue( Scalar = EncodeScalar(savedValue, savedType, path) }; } - if (savedType == typeof(IntPtr) || savedType == typeof(UIntPtr) || savedType.IsPointer || savedType.IsByRefLike) { + if (IsProcessPointerType(savedType)) { // The graph path is field names only, so it cannot say which type // holds the pointer, and that name is what identifies the mod a // refused pointer came from. Carry both: a refusal nobody can act @@ -3764,7 +4006,7 @@ public AkronReconstructionValue CaptureValue( bool entityIdentityMatches = savedValue is not Entity savedEntity || !HasStableSourceId(GetEntitySourceId(savedEntity)) || freshValue is Entity freshEntity && - GetEntitySourceId(freshEntity).Equals(GetEntitySourceId(savedEntity)); + EntityIdsMatch(GetEntitySourceId(freshEntity), GetEntitySourceId(savedEntity)); if ((!freshTypeMatches || !entityIdentityMatches) && (savedValue is Entity || savedValue is Component)) { FreshResource matchedRoomObject = FindFreshRoomObject(savedValue); @@ -3782,6 +4024,9 @@ freshValue is Entity freshEntity && bool liveAnchor = !persistentEventInstance && !persistentResource && (owner.isLiveResource(savedType) || additionalLiveAnchor); + if (!liveAnchor && !persistentResource && IsNativeLuaStateType(savedType)) { + throw new AkronReconstructionException(path, NativeLuaSnapshotRefusal, TypeName(savedType)); + } string savedLiveResourceKey = string.Empty; if (liveAnchor || persistentResource) { string savedResourceKey = ResourceKey(savedValue); @@ -3900,19 +4145,19 @@ freshValue is Entity freshEntity && eventPath = AkronEventInstanceUtils.GetEventPath(freshEventInstance); } node.EventInstance = AkronEventInstanceUtils.CapturePersistentState( - (EventInstance) savedValue, + (EventInstance)savedValue, eventPath); if (node.EventInstance == null) { throw new AkronReconstructionException(path, "FMOD event has no stable event path"); } } else if (weakReference) { - CaptureWeakReference(node, savedValue, freshTypeMatches ? freshValue : null, path); + traversalFrames.Push(CaptureWeakReference(node, savedValue, freshTypeMatches ? freshValue : null, path).GetEnumerator()); } else if (savedValue is Delegate savedDelegate) { - CaptureDelegate(node, savedDelegate, freshValue as Delegate, path, containingType); + traversalFrames.Push(CaptureDelegate(node, savedDelegate, freshValue as Delegate, path, containingType).GetEnumerator()); } else if (savedValue is Array savedArray) { - CaptureArray(node, savedArray, freshValue as Array, path); + traversalFrames.Push(CaptureArray(node, savedArray, freshValue as Array, path).GetEnumerator()); } else { - CaptureObject(node, savedValue, useFreshObject ? freshValue : null, path); + traversalFrames.Push(CaptureObject(node, savedValue, useFreshObject ? freshValue : null, path).GetEnumerator()); } return new AkronReconstructionValue { Kind = ReferenceValueKind, NodeId = nodeId }; @@ -3925,7 +4170,7 @@ freshValue is Entity freshEntity && // as its parent, which no fresh-slot authentication matches - the // restore then rebuilds it as a plain reconstructed object, and refuses // loudly if that object's type needs room authentication. - private void CaptureWeakReference( + private IEnumerable CaptureWeakReference( AkronReconstructionNode node, object savedWeakReference, object freshWeakReference, @@ -3941,6 +4186,7 @@ string path containingType: savedWeakReference.GetType(), parentNode: node, parentKind: "weak-target"); + yield return true; // Weak-reference nodes are rebuilt in one ascending-id pass after every // other node, so a target that is itself a weak reference must have been // captured before this one to exist when this one is created. Capture @@ -3965,7 +4211,7 @@ string path }; } - private void CaptureObject( + private IEnumerable CaptureObject( AkronReconstructionNode node, object savedObject, object freshObject, @@ -3995,10 +4241,11 @@ string path parentDeclaringTypeName: declaringTypeName, parentFieldName: field.Name) }); + yield return true; } } - private void CaptureArray( + private IEnumerable CaptureArray( AkronReconstructionNode node, Array savedArray, Array freshArray, @@ -4029,7 +4276,7 @@ string path node.PackedPrimitiveArrayBytes, 0, node.PackedPrimitiveArrayBytes.Length); - return; + yield break; } foreach (int[] indices in EnumerateArrayIndices(savedArray)) { string childPath = ArrayPath(path, indices); @@ -4041,10 +4288,11 @@ string path parentNode: node, parentKind: "array", parentArrayIndices: indices)); + yield return true; } } - private void CaptureDelegate( + private IEnumerable CaptureDelegate( AkronReconstructionNode node, Delegate savedDelegate, Delegate freshDelegate, @@ -4069,7 +4317,7 @@ Type containingType HookTargetReturnTypeName = TypeName(hookTarget.ReturnType), HookTargetParameterTypeNames = GetParameterTypeNames(hookTarget) }); - return; + yield break; } bool canUseFreshRuntimeDelegate = savedCalls.All(call => call.Method.DeclaringType == null && call.Target == null) && @@ -4095,7 +4343,7 @@ Type containingType // process-only function pointer. node.Kind = AnchorKind; node.UseFreshObject = true; - return; + yield break; } for (int index = 0; index < savedCalls.Length; index++) { @@ -4121,6 +4369,7 @@ Type containingType ReturnTypeName = TypeName(method.ReturnType), ParameterTypeNames = GetParameterTypeNames(method) }); + yield return true; } } @@ -4246,6 +4495,7 @@ private static List ClonePath(IEnumerable> traversalFrames = new Stack>(); private readonly AkronReconstructionDocument document; private readonly object freshRoot; private readonly Dictionary nodes; @@ -4258,6 +4508,7 @@ private sealed class RestoreContext { private readonly Dictionary> freshResourcesByStructuralPath = new Dictionary>(StringComparer.Ordinal); private readonly Dictionary detachedLiveResources = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary freshAliasParents = new Dictionary(); private readonly Dictionary entityListOwnerIds = new Dictionary(); private readonly HashSet indexedEntityListTypeOrdinals = new HashSet(); private readonly Dictionary entityListTypeOrdinals = @@ -4269,6 +4520,8 @@ private sealed class RestoreContext { new Dictionary(); private readonly Dictionary>> freshRendererTypesByRendererList = new Dictionary>>(); + private readonly Dictionary<(Type Type, string Room, int Id), List> freshSourceEntities = + new Dictionary<(Type Type, string Room, int Id), List>(); private readonly Dictionary freshFieldAliasReservations = new Dictionary(ReferenceEqualityComparer.Instance); private readonly Dictionary freshFieldAliasesByNode = new Dictionary(); @@ -4303,6 +4556,8 @@ private sealed class RestoreContext { private readonly HashSet authenticatedRuntimeStateNodes = new HashSet(); private readonly HashSet authenticatedRuntimeEntityNodes = new HashSet(); + private readonly Dictionary runtimeCollectionOrigins = + new Dictionary(); private readonly HashSet authenticatedOwnedNestedEntityNodes = new HashSet(); private readonly HashSet authenticatedOwnedNestedStateNodes = new HashSet(); private readonly HashSet authenticatedOwnedComponentNodes = new HashSet(); @@ -4375,6 +4630,7 @@ public void ResolveObjects() { freshRoot, new List(), new HashSet(ReferenceEqualityComparer.Instance)); + DrainTraversal(traversalFrames); ReserveFreshFieldAliases(); foreach (AkronReconstructionNode node in document.Nodes.OrderBy(node => node.Id)) { @@ -4391,7 +4647,7 @@ public void ResolveObjects() { if (freshResource != null && freshResource.GetType() != type) { freshResource = null; } - restoredObject = owner.resourceAdapter?.Restore(node.ResourcePayload, freshResource); + restoredObject = owner.resourceAdapter?.Restore(type, node.ResourcePayload, freshResource); if (restoredObject != null && !ReferenceEquals(restoredObject, freshResource)) { owner.ownedPersistentResources.Add(restoredObject); createdPersistentResources.Add(restoredObject); @@ -4449,8 +4705,7 @@ public void ResolveObjects() { // node so the saved reference identity wins. restoredObject = null; } - if (restoredObject is Entity restoredEntity && - !SavedEntitySourceMatches(node, restoredEntity)) { + if (restoredObject != null && !SavedFreshIdentityMatches(node, restoredObject)) { restoredObject = null; } if ((restoredObject == null || restoredObject.GetType() != type) && @@ -4500,7 +4755,7 @@ public void ResolveObjects() { restoredObject = lateSceneRenderer; resolvedFreshObjectNodes.Add(node.Id); } else if (node.Kind == DelegateKind || node.Kind == EventInstanceKind || - node.Kind == WeakReferenceKind) { + node.Kind == WeakReferenceKind) { continue; } else if (node.Kind == ArrayKind) { restoredObject = CreateAuthenticatedObject(node, type); @@ -4522,6 +4777,16 @@ public void ResolveObjects() { } VerifyDeferredIteratorStates(); + foreach (var origin in runtimeCollectionOrigins.Values) { + if (!resolvedFreshObjectNodes.Contains(origin.OwnerId) || + !Objects.TryGetValue(origin.OwnerId, out object restoredOwner) || + !ReferenceEquals(origin.FreshOwner, restoredOwner)) { + AkronReconstructionNode ownerNode = nodes[origin.OwnerId]; + throw new AkronReconstructionException( + ownerNode.Path, "runtime entity collection owner did not resolve to its proved fresh object", + ownerNode.TypeName); + } + } ValidateReferenceAuthenticity(); foreach (AkronReconstructionNode node in document.Nodes.Where(node => node.Kind == DelegateKind).OrderBy(node => node.Id)) { Objects[node.Id] = CreateDelegate(node); @@ -4589,53 +4854,10 @@ HashSet visited } Type type = value.GetType(); - if (IsScalarType(type) || type == typeof(IntPtr) || type == typeof(UIntPtr) || - type.IsPointer || type.IsByRefLike) { + if (IsScalarType(type) || IsProcessPointerType(type)) { return; } - if (value is Delegate freshDelegate) { - // A structural call key states that the fresh room runs this - // callback at this path - as loosely as StructuralDelegateCallKey - // reads a path, which already wildcards every array index - so it - // has to be recorded at every path the delegate is reachable - // from. Recording it at one path only makes the index disagree - // with the room depending on walk order. Ordinary objects below - // already work the other way: they record their structural type - // on every visit and only skip walking their fields again. A - // delegate used to skip the whole visit instead, so a callback - // object a room holds in two slots - which is what a cached - // non-capturing lambda or one shared handler is - left the - // second slot with no record, and a saved document whose own - // path was that second slot was refused for a callback the - // fresh room does have there. Object identity still ends the - // walk: the target below is indexed once. - bool firstDelegateVisit = visited.Add(value); - foreach (Delegate call in freshDelegate.GetInvocationList()) { - if (call.Target != null) { - freshStructuralDelegateCalls.Add( - StructuralDelegateCallKey(path, call.Target.GetType(), call.Method)); - } - if (!firstDelegateVisit) { - continue; - } - string methodKey = DelegateMethodKey(call.Method); - if (call.Target == null) { - freshStaticDelegateMethods.Add(methodKey); - continue; - } - if (!freshInstanceDelegateMethods.TryGetValue(call.Target, out HashSet methods)) { - methods = new HashSet(StringComparer.Ordinal); - freshInstanceDelegateMethods[call.Target] = methods; - } - methods.Add(methodKey); - // Capture serializes delegate targets at the owning - // delegate path. Follow the same path here so nested - // callbacks inside closure state can be authenticated. - IndexFreshResources(call.Target, path, visited); - } - return; - } - if (owner.isLiveResource(type)) { + if (owner.isLiveResource(type) || owner.isAdditionalLiveResource?.Invoke(value) == true) { if (!visited.Add(value)) { return; } @@ -4658,6 +4880,31 @@ HashSet visited structuralMatches.Add(value); return; } + if (IsNativeLuaStateType(type)) { + return; + } + if (value is Delegate freshDelegate) { + // A structural call key states that the fresh room runs this + // callback at this path - as loosely as StructuralDelegateCallKey + // reads a path, which already wildcards every array index - so it + // has to be recorded at every path the delegate is reachable + // from. Recording it at one path only makes the index disagree + // with the room depending on walk order. Ordinary objects below + // already work the other way: they record their structural type + // on every visit and only skip walking their fields again. A + // delegate used to skip the whole visit instead, so a callback + // object a room holds in two slots - which is what a cached + // non-capturing lambda or one shared handler is - left the + // second slot with no record, and a saved document whose own + // path was that second slot was refused for a callback the + // fresh room does have there. Object identity still ends the + // walk: the target below is indexed once. + traversalFrames.Push(IndexFreshDelegateCalls(freshDelegate, path, visited).GetEnumerator()); + return; + } + if (type.IsArray && IsScalarType(type.GetElementType())) { + return; + } // Only gameplay objects need structural authenticity. Arrays, // value types, and collection wrappers already have explicit safe @@ -4668,6 +4915,16 @@ HashSet visited if (!type.IsValueType) { firstVisit = visited.Add(value); } + if (firstVisit && value is Entity sourceEntity && + HasStableSourceId(GetEntitySourceId(sourceEntity))) { + EntityID sourceId = GetEntitySourceId(sourceEntity); + var key = (type, sourceId.Level, sourceId.ID); + if (!freshSourceEntities.TryGetValue(key, out List sourceEntities)) { + sourceEntities = new List(); + freshSourceEntities.Add(key, sourceEntities); + } + sourceEntities.Add(sourceEntity); + } if (!explicitlySafe) { freshStructuralTypes.Add(StructuralResourcePathKey(type, path)); if (HasListStorageIndex(path)) { @@ -4699,6 +4956,16 @@ HashSet visited IndexFreshStateSlots(freshStateMachine); } + traversalFrames.Push(IndexFreshChildren(value, type, path, visited, trackActiveSafeObject).GetEnumerator()); + } + + private IEnumerable IndexFreshChildren( + object value, + Type type, + List path, + HashSet visited, + bool trackActiveSafeObject + ) { try { if (value is Array array) { int[] indices = GetInitialArrayIndices(array); @@ -4710,11 +4977,12 @@ HashSet visited ArrayIndices = indices.ToList() }); IndexFreshResources(item, path, visited); + yield return true; path.RemoveAt(path.Count - 1); } IncrementArrayIndices(array, indices); } - return; + yield break; } foreach (FieldInfo field in GetInstanceFields(type)) { @@ -4731,6 +4999,7 @@ HashSet visited FieldName = field.Name }); IndexFreshResources(fieldValue, path, visited); + yield return true; path.RemoveAt(path.Count - 1); } } finally { @@ -4740,6 +5009,37 @@ HashSet visited } } + private IEnumerable IndexFreshDelegateCalls( + Delegate freshDelegate, + List path, + HashSet visited + ) { + bool firstDelegateVisit = visited.Add(freshDelegate); + foreach (Delegate call in freshDelegate.GetInvocationList()) { + if (call.Target != null) { + freshStructuralDelegateCalls.Add( + StructuralDelegateCallKey(path, call.Target.GetType(), call.Method)); + } + if (!firstDelegateVisit) { + continue; + } + string methodKey = DelegateMethodKey(call.Method); + if (call.Target == null) { + freshStaticDelegateMethods.Add(methodKey); + continue; + } + if (!freshInstanceDelegateMethods.TryGetValue(call.Target, out HashSet methods)) { + methods = new HashSet(); + freshInstanceDelegateMethods[call.Target] = methods; + } + methods.Add(methodKey); + // Delegate targets keep their owning path, including callbacks + // nested inside closure state. + IndexFreshResources(call.Target, path, visited); + yield return true; + } + } + // One entry per state slot the fresh machine has, holding what that slot // is called and what it runs. Read here rather than off the machine // later, because names and the callback arrays are both fields the @@ -4840,8 +5140,7 @@ private bool ShouldIndexFreshEdge(object value, HashSet visited) { return false; } Type type = value.GetType(); - if (IsScalarType(type) || type == typeof(IntPtr) || type == typeof(UIntPtr) || - type.IsPointer || type.IsByRefLike) { + if (IsScalarType(type) || IsProcessPointerType(type)) { return false; } if (type.IsValueType || !visited.Contains(value)) { @@ -4852,7 +5151,7 @@ private bool ShouldIndexFreshEdge(object value, HashSet visited) { // key here. Neither walks its fields again. A live resource is // identified by its own key rather than by a path, so revisiting // one would add nothing. - return value is Delegate || !owner.isLiveResource(type); + return !owner.isLiveResource(type) && owner.isAdditionalLiveResource?.Invoke(value) != true; } private object CreateAuthenticatedObject(AkronReconstructionNode node, Type type) { @@ -4913,11 +5212,9 @@ private object CreateAuthenticatedObject(AkronReconstructionNode node, Type type bool authenticatedOwnedNestedEntity = IsAuthenticatedFreshEntityOwnedNestedEntity(node, type); bool authenticatedOwnedNestedState = - IsAuthenticatedFreshEntityOwnedNestedState(node, type) || - IsAuthenticatedFreshRendererOwnedRuntimeState(node, type) || + IsAuthenticatedOwnedManagedState(node, type) || IsAuthenticatedRuntimeEntityOwnedState(node, type) || - IsAuthenticatedGeneratedEntityOwnedState(node, type) || - IsAuthenticatedRuntimeEntitySourceMetadataState(node, type); + IsAuthenticatedGeneratedEntityOwnedState(node, type); bool authenticatedOwnedComponent = IsAuthenticatedReconstructedOwnedComponent(node, type) || IsAuthenticatedIteratorClosureOwnedComponent(node, type); @@ -5224,6 +5521,19 @@ private void VerifyDeferredIteratorStates() { // which loads on the membership alone. authenticatedRuntimeStateNodes.Remove(node.Id); } + // A surviving iterator has the same owner proof as a rebuilt one. + // Its clean-load stack position is not its identity: Flattened can + // put the same frame in a different yielded/stack slot after updates. + foreach (int nodeId in resolvedFreshObjectNodes) { + AkronReconstructionNode node = nodes[nodeId]; + Type type = ResolveType(node.TypeName, node.Path); + if (IsCompilerGeneratedIterator(type) && + Objects.TryGetValue(nodeId, out object iterator) && + SavedFreshIdentityMatches(node, iterator) && + IsAuthenticatedCompilerIteratorState(node, type)) { + authenticatedRuntimeStateNodes.Add(nodeId); + } + } } private bool IsAuthenticatedIteratorClosure(AkronReconstructionNode node, Type type) { @@ -5441,6 +5751,68 @@ private static bool IsCompilerGeneratedIterator(Type type) { type.DeclaringType != null; } + private bool IsAuthenticatedIteratorSceneAlias( + AkronReconstructionNode target, + Type targetType, + AkronReconstructionNode iterator, + AkronReconstructionField edgeField + ) { + if (edgeField == null || !typeof(Scene).IsAssignableFrom(targetType) || + !TryGetAuthenticatedIteratorSceneOwner(target, iterator, out int entityId) || + !TryGetCoroutineEnumeratorStackOwner( + iterator, CoroutineStackWalk.IncludingYieldedValues, out int coroutineId)) { + return false; + } + FieldInfo field = ResolveField(edgeField.DeclaringTypeName, edgeField.Name, edgeField.Path); + return typeof(Scene).IsAssignableFrom(field.FieldType) && field.FieldType.IsAssignableFrom(targetType) && + IsCoroutineOwnedByEntity(coroutineId, entityId); + } + + private bool TryGetAuthenticatedIteratorSceneOwner( + AkronReconstructionNode scene, + AkronReconstructionNode iterator, + out int ownerEntityId + ) { + ownerEntityId = 0; + if (!resolvedFreshObjectNodes.Contains(scene.Id) || + !authenticatedRuntimeStateNodes.Contains(iterator.Id) || + deferredProvisionalIteratorIds.Contains(iterator.Id) || + !IsAuthenticatedCompilerIteratorState(iterator, ResolveType(iterator.TypeName, iterator.Path))) { + return false; + } + AkronReconstructionValue capturedOwner = FindReferenceField(iterator, "<>4__this"); + if (capturedOwner == null || !nodes.TryGetValue(capturedOwner.NodeId, out AkronReconstructionNode entity)) { + return false; + } + if (typeof(Component).IsAssignableFrom(ResolveType(entity.TypeName, entity.Path))) { + if (!TryGetComponentOwnerNodes(entity, out AkronReconstructionNode list, out int entityId) || + !IsSavedComponentListMember(entity, list) || !nodes.TryGetValue(entityId, out entity)) { + return false; + } + } + if (!typeof(Entity).IsAssignableFrom(ResolveType(entity.TypeName, entity.Path)) || + FindReferenceField(entity, "k__BackingField")?.NodeId != scene.Id || + !HasAuthenticatedEntityListSceneOwnership(entity, out _)) { + return false; + } + ownerEntityId = entity.Id; + return true; + } + + private bool IsCoroutineOwnedByEntity(int coroutineId, int entityId) { + AkronReconstructionNode current = nodes[coroutineId]; + for (int depth = 0; depth < MaxParentChainDepth; depth++) { + if (TryGetComponentOwnerNodes(current, out _, out int coroutineOwnerId)) { + return coroutineOwnerId == entityId; + } + if (!nodes.TryGetValue(current.ParentNodeId, out current) || + typeof(Entity).IsAssignableFrom(ResolveType(current.TypeName, current.Path))) { + return false; + } + } + return false; + } + private bool IsAuthenticatedIteratorOwnedComponentAlias( AkronReconstructionNode target, AkronReconstructionNode edgeParent @@ -5495,8 +5867,8 @@ parentObject is Array freshArray && } Type elementType = arrayType.GetElementType(); object freshItem = freshArray.GetValue(node.ParentArrayIndicesOrNull.ToArray()); - return (elementType == type && freshItem != null) || - (elementType.IsAssignableFrom(type) && freshItem?.GetType() == type); + return (elementType == type && freshItem != null) || + (elementType.IsAssignableFrom(type) && freshItem?.GetType() == type); } if (node.ParentKind == "field" && Objects.TryGetValue(node.ParentNodeId, out object fieldParent)) { @@ -5533,19 +5905,26 @@ out object matchedObject matchedObject = field.GetValue(parentObject); } } else if (node.ParentKind == "array" && - parentObject is Array array && - array.GetType().GetElementType().IsAssignableFrom(type) && - HasArrayIndex(array, node.ParentArrayIndicesOrNull)) { + parentObject is Array array && + array.GetType().GetElementType().IsAssignableFrom(type) && + HasArrayIndex(array, node.ParentArrayIndicesOrNull)) { matchedObject = array.GetValue(node.ParentArrayIndicesOrNull.ToArray()); } if (matchedObject == null || matchedObject.GetType() != type || freshOwners.ContainsKey(matchedObject) || + !SavedFreshIdentityMatches(node, matchedObject) || node.Kind == ArrayKind && (matchedObject is not Array matchedArray || !ArrayShapeMatches(matchedArray, node))) { matchedObject = null; return false; } + if (freshFieldAliasesByNode.TryGetValue(node.Id, out object reservedAlias) && + !ReferenceEquals(reservedAlias, matchedObject) && + IsReservedSceneFieldAlias(node, reservedAlias)) { + matchedObject = null; + return false; + } return true; } @@ -5733,20 +6112,6 @@ target.Kind is AnchorKind or PersistentResourceKind or DelegateKind or EventInst targetType, edgeParent, edgeField); - bool runtimeEntitySourceDataListAlias = - IsAuthenticatedRuntimeEntitySourceDataListAlias( - target, - targetType, - edgeParent, - edgeParentType, - edgeField); - bool runtimeEntitySourceMetadataLevelAlias = - IsAuthenticatedRuntimeEntitySourceMetadataLevelAlias( - target, - targetType, - edgeParent, - edgeParentType, - edgeField); bool freshOwnedNestedState = authenticatedOwnedNestedStateNodes.Contains(target.Id) && target.ParentNodeId == edgeParent.Id && @@ -5779,6 +6144,17 @@ target.Kind is AnchorKind or PersistentResourceKind or DelegateKind or EventInst (authenticatedFieldBuiltComponentNodes.Contains(target.Id) || IsAuthenticatedLazilyBuiltFieldComponent(target, targetType)); bool freshFieldAlias = IsAuthenticatedFreshFieldAlias(target, edgeParent, edgeField); + // A count of same-typed objects cannot authorize borrowing another + // scene's resource or replace missing component ownership. + if (!freshFieldAlias && edgeField != null && + typeof(Component).IsAssignableFrom(edgeParentType) && + Objects.TryGetValue(target.Id, out object sceneResource) && + IsReservedSceneFieldAlias(target, sceneResource)) { + throw new AkronReconstructionException( + edgeField.Path, + "component resource alias is not owned by its scene;type=" + targetType.FullName, + target.TypeName); + } bool freshOwnerAliasMerge = IsAuthenticatedFreshOwnerAliasMerge( target, targetType, @@ -5810,6 +6186,29 @@ target.Kind is AnchorKind or PersistentResourceKind or DelegateKind or EventInst bool freshHashSetMembership = IsAuthenticatedFreshHashSetMembership(target, edgeParent); bool iteratorOwnedComponentAlias = IsAuthenticatedIteratorOwnedComponentAlias(target, edgeParent); + bool iteratorSceneAlias = + IsAuthenticatedIteratorSceneAlias(target, targetType, edgeParent, edgeField); + if (!iteratorSceneAlias && typeof(Scene).IsAssignableFrom(targetType) && + authenticatedRuntimeStateNodes.Contains(edgeParent.Id) && + IsCompilerGeneratedIterator(edgeParentType) && + FindReferenceField(edgeParent, "<>4__this") is AkronReconstructionValue sceneIteratorOwner && + nodes.TryGetValue(sceneIteratorOwner.NodeId, out AkronReconstructionNode sceneIteratorOwnerNode)) { + Type sceneIteratorOwnerType = ResolveType(sceneIteratorOwnerNode.TypeName, sceneIteratorOwnerNode.Path); + if ((typeof(Entity).IsAssignableFrom(sceneIteratorOwnerType) || + typeof(Component).IsAssignableFrom(sceneIteratorOwnerType)) && + (!TryGetAuthenticatedIteratorSceneOwner(target, edgeParent, out int sceneOwnerEntityId) || + (TryGetCoroutineEnumeratorStackOwner( + edgeParent, CoroutineStackWalk.IncludingYieldedValues, out int sceneCoroutineId) && + !IsCoroutineOwnedByEntity(sceneCoroutineId, sceneOwnerEntityId)))) { + // Reject contradictory ownership even for a canonical Scene + // local, which generic exact-parent-slot proofs otherwise admit. + // No Coroutine means no new alias licence, not a contradiction: + // manually driven iterators retain their existing structural proof. + throw new AkronReconstructionException( + target.Path, "compiler iterator Scene contradicts its authenticated owner", + target.TypeName); + } + } bool coroutineStackIteratorAlias = IsAuthenticatedCoroutineStackIteratorAlias( target, @@ -5913,7 +6312,6 @@ target.Kind is AnchorKind or PersistentResourceKind or DelegateKind or EventInst freshArrayMembershipAlias || screenWipeRendererListAlias || freshRendererComponentIndexAlias || freshRendererEntityCacheAlias || freshEntityListAlias || freshEntityPeerLink || - runtimeEntitySourceDataListAlias || runtimeEntitySourceMetadataLevelAlias || freshComponentCapturedFreshEdge || runtimeEntityCapturedFreshEdge || entityOwnedCollectionAlias || @@ -5926,7 +6324,7 @@ target.Kind is AnchorKind or PersistentResourceKind or DelegateKind or EventInst reconstructedBuiltInComponentAlias || freshSceneEntityAlias || entityComponentListBackReference || sceneRendererBackReference || reconstructedEntitySceneBackReference || - freshHashSetMembership || iteratorOwnedComponentAlias || + freshHashSetMembership || iteratorOwnedComponentAlias || iteratorSceneAlias || coroutineStackIteratorAlias || coroutineStackIteratorOwnerEdge || directIteratorClosureOwnerEdge || @@ -6002,8 +6400,6 @@ target.Kind is AnchorKind or PersistentResourceKind or DelegateKind or EventInst ";authenticated-built-in-runtime-entity=" + authenticatedBuiltInRuntimeEntity.ToString().ToLowerInvariant() + ";fresh-entity-list-alias=" + freshEntityListAlias.ToString().ToLowerInvariant() + ";fresh-entity-peer-link=" + freshEntityPeerLink.ToString().ToLowerInvariant() + - ";runtime-entity-source-data-list-alias=" + runtimeEntitySourceDataListAlias.ToString().ToLowerInvariant() + - ";runtime-entity-source-metadata-level-alias=" + runtimeEntitySourceMetadataLevelAlias.ToString().ToLowerInvariant() + ";entity-owned-collection-alias=" + entityOwnedCollectionAlias.ToString().ToLowerInvariant() + ";fresh-owned-nested-state=" + freshOwnedNestedState.ToString().ToLowerInvariant() + ";reconstructed-owned-component-alias=" + reconstructedOwnedComponentAlias.ToString().ToLowerInvariant() + @@ -6099,8 +6495,6 @@ target.Kind is AnchorKind or PersistentResourceKind or DelegateKind or EventInst ";authenticated-built-in-runtime-entity=" + authenticatedBuiltInRuntimeEntity.ToString().ToLowerInvariant() + ";fresh-entity-list-alias=" + freshEntityListAlias.ToString().ToLowerInvariant() + ";fresh-entity-peer-link=" + freshEntityPeerLink.ToString().ToLowerInvariant() + - ";runtime-entity-source-data-list-alias=" + runtimeEntitySourceDataListAlias.ToString().ToLowerInvariant() + - ";runtime-entity-source-metadata-level-alias=" + runtimeEntitySourceMetadataLevelAlias.ToString().ToLowerInvariant() + ";entity-owned-collection-alias=" + entityOwnedCollectionAlias.ToString().ToLowerInvariant() + ";fresh-owned-nested-state=" + freshOwnedNestedState.ToString().ToLowerInvariant() + ";reconstructed-owned-component-alias=" + reconstructedOwnedComponentAlias.ToString().ToLowerInvariant() + @@ -6923,14 +7317,11 @@ private bool IsAuthenticatedBuiltInRuntimeEntity( AkronReconstructionNode node, Type type ) { - if (!typeof(Entity).IsAssignableFrom(type) || type.IsAbstract || + if (!typeof(Entity).IsAssignableFrom(type) || !IsSafeManagedReconstructionType(type) || type.Assembly != typeof(Entity).Assembly || - typeof(IDisposable).IsAssignableFrom(type) || - type.GetMethod( - "Finalize", - BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) != null || - HasCurrentRoomSourceId(node) || - !HasAuthenticatedEntityListSceneOwnership(node, out _)) { + !HasAuthenticatedEntityListSceneOwnership( + node, out (AkronReconstructionNode Node, EntityList List) entityList) || + (HasCurrentRoomSourceId(node) && !IsStillPlacedMapEntity(node, type, entityList.List))) { return false; } @@ -6950,12 +7341,8 @@ private bool IsAuthenticatedGeneratedRuntimeEntity( AkronReconstructionNode node, Type type ) { - if (!typeof(Entity).IsAssignableFrom(type) || type.IsAbstract || + if (!typeof(Entity).IsAssignableFrom(type) || !IsSafeManagedReconstructionType(type) || type.Assembly == typeof(Entity).Assembly || - typeof(IDisposable).IsAssignableFrom(type) || - type.GetMethod( - "Finalize", - BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) != null || HasCurrentRoomSourceId(node) || !HasAuthenticatedEntityListSceneOwnership( node, @@ -6963,209 +7350,253 @@ Type type return false; } - // Some mods generate a room's runtime entities from shuffled or - // random layouts. Their exact count and EntityList paths can differ - // after a cold reload even though the fresh room loaded the same - // concrete type. That fresh type occurrence, plus the saved - // Entity/List/Scene ownership loop, authenticates reconstruction of - // the saved population. A type absent from the fresh room remains - // rejected. - return GetFreshEntityTypes(entityList.Node, entityList.List).ContainsKey(type); + // Generated populations can differ at load. Pooled effects are + // explicitly engine-created runtime entities; non-pooled helpers + // can instead prove their creator through a concrete captured + // entity or list-owned component in this same authenticated Scene. + // Neither proof licenses a detached object or a foreign Scene. + return GetFreshEntityTypes(entityList.Node, entityList.List).ContainsKey(type) || + type.IsDefined(typeof(Pooled), inherit: false) || + HasAuthenticatedCapturedEntity(node, entityList.List) || + HasAuthenticatedRuntimeCollectionOrigin(node, type); } - private bool HasCurrentRoomSourceId(AkronReconstructionNode node) { - return TryGetSavedEntityId(node, out EntityID sourceId) && - string.Equals(sourceId.Level, document.Room, StringComparison.Ordinal); + private bool IsStillPlacedMapEntity(AkronReconstructionNode node, Type type, EntityList entityList) { + return node.MapPlacedEntity && + TryGetSavedEntityId(node, out EntityID sourceId) && + owner.IsMapPlacedEntityId( + freshRoot, sourceId, mapPlacedEntityIdsByRoom) == true && + !GetEntityListEntities(entityList).Any(candidate => + candidate != null && candidate.GetType() != type && + EntityIdsMatch(GetEntitySourceId(candidate), sourceId)); } - private bool HasAuthenticatedEntityListSceneOwnership( + private bool HasAuthenticatedCapturedEntity( AkronReconstructionNode node, - out (AkronReconstructionNode Node, EntityList List) ownerList + EntityList entityList ) { - ownerList = default; - if (!TryGetEntityListOwnerNode(node, out AkronReconstructionNode entityListNode)) { - return false; - } - AkronReconstructionValue entityScene = FindReferenceField(node, "k__BackingField"); - AkronReconstructionValue listScene = FindReferenceField(entityListNode, "k__BackingField"); - if (entityScene == null || listScene?.NodeId != entityScene.NodeId || - !nodes.TryGetValue(entityScene.NodeId, out AkronReconstructionNode sceneNode) || - FindReferenceField(sceneNode, "k__BackingField")?.NodeId != entityListNode.Id || - !resolvedFreshObjectNodes.Contains(sceneNode.Id) || - !resolvedFreshObjectNodes.Contains(entityListNode.Id) || - !Objects.TryGetValue(sceneNode.Id, out object sceneObject) || sceneObject is not Scene scene || - !Objects.TryGetValue(entityListNode.Id, out object listObject) || listObject is not EntityList liveList) { - return false; + AkronReconstructionValue scene = FindReferenceField(node, "k__BackingField"); + foreach (AkronReconstructionField savedField in + node.FieldsOrNull ?? Enumerable.Empty()) { + if (savedField.Value?.Kind != ReferenceValueKind || + !nodes.TryGetValue(savedField.Value.NodeId, out AkronReconstructionNode captured)) { + continue; + } + Type capturedType = ResolveType(captured.TypeName, captured.Path); + FieldInfo field = ResolveField( + savedField.DeclaringTypeName, savedField.Name, savedField.Path); + if (field.FieldType != capturedType) { + continue; + } + bool capturedComponent = typeof(Component).IsAssignableFrom(capturedType); + if (capturedComponent) { + // The captured component may precede its owner in allocation + // order. Authenticate the saved loop without allocating either + // side, then match the owner against the fresh scene below. + // An Entity pointer alone is not membership in its list. + if (capturedType == typeof(Component) || + !TryGetComponentOwnerNodes( + captured, + out AkronReconstructionNode componentList, + out int ownerEntityId) || + !IsSavedComponentListMember(captured, componentList) || + !nodes.TryGetValue(ownerEntityId, out captured) || + !HasAuthenticatedEntityListSceneOwnership(captured, out var ownerList) || + !ReferenceEquals(ownerList.List, entityList)) { + continue; + } + capturedType = ResolveType(captured.TypeName, captured.Path); + } else if (capturedType == typeof(Entity) || !typeof(Entity).IsAssignableFrom(capturedType)) { + continue; + } + if (FindReferenceField(captured, "k__BackingField")?.NodeId != scene?.NodeId) { + continue; + } + Entity entity = Objects.TryGetValue(captured.Id, out object capturedObject) + ? capturedObject as Entity + : TryResolveFreshOwnedEntity(captured, capturedType, out Entity freshEntity) + ? freshEntity + : null; + if (entity != null && + (!capturedComponent || + (Objects.TryGetValue(scene.NodeId, out object freshScene) && + ReferenceEquals(GetEntityScene(entity), freshScene))) && + GetEntityListEntities(entityList) + .Any(candidate => ReferenceEquals(candidate, entity))) { + return true; + } } + return false; + } - if (!ReferenceEquals(GetSceneEntities(scene), liveList)) { + private bool HasAuthenticatedRuntimeCollectionOrigin(AkronReconstructionNode entity, Type entityType) { + if (!savedArrayAliases.TryGetValue(entity.Id, out List arrays)) { return false; } - ownerList = (entityListNode, liveList); - return true; + return arrays.Any(array => IsAuthenticatedRuntimeCollectionOrigin(entity, entityType, array)); } - // Everest assigns SourceData while it constructs a map entity. An - // entity created as a side effect of that constructor can inherit the - // same metadata even when it later persists into another room. The - // EntityData then has two legitimate paths in the saved graph: the - // runtime entity's exact _sourceData field and its original - // LevelData.Entities or Triggers list. The first path owns the node; - // this authenticates only the second alias, using the SourceId fields - // that Everest derived from that same map entry. - private bool IsAuthenticatedRuntimeEntitySourceDataListAlias( - AkronReconstructionNode target, - Type targetType, - AkronReconstructionNode edgeParent, - Type edgeParentType, - AkronReconstructionField edgeField + private bool IsAuthenticatedRuntimeCollectionOrigin( + AkronReconstructionNode entity, + Type entityType, + AkronReconstructionNode array ) { - if (targetType != typeof(EntityData) || edgeParentType != typeof(EntityData[]) || - edgeField != null || - FindReferenceField(target, nameof(EntityData.Level)) is not AkronReconstructionValue levelReference || - !nodes.TryGetValue(levelReference.NodeId, out AkronReconstructionNode sourceLevel) || - !TryGetAuthenticatedRuntimeEntitySourceMetadata( - target, - sourceLevel, - out EntityID sourceId, - out int sourceDataId, - out string sourceRoom)) { + Type arrayType = ResolveType(array.TypeName, array.Path); + if (!arrayType.IsArray || arrayType.GetElementType() != entityType || + !savedArrayAliases.TryGetValue(entity.Id, out List aliases) || + !aliases.Contains(array)) { return false; } - - if (!TryGetFieldParent(edgeParent.Id, "_items", out AkronReconstructionNode sourceList) || - sourceList.ParentKind != "field" || sourceList.ParentNodeId <= 0 || - sourceList.ParentNodeId != sourceLevel.Id || - !(edgeParent.ItemsOrNull ?? Enumerable.Empty()) - .Any(item => item?.Kind == ReferenceValueKind && item.NodeId == target.Id)) { - return false; - } - - bool isTrigger = sourceList.ParentFieldName == nameof(LevelData.Triggers); - if (!isTrigger && sourceList.ParentFieldName != nameof(LevelData.Entities)) { - return false; + if (runtimeCollectionOrigins.TryGetValue(array.Id, out var origin)) { + return FindReferenceField(entity, "k__BackingField")?.NodeId == origin.SceneId; } - - int expectedSourceId = isTrigger - ? sourceDataId + AkronStartPosReconstruction.TriggerEntityIdOffset - : sourceDataId; - if (!string.Equals(sourceId.Level, sourceRoom, StringComparison.Ordinal) || - sourceId.ID != expectedSourceId) { - return false; + AkronReconstructionNode child = array; + for (int depth = 0; depth < MaxParentChainDepth; depth++) { + if (!nodes.TryGetValue(child.ParentNodeId, out AkronReconstructionNode parent) || + !IsExactNestedOwnershipShape(child, parent)) { + return false; + } + Type parentType = ResolveType(parent.TypeName, parent.Path); + if (typeof(Entity).IsAssignableFrom(parentType) || + typeof(Component).IsAssignableFrom(parentType) || + typeof(Renderer).IsAssignableFrom(parentType) || + typeof(Backdrop).IsAssignableFrom(parentType)) { + if (!IsSafeManagedReconstructionType(parentType) || owner.isLiveResource(parentType)) { + return false; + } + AkronReconstructionNode ancestor = parent; + for (int step = 0; step < MaxParentChainDepth; step++) { + if (!nodes.TryGetValue(ancestor.ParentNodeId, out ancestor)) { + return false; + } + if (!typeof(Scene).IsAssignableFrom(ResolveType(ancestor.TypeName, ancestor.Path))) { + continue; + } + if (FindReferenceField(entity, "k__BackingField")?.NodeId != ancestor.Id || + !resolvedFreshObjectNodes.Contains(ancestor.Id)) { + return false; + } + // Read the real canonical owner path, never a claimed + // FreshPath or a lexical relationship between mod types. + // The owner may occur after the effect in document order; + // ResolveObjects checks this reservation before any writes. + object freshOwner; + if (resolvedFreshObjectNodes.Contains(parent.Id) && + Objects.TryGetValue(parent.Id, out object resolvedOwner)) { + freshOwner = resolvedOwner; + } else if (typeof(Entity).IsAssignableFrom(parentType) && + TryResolveFreshOwnedEntity(parent, parentType, out Entity freshEntity)) { + freshOwner = freshEntity; + } else if (typeof(Component).IsAssignableFrom(parentType) && + TryResolveFreshOwnedComponent(parent, parentType, out Component freshComponent)) { + // The component's Entity may be later in the document + // and at a different EntityList index in the fresh room. + freshOwner = freshComponent; + } else { + freshOwner = ResolveFreshPath(GetDocumentStructuralPath(parent), parent.Path); + } + if (freshOwner?.GetType() != parentType || !SavedFreshIdentityMatches(parent, freshOwner) || + (Objects.ContainsKey(parent.Id) && !resolvedFreshObjectNodes.Contains(parent.Id)) || + !IsRuntimeCollectionOwnerInScene(parent, freshOwner, ancestor)) { + return false; + } + runtimeCollectionOrigins[array.Id] = (parent.Id, ancestor.Id, freshOwner); + return true; + } + return false; + } + if (!IsSafeManagedReconstructionType(parentType) || + typeof(Scene).IsAssignableFrom(parentType) || + typeof(Delegate).IsAssignableFrom(parentType) || + typeof(IDisposable).IsAssignableFrom(parentType) || + owner.isLiveResource(parentType)) { + return false; + } + child = parent; } - - return true; + return false; } - private bool IsAuthenticatedRuntimeEntitySourceMetadataLevelAlias( - AkronReconstructionNode target, - Type targetType, - AkronReconstructionNode edgeParent, - Type edgeParentType, - AkronReconstructionField edgeField + private bool IsRuntimeCollectionOwnerInScene( + AkronReconstructionNode ownerNode, + object freshOwner, + AkronReconstructionNode sceneNode ) { - if (targetType != typeof(LevelData) || edgeParentType != typeof(EntityData) || - edgeField?.Name != nameof(EntityData.Level) || - FindReferenceField(edgeParent, nameof(EntityData.Level))?.NodeId != target.Id) { + if (!Objects.TryGetValue(sceneNode.Id, out object freshScene) || freshScene is not Scene scene) { return false; } - - FieldInfo field = ResolveField( - edgeField.DeclaringTypeName, - edgeField.Name, - edgeField.Path); - if (field.DeclaringType != typeof(EntityData) || field.FieldType != typeof(LevelData)) { - return false; + if (freshOwner is Entity || freshOwner is Component) { + AkronReconstructionNode entityNode = ownerNode; + Entity entity = freshOwner as Entity; + if (freshOwner is Component component) { + if (!TryGetComponentOwnerNodes(ownerNode, out AkronReconstructionNode components, out int entityId) || + !IsSavedComponentListMember(ownerNode, components) || + !nodes.TryGetValue(entityId, out entityNode)) { + return false; + } + entity = GetComponentEntity(component); + if (entity == null || !GetComponentListComponents(GetEntityComponents(entity)) + .Any(candidate => ReferenceEquals(candidate, component))) { + return false; + } + } + return entity != null && ReferenceEquals(GetEntityScene(entity), scene) && + entity.GetType() == ResolveType(entityNode.TypeName, entityNode.Path) && + SavedFreshIdentityMatches(entityNode, entity) && + FindReferenceField(entityNode, "k__BackingField")?.NodeId == sceneNode.Id && + HasAuthenticatedEntityListSceneOwnership(entityNode, out var entityList) && + GetEntityListEntities(entityList.List).Any(candidate => ReferenceEquals(candidate, entity)); + } + if (freshOwner is Renderer renderer) { + if (!TryGetRendererListOwnerNode(ownerNode, out AkronReconstructionNode rendererListNode) || + FindReferenceField(rendererListNode, "scene")?.NodeId != sceneNode.Id || + FindReferenceField(sceneNode, "k__BackingField")?.NodeId != rendererListNode.Id) { + return false; + } + RendererList rendererList = SceneRendererListField?.GetValue(scene) as RendererList; + return rendererList != null && ReferenceEquals(RendererListSceneField?.GetValue(rendererList), scene) && + GetFreshRendererTypes(rendererListNode, rendererList) + .TryGetValue(renderer.GetType(), out List renderers) && + renderers.Any(candidate => ReferenceEquals(candidate, renderer)); } + // Backdrops have no Entity.Scene or renderer-list membership of + // their own. Their already matched Scene-owned path is the root. + return freshOwner is Backdrop; + } - bool authenticatedLevel = authenticatedOwnedNestedStateNodes.Contains(target.Id) || - IsAuthenticatedRuntimeEntitySourceMetadataState(target, targetType); - bool authenticatedEntry = authenticatedOwnedNestedStateNodes.Contains(edgeParent.Id) || - IsAuthenticatedRuntimeEntitySourceMetadataState(edgeParent, edgeParentType); - return authenticatedLevel && authenticatedEntry; + private bool HasCurrentRoomSourceId(AkronReconstructionNode node) { + return TryGetSavedEntityId(node, out EntityID sourceId) && + string.Equals(sourceId.Level, document.Room, StringComparison.Ordinal); } - private bool IsAuthenticatedRuntimeEntitySourceMetadataState( + private bool HasAuthenticatedEntityListSceneOwnership( AkronReconstructionNode node, - Type type + out (AkronReconstructionNode Node, EntityList List) ownerList ) { - if (type == typeof(EntityData)) { - // A proved source LevelData owns every exact record in its - // Entities and Triggers lists, not only the record aliased by - // Entity._sourceData. The remaining records are passive map - // metadata, but reconstructing the LevelData requires their - // original order and their back-references to that same level. - if (node.ParentKind != "array" || - !TryGetFieldParent(node.ParentNodeId, "_items", out AkronReconstructionNode sourceList) || - sourceList.ParentKind != "field" || sourceList.ParentNodeId <= 0 || - (sourceList.ParentFieldName != nameof(LevelData.Entities) && - sourceList.ParentFieldName != nameof(LevelData.Triggers)) || - !nodes.TryGetValue(sourceList.ParentNodeId, out AkronReconstructionNode sourceLevel) || - FindReferenceField(node, nameof(EntityData.Level))?.NodeId != sourceLevel.Id) { - return false; - } - return authenticatedOwnedNestedStateNodes.Contains(sourceLevel.Id) || - IsAuthenticatedRuntimeEntitySourceMetadataState(sourceLevel, typeof(LevelData)); - } - - if (type != typeof(LevelData) || - !nodes.TryGetValue(node.ParentNodeId, out AkronReconstructionNode sourceData)) { + ownerList = default; + if (!TryGetEntityListOwnerNode(node, out AkronReconstructionNode entityListNode)) { return false; } - return TryGetAuthenticatedRuntimeEntitySourceMetadata( - sourceData, - node, - out EntityID sourceId, - out int sourceDataId, - out string sourceRoom) && - string.Equals(sourceId.Level, sourceRoom, StringComparison.Ordinal) && - (sourceId.ID == sourceDataId || - sourceId.ID == sourceDataId + AkronStartPosReconstruction.TriggerEntityIdOffset); - } - - private bool TryGetAuthenticatedRuntimeEntitySourceMetadata( - AkronReconstructionNode sourceData, - AkronReconstructionNode sourceLevel, - out EntityID sourceId, - out int sourceDataId, - out string sourceRoom - ) { - sourceId = default; - sourceDataId = 0; - sourceRoom = null; - if (ResolveType(sourceData.TypeName, sourceData.Path) != typeof(EntityData) || - ResolveType(sourceLevel.TypeName, sourceLevel.Path) != typeof(LevelData) || - sourceData.ParentKind != "field" || sourceData.ParentFieldName != "_sourceData" || - sourceLevel.ParentKind != "field" || - sourceLevel.ParentFieldName != nameof(EntityData.Level) || - sourceLevel.ParentNodeId != sourceData.Id || - !nodes.TryGetValue(sourceData.ParentNodeId, out AkronReconstructionNode ownerNode) || - FindReferenceField(ownerNode, "_sourceData")?.NodeId != sourceData.Id || - FindReferenceField(sourceData, nameof(EntityData.Level))?.NodeId != sourceLevel.Id) { + AkronReconstructionValue entityScene = FindReferenceField(node, "k__BackingField"); + AkronReconstructionValue listScene = FindReferenceField(entityListNode, "k__BackingField"); + if (entityScene == null || listScene?.NodeId != entityScene.NodeId || + !nodes.TryGetValue(entityScene.NodeId, out AkronReconstructionNode sceneNode) || + FindReferenceField(sceneNode, "k__BackingField")?.NodeId != entityListNode.Id || + !resolvedFreshObjectNodes.Contains(sceneNode.Id) || + !resolvedFreshObjectNodes.Contains(entityListNode.Id) || + !Objects.TryGetValue(sceneNode.Id, out object sceneObject) || sceneObject is not Scene scene || + !Objects.TryGetValue(entityListNode.Id, out object listObject) || listObject is not EntityList liveList) { return false; } - Type ownerType = ResolveType(ownerNode.TypeName, ownerNode.Path); - bool authenticatedOwner = authenticatedRuntimeEntityNodes.Contains(ownerNode.Id) || - IsAuthenticatedBuiltInRuntimeEntity(ownerNode, ownerType) || - IsAuthenticatedGeneratedRuntimeEntity(ownerNode, ownerType); - AkronReconstructionField roomField = sourceLevel.FieldsOrNull?.FirstOrDefault(field => - field.Name == nameof(LevelData.Name) && field.Value?.Kind == ScalarValueKind); - AkronReconstructionField idField = sourceData.FieldsOrNull?.FirstOrDefault(field => - field.Name == nameof(EntityData.ID) && field.Value?.Kind == ScalarValueKind); - if (!authenticatedOwner || !TryGetSavedEntityId(ownerNode, out sourceId) || - roomField == null || idField == null || - DecodeScalar(roomField.Value, roomField.Path) is not string decodedRoom || - DecodeScalar(idField.Value, idField.Path) is not int decodedId) { - return false; - } - - authenticatedRuntimeEntityNodes.Add(ownerNode.Id); - sourceRoom = decodedRoom; - sourceDataId = decodedId; + if (!ReferenceEquals(GetSceneEntities(scene), liveList)) { + return false; + } + ownerList = (entityListNode, liveList); return true; } + private bool IsAuthenticatedFreshEntityPeerLink( AkronReconstructionNode target, Type targetType, @@ -7205,10 +7636,13 @@ AkronReconstructionNode edgeParent bool targetIsAuthenticatedRuntime = authenticatedRuntimeEntityNodes.Contains(target.Id); if (!typeof(Entity).IsAssignableFrom(targetType) || (!targetIsFresh && !targetIsAuthenticatedRuntime) || - !Objects.TryGetValue(target.Id, out object targetObject) || targetObject is not Entity targetEntity || - !TryGetEntityListOwnerNode(target, out AkronReconstructionNode targetEntityList)) { + !Objects.TryGetValue(target.Id, out object targetObject) || targetObject is not Entity targetEntity) { return false; } + if (targetIsAuthenticatedRuntime && + IsAuthenticatedRuntimeCollectionOrigin(target, targetType, edgeParent)) { + return true; + } AkronReconstructionNode current = edgeParent; while (current != null && @@ -7219,8 +7653,23 @@ AkronReconstructionNode edgeParent } bool ownerIsFresh = current != null && resolvedFreshObjectNodes.Contains(current.Id); bool ownerIsAuthenticatedRuntime = current != null && authenticatedRuntimeEntityNodes.Contains(current.Id); + if (targetIsFresh && ownerIsFresh && + !TryGetEntityListOwnerNode(target, out _) && + Objects.TryGetValue(current.Id, out object retainedOwner) && + retainedOwner is Entity retainedOwnerEntity && + GetEntityScene(retainedOwnerEntity) is Scene retainedScene && + ReferenceEquals(retainedScene, GetEntityScene(targetEntity)) && + TryGetSavedEntityId(target, out _) && SavedEntitySourceMatches(target, targetEntity) && + GetEntityListEntities(GetSceneEntities(retainedScene)) + .Any(candidate => ReferenceEquals(candidate, targetEntity))) { + // A removed trigger can remain in the player's collision + // bookkeeping. It still identifies the map entity a clean + // load built, even though no saved EntityList owns it. + return true; + } if (current == null || (!ownerIsFresh && !ownerIsAuthenticatedRuntime) || !Objects.TryGetValue(current.Id, out object ownerObject) || ownerObject is not Entity ownerEntity || + !TryGetEntityListOwnerNode(target, out AkronReconstructionNode targetEntityList) || !TryGetEntityListOwnerNode(current, out AkronReconstructionNode ownerEntityList) || ownerEntityList.Id != targetEntityList.Id || !Objects.TryGetValue(targetEntityList.Id, out object listObject) || listObject is not EntityList entityList) { @@ -7323,107 +7772,58 @@ AkronReconstructionNode parent child.ParentArrayIndicesOrNull?.Count == parentType.GetArrayRank(); } - private bool IsAuthenticatedFreshEntityOwnedNestedState( + private bool IsAuthenticatedOwnedManagedState( AkronReconstructionNode node, Type type ) { - if (!type.IsClass || type.IsAbstract || type.IsGenericType || + if (!type.IsClass || type.IsAbstract || typeof(Entity).IsAssignableFrom(type) || typeof(Component).IsAssignableFrom(type) || - typeof(Renderer).IsAssignableFrom(type) || typeof(Delegate).IsAssignableFrom(type) || - typeof(IDisposable).IsAssignableFrom(type) || - type.GetMethod("Finalize", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) != null) { + typeof(Renderer).IsAssignableFrom(type) || typeof(Scene).IsAssignableFrom(type) || + typeof(Delegate).IsAssignableFrom(type) || typeof(IDisposable).IsAssignableFrom(type) || + type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) || + owner.isLiveResource(type)) { return false; } - - AkronReconstructionNode child = node; - AkronReconstructionNode current = nodes.TryGetValue( - node.ParentNodeId, - out AkronReconstructionNode parent) - ? parent - : null; - while (current != null) { - Type ownerType = ResolveType(current.TypeName, current.Path); - if (typeof(Entity).IsAssignableFrom(ownerType)) { - if (type.DeclaringType != ownerType || - child.ParentNodeId != current.Id || child.ParentKind != "field" || - !resolvedFreshObjectNodes.Contains(current.Id) || - !Objects.TryGetValue(current.Id, out object ownerObject) || - ownerObject is not Entity) { - return false; - } - - FieldInfo field = ResolveField( - child.ParentDeclaringTypeName, - child.ParentFieldName, - child.Path); - bool ownsValue = field.FieldType == type || - field.FieldType.IsArray && field.FieldType.GetElementType() == type || - IsSupportedCollectionType(field.FieldType) && - field.FieldType.GetGenericArguments().Contains(type); - return field.DeclaringType.IsAssignableFrom(ownerType) && ownsValue; - } - if (typeof(Component).IsAssignableFrom(ownerType) || - typeof(Renderer).IsAssignableFrom(ownerType) || - !nodes.TryGetValue(current.ParentNodeId, out parent)) { - return false; - } - child = current; - current = parent; - } - return false; - } - - private bool IsAuthenticatedFreshRendererOwnedRuntimeState( - AkronReconstructionNode node, - Type type - ) { - if (!type.IsClass || type.IsAbstract || type.IsGenericType || - type.Assembly != typeof(Renderer).Assembly || - typeof(IDisposable).IsAssignableFrom(type) || - type.GetMethod( - "Finalize", - BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) != null) { + if (!IsSafeManagedReconstructionType(type)) { return false; } + // A concrete field is an ownership contract even when the helper + // record is generic or declared beside, rather than inside, its + // owner. Follow only exact typed fields and collection storage; + // arbitrary object/interface references cannot supply this proof. AkronReconstructionNode child = node; - AkronReconstructionNode current = nodes.TryGetValue( - node.ParentNodeId, - out AkronReconstructionNode parent) - ? parent - : null; - while (current != null) { - Type ownerType = ResolveType(current.TypeName, current.Path); - if (typeof(Renderer).IsAssignableFrom(ownerType)) { - if (type.DeclaringType != ownerType || - child.ParentNodeId != current.Id || child.ParentKind != "field" || - !resolvedFreshObjectNodes.Contains(current.Id) || - !Objects.TryGetValue(current.Id, out object ownerObject) || - ownerObject is not Renderer) { - return false; - } - - FieldInfo field = ResolveField( - child.ParentDeclaringTypeName, - child.ParentFieldName, - child.Path); - bool ownsElement = field.FieldType.IsArray - ? field.FieldType.GetElementType() == type - : IsSupportedCollectionType(field.FieldType) && - field.FieldType.GetGenericArguments().Contains(type); - return field.DeclaringType.IsInstanceOfType(ownerObject) && ownsElement; - } - if (typeof(Entity).IsAssignableFrom(ownerType) || - typeof(Component).IsAssignableFrom(ownerType) || - !nodes.TryGetValue(current.ParentNodeId, out parent)) { + int depth = 0; + while (nodes.TryGetValue(child.ParentNodeId, out AkronReconstructionNode parent) && + depth++ < MaxParentChainDepth) { + if (!IsExactNestedOwnershipShape(child, parent)) { return false; } - child = current; - current = parent; + Type parentType = ResolveType(parent.TypeName, parent.Path); + if (typeof(Entity).IsAssignableFrom(parentType) || + typeof(Component).IsAssignableFrom(parentType) || + typeof(Renderer).IsAssignableFrom(parentType)) { + // Objects contains only successfully authenticated nodes; + // the owner can itself be a saved-only reconstruction. + return !owner.isLiveResource(parentType) && IsSafeManagedReconstructionType(parentType) && + Objects.TryGetValue(parent.Id, out object parentObject) && + parentType.IsInstanceOfType(parentObject); + } + if (typeof(Scene).IsAssignableFrom(parentType) || + typeof(Delegate).IsAssignableFrom(parentType) || + typeof(IDisposable).IsAssignableFrom(parentType) || + owner.isLiveResource(parentType) || + (!IsExplicitlySafeReconstructionType(parentType) && + !resolvedFreshObjectNodes.Contains(parent.Id) && + !authenticatedOwnedNestedStateNodes.Contains(parent.Id))) { + return false; + } + child = parent; } return false; } + private bool IsAuthenticatedRuntimeEntityOwnedState( AkronReconstructionNode node, Type type @@ -7526,38 +7926,71 @@ Type type freshOwnersOfType.Any(candidate => field.GetValue(candidate)?.GetType() == type); } + private bool IsReservedSceneFieldAlias(AkronReconstructionNode target, object candidate) { + if (candidate is Entity || candidate is Component || candidate is Scene || + !freshFieldAliasSourcesByNode.TryGetValue(target.Id, out var source) || + !nodes.TryGetValue(source.ParentId, out AkronReconstructionNode scene) || + !resolvedFreshObjectNodes.Contains(scene.Id) || + !Objects.TryGetValue(scene.Id, out object sceneObject) || sceneObject is not Scene || + FindReferenceField(scene, source.FieldName)?.NodeId != target.Id) { + return false; + } + FieldInfo field = ResolveField(source.DeclaringTypeName, source.FieldName, target.Path); + return typeof(Scene).IsAssignableFrom(field.DeclaringType) && + field.DeclaringType.IsInstanceOfType(sceneObject) && + field.FieldType == candidate.GetType() && + ReferenceEquals(field.GetValue(sceneObject), candidate); + } + private bool IsAuthenticatedFreshFieldAlias( AkronReconstructionNode target, AkronReconstructionNode edgeParent, AkronReconstructionField edgeField ) { if (edgeField == null || !resolvedFreshObjectNodes.Contains(target.Id) || - !resolvedFreshObjectNodes.Contains(edgeParent.Id) || !Objects.TryGetValue(edgeParent.Id, out object parentObject) || !Objects.TryGetValue(target.Id, out object targetObject)) { return false; } + bool parentIsFresh = resolvedFreshObjectNodes.Contains(edgeParent.Id); + if (!parentIsFresh && parentObject is not Component) { + return false; + } FieldInfo field = ResolveField(edgeField.DeclaringTypeName, edgeField.Name, target.Path); if (!field.DeclaringType.IsInstanceOfType(parentObject)) { return false; } - if (ReferenceEquals(field.GetValue(parentObject), targetObject)) { + if (parentIsFresh && ReferenceEquals(field.GetValue(parentObject), targetObject)) { return true; } - // ResolveObjects can reserve the exact typed alias before this - // owner field receives its saved assignment. Accept only that - // pre-scanned owner edge, not another base-typed alias to the same - // saved node. - return field.FieldType == targetObject.GetType() && - freshFieldAliasesByNode.TryGetValue(target.Id, out object reservedAlias) && - ReferenceEquals(reservedAlias, targetObject) && - freshFieldAliasSourcesByNode.TryGetValue( - target.Id, - out (int ParentId, string DeclaringTypeName, string FieldName) source) && - source.ParentId == edgeParent.Id && - source.DeclaringTypeName == edgeField.DeclaringTypeName && - source.FieldName == edgeField.Name; + // ResolveObjects reserves typed owner aliases before their fields + // are assigned. An opaque field cannot establish another owner. + if (field.FieldType != targetObject.GetType() || + !freshFieldAliasesByNode.TryGetValue(target.Id, out object reservedAlias) || + !ReferenceEquals(reservedAlias, targetObject) || + !freshFieldAliasSourcesByNode.TryGetValue( + target.Id, + out (int ParentId, string DeclaringTypeName, string FieldName) source)) { + return false; + } + if (source.ParentId == edgeParent.Id && + source.DeclaringTypeName == edgeField.DeclaringTypeName && + source.FieldName == edgeField.Name) { + return parentIsFresh; + } + // A component retained in a concrete entity field can be detached + // in either graph. Its entity still proves the same-scene alias. + if (!(parentObject is Component) || targetObject is Entity || + targetObject is Component || targetObject is Scene || + !TryGetAuthenticatedComponentEntity(edgeParent, parentObject.GetType(), out int entityId) || + !nodes.TryGetValue(entityId, out AkronReconstructionNode entity) || + FindReferenceField(entity, "k__BackingField") is not AkronReconstructionValue sceneReference || + !nodes.TryGetValue(sceneReference.NodeId, out AkronReconstructionNode scene) || + source.ParentId != scene.Id) { + return false; + } + return IsReservedSceneFieldAlias(target, targetObject); } private bool IsAuthenticatedFreshOwnerAliasMerge( @@ -7672,7 +8105,7 @@ private bool IsAuthenticatedEntityComponentListBackReference( AkronReconstructionField edgeField, bool exactParentSlot ) { - if ((!exactParentSlot && + if ((!exactParentSlot && !resolvedFreshObjectNodes.Contains(target.Id) && !authenticatedRuntimeEntityNodes.Contains(target.Id) && !authenticatedOwnedNestedEntityNodes.Contains(target.Id)) || edgeField?.Name != "k__BackingField" || @@ -7803,6 +8236,7 @@ out bool exactTypedAlias bool ambiguous = false; if (freshFieldAliasesByNode.TryGetValue(target.Id, out object reservedAlias) && reservedAlias.GetType() == targetType && !freshOwners.ContainsKey(reservedAlias) && + SavedFreshIdentityMatches(target, reservedAlias) && (target.Kind != ArrayKind || reservedAlias is Array reservedArray && CanReuseFreshArray(reservedArray, target))) { matchedAlias = reservedAlias; @@ -7829,6 +8263,7 @@ out bool exactTypedAlias ? field.GetValue(parentObject) : null; if (candidate == null || candidate.GetType() != targetType || freshOwners.ContainsKey(candidate) || + !SavedFreshIdentityMatches(target, candidate) || (freshFieldAliasReservations.TryGetValue(candidate, out int reservedNodeId) && reservedNodeId != target.Id)) { continue; @@ -7855,6 +8290,72 @@ out bool exactTypedAlias return matchedAlias != null; } + private object ResolveFreshAliasParent(AkronReconstructionNode node) { + if (freshAliasParents.TryGetValue(node.Id, out object cached)) { + return cached; + } + List path = GetDocumentStructuralPath(node); + AkronReconstructionNode current = node; + int suffixLength = 0; + object identityOwner = null; + while (current != null && current.Id != document.RootNodeId) { + Type type = ResolveType(current.TypeName, current.Path); + if (typeof(Entity).IsAssignableFrom(type)) { + if (TryGetSavedEntityId(current, out EntityID sourceId) && + freshSourceEntities.TryGetValue((type, sourceId.Level, sourceId.ID), out List entities) && + entities.Count == 1) { + identityOwner = entities[0]; + } + break; + } + if (typeof(Component).IsAssignableFrom(type)) { + if (TryGetComponentOwnerNodes(current, out _, out int ownerId) && + nodes.TryGetValue(ownerId, out AkronReconstructionNode ownerNode) && + TryGetSavedEntityId(ownerNode, out EntityID sourceId) && + freshSourceEntities.TryGetValue( + (ResolveType(ownerNode.TypeName, ownerNode.Path), sourceId.Level, sourceId.ID), + out List owners) && + owners.Count == 1 && GetEntityComponents(owners[0]) is ComponentList components) { + foreach (Component component in GetComponentListComponents(components)) { + if (component == null || component.GetType() != type || + !ReferenceEquals(GetComponentEntity(component), owners[0])) { + continue; + } + if (identityOwner != null) { + identityOwner = null; + break; + } + identityOwner = component; + } + } + break; + } + if (current.ParentKind != "field" && current.ParentKind != "array") { + freshAliasParents[node.Id] = null; + return null; + } + if (!nodes.TryGetValue(current.ParentNodeId, out current)) { + break; + } + suffixLength++; + } + // Named fields below a shuffled EntityList must be read relative + // to the identified owner, not the entity now at its old index. + // Ambiguous/unidentified owners wait for ordinary node resolution; + // a provisional reservation must never override their identity. + object result = null; + if (identityOwner != null) { + path.RemoveRange(0, path.Count - suffixLength); + if (!HasListStorageIndex(path)) { + result = ResolveFreshPathFrom(identityOwner, path, node.Path); + } + } else if (!HasListStorageIndex(path)) { + result = ResolveFreshPath(path, node.Path); + } + freshAliasParents[node.Id] = result; + return result; + } + private void ReserveFreshFieldAliases() { HashSet ambiguous = new HashSet(ReferenceEqualityComparer.Instance); Dictionary aliasPriorities = @@ -7872,20 +8373,18 @@ private void ReserveFreshFieldAliases() { if (IsDocumentDescendantOf(parent, target.Id) || IsDocumentCollectionStorageNode(parent)) { continue; } - List aliasPath = GetDocumentStructuralPath(parent); - aliasPath.Add(new AkronReconstructionPathStep { - Kind = "field", - DeclaringTypeName = savedField.DeclaringTypeName, - FieldName = savedField.Name - }); - object candidate = ResolveFreshPath(aliasPath, target.Path); - if (candidate == null || candidate.GetType() != targetType || ambiguous.Contains(candidate)) { - continue; - } + object parentObject = ResolveFreshAliasParent(parent); FieldInfo aliasField = ResolveField( savedField.DeclaringTypeName, savedField.Name, savedField.Path); + object candidate = parentObject != null && aliasField.DeclaringType.IsInstanceOfType(parentObject) + ? aliasField.GetValue(parentObject) + : null; + if (candidate == null || candidate.GetType() != targetType || ambiguous.Contains(candidate) || + !SavedFreshIdentityMatches(target, candidate)) { + continue; + } if (freshFieldAliasReservations.TryGetValue(candidate, out int existingTargetId) && existingTargetId != target.Id) { freshFieldAliasReservations.Remove(candidate); @@ -7950,18 +8449,13 @@ private bool IsDocumentDescendantOf(AkronReconstructionNode node, int possibleAn } private bool IsDocumentCollectionStorageNode(AkronReconstructionNode node) { - AkronReconstructionNode current = node; - while (current != null && current.ParentNodeId > 0 && - nodes.TryGetValue(current.ParentNodeId, out AkronReconstructionNode parent)) { - Type parentType = ResolveType(parent.TypeName, parent.Path); - if (current.ParentKind == "field" && - ((parentType == typeof(EntityList) && IsEntityListStorageField(current.ParentFieldName)) || - (parentType == typeof(ComponentList) && IsComponentListStorageField(current.ParentFieldName)))) { - return true; - } - current = parent; - } - return false; + // A collection's descendants are not all storage. In particular, + // EntityList -> List -> Entity -> gui is an ordinary owner + // field, even though the path used to reach that owner has indices. + Type type = ResolveType(node.TypeName, node.Path); + return type.IsArray || IsSupportedCollectionType(type) || + IsCoreCollectionStorageType(type) || + type == typeof(EntityList) || type == typeof(ComponentList); } private bool IsSavedDelegateTargetAlias(int targetNodeId, Type targetType) { @@ -8239,10 +8733,28 @@ private bool TryResolveFreshOwnedEntity( out Entity matchedEntity ) { matchedEntity = null; - if (!typeof(Entity).IsAssignableFrom(targetType) || - !TryGetEntityListOwnerNode(target, out AkronReconstructionNode entityListNode)) { + if (!typeof(Entity).IsAssignableFrom(targetType)) { return false; } + if (!TryGetEntityListOwnerNode(target, out AkronReconstructionNode entityListNode)) { + if (!TryGetSavedEntityId(target, out EntityID sourceId) || + !freshSourceEntities.TryGetValue((targetType, sourceId.Level, sourceId.ID), out List candidates)) { + return false; + } + foreach (Entity candidate in candidates) { + if (freshOwners.ContainsKey(candidate) || + (freshFieldAliasReservations.TryGetValue(candidate, out int reserved) && + reserved != target.Id)) { + continue; + } + if (matchedEntity != null) { + matchedEntity = null; + return false; + } + matchedEntity = candidate; + } + return matchedEntity != null; + } object entityListObject = Objects.TryGetValue(entityListNode.Id, out object restoredEntityList) ? restoredEntityList : ResolveFreshObject(entityListNode); @@ -8375,6 +8887,64 @@ private bool SavedEntitySourceMatches(AkronReconstructionNode entityNode, Entity EntityIdsMatch(GetEntitySourceId(candidate), savedId); } + private bool SavedFreshIdentityMatches(AkronReconstructionNode node, object candidate) { + if (candidate is Entity entity) { + return SavedEntitySourceMatches(node, entity); + } + AkronReconstructionValue ownerReference; + object candidateOwner; + if (candidate is Component component) { + ownerReference = FindReferenceField(node, "k__BackingField"); + candidateOwner = GetComponentEntity(component); + if (candidateOwner == null && ownerReference != null && + IsComponentTypeSafeToReconstruct(component.GetType()) && + TryGetComponentOwnerNodes(node, out AkronReconstructionNode savedList, out int entityId) && + IsSavedComponentListMember(node, savedList) && + nodes.TryGetValue(entityId, out AkronReconstructionNode entityNode) && + ResolveFreshAliasParent(entityNode) is Entity freshEntity && + SavedEntitySourceMatches(entityNode, freshEntity) && + savedFieldAliases.TryGetValue(node.Id, out var componentAliases)) { + // Components may be detached in the fresh room and attached + // at capture. Their entity's exact typed field identifies + // them even when another same-type component occupies the + // old list index. + foreach ((AkronReconstructionNode parent, AkronReconstructionField alias) in componentAliases) { + if (parent.Id != entityId) { + continue; + } + FieldInfo field = ResolveField(alias.DeclaringTypeName, alias.Name, alias.Path); + if (field.FieldType == component.GetType() && + field.DeclaringType.IsInstanceOfType(freshEntity) && + ReferenceEquals(field.GetValue(freshEntity), component)) { + return true; + } + } + } + } else if (IsCompilerGeneratedIterator(candidate.GetType())) { + ownerReference = FindReferenceField(node, "<>4__this"); + candidateOwner = candidate.GetType().GetField( + "<>4__this", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?.GetValue(candidate); + } else { + return true; + } + if (ownerReference == null) { + return true; + } + if (Objects.TryGetValue(ownerReference.NodeId, out object resolvedOwner)) { + return ReferenceEquals(resolvedOwner, candidateOwner); + } + if (candidateOwner == null || + !nodes.TryGetValue(ownerReference.NodeId, out AkronReconstructionNode ownerNode) || + candidateOwner.GetType() != ResolveType(ownerNode.TypeName, ownerNode.Path)) { + return false; + } + // Before the owner node is visited, Entity identity (also behind a + // captured Component) can still disprove a sibling's fresh object. + return candidateOwner is not Entity && candidateOwner is not Component || + SavedFreshIdentityMatches(ownerNode, candidateOwner); + } + private bool TryGetSavedEntityId(AkronReconstructionNode entityNode, out EntityID savedId) { savedId = default; AkronReconstructionValue sourceIdReference = FindReferenceField( @@ -8391,13 +8961,13 @@ private bool TryGetSavedEntityId(AkronReconstructionNode entityNode, out EntityI if (levelField == null || idField == null) { return false; } - string room = (string) DecodeScalar(levelField.Value, levelField.Path); + string room = (string)DecodeScalar(levelField.Value, levelField.Path); if (string.IsNullOrEmpty(room)) { return false; } savedId = new EntityID { Level = room, - ID = (int) DecodeScalar(idField.Value, idField.Path) + ID = (int)DecodeScalar(idField.Value, idField.Path) }; return true; } @@ -8502,10 +9072,10 @@ private bool IsStructurallyAuthenticDelegateTarget(AkronReconstructionNode targe out _) || freshStructuralDelegateCalls.Contains( StructuralDelegateCallKey(GetDocumentStructuralPath(delegateNode), targetType, method)) || - IsAuthenticatedBuiltInOwnedPureDelegateClosure(targetNode, targetType, delegateNode, method); + IsAuthenticatedOwnedDelegateClosure(targetNode, targetType, delegateNode, method); } - private bool IsAuthenticatedBuiltInOwnedPureDelegateClosure( + private bool IsAuthenticatedOwnedDelegateClosure( AkronReconstructionNode targetNode, Type targetType, AkronReconstructionNode delegateNode, @@ -8514,9 +9084,7 @@ MethodInfo method FieldInfo[] capturedFields = GetInstanceFields(targetType).ToArray(); bool compilerSingleton = targetType.Name == "<>c" && capturedFields.Length == 0; if (!targetType.IsClass || !targetType.IsSealed || - targetType.Assembly != typeof(Ease).Assembly || targetType.DeclaringType is not Type declaringType || - !declaringType.IsAbstract || !declaringType.IsSealed || !targetType.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) || typeof(IDisposable).IsAssignableFrom(targetType) || targetType.GetMethod( @@ -8534,10 +9102,10 @@ targetType.DeclaringType is not Type declaringType || authenticatedOwnedNestedStateNodes.Contains(ownerNode.Id) || authenticatedOwnedComponentNodes.Contains(ownerNode.Id) || authenticatedDelegateTargetNodes.Contains(ownerNode.Id) || - IsAuthenticatedFreshEntityOwnedNestedState(ownerNode, ownerType) || - IsAuthenticatedFreshRendererOwnedRuntimeState(ownerNode, ownerType) || + IsAuthenticatedOwnedManagedState(ownerNode, ownerType) || IsAuthenticatedRuntimeEntityOwnedState(ownerNode, ownerType) || - IsAuthenticatedReconstructedOwnedComponent(ownerNode, ownerType); + IsAuthenticatedReconstructedOwnedComponent(ownerNode, ownerType) || + TryGetAuthenticatedComponentEntity(ownerNode, ownerType, out _); if (!authenticatedOwner) { return false; } @@ -8547,10 +9115,103 @@ targetType.DeclaringType is not Type declaringType || delegateNode.ParentFieldName, delegateNode.Path); Type delegateType = ResolveType(delegateNode.TypeName, delegateNode.Path); - return delegateField.DeclaringType.IsAssignableFrom(ownerType) && - delegateField.FieldType == delegateType && - capturedFields.All(field => - typeof(Delegate).IsAssignableFrom(field.FieldType) || IsScalarType(field.FieldType)); + if (!delegateField.DeclaringType.IsAssignableFrom(ownerType) || + delegateField.FieldType != delegateType) { + return false; + } + if (targetType.Assembly == typeof(Ease).Assembly && + declaringType.IsAbstract && declaringType.IsSealed && + capturedFields.All(field => + typeof(Delegate).IsAssignableFrom(field.FieldType) || IsScalarType(field.FieldType))) { + return true; + } + + // A component constructor can wrap one of its entity's other + // components in a callback. The closure's lexical owner and every + // captured component must agree with the delegate field's owner. + if (!typeof(Component).IsAssignableFrom(ownerType) || + !declaringType.IsAssignableFrom(ownerType) || + !method.Name.StartsWith("<.ctor>", StringComparison.Ordinal) || + !TryGetAuthenticatedComponentEntity(ownerNode, ownerType, out int entityId)) { + return false; + } + foreach (FieldInfo field in capturedFields) { + if (IsScalarType(field.FieldType)) { + continue; + } + if (!typeof(Component).IsAssignableFrom(field.FieldType)) { + return false; + } + AkronReconstructionValue captured = targetNode.FieldsOrNull? + .FirstOrDefault(candidate => + candidate.Name == field.Name && + candidate.DeclaringTypeName == TypeName(field.DeclaringType))?.Value; + if (captured?.Kind == NullValueKind) { + continue; + } + if (captured?.Kind != ReferenceValueKind || + !nodes.TryGetValue(captured.NodeId, out AkronReconstructionNode component) || + !field.FieldType.IsAssignableFrom(ResolveType(component.TypeName, component.Path)) || + !TryGetComponentOwnerNodes(component, out AkronReconstructionNode capturedList, out int capturedEntityId) || + capturedEntityId != entityId || !IsSavedComponentListMember(component, capturedList)) { + return false; + } + } + return true; + } + + private bool TryGetAuthenticatedComponentEntity( + AkronReconstructionNode component, + Type componentType, + out int entityId + ) { + entityId = 0; + if (!IsComponentTypeSafeToReconstruct(componentType)) { + return false; + } + AkronReconstructionNode entity; + if (TryGetComponentOwnerNodes(component, out AkronReconstructionNode list, out entityId)) { + if (!IsSavedComponentListMember(component, list) || + !nodes.TryGetValue(entityId, out entity)) { + return false; + } + } else { + // Removed components can remain in their entity's concrete + // fields. A null Entity link is detached, not a licence to + // ignore an attached component's contradictory ownership. + AkronReconstructionValue attachedOwner = component.FieldsOrNull?.FirstOrDefault(field => + field.Name == "k__BackingField" && + field.DeclaringTypeName == TypeName(typeof(Component)))?.Value; + if (attachedOwner?.Kind != NullValueKind || + !savedFieldAliases.TryGetValue(component.Id, out var aliases)) { + return false; + } + // A constructor callback may encounter the detached component + // before its owning entity field. Capture order is not ownership: + // inspect every exact typed field, rejecting competing owners. + entity = null; + foreach ((AkronReconstructionNode parent, AkronReconstructionField savedField) in aliases) { + Type parentType = ResolveType(parent.TypeName, parent.Path); + if (!typeof(Entity).IsAssignableFrom(parentType)) { + continue; + } + FieldInfo field = ResolveField(savedField.DeclaringTypeName, savedField.Name, savedField.Path); + if (field.FieldType != componentType || !field.DeclaringType.IsAssignableFrom(parentType) || + !HasAuthenticatedEntityListSceneOwnership(parent, out _)) { + continue; + } + if (entity != null && entity.Id != parent.Id) { + return false; + } + entity = parent; + } + if (entity == null) { + return false; + } + entityId = entity.Id; + } + return typeof(Entity).IsAssignableFrom(ResolveType(entity.TypeName, entity.Path)) && + HasAuthenticatedEntityListSceneOwnership(entity, out _); } private bool TryGetAuthenticFreshDelegateCall( @@ -8625,6 +9286,12 @@ private static bool IsExplicitlySafeReconstructionType(Type type) { if (type.IsArray || type.IsValueType || type == typeof(object)) { return true; } + // Module sessions can retain map records from rooms the clean load + // has not visited. These exact parser records contain data, not + // runtime entities; their referenced values still validate normally. + if (type == typeof(EntityData) || type == typeof(LevelData)) { + return true; + } // MTexture is a mutable crop/draw wrapper, not the GPU resource. // Rebuild the wrapper and its aliases from saved fields while its // VirtualTexture child remains a separately authenticated anchor. @@ -9048,7 +9715,7 @@ public void ValidateAssignments() { object target = Objects[node.Id]; if (node.Kind == ArrayKind) { - ValidateArrayAssignments(node, (Array) target); + ValidateArrayAssignments(node, (Array)target); continue; } @@ -9096,15 +9763,15 @@ private void ValidateArrayAssignments(AkronReconstructionNode node, Array target return; } IReadOnlyList items = - node.ItemsOrNull ?? (IReadOnlyList) Array.Empty(); + node.ItemsOrNull ?? (IReadOnlyList)Array.Empty(); int[] stateTargetSlots = RestoredStateSlotTargets(node); if (stateTargetSlots == null) { if (target.LongLength != items.Count) { throw new AkronReconstructionException(node.Path, "array item count differs"); } } else if (target.Rank != 1 || target.GetLowerBound(0) != 0 || - stateTargetSlots.Length != items.Count || - stateTargetSlots.Any(slot => slot < 0 || slot >= target.Length)) { + stateTargetSlots.Length != items.Count || + stateTargetSlots.Any(slot => slot < 0 || slot >= target.Length)) { throw new AkronReconstructionException(node.Path, "state slot array shape differs"); } @@ -9486,7 +10153,14 @@ restoredValue is EventInstance restoredEvent && } private object ResolveFreshPath(IEnumerable path, string errorPath) { - object current = freshRoot; + return ResolveFreshPathFrom(freshRoot, path, errorPath); + } + + private object ResolveFreshPathFrom( + object current, + IEnumerable path, + string errorPath + ) { foreach (AkronReconstructionPathStep step in path ?? Enumerable.Empty()) { if (current == null) { return null; @@ -9518,14 +10192,14 @@ private object ResolveFreshObject(AkronReconstructionNode node) { } switch (node.ParentKind) { case "field": { - FieldInfo field = ResolveField( - node.ParentDeclaringTypeName, - node.ParentFieldName, - node.Path); - return field.DeclaringType.IsInstanceOfType(parent) - ? field.GetValue(parent) - : null; - } + FieldInfo field = ResolveField( + node.ParentDeclaringTypeName, + node.ParentFieldName, + node.Path); + return field.DeclaringType.IsInstanceOfType(parent) + ? field.GetValue(parent) + : null; + } case "array": return parent is Array array && HasArrayIndex(array, node.ParentArrayIndicesOrNull) ? array.GetValue(node.ParentArrayIndicesOrNull.ToArray()) @@ -9616,7 +10290,7 @@ IEnumerable path private object CreateDelegate(AkronReconstructionNode node) { Type delegateType = ResolveType(node.TypeName, node.Path); IReadOnlyList calls = - node.DelegateCallsOrNull ?? (IReadOnlyList) Array.Empty(); + node.DelegateCallsOrNull ?? (IReadOnlyList)Array.Empty(); Delegate combined = null; for (int index = 0; index < calls.Count; index++) { AkronReconstructionDelegateCall call = calls[index]; @@ -9654,11 +10328,10 @@ private object CreateDelegate(AkronReconstructionNode node) { method); } if (!authentic && target != null) { - authentic = IsAuthenticatedBuiltInOwnedPureDelegateCall( - node, - call, - target, - method); + authentic = IsAuthenticatedOwnedDelegateCall(node, + call, + target, + method); } if (!authentic && target != null) { authentic = IsAuthenticatedDirectIteratorClosureDelegateCall(node, call, target, method); @@ -9689,7 +10362,7 @@ private object CreateDelegate(AkronReconstructionNode node) { return combined; } - private bool IsAuthenticatedBuiltInOwnedPureDelegateCall( + private bool IsAuthenticatedOwnedDelegateCall( AkronReconstructionNode delegateNode, AkronReconstructionDelegateCall call, object targetObject, @@ -9702,7 +10375,7 @@ MethodInfo method !ReferenceEquals(restoredTarget, targetObject)) { return false; } - return IsAuthenticatedBuiltInOwnedPureDelegateClosure( + return IsAuthenticatedOwnedDelegateClosure( targetNode, targetObject.GetType(), delegateNode, @@ -9828,7 +10501,7 @@ private static Array CreateArray(Type arrayType, AkronReconstructionNode node, s for (int dimension = 0; dimension < node.ArrayLengthsOrNull.Count; dimension++) { int length = node.ArrayLengthsOrNull[dimension]; int lowerBound = node.ArrayLowerBoundsOrNull[dimension]; - long upperBound = (long) lowerBound + length - 1L; + long upperBound = (long)lowerBound + length - 1L; if (length < 0 || length > 0 && (upperBound < int.MinValue || upperBound > int.MaxValue)) { throw new AkronReconstructionException(path, "array bounds are invalid"); @@ -9896,7 +10569,7 @@ private bool CanReuseFreshArray(Array array, AkronReconstructionNode node) { return true; } IReadOnlyList items = - node.ItemsOrNull ?? (IReadOnlyList) Array.Empty(); + node.ItemsOrNull ?? (IReadOnlyList)Array.Empty(); if (array.Rank != 1 || array.GetLowerBound(0) != 0 || array.LongLength <= items.Count) { return false; } @@ -9963,13 +10636,13 @@ public void Verify() { throw new AkronReconstructionException(node.Path, "persistent resource state differs"); } } else if (node.Kind == DelegateKind) { - VerifyDelegate(node, (Delegate) current); + VerifyDelegate(node, (Delegate)current); } else if (node.Kind == EventInstanceKind) { - VerifyEventInstance(node, (EventInstance) current); + VerifyEventInstance(node, (EventInstance)current); } else if (node.Kind == WeakReferenceKind) { VerifyWeakReference(node, current); } else if (node.Kind == ArrayKind) { - VerifyArray(node, (Array) current); + VerifyArray(node, (Array)current); } else { VerifyObject(node, current); } @@ -10131,7 +10804,7 @@ private void VerifyArray(AkronReconstructionNode node, Array current) { return; } IReadOnlyList items = - node.ItemsOrNull ?? (IReadOnlyList) Array.Empty(); + node.ItemsOrNull ?? (IReadOnlyList)Array.Empty(); int[] stateTargetSlots = null; stateSlotPermutation?.ByArray.TryGetValue(node.Id, out stateTargetSlots); if (stateTargetSlots == null) { @@ -10139,8 +10812,8 @@ private void VerifyArray(AkronReconstructionNode node, Array current) { throw new AkronReconstructionException(node.Path, "array item count differs"); } } else if (current.Rank != 1 || current.GetLowerBound(0) != 0 || - stateTargetSlots.Length != items.Count || - stateTargetSlots.Any(slot => slot < 0 || slot >= current.Length)) { + stateTargetSlots.Length != items.Count || + stateTargetSlots.Any(slot => slot < 0 || slot >= current.Length)) { throw new AkronReconstructionException(node.Path, "state slot array shape differs"); } int[] itemIndices = GetInitialArrayIndices(current); @@ -10178,7 +10851,7 @@ private void VerifyWeakReference(AkronReconstructionNode node, object current) { private void VerifyDelegate(AkronReconstructionNode node, Delegate current) { Delegate[] calls = current.GetInvocationList(); IReadOnlyList expectedCalls = - node.DelegateCallsOrNull ?? (IReadOnlyList) Array.Empty(); + node.DelegateCallsOrNull ?? (IReadOnlyList)Array.Empty(); if (calls.Length != expectedCalls.Count) { throw new AkronReconstructionException(node.Path, "delegate invocation count differs"); } @@ -10307,7 +10980,7 @@ AkronPersistentEventInstanceState actual !EventFloatMatches(expected.UpX, actual.UpX) || !EventFloatMatches(expected.UpY, actual.UpY) || !EventFloatMatches(expected.UpZ, actual.UpZ) || expected.HasListenerMask != actual.HasListenerMask || expected.ListenerMask != actual.ListenerMask || - Math.Abs((long) expected.TimelinePosition - actual.TimelinePosition) > 1L || + Math.Abs((long)expected.TimelinePosition - actual.TimelinePosition) > 1L || expected.ShouldPlay != actual.ShouldPlay || expected.Paused != actual.Paused || expected.ManualClone != actual.ManualClone) { @@ -10367,7 +11040,7 @@ private static IEnumerable EnumerateArrayIndices(Array array) { } while (true) { - yield return (int[]) indices.Clone(); + yield return (int[])indices.Clone(); int dimension = array.Rank - 1; while (dimension >= 0) { indices[dimension]++; @@ -10544,7 +11217,7 @@ private static AkronReconstructionGraph CreateGraph() { return new AkronReconstructionGraph( IsLiveResourceType, GetLiveResourceKey, - new AkronVirtualRenderTargetResourceAdapter(), + new AkronRoomResourceAdapter(), ResolveDetachedLiveResource, areEquivalentLiveResources: AreEquivalentLiveResources, hasPortableLiveResourceKey: HasPortableLiveResourceKey, @@ -10598,6 +11271,9 @@ Func hasReproducibleAssemblyName effect, GetLoadedEverestModuleAssemblies())); } + if (resource is GraphicsResource graphicsState) { + return !string.IsNullOrEmpty(GetNamedGraphicsResourceKey(graphicsState)); + } if (resource is CompareInfo) { // A sort name. Every install derives the same one for the same // collation, and one it cannot open is a collation it does not have. @@ -10785,8 +11461,8 @@ internal static IEnumerable GetMapPlacedEntityIds(object roomRoot, string r string.Equals(level.Name, roomName ?? string.Empty, StringComparison.Ordinal)); return room == null ? Array.Empty() : GetMapPlacedEntityIds(room).ToList(); } catch (Exception exception) when (exception is ArgumentOutOfRangeException || - exception is IndexOutOfRangeException || - exception is InvalidOperationException) { + exception is IndexOutOfRangeException || + exception is InvalidOperationException) { // The three shapes a rebuild of AreaData.Areas under a reader takes, and the // reason the bounds checks in ResolveMapData are not on their own a mitigation: // they are check-then-act on a list another thread owns. ArgumentOutOfRange is @@ -10823,7 +11499,7 @@ private static MapData ResolveMapData(Session session) { return null; } ModeProperties[] modes = areas[areaId]?.Mode; - int modeIndex = (int) session.Area.Mode; + int modeIndex = (int)session.Area.Mode; if (modes == null || modeIndex < 0 || modeIndex >= modes.Length) { return null; } @@ -11127,6 +11803,13 @@ public static bool TryLoadSnapshot( } } + // The portable bundle supplies raw document bytes; both readers enforce the same + // document contract and allocation limits through the restore graph. + internal static AkronReconstructionDocument ReadPackSnapshot(Stream snapshotStream) { + using AkronBoundedReadStream bounded = new AkronBoundedReadStream(snapshotStream, MaxDecompressedSnapshotBytes); + return RestoreGraph.Deserialize(bounded); + } + public static bool TryReadSnapshot( Stream snapshotStream, out AkronReconstructionDocument document, @@ -11341,7 +12024,7 @@ public override int ReadByte() { bytesRead++; if (hash != null) { Span oneByte = stackalloc byte[1]; - oneByte[0] = (byte) value; + oneByte[0] = (byte)value; hash.AppendData(oneByte); } } @@ -11804,7 +12487,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 = "v10-"; + private const string SnapshotFileNamePrefix = "v11-"; // Internal so the snapshot-report command can glob the same files this writes. internal const string SnapshotFileNameSuffix = ".json.gz"; @@ -12021,7 +12704,7 @@ internal static bool IsLiveResourceType(Type type) { // problem this file solves for EntityList and ComponentList alone // (see ValidateAndNormalizeMembershipSet) and nowhere else yet. return type == typeof(Pathfinder) || - type == DynamicDataCacheType || + IsDynamicDataCache(type) || type == typeof(CompareInfo) || typeof(Type).IsAssignableFrom(type) || typeof(MemberInfo).IsAssignableFrom(type) || @@ -12072,6 +12755,11 @@ internal static object ResolveDetachedLiveResource(Type resourceType, string typ if (resourceKey.StartsWith(HookOwnerKeyPrefix, StringComparison.Ordinal)) { return ResolveHookOwner(resourceType, resourceKey); } + if (resourceKey.StartsWith(NamedGraphicsResourcePrefix, StringComparison.Ordinal)) { + string fieldName = resourceKey.Substring(NamedGraphicsResourcePrefix.Length); + return GetNamedGraphicsResourceFields(resourceType) + .FirstOrDefault(field => field.Name == fieldName)?.GetValue(null); + } if (typeof(Effect).IsAssignableFrom(resourceType)) { return ResolveRegisteredEffect( resourceType, @@ -12084,7 +12772,7 @@ internal static object ResolveDetachedLiveResource(Type resourceType, string typ // asset identity, so the saved texture can still be authenticated // even when the fresh entity graph did not select it. IEnumerable assets = - (IEnumerable) VirtualContentAssetsField.GetValue(null); + (IEnumerable)VirtualContentAssetsField.GetValue(null); return assets.FirstOrDefault(asset => asset?.GetType() == resourceType && string.Equals(GetLiveResourceKey(asset), resourceKey, StringComparison.Ordinal)); @@ -12121,7 +12809,7 @@ internal static object ResolveDetachedLiveResource(Type resourceType, string typ try { return CompareInfo.GetCompareInfo(resourceKey.Substring(CompareInfoSortNameKeyPrefix.Length)); } catch (Exception exception) when ( - exception is CultureNotFoundException || exception is ExternalException) { + exception is CultureNotFoundException || exception is ExternalException) { // The saved frame names a sort this install cannot open: // CultureNotFoundException when the name is unknown, and // ExternalException when the platform has the name but fails to @@ -12240,13 +12928,17 @@ internal static object RecreateDetachedLiveResource(Type resourceType, string ty private static readonly FieldInfo DynamicDataCacheMapField = typeof(MonoMod.Utils.DynamicData).GetField("_CacheMap", BindingFlags.Static | BindingFlags.NonPublic); + internal static bool IsDynamicDataCache(Type type) { + return type != null && type == DynamicDataCacheType; + } + private static string GetDynamicDataCacheKey(object cache) { if (DynamicDataCacheMapField?.GetValue(null) is not IDictionary cacheMap) { return string.Empty; } foreach (DictionaryEntry entry in cacheMap) { if (ReferenceEquals(entry.Value, cache)) { - Type target = (Type) entry.Key; + Type target = (Type)entry.Key; return target.AssemblyQualifiedName ?? target.FullName ?? string.Empty; } } @@ -12711,10 +13403,10 @@ private static IDictionary ReadEffectRegistry(FieldInfo field) { try { return field.GetValue(null) as IDictionary; } catch (Exception exception) when ( - exception is MemberAccessException || - exception is TargetInvocationException || - exception is TypeInitializationException || - IsAssemblyReflectionLoadFailure(exception)) { + exception is MemberAccessException || + exception is TargetInvocationException || + exception is TypeInitializationException || + IsAssemblyReflectionLoadFailure(exception)) { // A registry belongs to another mod. If that mod cannot expose its // already-loaded registry, it cannot authenticate this Effect. return null; @@ -12854,6 +13546,32 @@ IEnumerable moduleAssemblies : null; } + private const string NamedGraphicsResourcePrefix = "graphics-static|"; + private static readonly ConcurrentDictionary NamedGraphicsResourceFields = + new ConcurrentDictionary(); + + private static FieldInfo[] GetNamedGraphicsResourceFields(Type type) { + return NamedGraphicsResourceFields.GetOrAdd(type, candidate => + candidate.Assembly == typeof(GraphicsResource).Assembly && + typeof(GraphicsResource).IsAssignableFrom(candidate) + ? candidate.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(field => field.IsInitOnly && field.FieldType == candidate) + .OrderBy(field => field.Name, StringComparer.Ordinal) + .ToArray() + : Array.Empty()); + } + + private static string GetNamedGraphicsResourceKey(GraphicsResource resource) { + // Only name FNA's own public static instances, never arbitrary mod + // statics or native handles. Mutable BlendState descriptors persist separately. + foreach (FieldInfo field in GetNamedGraphicsResourceFields(resource.GetType())) { + if (ReferenceEquals(field.GetValue(null), resource)) { + return NamedGraphicsResourcePrefix + field.Name; + } + } + return string.Empty; + } + internal static string GetLiveResourceKey(object resource) { string hookOwnerKey = GetHookOwnerResourceKey(resource); if (!string.IsNullOrWhiteSpace(hookOwnerKey)) { @@ -12893,6 +13611,9 @@ internal static string GetLiveResourceKey(object resource) { if (resource is Effect effect) { return GetRegisteredEffectResourceKey(effect, GetLoadedEverestModuleAssemblies()); } + if (resource is GraphicsResource graphicsState) { + return GetNamedGraphicsResourceKey(graphicsState); + } if (resource is Atlas atlas && !string.IsNullOrWhiteSpace(atlas.DataPath)) { return (atlas.DataMethod ?? string.Empty) + "|" + atlas.DataPath + "|" + (atlas.RelativeDataPath ?? string.Empty) + "|" + @@ -12907,15 +13628,15 @@ internal static string GetLiveResourceKey(object resource) { VirtualTexturePathField.GetValue(texture) is string texturePath && !string.IsNullOrWhiteSpace(texturePath)) { return texturePath + "|" + - ((int) VirtualAssetWidthField.GetValue(texture)).ToString(CultureInfo.InvariantCulture) + "x" + - ((int) VirtualAssetHeightField.GetValue(texture)).ToString(CultureInfo.InvariantCulture); + ((int)VirtualAssetWidthField.GetValue(texture)).ToString(CultureInfo.InvariantCulture) + "x" + + ((int)VirtualAssetHeightField.GetValue(texture)).ToString(CultureInfo.InvariantCulture); } if (resource is VirtualAsset asset && VirtualAssetNameField.GetValue(asset) is string assetName && !string.IsNullOrWhiteSpace(assetName)) { return assetName + "|" + - ((int) VirtualAssetWidthField.GetValue(asset)).ToString(CultureInfo.InvariantCulture) + "x" + - ((int) VirtualAssetHeightField.GetValue(asset)).ToString(CultureInfo.InvariantCulture); + ((int)VirtualAssetWidthField.GetValue(asset)).ToString(CultureInfo.InvariantCulture) + "x" + + ((int)VirtualAssetHeightField.GetValue(asset)).ToString(CultureInfo.InvariantCulture); } return string.Empty; } diff --git a/Source/SaveLoad/akron-save-load-models.cs b/Source/SaveLoad/akron-save-load-models.cs index a5b0c25f..ad0ce430 100644 --- a/Source/SaveLoad/akron-save-load-models.cs +++ b/Source/SaveLoad/akron-save-load-models.cs @@ -96,6 +96,7 @@ public AkronSaveLoadSlot(string slotName, string levelName, string mapSid, bool public float GlitchValue { get; set; } public float DistortAnxiety { get; set; } public float DistortGameRate { get; set; } + internal DustStyles.DustStyle? DustStyle { get; set; } public Dictionary>> ActionState { get; } internal List GameplayBuffers { get; set; } = new List(); internal IReadOnlyDictionary PersistentRenderTargets { get; set; } = @@ -310,6 +311,7 @@ internal sealed class AkronPersistentRuntimeState { public float GlitchValue { get; set; } public float DistortAnxiety { get; set; } public float DistortGameRate { get; set; } + public DustStyles.DustStyle? DustStyle { get; set; } public Dictionary ModuleSessions { get; set; } = new Dictionary(); @@ -321,7 +323,8 @@ public static AkronPersistentRuntimeState CaptureSaved(AkronSaveLoadSlot slot) { EngineTimeRate = slot.EngineTimeRate, GlitchValue = slot.GlitchValue, DistortAnxiety = slot.DistortAnxiety, - DistortGameRate = slot.DistortGameRate + DistortGameRate = slot.DistortGameRate, + DustStyle = slot.DustStyle }; CopyNonAkronModuleState(slot.ModuleSessions, state.ModuleSessions); return state; @@ -337,7 +340,8 @@ public static AkronPersistentRuntimeState CaptureCurrent(Level level) { #pragma warning restore CS0618 GlitchValue = Glitch.Value, DistortAnxiety = Distort.Anxiety, - DistortGameRate = Distort.GameRate + DistortGameRate = Distort.GameRate, + DustStyle = CaptureDustStyle(DustStyles.Styles, level.Session.Area.ID) }; foreach (EverestModule module in Everest.Modules.Where(module => module is not AkronModule && module.GetType().Name != "NullModule")) { @@ -349,6 +353,27 @@ public static AkronPersistentRuntimeState CaptureCurrent(Level level) { return state; } + internal static DustStyles.DustStyle? CaptureDustStyle( + Dictionary styles, + int areaId + ) { + return styles.TryGetValue(areaId, out DustStyles.DustStyle style) ? style : null; + } + + internal static void RestoreDustStyle( + Dictionary styles, + int areaId, + DustStyles.DustStyle? style + ) { + // The table is process-owned; only the active area's entry belongs to the + // room. Keep the style's graph aliases intact and leave every other area alone. + if (style.HasValue) { + styles[areaId] = style.Value; + } else { + styles.Remove(areaId); + } + } + private static void CopyNonAkronModuleState( IReadOnlyDictionary source, IDictionary destination diff --git a/Source/Setups/akron-setup-packs.cs b/Source/Setups/akron-setup-packs.cs index 6fbc172f..9715f892 100644 --- a/Source/Setups/akron-setup-packs.cs +++ b/Source/Setups/akron-setup-packs.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.IO.Compression; using System.Linq; using System.Reflection; using System.Security.Cryptography; @@ -9,6 +10,8 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; using Celeste; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; @@ -26,6 +29,8 @@ public sealed class AkronSetupPack { public Dictionary MenuActionBindings { get; set; } = new Dictionary(); public Dictionary StartPositions { get; set; } = new Dictionary(); + public string SnapshotBundleSha256 { get; set; } = string.Empty; + [JsonIgnore] public string ArchiveMapSid { get; set; } = string.Empty; @@ -102,7 +107,11 @@ public static partial class AkronSetupPacks { // whether a saved resource's key names it, and, for the room half of a snapshot, // whether the map laid a saved entity's id out - so the v8 snapshots inside a v5 // pack cannot be rebuilt here either. - public const string SetupPackFormat = "akron-setup-v9"; + // v10 changes only the portable snapshot encoding to a shared Brotli bundle. + // The native snapshot format is versioned independently; export requires the current one. + public const string SetupPackFormat = "akron-setup-v10"; + private static Task exportTask; + internal static bool ExportInProgress => exportTask != null && !exportTask.IsCompleted; public const int MaxStartPositions = 99; public const int MaxAutoKillAreas = 128; @@ -135,7 +144,7 @@ public static partial class AkronSetupPacks { private const long MaxDecompressedSnapshotBytes = AkronStartPosReconstruction.MaxDecompressedSnapshotBytes; private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { - WriteIndented = true, + WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, Converters = { new JsonStringEnumConverter(namingPolicy: null, allowIntegerValues: false) } @@ -527,14 +536,35 @@ Func persistStartPosMetadata } public static string ExportCurrent(string name = "", AkronSetupSection section = AkronSetupSection.Whole) { + if (ExportInProgress) { + Engine.Scene?.Add(new AkronToast("A setup export is already running.")); + return string.Empty; + } section = NormalizeSection(section); try { AkronSetupPack pack = Capture(AkronModule.Settings, AkronModule.Session, name, section); Directory.CreateDirectory(GetSetupDirectory()); string fileName = SanitizeFileName(pack.Name) + "-" + DateTime.UtcNow.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture) + AkronArchive.Extension; string path = Path.Combine(GetSetupDirectory(), fileName); - Write(AkronModule.Settings, AkronModule.Session, path, name, section); - Engine.Scene?.Add(new AkronToast("Exported " + FormatSection(section) + " setup " + pack.Name + ".")); + // Capture live state above on the game thread. Compression only reads + // that detached pack and verified snapshot files in the background. + ValidatePortablePack(pack, pack.Section, pack.ArchiveMapSid); + exportTask = Task.Run(() => { + string message; + try { + WriteArchive(path, pack, new AkronArchiveManifest { + Kind = SetupArchiveKind, + CreatedAt = pack.CreatedUtc, + Target = new AkronArchiveTarget { MapSid = pack.ArchiveMapSid } + }); + message = "Exported " + FormatSection(section) + " setup " + pack.Name + "."; + } catch (Exception exception) { + Logger.Log(LogLevel.Warn, nameof(AkronModule), "Failed to export Akron setup archive: " + exception.Message); + message = "Could not export setup pack."; + } + MainThreadHelper.Schedule(() => Engine.Scene?.Add(new AkronToast(message))); + }); + Engine.Scene?.Add(new AkronToast("Compressing setup pack in the background...")); return path; } catch (Exception exception) when (exception is InvalidDataException || exception is AkronSetupPackFormatException || exception is IOException || exception is UnauthorizedAccessException) { Logger.Log(LogLevel.Warn, nameof(AkronModule), "Failed to export Akron setup archive: " + exception.Message); @@ -649,42 +679,46 @@ internal static string SerializePackPayloadForArchive(AkronSetupPack pack) { throw new InvalidDataException("Setup archive payload is too large."); } - internal static void WriteArchive(string path, AkronSetupPack pack, AkronArchiveManifest manifest) { - HashSet expectedEntries = GetExpectedSnapshotEntries(pack); - Dictionary attachments = new Dictionary(StringComparer.Ordinal); - long attachmentBytes = 0; - foreach (KeyValuePair pair in pack.StartPositions ?? new Dictionary()) { + internal static void WriteArchive(string path, AkronSetupPack pack, AkronArchiveManifest manifest, CancellationToken cancellationToken = default) { + GetExpectedSnapshotEntries(pack); + var sources = new List(); + long sourceBytes = 0; + foreach (KeyValuePair pair in pack.StartPositions) { if (!pack.SnapshotSourcePaths.TryGetValue(pair.Key, out string snapshotPath) || !File.Exists(snapshotPath)) { throw new InvalidDataException("StartPos slot " + pair.Key.ToString(CultureInfo.InvariantCulture) + " has no exact snapshot to export."); } - long snapshotBytes = new FileInfo(snapshotPath).Length; - if (snapshotBytes > MaxSnapshotAttachmentBytes) { - throw new InvalidDataException( - "StartPos slot " + pair.Key.ToString(CultureInfo.InvariantCulture) + " snapshot is too large to export."); + long length = new FileInfo(snapshotPath).Length; + if (length > MaxSnapshotAttachmentBytes) { + throw new InvalidDataException("StartPos slot " + pair.Key.ToString(CultureInfo.InvariantCulture) + " snapshot is too large to export."); } - if (attachmentBytes > MaxSnapshotAttachmentsBytes - snapshotBytes) { + sourceBytes += length; + if (sourceBytes > MaxSnapshotAttachmentsBytes) { throw new InvalidDataException("StartPos snapshot attachments are too large to export."); } - attachmentBytes += snapshotBytes; - attachments[pair.Value.SnapshotEntry] = snapshotPath; - } - if (!expectedEntries.SetEquals(attachments.Keys)) { - throw new InvalidDataException("StartPos snapshot attachments are incomplete."); + sources.Add(new AkronSnapshotBundle.Source(pair.Key, snapshotPath, pair.Value.SnapshotSha256)); } - foreach (KeyValuePair pair in pack.StartPositions ?? new Dictionary()) { - string snapshotPath = attachments[pair.Value.SnapshotEntry]; - if (!string.Equals(ComputeFileSha256(snapshotPath), pair.Value.SnapshotSha256, StringComparison.Ordinal)) { - throw new InvalidDataException("StartPos slot " + pair.Key.ToString(CultureInfo.InvariantCulture) + " changed during export."); + // Captured checksums identify local gzip files. Only the exported clone gets + // raw-document hashes and the compressed bundle hash, so capture is reusable. + AkronSetupPack exportedPack = JsonSerializer.Deserialize(JsonSerializer.Serialize(pack, JsonOptions), JsonOptions); + var attachments = new Dictionary(StringComparer.Ordinal); + string stagingDirectory = Path.Combine(Path.GetTempPath(), "akron-pack-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(stagingDirectory); + try { + if (sources.Count > 0) { + string bundlePath = Path.Combine(stagingDirectory, "snapshots.bin.br"); + Dictionary hashes = AkronSnapshotBundle.Write(bundlePath, sources, cancellationToken); + foreach (KeyValuePair pair in hashes) { + exportedPack.StartPositions[pair.Key].SnapshotSha256 = pair.Value; + } + exportedPack.SnapshotBundleSha256 = ComputeFileSha256(bundlePath); + attachments.Add(AkronSnapshotBundle.EntryName, bundlePath); } + cancellationToken.ThrowIfCancellationRequested(); + AkronArchive.WritePayloadArchive(path, manifest, SetupArchivePayload, SerializePackPayloadForArchive(exportedPack), attachments); + } finally { + Directory.Delete(stagingDirectory, recursive: true); } - - AkronArchive.WritePayloadArchive( - path, - manifest, - SetupArchivePayload, - SerializePackPayloadForArchive(pack), - attachments); } public static AkronSetupPack Read(string path) { @@ -715,6 +749,11 @@ internal static AkronSetupPack Read(string path, out AkronArchiveManifest manife if (!expectedAttachments.SetEquals(attachmentNames)) { throw new InvalidDataException("Setup pack snapshot entries do not match its StartPos data."); } + if (expectedAttachments.Count == 0 ? pack.SnapshotBundleSha256 != string.Empty : + pack.SnapshotBundleSha256?.Length != 64 || !pack.SnapshotBundleSha256.All(Uri.IsHexDigit) || + pack.SnapshotBundleSha256 != pack.SnapshotBundleSha256.ToLowerInvariant()) { + throw new InvalidDataException("Setup pack has an invalid snapshot bundle checksum."); + } pack.ArchiveMapSid = manifest.Target.MapSid; pack.ArchivePath = path; return pack; @@ -728,20 +767,20 @@ private static HashSet GetExpectedSnapshotEntries(AkronSetupPack pack) { foreach (KeyValuePair pair in pack.StartPositions ?? new Dictionary()) { AkronStartPosPackEntry entry = pair.Value ?? throw new InvalidDataException("Setup pack has an invalid StartPos entry."); - string expectedName = GetSnapshotEntryName(pair.Key); + string expectedName = AkronSnapshotBundle.EntryName; if (!string.Equals(entry.SnapshotEntry, expectedName, StringComparison.Ordinal) || entry.SnapshotSha256?.Length != 64 || !entry.SnapshotSha256.All(Uri.IsHexDigit) || - !string.Equals(entry.SnapshotSha256, entry.SnapshotSha256.ToLowerInvariant(), StringComparison.Ordinal) || - !entries.Add(entry.SnapshotEntry)) { + !string.Equals(entry.SnapshotSha256, entry.SnapshotSha256.ToLowerInvariant(), StringComparison.Ordinal)) { throw new InvalidDataException("Setup pack has invalid StartPos snapshot metadata."); } + entries.Add(entry.SnapshotEntry); } return entries; } private static PreparedStartPosImport PrepareStartPosImport(AkronSetupPack pack, string targetMapSid) { - if (pack == null || string.IsNullOrWhiteSpace(pack.ArchivePath)) { + if (pack == null || string.IsNullOrWhiteSpace(pack.ArchivePath) || pack.StartPositions.Count == 0) { return null; } @@ -755,21 +794,21 @@ private static PreparedStartPosImport PrepareStartPosImport(AkronSetupPack pack, ? AkronBerryProgressSnapshot.Capture(recipientLevel) : null; try { - foreach (KeyValuePair pair in pack.StartPositions.OrderBy(pair => pair.Key)) { - AkronStartPosPackEntry entry = pair.Value; - byte[] compressedSnapshot = AkronArchive.ReadBinaryEntry(pack.ArchivePath, entry.SnapshotEntry, MaxSnapshotAttachmentBytes); - string digest = Convert.ToHexString(SHA256.HashData(compressedSnapshot)).ToLowerInvariant(); - if (!string.Equals(digest, entry.SnapshotSha256, StringComparison.Ordinal)) { - throw new InvalidDataException("StartPos slot " + pair.Key.ToString(CultureInfo.InvariantCulture) + " snapshot checksum differs."); - } - - using MemoryStream snapshotStream = new MemoryStream(compressedSnapshot, writable: false); - if (!AkronStartPosReconstruction.TryReadSnapshot( - snapshotStream, - out AkronReconstructionDocument document, - out string readError, - MaxDecompressedSnapshotBytes)) { - throw new InvalidDataException("StartPos slot " + pair.Key.ToString(CultureInfo.InvariantCulture) + " snapshot is invalid: " + readError); + using ZipArchive archive = ZipFile.OpenRead(pack.ArchivePath); + ZipArchiveEntry bundle = archive.GetEntry(AkronSnapshotBundle.EntryName) + ?? throw new InvalidDataException("Setup pack snapshot bundle is missing."); + if (bundle.Length > MaxSnapshotAttachmentsBytes) throw new InvalidDataException("Snapshot bundle is too large."); + using Stream encoded = bundle.Open(); + using SHA256 bundleHash = SHA256.Create(); + using var checkedStream = new CryptoStream(encoded, bundleHash, CryptoStreamMode.Read); + Dictionary hashes = AkronSnapshotBundle.Read(checkedStream, (slot, snapshotStream) => { + if (!pack.StartPositions.TryGetValue(slot, out AkronStartPosPackEntry entry)) + throw new InvalidDataException("Snapshot bundle contains an unexpected slot."); + AkronReconstructionDocument document; + try { + document = AkronStartPosReconstruction.ReadPackSnapshot(snapshotStream); + } catch (Exception exception) { + throw new InvalidDataException("StartPos slot " + slot.ToString(CultureInfo.InvariantCulture) + " snapshot is invalid: " + exception.Message, exception); } if (!string.Equals(document.MapSid, targetMapSid, StringComparison.Ordinal) || !string.Equals(document.Room, entry.Room, StringComparison.Ordinal)) { @@ -777,7 +816,7 @@ private static PreparedStartPosImport PrepareStartPosImport(AkronSetupPack pack, } document.BerryProgress = recipientBerryProgress; - string targetSlotName = AkronActions.GetStartPosStateSlotName(targetMapSid, pair.Key); + string targetSlotName = AkronActions.GetStartPosStateSlotName(targetMapSid, slot); int recipientFileSlot = SaveData.Instance?.FileSlot ?? -1; if (!AkronStartPosReconstruction.SaveSnapshot( targetSlotName, @@ -787,11 +826,22 @@ private static PreparedStartPosImport PrepareStartPosImport(AkronSetupPack pack, document, out string saveError, stagingDirectory)) { - throw new InvalidDataException("Could not stage StartPos slot " + pair.Key.ToString(CultureInfo.InvariantCulture) + ": " + saveError); + throw new InvalidDataException("Could not stage StartPos slot " + slot.ToString(CultureInfo.InvariantCulture) + ": " + saveError); } - stagedSnapshots[pair.Key] = AkronStartPosReconstruction.GetSnapshotPath(targetSlotName, stagingDirectory); + stagedSnapshots[slot] = AkronStartPosReconstruction.GetSnapshotPath(targetSlotName, stagingDirectory); + }); + if (!Convert.ToHexString(bundleHash.Hash).Equals(pack.SnapshotBundleSha256, StringComparison.OrdinalIgnoreCase) || + hashes.Count != pack.StartPositions.Count) { + throw new InvalidDataException("Snapshot bundle does not match its pack metadata."); + } + foreach (KeyValuePair pair in hashes) { + if (!string.Equals(pair.Value, pack.StartPositions[pair.Key].SnapshotSha256, StringComparison.Ordinal)) + throw new InvalidDataException("StartPos snapshot checksum differs."); } return new PreparedStartPosImport(stagingDirectory, targetMapSid, stagedSnapshots); + } catch (EndOfStreamException exception) { + Directory.Delete(stagingDirectory, recursive: true); + throw new InvalidDataException("StartPos snapshot bundle is truncated.", exception); } catch { Directory.Delete(stagingDirectory, recursive: true); throw; @@ -811,7 +861,7 @@ private static void RequireCurrentPackFormat(AkronSetupPack pack) { throw new AkronSetupPackFormatException( "This setup pack is " + DescribePackFormat(pack) + " and Akron now reads " + SetupPackFormat + - ". Packs from an older Akron built rooms differently. Recreate the setup and its StartPos slots in this build, then export a new pack."); + ". Export a new pack with this build. Recreate older StartPos slots only if this build can no longer load them."); } // The format string comes out of a pack payload, which is allowed to be 2 MiB, so it @@ -829,12 +879,6 @@ private static string DescribePackFormat(AkronSetupPack pack) { private const int MaxReportedPackFormatChars = 32; - // Tracks AkronReconstructionDocument.CurrentFormat, so the entry name states which - // fresh-room baseline the attachment was measured against. - private static string GetSnapshotEntryName(int slot) { - return "startpos/" + slot.ToString(CultureInfo.InvariantCulture) + ".v10.json.gz"; - } - private static string ComputeFileSha256(string path) { using FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); @@ -1133,6 +1177,7 @@ private static string SerializePortablePack(AkronSetupPack pack) { if (pack.Section is not AkronSetupSection.StartPos and not AkronSetupSection.Whole) { root.Remove("startPositions"); + root.Remove("snapshotBundleSha256"); } return root.ToJsonString(JsonOptions); @@ -1158,6 +1203,7 @@ private static void ValidatePortablePackJson(string payload) { } if (section is AkronSetupSection.StartPos or AkronSetupSection.Whole) { expectedTopLevel.Add("startPositions"); + expectedTopLevel.Add("snapshotBundleSha256"); } RequireExactJsonProperties(root, expectedTopLevel, "Setup pack"); @@ -1679,7 +1725,7 @@ out Dictionary snapshotSourcePaths } string snapshotPath = AkronStartPosReconstruction.GetSnapshotPath(pair.Value.StateSlotName); - string snapshotEntry = GetSnapshotEntryName(pair.Key); + string snapshotEntry = AkronSnapshotBundle.EntryName; bool hasSnapshot = !string.IsNullOrWhiteSpace(pair.Value.StateSlotName) && File.Exists(snapshotPath); entries[pair.Key] = new AkronStartPosPackEntry { X = pair.Value.Position.X, diff --git a/Source/vendor/deepcloner/Helpers/ClonerToExprGenerator.cs b/Source/vendor/deepcloner/Helpers/ClonerToExprGenerator.cs index 7a26e695..0471f062 100644 --- a/Source/vendor/deepcloner/Helpers/ClonerToExprGenerator.cs +++ b/Source/vendor/deepcloner/Helpers/ClonerToExprGenerator.cs @@ -61,33 +61,25 @@ private static object GenerateProcessMethod(Type type, bool isDeepClone) { } while (tp != null); foreach (FieldInfo fieldInfo in fi) { + Expression value = Expression.Field(fromLocal, fieldInfo); if (isDeepClone && !DeepClonerSafeTypes.CanReturnSameObject(fieldInfo.FieldType)) { MethodInfo methodInfo = fieldInfo.FieldType.IsValueType() ? typeof(DeepClonerGenerator).GetPrivateStaticMethod("CloneStructInternal") .MakeGenericMethod(fieldInfo.FieldType) : typeof(DeepClonerGenerator).GetPrivateStaticMethod("CloneClassInternal"); - MemberExpression get = Expression.Field(fromLocal, fieldInfo); - - // toLocal.Field = Clone...Internal(fromLocal.Field) - Expression call = (Expression)Expression.Call(methodInfo, get, state); + value = Expression.Call(methodInfo, value, state); if (!fieldInfo.FieldType.IsValueType()) { - call = Expression.Convert(call, fieldInfo.FieldType); + value = Expression.Convert(value, fieldInfo.FieldType); } + } - // should handle specially - // todo: think about optimization, but it rare case - if (fieldInfo.IsInitOnly) { - // var setMethod = fieldInfo.GetType().GetMethod("SetValue", new[] { typeof(object), typeof(object) }); - // expressionList.Add(Expression.Call(Expression.Constant(fieldInfo), setMethod, toLocal, call)); - MethodInfo setMethod = typeof(DeepClonerExprGenerator).GetPrivateStaticMethod("ForceSetField"); - expressionList.Add(Expression.Call(setMethod, Expression.Constant(fieldInfo), - Expression.Convert(toLocal, typeof(object)), Expression.Convert(call, typeof(object)))); - } else { - expressionList.Add(Expression.Assign(Expression.Field(toLocal, fieldInfo), call)); - } + if (fieldInfo.IsInitOnly) { + MethodInfo setMethod = typeof(DeepClonerExprGenerator).GetPrivateStaticMethod("ForceSetField"); + expressionList.Add(Expression.Call(setMethod, Expression.Constant(fieldInfo), + Expression.Convert(toLocal, typeof(object)), Expression.Convert(value, typeof(object)))); } else { - expressionList.Add(Expression.Assign(Expression.Field(toLocal, fieldInfo), Expression.Field(fromLocal, fieldInfo))); + expressionList.Add(Expression.Assign(Expression.Field(toLocal, fieldInfo), value)); } } diff --git a/Source/vendor/deepcloner/Helpers/DeepClonerExprGenerator.cs b/Source/vendor/deepcloner/Helpers/DeepClonerExprGenerator.cs index 5cfeda1b..b9e003ab 100644 --- a/Source/vendor/deepcloner/Helpers/DeepClonerExprGenerator.cs +++ b/Source/vendor/deepcloner/Helpers/DeepClonerExprGenerator.cs @@ -13,39 +13,11 @@ internal static object GenerateClonerInternal(Type realType, bool asObject) { return GenerateProcessMethod(realType, asObject && realType.IsValueType()); } - private static FieldInfo _attributesFieldInfo = typeof(FieldInfo).GetPrivateField("m_fieldAttributes"); - - // slow, but hardcore method to set readonly field + // Instance readonly fields can be set through reflection on the + // supported runtime. Do not patch shared FieldInfo metadata or silently + // keep the source reference when an assignment fails. internal static void ForceSetField(FieldInfo field, object obj, object value) { - FieldInfo fieldInfo = field.GetType().GetPrivateField("m_fieldAttributes"); - - // TODO: think about it - // nothing to do :( we should a throw an exception, but it is no good for user - if (fieldInfo == null) { - return; - } - - object ov = fieldInfo.GetValue(field); - if (!(ov is FieldAttributes)) { - return; - } - - FieldAttributes v = (FieldAttributes)ov; - - // protect from parallel execution, when first thread set field readonly back, and second set it to write value - lock (fieldInfo) { - try { - fieldInfo.SetValue(field, v & ~FieldAttributes.InitOnly); - field.SetValue(obj, value); - } catch (FieldAccessException) { - // Some modded Celeste runtimes reject writes to initonly - // fields even after the FieldInfo attributes are patched. - // MemberwiseClone already copied the field value, so keeping - // that original reference is safer than crashing a restore. - } finally { - fieldInfo.SetValue(field, v | FieldAttributes.InitOnly); - } - } + field.SetValue(obj, value); } private static object GenerateProcessMethod(Type type, bool unboxStruct) { diff --git a/docs/concepts/akr-files-and-setup-state.mdx b/docs/concepts/akr-files-and-setup-state.mdx index 96b78a36..0998ba5e 100644 --- a/docs/concepts/akr-files-and-setup-state.mdx +++ b/docs/concepts/akr-files-and-setup-state.mdx @@ -22,13 +22,13 @@ The overlay shows the current attempt status and tool state. It can export the w ## .akr files -An `.akr` file is a strict ZIP archive with a manifest and one main payload. Current setup packs use a `setup.json` payload. StartPos and Whole packs also contain one compressed v10 snapshot entry per saved StartPos. +An `.akr` file is a strict ZIP archive with a manifest and one main payload. Current setup packs use a `setup.json` payload. StartPos and Whole packs with saved slots also contain one compressed bundle holding their v11 snapshots. ```mermaid flowchart TD archive["example.akr"] --> manifest["manifest.json"] archive --> payload["setup.json"] - archive --> snapshots["startpos/*.v10.json.gz"] + archive --> snapshots["startpos/snapshots.bin.br"] manifest --> target["Format and target"] payload --> settings["Setup data"] snapshots --> roomState["Exact StartPos room state"] @@ -54,8 +54,9 @@ also replace every slot for the pack's target map while preserving slots for other maps. Setup packs keep machine-local paths, device choices, recording colorspace arguments, and the Auto Deafen hotkey unchanged. -Setup packs created before `akron-setup-v9` cannot be imported. Recreate the -setup and its StartPos slots in the current build, then export a new pack. +Current packs use `akron-setup-v10` and current StartPos snapshots use +`akron-reconstruction-v11`. Older packs or snapshots cannot be imported. +Set older StartPos slots again in the current build, then export a new pack. ## Sharing guidance diff --git a/docs/feature-guide/startpos.mdx b/docs/feature-guide/startpos.mdx index 77ab2540..8900996a 100644 --- a/docs/feature-guide/startpos.mdx +++ b/docs/feature-guide/startpos.mdx @@ -19,7 +19,7 @@ The StartPos tab is Akron's player position and room-state restore workflow. The A StartPos is an exact gameplay-state snapshot. Loading it must restore every captured entity, component, reference, position, speed, timer, state machine, animation, coroutine, random-number state, and registered helper state to the -captured frame. Akron stores this state in a local v10 snapshot file so it +captured frame. Akron stores this state in a local v11 snapshot file so it survives closing and restarting Celeste. Set and Load hold the room state until Celeste renders the restored frame. @@ -30,6 +30,23 @@ Captured coroutine state includes removed components still held by a running routine, such as a CrushBlock's sound after its delayed removal. Those components restore as detached objects, without adding them back to the room. +Reconstruction follows captured object ownership and identity, including typed +managed grids, records, pooled effects, helper-owned UI, backdrop particles, +and removed objects still referenced by their owners. Running coroutines keep +references to their owning room. A helper does not need a map-specific exception +for these shapes. Process-owned caches stay attached to the running engine. +Mutable blend settings are copied as managed values, without copying GPU +handles. Room-owned dust styles are saved with the room so their controller +references remain consistent across repeated loads. + +Callbacks created by a component's constructor can also restore captured +sibling components. Named component fields preserve identity when an entity +swaps which component is attached; removed callbacks must still belong to +their entity, not borrow another entity's state. + +The v11 snapshot adds required room-registry state. Set existing v10 StartPos +slots again, then export any StartPos packs again. Positions, spawn settings, +and keybinds are unchanged; the older saved room state cannot be loaded exactly. A successful first Load prepares the other slots in the active chapter within the machine's memory budget. Later Loads reuse those room states. Restoring the saved render-buffer pixels must not add a GPU readback to each Load; @@ -52,8 +69,10 @@ statistics do not rewind. Room simulation clocks restore to the Set frame because room objects and visuals use them. Akron must reject capture or load when it cannot represent required gameplay -state exactly. It must not replace an exact StartPos with a player-only or -session-only room reload. +state exactly. This includes unregistered native handles, such as a running Lua +interpreter, that a managed snapshot cannot recreate. Akron reports a refusal +instead of substituting a player-only or session-only room reload. Manual +capture and load are blocked while paused, dead, transitioning, or in a cutscene. There is one named exception to that contract. Akron does not capture the playback tutorial ghost, Celeste's `playbackTutorial` entity, or the trail it @@ -69,14 +88,13 @@ ghosts a map places in a room. Playback figures a cutscene owns, such as Farewell's wave-dash tutorial, are captured and restored exactly as before. Use **Disable Playback** if you would rather the ghost were not there at all. -Akron does not load old selected-field room snapshots. A v10 snapshot contains -the complete managed room graph, registered helper state, audio state, and -render-buffer pixels required by the Set frame. Akron refuses the load if the -file is missing, damaged, uses another snapshot format, or belongs to another -map or save file. A snapshot format change refuses every slot set before it, -because a snapshot names room objects by their place in a clean reload of the -room and a change to what that reload contains moves them; those slots have to -be set again, and Akron says so when you load one. Imported StartPos packs bind +Akron does not load old selected-field room snapshots. A v11 snapshot contains +the complete managed room graph, registered helper state, audio state, room +registry state, and render-buffer pixels required by the Set frame. Akron +refuses the load if the file is missing, damaged, uses another snapshot format, +or belongs to another map or save file. Snapshot versions change when required +captured state or reconstruction assumptions change. Older slots must be set +again; Akron does not guess the missing state. Imported StartPos packs bind their snapshots to the active save-file slot without importing the author's progress or module save data. The same map and object-providing mods must remain installed because an exact diff --git a/docs/player-guide/community-packs.mdx b/docs/player-guide/community-packs.mdx index 9d307b8a..e2700164 100644 --- a/docs/player-guide/community-packs.mdx +++ b/docs/player-guide/community-packs.mdx @@ -58,10 +58,10 @@ Community packs use the same `.akr` import path as local exports. - Export your current setup before importing a community pack. - Confirm that the pack section is StartPos, Auto Kill, or Auto Deafen. -- For StartPos packs, install the same map and object-providing mods used by the author. Each imported StartPos includes its v10 snapshot, coordinates, and spawn settings. +- For StartPos packs, install the same map and object-providing mods used by the author. Each imported StartPos includes its v11 snapshot, coordinates, and spawn settings. - Imported settings can affect attempt status and proof metadata when used. - The Community Packs browser accepts StartPos, Auto Kill, and Auto Deafen setup archives. -- A public pack contains `manifest.json`, `setup.json`, and one v10 snapshot entry per StartPos. Its manifest `kind` is `setup` and its payload format is `akron-setup-v9`. +- A public pack contains `manifest.json`, `setup.json`, and, for saved StartPos slots, one shared `startpos/snapshots.bin.br` bundle. Its manifest `kind` is `setup` and its payload format is `akron-setup-v10`. ## Size limits @@ -70,8 +70,9 @@ Akron caps catalog and pack downloads before importing: - Catalog index: 1 MiB. - `.akr` pack download: 512 MiB. - Setup payload inside an `.akr`: 2 MiB. -- Each compressed StartPos snapshot attachment: 128 MiB. -- All compressed StartPos snapshot attachments in one pack: 509 MiB. +- Compressed StartPos snapshot bundle: 509 MiB. +- Each expanded StartPos snapshot: 384 MiB. +- All expanded StartPos snapshots in one pack: 1 GiB. - Each downloaded catalog preview image: 2 MiB, no wider or taller than 2048 pixels, and no larger than 4,194,304 pixels in total. - Marked-room captures in one in-game upload: 10 rooms. diff --git a/docs/reference/akr-archives.mdx b/docs/reference/akr-archives.mdx index e600028d..aa3480fe 100644 --- a/docs/reference/akr-archives.mdx +++ b/docs/reference/akr-archives.mdx @@ -3,17 +3,35 @@ title: .akr archive format description: Technical specification for the Akron archive format, including manifests, payloads, and import safety rules. --- -Akron uses `.akr` files to store and share setup sections, overlay themes, HUD labels, and control-display presets. An `.akr` file is a ZIP archive containing one manifest and one main payload. StartPos and Whole setup packs also contain the exact compressed snapshot named by each StartPos entry. The reader accepts only `manifest.json`, the main payload required by the archive kind, and StartPos attachments declared by `setup.json`. +Akron uses `.akr` files to store and share setup sections, overlay themes, HUD labels, and control-display presets. An `.akr` file is a ZIP archive containing one manifest and one main payload. StartPos and Whole setup packs with saved slots also contain one shared snapshot bundle. The reader accepts only `manifest.json`, the main payload required by the archive kind, and StartPos attachments declared by `setup.json`. ## Archive shape -Most archives contain two entries. StartPos and Whole setup archives add one snapshot entry per StartPos: +Exports write compact manifest and setup JSON and use maximum DEFLATE compression +for JSON entries. StartPos slots share one `startpos/snapshots.bin.br` entry stored +without further ZIP compression. A bounded dictionary shares repeated byte chunks; +a reversible base64 transform removes text overhead before Brotli quality 11, +window 24. Every original snapshot JSON byte survives unchanged. + +`snapshotBundleSha256` covers the compressed bundle. Each slot's +`snapshotSha256` covers its raw JSON document. Local StartPos saves continue to use +gzip; export does not rewrite them. Imports validate each document and save it +through the normal local snapshot writer. The byte-level contract is documented in +`docs/reference/snapshot-bundle.md`. + +Pack creation runs in the background because maximum compression can take minutes +on large maps. The completed archive replaces its destination atomically. Uploads +and downloads transfer those compressed bytes directly. A downloaded pack with the +catalog's exact size and SHA-256 is reused on subsequent imports. Existing cloud +objects do not shrink until replaced with new exports. + +Most archives contain two entries. StartPos and Whole setup archives with saved slots add one shared entry: ```mermaid flowchart TD archive["example.akr"] --> manifest["manifest.json"] archive --> payload["payload JSON"] - archive --> snapshots["startpos/*.v10.json.gz"] + archive --> snapshots["startpos/snapshots.bin.br"] manifest --> format["Format and target"] payload --> data["Kind data"] snapshots --> roomState["Exact StartPos room state"] @@ -59,7 +77,7 @@ manifest size limit is 16 KiB. | Kind | Payload entry | Payload format | Payload limit | Default directory | |---|---|---|---|---| -| `setup` | `setup.json` | `akron-setup-v9` | 2 MiB, plus declared StartPos attachments | `Saves/AkronSetups` | +| `setup` | `setup.json` | `akron-setup-v10` | 2 MiB, plus declared StartPos attachments | `Saves/AkronSetups` | | `theme` | `theme.json` | `akron-overlay-theme-v2` | 64 KiB | `Saves/AkronThemes` | | `hud-labels` | `hud-labels.json` | `akron-hud-labels-v1` | 256 KiB | `Saves/AkronHudLabels` | | `control-display` | `control-display.json` | `akron-control-display-v1` | 256 KiB | `Saves/AkronControlDisplay` | @@ -72,19 +90,18 @@ The archive container (`akron-archive`, `formatVersion` 1, `kindVersion` 1) desc the ZIP shape and the manifest. It is independent of what a payload means, and it does not change when a payload contract changes. -The `setup` payload contract and the StartPos snapshot contract move together: +The current portable and local contracts are: | Setup payload | StartPos snapshot document | StartPos attachment name | Snapshot file on disk | |---|---|---|---| -| `akron-setup-v9` | `akron-reconstruction-v10` | `startpos/.v10.json.gz` | `Saves/AkronStartPos/v10-.json.gz` | +| `akron-setup-v10` | `akron-reconstruction-v11` | `startpos/snapshots.bin.br` | `Saves/AkronStartPos/v11-.json.gz` | -A StartPos snapshot addresses each object by its position in a clean reload of the room -rather than by an identifier of its own. That makes a snapshot meaningful only against -the fresh room the build that wrote it produced. When Akron changes what a clean reload -contains, every position after the change shifts, and two objects of the same type with -no distinguishing identity can pair the wrong way round and restore without reporting a -failure. Both contracts are therefore bumped together whenever the fresh room changes, -and the snapshot file name carries the same version so an older file is never read. +A StartPos snapshot records an object graph with map identities, ownership +edges, live-resource identities, and structural paths into a clean room reload. +Reconstruction checks those relationships before applying saved state. A change +to required captured state or baseline assumptions requires a new snapshot +version; the filename carries that version too. The setup payload version +changes independently when the pack's own contract changes. The contracts also move when a snapshot has to carry something it did not carry before. A snapshot records, next to each saved object, whether the name it was saved under is @@ -99,6 +116,8 @@ refused by the reader, before the reconstruction path sees it. | Change | Effect on existing data | |---|---| +| `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-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. | @@ -114,7 +133,7 @@ Setup packs use the `setup` archive contract: ```text kind: setup Payload entry: setup.json -Payload format: akron-setup-v9 +Payload format: akron-setup-v10 Directory: Saves/AkronSetups ``` @@ -123,31 +142,39 @@ It is not an importable payload because `state` omits its required fields: ```json { - "format": "akron-setup-v9", + "format": "akron-setup-v10", "name": "Whole Setup", "createdUtc": "2026-05-14T00:00:00.0000000Z", "section": "Whole", "state": {}, "buttonBindings": {}, "menuActionBindings": {}, - "startPositions": {} + "startPositions": {}, + "snapshotBundleSha256": "" } ``` The reader requires the exact state fields owned by the declared section. It rejects missing fields, extra fields, unknown enum values, and incorrectly cased property names. `buttonBindings` and `menuActionBindings` are present -only for `Keybinds` and `Whole`. `startPositions` is present only for -`StartPos` and `Whole`. The payload `createdUtc` value must exactly match the +only for `Keybinds` and `Whole`. `startPositions` and `snapshotBundleSha256` are +present only for `StartPos` and `Whole`. The bundle hash is empty when there are no slots. The payload `createdUtc` value must exactly match the manifest `createdAt` value. The setup payload size limit is 2 MiB. -Each compressed StartPos snapshot attachment is limited to 128 MiB. All -snapshot attachments in one setup archive are limited to 509 MiB in total. -Each expanded v10 snapshot is limited to 384 MiB while Akron validates it. -A `StartPos` or `Whole` archive can contain at most 99 StartPos entries. Each -slot key must be in the range 1-99. +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, +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. +Each slot key must be in the range 1-99. + +The public upload service has narrower resource limits: 32 MiB per archive and +384 MiB of expanded snapshot JSON across the whole pack. Split larger packs +before uploading. Catalog previews use the existing JPEG conversion with a 2 MiB +cap. Published private upload sources become eligible for cleanup after 24 hours; +the approved public pack and images remain stored. ## Portable setup validation limits @@ -208,8 +235,8 @@ but cannot exceed their listed length. | HUD row-order key | Required, at most 128 characters | Every enum field must contain a defined named string value. Each StartPos entry -must use the archive name `startpos/.v10.json.gz` and a 64-character -lowercase hexadecimal SHA-256 digest. +must use the archive name `startpos/snapshots.bin.br` and a 64-character +lowercase hexadecimal SHA-256 digest of its original JSON bytes. ## Setup sections @@ -250,10 +277,10 @@ 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-v9` cannot be imported. Recreate the setup and its StartPos slots in the current build, then export a new pack. +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. -For each StartPos attachment, Akron verifies the declared checksum, map, and -room before applying the setup. It then binds the snapshot to the recipient's +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 active save-file slot. The exported attachment contains the captured active map's berry progress, but import replaces that field with the recipient's current active-map berry progress before binding the snapshot. Akron does not diff --git a/docs/reference/community-pack-catalog.mdx b/docs/reference/community-pack-catalog.mdx index d4def241..0d345c2d 100644 --- a/docs/reference/community-pack-catalog.mdx +++ b/docs/reference/community-pack-catalog.mdx @@ -139,9 +139,9 @@ One in-game upload captures at most 10 marked rooms. For StartPos uploads, Akron Before publishing, verify the catalog-facing pack contract: -- Verify that the archive contains `manifest.json`, `setup.json`, and the exact v10 snapshot entry named by each StartPos. +- Verify that the archive contains `manifest.json`, `setup.json`, and, when StartPos slots are present, `startpos/snapshots.bin.br` containing each declared v11 snapshot. - Verify that the manifest `kind` is `setup`. -- Ensure that `setup.json` uses the `akron-setup-v9` format. +- Ensure that `setup.json` uses the `akron-setup-v10` format. - Verify that the entry `section` exactly matches the setup section. - Confirm that the download URL returns the expected `.akr` archive. - Reject every section except `StartPos`, `AutoKill`, and `AutoDeafen`. diff --git a/docs/reference/debug-commands.mdx b/docs/reference/debug-commands.mdx index a5b08a95..71663765 100644 --- a/docs/reference/debug-commands.mdx +++ b/docs/reference/debug-commands.mdx @@ -47,6 +47,8 @@ Akron exposes developer-facing console commands through Everest's debug console | `akron_perf >` | Developer performance telemetry: overlay/render diagnostics, frame-time and GC attribution, and the JSONL perf record the `scripts/akron-perf` harness drives. `gcevents` controls the runtime GC event subscription used while recording. | | `akron_feature [on\|off\|toggle\|status]` | Low-level wrapper over many toggles. Document the concrete overlay row first. | | `akron_qa_*`, `akron_player_state`, `akron_input_state` | QA state setup and telemetry for live verification. | +| `akron_qa_list_rooms [offset] [limit]` | Page exact room names from the loaded map, with JSON string output and a continuation offset. | +| `akron_qa_warp_room ` | Schedule a warp within the loaded map using the exact, untrimmed room name. | | `akron_qa_messages [count]` | Report the most recent Akron messages raised for the player, newest last. `count` defaults to 5 and must be positive. Output includes `qa-messages-raised`, `qa-messages-reported`, and one `qa-message` line per returned message. | | `akron_position ` | Command-only position setter for controlled map debugging. StartPos and room-warp UI flows are the player-facing paths. | | `akron_debug_snapshot [tag]` | Diagnostic JSON artifact for bug reports and automation. | @@ -57,6 +59,33 @@ Akron exposes developer-facing console commands through Everest's debug console | `akron_prompt_state`, `akron_prompt_select` | Prompt automation for tests and live verification. | | `akron_broker_warnings ` | Show or set Speedrun Tool broker warning behavior. | +### Room inventory and warping + +Both commands require a loaded level. + +`akron_qa_list_rooms [offset] [limit]` defaults to offset `0` and limit `100`. +The offset must be a nonnegative integer; the limit must be an integer from +`1` through `100`. Invalid arguments print the command's usage without listing +rooms. The offset is a zero-based index into the map's room entries. Null and +dummy entries are skipped, so a page can emit fewer names than its limit. + +Each room is reported as `qa-map-room: `. Decode that JSON string +to recover the exact name, including whitespace and escaped characters. Every +page ends with `qa-map-rooms-next: `: pass that offset to the next call, +or stop at `-1`. An offset at or beyond the end returns no names and `-1`. +Do not infer completion from an empty page alone. + +`akron_qa_warp_room ` looks up the supplied name without trimming +it. Use the decoded inventory name exactly, encoded as a JSON string argument +when using the file-backed queue described below. Empty input reports +`qa-warp-room: missing room`; an unknown name reports +`qa-warp-room: not-found room=`. A valid name schedules the warp for the +end of the frame and reports `qa-warp-room: room-json=`. JSON-decode +the acknowledgement before comparing it with the requested name; it preserves +quotes, backslashes, leading/trailing whitespace, and CR/LF in the actual room +name. That line acknowledges the request; it does not prove the room has +finished loading. + ## Extended gameplay commands | Command | Purpose | @@ -177,6 +206,39 @@ akron_overlay show The queue accepts only commands in Akron's internal automation allowlist. It rejects command files larger than 64 KiB, files with more than 128 lines, runs with more than 64 commands, and command lines longer than 2,048 characters. +Each physical line contains one command. Unquoted arguments are literal, +whitespace-delimited tokens; backslashes in an unquoted Windows path stay +literal. An argument beginning with `"` must be one complete JSON string +token, followed by whitespace or the end of the line. There is no shell-style +quote concatenation or single-quote syntax. + +Use a JSON encoder for arbitrary argument values rather than surrounding them +with quotes yourself. JSON strings preserve empty strings (`""`), leading and +trailing spaces, tabs, and CR/LF after decoding. Encode embedded quotes as +`\"`, backslashes as `\\`, and line breaks as `\r` and `\n`; never put a literal +line break inside an argument in the command file. In Python, use +`json.dumps(value, ensure_ascii=False)` so Unicode names do not needlessly +expand against the command-line limit. +The repository's verification and perf shell harnesses require local `python3` +for this encoding. + +```text +akron_qa_warp_room " room \"A\"\\branch\r\nnext " +akron_setup import startpos "C:\\Games\\Celeste\\Saves\\My setup.akr" +akron_tas_file C:\Celeste\Saves\scenario.tas +``` + +The first example passes one exact room name, including both leading and +trailing spaces and the decoded CR/LF. The quoted Windows path must double +every backslash: `"C:\new\test.akr"` would decode `\n` and `\t` as control +characters instead of path separators. Bare Windows paths need no escaping, +but cannot contain whitespace. + +Malformed JSON, including invalid escapes, unescaped control characters, or +an unterminated string, rejects the entire command file before any commands +are queued. Text concatenated to a closed quoted token, such as `"room"suffix`, +is also rejected; separate arguments with whitespace. + | File | Purpose | |---|---| | `Saves/AkronAutomation/command.txt` | Session-token header followed by newline-delimited allowlisted commands. | diff --git a/docs/reference/file-locations.mdx b/docs/reference/file-locations.mdx index 134ea61d..99fbddb1 100644 --- a/docs/reference/file-locations.mdx +++ b/docs/reference/file-locations.mdx @@ -12,7 +12,7 @@ Most paths are relative to `Everest.PathGame` (the active Celeste/Everest instal | `Saves/AkronSetups` | Exported and imported `.akr` setup packs. | | `Saves/AkronBackups` | Automatic and manual save backups. | | `Saves/AkronLogs` | Akron diagnostic logs and rotated log files. | -| `Saves/AkronStartPos` | Runtime-managed, compressed v10 StartPos snapshots. Do not edit these files manually. | +| `Saves/AkronStartPos` | Runtime-managed, compressed v11 StartPos snapshots. Do not edit these files manually. | | `Saves/AkronRecordings` | Video recordings and clips. Akron writes here by default; the output folder is configurable via the recorder settings. | | `Saves/AkronAutomation` | Automation command-queue input and result files. Normal player workflows do not need this directory. | | `Saves/AkronProof` | Proof sidecar JSON files written by the proof system and area-completion captures. | diff --git a/docs/reference/snapshot-bundle.md b/docs/reference/snapshot-bundle.md new file mode 100644 index 00000000..bf428234 --- /dev/null +++ b/docs/reference/snapshot-bundle.md @@ -0,0 +1,82 @@ +# Portable snapshot bundle + +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 +slots, then re-export their setup packs. + +## Wire contract + +An archive with snapshots contains one stored ZIP entry, +`startpos/snapshots.bin.br`. Its SHA-256 covers the complete compressed entry. +Each slot's snapshot SHA-256 covers its original, uncompressed JSON bytes. + +The entry is a single Brotli stream, encoded at quality 11 with window 24. +There must be no trailing bytes or concatenated streams. After Brotli decoding, +read frames until EOF. Each frame starts with a one-byte tag and a little-endian +unsigned 32-bit payload length: + +- Tag 0: 1 through 65,536 literal bytes. +- Tag 1: 3 through 49,152 binary bytes, with length divisible by three. Expand + them to standard, unpadded ASCII base64 and append those bytes. + +Frame parsing starts with 65,536 units of fragmentation credit. Before reading +each payload, update `credit = min(65536, credit + decodedFrameBytes - 64)`; +reject the frame if the result is negative. For tag 1, `decodedFrameBytes` is +four thirds of the binary payload length. The credit cap prevents large +earlier frames from funding an arbitrarily long burst of tiny frames. + +The resulting byte stream has this structure. All integers are unsigned 32-bit +little-endian values: + +1. Eight literal magic bytes: `AKRSB001`. +2. Dictionary count, at most 4,096. +3. For each dictionary item: byte length, then bytes. Each item contains 1 + through 65,536 bytes. The total dictionary is at most 16 MiB. +4. Document count, from 1 through 99. +5. For each document: slot number, original byte length, then commands until + exactly that many bytes have been reconstructed. Slots are strictly + increasing, from 1 through 99. Each document contains 1 through 384 MiB. + The combined expanded length of all documents cannot exceed 1 GiB. +6. EOF, with no additional frames or bytes. + +A document command starts with one byte. Tag 0 is followed by a literal length +and 1 through 65,536 literal bytes. Tag 1 is followed by a zero-based dictionary +index. Neither command may exceed the document's remaining length. +Each document permits at most `1024 + ceil(originalByteLength / 4096)` +commands. Together with the frame-credit limit, this bounds parsing work +even when the compressed entry and reconstructed documents are small. + +## Encoder policy + +The decoder does not require the encoder's chunk boundaries. Akron's +encoder uses content-defined chunks with a 4 KiB minimum, 16 KiB target, and +64 KiB maximum. It keeps at most 65,536 discovery records and spools at most +128 MiB of candidate bytes. The dictionary itself remains capped at 16 MiB. +Repeated chunks are ranked by estimated compressed bytes saved per dictionary +byte, not by their raw length alone. + +The encoder writes two candidates when a useful dictionary exists: one with +that dictionary and one with an empty dictionary. It keeps the smaller complete +Brotli file. These are encoder choices within one format, not compatibility +paths. Input gzip checksums and raw document checksums detect source changes +between capture and encoding. Cancellation removes temporary files. + +The base64 transform recognizes runs of the standard ASCII base64 alphabet. +A run is packed only after it reaches 128 bytes. +It converts only complete four-character groups, leaving padding, short tails, +quotes, escapes, and all other bytes literal. Thus it is reversible for arbitrary +input bytes and does not depend on JSON string semantics. + +## Verification still required + +The public upload service accepts at most 32 MiB per `.akr` and 384 MiB of +expanded snapshot JSON across the entire pack. This is a service resource limit, +not the local format's per-document limit. It also bounds JSON tokens to 16 MiB, +nesting to 128 levels, and object keys to 4,096 per object. Larger local packs +must be split before upload; the service does not silently discard slots. + +Prototype measurements favor shared chunks and the reversible base64 transform +over plain Brotli, zstd, and xz on the sampled saves. Production codec round trips, +matched quality-11 controls, cross-language decoding, archive integration, and +Cloudflare runtime measurements must pass before this contract is released. diff --git a/docs/startpos-restore-verification.md b/docs/startpos-restore-verification.md index 9f1ca48e..1176f536 100644 --- a/docs/startpos-restore-verification.md +++ b/docs/startpos-restore-verification.md @@ -21,9 +21,9 @@ The pack's SHA-256 is Public beta 79 imports its `akron-setup-v9` container. The original verification build also contained a separate, unpublished setup-compression change, so the pack was imported with beta 79 first. Verification then used those same native -`akron-reconstruction-v10` snapshot files with the fixes applied. This PR is -based on beta 79 and excludes that compression change; its setup format remains -`akron-setup-v9`. No snapshot re-export or compatibility path was required. +`akron-reconstruction-v10` snapshot files with the fixes applied. PR #192 shipped +that restore fix without the compression change and retained `akron-setup-v9`. +No snapshot re-export or compatibility path was required for that check. Beta 79 reproduced the removed-sound refusal in slots 4 and 5. A later slot 2 load itself took 62.5 ms, but preparation spent another 17,067.6 ms retrying @@ -84,3 +84,242 @@ reconstruction cost. Timings apply to the reduced mod configuration above. The separate preparation-recovery guard failure in the original log was not reproduced. No guard was bypassed or weakened, and this case remains unresolved. + +## 2026-09-10 through 2026-09-12: shared ownership and lifecycle fixes + +This check uses newly captured `akron-reconstruction-v11` snapshots, not the +v10 Reflection snapshots above. The setup container remains `akron-setup-v10`. +Older slots must be captured again because their snapshots lack the active +area's room-owned dust style. + +The changes operate on ownership and identity, not map SIDs: + +- Typed managed ownership admits grids and intermediate records without + requiring lexical nesting or identical fresh-room populations. +- Entity matching uses concrete type and map SourceId. Named peer fields + follow their identified owners when entity-list order changes. +- Retained map entities, session-suppressed built-in entities, pooled effects, + and runtime entities with a proved scene owner can be reconstructed. +- Process caches retain their live identities. Mutable BlendState descriptors + are copied as values, preserving aliases without copying graphics handles. + Custom sidecar state is refused rather than silently discarded. +- The active area's dust-style entry shares the same clone graph as its + controllers and is restored without replacing other areas' entries. +- Constructor-generated component callbacks can retain sibling components + through their common entity owner. Attached components require saved-list + membership; detached callbacks require an exact component field on the + authenticated entity. Named fields preserve identity when same-type + components swap attachment. Foreign owners, missing captured-component + membership, and opaque captures remain refused. +- Scene-owned resources keep their canonical live identity when retained + components have stale or missing references in the clean room, including + components already detached at capture and reached first through a callback. + Exact entity-field aliases establish ownership independently of traversal + order. Competing owners are refused. The saved owner must prove the same + scene; same-type occurrence counts cannot substitute for that ownership. +- Exact `EntityData` and `LevelData` records can survive in a mod session's cache + after leaving their room. Their values and shared references restore as map + data; runtime entity placement and resource checks remain separate. +- Capture and fresh-resource indexing use an explicit depth-first work stack. + Node and alias order stay unchanged, but graph depth no longer consumes the + save worker's native stack. +- Both resource indexes stop at registered live-instance anchors as well as + live resource types. They do not walk private process-cache contents or + scalar-array cells that cannot contain resources. + +The current Release suite passed all 1,970 tests, with no failures or skips. +Coverage includes reordered same-type owner/peer pairs, contradictory map +identities, foreign-scene refusal, typed versus opaque ownership, coroutine +and direct-iterator ownership, readonly native copies, malformed resource +descriptors, and warm/disk dust-style alias preservation. A separate smoke +check using the installed FNA assembly passed descriptor copying, all twelve +settings, aliases through base/object/readonly fields, mutation isolation, +and refusal of custom sidecar data. It did not initialize a GPU. + +An isolated 1,000-node capture on a 256 KiB worker stack reproduced a process +stack overflow in `CaptureContext.IndexFreshValue` before the traversal change. +The same executable now completes capture, serialization, deserialization, +and restore with matching values. The owning test suite also checks a deep +cyclic graph and shared leaf identity on that small stack. + +The Windows Maya run captured both `Delta` and `Extra Ball`, then the process +exited when pause released the persistence worker. Windows Error Reporting +recorded `0xc00000fd` in `coreclr.dll`, not a managed load refusal. The original +command timeout concealed that process exit. A separate QA map reload produced +`0xc0000374` heap corruption; that native reload failure is not evidence of the +same bug. + +The initial Windows candidate ran on Celeste 1.4.0.0 with Everest 1.6305.0 +and the existing installed mod set. Its archive SHA-256: +`140d2f2cd34aa9eee90049f60252ff92900101b0351129afff48f1f7a8ae7596`. + +The Forsaken City control passed capture, per-slot restart-copy checks, +export/import, independent cold loads, repeated warm loads, and a real Celeste +process restart. Player position, speed, facing, animation/frame, state, +stamina, and dashes matched capture; the controlled session flag and counter +also restored. Warm restore bodies took 15.3-20.1 ms; disk reconstruction took +3.1-3.9 seconds. These timings do not include every warm-all preparation step. + +The sweep now waits for asynchronous export completion and checks the exact +archive's import result. It reimports before each slot's cold check so another +slot's preparation cannot conceal that path. It compares state only after +the load completion callback reports success. + +A subsequent full 14-map Windows matrix passed the complete pipeline on +Forsaken City, Beginner Lobby, frozenflygone, spirialis, Hydro, +RadleyMcTuneston, Indecx, vitellary, Rocketguy2, ZZ-HeartSide, and RedBatNick. +That run preceded the final callback, camera, and bounded-stack changes. +Skunkynator still refused reconstruction, Maya timed out as described above, +and HankyMueller's requested `Easter Egg Puzzle` remained in a cutscene after +normal skip. A later check of `Double Vision` and `Feedback Loop` exposed +retained map metadata in a mod-session cache. No cutscene flags were forced. + +The bounded-stack candidate +(`3aa277bd61b255e2583af4a52c22676e4282338769171f4f21a8af2fff69f4ad`) +passed the full pipeline for HankyMueller's `Double Vision` and `Feedback Loop`, +frozenflygone's `Lab-secret`, and Forsaken City's `1` and `6b`. Simulation +resumed after restoration; captured game images showed no error screen. +HankyMueller's four cold loads took 8.1-11.1 seconds and four warm loads +44.6-59.4 ms. Its retained metadata no longer caused reconstruction refusal. + +That candidate still refused Skunkynator's camera alias. Maya no longer +exited with stack overflow, but both restart copies remained pending after +120 seconds while the worker was in the fresh-resource index. The live-instance +boundary fix did not resolve that stall. A subsequent trace found repeated +`System.Reflection.Pointer` visits at more than 14 million path levels through +a Lua coroutine proxy. Reading `Pointer._ptr` boxes another `Pointer`, so +reference-identity cycle detection could never terminate that walk. Both indexes +now treat boxed pointers as native leaves; capture refuses them at the field path. + +The installed LuaCutscenes coroutine resumes through Everest's shared native Lua +VM. Its `LuaCoroutine` exposes `MoveNext` and an unsupported `Reset`, not a +snapshot/rewind contract. Installed Speedrun Tool also marks Lua state as a +desynchronization risk and retains its native wrappers instead of copying their +handles. Akron now refuses unhandled Lua state before native cloning, reports +the capture failure, and preserves the normal rollback path. It does not claim +to persist or rewind native Lua execution. + +The native-boundary candidate +(`11a91ea80c671dde9b038ed7edf3ca32d16c352fc7b93e7e1227cab986819961`) +passed the full pipeline again for HankyMueller's two rooms, `Lab-secret`, and +Forsaken City's two rooms. Maya's `Delta` refused capture with the Lua warning. +A subsequent command check found no slot and no outstanding restart copies; +after unfreezing, the scene remained `Level` and its screenshot showed gameplay +without an error screen. This is a verified safe refusal, not Lua restore support. + +That candidate still refused Skunkynator's detached grid when the callback was +its first captured reference. The original regression used the real +`TileInterceptor` callback and failed before checking all exact entity-field +aliases. CI's stripped assembly removes that callback body, so the durable +fixture uses an executable constructor closure with the same concrete +sibling-grid ownership. It checks memory/disk camera identity and mutation of +the correct grid; foreign, missing, and competing owners remain refused. +The final callback-first candidate +(`53ef1c0e746e6a1dd84e25149be0983fb6b3a87577bdfc1778cff12c6f2fe2f4`) +passed capture, per-slot restart copies, export/import, independent cold loads, +repeated warm loads, and resumed simulation in Skunkynator's `a-0` and `a-2`. +Player and controlled session state matched capture. Four cold restore bodies +took 3.7-5.1 seconds; four warm bodies took 23.3-27.6 ms. Forsaken City's `1` and +`6b` passed on the same archive. Both post-restore screenshots showed gameplay +without an error screen. The Release build and archive integrity check passed. + +The final run's command responses, map logs, screenshots, result JSON, and +deployed checksum are retained in the verification workspace's +`live-alias-final` directory. +The 1,970-test TRX is under the sibling `alias-final-tests` directory; the native +Lua refusal and resumed frame are under `live-native-final` and +`lua-refusal-resume`. Earlier candidate results remain separate from this run. + +Linux Mint verification has not been performed for this change: SSH to the +documented machine timed out. Its old bulk archive inside `Saves` could not +be moved. New bulk runs create their recovery archive +outside `Saves`; a local archive round trip confirmed repeated backups do not +include an earlier recovery archive. + +PR review follow-up: all 1,972 Release tests passed with zero failures or skips +against the exact stripped reference archive used by CI +(`ab72454daf77701ccf8bc36e591280551795b53734c453abbf7fec4cf94fcf8e`). +The tests no longer depend on stripped engine getters, constructors, indexers, +or `EntityID` hashing. Camera fixtures use distinct primitive scalar state; +the runtime identity index compares the same type, room, and numeric ID without +calling stripped engine methods. This follow-up has headless verification, +not a new live map sweep. + +The snapshot bundle now caps combined expanded documents at 1 GiB. Streaming +tests accepted exactly that boundary and refused a fourth document before +delivering its callback when it crossed the limit. A throwaway export probe +also rejected repeated documents above the limit, preserved the existing +destination, and removed its staging files. + +A throwaway harness exercised the real bulk-sweep control flow with command +substitutes: SSH recovery, unrecoverable SSH, failed relaunch, failed prelaunch, +missing and empty results, stale startup logs, and launch-time SSH failure. +All eight scenarios passed, including result retention and restart ordering; +the complete shell script also passed `bash -n`. + +The follow-up CI-mode solution build completed with zero warnings or errors. +Package CRCs, PDB exclusion, and exact license/notice contents passed. That +headless review package is +`7feb6e6c83b77bafe04c75aa4da146191b65197498fd68d74feb3f948a58ec96`; +it is not the earlier Windows-deployed archive. + +A second review found that an outer sweep timeout could leave a nonempty +results file containing only the first completed side. The aggregate now +retains those completed rows and records every missing requested side as +blocked, including in the summary counts and bugs list. The reproduction +first failed with only the normal-side pass present. After the fix, six +merge/summary smoke scenarios passed: partial timeout, partial failure, +complete failure with unavailable-side skips, incomplete success, a requested +subset, and failed prelaunch. Complete results do not gain artificial blocked +rows, and a retry replaces stale rows without removing another map's results. + +A command-transport review found that command-file quoting could lose embedded +quotes or turn room-name line breaks into command boundaries. A probe against +the pre-fix Akron assembly reproduced the loss; the same probe passed after +the parser change. Quoted automation arguments now use JSON string escaping, +and the warp acknowledgement carries a JSON room name. Bare arguments retain +literal backslashes. + +All 1,983 Release tests passed against the pinned CI references, with zero +failures or skips. The existing automation suite now covers exact decoded +text, empty arguments, command-looking encoded newlines, malformed quoting, +and bare Windows paths. A throwaway producer-to-parser smoke executed the real +Python warp/import expressions and shell encoders against the compiled C# +command-file parser. Exact names and Windows paths round-tripped, mismatched +warp acknowledgements were refused, and perf labels retained their contents. +All three affected shell scripts passed `bash -n`; the Python sweep parsed +without generating bytecode. + +These transport checks are headless, not a new live map sweep. Run the updated +harness with the updated Akron build: quoted command-file arguments and the +warp acknowledgement use the new documented grammar. + +The next bundle review found that document-size limits did not bound command +and frame overhead. Two independent bundles with 2 KiB documents, one using +one-byte commands and the other one-byte frames, were accepted by the old +compiled reader. The same probe now rejects both through `InvalidDataException`. +The reader caps command count per document and uses a capped, rolling +fragmentation allowance for frames. + +All 1,986 Release tests passed against the pinned CI references, with zero +failures or skips. The bundle suite retains regressions for excessive commands, +a tiny-frame burst after a full frame, and writer round trips containing +minimum-length packed runs separated by tiny literal frames. These checks +exercise the compiled codec headlessly, not the running game. + +A later review found that unsupported blend descriptors threw +`InvalidOperationException` during the synchronous clone, outside the capture +refusal contract. They now throw `AkronReconstructionException`, which the +existing capture handler reports without letting Set rethrow it. + +The canonical unsupported-descriptor regression failed before the change and +passes afterward. A separate real deep-clone probe used executable FNA from +the Windows installation because CI's stripped `IsDisposed` getter cannot +execute. Custom `Tag`, DynamicData, and disposed descriptors all changed from +`InvalidOperationException` to the capture-refusal type; a normal descriptor +still cloned independently. That FNA assembly's SHA-256 is +`7249fd96f989fa6c3adc28b64dbb7cbff21bb1746128e250e243fabf21a1932f`. + +All 1,987 Release tests passed against the pinned CI references, with zero +failures or skips. The real-FNA probe is headless; the in-game failure toast +has not been visually rechecked for this follow-up. diff --git a/docs/troubleshooting/startpos-recovery.mdx b/docs/troubleshooting/startpos-recovery.mdx index 52eaa38b..335307ea 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 v10 snapshot. After a +while a worker builds and validates the restart-safe v11 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 v10 snapshot file is missing, damaged, or incomplete. +- The v11 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 v10 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 v11 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 where each room object sits in a clean reload of that room, so it only means anything against the fresh room the build that wrote it produced. When Akron changes what a clean reload contains, the snapshot format version changes with it, and snapshots written before the change are refused rather than read: an index that has shifted can pair two same-typed objects the wrong way round and report a successful load. The format also changes when a snapshot has to carry something it did not carry before, such as whether the map laid a saved entity out, because a snapshot without it cannot be checked against it. That map check covers the room a slot was set in; state a mod registers with Akron separately is checked by every other rule but not by the map. The current format is `akron-reconstruction-v10`. +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. 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/scripts/akron-perf/run.sh b/scripts/akron-perf/run.sh index 538c7335..33880502 100755 --- a/scripts/akron-perf/run.sh +++ b/scripts/akron-perf/run.sh @@ -101,6 +101,7 @@ say() { printf '\n== %s %s\n' "$(date -u +%H:%M:%S)" "$*"; } SSH_HOST_KEY_OPTIONS=(-o StrictHostKeyChecking=yes -o "UserKnownHostsFile=${KNOWN_HOSTS}" -o ConnectTimeout=20) rsh() { sshpass -e ssh "${SSH_HOST_KEY_OPTIONS[@]}" "${USER_NAME}@${HOST}" "$@"; } rcp() { sshpass -e scp "${SSH_HOST_KEY_OPTIONS[@]}" "$@"; } +json_arg() { python3 -c 'import json, sys; print(json.dumps(sys.argv[1], ensure_ascii=False))' "$1"; } # ---------------------------------------------------------------- build/deploy @@ -148,6 +149,7 @@ say "Uploading the TAS scenario" rsh "mkdir -p '${GAME_ROOT}/Saves/AkronPerfScenario' '${PERF_DIR}'; rm -f '${PERF_DIR}'/*.jsonl" rcp scripts/akron-perf/scenario.tas "${USER_NAME}@${HOST}:${GAME_ROOT}/Saves/AkronPerfScenario/scenario.tas" TAS_PATH="${GAME_ROOT}/Saves/AkronPerfScenario/scenario.tas" +TAS_PATH_JSON="$(json_arg "$TAS_PATH")" # ------------------------------------------------------------------ game loop @@ -277,16 +279,17 @@ place_slots() { # that it says everything to the game in one file before playback begins. place_and_play_during() { local n="$1" - local label="$2" + local label_json + label_json="$(json_arg "$2")" || return 1 local i body="" - body="akron_tas_file ${TAS_PATH} + body="akron_tas_file ${TAS_PATH_JSON} " if [ "$GC_EVENTS" -eq 0 ]; then body="${body}akron_perf gcevents off " fi body="${body}akron_perf reset -akron_perf record ${label} +akron_perf record ${label_json} " for i in $(seq 1 "$n"); do body="${body}akron_startpos set ${i} "; done @@ -296,7 +299,8 @@ akron_perf record ${label} } record_and_play() { - local label="$1" + local label_json + label_json="$(json_arg "$1")" || return 1 # Stress mode runs the overlay churn loop concurrently with scripted # gameplay: visibility toggled every 15 frames, UI mutated every frame, and # a forced full GC every 120 frames. It only exists in Debug builds @@ -309,12 +313,12 @@ record_and_play() { # before. Once it is on it stalls the automation queue: measured on the box, # every command sent after `akron_qa_stress on` timed out, so anything the # harness still needs to say to the game has to be said first. - send "akron_tas_file ${TAS_PATH}" 30 >/dev/null + send "akron_tas_file ${TAS_PATH_JSON}" 30 >/dev/null if [ "$GC_EVENTS" -eq 0 ]; then send "akron_perf gcevents off" 30 >/dev/null fi send "akron_perf reset -akron_perf record ${label} +akron_perf record ${label_json} akron_startpos status" 60 send "akron_play_tas" 30 >/dev/null if [ "$STRESS" -eq 1 ]; then diff --git a/scripts/akron-verify/map-sweep-bulk.sh b/scripts/akron-verify/map-sweep-bulk.sh new file mode 100755 index 00000000..3477d25a --- /dev/null +++ b/scripts/akron-verify/map-sweep-bulk.sh @@ -0,0 +1,406 @@ +#!/usr/bin/env bash +# Bulk StartPos map sweep: every loaded map, one at a time, resumable. +# +# Runs scripts/akron-verify/map-sweep.py per map SID so a process crash costs +# one map instead of the batch, relaunches the game when it dies, and merges +# every per-map results.json into one aggregate with a bugs file. +# +# Do NOT use this to deploy builds or change mods; it only drives the game +# that is already installed. Backs up remote saves before the first sweep. +# +# Required env (same names as scripts/akron-verify/run.sh): +# AKRON_PERF_HOST AKRON_PERF_USER AKRON_PERF_PASSWORD (or ssh key) +# AKRON_PERF_KNOWN_HOSTS AKRON_PERF_LAUNCH_DIR +# Optional: AKRON_PERF_GAME_ROOT (default $LAUNCH_DIR/files/game-root) +# AKRON_PERF_WINE_PREFIX (default $LAUNCH_DIR/wine-prefix) +# AKRON_PERF_TOKEN (default akron-bulk-) +# Windows mode: AKRON_PERF_WINDOWS=1, AKRON_PERF_WINDOWS_TASK, +# AKRON_PERF_WINDOWS_BACKUP (an existing save archive) +# +# Usage: +# scripts/akron-verify/map-sweep-bulk.sh --output /tmp/startpos-bulk \ +# [--sides normal,b,c] [--rooms 2] [--filter Strawberry] [--no-resume] +# +# Resume (default): SID+side rows already marked pass in the aggregate +# results.json are skipped. Use --no-resume to re-run everything. +# Bugs: aggregate bugs.md lists every fail/blocked row with its reason and +# evidence directory. No bug is fixed here; this only records it. +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" || exit 1 +cd "$REPO_ROOT" || exit 1 + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + sed -n '2,25p' "$0" + exit 0 +fi +HOST="${AKRON_PERF_HOST:?set AKRON_PERF_HOST to the test box address}" +USER_NAME="${AKRON_PERF_USER:?set AKRON_PERF_USER to the account on that box}" +export SSHPASS="${AKRON_PERF_PASSWORD:-}" +KNOWN_HOSTS="${AKRON_PERF_KNOWN_HOSTS:?set AKRON_PERF_KNOWN_HOSTS to a known_hosts file containing the test host key}" +[ -f "$KNOWN_HOSTS" ] || { echo "known_hosts file does not exist: ${KNOWN_HOSTS}" >&2; exit 1; } +LAUNCH_DIR="${AKRON_PERF_LAUNCH_DIR:?set AKRON_PERF_LAUNCH_DIR to the Celeste install on that box}" +GAME_ROOT="${AKRON_PERF_GAME_ROOT:-${LAUNCH_DIR}/files/game-root}" +WINE_PREFIX="${AKRON_PERF_WINE_PREFIX:-${LAUNCH_DIR}/wine-prefix}" +TOKEN="${AKRON_PERF_TOKEN:-akron-bulk-harness-$(date -u +%Y%m%d)-a3f9c2e7}" +WINDOWS="${AKRON_PERF_WINDOWS:-0}" +WINDOWS_TASK="${AKRON_PERF_WINDOWS_TASK:-\\AkronBulkInteractive}" +WINDOWS_BACKUP="${AKRON_PERF_WINDOWS_BACKUP:-}" +SIDES="normal,b,c" +ROOMS=2 +FILTER="" +OUTPUT="" +RESUME=1 + +while [ $# -gt 0 ]; do + case "$1" in + --output) OUTPUT="$2"; shift ;; + --sides) SIDES="$2"; shift ;; + --rooms) ROOMS="$2"; shift ;; + --filter) FILTER="$2"; shift ;; + --no-resume) RESUME=0 ;; + -h|--help) sed -n '2,25p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac + shift +done +[ -n "$OUTPUT" ] || { echo "missing --output DIR" >&2; exit 2; } + +# -n: rsh runs inside a while-read loop over the map list; without -n, ssh +# would consume the loop's stdin and end the sweep after one map. +SSH_HOST_KEY_OPTIONS=(-n -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=${KNOWN_HOSTS}" -o ConnectTimeout=20) +if [ -n "${SSHPASS:-}" ]; then + rsh() { sshpass -e ssh "${SSH_HOST_KEY_OPTIONS[@]}" "${USER_NAME}@${HOST}" "$@"; } +else + rsh() { ssh "${SSH_HOST_KEY_OPTIONS[@]}" "${USER_NAME}@${HOST}" "$@"; } +fi +SWEEP_ENV=(env "AKRON_PERF_HOST=${HOST}" "AKRON_PERF_USER=${USER_NAME}" + "AKRON_PERF_GAME_ROOT=${GAME_ROOT}" "AKRON_PERF_TOKEN=${TOKEN}" + "AKRON_PERF_KNOWN_HOSTS=${KNOWN_HOSTS}") +if [ "$WINDOWS" = "1" ]; then + SWEEP_ENV+=("AKRON_PERF_WINDOWS=1") +fi +if [ -n "${SSHPASS:-}" ]; then + SWEEP_ENV+=("AKRON_PERF_PASSWORD=${SSHPASS}") +fi + +mkdir -p "$OUTPUT" || exit 1 +AGGREGATE="$OUTPUT/results.json" +[ -f "$AGGREGATE" ] || echo "[]" > "$AGGREGATE" + +say() { printf '\n== %s %s\n' "$(date -u +%H:%M:%S)" "$*"; } + +stop_game() { + if [ "$WINDOWS" = "1" ]; then + rsh "taskkill /f /im Celeste.exe >nul 2>&1" >/dev/null 2>&1 || true + return + fi + rsh "export WINEPREFIX=${WINE_PREFIX} + pkill -u ${USER_NAME} -f '[C]eleste.exe' || true + sleep 5 + pkill -9 -u ${USER_NAME} -f '[C]eleste.exe' || true + pkill -u ${USER_NAME} -f '[w]inedbg' || true + sleep 2 + /usr/bin/wineserver -k 2>/dev/null || true + sleep 6" >/dev/null 2>&1 || true +} + +launch_game() { + ssh_up || return 1 + if [ "$WINDOWS" = "1" ]; then + rsh "schtasks /run /tn \"${WINDOWS_TASK}\"" >/dev/null 2>&1 || return 1 + local i + for i in $(seq 1 90); do + if game_alive; then + # The scheduled task only confirms process creation. Give Steam, + # Everest, and the automation service time to finish loading. + sleep 60 + game_alive || return 1 + return 0 + fi + sleep 5 + done + echo "Windows scheduled task did not start Celeste" >&2 + return 1 + fi + local launch_cmd="cd '${LAUNCH_DIR}' && touch /tmp/akron-bulk.marker && { setsid env \ + AKRON_AUTOMATION_ENABLED=1 AKRON_AUTOMATION_SESSION_TOKEN='${TOKEN}' \ + DISPLAY=:0 XAUTHORITY=/home/${USER_NAME}/.Xauthority \ + XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + PATH=/home/${USER_NAME}/.local/bin:/usr/local/bin:/usr/bin:/bin \ + nohup ./start.n-w.sh >/tmp/akron-bulk-launch.log 2>&1 /dev/null 2>&1 || return 1 + else + timeout 45 ssh "${SSH_HOST_KEY_OPTIONS[@]}" "${USER_NAME}@${HOST}" \ + "$launch_cmd" >/dev/null 2>&1 || return 1 + fi + local i + # Commands are only processed once Everest finishes loading and the module + for i in $(seq 1 90); do + if rsh "[ '${GAME_ROOT}/log.txt' -nt /tmp/akron-bulk.marker ] && grep -q 'DONE LOADING' '${GAME_ROOT}/log.txt' 2>/dev/null"; then + if rsh "for pid in \$(pgrep -u ${USER_NAME} -f '[C]eleste.exe'); do tr '\\0' '\\n' < /proc/\$pid/environ 2>/dev/null | grep -q '^AKRON_AUTOMATION_ENABLED=1' && exit 0; done; exit 1"; then + sleep 10 + return 0 + fi + echo "automation env is not set on the Celeste process" >&2 + return 1 + fi + sleep 5 + done + echo "game did not finish loading" >&2 + return 1 +} + +# ssh exit 255: connection failed, game state unknown — assume alive so we +# never relaunch during an outage. pgrep 0: alive. anything else: dead. +game_alive() { + if [ "$WINDOWS" = "1" ]; then + rsh "tasklist /fi \"IMAGENAME eq Celeste.exe\" | findstr /i \"Celeste.exe\" >nul" >/dev/null 2>&1 + else + rsh "pgrep -u ${USER_NAME} -f '[C]eleste.exe' >/dev/null" >/dev/null 2>&1 + fi + case $? in + 0) return 0 ;; + 255) return 0 ;; + *) return 1 ;; + esac +} + +ssh_up() { + if [ "$WINDOWS" = "1" ]; then + rsh "ver" >/dev/null 2>&1 + else + rsh "true" >/dev/null 2>&1 + fi +} + +wait_ssh() { + # Overnight bulk runs: a Tailscale relay blip can last hours. Never give up. + local i=0 + while true; do + i=$((i + 1)) + if ssh_up; then return 0; fi + echo "ssh still down (try $i); sleeping 30s" >&2 + sleep 30 + done +} + +capture_game_log() { + if [ "$WINDOWS" = "1" ]; then + rsh "powershell -NoProfile -Command \"Get-Content -LiteralPath '${GAME_ROOT}/log.txt' -Tail 200\"" \ + > "$1" 2>/dev/null || true + else + rsh "tail -c 8000 '${GAME_ROOT}/log.txt' 2>/dev/null" \ + > "$1" 2>/dev/null || true + fi +} + + # Keep the sweep's recovery archive outside Saves so Akron's startup backup + # cannot archive it again on every game launch. + say "Waiting for ssh" + wait_ssh || { echo "ssh never came back; aborting" >&2; exit 1; } + + STAMP="$(date -u +%Y%m%d)" + if [ "$WINDOWS" = "1" ]; then + if [ -z "$WINDOWS_BACKUP" ]; then + echo "set AKRON_PERF_WINDOWS_BACKUP to an existing Windows save backup" >&2 + exit 1 + fi + if ! rsh "dir \"${WINDOWS_BACKUP}\" >nul 2>&1"; then + echo "Windows save backup does not exist: ${WINDOWS_BACKUP}" >&2 + exit 1 + fi + else + BACKUP_PATH="${LAUNCH_DIR}/AkronSavesBackup-bulk-${STAMP}.tar.gz" + if ! rsh "test -s '${BACKUP_PATH}'"; then + say "Backing up remote saves outside Saves" + rsh "tar -czf '${BACKUP_PATH}.part' -C '${GAME_ROOT}/Saves' \ + --exclude='AkronSavesBackup-*.tar.gz' --exclude='AkronBackups' --exclude='AkronTestBackups' \ + --exclude='AkronStartPos' --exclude='AkronSetups' --exclude='AkronLogs' \ + --exclude='AkronAutomation' --exclude='AkronNative' --exclude='Cache' . && + tar -tzf '${BACKUP_PATH}.part' >/dev/null && + mv '${BACKUP_PATH}.part' '${BACKUP_PATH}'" || exit 1 + fi + fi + say "Launching game" + if [ "$WINDOWS" = "1" ]; then + if ! game_alive; then + launch_game || exit 1 + fi + elif ! game_alive || ! rsh "grep -q 'Loaded assembly Akron' '${GAME_ROOT}/log.txt' 2>/dev/null"; then + stop_game + launch_game || exit 1 + fi + + # Inventory all loaded maps through a throwaway output dir. + # map-sweep.py creates --output itself, so point it one level under mktemp's dir. + refresh_inventory() { + local attempt list_dir inventory_args + for attempt in 1 2 3; do + list_dir="$(mktemp -d "${OUTPUT}/.inventory-XXXXXX")/inventory" + say "Listing loaded maps (attempt ${attempt})" + inventory_args=(--output "$list_dir" --list-only) + [ -n "$FILTER" ] && inventory_args+=(--filter "$FILTER") + if "${SWEEP_ENV[@]}" python3 scripts/akron-verify/map-sweep.py \ + "${inventory_args[@]}" > "$OUTPUT/map-list.txt.part"; then + mv "$OUTPUT/map-list.txt.part" "$OUTPUT/map-list.txt" + rm -rf "$(dirname "$list_dir")" + return 0 + fi + rm -f "$OUTPUT/map-list.txt.part" + rm -rf "$(dirname "$list_dir")" + # A timeout can leave Celeste alive but unable to process commands. + # Capture the log first, then recycle the process on every retry. + wait_ssh || return 1 + capture_game_log "$OUTPUT/inventory-attempt-${attempt}-game-log-tail.txt" + say "Game did not answer inventory; restarting" + stop_game + launch_game || continue + done + return 1 + } + if ! refresh_inventory; then + echo "map inventory failed after retries; is the game up with automation enabled?" >&2 + exit 1 + fi + MAP_COUNT="$(grep -c . "$OUTPUT/map-list.txt" || true)" + say "Found ${MAP_COUNT} maps" + cat "$OUTPUT/map-list.txt" + +SKIPPED=0 +while IFS=$'\t' read -r SID _REST; do + [ -n "$SID" ] || continue + SAFE="$(printf '%s' "$SID" | tr '/ ' '__')" + MAP_DIR="$OUTPUT/per-map/${SAFE}" + # Resume: skip maps whose every requested side already passed in this aggregate. + if [ "$RESUME" -eq 1 ] && [ -s "$AGGREGATE" ]; then + if python3 - "$AGGREGATE" "$SID" "$SIDES" <<'EOF'; then +import json, sys +agg, sid, sides = sys.argv[1], sys.argv[2], sys.argv[3].split(",") +try: + rows = json.load(open(agg)) +except Exception: + sys.exit(1) +want = {(sid, s) for s in sides} +done = {(r.get("sid"), r.get("side")) for r in rows if r.get("status") in ("pass", "skip")} + # A side that passed is done; a side the map does not provide (skip) is + # also done, or maps without b/c sides would re-sweep on every resume. +sys.exit(0 if want <= done else 1) +EOF + echo "SKIP (already pass) $SID" + SKIPPED=$((SKIPPED + 1)) + continue + fi + fi + mkdir -p "$MAP_DIR" || exit 1 + # map-sweep.py creates its output dir itself, so give each attempt a fresh + # numbered run directory under the map's evidence directory. + RUN_NUM="$(find "$MAP_DIR" -maxdepth 1 -type d -name 'attempt-*' 2>/dev/null | wc -l)" + RUN_DIR="$MAP_DIR/attempt-$RUN_NUM" + BLOCK_REASON="" + SWEEP_FAILED=0 + if ! game_alive; then + say "Game died; relaunching before $SID" + if ! wait_ssh; then + BLOCK_REASON="ssh stayed down before this map started" + else + capture_game_log "$MAP_DIR/attempt-${RUN_NUM}-prelaunch-game-log-tail.txt" + stop_game + launch_game || BLOCK_REASON="game process was gone before this map started; relaunch failed" + fi + fi + if [ -z "$BLOCK_REASON" ]; then + say "Sweeping $SID ($SIDES, rooms=$ROOMS)" + if "${SWEEP_ENV[@]}" timeout 1500 python3 scripts/akron-verify/map-sweep.py \ + --exact --filter "$SID" --sides "$SIDES" --rooms "$ROOMS" --output "$RUN_DIR"; then + : + else + echo "WARN $SID sweep exited $?; game may have crashed" >&2 + SWEEP_FAILED=1 + fi + fi + # A timeout or failed prelaunch may occur before map-sweep creates its directory. + mkdir -p "$RUN_DIR" || exit 1 + if [ -f "$MAP_DIR/attempt-${RUN_NUM}-prelaunch-game-log-tail.txt" ]; then + mv "$MAP_DIR/attempt-${RUN_NUM}-prelaunch-game-log-tail.txt" "$RUN_DIR/prelaunch-game-log-tail.txt" + fi + # Commit this attempt before any recovery can wait indefinitely or fail. + python3 - "$AGGREGATE" "$RUN_DIR" "$SID" "$BLOCK_REASON" "$SWEEP_FAILED" "$SIDES" <<'EOF' +import json, os, sys, tempfile +from pathlib import Path +agg_path, run_dir, sid = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3] +agg = json.load(open(agg_path)) +# A fresh run replaces the whole map: drop every prior row for this SID so +# retries never accumulate stale fail rows next to the new ones. Partial +# runs simply run again because not all sides are done. +agg = [r for r in agg if r.get("sid") != sid] +results = run_dir / "results.json" +rows = json.load(open(results)) if results.exists() else [] +for row in rows: + row["evidence"] = str(run_dir) + agg.append(row) +reported_sides = {row.get("side") for row in rows} +for side in dict.fromkeys(sys.argv[6].split(",")): + if side in reported_sides: + continue + reason = sys.argv[4] or ( + "sweep exited before reporting this side (crash or timeout)" + if sys.argv[5] == "1" else "sweep produced no result for this requested side") + agg.append({"sid": sid, "side": side, "status": "blocked", + "reason": reason, "evidence": str(run_dir)}) +fd, staged = tempfile.mkstemp(dir=str(agg_path.parent), suffix=".json") +with os.fdopen(fd, "w") as handle: + json.dump(agg, handle, indent=2) +os.replace(staged, agg_path) +EOF + if [ -n "$BLOCK_REASON" ]; then + echo "$BLOCK_REASON; stopping after $SID" >&2 + break + fi + if [ "$SWEEP_FAILED" -eq 1 ]; then + if ! ssh_up; then + echo "WARN ssh is down after $SID; waiting for the network before continuing" >&2 + wait_ssh || { echo "ssh stayed down; stopping after $SID" >&2; break; } + fi + # A failed command can leave Celeste alive but unable to consume the next + # command. Preserve its log after SSH recovers, then recycle every failure. + capture_game_log "$RUN_DIR/game-log-tail.txt" + echo "WARN recycling game after failed sweep for $SID" >&2 + stop_game + launch_game || { echo "relaunch failed; stopping after $SID" >&2; break; } + fi +done < "$OUTPUT/map-list.txt" +# Aggregate summary + bugs file (record only; never fixed here). +python3 - "$AGGREGATE" "$OUTPUT" <<'EOF' +import json, os, tempfile +from pathlib import Path +from collections import Counter +agg_path, out = Path(__import__("sys").argv[1]), Path(__import__("sys").argv[2]) +rows = json.load(open(agg_path)) +counts = Counter(r.get("status", "?") for r in rows) +summary = {"maps": len({r.get("sid") for r in rows}), + "rows": len(rows), **dict(counts)} +fd, staged = tempfile.mkstemp(dir=str(out), suffix=".json") +with os.fdopen(fd, "w") as handle: + json.dump(summary, handle, indent=2) +os.replace(staged, out / "summary.json") +bugs = ["# Bulk sweep bugs (record only, not fixed)", ""] +for r in rows: + if r.get("status") in ("fail", "blocked"): + bugs.append(f"- {r.get('status','?').upper()} {r.get('sid')} [{r.get('side')}] " + f"{r.get('reason','')} (evidence: {r.get('evidence','?')})") +if len(bugs) == 2: + bugs.append("- none") +fd, staged = tempfile.mkstemp(dir=str(out), suffix=".md") +with os.fdopen(fd, "w") as handle: + handle.write("\n".join(bugs) + "\n") +os.replace(staged, out / "bugs.md") +print(json.dumps(summary)) +EOF + +say "Bulk sweep finished" +echo "aggregate: $AGGREGATE" +echo "bugs: $OUTPUT/bugs.md" +echo "resumed skips this run: $SKIPPED (authoritative counts: $OUTPUT/summary.json)" diff --git a/scripts/akron-verify/map-sweep.py b/scripts/akron-verify/map-sweep.py new file mode 100755 index 00000000..75e21398 --- /dev/null +++ b/scripts/akron-verify/map-sweep.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Capture, export/import, cold-load and warm-load StartPos across loaded maps. + +Uses the existing in-game QA commands on an explicitly configured test machine. +Back up its saves first. This writes StartPos and setup packs in throwaway save +slot 99; it neither deploys a build nor changes the enabled mod set. +""" + +import argparse +import json +import os +from pathlib import Path +import re +import shlex +import subprocess +import tempfile +import time + + +def arguments(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--filter", default="", help="substring of loaded map SID") + parser.add_argument("--exact", action="store_true", help="match --filter as a full SID instead of a substring") + parser.add_argument("--limit", type=int, default=0, help="maximum maps; 0 means all matches") + parser.add_argument("--sides", default="normal", help="comma-separated normal,b,c") + parser.add_argument("--rooms", type=int, default=2, choices=range(1, 6)) + parser.add_argument("--room", action="append", help="exact room name to check; repeat for up to five rooms") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--list-only", action="store_true", help="print matching map SIDs and exit without checking") + return parser.parse_args() + + +class Game: + def __init__(self, output): + self.output = output + output.mkdir(parents=True, exist_ok=False) + self.index = 0 + host = os.environ["AKRON_PERF_HOST"] + user = os.environ["AKRON_PERF_USER"] + self.remote_python = os.environ.get("AKRON_PERF_REMOTE_PYTHON", "python3") + self.windows = os.environ.get("AKRON_PERF_WINDOWS") == "1" + self.root = os.environ["AKRON_PERF_GAME_ROOT"] + self.token = os.environ["AKRON_PERF_TOKEN"] + self.environment = dict(os.environ) + password = os.environ.get("AKRON_PERF_PASSWORD") or os.environ.get("SSHPASS") + self.ssh_prefix = [] + if password: + self.environment["SSHPASS"] = password + self.ssh_prefix = ["sshpass", "-e"] + # -n: never read stdin, so the sweep is safe to run inside a + # while-read loop without eating the caller's input. + ssh_options = ["ssh", "-n", "-o", "ConnectTimeout=15", "-o", "StrictHostKeyChecking=yes"] + if os.environ.get("AKRON_PERF_KNOWN_HOSTS"): + ssh_options.extend(["-o", "UserKnownHostsFile=" + os.environ["AKRON_PERF_KNOWN_HOSTS"]]) + self.connection_directory = tempfile.TemporaryDirectory(prefix="akron-map-ssh-") + ssh_options.extend(["-o", "ControlMaster=auto", "-o", "ControlPersist=60", + "-o", "ControlPath=" + self.connection_directory.name + "/socket"]) + self.ssh_target = user + "@" + host + self.remote_stage_path = "C:\\Users\\" + user + "\\akron-automation-upload.txt" + self.ssh = self.ssh_prefix + ssh_options + [self.ssh_target] + self.scp = self.ssh_prefix + ["scp"] + ssh_options[2:] + + def remote(self, program): + command = self.ssh + [self.remote_python + " -c " + shlex.quote(program)] + last_error = None + for attempt in range(4): + try: + return subprocess.run(command, env=self.environment, check=True, + capture_output=True, text=True, timeout=25).stdout + except (subprocess.TimeoutExpired, subprocess.CalledProcessError) as error: + last_error = error + time.sleep(2 * (attempt + 1)) + raise TimeoutError("remote ssh failed after retries: " + str(last_error)) + + def windows_shell(self, program): + command = self.ssh + [program] + return subprocess.run(command, env=self.environment, check=True, capture_output=True, + text=True, timeout=25).stdout + + def automation_directory(self): + separator = "\\" if self.windows else "/" + return self.root + separator + "Saves" + separator + "AkronAutomation" + + def write_remote_file(self, path, text): + if not self.windows: + directory = os.path.dirname(path) + self.remote( + "from pathlib import Path; p=Path(" + repr(directory) + ");" + "(p/'" + os.path.basename(path) + "').write_text(" + repr(text) + ")" + ) + return + with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as staged: + staged.write(text) + staged_name = staged.name + try: + # Windows OpenSSH scp rejects destination paths containing spaces. + # Upload to the user's profile, then move it with cmd.exe. + subprocess.run([*self.scp, staged_name, + self.ssh_target + ":" + self.remote_stage_path.replace("\\", "/")], + env=self.environment, check=True, capture_output=True, + text=True, timeout=30) + self.windows_shell('move /y "' + self.remote_stage_path + '" "' + path + '"') + finally: + os.unlink(staged_name) + + def remove_remote_file(self, path): + if not self.windows: + self.remote( + "from pathlib import Path; Path(" + repr(path) + ").unlink(missing_ok=True)" + ) + return + self.windows_shell('if exist "' + path + '" del /f /q "' + path + '"') + + def read_remote_file(self, path): + if not self.windows: + return self.remote( + "from pathlib import Path; import sys; sys.stdout.write(Path(" + + repr(path) + ").read_text(errors='replace'))" + ) + result = subprocess.run(self.ssh + ['type "' + path + '"'], env=self.environment, + capture_output=True, text=True, errors="replace", timeout=30) + return result.stdout + + def remote_file_exists(self, path): + if not self.windows: + return self.remote( + "from pathlib import Path; print(Path(" + repr(path) + ").exists())" + ).strip() == "True" + return self.windows_shell( + 'if exist "' + path + '" (echo YES) else (echo NO)').strip() == "YES" + + def log_size(self): + path = self.root + ("/" if not self.windows else "\\") + \ + "Saves" + ("\\" if self.windows else "/") + "AkronLogs" + \ + ("\\" if self.windows else "/") + "akron-current.log" + if not self.windows: + return int(self.remote( + "from pathlib import Path; print(Path(" + repr(path) + ").stat().st_size)" + )) + return int(self.windows_shell( + "powershell -NoProfile -Command \"(Get-Content -Raw -LiteralPath '" + path + + "').Replace(([char]13).ToString()+([char]10).ToString(),([char]10).ToString()).Length\"" + ).strip()) + + def log(self, offset): + path = self.root + ("/" if not self.windows else "\\") + \ + "Saves" + ("\\" if self.windows else "/") + "AkronLogs" + \ + ("\\" if self.windows else "/") + "akron-current.log" + if not self.windows: + return self.remote( + "from pathlib import Path; import sys; p=Path(" + + repr(path) + + "); f=p.open('rb'); f.seek(" + str(offset) + + " if p.stat().st_size >= " + str(offset) + + " else 0); sys.stdout.write(f.read().decode(errors='replace'))" + ) + whole = self.read_remote_file(path) + return whole[offset:] if len(whole) >= offset else whole + + def send(self, body, label): + self.index += 1 + evidence = self.output / f"{self.index:04d}-{label}.txt" + directory = self.automation_directory() + command_path = directory + ("\\" if self.windows else "/") + "command.txt" + result_path = directory + ("\\" if self.windows else "/") + "last-result.txt" + payload = "token: " + self.token + "\n" + body + "\n" + staging_path = directory + ("\\" if self.windows else "/") + "command.txt.part" + self.remove_remote_file(command_path) + self.remove_remote_file(result_path) + self.write_remote_file(staging_path, payload) + if self.windows: + self.windows_shell('move /y "' + staging_path + '" "' + command_path + '"') + else: + self.remote( + "from pathlib import Path; Path(" + repr(staging_path) + + ").rename(" + repr(command_path) + ")" + ) + deadline = time.monotonic() + 180 + response = "" + while time.monotonic() < deadline: + time.sleep(1) + if self.remote_file_exists(command_path): + continue + if not self.remote_file_exists(result_path): + continue + response = self.read_remote_file(result_path).replace("\r\n", "\n") + if response.startswith(("status: complete", "status: failed", "status: rejected")): + evidence.write_text(response) + if not response.startswith("status: complete"): + raise RuntimeError(response[:500]) + return response + evidence.write_text(response) + raise TimeoutError("In-game command did not finish: " + label) + + + +def probe(output, prefix): + fields = ("position", "speed", "facing", "animation", "animation-frame", "state", "stamina", "dashes") + captured = {} + for field in fields: + value_pattern = r"(.*)$" if field == "animation" else r"(.+)$" + match = re.search(r"^" + re.escape(prefix + "-" + field + ": ") + value_pattern, output, re.M) + if not match: + raise RuntimeError("Missing player probe: " + prefix + "-" + field) + captured[field] = match.group(1) + return captured + + +def require_loaded(output, slot): + if f"qa-startpos-load-probe: loaded;slot={slot}\n" not in output: + raise RuntimeError(f"Slot {slot} did not load; see the load response and map log") + if f"startpos-last-loaded-slot: {slot}\n" not in output: + raise RuntimeError(f"Slot {slot} was not recorded as loaded") + + +def room_inventory(game): + rooms = [] + offset = 0 + while offset >= 0: + inventory = game.send(f"akron_qa_list_rooms {offset} 100", "rooms") + rooms.extend(json.loads(name) for name in + re.findall(r"^qa-map-room: (.+)$", inventory, re.M)) + next_page = re.search(r"^qa-map-rooms-next: (-?\d+)$", inventory, re.M) + if not next_page: + raise RuntimeError("Room inventory was incomplete") + next_offset = int(next_page.group(1)) + if next_offset != -1 and next_offset <= offset: + raise RuntimeError("Room inventory did not advance") + offset = next_offset + return list(dict.fromkeys(rooms)) + + +class RoomUnavailable(RuntimeError): + """The room has not reached a state where StartPos capture is supported.""" + + +def finish_room_cutscene(game): + deadline = time.monotonic() + 30 + requested_skip = False + while time.monotonic() < deadline: + status = game.send("akron_qa_cutscene_state status", "cutscene-state") + match = re.search(r"qa-cutscene-state: in-cutscene=(True|False);skipping=(True|False);", status) + if not match: + raise RuntimeError("Could not inspect the room's cutscene state") + active, skipping = match.groups() + if active == "False" and skipping == "False": + return requested_skip + if active == "True" and skipping == "False" and not requested_skip: + game.send("akron_skip_cutscene", "skip-cutscene") + requested_skip = True + time.sleep(1) + raise RoomUnavailable("Room remained in a cutscene; capture was not tested") + + +def check_map(game, area, side, room_count, selected_rooms=None): + report = {"sid": area["sid"], "area": area["id"], "side": side, "rooms": []} + try: + log_offset = game.log_size() + except TimeoutError: + log_offset = 0 + try: + entry = game.send(f"akron_qa_enter_level {area['id']} {side} 99", "enter") + if "qa-enter-level: missing-mode" in entry: + return {**report, "status": "skip", "reason": "map has no requested side"} + if "qa-enter-level: area=" not in entry: + raise RuntimeError("Map entry failed: " + entry[:500]) + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + time.sleep(2) + status = game.send("akron_status", "entry-state") + if "scene: Level\n" in status and "room: " + area["sid"] + " / " in status: + break + else: + raise TimeoutError("Map did not finish loading: " + area["sid"]) + available = room_inventory(game) + if not available: + raise RuntimeError("No room inventory available") + selected = selected_rooms or list(dict.fromkeys( + available[index * len(available) // room_count] for index in range(room_count))) + if any(room not in available for room in selected): + raise RuntimeError("A requested room does not exist on this side") + game.send("akron_startpos wait on\nakron_startpos respawn off\n" + + "\n".join(f"akron_startpos clear {slot}" for slot in range(1, 16)), "prepare") + references = {} + for slot, room in enumerate(selected, 1): + warped = game.send("akron_freeze off\nakron_qa_warp_room " + + json.dumps(room, ensure_ascii=False), "warp") + warp_match = re.search(r"^qa-warp-room: room-json=(.+)$", warped, re.M) + try: + warped_room = json.loads(warp_match.group(1)) if warp_match else None + except json.JSONDecodeError as error: + raise RuntimeError("Invalid room warp acknowledgement") from error + if warped_room != room: + raise RuntimeError("Room warp failed: " + room) + time.sleep(2) + requested_cutscene_skip = finish_room_cutscene(game) + capture = game.send( + "akron_qa_session_state sweep-flag sweep-counter 73\n" + f"akron_qa_startpos_reference_capture {slot} sweep-{game.index}-{slot}\n" + "akron_qa_messages 5", "capture") + if "qa-startpos-reference-capture: captured" not in capture: + raise RuntimeError("Capture refused in room " + room) + references[slot] = probe(capture, "qa-startpos-reference-capture") + captured_room = re.search(r"^startpos-room: (.*)$", capture, re.M) + if not captured_room: + raise RuntimeError("Capture did not report its saved room") + report["rooms"].append({"room": captured_room.group(1), "requested_room": room, + "slot": slot, "state": references[slot], + "cutscene_skip_requested": requested_cutscene_skip}) + + game.send("akron_qa_pause pause", "pause-for-save") + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + persisted = game.send("akron_startpos status", "persistence") + if "startpos-restart-copies-outstanding: 0\n" in persisted: + break + time.sleep(2) + else: + raise RuntimeError("Restart copies did not finish while paused") + for slot in references: + durable = game.send(f"akron_startpos slot {slot}", "persisted-slot") + if "startpos-set: true\n" not in durable or "startpos-snapshot-on-disk: true\n" not in durable: + raise RuntimeError(f"Slot {slot} lost its restart copy; see the map log") + + # Reimport before each slot's first load so another slot's warm-up cannot + # conceal a failed cold restore. The second load exercises its warm copy. + exported = game.send(f"akron_setup export startpos map-sweep-{time.time_ns()}", "export") + export_match = re.search(r"^setup-export-started: (.+\.akr)$", exported, re.M) + if not export_match: + raise RuntimeError("Snapshot export did not start") + export_path = export_match.group(1) + deadline = time.monotonic() + 1200 + while time.monotonic() < deadline: + export_status = game.send("akron_setup", "export-status") + if "setup-export-in-progress: false\n" in export_status: + break + time.sleep(2) + else: + raise TimeoutError("Snapshot export was still running after 20 minutes; loads were not tested") + for slot in references: + game.send("akron_qa_pause pause", "pause-for-import") + imported = game.send("akron_setup import startpos " + + json.dumps(export_path, ensure_ascii=False), "import") + if "setup-imported: true\n" not in imported: + raise RuntimeError("Snapshot pack import failed") + game.send("akron_qa_pause unpause", "resume") + for path in ("cold", "warm"): + loaded = game.send( + "akron_qa_session_state sweep-flag sweep-counter 0\n" + f"akron_qa_startpos_load_probe {slot} sweep-flag sweep-counter\nakron_startpos status", + path + "-load") + require_loaded(loaded, slot) + actual = probe(loaded, "qa-startpos-load-probe") + if actual != references[slot]: + raise RuntimeError(f"Slot {slot} state mismatch: expected {references[slot]}, got {actual}") + if "qa-session-flag: sweep-flag=true" not in loaded or "qa-session-counter: sweep-counter=73" not in loaded: + raise RuntimeError(f"Slot {slot} did not restore controlled session state") + report["status"] = "pass" + except (RuntimeError, TimeoutError) as error: + # Timeouts (in-game hang, game crash, ssh loss) must land here too, or + # the sweep dies before the per-side log snapshot below is taken — the + # snapshot is the crash evidence the bulk runner needs. + report.update(status="fail", reason=type(error).__name__ + ": " + str(error)) + if isinstance(error, RoomUnavailable): + report["status"] = "blocked" + if isinstance(error, TimeoutError): + report["requires_recovery"] = True + if str(error).startswith("remote ssh failed"): + report["status"] = "blocked" + try: + new_log = game.log(log_offset) + except TimeoutError: + new_log = "" + (game.output / f"map-{area['id']}-{side}.log").write_text(new_log) + errors = [line for line in new_log.splitlines() + if any(message in line for message in ( + "could not be loaded", "could not be kept warm", "was removed because", + "was not replaced because"))] + cold = re.findall(r"StartPos cold restore finished in ([\d.]+) ms", new_log) + warm = re.findall(r"StartPos warm restore finished in ([\d.]+) ms", new_log) + report.update(cold_ms=[float(value) for value in cold], warm_ms=[float(value) for value in warm]) + if report["status"] == "pass" and (errors or len(cold) < len(references) or len(warm) < len(references)): + report.update(status="fail", reason="Restore errors or missing cold/warm evidence") + if errors: + report["errors"] = errors + return report + + +def main(): + args = arguments() + if args.room and len(args.room) > 5: + raise ValueError("At most five --room arguments are supported") + sides = args.sides.split(",") + if any(side not in ("normal", "b", "c") for side in sides): + raise ValueError("Sides must be normal,b,c") + game = Game(args.output) + inventory = game.send("akron_qa_list_maps", "inventory") + areas = [{"sid": sid, "id": int(area)} for sid, area in + re.findall(r"^qa-map: sid=([^;]+);id=(\d+);", inventory, re.M) + if (sid.lower() == args.filter.lower() if args.exact and args.filter else args.filter.lower() in sid.lower())] + if args.limit: + areas = areas[:args.limit] + if not areas: + raise RuntimeError("No maps matched") + if args.list_only: + for area in areas: + print(f"{area['sid']}\tid={area['id']}", flush=True) + return 0 + reports = [] + blocked_reason = None + for area in areas: + for side in sides: + print("Checking", area["sid"], side, flush=True) + if blocked_reason: + report = {"sid": area["sid"], "area": area["id"], "side": side, + "rooms": [], "status": "blocked", "reason": blocked_reason} + else: + try: + report = check_map(game, area, side, args.rooms, args.room) + except Exception as error: # noqa: BLE001 - preserve evidence on harness errors + report = {"sid": area["sid"], "area": area["id"], "side": side, + "rooms": [], "status": "blocked", + "reason": "harness: " + type(error).__name__ + ": " + str(error)[:300]} + blocked_reason = report["reason"] + if report.get("requires_recovery"): + blocked_reason = f"Not tested after a timeout during {area['sid']} [{side}]: {report['reason']}" + reports.append(report) + staged = args.output / "results.json.part" + staged.write_text(json.dumps(reports, indent=2)) + staged.replace(args.output / "results.json") + print(report["status"].upper(), area["sid"], side, report.get("reason", ""), flush=True) + print(json.dumps({status: sum(row["status"] == status for row in reports) + for status in ("pass", "fail", "skip", "blocked")}), flush=True) + return int(any(row["status"] in ("fail", "blocked") for row in reports)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/akron-verify/run.sh b/scripts/akron-verify/run.sh index 2dd54c2b..01b7c0ae 100755 --- a/scripts/akron-verify/run.sh +++ b/scripts/akron-verify/run.sh @@ -46,9 +46,9 @@ # exact values the setup wrote (facing, dashes, flag, counter - position is one # gravity frame past the set point, so it is deterministic but not the set value), # and keeps the whole field set as a baseline; checks 2 and 3 assert their probes -# reproduce that baseline exactly. A refused load still arms a pixel capture, so -# the hash comparisons alone never claimed the load succeeded - these field -# assertions are what does. +# reproduce that baseline exactly. A refused load reports failure without arming +# a pixel capture. The named state assertions, not buffer hashes, check that the +# requested state was restored. set -uo pipefail @@ -249,7 +249,8 @@ if [ -z "$REF_HASH" ]; then fi REF_ROOM="$(send 'akron_status' 40 | sed -n 's/^startpos-room: //p')" -send "akron_qa_warp_room ${AWAY_ROOM}" 60 >/dev/null +AWAY_ROOM_JSON="$(python3 -c 'import json, sys; print(json.dumps(sys.argv[1], ensure_ascii=False))' "$AWAY_ROOM")" || exit 1 +send "akron_qa_warp_room ${AWAY_ROOM_JSON}" 60 >/dev/null sleep 4 AWAY_ROOM_ACTUAL="$(send 'akron_status' 40 | sed -n 's|^room: .*/ ||p')" diff --git a/tests/frosthelper-savestate-tests.cs b/tests/frosthelper-savestate-tests.cs index e6a7cf64..087df0f2 100644 --- a/tests/frosthelper-savestate-tests.cs +++ b/tests/frosthelper-savestate-tests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; using Celeste.Mod.Akron; using MonoMod.Utils; using Xunit; @@ -55,6 +57,63 @@ public void DeepClonePreservesMonoModDynamicDataSidecars() { } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CopyIntoRestoresReadonlyFieldsWithTheRequestedCopyDepth(bool deep) { + ReadonlyCloneProbe source = new ReadonlyCloneProbe(47, new List { 37 }); + ReadonlyCloneProbe target = new ReadonlyCloneProbe(12, new List { 5 }); + AkronDeepClone.Initialize(); + AkronDeepClone.ClearSharedState(); + try { + if (deep) { + AkronDeepClone.CopyInto(source, target); + } else { + Force.DeepCloner.DeepClonerExtensions.ShallowCloneTo(source, target); + } + + Assert.Equal(47, target.Value); + Assert.Same(target.Items, target.Alias); + source.Items.Add(99); + Assert.Equal(deep ? new[] { 37 } : new[] { 37, 99 }, target.Items); + } finally { + AkronDeepClone.Reset(); + } + } + + [Theory] + [InlineData("Celeste.Mod.LuaCoroutine, Celeste")] + [InlineData("NLua.LuaTable, NLua")] + public void NativeLuaStateIsRefusedBeforeCloningItsHandles(string typeName) { + object source = RuntimeHelpers.GetUninitializedObject(Type.GetType(typeName, throwOnError: true)!); + GC.SuppressFinalize(source); + object? clone = null; + AkronDeepClone.Initialize(); + AkronDeepClone.ClearSharedState(); + try { + Exception? refusal = Record.Exception(() => clone = AkronSaveLoadService.DeepClone(source)); + if (clone != null) { + GC.SuppressFinalize(clone); + } + + Assert.IsType(refusal); + } finally { + AkronDeepClone.Reset(); + } + } + + private sealed class ReadonlyCloneProbe { + public readonly int Value; + public readonly List Items; + public readonly List Alias; + + public ReadonlyCloneProbe(int value, List items) { + Value = value; + Items = items; + Alias = items; + } + } + private sealed class FrostHelperPersistedProbe : FrostHelper.ModIntegration.ISavestatePersisted { } diff --git a/tests/module-settings-tests.cs b/tests/module-settings-tests.cs index 5c8071a0..282b81cf 100644 --- a/tests/module-settings-tests.cs +++ b/tests/module-settings-tests.cs @@ -2927,13 +2927,72 @@ public void AutomationCommandFilesRequireOptInTokenCapsAndAllowlistedCommands() token, out _, out error)); - Assert.Contains("allowlisted", error, StringComparison.OrdinalIgnoreCase); Assert.False(AkronAutomationService.TryParseCommandFileForTesting( "token: wrong-token\nakron_status", token, out _, out error)); - Assert.Contains("token", error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AutomationQuotedArgumentsPreserveExactTextAndEmptyStrings() + { + string token = new string('t', 32); + string command = "akron_qa_warp_room \" \\\"north\\\" \\\\ passage\\r\\n雪\\u03a9 \" \"\" 0"; + + Assert.True(AkronAutomationService.TryParseCommandFileForTesting( + "token: " + token + "\n" + command, + token, + out IReadOnlyList commands, + out string error), error); + Assert.Equal( + new[] { "akron_qa_warp_room", " \"north\" \\ passage\r\n雪Ω ", "", "0" }, + AkronAutomationService.Tokenize(Assert.Single(commands))); + } + + [Fact] + public void AutomationEncodedNewlinesStayInsideOneArgument() + { + string token = new string('t', 32); + + Assert.True(AkronAutomationService.TryParseCommandFileForTesting( + "token: " + token + "\nakron_qa_warp_room \"room\\r\\nakron_status\\nquit\" 0\nakron_status", + token, + out IReadOnlyList commands, + out string error), error); + Assert.Collection(commands, + command => Assert.Equal( + new[] { "akron_qa_warp_room", "room\r\nakron_status\nquit", "0" }, + AkronAutomationService.Tokenize(command)), + command => Assert.Equal(new[] { "akron_status" }, AkronAutomationService.Tokenize(command))); + } + + [Theory] + [InlineData("\"unfinished")] + [InlineData("\"trailing\\")] + [InlineData("\"bad\\q\"")] + [InlineData("\"bad\\u12xz\"")] + [InlineData("\"raw\tcontrol\"")] + [InlineData("\"room\"suffix")] + [InlineData("\"room\"\"next\"")] + [InlineData("prefix\"room\"")] + public void AutomationMalformedQuotedArgumentsRejectTheCommandFile(string argument) + { + string token = new string('t', 32); + + Assert.False(AkronAutomationService.TryParseCommandFileForTesting( + "token: " + token + "\nakron_status\nakron_qa_warp_room " + argument + "\nakron_status", + token, + out _, + out _)); + } + + [Fact] + public void AutomationBareWindowsPathsKeepLiteralBackslashes() + { + Assert.Equal( + new[] { "akron_tas_file", @"C:\new\test.tas", @"D:\folder\" }, + AkronAutomationService.Tokenize(@"akron_tas_file C:\new\test.tas D:\folder\")); } [Fact] diff --git a/tests/setup-pack-tests.cs b/tests/setup-pack-tests.cs index 731b1f3a..47be9031 100644 --- a/tests/setup-pack-tests.cs +++ b/tests/setup-pack-tests.cs @@ -867,9 +867,8 @@ public void ASetupPackFromAnOlderAkronIsRefusedWithAMessageThatSaysWhatToDo() { Assert.Contains("akron-setup-v4", refusal.Message); Assert.Contains(AkronSetupPacks.SetupPackFormat, refusal.Message); - Assert.Contains("built rooms differently", refusal.Message); - Assert.Contains("Recreate the setup and its StartPos slots in this build", refusal.Message); - Assert.Contains("then export a new pack", refusal.Message); + Assert.Contains("Export a new pack with this build", refusal.Message); + Assert.Contains("Recreate older StartPos slots only if this build can no longer load them", refusal.Message); } [Fact] @@ -903,7 +902,7 @@ public void AnArchiveWhosePayloadPredatesTheFormatBumpIsRefusedWhenItIsRead() { } [Fact] - public void AStartPosPackNamesItsSnapshotAttachmentAfterTheCurrentDocumentFormat() { + public void AStartPosPackReferencesTheCanonicalSnapshotBundle() { const string areaSid = "Maps/EntryName"; const int slot = 6; string stateSlotName = AkronActions.GetStartPosStateSlotName(areaSid, slot); @@ -926,11 +925,9 @@ public void AStartPosPackNamesItsSnapshotAttachmentAfterTheCurrentDocumentFormat AkronSetupPack written = AkronSetupPacks.Read(archivePath); - // The attachment name states which fresh-room baseline the document inside - // it was measured against, so it tracks the document format rather than the - // pack format. A stale name here would let a v7 attachment ride inside a - // pack that claims to be current. - Assert.Equal("startpos/6.v10.json.gz", Assert.Single(written.StartPositions).Value.SnapshotEntry); + // Slots share one transport entry; the document retains its own graph format. + Assert.Equal("startpos/snapshots.bin.br", Assert.Single(written.StartPositions).Value.SnapshotEntry); + Assert.Matches("^[a-f0-9]{64}$", written.SnapshotBundleSha256); Assert.Equal(AkronSetupPacks.SetupPackFormat, written.Format); } finally { AkronStartPosReconstruction.DeleteSnapshot(stateSlotName); @@ -1177,7 +1174,7 @@ private static AkronSetupPack PackWithSnapshotPaths(Dictionary snap pack.StartPositions[snapshot.Key] = new AkronStartPosPackEntry { AreaSid = pack.ArchiveMapSid, Room = "room-" + snapshot.Key, - SnapshotEntry = "startpos/" + snapshot.Key + ".v10.json.gz", + SnapshotEntry = AkronSnapshotBundle.EntryName, SnapshotSha256 = new string('0', 64) }; pack.SnapshotSourcePaths[snapshot.Key] = snapshot.Value; diff --git a/tests/snapshot-bundle-tests.cs b/tests/snapshot-bundle-tests.cs new file mode 100644 index 00000000..f639485c --- /dev/null +++ b/tests/snapshot-bundle-tests.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using Xunit; + +namespace Celeste.Mod.Akron.Tests; + +public sealed class SnapshotBundleTests { + [Fact] + public void RoundTripPreservesArbitraryBytesAndSharedDocuments() { + using var files = new BundleFiles(); + byte[] first = new byte[192 * 1024 + 17]; + new Random(423).NextBytes(first); + // Include long base64 runs, padding, escaped strings, and numeric lexemes. + Encoding.ASCII.GetBytes(new string('A', 70001)).CopyTo(first, 31); + Encoding.UTF8.GetBytes("\"AAAA==\\\"\",-0,1.00000000000000000001,1e+300,é").CopyTo(first, 70100); + byte[] second = (byte[])first.Clone(); + second[^1] ^= 1; + var expected = new Dictionary { [2] = first, [99] = second }; + var sources = new[] { files.Source(99, second), files.Source(2, first) }; + Dictionary written = AkronSnapshotBundle.Write(files.Bundle, sources); + var visited = new List(); + using var stream = File.OpenRead(files.Bundle); + Dictionary read = AkronSnapshotBundle.Read(stream, (slot, document) => { + visited.Add(slot); + using var restored = new MemoryStream(); + document.CopyTo(restored, 997); + Assert.Equal(expected[slot], restored.ToArray()); + }); + Assert.Equal(new[] { 2, 99 }, visited); + foreach (int slot in visited) { + string hash = Convert.ToHexString(SHA256.HashData(expected[slot])).ToLowerInvariant(); + Assert.Equal(hash, written[slot]); + Assert.Equal(hash, read[slot]); + } + } + + [Fact] + public void ChangedSourceCannotProduceAnArchive() { + using var files = new BundleFiles(); + AkronSnapshotBundle.Source source = files.Source(1, Encoding.UTF8.GetBytes("original")); + File.AppendAllText(source.Path, "changed"); + Assert.Throws(() => AkronSnapshotBundle.Write(files.Bundle, new[] { source })); + Assert.False(File.Exists(files.Bundle)); + Assert.Empty(Directory.GetDirectories(files.DirectoryPath)); + } + + [Fact] + public void CancellationPreservesAnExistingDestination() { + using var files = new BundleFiles(); + AkronSnapshotBundle.Source source = files.Source(1, Encoding.UTF8.GetBytes("original")); + File.WriteAllText(files.Bundle, "previous export"); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + Assert.Throws(() => AkronSnapshotBundle.Write(files.Bundle, new[] { source }, cancellation.Token)); + Assert.Equal("previous export", File.ReadAllText(files.Bundle)); + Assert.Empty(Directory.GetDirectories(files.DirectoryPath)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void RejectsTrailingAndTruncatedBrotli(bool trailing) { + using var files = new BundleFiles(); + AkronSnapshotBundle.Write(files.Bundle, new[] { files.Source(1, Encoding.UTF8.GetBytes("snapshot")) }); + byte[] valid = File.ReadAllBytes(files.Bundle); + byte[] invalid = new byte[valid.Length + (trailing ? 1 : -1)]; + Array.Copy(valid, invalid, Math.Min(valid.Length, invalid.Length)); + using var stream = new MemoryStream(invalid); + Action read = () => AkronSnapshotBundle.Read(stream, (_, document) => document.CopyTo(Stream.Null)); + if (trailing) Assert.Throws(read); + else Assert.Throws(read); + } + + [Fact] + public void RejectsAConsumerThatLeavesDocumentBytesUnread() { + using var files = new BundleFiles(); + AkronSnapshotBundle.Write(files.Bundle, new[] { files.Source(1, Encoding.UTF8.GetBytes("snapshot")) }); + using var stream = File.OpenRead(files.Bundle); + Assert.Throws(() => AkronSnapshotBundle.Read(stream, (_, _) => { })); + } + + [Theory] + [InlineData(4097, 0, 0)] + [InlineData(0, 100, 0)] + [InlineData(0, 1, 100)] + public void RejectsOutOfRangeBundleHeaders(int dictionaryCount, int documents, int slot) { + using var raw = new MemoryStream(); + using (var writer = new BinaryWriter(raw, Encoding.UTF8, leaveOpen: true)) { + writer.Write(Encoding.ASCII.GetBytes("AKRSB001")); + writer.Write(dictionaryCount); + writer.Write(documents); + writer.Write(slot); + } + using MemoryStream encoded = LiteralBundle(raw.ToArray()); + Assert.Throws(() => AkronSnapshotBundle.Read(encoded, (_, document) => document.CopyTo(Stream.Null))); + } + + [Fact] + public void ReadsAnIndependentlyConstructedDictionaryReference() { + using var raw = new MemoryStream(); + using (var writer = new BinaryWriter(raw, Encoding.UTF8, leaveOpen: true)) { + writer.Write(Encoding.ASCII.GetBytes("AKRSB001")); + writer.Write(1); writer.Write(3); writer.Write(Encoding.ASCII.GetBytes("abc")); + writer.Write(1); writer.Write(7); writer.Write(6); + writer.Write((byte)1); writer.Write(0); + writer.Write((byte)0); writer.Write(3); writer.Write(Encoding.ASCII.GetBytes("def")); + } + using MemoryStream encoded = LiteralBundle(raw.ToArray()); + AkronSnapshotBundle.Read(encoded, (slot, document) => { + Assert.Equal(7, slot); + using var restored = new StreamReader(document, leaveOpen: true); + Assert.Equal("abcdef", restored.ReadToEnd()); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void EnforcesExpandedPackBudgetBeforeDeliveringTheCrossingDocument(bool overBudget) { + const int blockBytes = 65536; + int[] lengths = { 384 * 1024 * 1024, 384 * 1024 * 1024, 256 * 1024 * 1024 }; + using var raw = new MemoryStream(); + using (var writer = new BinaryWriter(raw, Encoding.UTF8, leaveOpen: true)) { + writer.Write(Encoding.ASCII.GetBytes("AKRSB001")); + writer.Write(1); + writer.Write(blockBytes); + writer.Write(new byte[blockBytes]); + writer.Write(lengths.Length + (overBudget ? 1 : 0)); + for (int i = 0; i < lengths.Length; i++) { + writer.Write(i + 1); + writer.Write(lengths[i]); + for (int remaining = lengths[i]; remaining > 0; remaining -= blockBytes) { + writer.Write((byte)1); + writer.Write(0); + } + } + if (overBudget) { + writer.Write(4); + writer.Write(1); + writer.Write((byte)0); + writer.Write(1); + writer.Write((byte)0); + } + } + using MemoryStream encoded = LiteralBundle(raw.ToArray()); + var visited = new List(); + var buffer = new byte[blockBytes]; + long consumed = 0; + Action read = () => AkronSnapshotBundle.Read(encoded, (slot, document) => { + visited.Add(slot); + int count; + while ((count = document.Read(buffer)) > 0) consumed += count; + }); + if (overBudget) Assert.Throws(read); + else read(); + Assert.Equal(new[] { 1, 2, 3 }, visited); + Assert.Equal(1024L * 1024 * 1024, consumed); + } + + [Fact] + public void RejectsExcessiveOneByteCommands() { + const int length = 2048; + using var raw = new MemoryStream(); + using (var writer = new BinaryWriter(raw, Encoding.UTF8, leaveOpen: true)) { + writer.Write(Encoding.ASCII.GetBytes("AKRSB001")); + writer.Write(0); writer.Write(1); writer.Write(1); writer.Write(length); + for (int index = 0; index < length; index++) { + writer.Write((byte)0); writer.Write(1); writer.Write((byte)0); + } + } + using MemoryStream encoded = LiteralBundle(raw.ToArray()); + Assert.Throws(() => AkronSnapshotBundle.Read(encoded, + (_, document) => document.CopyTo(Stream.Null))); + } + + [Fact] + public void RejectsTinyFrameBurstAfterAFullFrame() { + const int tailBytes = 2000; + int length = AkronSnapshotBundle.BlockBytes + tailBytes; + using var raw = new MemoryStream(); + using (var writer = new BinaryWriter(raw, Encoding.UTF8, leaveOpen: true)) { + writer.Write(Encoding.ASCII.GetBytes("AKRSB001")); + writer.Write(0); writer.Write(1); writer.Write(1); writer.Write(length); + writer.Write((byte)0); writer.Write(AkronSnapshotBundle.BlockBytes); + writer.Write(new byte[AkronSnapshotBundle.BlockBytes]); + writer.Write((byte)0); writer.Write(tailBytes); writer.Write(new byte[tailBytes]); + } + byte[] bytes = raw.ToArray(); + using var encoded = new MemoryStream(); + using (var brotli = new BrotliStream(encoded, CompressionLevel.SmallestSize, leaveOpen: true)) + using (var writer = new BinaryWriter(brotli)) { + writer.Write((byte)0); writer.Write(AkronSnapshotBundle.BlockBytes); + writer.Write(bytes, 0, AkronSnapshotBundle.BlockBytes); + for (int index = AkronSnapshotBundle.BlockBytes; index < bytes.Length; index++) { + writer.Write((byte)0); writer.Write(1); writer.Write(bytes[index]); + } + } + encoded.Position = 0; + Assert.Throws(() => AkronSnapshotBundle.Read(encoded, + (_, document) => document.CopyTo(Stream.Null))); + } + + [Fact] + public void WriterRoundTripsMinimumPackedRunsAndTinyLiteralSeparators() { + using var files = new BundleFiles(); + byte[] expected = new byte[129 * 2048]; + var random = new Random(912); + for (int index = 0; index < expected.Length; index++) + expected[index] = index % 129 == 128 ? (byte)0 : (byte)random.Next('A', 'Z' + 1); + AkronSnapshotBundle.Write(files.Bundle, new[] { files.Source(1, expected) }); + using var encoded = File.OpenRead(files.Bundle); + AkronSnapshotBundle.Read(encoded, (_, document) => { + using var restored = new MemoryStream(); + document.CopyTo(restored); + Assert.Equal(expected, restored.ToArray()); + }); + } + + private static MemoryStream LiteralBundle(byte[] raw) { + var encoded = new MemoryStream(); + using (var brotli = new BrotliStream(encoded, CompressionLevel.Fastest, leaveOpen: true)) + using (var writer = new BinaryWriter(brotli)) { + for (int offset = 0; offset < raw.Length; offset += AkronSnapshotBundle.BlockBytes) { + int count = Math.Min(AkronSnapshotBundle.BlockBytes, raw.Length - offset); + writer.Write((byte)0); writer.Write(count); writer.Write(raw, offset, count); + } + } + encoded.Position = 0; + return encoded; + } + + private sealed class BundleFiles : IDisposable { + internal string DirectoryPath { get; } = Path.Combine(Path.GetTempPath(), "akron-bundle-" + Guid.NewGuid().ToString("N")); + internal string Bundle => Path.Combine(DirectoryPath, "bundle.br"); + internal BundleFiles() => Directory.CreateDirectory(DirectoryPath); + internal AkronSnapshotBundle.Source Source(int slot, byte[] bytes) { + string path = Path.Combine(DirectoryPath, slot + ".json.gz"); + using (var file = File.Create(path)) + using (var gzip = new GZipStream(file, CompressionLevel.Fastest)) gzip.Write(bytes); + using var source = File.OpenRead(path); + return new AkronSnapshotBundle.Source(slot, path, Convert.ToHexString(SHA256.HashData(source))); + } + public void Dispose() => Directory.Delete(DirectoryPath, recursive: true); + } +} diff --git a/tests/startpos-hotpath-cache-tests.cs b/tests/startpos-hotpath-cache-tests.cs index 5c8d4045..619a1927 100644 --- a/tests/startpos-hotpath-cache-tests.cs +++ b/tests/startpos-hotpath-cache-tests.cs @@ -52,7 +52,8 @@ public void SnapshotPathIsMemoizedAndStillMatchesTheSha256Layout() { string expectedDigest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(slotName))).ToLowerInvariant(); - Assert.Equal("v10-" + expectedDigest + ".json.gz", Path.GetFileName(first)); + string version = AkronReconstructionDocument.CurrentFormat["akron-reconstruction-".Length..]; + Assert.Equal(version + "-" + expectedDigest + ".json.gz", Path.GetFileName(first)); } [Fact] diff --git a/tests/startpos-persistence-tests.cs b/tests/startpos-persistence-tests.cs index 1034b924..da9258f8 100644 --- a/tests/startpos-persistence-tests.cs +++ b/tests/startpos-persistence-tests.cs @@ -40,6 +40,86 @@ public sealed class StartPosPersistenceTests { BindingFlags.Instance | BindingFlags.NonPublic ) ?? throw new InvalidOperationException("Celeste.Player.temp field is unavailable."); + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RoomDustStyleRestoresItsControllerAliasWithoutReplacingOtherAreas(bool fromDisk) { + const int areaId = int.MaxValue - 1; + const int otherAreaId = int.MaxValue; + Dictionary table = new Dictionary(); + string directory = Path.Combine(Path.GetTempPath(), "akron-dust-style-" + Guid.NewGuid().ToString("N")); + try { + DustStyles.DustStyle style = new DustStyles.DustStyle { + EdgeColors = new[] { new Vector3 { X = 0.2f, Y = 0.4f, Z = 0.6f } }, + EyeTextures = "danger/dustcreature/eyes" + }; + DustStyles.DustStyle otherStyle = new DustStyles.DustStyle { + EdgeColors = new[] { new Vector3 { X = 1f, Y = 1f, Z = 1f } }, + EyeTextures = "unrelated-area" + }; + table[areaId] = style; + table[otherAreaId] = otherStyle; + DustStyleRoom saved = new DustStyleRoom { + ControllerStyle = style, + RuntimeState = new AkronPersistentRuntimeState { + DustStyle = AkronPersistentRuntimeState.CaptureDustStyle(table, areaId) + } + }; + AkronDeepClone.ClearSharedState(); + saved = (DustStyleRoom) AkronDeepClone.Clone(saved); + AkronDeepClone.ClearSharedState(); + Assert.NotSame(style.EdgeColors, saved.ControllerStyle.EdgeColors); + + DustStyleRoom restored; + if (fromDisk) { + AkronReconstructionGraph graph = new AkronReconstructionGraph(_ => false); + DustStyleRoom fresh = new DustStyleRoom { + RuntimeState = new AkronPersistentRuntimeState() + }; + AkronReconstructionCapture capture = graph.Capture(saved, fresh); + Assert.True(capture.Success, capture.Error); + Assert.True(AkronStartPosReconstruction.SaveSnapshot( + "style", "Tests/DustStyles", "room", 0, capture.Document, out string saveError, directory), saveError); + Assert.True(AkronStartPosReconstruction.TryLoadSnapshot( + "style", out AkronReconstructionDocument document, out string loadError, directory), loadError); + AkronReconstructionRestore result = graph.Restore(document, fresh); + Assert.True(result.Success, result.Error); + restored = (DustStyleRoom) result.Objects[document.RootNodeId]; + } else { + restored = (DustStyleRoom) AkronDeepClone.Clone(saved); + } + + // Removing an outgoing controller clears its registration. Repeated + // restores must install the incoming controller's exact style alias. + for (int restore = 0; restore < 2; restore++) { + table.Remove(areaId); + AkronPersistentRuntimeState.RestoreDustStyle(table, areaId, restored.RuntimeState.DustStyle); + Assert.Same(restored.ControllerStyle.EdgeColors, table[areaId].EdgeColors); + Assert.Equal(restored.ControllerStyle.EyeTextures, table[areaId].EyeTextures); + Assert.Same(otherStyle.EdgeColors, table[otherAreaId].EdgeColors); + Assert.Equal(otherStyle.EyeTextures, table[otherAreaId].EyeTextures); + } + + table.Remove(areaId); + DustStyles.DustStyle? absent = AkronPersistentRuntimeState.CaptureDustStyle(table, areaId); + table[areaId] = style; + AkronPersistentRuntimeState.RestoreDustStyle(table, areaId, absent); + Assert.False(table.ContainsKey(areaId)); + Assert.Same(otherStyle.EdgeColors, table[otherAreaId].EdgeColors); + Assert.Equal(otherStyle.EyeTextures, table[otherAreaId].EyeTextures); + } finally { + AkronDeepClone.ClearSharedState(); + if (Directory.Exists(directory)) { + Directory.Delete(directory, recursive: true); + } + } + } + + private sealed class DustStyleRoom { + public DustStyles.DustStyle ControllerStyle; + public AkronPersistentRuntimeState RuntimeState = null!; + } + [Fact] public void FreshRoomDrainFinishesEntitiesAddedDuringAwake() { List entities = new List(); @@ -557,7 +637,6 @@ public void ACurrentSnapshotStillRoundTripsAfterTheFormatBump() { Assert.True(AkronStartPosReconstruction.TryLoadSnapshot( slotName, out AkronReconstructionDocument document, out string loadError, directory), loadError); Assert.Equal(AkronReconstructionDocument.CurrentFormat, document.Format); - Assert.Equal("akron-reconstruction-v10", document.Format); Assert.Equal(slotName, document.SlotName); Assert.Equal("Tests/FormatBump", document.MapSid); Assert.Equal("room", document.Room); @@ -599,30 +678,6 @@ public void ASlotTheFormatBumpLeftBehindIsNotReportedAsASlotThatWasNeverSaved() } } - // Finding the catalog needs a loaded save file; reading one does not. The - // sentence is chosen from the catalog alone, so it is exercised against a real - // catalog here and only the lookup is pinned in the source. - string source = File.ReadAllText(GetSourcePath("Actions", "akron-startpos-actions.cs")); - int describe = source.IndexOf( - "internal static string DescribeMissingStartPos(Level level, int slot)", StringComparison.Ordinal); - int describeEnd = source.IndexOf( - "internal static string DescribeMissingStartPos(", describe + 1, StringComparison.Ordinal); - string describeMethod = SourceSlice(source, describe, describeEnd - describe); - - Assert.Contains( - "DescribeMissingStartPos(slot, GetPersistedStartPositions(GetAreaSid(level)))", - describeMethod); - Assert.DoesNotContain("HasSupersededSnapshot", describeMethod); - - // And the direct load is what asks for it. A sentence no path reaches has - // shipped here before, so both callers are pinned rather than assumed: this - // one, and Previous and Next below. - int load = source.IndexOf("public static void LoadStartPos(Level level)", StringComparison.Ordinal); - int loadEnd = source.IndexOf("public static void LoadStartPosSlot(", load, StringComparison.Ordinal); - - Assert.Contains( - "new AkronToast(DescribeMissingStartPos(level, slot))", - SourceSlice(source, load, loadEnd - load)); } // The catalog a save file would hold for one map, with each slot's state recorded @@ -2407,45 +2462,6 @@ public void ASecondInstallAttemptIsRefusedRatherThanRunOnTheFirstOnesRecord() { } } - // An install may only remove a destination it can prove it created. - // - // The install asks whether the slot already holds a snapshot by moving it aside and - // reading the failure, and "missing" is also what comes back for a query the - // filesystem could not complete: a folder in the path that has lost its search - // permission, an IO error, a Windows attribute read a scanner is holding off. - // Measured outside the suite, with the snapshot folder made unsearchable and then - // searchable again mid-install: taking that answer at face value ends with - // File.Delete removing a snapshot the install never touched, which is the loss this - // whole path exists to prevent. So the emptiness answer is proved with an exclusive - // create, and the rollback's delete is reached only through that proof. - // - // Asserted on the source because the divergence needs the filesystem to change - // between two statements inside Install, which no test can arrange from outside; - // what a test can hold is that the delete has no other route to it. - [Fact] - public void AnInstallOnlyRemovesADestinationItProvedItCreated() { - string source = File.ReadAllText(GetSourcePath("SaveLoad", "akron-reconstruction-graph.cs")); - int install = source.IndexOf("internal sealed class PreparedSnapshotInstall", StringComparison.Ordinal); - int end = source.IndexOf("private const string CompareInfoSortNameKeyPrefix", install, StringComparison.Ordinal); - Assert.True(install >= 0 && end > install); - string transaction = SourceSlice(source, install, end - install); - - // The proof, and the only branch that may delete, gated on it. - Assert.Contains("new FileStream(destinationPath, FileMode.CreateNew", transaction); - Assert.Contains("destinationClaimed = true;", transaction); - Assert.Contains("} else if (destinationClaimed) {\n", transaction); - Assert.Equal(1, CountOccurrences(transaction, "File.Delete(")); - - // Neither the install nor the rollback may ask the filesystem what it did: those - // answers are the ones that cannot tell "nothing there" from "cannot say". - Assert.DoesNotContain("File.Exists(destinationPath)", transaction); - Assert.DoesNotContain("File.Exists(backupPath)", transaction); - // The one existence question left is about the staged file the caller wrote, and - // a wrong answer there refuses the install rather than removing anything. - Assert.Equal(1, CountOccurrences(transaction, "File.Exists(")); - Assert.Contains("File.Exists(sourcePath)", transaction); - } - // A rollback that cannot do its job says so and stops; it does not throw. // // Both of its callers are already carrying a failure. Install's catch block calls it @@ -2526,35 +2542,6 @@ public void AFailedSetSaysWhenThePreviousSnapshotCouldNotBePutBack() { Assert.True(read > dispose); } - [Fact] - public void StartPosLoadsWaitForAStableEngineBoundary() { - string source = File.ReadAllText(GetActionsSourcePath()); - int loadStart = source.IndexOf("public static void LoadStartPos(Level level)", StringComparison.Ordinal); - int loadEnd = source.IndexOf("public static void LoadStartPosSlot", loadStart, StringComparison.Ordinal); - int deathStart = source.IndexOf("internal static void RestoreStartPosAfterDeath", StringComparison.Ordinal); - int deathEnd = source.IndexOf("private static bool RestoreStartPos", deathStart, StringComparison.Ordinal); - - string loadMethod = SourceSlice(source, loadStart, loadEnd - loadStart); - string deathMethod = SourceSlice(source, deathStart, deathEnd - deathStart); - - Assert.Contains("AkronModule.ScheduleAfterStableEngineUpdate", loadMethod); - Assert.DoesNotContain("level.OnEndOfFrame", loadMethod); - Assert.Contains("AkronModule.ScheduleAfterStableEngineUpdate", deathMethod); - Assert.DoesNotContain("level.OnEndOfFrame", deathMethod); - } - - [Fact] - public void DeferredStartPosLoadStopsAfterTheSceneChanges() { - string source = File.ReadAllText(GetActionsSourcePath()); - int load = source.IndexOf("public static void LoadStartPos(Level level)", StringComparison.Ordinal); - int schedule = source.IndexOf("AkronModule.ScheduleAfterStableEngineUpdate(() =>", load, StringComparison.Ordinal); - int sceneGuard = source.IndexOf("if (Engine.Scene != level)", schedule, StringComparison.Ordinal); - int restore = source.IndexOf("RestoreStartPos(", sceneGuard, StringComparison.Ordinal); - - Assert.True(schedule > load); - Assert.True(sceneGuard > schedule); - Assert.True(restore > sceneGuard); - } [Fact] public void StartPosCaptureFiltersIgnoredEntitiesWithoutChangingTheLiveRoom() { @@ -2801,25 +2788,6 @@ public void ConfiguredStartPosRefreshesTheNativePoseAtCaptureOrLoadBoundary() { Assert.DoesNotContain("player.Sprite.Play(animation);", playerSnapshot); } - [Fact] - public void PlayerCollisionScratchNeverBecomesSavedState() { - Assert.True(AkronReconstructionGraph.IsTransientRuntimeField(typeof(Player), PlayerTempField)); - - string source = File.ReadAllText(GetSourcePath("SaveLoad", "akron-reconstruction-graph.cs")); - int captureFreshIndex = source.IndexOf("private void IndexFreshValue(", StringComparison.Ordinal); - int captureObject = source.IndexOf("private void CaptureObject(", captureFreshIndex, StringComparison.Ordinal); - int restoreFreshIndex = source.IndexOf("private void IndexFreshResources(", captureObject, StringComparison.Ordinal); - - Assert.Contains( - "IsTransientRuntimeField(type, field)", - SourceSlice(source, captureFreshIndex, captureObject - captureFreshIndex)); - Assert.Contains( - "IsTransientRuntimeField(savedObject.GetType(), field)", - SourceSlice(source, captureObject, restoreFreshIndex - captureObject)); - Assert.Contains( - "IsTransientRuntimeField(type, field)", - SourceTail(source, restoreFreshIndex)); - } [Theory] [InlineData(false)] @@ -3075,76 +3043,7 @@ public void EdgeCaptureKeepsAutomationOpenUntilTheDiskSnapshotCommits() { Assert.True(methodEnd > complete); } - [Fact] - public void LoadProbeRecordsItsPixelCaptureAfterTheRestoreFrame() { - string qaSource = File.ReadAllText(GetQaCommandsSourcePath()); - int method = qaSource.IndexOf("public static void QaStartPosLoadProbe", StringComparison.Ordinal); - int load = qaSource.IndexOf("AkronActions.LoadStartPos(level);", method, StringComparison.Ordinal); - int probe = qaSource.IndexOf("Func recordProbe =", load, StringComparison.Ordinal); - int pixelCapture = qaSource.IndexOf("AkronCapture.RequestGameplayBufferQaCapture(", probe, StringComparison.Ordinal); - int stableBoundary = qaSource.IndexOf("AkronModule.ScheduleAfterStableEngineUpdate(() =>", pixelCapture, StringComparison.Ordinal); - - Assert.True(method >= 0); - Assert.True(load > method); - Assert.True(probe > load); - Assert.True(pixelCapture > probe); - Assert.True(stableBoundary > pixelCapture); - } - - [Fact] - public void LoadProbeKeepsAutomationOpenUntilEndOfFrameStateIsRecorded() { - string qaSource = File.ReadAllText(GetQaCommandsSourcePath()); - string automationSource = File.ReadAllText(GetSourcePath("Automation", "akron-automation-service.cs")); - int method = qaSource.IndexOf("public static void QaStartPosLoadProbe", StringComparison.Ordinal); - int defer = qaSource.IndexOf("AkronAutomationService.DeferRunCompletion();", method, StringComparison.Ordinal); - - Assert.True(method >= 0); - Assert.True(defer > method); - - int stableBoundary = qaSource.IndexOf("AkronModule.ScheduleAfterStableEngineUpdate(() =>", method, StringComparison.Ordinal); - Assert.True(stableBoundary > method); - Assert.True(defer > stableBoundary); - - int complete = qaSource.IndexOf("AkronAutomationService.CompleteDeferredRun();", stableBoundary, StringComparison.Ordinal); - - Assert.True(complete > stableBoundary); - Assert.Contains("if (HandleDeferredRun())", automationSource); - Assert.Contains("DeferredRunFrameLimit", automationSource); - Assert.Contains("FailDeferredRun", automationSource); - } - - [Fact] - public void IdlePollSurvivesTheFrameCounterThatAStartPosRestores() { - string source = File.ReadAllText(GetSourcePath("Automation", "akron-automation-service.cs")); - int process = source.IndexOf("public static void ProcessPendingCommands", StringComparison.Ordinal); - Assert.True(process >= 0); - int idleBranch = source.IndexOf("if (!hasActiveRun) {", process, StringComparison.Ordinal); - Assert.True(idleBranch > process); - int guard = source.IndexOf("if (Engine.FrameCounter < nextIdlePollFrame &&", idleBranch, StringComparison.Ordinal); - Assert.True(guard > idleBranch); - int rewind = source.IndexOf("nextIdlePollFrame - Engine.FrameCounter <= IdlePollFrames", guard, StringComparison.Ordinal); - Assert.True(rewind > guard); - int schedule = source.IndexOf("nextIdlePollFrame = Engine.FrameCounter + IdlePollFrames;", rewind, StringComparison.Ordinal); - Assert.True(schedule > rewind); - - // FinalizeRun must not own this: LoadStartPos runs the restore on a later - // engine boundary, so the run has already finalized by the time the counter - // moves and a deadline written there is the pre-restore clock. - int finalizeStart = source.IndexOf("private static void FinalizeRun(", StringComparison.Ordinal); - int finalizeEnd = source.IndexOf("private static void WriteResult(", finalizeStart, StringComparison.Ordinal); - string finalizeRun = SourceSlice(source, finalizeStart, finalizeEnd - finalizeStart); - - Assert.True(finalizeStart >= 0); - Assert.True(finalizeEnd > finalizeStart); - Assert.DoesNotContain("nextIdlePollFrame", finalizeRun); - - string actionsSource = File.ReadAllText(GetActionsSourcePath()); - int loadStartPos = actionsSource.IndexOf("public static void LoadStartPos(Level level)", StringComparison.Ordinal); - int deferredRestore = actionsSource.IndexOf("AkronModule.ScheduleAfterStableEngineUpdate(", loadStartPos, StringComparison.Ordinal); - Assert.True(loadStartPos >= 0); - Assert.True(deferredRestore > loadStartPos); - } [Fact] public void DeferredAutomationCompletionKeepsLaterCommandsQueued() { @@ -3740,19 +3639,6 @@ public void FailedPersistentRestoreReloadsThePreLoadRuntimeState() { Assert.Contains("capturePersistentResources: false", SourceSlice(source, captureRollback, 320)); } - [Fact] - public void PixelTaggedLoadProbeCompletesAfterTheRenderCapture() { - string qaSource = File.ReadAllText(GetSourcePath("Commands", "akron-qa-commands.cs")); - string captureSource = File.ReadAllText(GetSourcePath("Tools", "akron-capture.cs")); - - int request = qaSource.IndexOf("RequestGameplayBufferQaCapture(", StringComparison.Ordinal); - int pixelTag = qaSource.IndexOf("pixelTag,", request, StringComparison.Ordinal); - int completion = qaSource.IndexOf("AkronAutomationService.CompleteDeferredRun", pixelTag, StringComparison.Ordinal); - Assert.True(request >= 0 && pixelTag > request && completion > pixelTag); - Assert.Contains("if (!waitForPixelCapture)", qaSource); - Assert.Contains("pendingGameplayBufferQaCompletion", captureSource); - Assert.Contains("completion?.Invoke()", captureSource); - } [Fact] public void InMemoryRestoreRefreshesTheTrackerBeforeHelperCallbacks() { @@ -3993,7 +3879,7 @@ public void WarmAndColdStartPosPathsRestoreBerryProgressAfterFallibleWork() { public void SetupPackImportBindsStartPosBerryProgressToTheRecipientSave() { string source = File.ReadAllText(GetSourcePath("Setups", "akron-setup-packs.cs")); int prepareStart = source.IndexOf("private static PreparedStartPosImport PrepareStartPosImport", StringComparison.Ordinal); - int prepareEnd = source.IndexOf("private static string GetSnapshotEntryName", prepareStart, StringComparison.Ordinal); + int prepareEnd = source.IndexOf("private static void RequireCurrentPackFormat", prepareStart, StringComparison.Ordinal); string prepareImport = SourceSlice(source, prepareStart, prepareEnd - prepareStart); int level = prepareImport.IndexOf("Level recipientLevel = TryGetCurrentLevel();", StringComparison.Ordinal); @@ -4001,7 +3887,7 @@ public void SetupPackImportBindsStartPosBerryProgressToTheRecipientSave() { "string.Equals(recipientLevel?.Session?.Area.GetSID(), targetMapSid, StringComparison.Ordinal)", StringComparison.Ordinal); int capture = prepareImport.IndexOf("AkronBerryProgressSnapshot.Capture(recipientLevel)", StringComparison.Ordinal); - int loop = prepareImport.IndexOf("foreach (KeyValuePair", StringComparison.Ordinal); + int loop = prepareImport.IndexOf("AkronSnapshotBundle.Read(checkedStream", StringComparison.Ordinal); Assert.True(level >= 0 && targetCheck > level && capture > targetCheck && capture < loop); Assert.Contains("document.BerryProgress = recipientBerryProgress;", prepareImport); } @@ -4063,20 +3949,6 @@ public void StartPosSnapshotKeysDoNotUseLossyAreaSidSanitization() { Assert.DoesNotContain("char.IsLetterOrDigit(character)", source); } - [Fact] - public void LoadingStartPosPreservesTheRespawnPreference() { - string source = File.ReadAllText(GetActionsSourcePath()); - int load = source.IndexOf("public static void LoadStartPos(Level level)", StringComparison.Ordinal); - int loadEnd = source.IndexOf("public static void LoadStartPosSlot", load, StringComparison.Ordinal); - int restore = source.IndexOf("private static bool RestoreStartPos(", StringComparison.Ordinal); - int restoreEnd = source.IndexOf("internal static void RelinkRuntimeRenderState", restore, StringComparison.Ordinal); - string loadPath = SourceSlice(source, load, loadEnd - load); - string restorePath = SourceSlice(source, restore, restoreEnd - restore); - - Assert.DoesNotContain("enableRespawnAtStartPosAfterRestore", loadPath); - Assert.DoesNotContain("enableRespawnAtStartPosAfterRestore", restorePath); - Assert.Contains("AkronModule.Settings.RespawnAtStartPos = restoreRespawnAtStartPos;", restorePath); - } [Fact] public void EnabledStartPosRespawnUsesTheLastLoadedSlotAfterDeath() { @@ -4362,34 +4234,6 @@ public void ARoomChangeCannotForceAStartPosLoadOntoTheColdPath() { Assert.DoesNotContain("DiscardRuntimeStateMemory", transitionPath); } - [Fact] - public void EveryStartPosLoadOutcomeReachesThePlayer() { - string source = File.ReadAllText(GetActionsSourcePath()); - int load = source.IndexOf("public static void LoadStartPos(Level level)", StringComparison.Ordinal); - int loadEnd = source.IndexOf("public static void LoadStartPosSlot(", load, StringComparison.Ordinal); - string loadPath = SourceSlice(source, load, loadEnd - load); - - // The two deferred-boundary guards used to return without a word, which looks - // exactly like a dead hotkey. - Assert.Contains("was not loaded: the scene changed.", loadPath); - Assert.Contains("was not loaded: a capture is still finishing.", loadPath); - - // The deferred boundary swallows exceptions, so the restore reports its own. - int restore = source.IndexOf( - "private static bool RestoreStartPos(Level level, AkronStartPos startPos", - StringComparison.Ordinal); - int restoreEnd = source.IndexOf("private static void ReportStartPosLoadFailure(", restore, StringComparison.Ordinal); - string restorePath = SourceSlice(source, restore, restoreEnd - restore); - Assert.Contains("catch (Exception exception)", restorePath); - Assert.Contains("ReportStartPosLoadFailure(", restorePath); - - // A rolled-back cold restore has to say that nothing changed, or it is - // indistinguishable from the load never having run. - string saveLoadSource = File.ReadAllText(GetSaveLoadSourcePath()); - Assert.Contains("nothing was changed and you are still in ", saveLoadSource); - Assert.Contains("its restart copy is still finishing", saveLoadSource); - Assert.Contains("no restart copy of this StartPos exists on disk", saveLoadSource); - } // What the refusal is about decides the sentence, and it is carried from the graph to // the toast through five hops. Every one of them can silently drop it and leave the diff --git a/tests/startpos-reconstruction-tests.cs b/tests/startpos-reconstruction-tests.cs index 21ef34a6..1d0953d5 100644 --- a/tests/startpos-reconstruction-tests.cs +++ b/tests/startpos-reconstruction-tests.cs @@ -930,7 +930,7 @@ public void AMapEditOnAModsOwnEntityNamesTheMapRatherThanTheMod() { // without dropping a live object the document keeps. Nothing the player owns explains // that, so it keeps the bug-report sentence. [Fact] - public void ARefusalTheMapCannotExplainStillAsksForABugReport() { + public void AnUnexplainedOwnershipConflictIsClassifiedAsASavedObjectRefusal() { PlaybackGhostReloadRoom fresh = CreateTwoTrailReloadedGhostRoomTheSessionBuiltDifferently(unpairableFirst: true); @@ -941,25 +941,7 @@ public void ARefusalTheMapCannotExplainStillAsksForABugReport() { mapIdsAtReload: new[] { 42, 7, 8 }); Assert.False(restore.Success); - Assert.Contains( - "reconstructed reference edge would drop a fresh object this document keeps", - restore.Error); - // A vanilla Celeste type, same as the map refusal above, and it gets the other - // sentence. The kind is what separates them, not the assembly. - Assert.Equal(typeof(PlayerHair).AssemblyQualifiedName, restore.RefusedTypeName); Assert.Equal(AkronReconstructionRefusalKind.SavedObject, restore.RefusedKind); - - string message = AkronStartPosRefusal.Describe( - "StartPos 1", - restore.RefusedTypeName, - restore.RefusedKind, - new[] { ("ExtendedVariantMode", "ExtendedVariantMode") }); - - Assert.Equal( - "StartPos 1 could not be rebuilt: this room has no PlayerHair to match, and no mod " + - "owns it. If your mods have not changed, this is an Akron bug; report " + - "akron-current.log.", - message); } // Everest's own CoreModule is a real EverestModule named "Everest" and it lives in @@ -2250,56 +2232,7 @@ public void AnUnpairedGhostIsRefusedWhenTheMapNoLongerPlacesIt() { } [Fact] - public void AnUnpairedGhostStillTakesItsSceneEdgeOnStructuralBudgetAloneAndRestoresWrongly() { - // THIS TEST PINS BEHAVIOUR THAT IS WRONG. It is here so that the day it is - // fixed, it fails and says so. - // - // ValidateReferenceEdge accepts a reference edge with no authenticator at all - // whenever freshListStructuralTypeCounts holds a remaining occurrence for - // (target type, structural path with every list index wildcarded). That budget - // records that SOME object of that type sits at that path in the fresh room. It - // does not record WHICH, and it does not require the edge's parent to be an - // object the fresh room holds. So when a saved entity fails to pair - here - // because the fresh room rebuilt it with a different EntityID - the - // reconstructed copy's edge spends the occurrence that the room's own - // live entity put there, and the restore reports Success. - // - // What ends up wrong is not only that edge. The room's own PlayerSprite is - // fresh-resolved, so its back reference is rewritten to the - // reconstructed copy, the saved state lands on that copy, and the entity the - // room actually holds keeps its clean-load state. In game the surviving trail - // would render the room's sprite at the reconstructed copy's position. - // - // The map here is the same map it always was: it lays out both ghosts, and - // this reload's session state is why entity 42 was not built. The refusal - // above cannot reach that, and it should not - a room whose session no longer - // spawns one of its entities has to keep restoring. - // - // What that leaves is not "rebuilt beside the live ghost". The saved entity - // list holds four entities and so does the reloaded room, so the rebuilt ghost - // takes the live ghost's slot rather than being added next to it, and the - // ghost the reload built is dropped. That much is the saved population winning, - // which is what a restore is for. - // - // What is wrong is what happens to that dropped ghost, and it is measured - // below rather than described. Several edges here carry no authenticator and - // ride the occurrence budget, the edge above among them; Snapshot.Hair - // is the only one of them whose target is a component, and it is the one the - // "component aliases on occurrence budget alone" question is about. That write - // is not what makes the room wrong: the rebuilt hair lands in the rebuilt - // ghost's own Hair field and both halves of the trail end up pointing at that - // same rebuilt ghost, so the trail is not split between two owners. - // - // The harm is on the other side of the same room. The room's own PlayerSprite - // is fresh-resolved and relabelled, so the object the reload built for ghost 43 - // now belongs to the rebuilt ghost 42 while ghost 43's own component list still - // lists it, and ghost 43 is left out of the entity list with its Scene still - // pointing at the Level. That write is a pairing rather than a budget - // admission - the snapshot's Sprite field is a fresh path and the resolver - // takes what is in it, with no identity check - so no rule about which - // component edges the budget admits reaches it. A stricter budget would still - // refuse this document as a whole, because the restore only gets far enough to - // make that write while its count-only edges are admitted. + public void AReconstructedGhostDoesNotStealTheComponentsOfADifferentlyIdentifiedGhost() { PlaybackGhostReloadRoom fresh = CreateReloadedGhostRoomWithRenumberedGhost(); Level level = fresh.Level; EntityList entities = fresh.Entities; @@ -2313,39 +2246,25 @@ public void AnUnpairedGhostStillTakesItsSceneEdgeOnStructuralBudgetAloneAndResto mapIdsWhenSet: new[] { 42, 7 }, mapIdsAtReload: new[] { 42, 43, 7 }); - // Accepted, with no authenticator: the saved document asked for the fresh Level - // at a path the fresh room does hold a Level at, and that was enough. + // Session state may suppress a still-placed ghost. The saved population + // wins, but the dropped fresh ghost must retain its own components. Assert.True(restore.Success, restore.Error); - // WRONG: the room's own sprite no longer points at the ghost the room holds. - Entity? spriteOwner = GetRuntimeField(freshSprite, "k__BackingField"); - Assert.NotSame(liveGhost, spriteOwner); - PlayerPlayback reconstructedGhost = Assert.IsType(spriteOwner); - // WRONG: the reconstructed copy takes the entity-list slot of the ghost the room - // load built, and gets the live Level in its Scene on the occurrence budget - // alone. The ghost LoadLevel produced is dropped from the room entirely. + Assert.Same(liveGhost, GetRuntimeField(freshSprite, "k__BackingField")); + Assert.Same(liveGhost, GetRuntimeField(freshHair, "k__BackingField")); + PlayerPlayback reconstructedGhost = Assert.Single( + GetEntityListContents(entities).OfType()); + Assert.NotSame(liveGhost, reconstructedGhost); Assert.Same(level, GetRuntimeField(reconstructedGhost, "k__BackingField")); Assert.Contains(GetEntityListContents(entities), entity => ReferenceEquals(entity, reconstructedGhost)); Assert.DoesNotContain(GetEntityListContents(entities), entity => ReferenceEquals(entity, liveGhost)); - // WRONG: the saved state landed on the reconstructed copy, and the ghost the - // room actually holds kept its clean-load state. Assert.Equal(2.5f, GetRuntimeField(reconstructedGhost, "time")); Assert.Equal(0f, GetRuntimeField(liveGhost, "time")); - // The surviving snapshot keeps the room's PlayerSprite and is handed a - // reconstructed PlayerHair on the occurrence budget alone. - Assert.Same(freshSprite, snapshot.Sprite); + Assert.NotSame(freshSprite, snapshot.Sprite); Assert.NotSame(freshHair, snapshot.Hair); - // NOT wrong, and pinned because the comment above used to claim it was: both - // halves of the trail point at the same ghost afterwards, and it is the rebuilt - // one. The rebuilt hair goes where the document says it goes. Assert.Same(reconstructedGhost, GetRuntimeField(snapshot.Hair!, "k__BackingField")); Assert.Same(reconstructedGhost, GetRuntimeField(snapshot.Sprite!, "k__BackingField")); Assert.Contains(snapshot.Hair, GetComponentListContents(reconstructedGhost)); - // WRONG, and this is the part no rule about component edges reaches: the ghost - // the reload built is out of the entity list while its Scene still points at - // the Level, and its own component list still holds the PlayerSprite that now - // belongs to the rebuilt ghost. - Assert.Same(level, GetRuntimeField(liveGhost, "k__BackingField")); Assert.Contains(freshSprite, GetComponentListContents(liveGhost)); Assert.Contains(freshHair, GetComponentListContents(liveGhost)); } @@ -3204,6 +3123,261 @@ public void ReconstructedCallbackTargetRequiresTheFreshStructuralMethod() { Assert.Equal(38, Assert.IsType(fresh.Callback.Target).Value); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ComponentConstructorCallbackRestoresItsOwnedCapture(bool fromDisk) { + var saved = CreateComponentCallbackScene(includeCallback: true); + var baseline = CreateComponentCallbackScene(includeCallback: false); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved.Root, baseline.Root); + Assert.True(capture.Success, capture.Error); + AkronReconstructionDocument document = fromDisk + ? graph.Deserialize(graph.Serialize(capture.Document)) + : capture.Document; + var fresh = CreateComponentCallbackScene(includeCallback: false); + + AkronReconstructionRestore restore = graph.Restore(document, fresh.Root); + + Assert.True(restore.Success, restore.Error); + Assert.True(graph.Verify(document, restore, Array.Empty()).Success); + fresh.Owner.Callback!.Callback(); + Assert.Equal(38, fresh.Owner.Target.Value); + Assert.Equal(0, Assert.IsType( + GetEntityListContents(fresh.Root.Entities)[1]).Target.Value); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ConstructorCallbackRestoresItsCapturedGrid(bool fromDisk) { + static SavedSceneRoot Scene(bool firstAttached) { + GridCallbackOwner owner = CreateUninitializedEntity(); + InitializeEmptyComponentList(owner); + SetRuntimeField(owner, "k__BackingField", CreateEntityId("a00", 10)); + owner.Grid = (TileGrid) RuntimeHelpers.GetUninitializedObject(typeof(TileGrid)); + owner.Grid.Tiles = CreateSingleSegmentTextureGrid(1, 1); + owner.OtherGrid = (TileGrid) RuntimeHelpers.GetUninitializedObject(typeof(TileGrid)); + owner.OtherGrid.Tiles = CreateSingleSegmentTextureGrid(1, 1); + owner.Interceptor = new GridCallbackComponent(owner.Grid); + owner.OtherInterceptor = new GridCallbackComponent(owner.Grid); + Component[] attached = firstAttached + ? new Component[] { owner.Grid, owner.Interceptor } + : new Component[] { owner.OtherGrid, owner.OtherInterceptor }; + foreach (Component component in attached) { + SetRuntimeField(component, "k__BackingField", owner); + GetRuntimeField>(GetRuntimeField(owner, "k__BackingField"), "components").Add(component); + GetRuntimeField>(GetRuntimeField(owner, "k__BackingField"), "current").Add(component); + } + return CreateOwnedScene(owner); + } + + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(Scene(true), Scene(false)); + Assert.True(capture.Success, capture.Error); + AkronReconstructionDocument document = fromDisk + ? graph.Deserialize(graph.Serialize(capture.Document)) + : capture.Document; + SavedSceneRoot fresh = Scene(false); + + AkronReconstructionRestore restore = graph.Restore(document, fresh); + + Assert.True(restore.Success, restore.Error); + Assert.True(graph.Verify(document, restore, Array.Empty()).Success); + GridCallbackOwner owner = Assert.IsType(GetEntityListContents(fresh.Entities)[0]); + VirtualMap tiles = CreateSingleSegmentTextureGrid(1, 1); + owner.Interceptor.Intercept(tiles); + Assert.Same(tiles, owner.Grid.Tiles); + owner.OtherInterceptor.Intercept(null); + Assert.Null(owner.Grid.Tiles); + } + + [Fact] + public void LiveInstanceAnchorsDoNotIndexOrRestoreTheirContents() { + List savedCache = new List { + new TestResourceHolder { Resource = new TestResource("saved-private-resource") } + }; + List liveCache = new List { + new TestResourceHolder { Resource = new TestResource("live-private-resource") } + }; + bool IsCache(object value) => ReferenceEquals(value, savedCache) || ReferenceEquals(value, liveCache); + AkronReconstructionGraph graph = new AkronReconstructionGraph( + IsLiveResource, + value => IsCache(value) ? "registered-cache" : + throw new InvalidOperationException("A live anchor's private resource must not be indexed."), + resolveDetachedLiveResource: (type, key) => + type == liveCache.GetType() && key == type.AssemblyQualifiedName + "|registered-cache" + ? liveCache : null, + isAdditionalLiveResource: IsCache); + AkronReconstructionCapture capture = graph.Capture( + new TestResourceListRoot { Holders = savedCache }, + new TestResourceListRoot { Holders = liveCache }); + Assert.True(capture.Success, capture.Error); + AkronReconstructionDocument document = graph.Deserialize(graph.Serialize(capture.Document)); + TestResource replacement = new TestResource("updated-live-resource"); + liveCache[0].Resource = replacement; + TestResourceListRoot fresh = new TestResourceListRoot { Holders = liveCache }; + + AkronReconstructionRestore restore = graph.Restore(document, fresh); + + Assert.True(restore.Success, restore.Error); + Assert.Same(liveCache, fresh.Holders); + Assert.Same(replacement, fresh.Holders[0].Resource); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ModuleSessionCanRestoreRetainedMapMetadata(bool fromDisk) { + LevelData room = (LevelData) RuntimeHelpers.GetUninitializedObject(typeof(LevelData)); + room.Name = "previous-room"; + EntityData key = new EntityData { ID = 42, Name = "key", Level = room }; + EntityData sibling = new EntityData { ID = 43, Name = "spring", Level = room }; + room.Entities = new List { key, sibling }; + room.Triggers = new List(); + RetainedMapMetadataSession saved = new RetainedMapMetadataSession(); + saved.Entries.Add("retained-key", key); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved, new RetainedMapMetadataSession()); + Assert.True(capture.Success, capture.Error); + AkronReconstructionDocument document = fromDisk + ? graph.Deserialize(graph.Serialize(capture.Document)) + : capture.Document; + RetainedMapMetadataSession fresh = new RetainedMapMetadataSession(); + + AkronReconstructionRestore restore = graph.Restore(document, fresh); + + Assert.True(restore.Success, restore.Error); + EntityData restoredKey = fresh.Entries["retained-key"]; + Assert.Equal(42, restoredKey.ID); + Assert.Equal("previous-room", restoredKey.Level.Name); + Assert.Same(restoredKey, restoredKey.Level.Entities[0]); + Assert.Equal("spring", restoredKey.Level.Entities[1].Name); + Assert.Same(restoredKey.Level, restoredKey.Level.Entities[1].Level); + } + + [Fact] + public void DeepGraphPersistencePreservesValuesAndCyclesOnASmallWorkerStack() { + const int depth = 1024; + static DeepGraphRecord CreateGraph(int offset) { + DeepGraphRecord last = new DeepGraphRecord { Value = offset }; + DeepGraphRecord root = last; + for (int index = 1; index <= depth; index++) { + root = new DeepGraphRecord { Next = root, Value = offset + index }; + } + last.Next = root; + root.Shared = last; + return root; + } + + Exception? failure = null; + System.Threading.Thread worker = new System.Threading.Thread(() => { + try { + DeepGraphRecord saved = CreateGraph(17); + DeepGraphRecord fresh = CreateGraph(-10000); + DeepGraphRecord originalLast = fresh.Shared!; + AkronReconstructionGraph graph = new AkronReconstructionGraph(_ => false, _ => string.Empty); + AkronReconstructionCapture capture = graph.Capture(saved, CreateGraph(-10000)); + Assert.True(capture.Success, capture.Error); + AkronReconstructionDocument document = graph.Deserialize(graph.Serialize(capture.Document)); + + AkronReconstructionRestore restore = graph.Restore(document, fresh); + + Assert.True(restore.Success, restore.Error); + DeepGraphRecord cursor = fresh; + for (int index = depth; index >= 0; index--) { + Assert.Equal(17 + index, cursor.Value); + cursor = Assert.IsType(cursor.Next); + } + Assert.Same(fresh, cursor); + Assert.Same(originalLast, fresh.Shared); + Assert.Equal(17, fresh.Shared!.Value); + } catch (Exception exception) { + failure = exception; + } + }, 256 * 1024) { IsBackground = true }; + worker.Start(); + Assert.True(worker.Join(TimeSpan.FromSeconds(60)), "The deep graph round trip did not finish."); + Assert.True(failure == null, failure?.ToString()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void ComponentCanRestoreAnAliasToItsScenesCamera(bool fromDisk, bool detachedAtCapture) { + var saved = CreateSceneCameraAlias(captured: true, detachedAtCapture: detachedAtCapture); + var baseline = CreateSceneCameraAlias(captured: false, staleCamera: !detachedAtCapture); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved.Root, baseline.Root); + Assert.True(capture.Success, capture.Error); + AkronReconstructionDocument document = fromDisk + ? graph.Deserialize(graph.Serialize(capture.Document)) + : capture.Document; + var fresh = CreateSceneCameraAlias(captured: false, staleCamera: !detachedAtCapture); + Camera originalCamera = fresh.Scene.Camera; + + AkronReconstructionRestore restore = graph.Restore(document, fresh.Root); + + Assert.True(restore.Success, restore.Error); + AkronReconstructionVerification verification = graph.Verify(document, restore, Array.Empty()); + Assert.True(verification.Success, verification.Error); + Assert.Same(originalCamera, fresh.Scene.Camera); + Assert.Same(originalCamera, fresh.Component.ClipCamera); + GridCallbackOwner owner = Assert.IsType( + GetEntityListContents(fresh.Root.Entities)[1]); + VirtualMap tiles = CreateSingleSegmentTextureGrid(1, 1); + owner.Interceptor.Intercept(tiles); + Assert.Same(tiles, owner.Grid.Tiles); + if (detachedAtCapture) { + Assert.Null(GetRuntimeField(fresh.Component, "k__BackingField")); + } + } + + [Theory] + [InlineData("foreign-scene", false)] + [InlineData("foreign-scene", true)] + [InlineData("missing-membership", false)] + [InlineData("missing-field", true)] + [InlineData("competing-owner", true)] + public void ComponentCannotBorrowAnotherScenesCamera(string invalidProof, bool detachedAtCapture) { + var saved = CreateSceneCameraAlias(captured: true, invalidProof, detachedAtCapture: detachedAtCapture); + var baseline = CreateSceneCameraAlias(captured: false); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved.Root, baseline.Root); + Assert.True(capture.Success, capture.Error); + var fresh = CreateSceneCameraAlias(captured: false); + Camera originalCamera = fresh.Component.ClipCamera; + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh.Root); + + Assert.False(restore.Success); + Assert.Same(originalCamera, fresh.Component.ClipCamera); + } + + [Theory] + [InlineData("foreign-owner", false)] + [InlineData("missing-membership", false)] + [InlineData("opaque-field", false)] + [InlineData("foreign-owner", true)] + [InlineData("missing-membership", true)] + [InlineData("opaque-field", true)] + public void ComponentConstructorCallbackCannotBorrowUnownedState(string invalidProof, bool detachedCallback) { + var saved = CreateComponentCallbackScene(includeCallback: true, invalidProof, detachedCallback); + var baseline = CreateComponentCallbackScene(includeCallback: false); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved.Root, baseline.Root); + Assert.True(capture.Success, capture.Error); + var fresh = CreateComponentCallbackScene(includeCallback: false); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh.Root); + + Assert.False(restore.Success); + Assert.Null(fresh.Owner.Callback); + Assert.Equal(0, fresh.Owner.Target.Value); + } + [Fact] public void ReconstructedCallbackClosureCanPointBackToItsFreshDeclaringOwner() { CallbackClosureOwner savedOwner = new CallbackClosureOwner { Value = 37 }; @@ -3372,8 +3546,10 @@ public void ReconstructedCallbackAuthenticatesNestedTargetCallbacks() { // an entity. Reading one would give two documents claiming one format two // different guarantees, with nothing on screen to say which you got, so the // format moved instead. - [Fact] - public void ASnapshotFromBeforeTheIdentityEvidenceIsRefusedRatherThanReadWithoutIt() { + [Theory] + [InlineData("akron-reconstruction-v8")] + [InlineData("akron-reconstruction-v10")] + public void ASnapshotMissingRequiredCapturedStateIsRefusedRatherThanPartiallyRestored(string oldFormat) { AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); AkronReconstructionCapture capture = graph.Capture( new TestRoot { Counter = 7 }, @@ -3381,17 +3557,14 @@ public void ASnapshotFromBeforeTheIdentityEvidenceIsRefusedRatherThanReadWithout Assert.True(capture.Success, capture.Error); string json = graph.Serialize(capture.Document).Replace( AkronReconstructionDocument.CurrentFormat, - "akron-reconstruction-v8", + oldFormat, StringComparison.Ordinal); InvalidOperationException exception = Assert.Throws(() => graph.Deserialize(json)); - Assert.StartsWith( - "Reconstruction document format is unsupported: set this StartPos again.", - exception.Message); - Assert.Contains("akron-reconstruction-v8", exception.Message); - Assert.Contains("akron-reconstruction-v10", exception.Message); + Assert.Contains(oldFormat, exception.Message); + Assert.Contains(AkronReconstructionDocument.CurrentFormat, exception.Message); } [Fact] @@ -3404,7 +3577,7 @@ public void DeserializeRejectsTooManyJsonContainersWhileStreaming() { maxJsonBinaryBytes: 100); InvalidOperationException exception = Assert.Throws(() => - graph.Deserialize("{\"Format\":\"akron-reconstruction-v10\",\"Nodes\":[]}")); + graph.Deserialize("{\"Format\":\"" + AkronReconstructionDocument.CurrentFormat + "\",\"Nodes\":[]}")); Assert.Contains("container count exceeds", exception.Message); } @@ -3974,6 +4147,47 @@ public void MissingFreshResourceFailsAtItsExactPathBeforeChangingTheRoom() { Assert.Null(fresh.Resource); } + [Fact] + public void CustomBlendDescriptorUsesTheCaptureRefusalContract() { + BlendState source = (BlendState)RuntimeHelpers.GetUninitializedObject(typeof(CustomBlendDescriptor)); + GC.SuppressFinalize(source); + + Assert.Throws(() => AkronBlendStateSnapshot.Clone(source)); + } + + private sealed class CustomBlendDescriptor : BlendState { + } + + [Fact] + public void ABlendStateCannotRequestRenderTargetAllocationThroughItsPayload() { + AkronReconstructionResourcePayload payload = new AkronReconstructionResourcePayload { + Kind = "virtual-render-target-rgba-v1", + Width = 1, + Height = 1, + Bytes = new byte[4] + }; + + Assert.Throws(() => + new AkronRoomResourceAdapter().Restore(typeof(BlendState), payload, null!)); + } + + [Theory] + [InlineData(47, -1, 0)] + [InlineData(48, 0, int.MaxValue)] + [InlineData(48, 6, 16)] + public void InvalidBlendDescriptorsAreRefusedBeforeCreatingGraphicsState(int length, int word, int value) { + byte[] bytes = new byte[length]; + if (word >= 0) { + System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(word * sizeof(int)), value); + } + AkronReconstructionResourcePayload payload = new AkronReconstructionResourcePayload { + Kind = AkronBlendStateSnapshot.PayloadKind, + Bytes = bytes + }; + + Assert.Throws(() => AkronBlendStateSnapshot.Restore(payload)); + } + [Fact] public void PersistedResourceRecreatesAMissingFreshResourceFromItsSavedPayload() { TestRoot saved = new TestRoot { @@ -5458,70 +5672,564 @@ public void ADetachedCoroutineInAnUnownedFieldRemainsRefused() { AkronReconstructionCapture capture = graph.Capture(saved, new DetachedCoroutineRoot()); Assert.True(capture.Success, capture.Error); - AkronReconstructionRestore restore = graph.Restore(capture.Document, new DetachedCoroutineRoot()); + AkronReconstructionRestore restore = graph.Restore(capture.Document, new DetachedCoroutineRoot()); + + Assert.False(restore.Success); + Assert.Contains("reconstructed type is not authentic", restore.Error); + } + + // DustGraphic.Eyeballs' shape: the room builds an extra entity on first + // render and hands it the component that built it. The surplus watcher the + // fresh room did not build must keep its reference to the fresh component. + [Fact] + public void ARuntimeEntityKeepsItsReferenceToTheFreshComponentThatBuiltIt() { + (SavedSceneRoot saved, LazyBlinkOwnerEntity savedOwner, EyeballsWatcherEntity[] savedWatchers) = + CreateEyeballsScene(2); + foreach (EyeballsWatcherEntity watcher in savedWatchers) { + watcher.Dust = savedOwner.Graphic; + } + (SavedSceneRoot baseline, _, _) = CreateEyeballsScene(1); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved, baseline); + Assert.True(capture.Success, capture.Error); + (SavedSceneRoot fresh, LazyBlinkOwnerEntity freshOwner, _) = CreateEyeballsScene(1); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + Assert.True(restore.Success, restore.Error); + EyeballsWatcherEntity[] watchers = GetEntityListContents(fresh.Entities) + .OfType() + .ToArray(); + Assert.Equal(2, watchers.Length); + Assert.All(watchers, watcher => Assert.Same(freshOwner.Graphic, watcher.Dust)); + } + + // DynamicData's per-type member cache holds compiled FastReflection + // invokers - anonymous delegates no fresh room can vouch for - and every + // instance points at the process-wide entry. A mod attaching DynamicData + // to a room entity removed every Set in the room over those delegates. + // The cache is a live resource now: capture never walks in, and restore + // rebinds to this process's own entry for the same target type. + [Fact] + public void ADynamicDataMemberCacheRestoresAsThisProcessesOwnEntry() { + DynamicDataSubject subject = new DynamicDataSubject(); + MonoMod.Utils.DynamicData data = new MonoMod.Utils.DynamicData(subject); + Assert.Equal(5, data.Get("Exposed")); + DynamicDataHolder saved = new DynamicDataHolder { Data = data, Value = 3 }; + DynamicDataHolder baseline = new DynamicDataHolder { + Data = new MonoMod.Utils.DynamicData(new DynamicDataSubject()) + }; + AkronReconstructionGraph graph = new AkronReconstructionGraph( + AkronStartPosReconstruction.IsLiveResourceType, + AkronStartPosReconstruction.GetLiveResourceKey, + resolveDetachedLiveResource: AkronStartPosReconstruction.ResolveDetachedLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved, baseline); + Assert.True(capture.Success, capture.Error); + DynamicDataHolder fresh = new DynamicDataHolder { + Data = new MonoMod.Utils.DynamicData(new DynamicDataSubject()) + }; + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + Assert.True(restore.Success, restore.Error); + Assert.Equal(3, fresh.Value); + object restoredCache = GetRuntimeField(fresh.Data!, "_Cache"); + object liveCache = GetRuntimeField(new MonoMod.Utils.DynamicData(new DynamicDataSubject()), "_Cache"); + Assert.Same(liveCache, restoredCache); + } + + [Fact] + public void AClonedDynamicDataCacheKeepsItsIdentityWhenTheBaselineHasNoWrapper() { + AkronDeepClone.Initialize(); + DynamicDataHolder live = new DynamicDataHolder { + Data = new MonoMod.Utils.DynamicData(new DynamicDataSubject()), + Value = 37 + }; + DynamicDataHolder saved = (DynamicDataHolder) AkronSaveLoadService.DeepClone(live); + AkronReconstructionGraph graph = new AkronReconstructionGraph( + AkronStartPosReconstruction.IsLiveResourceType, + AkronStartPosReconstruction.GetLiveResourceKey, + resolveDetachedLiveResource: AkronStartPosReconstruction.ResolveDetachedLiveResource); + + AkronReconstructionCapture capture = graph.Capture(saved, new DynamicDataHolder()); + + Assert.True(capture.Success, capture.Error); + DynamicDataHolder fresh = new DynamicDataHolder { + Data = new MonoMod.Utils.DynamicData(new DynamicDataSubject()) + }; + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + Assert.True(restore.Success, restore.Error); + Assert.Equal(37, fresh.Value); + Assert.Same(GetRuntimeField(live.Data, "_Cache"), + GetRuntimeField(fresh.Data!, "_Cache")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ManagedGridOwnershipRequiresAnExactDeclaredField(bool opaqueOwner) { + ManagedGridOwnerEntity savedOwner = CreateUninitializedEntity(); + InitializeEmptyComponentList(savedOwner); + VirtualMap grid = CreateSingleSegmentTextureGrid(4, 4); + MTexture texture = (MTexture) RuntimeHelpers.GetUninitializedObject(typeof(MTexture)); + GetRuntimeField(grid, "segments")[0, 0][1, 2] = texture; + if (opaqueOwner) { + savedOwner.Opaque = grid; + } else { + savedOwner.Grid = grid; + } + savedOwner.Texture = texture; + ManagedGridOwnerEntity baselineOwner = CreateUninitializedEntity(); + InitializeEmptyComponentList(baselineOwner); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture( + CreateSourceEntityListOwnerRoot(savedOwner), + CreateSourceEntityListOwnerRoot(baselineOwner)); + Assert.True(capture.Success, capture.Error); + ManagedGridOwnerEntity freshOwner = CreateUninitializedEntity(); + InitializeEmptyComponentList(freshOwner); + + AkronReconstructionRestore restore = graph.Restore( + capture.Document, CreateSourceEntityListOwnerRoot(freshOwner)); + + if (opaqueOwner) { + Assert.False(restore.Success); + Assert.Null(freshOwner.Opaque); + Assert.Null(freshOwner.Texture); + } else { + Assert.True(restore.Success, restore.Error); + MTexture[,] values = GetRuntimeField(freshOwner.Grid!, "segments")[0, 0]; + Assert.Same(freshOwner.Texture, values[1, 2]); + Assert.Null(values[2, 1]); + Assert.True(graph.Verify(capture.Document, restore, Array.Empty()).Success); + } + } + + [Fact] + public void OwnedCollectionRecordsRestoreThroughIntermediateManagedState() { + ManagedGridOwnerEntity savedOwner = CreateUninitializedEntity(); + InitializeEmptyComponentList(savedOwner); + savedOwner.Surface = new ManagedSurfaceState(); + savedOwner.Surface.Records.Add(new ManagedSurfaceState.Record { Value = 37 }); + ManagedGridOwnerEntity baselineOwner = CreateUninitializedEntity(); + InitializeEmptyComponentList(baselineOwner); + baselineOwner.Surface = new ManagedSurfaceState(); + baselineOwner.Surface.Records.Capacity = 4; + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture( + CreateSourceEntityListOwnerRoot(savedOwner), + CreateSourceEntityListOwnerRoot(baselineOwner)); + Assert.True(capture.Success, capture.Error); + ManagedGridOwnerEntity freshOwner = CreateUninitializedEntity(); + InitializeEmptyComponentList(freshOwner); + freshOwner.Surface = new ManagedSurfaceState(); + freshOwner.Surface.Records.Capacity = 4; + + AkronReconstructionRestore restore = graph.Restore( + capture.Document, CreateSourceEntityListOwnerRoot(freshOwner)); + + Assert.True(restore.Success, restore.Error); + Assert.Equal(37, Assert.Single(freshOwner.Surface.Records).ReadValue()); + Assert.True(graph.Verify(capture.Document, restore, Array.Empty()).Success); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PooledRuntimeEntitiesRequireTheAuthenticatedSceneOwnershipLoop(bool foreignScene) { + PooledRuntimeEffect effect = CreateUninitializedEntity(); + InitializeEmptyComponentList(effect); + effect.Value = 37; + SavedSceneRoot saved = CreateOwnedScene(effect); + if (foreignScene) { + SetRuntimeField(effect, "k__BackingField", + RuntimeHelpers.GetUninitializedObject(typeof(Scene))); + } + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved, CreateOwnedScene()); + Assert.True(capture.Success, capture.Error); + SavedSceneRoot fresh = CreateOwnedScene(); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + if (foreignScene) { + Assert.False(restore.Success); + Assert.Empty(GetEntityListContents(fresh.Entities)); + } else { + Assert.True(restore.Success, restore.Error); + PooledRuntimeEffect restored = Assert.IsType( + Assert.Single(GetEntityListContents(fresh.Entities))); + Assert.Equal(37, restored.Value); + Assert.Same(fresh.Scene, GetRuntimeField(restored, "k__BackingField")); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RuntimeEntityCreatorProofDoesNotDependOnEntityListOrder(bool creatorFirst) { + SourceIdentifiedEntity creator = CreateSourceIdentifiedEntity("a00", 10, 37); + CapturedRuntimeEffect effect = CreateUninitializedEntity(); + InitializeEmptyComponentList(effect); + effect.Creator = creator; + SavedSceneRoot saved = creatorFirst + ? CreateOwnedScene(creator, effect) + : CreateOwnedScene(effect, creator); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture( + saved, CreateOwnedScene(CreateSourceIdentifiedEntity("a00", 10, 0))); + Assert.True(capture.Success, capture.Error); + SourceIdentifiedEntity freshCreator = CreateSourceIdentifiedEntity("a00", 10, 0); + SavedSceneRoot fresh = CreateOwnedScene(freshCreator); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + Assert.True(restore.Success, restore.Error); + CapturedRuntimeEffect restored = Assert.Single( + GetEntityListContents(fresh.Entities).OfType()); + Assert.Same(freshCreator, restored.Creator); + Assert.Equal(37, restored.Creator.Value); + Assert.Same(fresh.Scene, GetRuntimeField(restored, "k__BackingField")); + Assert.True(graph.Verify(capture.Document, restore, Array.Empty()).Success); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RuntimeEntityComponentCreatorProofDoesNotDependOnEntityListOrder(bool creatorFirst) { + OwnedComponentEntity creator = CreateOwnedComponentEntity(); + SetRuntimeField(creator, "k__BackingField", CreateEntityId("a00", 10)); + creator.Owned.Value = 37; + ComponentCapturedRuntimeEffect effect = CreateUninitializedEntity(); + InitializeEmptyComponentList(effect); + effect.Creator = creator.Owned; + SavedSceneRoot saved = creatorFirst + ? CreateOwnedScene(creator, effect) + : CreateOwnedScene(effect, creator); + OwnedComponentEntity baselineCreator = CreateOwnedComponentEntity(); + SetRuntimeField(baselineCreator, "k__BackingField", CreateEntityId("a00", 10)); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved, CreateOwnedScene(baselineCreator)); + Assert.True(capture.Success, capture.Error); + OwnedComponentEntity freshCreator = CreateOwnedComponentEntity(ownedFirst: true); + SetRuntimeField(freshCreator, "k__BackingField", CreateEntityId("a00", 10)); + OwnedTestComponent freshComponent = freshCreator.Owned; + SavedSceneRoot fresh = CreateOwnedScene(freshCreator); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + Assert.True(restore.Success, restore.Error); + ComponentCapturedRuntimeEffect restored = Assert.Single( + GetEntityListContents(fresh.Entities).OfType()); + Assert.Same(freshComponent, restored.Creator); + Assert.Same(freshComponent, freshCreator.Owned); + Assert.Same(freshCreator, GetRuntimeField(restored.Creator!, "k__BackingField")); + Assert.Equal(37, restored.Creator!.Value); + Assert.Same(fresh.Scene, GetRuntimeField(restored, "k__BackingField")); + Assert.True(graph.Verify(capture.Document, restore, Array.Empty()).Success); + } + + [Theory] + [InlineData("opaque-field")] + [InlineData("base-field")] + [InlineData("missing-membership")] + [InlineData("wrong-component-owner")] + [InlineData("wrong-list-owner")] + [InlineData("foreign-scene")] + [InlineData("wrong-fresh-owner")] + public void RuntimeEntityComponentCreatorsRequireTypedMembershipAndTheSameScene(string invalidProof) { + OwnedComponentEntity creator = CreateOwnedComponentEntity(); + SetRuntimeField(creator, "k__BackingField", CreateEntityId("a00", 10)); + creator.Owned.Value = 37; + ComponentCapturedRuntimeEffect effect = CreateUninitializedEntity(); + InitializeEmptyComponentList(effect); + if (invalidProof == "opaque-field") { + effect.OpaqueCreator = creator.Owned; + } else if (invalidProof == "base-field") { + effect.BaseCreator = creator.Owned; + } else { + effect.Creator = creator.Owned; + } + // Visit the effect before its component and owner, including refusal. + SavedSceneRoot saved = CreateOwnedScene(effect, creator); + if (invalidProof == "missing-membership") { + GetRuntimeField>(GetRuntimeField(creator, "k__BackingField"), "components").Remove(creator.Owned); + } else if (invalidProof == "wrong-component-owner" || invalidProof == "wrong-list-owner") { + SourceIdentifiedEntity other = CreateSourceIdentifiedEntity("a00", 20, 0); + SetRuntimeField(other, "k__BackingField", saved.Scene); + AddDetachedEntity(saved.Entities, other); + SetRuntimeField( + invalidProof == "wrong-component-owner" ? (object) creator.Owned : GetRuntimeField(creator, "k__BackingField"), + "k__BackingField", + other); + } else if (invalidProof == "foreign-scene") { + SetRuntimeField(creator, "k__BackingField", + RuntimeHelpers.GetUninitializedObject(typeof(Scene))); + } + OwnedComponentEntity baselineCreator = CreateOwnedComponentEntity(); + SetRuntimeField(baselineCreator, "k__BackingField", CreateEntityId("a00", 10)); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved, CreateOwnedScene(baselineCreator)); + Assert.True(capture.Success, capture.Error); + OwnedComponentEntity freshCreator = CreateOwnedComponentEntity(); + SetRuntimeField(freshCreator, "k__BackingField", + CreateEntityId("a00", invalidProof == "wrong-fresh-owner" ? 20 : 10)); + OwnedTestComponent freshComponent = freshCreator.Owned; + SavedSceneRoot fresh = CreateOwnedScene(freshCreator); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + Assert.False(restore.Success); + Assert.Same(freshCreator, Assert.Single(GetEntityListContents(fresh.Entities))); + Assert.Same(freshComponent, freshCreator.Owned); + Assert.Same(freshCreator, GetRuntimeField(freshComponent, "k__BackingField")); + Assert.Equal(0, freshComponent.Value); + Assert.Same(fresh.Scene, GetRuntimeField(freshCreator, "k__BackingField")); + } + + [Theory] + [InlineData("valid")] + [InlineData("opaque-owner")] + [InlineData("base-element")] + [InlineData("foreign-scene")] + [InlineData("missing-owner")] + public void RuntimeEntitiesRequireTheirTypedFreshBackdropCollection(string ownership) { + (SavedSceneRoot saved, RuntimeCollectionBackdrop savedBackdrop) = + CreateRuntimeBackdropScene(includeEffect: true, ownership); + (SavedSceneRoot baseline, _) = CreateRuntimeBackdropScene(includeEffect: false, ownership); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved, baseline); + Assert.True(capture.Success, capture.Error); + (SavedSceneRoot fresh, RuntimeCollectionBackdrop freshBackdrop) = + CreateRuntimeBackdropScene(includeEffect: false, ownership); + if (ownership == "missing-owner") { + ((RuntimeBackdropScene) fresh.Scene).Backdrop = null; + } + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + if (ownership != "valid") { + Assert.False(restore.Success); + Assert.Empty(GetEntityListContents(fresh.Entities)); + Assert.Empty(freshBackdrop.Effects); + return; + } + Assert.True(restore.Success, restore.Error); + CollectionRuntimeEffect effect = Assert.IsType( + Assert.Single(GetEntityListContents(fresh.Entities))); + Assert.Same(effect, Assert.Single(freshBackdrop.Effects)); + Assert.NotSame(Assert.Single(savedBackdrop.Effects), effect); + Assert.Same(fresh.Scene, GetRuntimeField(effect, "k__BackingField")); + Assert.Equal(37, effect.Value); + Assert.True(graph.Verify(capture.Document, restore, Array.Empty()).Success); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TypedRuntimeEntityCollectionOriginIsIndependentOfAllocationOrder(bool creatorFirst) { + RuntimeCollectionEntity owner = CreateUninitializedEntity(); + InitializeEmptyComponentList(owner); + SetRuntimeField(owner, "k__BackingField", CreateEntityId("a00", 10)); + CollectionRuntimeEffect effect = CreateUninitializedEntity(); + InitializeEmptyComponentList(effect); + effect.Value = 37; + owner.Effects = new List { effect }; + SavedSceneRoot saved = creatorFirst ? CreateOwnedScene(owner, effect) : CreateOwnedScene(effect, owner); + RuntimeCollectionEntity baselineOwner = CreateRuntimeCollectionEntity(); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved, CreateOwnedScene(baselineOwner)); + Assert.True(capture.Success, capture.Error); + RuntimeCollectionEntity freshOwner = CreateRuntimeCollectionEntity(); + SavedSceneRoot fresh = CreateOwnedScene(freshOwner); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + Assert.True(restore.Success, restore.Error); + CollectionRuntimeEffect restored = Assert.Single( + GetEntityListContents(fresh.Entities).OfType()); + Assert.Same(restored, Assert.Single(freshOwner.Effects)); + Assert.Same(fresh.Scene, GetRuntimeField(restored, "k__BackingField")); + Assert.Equal(37, restored.Value); + Assert.True(graph.Verify(capture.Document, restore, Array.Empty()).Success); + } + + [Theory] + [InlineData("entity", "foreign")] + [InlineData("entity", "detached")] + [InlineData("component", "valid")] + [InlineData("component", "foreign")] + [InlineData("component", "detached")] + [InlineData("renderer", "valid")] + [InlineData("renderer", "foreign")] + [InlineData("renderer", "detached")] + public void RetainedRuntimeCollectionRootsRequireActualSceneMembership(string rootKind, string membership) { + (SavedSceneRoot saved, _) = CreateRetainedRuntimeOwnerScene(rootKind, membership, includeEffect: true); + (SavedSceneRoot baseline, _) = CreateRetainedRuntimeOwnerScene(rootKind, membership, includeEffect: false); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved, baseline); + Assert.True(capture.Success, capture.Error); + (SavedSceneRoot fresh, List effects) = + CreateRetainedRuntimeOwnerScene(rootKind, membership, includeEffect: false); + Entity[] before = GetEntityListContents(fresh.Entities).ToArray(); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + if (membership != "valid") { + Assert.False(restore.Success); + Assert.Equal(before, GetEntityListContents(fresh.Entities)); + Assert.Empty(effects); + return; + } + Assert.True(restore.Success, restore.Error); + CollectionRuntimeEffect effect = Assert.Single( + GetEntityListContents(fresh.Entities).OfType()); + Assert.Same(effect, Assert.Single(effects)); + Assert.Same(fresh.Scene, GetRuntimeField(effect, "k__BackingField")); + Assert.Equal(37, effect.Value); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public void CompilerIteratorSceneLocalAliasesItsAuthenticatedOwnerScene( + bool componentOwner, bool componentFirst + ) { + (CapturedSceneRoom saved, _, _) = CreateCapturedSceneRoom(true, componentOwner, componentFirst); + (CapturedSceneRoom baseline, _, _) = CreateCapturedSceneRoom(false, componentOwner, componentFirst); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved, baseline); + Assert.True(capture.Success, capture.Error); + (CapturedSceneRoom fresh, SceneRoutineEntity freshOwner, Coroutine freshRoutine) = + CreateCapturedSceneRoom(false, componentOwner, componentFirst); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + Assert.True(restore.Success, restore.Error); + IEnumerator iterator = Assert.Single(GetRuntimeField>(freshRoutine, "enumerators")); + Assert.True(iterator.MoveNext()); + Assert.Same(fresh.Scene, componentOwner ? freshOwner.Driver!.ObservedScene : freshOwner.ObservedScene); + Assert.Same(freshOwner, GetRuntimeField(freshRoutine, "k__BackingField")); + Assert.NotSame(fresh.ForeignScene, freshOwner.ObservedScene); + } + + [Theory] + [InlineData("foreign-scene")] + [InlineData("foreign-coroutine")] + [InlineData("opaque-scene")] + public void CompilerIteratorSceneLocalCannotBorrowAnotherSceneOrOwner(string invalidProof) { + (CapturedSceneRoom saved, _, _) = CreateCapturedSceneRoom(true, false, false, invalidProof); + (CapturedSceneRoom baseline, _, _) = CreateCapturedSceneRoom(false, false, false, invalidProof); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); + AkronReconstructionCapture capture = graph.Capture(saved, baseline); + Assert.True(capture.Success, capture.Error); + (CapturedSceneRoom fresh, SceneRoutineEntity owner, Coroutine routine) = + CreateCapturedSceneRoom(false, false, false, invalidProof); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); Assert.False(restore.Success); - Assert.Contains("reconstructed type is not authentic", restore.Error); + Assert.Empty(GetRuntimeField>(routine, "enumerators")); + Assert.Null(owner.ObservedScene); + Assert.Same(fresh.Scene, GetRuntimeField(owner, "k__BackingField")); } - // DustGraphic.Eyeballs' shape: the room builds an extra entity on first - // render and hands it the component that built it. The surplus watcher the - // fresh room did not build must keep its reference to the fresh component. - [Fact] - public void ARuntimeEntityKeepsItsReferenceToTheFreshComponentThatBuiltIt() { - (SavedSceneRoot saved, LazyBlinkOwnerEntity savedOwner, EyeballsWatcherEntity[] savedWatchers) = - CreateEyeballsScene(2); - foreach (EyeballsWatcherEntity watcher in savedWatchers) { - watcher.Dust = savedOwner.Graphic; + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ManuallyDrivenIteratorKeepsItsStructuralSceneProofWithoutACoroutine(bool foreignScene) { + static (CapturedSceneRoom Room, SceneRoutineEntity Owner) CreateManualRoom(bool foreign) { + (CapturedSceneRoom room, SceneRoutineEntity owner, _) = + CreateCapturedSceneRoom(false, false, false); + owner.Manual = owner.Run(); + Assert.True(owner.Manual.MoveNext()); + if (foreign) { + owner.Manual.GetType().GetFields(RuntimeInstanceFields) + .Single(field => field.FieldType == typeof(Scene)) + .SetValue(owner.Manual, room.ForeignScene); + } + return (room, owner); } - (SavedSceneRoot baseline, _, _) = CreateEyeballsScene(1); + + (CapturedSceneRoom saved, SceneRoutineEntity savedOwner) = CreateManualRoom(foreignScene); + (CapturedSceneRoom baseline, _) = CreateManualRoom(false); AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource); AkronReconstructionCapture capture = graph.Capture(saved, baseline); Assert.True(capture.Success, capture.Error); - (SavedSceneRoot fresh, LazyBlinkOwnerEntity freshOwner, _) = CreateEyeballsScene(1); + (CapturedSceneRoom fresh, SceneRoutineEntity freshOwner) = CreateManualRoom(false); + IEnumerator previous = freshOwner.Manual!; AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + if (foreignScene) { + Assert.False(restore.Success); + Assert.Same(previous, freshOwner.Manual); + Assert.Null(freshOwner.ObservedScene); + return; + } Assert.True(restore.Success, restore.Error); - EyeballsWatcherEntity[] watchers = GetEntityListContents(fresh.Entities) - .OfType() - .ToArray(); - Assert.Equal(2, watchers.Length); - Assert.All(watchers, watcher => Assert.Same(freshOwner.Graphic, watcher.Dust)); + Assert.NotSame(savedOwner.Manual, freshOwner.Manual); + Assert.True(freshOwner.Manual!.MoveNext()); + Assert.Same(fresh.Scene, freshOwner.ObservedScene); } - // DynamicData's per-type member cache holds compiled FastReflection - // invokers - anonymous delegates no fresh room can vouch for - and every - // instance points at the process-wide entry. A mod attaching DynamicData - // to a room entity removed every Set in the room over those delegates. - // The cache is a live resource now: capture never walks in, and restore - // rebinds to this process's own entry for the same target type. - [Fact] - public void ADynamicDataMemberCacheRestoresAsThisProcessesOwnEntry() { - DynamicDataSubject subject = new DynamicDataSubject(); - MonoMod.Utils.DynamicData data = new MonoMod.Utils.DynamicData(subject); - Assert.Equal(5, data.Get("Exposed")); - DynamicDataHolder saved = new DynamicDataHolder { Data = data, Value = 3 }; - DynamicDataHolder baseline = new DynamicDataHolder { - Data = new MonoMod.Utils.DynamicData(new DynamicDataSubject()) - }; + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ASessionSuppressedMapEntityRestoresOnlyWhenItsIdentityHasNoConflictingType(bool conflictingType) { + MiniTextboxTrigger trigger = CreateUninitializedEntity(); + InitializeEmptyComponentList(trigger); + SetRuntimeField(trigger, "k__BackingField", CreateEntityId("a00", 10000010)); + SetRuntimeField(trigger, "triggered", true); + SetRuntimeField(trigger, "onlyOnce", true); AkronReconstructionGraph graph = new AkronReconstructionGraph( - AkronStartPosReconstruction.IsLiveResourceType, - AkronStartPosReconstruction.GetLiveResourceKey, - resolveDetachedLiveResource: AkronStartPosReconstruction.ResolveDetachedLiveResource); + IsLiveResource, getMapPlacedEntityIds: (_, _) => new[] { 10000010 }); + AkronReconstructionCapture capture = graph.Capture( + CreateOwnedScene(trigger), CreateOwnedScene()); + Assert.True(capture.Success, capture.Error); + capture.Document.Room = "a00"; + Entity replacement = CreateSourceIdentifiedEntity("a00", 10000010, 0); + SavedSceneRoot fresh = conflictingType ? CreateOwnedScene(replacement) : CreateOwnedScene(); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + if (conflictingType) { + Assert.False(restore.Success); + Assert.Same(replacement, Assert.Single(GetEntityListContents(fresh.Entities))); + Assert.Same(fresh.Scene, GetRuntimeField(replacement, "k__BackingField")); + return; + } + Assert.True(restore.Success, restore.Error); + MiniTextboxTrigger restored = Assert.IsType( + Assert.Single(GetEntityListContents(fresh.Entities))); + Assert.True(GetRuntimeField(restored, "triggered")); + Assert.True(GetRuntimeField(restored, "onlyOnce")); + Assert.Same(fresh.Scene, GetRuntimeField(restored, "k__BackingField")); + } + + [Fact] + public void RemovedEntityRetainedByAnOwnerCollectionMatchesItsFreshSourceIdentity() { + PeerTargetEntity retained = CreatePeerTargetEntity("a00", 10); + PeerCollectionOwnerEntity owner = CreatePeerCollectionOwnerEntity("a00", 20, retained); + SavedSceneRoot saved = CreateOwnedScene(owner); + PeerTargetEntity baselineTarget = CreatePeerTargetEntity("a00", 10); + SavedSceneRoot baseline = CreateOwnedScene( + CreatePeerCollectionOwnerEntity("a00", 20), baselineTarget); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource, _ => string.Empty); AkronReconstructionCapture capture = graph.Capture(saved, baseline); Assert.True(capture.Success, capture.Error); - DynamicDataHolder fresh = new DynamicDataHolder { - Data = new MonoMod.Utils.DynamicData(new DynamicDataSubject()) - }; + PeerCollectionOwnerEntity freshOwner = CreatePeerCollectionOwnerEntity("a00", 20); + PeerTargetEntity freshTarget = CreatePeerTargetEntity("a00", 10); + SavedSceneRoot fresh = CreateOwnedScene(freshOwner, freshTarget); AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); Assert.True(restore.Success, restore.Error); - Assert.Equal(3, fresh.Value); - object restoredCache = GetRuntimeField(fresh.Data!, "_Cache"); - object liveCache = GetRuntimeField(new MonoMod.Utils.DynamicData(new DynamicDataSubject()), "_Cache"); - Assert.Same(liveCache, restoredCache); + Assert.Same(freshTarget, Assert.Single(freshOwner.Peers)); + Assert.Same(freshOwner, Assert.Single(GetEntityListContents(fresh.Entities))); + Assert.Null(GetRuntimeField(freshTarget, "k__BackingField")); + Assert.True(graph.Verify(capture.Document, restore, Array.Empty()).Success); } // Frost Helper's EntityBatcher starts with a shader id in its parameter @@ -6286,6 +6994,47 @@ public void FreshEntityCanRestoreAnExactTypedPeerLinkWithinItsEntityList() { Assert.True(graph.Verify(capture.Document, restore, Array.Empty()).Success); } + [Fact] + public void NamedPeerAliasesFollowSourceIdentityWhenOwnerAndPeerPairsChangeListOrder() { + PeerTargetEntity savedP = CreatePeerTargetEntity("a00", 20); + PeerTargetEntity savedQ = CreatePeerTargetEntity("a00", 21); + savedP.Value = 37; + savedQ.Value = 81; + SourceEntityListOwnerRoot saved = CreateSourceEntityListOwnerRoot( + CreatePeerLinkEntity("a00", 10, savedP), savedP, + CreatePeerLinkEntity("a00", 11, savedQ), savedQ); + PeerTargetEntity baselineP = CreatePeerTargetEntity("a00", 20); + PeerTargetEntity baselineQ = CreatePeerTargetEntity("a00", 21); + SourceEntityListOwnerRoot baseline = CreateSourceEntityListOwnerRoot( + CreatePeerLinkEntity("a00", 10, baselineP), baselineP, + CreatePeerLinkEntity("a00", 11, baselineQ), baselineQ); + PeerTargetEntity freshP = CreatePeerTargetEntity("a00", 20); + PeerTargetEntity freshQ = CreatePeerTargetEntity("a00", 21); + PeerLinkEntity freshA = CreatePeerLinkEntity("a00", 10, freshP); + PeerLinkEntity freshB = CreatePeerLinkEntity("a00", 11, freshQ); + SourceEntityListOwnerRoot fresh = CreateSourceEntityListOwnerRoot( + freshB, freshQ, freshA, freshP); + AkronReconstructionGraph graph = new AkronReconstructionGraph(IsLiveResource, _ => string.Empty); + AkronReconstructionCapture capture = graph.Capture(saved, baseline); + Assert.True(capture.Success, capture.Error); + + AkronReconstructionRestore restore = graph.Restore(capture.Document, fresh); + + Assert.True(restore.Success, restore.Error); + Assert.Same(freshP, freshA.Peer); + Assert.Same(freshQ, freshB.Peer); + Assert.Equal(37, freshP.Value); + Assert.Equal(81, freshQ.Value); + Assert.Equal(20, GetRuntimeField(freshP, "k__BackingField").ID); + Assert.Equal(21, GetRuntimeField(freshQ, "k__BackingField").ID); + Assert.Collection(GetEntityListContents(fresh.Entities), + entity => Assert.Same(freshA, entity), + entity => Assert.Same(freshP, entity), + entity => Assert.Same(freshB, entity), + entity => Assert.Same(freshQ, entity)); + Assert.True(graph.Verify(capture.Document, restore, Array.Empty()).Success); + } + // The crossed population through a named field, and the reason // RefuseAnEdgeThatDropsAFreshObjectTheDocumentKeeps asks whether the displaced // object is one the document keeps rather than only whether the slot is occupied. @@ -6662,6 +7411,134 @@ private static OwnedComponentEntity CreateOwnedComponentEntity(bool ownedFirst = return entity; } + private sealed class RetainedMapMetadataSession : EverestModuleSession { + public Dictionary Entries = new Dictionary(); + } + + private sealed class DeepGraphRecord { + public DeepGraphRecord? Next; + public DeepGraphRecord? Shared; + public int Value; + } + + private sealed class ResourceCameraScene : Level { + public ResourceCameraScene? Foreign; + } + + private static VirtualMap CreateSingleSegmentTextureGrid(int columns, int rows) { + VirtualMap grid = + (VirtualMap) RuntimeHelpers.GetUninitializedObject(typeof(VirtualMap)); + SetRuntimeField(grid, "Columns", columns); + SetRuntimeField(grid, "Rows", rows); + SetRuntimeField(grid, "SegmentColumns", 1); + SetRuntimeField(grid, "SegmentRows", 1); + var segments = new MTexture[1, 1][,]; + segments[0, 0] = new MTexture[VirtualMap.SegmentSize, VirtualMap.SegmentSize]; + SetRuntimeField(grid, "segments", segments); + return grid; + } + + private static (SavedSceneRoot Root, ResourceCameraScene Scene, TileGrid Component) CreateSceneCameraAlias( + bool captured, + string proof = "valid", + bool staleCamera = false, + bool detachedAtCapture = false + ) { + ResourceCameraScene scene = (ResourceCameraScene) RuntimeHelpers.GetUninitializedObject(typeof(ResourceCameraScene)); + scene.Camera = (Camera) RuntimeHelpers.GetUninitializedObject(typeof(Camera)); + // Use a primitive scalar: CI strips FNA's vector constructors. + SetRuntimeField(scene.Camera, "angle", 0.125f); + scene.Foreign = (ResourceCameraScene) RuntimeHelpers.GetUninitializedObject(typeof(ResourceCameraScene)); + scene.Foreign.Camera = (Camera) RuntimeHelpers.GetUninitializedObject(typeof(Camera)); + SetRuntimeField(scene.Foreign.Camera, "angle", 0.25f); + LinkSceneEntities(scene.Foreign, CreateDetachedEntityList()); + EntityList entities = LinkSceneEntities(scene, CreateDetachedEntityList()); + BackgroundTiles canonicalOwner = CreateUninitializedEntity(); + InitializeEmptyComponentList(canonicalOwner); + AddDetachedEntity(entities, canonicalOwner); + SetRuntimeField(canonicalOwner, "k__BackingField", scene); + SetRuntimeField(scene, "BgTiles", canonicalOwner); + TileGrid canonicalGrid = (TileGrid) RuntimeHelpers.GetUninitializedObject(typeof(TileGrid)); + canonicalGrid.ClipCamera = scene.Camera; + SetRuntimeField(canonicalOwner, "Tiles", canonicalGrid); + SetRuntimeField(canonicalGrid, "k__BackingField", canonicalOwner); + GetRuntimeField>(GetRuntimeField(canonicalOwner, "k__BackingField"), "components").Add(canonicalGrid); + GetRuntimeField>(GetRuntimeField(canonicalOwner, "k__BackingField"), "current").Add(canonicalGrid); + GridCallbackOwner owner = CreateUninitializedEntity(); + InitializeEmptyComponentList(owner); + SetRuntimeField(owner, "k__BackingField", CreateEntityId("a00", 10)); + AddDetachedEntity(entities, owner); + SetRuntimeField(owner, "k__BackingField", scene); + TileGrid grid = (TileGrid) RuntimeHelpers.GetUninitializedObject(typeof(TileGrid)); + grid.Tiles = CreateSingleSegmentTextureGrid(1, 1); + grid.ClipCamera = captured + ? (proof == "foreign-scene" ? scene.Foreign.Camera : scene.Camera) + : staleCamera ? (Camera) RuntimeHelpers.GetUninitializedObject(typeof(Camera)) : null; + if (!captured && grid.ClipCamera is Camera stale) { + SetRuntimeField(stale, "angle", -0.125f); + } + owner.Grid = grid; + owner.Interceptor = new GridCallbackComponent(grid); + SetRuntimeField(owner.Interceptor, "k__BackingField", owner); + GetRuntimeField>(GetRuntimeField(owner, "k__BackingField"), "components").Add(owner.Interceptor); + GetRuntimeField>(GetRuntimeField(owner, "k__BackingField"), "current").Add(owner.Interceptor); + if (captured && !detachedAtCapture) { + SetRuntimeField(grid, "k__BackingField", owner); + GetRuntimeField>(GetRuntimeField(owner, "k__BackingField"), "components").Add(grid); + GetRuntimeField>(GetRuntimeField(owner, "k__BackingField"), "current").Add(grid); + } + if (captured && proof == "missing-membership") { + SetRuntimeField(GetRuntimeField(owner, "k__BackingField"), "k__BackingField", null); + } + if (captured && proof == "missing-field") { + owner.Grid = null!; + } + if (captured && proof == "competing-owner") { + GridCallbackOwner otherOwner = CreateUninitializedEntity(); + InitializeEmptyComponentList(otherOwner); + SetRuntimeField(otherOwner, "k__BackingField", CreateEntityId("a00", 11)); + AddDetachedEntity(entities, otherOwner); + SetRuntimeField(otherOwner, "k__BackingField", scene); + otherOwner.Grid = grid; + } + return (new SavedSceneRoot { Scene = scene, Entities = entities }, scene, grid); + } + + private static (SavedSceneRoot Root, ComponentCallbackOwner Owner) CreateComponentCallbackScene( + bool includeCallback, + string captureProof = "valid", + bool detachedCallback = false + ) { + ComponentCallbackOwner[] owners = new ComponentCallbackOwner[2]; + for (int index = 0; index < owners.Length; index++) { + ComponentCallbackOwner entity = CreateUninitializedEntity(); + InitializeEmptyComponentList(entity); + SetRuntimeField(entity, "k__BackingField", CreateEntityId("a00", 10 + index)); + entity.Target = new OwnedTestComponent { Value = includeCallback && index == 0 ? 37 : 0 }; + SetRuntimeField(entity.Target, "k__BackingField", entity); + GetRuntimeField>(GetRuntimeField(entity, "k__BackingField"), "components").Add(entity.Target); + GetRuntimeField>(GetRuntimeField(entity, "k__BackingField"), "current").Add(entity.Target); + owners[index] = entity; + } + ComponentCallbackOwner owner = owners[0]; + if (includeCallback) { + OwnedTestComponent captured = captureProof == "foreign-owner" ? owners[1].Target : owner.Target; + owner.Callback = captureProof == "opaque-field" + ? new ConstructorCallbackComponent((object) captured) + : new ConstructorCallbackComponent(captured); + if (!detachedCallback) { + SetRuntimeField(owner.Callback, "k__BackingField", owner); + GetRuntimeField>(GetRuntimeField(owner, "k__BackingField"), "components").Add(owner.Callback); + GetRuntimeField>(GetRuntimeField(owner, "k__BackingField"), "current").Add(owner.Callback); + } + if (captureProof == "missing-membership") { + GetRuntimeField>(GetRuntimeField(owner, "k__BackingField"), "components").Remove(captured); + GetRuntimeField>(GetRuntimeField(owner, "k__BackingField"), "current").Remove(captured); + } + } + return (CreateOwnedScene(owners), owner); + } + // withBlink mirrors what BeforeRender does for the saved side; the fresh and // baseline sides never rendered, so their private slot stays null. The blink // coroutine is deliberately never added to the component list, exactly like @@ -7126,6 +8003,42 @@ public void CaptureRefusesANativeUnsignedPointerOnTheSameGate() { Assert.Contains("pointer-type=System.UIntPtr", capture.Error); } + [Fact] + public void CaptureRefusesBoxedPointersWithoutWalkingTheirPointerField() { + BoxedNativeHandleRoot saved = new BoxedNativeHandleRoot { + Handle = RuntimeHelpers.GetUninitializedObject(typeof(Pointer)) + }; + BoxedNativeHandleRoot baseline = new BoxedNativeHandleRoot { + Handle = RuntimeHelpers.GetUninitializedObject(typeof(Pointer)) + }; + AkronReconstructionGraph graph = CreateFinitePointerProbeGraph(); + + AkronReconstructionCapture capture = graph.Capture(saved, baseline); + + Assert.False(capture.Success); + Assert.Equal("$.Handle", capture.ErrorPath); + Assert.Null(capture.Document); + } + + [Fact] + public void RestoreSkipsUnusedBoxedPointersInTheFreshResourceIndex() { + AkronReconstructionGraph graph = CreateFinitePointerProbeGraph(); + AkronReconstructionCapture capture = graph.Capture( + new BoxedNativeHandleRoot { Value = 41 }, + new BoxedNativeHandleRoot()); + Assert.True(capture.Success, capture.Error); + AkronReconstructionDocument document = graph.Deserialize(graph.Serialize(capture.Document)); + BoxedNativeHandleRoot fresh = new BoxedNativeHandleRoot { + Handle = RuntimeHelpers.GetUninitializedObject(typeof(Pointer)) + }; + + AkronReconstructionRestore restore = graph.Restore(document, fresh); + + Assert.True(restore.Success, restore.Error); + Assert.Equal(41, fresh.Value); + Assert.Null(fresh.Handle); + } + // A snapshot written before that gate was fixed holds a scalar whose type is // System.IntPtr, and capture can no longer produce one, so the document is // edited into the shape those files already have on disk. Rebuilding one has @@ -8182,10 +9095,10 @@ public void CompressedSnapshotFileRoundTripsGraphAndIdentity() { Assert.Equal("Celeste/1-ForsakenCity", document.MapSid); Assert.Equal("1", document.Room); Assert.Equal(0, document.FileSlot); - Assert.Equal("akron-reconstruction-v10", document.Format); + 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("v10-", Path.GetFileName(AkronStartPosReconstruction.GetSnapshotPath("Akron StartPos test 1", directory))); + 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)) { @@ -8365,6 +9278,22 @@ private sealed class NativeHandleRoot { public IntPtr Handle; } + private sealed class BoxedNativeHandleRoot { + public object? Handle; + public int Value; + } + + private static AkronReconstructionGraph CreateFinitePointerProbeGraph() { + int pointerVisits = 0; + return new AkronReconstructionGraph(type => { + // Bound a regressed reboxing loop without exhausting the test runner. + if (type == typeof(Pointer) && ++pointerVisits > 32) { + throw new InvalidOperationException("The resource index entered a boxed pointer repeatedly."); + } + return IsLiveResource(type); + }); + } + // The Spring Collab 2020 shape that used to refuse every Heart of the Storm // capture: an ordinary room object holding a WeakReference alongside a // strong edge to the same target. @@ -8767,7 +9696,7 @@ public AkronReconstructionResourcePayload Capture(object resource) { }; } - public object Restore(AkronReconstructionResourcePayload payload, object freshResource) { + public object Restore(Type resourceType, AkronReconstructionResourcePayload payload, object freshResource) { LastRestored = new TestResource(System.Text.Encoding.UTF8.GetString(payload.Bytes), payload.Name); return LastRestored; } @@ -8813,6 +9742,7 @@ private sealed class NestedDecalInfo { } private sealed class PeerTargetEntity : Entity { + public int Value; } private sealed class PeerLinkEntity : Entity { @@ -9903,6 +10833,256 @@ private sealed class DynamicDataSubject { public int Exposed = 5; } + private sealed class ManagedGridOwnerEntity : Entity { + public object? Opaque; + public VirtualMap? Grid; + public MTexture? Texture; + public ManagedSurfaceState? Surface; + } + + private sealed class ManagedSurfaceState { + public List Records = new List(); + + public sealed class Record { + public int Value; + + public int ReadValue() { + return Value; + } + } + } + + [Pooled] + private sealed class PooledRuntimeEffect : Entity { + public int Value; + } + + private sealed class CapturedRuntimeEffect : Entity { + public SourceIdentifiedEntity Creator = null!; + } + + private sealed class ComponentCapturedRuntimeEffect : Entity { + public OwnedTestComponent? Creator; + public Component? BaseCreator; + public object? OpaqueCreator; + } + + private sealed class CollectionRuntimeEffect : Entity { + public int Value; + } + + private sealed class RuntimeCollectionEntity : Entity { + public List Effects = null!; + } + + private sealed class RuntimeCollectionComponent : Component { + public List Effects = new List(4); + + public RuntimeCollectionComponent() : base(false, false) { } + } + + private sealed class RuntimeCollectionRenderer : Renderer { + public List Effects = new List(4); + } + + private static RuntimeCollectionEntity CreateRuntimeCollectionEntity() { + RuntimeCollectionEntity owner = CreateUninitializedEntity(); + InitializeEmptyComponentList(owner); + SetRuntimeField(owner, "k__BackingField", CreateEntityId("a00", 10)); + owner.Effects = new List(4); + return owner; + } + + private sealed class RuntimeCollectionBackdrop : Backdrop { + public List Effects = null!; + public List? BaseEffects; + public object? OpaqueEffects; + } + + private sealed class RuntimeBackdropScene : Scene { + public Backdrop? Backdrop; + public Entity? RetainedEntity; + public Component? RetainedComponent; + public Renderer? RetainedRenderer; + public Scene? ForeignScene; + } + + private static (SavedSceneRoot Root, RuntimeCollectionBackdrop Backdrop) CreateRuntimeBackdropScene( + bool includeEffect, string ownership + ) { + RuntimeBackdropScene scene = + (RuntimeBackdropScene) RuntimeHelpers.GetUninitializedObject(typeof(RuntimeBackdropScene)); + EntityList entities = LinkSceneEntities(scene, CreateDetachedEntityList()); + RuntimeCollectionBackdrop backdrop = + (RuntimeCollectionBackdrop) RuntimeHelpers.GetUninitializedObject(typeof(RuntimeCollectionBackdrop)); + backdrop.Effects = new List(4); + scene.Backdrop = backdrop; + scene.ForeignScene = (Scene) RuntimeHelpers.GetUninitializedObject(typeof(Scene)); + if (includeEffect) { + CollectionRuntimeEffect effect = CreateUninitializedEntity(); + InitializeEmptyComponentList(effect); + effect.Value = 37; + SetRuntimeField(effect, "k__BackingField", + ownership == "foreign-scene" ? scene.ForeignScene : scene); + AddDetachedEntity(entities, effect); + if (ownership == "opaque-owner") { + backdrop.OpaqueEffects = new List { effect }; + } else if (ownership == "base-element") { + backdrop.BaseEffects = new List { effect }; + } else { + backdrop.Effects.Add(effect); + } + } + return (new SavedSceneRoot { Scene = scene, Entities = entities }, backdrop); + } + + private static (SavedSceneRoot Root, List Effects) CreateRetainedRuntimeOwnerScene( + string rootKind, string membership, bool includeEffect + ) { + RuntimeBackdropScene scene = + (RuntimeBackdropScene) RuntimeHelpers.GetUninitializedObject(typeof(RuntimeBackdropScene)); + EntityList entities = LinkSceneEntities(scene, CreateDetachedEntityList()); + SavedSceneRoot foreign = CreateOwnedScene(); + scene.ForeignScene = foreign.Scene; + Scene ownerScene = membership == "valid" ? scene : foreign.Scene; + EntityList ownerEntities = membership == "valid" ? entities : foreign.Entities; + List effects; + RuntimeCollectionEntity? entityOwner = null; + if (rootKind == "renderer") { + RuntimeCollectionRenderer renderer = new RuntimeCollectionRenderer(); + scene.RetainedRenderer = renderer; + effects = renderer.Effects; + if (membership != "detached") { + RendererList renderers = (RendererList) RuntimeHelpers.GetUninitializedObject(typeof(RendererList)); + SetRuntimeField(renderers, "Renderers", new List { renderer }); + SetRuntimeField(renderers, "adding", new List()); + SetRuntimeField(renderers, "removing", new List()); + SetRuntimeField(renderers, "scene", ownerScene); + SetRuntimeField(ownerScene, "k__BackingField", renderers); + } + } else { + entityOwner = CreateRuntimeCollectionEntity(); + if (rootKind == "component") { + RuntimeCollectionComponent component = new RuntimeCollectionComponent(); + SetRuntimeField(component, "k__BackingField", entityOwner); + GetComponentListContents(entityOwner).Add(component); + SetRuntimeField(GetRuntimeField(entityOwner, "k__BackingField"), "current", new HashSet { component }); + scene.RetainedComponent = component; + effects = component.Effects; + } else { + scene.RetainedEntity = entityOwner; + effects = entityOwner.Effects; + } + if (membership != "detached") { + SetRuntimeField(entityOwner, "k__BackingField", ownerScene); + } + } + if (includeEffect) { + CollectionRuntimeEffect effect = CreateUninitializedEntity(); + InitializeEmptyComponentList(effect); + effect.Value = 37; + SetRuntimeField(effect, "k__BackingField", scene); + AddDetachedEntity(entities, effect); + effects.Add(effect); + } + if (entityOwner != null && membership != "detached") { + AddDetachedEntity(ownerEntities, entityOwner); + } + return (new SavedSceneRoot { Scene = scene, Entities = entities }, effects); + } + + private sealed class CapturedSceneRoom { + public Scene Scene = null!; + public Scene ForeignScene = null!; + public EntityList Entities = null!; + } + + private sealed class SceneRoutineEntity : Entity { + public SceneRoutineComponent? Driver; + public Scene? ObservedScene; + public IEnumerator? Manual; + + public IEnumerator Run() { + Scene scene = GetRuntimeField(this, "k__BackingField"); + while (true) { + yield return null; + ObservedScene = scene; + } + } + + public IEnumerator RunOpaque() { + object scene = GetRuntimeField(this, "k__BackingField"); + while (true) { + yield return null; + ObservedScene = (Scene) scene; + } + } + } + + private sealed class SceneRoutineComponent : Component { + public Scene? ObservedScene; + + public SceneRoutineComponent() : base(false, false) { } + + public IEnumerator Run() { + Scene scene = GetRuntimeField(GetRuntimeField(this, "k__BackingField"), "k__BackingField"); + while (true) { + yield return null; + ObservedScene = scene; + } + } + } + + private static (CapturedSceneRoom Root, SceneRoutineEntity Owner, Coroutine Routine) CreateCapturedSceneRoom( + bool midFlight, bool componentOwner, bool componentFirst, string invalidProof = "" + ) { + SceneRoutineEntity owner = CreateUninitializedEntity(); + InitializeEmptyComponentList(owner); + SetRuntimeField(owner, "k__BackingField", CreateEntityId("a00", 10)); + SceneRoutineEntity other = CreateUninitializedEntity(); + InitializeEmptyComponentList(other); + SetRuntimeField(other, "k__BackingField", CreateEntityId("a00", 20)); + SavedSceneRoot scene = CreateOwnedScene(owner, other); + CapturedSceneRoom root = new CapturedSceneRoom { + Scene = scene.Scene, + Entities = scene.Entities, + ForeignScene = (Scene) RuntimeHelpers.GetUninitializedObject(typeof(Scene)) + }; + Coroutine routine = CreateDetachedCoroutine(); + SceneRoutineEntity coroutineOwner = invalidProof == "foreign-coroutine" ? other : owner; + SetRuntimeField(routine, "k__BackingField", coroutineOwner); + List ordered = GetComponentListContents(coroutineOwner); + ordered.Add(routine); + if (componentOwner) { + owner.Driver = new SceneRoutineComponent(); + SetRuntimeField(owner.Driver, "k__BackingField", owner); + ordered.Insert(componentFirst ? 0 : 1, owner.Driver); + } + SetRuntimeField(GetRuntimeField(coroutineOwner, "k__BackingField"), "current", new HashSet(ordered)); + if (midFlight) { + IEnumerator iterator = componentOwner ? owner.Driver!.Run() + : invalidProof == "opaque-scene" ? owner.RunOpaque() : owner.Run(); + Assert.True(iterator.MoveNext()); + if (invalidProof == "foreign-scene") { + iterator.GetType().GetFields(RuntimeInstanceFields) + .Single(field => field.FieldType == typeof(Scene)) + .SetValue(iterator, root.ForeignScene); + } + GetRuntimeField>(routine, "enumerators").Push(iterator); + } + return (root, owner, routine); + } + + private static SavedSceneRoot CreateOwnedScene(params Entity[] members) { + Scene scene = (Scene) RuntimeHelpers.GetUninitializedObject(typeof(Scene)); + EntityList entities = LinkSceneEntities(scene, CreateDetachedEntityList()); + foreach (Entity entity in members) { + SetRuntimeField(entity, "k__BackingField", scene); + AddDetachedEntity(entities, entity); + } + return new SavedSceneRoot { Scene = scene, Entities = entities }; + } + private sealed class RegisteredEffectRoot { public Effect? Effect; } @@ -10026,6 +11206,41 @@ public void SetValue(int value) { } } + private sealed class GridCallbackOwner : Entity { + public TileGrid Grid = null!; + public TileGrid OtherGrid = null!; + public GridCallbackComponent Interceptor = null!; + public GridCallbackComponent OtherInterceptor = null!; + } + + // TileInterceptor's constructor and callback bodies are absent from CI's + // reference assembly. This executable fixture retains the same constructor + // closure and concrete sibling-grid ownership exercised by the live check. + private sealed class GridCallbackComponent : Component { + public Action?> Intercept; + + public GridCallbackComponent(TileGrid grid) : base(false, false) { + Intercept = tiles => grid.Tiles = tiles; + } + } + + private sealed class ComponentCallbackOwner : Entity { + public OwnedTestComponent Target = null!; + public ConstructorCallbackComponent? Callback; + } + + private sealed class ConstructorCallbackComponent : Component { + public Action Callback; + + public ConstructorCallbackComponent(OwnedTestComponent captured) : base(false, false) { + Callback = () => captured.Value++; + } + + public ConstructorCallbackComponent(object captured) : base(false, false) { + Callback = () => ((OwnedTestComponent) captured).Value++; + } + } + private sealed class OwnedStateRoot { public OwnedStateEntity Owner = null!; }