using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using Newtonsoft.Json.Linq; using Sandbox.Game.Entities; using Sandbox.Game.Multiplayer; using Sandbox.Game.World; using VRage.Game; using VRage.Game.Entity; using VRageMath; using Terminal = Sandbox.ModAPI.Ingame.IMyTerminalBlock; namespace XFE.SeAgent.Plugin.Game { /// Game-thread-only, explicitly bounded debug operations. Never invokes arbitrary code. public sealed partial class GameDebugApi : IDisposable { private readonly Func _authorizedWorld; private readonly Action _log; private bool _disposed; private string _loadingPath; private string _loadError; private DateTime _loadStarted; private MySession _pausedSession; public GameDebugApi(Func authorizedWorld, Action log) { _authorizedWorld = authorizedWorld ?? throw new ArgumentNullException(nameof(authorizedWorld)); _log = log ?? delegate { }; } public JObject Execute(string method, JObject args) { if (_disposed) throw new ObjectDisposedException(nameof(GameDebugApi)); args = args ?? new JObject(); switch (method) { case "world.status": return WorldStatus(); case "world.load": return LoadWorld(args); case "world.save": RequireWritableWorld(); return SaveWorld(); case "world.exit": RequireWritableWorld(); return ExitWorld(args); case "world.pause": RequireWritableWorld(); return PauseWorld(args); case "grids.list": RequireWorld(); return ListGrids(args); case "grids.get": RequireWorld(); return DescribeGridWithIntegrity(Grid(Id(args, "entityId"))); case "blocks.list": RequireWorld(); return ListBlocks(args); case "blocks.get": RequireWorld(); return DescribeBlock(Block(Id(args, "entityId")), true); case "blocks.actions": RequireWorld(); return ListActions(args); case "blocks.action": RequireWritableWorld(); return ApplyAction(args); case "blocks.properties": RequireWorld(); return ListProperties(args); case "blocks.setProperty": RequireWritableWorld(); return SetProperty(args); case "pb.read": RequireWorld(); return ReadProgram(args); case "pb.inspect": RequireWorld(); return InspectProgram(args); case "pb.deploy": RequireWritableWorld(); return DeployProgram(args); case "pb.run": RequireWritableWorld(); return RunProgram(args); case "cameras.scan": RequireWritableWorld(); return ScanCamera(args); case "telemetry.snapshot": RequireWorld(); return Telemetry(args); case "grid.stop": RequireWritableWorld(); return StopGrid(args); case "debug.screenshot": RequireWritableWorld(); return Screenshot(); default: throw new ArgumentException("Unknown method: " + method); } } public void Update() { if (_disposed || _loadingPath == null) return; var session = MySession.Static; if (session != null && session.Ready && SamePath(session.CurrentPath, _loadingPath)) { _log("Loaded authorized debug world: " + _loadingPath); _loadingPath = null; } else if (DateTime.UtcNow - _loadStarted > TimeSpan.FromMinutes(10)) { _loadError = "World load did not complete within ten minutes; inspect the game loading screen/log."; _loadingPath = null; } } public void Dispose() { if (_pausedSession != null && ReferenceEquals(MySession.Static, _pausedSession)) Sandbox.MySandboxGame.PausePop(); _pausedSession = null; _disposed = true; } private JObject WorldStatus() { var s = MySession.Static; var result = new JObject { ["loaded"] = s != null, ["loadingPath"] = _loadingPath, ["loadError"] = _loadError }; if (s == null) return result; result["name"] = s.Name; result["path"] = s.CurrentPath; result["ready"] = s.Ready; result["isUnloading"] = s.IsUnloading; result["isServer"] = Sync.IsServer; result["onlineMode"] = s.OnlineMode.ToString(); result["authorizedWorld"] = IsAuthorized(s.CurrentPath); result["canMutate"] = CanWrite(s); result["scriptsEnabled"] = s.EnableIngameScripts; result["saveInProgress"] = s.IsSaveInProgress; result["frame"] = s.GameplayFrameCounter; result["elapsedSeconds"] = s.ElapsedGameTime.TotalSeconds; result["simulationSpeed"] = Sandbox.Engine.Physics.MyPhysics.SimulationRatio; result["paused"] = Sandbox.MySandboxGame.IsPaused; result["pausedByBridge"] = ReferenceEquals(_pausedSession, s); return result; } private JObject LoadWorld(JObject args) { string path = Path.GetFullPath(Text(args, "path")); if (!IsAuthorized(path)) throw new InvalidOperationException("world.load accepts only an explicitly authorized local test-copy path."); if (!File.Exists(Path.Combine(path, "Sandbox.sbc"))) throw new FileNotFoundException("The authorized world has no Sandbox.sbc checkpoint."); if (_loadingPath != null) throw new InvalidOperationException("A world load is already pending."); var current = MySession.Static; if (current != null) { if (SamePath(current.CurrentPath, path) && current.Ready) return WorldStatus(); // Loading a different world can unload unsaved state. Let the user/game return to the menu first. throw new InvalidOperationException("Return to the main menu before loading another test world; the bridge does not unload an active world."); } _loadError = null; _loadingPath = path; _loadStarted = DateTime.UtcNow; try { MySessionLoader.LoadSingleplayerSession(path, null, null, MyOnlineModeEnum.OFFLINE, 1, null, null); } catch { _loadingPath = null; throw; } return new JObject { ["accepted"] = true, ["path"] = path, ["state"] = "loading", ["pollMethod"] = "world.status" }; } private JObject SaveWorld() { var s = RequireWorld(); if (s.IsSaveInProgress) throw new InvalidOperationException("A save is already in progress."); bool saved = s.Save(null, null); return new JObject { ["saved"] = saved, ["path"] = s.CurrentPath, ["saveInProgress"] = s.IsSaveInProgress }; } private JObject ExitWorld(JObject args) { if (args["save"]?.Type != JTokenType.Boolean || (bool)args["save"] != true) throw new ArgumentException("world.exit requires save:true; exiting without saving is not supported."); var session = RequireWorld(); if (session.IsSaveInProgress) throw new InvalidOperationException("A save is already in progress; the game was not closed."); // In SE 1.210 MySession.Save calls snapshot.Save synchronously and returns its disk-save result. if (!session.Save(null, null)) throw new InvalidOperationException("The test world could not be saved; the game was not closed."); if (session.IsSaveInProgress) throw new InvalidOperationException("Saving has not finished; the game was not closed."); string path = session.CurrentPath; _log("Saved authorized debug world and requested normal game exit: " + path); Sandbox.MySandboxGame.ExitThreadSafe(); return new JObject { ["saved"] = true, ["path"] = path, ["exitRequested"] = true }; } private JObject PauseWorld(JObject args) { if (args["paused"]?.Type != JTokenType.Boolean) throw new ArgumentException("paused must be a boolean."); bool pause = (bool)args["paused"]; var session = RequireWorld(); if (!ReferenceEquals(_pausedSession, session)) _pausedSession = null; if (pause && _pausedSession == null) { Sandbox.MySandboxGame.PausePush(); _pausedSession = session; } else if (!pause && _pausedSession != null) { Sandbox.MySandboxGame.PausePop(); _pausedSession = null; } return new JObject { ["paused"] = Sandbox.MySandboxGame.IsPaused, ["pausedByBridge"] = _pausedSession != null, ["note"] = !pause && Sandbox.MySandboxGame.IsPaused ? "The bridge released its pause; another game/menu pause remains." : null }; } private JObject Screenshot() { string directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "XFE", "SpaceEngineersAgent", "Screenshots"); Directory.CreateDirectory(directory); string path = Path.Combine(directory, "se-" + DateTime.UtcNow.ToString("yyyyMMddTHHmmssfffffff", CultureInfo.InvariantCulture) + ".png"); VRageRender.MyRenderProxy.TakeScreenshot(Vector2.One, path, false, false, false); return new JObject { ["accepted"] = true, ["path"] = path, ["note"] = "The render thread writes this file asynchronously." }; } private JObject StopGrid(JObject args) { var grid = Grid(Id(args, "entityId")); int thrusters = 0, gyros = 0, programs = 0; foreach (var block in grid.GetFatBlocks()) { if (block is Sandbox.ModAPI.Ingame.IMyThrust thrust) { thrust.ThrustOverridePercentage = 0; thrust.ThrustOverride = 0; thrusters++; } if (block is Sandbox.ModAPI.Ingame.IMyGyro gyro) { gyro.Yaw = 0; gyro.Pitch = 0; gyro.Roll = 0; gyro.GyroOverride = false; gyros++; } if ((bool?)args["disableProgrammableBlocks"] == true && block is Sandbox.ModAPI.Ingame.IMyProgrammableBlock pb) { pb.Enabled = false; programs++; } } if (grid.Physics != null) grid.Physics.ClearSpeed(); var result = DescribeGrid(grid); result["clearedThrusters"] = thrusters; result["clearedGyros"] = gyros; result["disabledProgrammableBlocks"] = programs; result["note"] = "One-time stop of this grid; running controllers/scripts or connected grids can apply new forces."; return result; } private MySession RequireWorld() { var s = MySession.Static; if (s == null || !s.Ready || s.IsUnloading) throw new InvalidOperationException("No ready game world is loaded."); return s; } private void RequireWritableWorld() { var s = RequireWorld(); if (!CanWrite(s)) throw new InvalidOperationException("Mutation is restricted to an explicitly authorized offline test world on the local server."); } private bool CanWrite(MySession s) { return s.Ready && !s.IsUnloading && Sync.IsServer && s.OnlineMode == MyOnlineModeEnum.OFFLINE && IsAuthorized(s.CurrentPath); } private bool IsAuthorized(string path) { return !string.IsNullOrWhiteSpace(path) && _authorizedWorld(Path.GetFullPath(path)); } private static bool SamePath(string a, string b) { return !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b) && string.Equals(Path.GetFullPath(a).TrimEnd('\\', '/'), Path.GetFullPath(b).TrimEnd('\\', '/'), StringComparison.OrdinalIgnoreCase); } private static long Id(JObject args, string key) { long id; if (!long.TryParse((string)args[key], NumberStyles.Integer, CultureInfo.InvariantCulture, out id) || id == 0) throw new ArgumentException(key + " must be a nonzero decimal entity ID string."); return id; } private static string Text(JObject args, string key) { if (args[key] == null || args[key].Type != JTokenType.String) throw new ArgumentException(key + " must be a string."); return (string)args[key]; } private static int Limit(JObject args, int fallback = 256, int max = 2048) { return Math.Max(1, Math.Min(max, (int?)args["limit"] ?? fallback)); } private static double Number(JObject args, string key, double fallback) { double value = (double?)args[key] ?? fallback; if (double.IsNaN(value) || double.IsInfinity(value)) throw new ArgumentException(key + " must be finite."); return value; } private static MyEntity Entity(long id) { MyEntity entity; if (!MyEntities.TryGetEntityById(id, out entity, false) || entity == null || entity.Closed) throw new KeyNotFoundException("Entity not found: " + id.ToString(CultureInfo.InvariantCulture)); return entity; } private static MyCubeGrid Grid(long id) { return Entity(id) as MyCubeGrid ?? throw new ArgumentException("Entity is not a grid."); } private static Terminal Block(long id) { return Entity(id) as Terminal ?? throw new ArgumentException("Entity is not a terminal block."); } private static string Sid(long id) { return id.ToString(CultureInfo.InvariantCulture); } private static JObject Vec(Vector3D value) { return new JObject { ["x"] = value.X, ["y"] = value.Y, ["z"] = value.Z }; } private static JObject Vec(Vector3I value) { return new JObject { ["x"] = value.X, ["y"] = value.Y, ["z"] = value.Z }; } private static JObject Bounds(BoundingBoxD b) { return new JObject { ["min"] = Vec(b.Min), ["max"] = Vec(b.Max) }; } private static JObject Pose(MatrixD m) { return new JObject { ["position"] = Vec(m.Translation), ["forward"] = Vec(m.Forward), ["up"] = Vec(m.Up), ["right"] = Vec(m.Right) }; } private static string Sha(string value) { using (var hash = SHA256.Create()) return BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(value ?? ""))).Replace("-", "").ToLowerInvariant(); } private static string Clip(string value, int length = 4096) { return value == null || value.Length <= length ? value : value.Substring(0, length) + "…"; } } }