using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Sandbox.Game.Entities.Blocks; using Sandbox.ModAPI.Interfaces; using VRage.Scripting.MemorySafeTypes; using VRageMath; using Terminal = Sandbox.ModAPI.Ingame.IMyTerminalBlock; using ProgramBlock = Sandbox.ModAPI.IMyProgrammableBlock; namespace XFE.SeAgent.Plugin.Game { public sealed partial class GameDebugApi { private JObject ListActions(JObject args) { var block = Block(Id(args, "entityId")); var actions = new List(); block.GetActions(actions, null); var array = new JArray(); foreach (var action in actions.Take(Limit(args))) { var value = new StringBuilder(); action.WriteValue(block, value); array.Add(new JObject { ["id"] = action.Id, ["name"] = action.Name.ToString(), ["enabled"] = action.IsEnabled(block), ["value"] = Clip(value.ToString()) }); } return new JObject { ["entityId"] = Sid(block.EntityId), ["actions"] = array, ["total"] = actions.Count }; } private JObject ApplyAction(JObject args) { var block = Block(Id(args, "entityId")); var action = block.GetActionWithName(Text(args, "actionId")); if (action == null) throw new ArgumentException("The block does not expose that terminal action."); if (!action.IsEnabled(block)) throw new InvalidOperationException("The requested terminal action is disabled."); action.Apply(block); _log("Terminal action " + action.Id + " on " + Sid(block.EntityId)); return new JObject { ["applied"] = true, ["entityId"] = Sid(block.EntityId), ["actionId"] = action.Id }; } private JObject ListProperties(JObject args) { var block = Block(Id(args, "entityId")); var properties = new List(); block.GetProperties(properties, null); var array = new JArray(); foreach (var property in properties.Take(Limit(args))) { var item = new JObject { ["id"] = property.Id, ["type"] = property.TypeName }; try { item["value"] = PropertyValue(block, property); item["supported"] = SupportedProperty(property); } catch (Exception error) { item["error"] = error.Message; } array.Add(item); } return new JObject { ["entityId"] = Sid(block.EntityId), ["properties"] = array, ["total"] = properties.Count }; } private static JToken PropertyValue(Terminal block, ITerminalProperty property) { if (property is ITerminalProperty b) return new JValue(b.GetValue(block)); if (property is ITerminalProperty f) return new JValue(f.GetValue(block)); if (property is ITerminalProperty d) return new JValue(d.GetValue(block)); if (property is ITerminalProperty i) return new JValue(i.GetValue(block)); if (property is ITerminalProperty l) return new JValue(Sid(l.GetValue(block))); if (property is ITerminalProperty s) return new JValue(Clip(s.GetValue(block))); if (property is ITerminalProperty sb) return new JValue(Clip(sb.GetValue(block)?.ToString())); if (property is ITerminalProperty c) { var v = c.GetValue(block); return new JObject { ["r"] = v.R, ["g"] = v.G, ["b"] = v.B, ["a"] = v.A }; } return JValue.CreateNull(); } private static bool SupportedProperty(ITerminalProperty property) { return property is ITerminalProperty || property is ITerminalProperty || property is ITerminalProperty || property is ITerminalProperty || property is ITerminalProperty || property is ITerminalProperty || property is ITerminalProperty || property is ITerminalProperty; } private JObject SetProperty(JObject args) { var block = Block(Id(args, "entityId")); var property = block.GetProperty(Text(args, "propertyId")); if (property == null) throw new ArgumentException("The block does not expose that terminal property."); var value = args["value"] ?? throw new ArgumentException("value is required."); if (property is ITerminalProperty b) { if (value.Type != JTokenType.Boolean) throw new ArgumentException("value must be a JSON boolean."); b.SetValue(block, (bool)value); } else if (property is ITerminalProperty f) { double n = Finite(value); if (n < f.GetMinimum(block) || n > f.GetMaximum(block)) throw new ArgumentOutOfRangeException("value"); f.SetValue(block, (float)n); } else if (property is ITerminalProperty d) { double n = Finite(value); if (n < d.GetMinimum(block) || n > d.GetMaximum(block)) throw new ArgumentOutOfRangeException("value"); d.SetValue(block, n); } else if (property is ITerminalProperty i) { int n = value.Value(); if (n < i.GetMinimum(block) || n > i.GetMaximum(block)) throw new ArgumentOutOfRangeException("value"); i.SetValue(block, n); } else if (property is ITerminalProperty l) { long n = value.Value(); if (n < l.GetMinimum(block) || n > l.GetMaximum(block)) throw new ArgumentOutOfRangeException("value"); l.SetValue(block, n); } else if (property is ITerminalProperty s) { if (value.Type != JTokenType.String) throw new ArgumentException("value must be a string."); s.SetValue(block, (string)value); } else if (property is ITerminalProperty sb) { if (value.Type != JTokenType.String) throw new ArgumentException("value must be a string."); sb.SetValue(block, new StringBuilder((string)value)); } else if (property is ITerminalProperty c) { var color = value as JObject ?? throw new ArgumentException("Color requires {r,g,b,a} byte values."); c.SetValue(block, new Color(color.Value("r"), color.Value("g"), color.Value("b"), (byte?)color["a"] ?? (byte)255)); } else throw new NotSupportedException("Property type is read-only to this bridge: " + property.TypeName); _log("Set terminal property " + property.Id + " on " + Sid(block.EntityId)); return new JObject { ["entityId"] = Sid(block.EntityId), ["propertyId"] = property.Id, ["value"] = PropertyValue(block, property) }; } private static double Finite(JToken value) { if (value.Type != JTokenType.Integer && value.Type != JTokenType.Float) throw new ArgumentException("value must be a JSON number."); double n = value.Value(); if (double.IsNaN(n) || double.IsInfinity(n)) throw new ArgumentException("value must be finite."); return n; } private static ProgramBlock Pb(JObject args) { return Block(Id(args, "entityId")) as ProgramBlock ?? throw new ArgumentException("Entity is not a programmable block."); } // Deliberately fixed field names, never a caller-supplied reflection path. private static object PbField(ProgramBlock block, string field) { return typeof(MyProgrammableBlock).GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(block); } private JObject ReadProgram(JObject args) { var pb = Pb(args); string source = pb.ProgramData ?? ""; var result = DescribeBlock(pb, true); result["programData"] = source; result["source"] = source; result["sha256"] = Sha(source); result["customData"] = pb.CustomData ?? ""; result["storage"] = PbField(pb, "m_storageData") as string ?? ""; result["hasCompileErrors"] = pb.HasCompileErrors; result["compilerErrors"] = new JArray((PbField(pb, "m_compilerErrors") as IEnumerable ?? Enumerable.Empty()).Take(256)); result["echo"] = Clip(PbField(pb, "m_echoOutput")?.ToString(), 32768); result["terminationReason"] = PbField(pb, "m_terminationReason")?.ToString(); result["isRunning"] = PbField(pb, "m_isRunning") as bool? ?? false; var instance = PbField(pb, "m_instance") as Sandbox.ModAPI.IMyGridProgram; result["hasInstance"] = instance != null; if (instance != null) { result["liveStorage"] = instance.Storage; result["runtime"] = Runtime(instance.Runtime); } return result; } private static JObject Runtime(Sandbox.ModAPI.Ingame.IMyGridProgramRuntimeInfo runtime) { var result = new JObject(); if (runtime == null) return result; result["lastRunTimeMs"] = runtime.LastRunTimeMs; result["timeSinceLastRunSeconds"] = runtime.TimeSinceLastRun.TotalSeconds; result["updateFrequency"] = runtime.UpdateFrequency.ToString(); result["lifetimeTicks"] = runtime.LifetimeTicks; try { result["maxInstructionCount"] = runtime.MaxInstructionCount; result["maxCallChainDepth"] = runtime.MaxCallChainDepth; result["currentInstructionCount"] = runtime.CurrentInstructionCount; result["currentCallChainDepth"] = runtime.CurrentCallChainDepth; } catch (Exception error) { result["counterUnavailable"] = error.Message; } return result; } private JObject DeployProgram(JObject args) { var pb = Pb(args); string source = Text(args, "source"); if (source.Length > 100000) throw new ArgumentException("A programmable block source may contain at most 100000 characters."); string expected = Text(args, "expectedSha256"); string current = pb.ProgramData ?? ""; if (!string.Equals(expected, Sha(current), StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Programmable block source changed; read it again before deploying (SHA-256 conflict)."); if (!RequireWorld().EnableIngameScripts) throw new InvalidOperationException("Ingame scripts are disabled in this test world."); var instance = PbField(pb, "m_instance") as Sandbox.ModAPI.IMyGridProgram; string directory = Path.Combine(RequireWorld().CurrentPath, "Storage", "XFE.AgentBridge", "Backups"); Directory.CreateDirectory(directory); string backup = Path.Combine(directory, Sid(pb.EntityId) + "-" + DateTime.UtcNow.ToString("yyyyMMddTHHmmssfffffff", CultureInfo.InvariantCulture) + "-" + Guid.NewGuid().ToString("N") + ".json"); var snapshot = new JObject { ["formatVersion"] = 1, ["entityId"] = Sid(pb.EntityId), ["utc"] = DateTime.UtcNow, ["programData"] = current, ["sha256"] = Sha(current), ["customData"] = pb.CustomData ?? "", ["storage"] = PbField(pb, "m_storageData") as string ?? "", ["liveStorage"] = instance?.Storage }; // CreateNew + durable flush ensures a complete backup exists before the setter compiles/runs the constructor. using (var stream = new FileStream(backup, FileMode.CreateNew, FileAccess.Write, FileShare.Read)) { var bytes = Encoding.UTF8.GetBytes(snapshot.ToString(Formatting.Indented)); stream.Write(bytes, 0, bytes.Length); stream.Flush(true); } pb.ProgramData = source; // The server setter synchronously Recompile(true); do not compile twice. var result = ReadProgram(args); result["backupPath"] = backup; result["deployed"] = true; result["compiled"] = !pb.HasCompileErrors && PbField(pb, "m_instance") != null; _log("Deployed PB " + Sid(pb.EntityId) + " SHA256=" + Sha(source) + " backup=" + backup); return result; } private JObject RunProgram(JObject args) { var pb = Pb(args); string argument = (string)args["argument"] ?? ""; if (argument.Length > 16384) throw new ArgumentException("argument is too long."); bool ran = pb.TryRun(argument); var result = ReadProgram(args); result["ran"] = ran; return result; } private JObject InspectProgram(JObject args) { var pb = Pb(args); object instance = PbField(pb, "m_instance"); if (instance == null) return new JObject { ["entityId"] = Sid(pb.EntityId), ["hasInstance"] = false }; int depth = Math.Max(0, Math.Min(3, (int?)args["depth"] ?? 2)); int budget = 600; var visited = new HashSet(ReferenceComparer.Instance); HashSet selected = null; if (args["fields"] != null) { var fields = args["fields"] as JArray ?? throw new ArgumentException("fields must be an array of root script field names."); if (fields.Count > 32 || fields.Any(f => f.Type != JTokenType.String)) throw new ArgumentException("fields accepts at most 32 root script field names."); selected = new HashSet(fields.Values(), StringComparer.Ordinal); var available = new HashSet(instance.GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .Where(f => f.DeclaringType.Assembly == instance.GetType().Assembly).Select(f => f.Name), StringComparer.Ordinal); if (selected.Any(name => !available.Contains(name))) throw new ArgumentException("Unknown root script field: " + string.Join(", ", selected.Where(name => !available.Contains(name)))); } return new JObject { ["entityId"] = Sid(pb.EntityId), ["hasInstance"] = true, ["fields"] = Inspect(instance, instance.GetType().Assembly, depth, ref budget, visited, selected), ["budgetRemaining"] = budget }; } private static JToken Inspect(object value, Assembly script, int depth, ref int budget, HashSet visited, HashSet rootFields = null) { if (value == null) return JValue.CreateNull(); if (--budget < 0) return new JValue(""); var type = value.GetType(); if (value is string text) return new JValue(Clip(text)); if (value is StringBuilder sb) return new JValue(Clip(sb.ToString())); if (type == typeof(MemorySafeStringBuilder)) { var builder = (MemorySafeStringBuilder)value; int length = builder.Length; return new JValue(builder.ToString(0, Math.Min(4096, length)) + (length > 4096 ? "…" : "")); } if (type.IsEnum) return new JValue(value.ToString()); if (type.IsPrimitive || value is decimal) return JToken.FromObject(value); if (value is Vector3D vd) return Vec(vd); if (value is Vector3 vf) return Vec((Vector3D)vf); if (value is Vector3I vi) return Vec(vi); if (value is MatrixD matrix) return Pose(matrix); if (value is DateTime date) return new JValue(date); if (value is TimeSpan time) return new JValue(time.TotalSeconds); // Never traverse game entities, delegates, runtime handles or arbitrary framework object graphs. bool collection = IsInspectableCollection(type); if (type.Assembly != script && !collection) return new JValue("<" + type.FullName + ">"); if (depth < 0) return new JValue(""); if (!type.IsValueType && !visited.Add(value)) return new JValue(""); if (collection && value is IDictionary dictionary) { var entries = new JArray(); int count = 0; foreach (DictionaryEntry entry in dictionary) { if (++count > 64 || budget <= 0) { entries.Add(""); break; } // Collection wrappers are transparent to object-field depth. Item/count and global budgets still apply. entries.Add(new JObject { ["key"] = Inspect(entry.Key, script, depth, ref budget, visited), ["value"] = Inspect(entry.Value, script, depth, ref budget, visited) }); } return entries; } if (collection && value is IEnumerable sequence) { var entries = new JArray(); int count = 0; foreach (var entry in sequence) { if (++count > 64 || budget <= 0) { entries.Add(""); break; } entries.Add(Inspect(entry, script, depth, ref budget, visited)); } return entries; } var result = new JObject(); foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Where(f => rootFields == null || rootFields.Contains(f.Name)).Take(128)) { if (budget <= 0) { result["$truncated"] = true; break; } if (field.DeclaringType.Assembly != script) continue; try { result[field.Name] = Inspect(field.GetValue(value), script, depth - 1, ref budget, visited); } catch (Exception error) { result[field.Name] = ""; } } return result; } private static bool IsInspectableCollection(Type type) { if (type.IsArray) return true; if (!type.IsGenericType) return false; var definition = type.GetGenericTypeDefinition(); // Exact runtime type identity excludes custom subclasses and lookalikes from other assemblies. return definition == typeof(List<>) || definition == typeof(Dictionary<,>) || definition == typeof(HashSet<>) || definition == typeof(Queue<>) || definition == typeof(Stack<>) || definition == typeof(MemorySafeList<>) || definition == typeof(MemorySafeDictionary<,>) || definition == typeof(MemorySafeHashSet<>) || definition == typeof(MemorySafeQueue<>) || definition == typeof(MemorySafeStack<>); } private sealed class ReferenceComparer : IEqualityComparer { public static readonly ReferenceComparer Instance = new ReferenceComparer(); public new bool Equals(object x, object y) { return ReferenceEquals(x, y); } public int GetHashCode(object obj) { return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); } } private JObject ScanCamera(JObject args) { var camera = Block(Id(args, "entityId")) as Sandbox.ModAPI.Ingame.IMyCameraBlock ?? throw new ArgumentException("Entity is not a camera."); if ((bool?)args["enableRaycast"] == true) camera.EnableRaycast = true; if (!camera.EnableRaycast) throw new InvalidOperationException("Raycast is disabled; request enableRaycast:true explicitly."); double distance = Number(args, "distance", 100); if (distance <= 0 || distance > 1000000) throw new ArgumentOutOfRangeException("distance", "Scan distance must be within (0, 1000000] metres."); float pitch = (float)Number(args, "pitch", 0), yaw = (float)Number(args, "yaw", 0); if (Math.Abs(pitch) > camera.RaycastConeLimit || Math.Abs(yaw) > camera.RaycastConeLimit) throw new ArgumentOutOfRangeException("pitch/yaw", "Scan angles exceed the camera cone."); if (camera.RaycastDistanceLimit >= 0 && distance > camera.RaycastDistanceLimit) throw new ArgumentOutOfRangeException("distance", "Scan exceeds this camera's maximum distance."); if (!camera.CanScan(distance)) return new JObject { ["scanned"] = false, ["reason"] = "insufficientCharge", ["availableScanRange"] = camera.AvailableScanRange, ["timeUntilScanMs"] = camera.TimeUntilScan(distance) }; var hit = camera.Raycast(distance, pitch, yaw); return new JObject { ["scanned"] = true, ["cameraId"] = Sid(camera.EntityId), ["availableScanRange"] = camera.AvailableScanRange, ["empty"] = hit.IsEmpty(), ["entityId"] = Sid(hit.EntityId), ["name"] = hit.Name, ["type"] = hit.Type.ToString(), ["position"] = Vec(hit.Position), ["hitPosition"] = hit.HitPosition.HasValue ? Vec(hit.HitPosition.Value) : null, ["velocity"] = Vec(hit.Velocity), ["bounds"] = Bounds(hit.BoundingBox), ["relationship"] = hit.Relationship.ToString(), ["timestamp"] = hit.TimeStamp }; } } }