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<ITerminalAction>();
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<ITerminalProperty>();
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<bool> b) return new JValue(b.GetValue(block));
if (property is ITerminalProperty<float> f) return new JValue(f.GetValue(block));
if (property is ITerminalProperty<double> d) return new JValue(d.GetValue(block));
if (property is ITerminalProperty<int> i) return new JValue(i.GetValue(block));
if (property is ITerminalProperty<long> l) return new JValue(Sid(l.GetValue(block)));
if (property is ITerminalProperty<string> s) return new JValue(Clip(s.GetValue(block)));
if (property is ITerminalProperty<StringBuilder> sb) return new JValue(Clip(sb.GetValue(block)?.ToString()));
if (property is ITerminalProperty<Color> 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<bool> || property is ITerminalProperty<float> || property is ITerminalProperty<double> ||
property is ITerminalProperty<int> || property is ITerminalProperty<long> || property is ITerminalProperty<string> ||
property is ITerminalProperty<StringBuilder> || property is ITerminalProperty<Color>;
}
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<bool> 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<float> 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<double> 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<int> i) { int n = value.Value<int>(); if (n < i.GetMinimum(block) || n > i.GetMaximum(block)) throw new ArgumentOutOfRangeException("value"); i.SetValue(block, n); }
else if (property is ITerminalProperty<long> l) { long n = value.Value<long>(); if (n < l.GetMinimum(block) || n > l.GetMaximum(block)) throw new ArgumentOutOfRangeException("value"); l.SetValue(block, n); }
else if (property is ITerminalProperty<string> s) { if (value.Type != JTokenType.String) throw new ArgumentException("value must be a string."); s.SetValue(block, (string)value); }
else if (property is ITerminalProperty<StringBuilder> 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<Color> c) { var color = value as JObject ?? throw new ArgumentException("Color requires {r,g,b,a} byte values."); c.SetValue(block, new Color(color.Value<byte>("r"), color.Value<byte>("g"), color.Value<byte>("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<double>();
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<string> ?? Enumerable.Empty<string>()).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<object>(ReferenceComparer.Instance);
HashSet<string> 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<string>(fields.Values<string>(), StringComparer.Ordinal);
var available = new HashSet<string>(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<object> visited, HashSet<string> rootFields = null)
{
if (value == null) return JValue.CreateNull();
if (--budget < 0) return new JValue("<budget exhausted>");
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("<depth limit: " + type.Name + ">");
if (!type.IsValueType && !visited.Add(value)) return new JValue("<reference>");
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("<truncated>"); 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("<truncated>"); 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] = "<unavailable: " + error.GetType().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<object>
{
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 };
}
}
}
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<ITerminalAction>();
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<ITerminalProperty>();
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<bool> b) return new JValue(b.GetValue(block));
if (property is ITerminalProperty<float> f) return new JValue(f.GetValue(block));
if (property is ITerminalProperty<double> d) return new JValue(d.GetValue(block));
if (property is ITerminalProperty<int> i) return new JValue(i.GetValue(block));
if (property is ITerminalProperty<long> l) return new JValue(Sid(l.GetValue(block)));
if (property is ITerminalProperty<string> s) return new JValue(Clip(s.GetValue(block)));
if (property is ITerminalProperty<StringBuilder> sb) return new JValue(Clip(sb.GetValue(block)?.ToString()));
if (property is ITerminalProperty<Color> 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<bool> || property is ITerminalProperty<float> || property is ITerminalProperty<double> ||
property is ITerminalProperty<int> || property is ITerminalProperty<long> || property is ITerminalProperty<string> ||
property is ITerminalProperty<StringBuilder> || property is ITerminalProperty<Color>;
}
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<bool> 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<float> 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<double> 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<int> i) { int n = value.Value<int>(); if (n < i.GetMinimum(block) || n > i.GetMaximum(block)) throw new ArgumentOutOfRangeException("value"); i.SetValue(block, n); }
else if (property is ITerminalProperty<long> l) { long n = value.Value<long>(); if (n < l.GetMinimum(block) || n > l.GetMaximum(block)) throw new ArgumentOutOfRangeException("value"); l.SetValue(block, n); }
else if (property is ITerminalProperty<string> s) { if (value.Type != JTokenType.String) throw new ArgumentException("value must be a string."); s.SetValue(block, (string)value); }
else if (property is ITerminalProperty<StringBuilder> 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<Color> c) { var color = value as JObject ?? throw new ArgumentException("Color requires {r,g,b,a} byte values."); c.SetValue(block, new Color(color.Value<byte>("r"), color.Value<byte>("g"), color.Value<byte>("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<double>();
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<string> ?? Enumerable.Empty<string>()).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<object>(ReferenceComparer.Instance);
HashSet<string> 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<string>(fields.Values<string>(), StringComparer.Ordinal);
var available = new HashSet<string>(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<object> visited, HashSet<string> rootFields = null)
{
if (value == null) return JValue.CreateNull();
if (--budget < 0) return new JValue("<budget exhausted>");
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("<depth limit: " + type.Name + ">");
if (!type.IsValueType && !visited.Add(value)) return new JValue("<reference>");
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("<truncated>"); 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("<truncated>"); 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] = "<unavailable: " + error.GetType().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<object>
{
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 };
}
}
}