Editor/Tools/PocketknifeComplementTools.cs
#nullable enable
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
namespace Editor.Mcp
{
public static partial class SboxMcpAssistant
{
[McpTool("pocketknife_scene_sessions")]
public static string SceneSessions(int limit = 50, int offset = 0)
{
try
{
var boundedLimit = Math.Clamp(limit, 1, 100);
var boundedOffset = Math.Max(offset, 0);
var snapshots = GetSceneSnapshots().ToArray();
var items = snapshots.Skip(boundedOffset).Take(boundedLimit).Select(snapshot => new
{
SessionId = snapshot.Id,
Path = snapshot.Path,
Name = snapshot.Name,
Active = snapshot.Active,
Dirty = snapshot.Dirty,
Mounted = snapshot.Mounted,
Playing = snapshot.Playing
}).ToArray();
return McpJson.Write(ToolOutcome<object>.Ready(new { Count = items.Length, Total = snapshots.Length, Offset = boundedOffset, Limit = boundedLimit, HasMore = boundedOffset + items.Length < snapshots.Length, Sessions = items }));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "scene.sessions_failed", "Scene session discovery failed.", ex)); }
}
[McpTool("pocketknife_scene_activate")]
public static string ActivateSceneSession(string sessionIdOrPath)
{
try
{
if (string.IsNullOrWhiteSpace(sessionIdOrPath) || sessionIdOrPath.Trim().Length > 400)
return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "scene.session_id_invalid", "A scene session ID or path of at most 400 characters is required."));
var snapshots = GetSceneSnapshots();
var requested = sessionIdOrPath.Trim();
var target = snapshots.FirstOrDefault(snapshot => snapshot.Id.Equals(requested, StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrWhiteSpace(snapshot.Path) && snapshot.Path.Equals(ToolGuard.Normalize(requested), StringComparison.OrdinalIgnoreCase)) || snapshot.Name.Equals(requested, StringComparison.OrdinalIgnoreCase));
if (target is null)
return McpJson.Write(ToolOutcome<object>.Failure(new { Requested = requested }, ToolStatus.Unavailable, "scene.session_not_found", "No open scene session matched the requested stable ID or path."));
EditorRuntime.InvokeInstance(target.Session, "MakeActive", true);
EditorRuntime.InvokeInstance(target.Session, "BringToFront");
var deadline = DateTime.UtcNow.AddSeconds(5);
object? active = null;
while (DateTime.UtcNow < deadline)
{
active = EditorRuntime.GetStaticProperty("Editor.SceneEditorSession", "Active");
if (ReferenceEquals(active, target.Session)) break;
Thread.Sleep(50);
}
var verified = ReferenceEquals(active, target.Session);
var value = new { Requested = requested, SessionId = target.Id, Path = target.Path, Active = verified, PostconditionVerified = verified };
return verified
? McpJson.Write(ToolOutcome<object>.Ready(value))
: McpJson.Write(ToolOutcome<object>.Degraded(value, "scene.activation_unverified", "The editor accepted scene activation, but SceneEditorSession.Active did not reach the requested session."));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "scene.activation_failed", "Scene session activation failed.", ex)); }
}
[McpTool("pocketknife_dock_state")]
public static string DockState(string? dockId = null, int limit = 50, int offset = 0)
{
try
{
if ((dockId?.Trim().Length ?? 0) > 200) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "editor.dock_id_too_long", "Dock ID must be 200 characters or fewer."));
var boundedLimit = Math.Clamp(limit, 1, 100);
var boundedOffset = Math.Max(offset, 0);
var manager = GetDockManager();
if (manager is null) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Unavailable, "editor.dock_manager_unavailable", "The loaded editor does not expose Editor.DockManager."));
var types = EditorRuntime.GetMember(manager, "DockTypes") as IEnumerable;
if (types is null) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Unavailable, "editor.dock_registry_unavailable", "The loaded editor does not expose its dock registry."));
var all = new List<object>();
foreach (var info in types)
{
var title = EditorRuntime.GetMember(info, "Title")?.ToString();
if (string.IsNullOrWhiteSpace(title)) continue;
if (!string.IsNullOrWhiteSpace(dockId) && !title.Equals(dockId.Trim(), StringComparison.OrdinalIgnoreCase)) continue;
var open = ReadNullableBool(EditorRuntime.InvokeInstance(manager, "IsDockOpen", title));
var widget = EditorRuntime.InvokeInstance(manager, "FindDockWidget", title);
var active = widget is null ? (open == false ? false : null) : ReadNullableBool(widget, "IsFocused") ?? ReadNullableBool(widget, "IsActiveWindow");
var raised = widget is null ? (open == false ? false : null) : ReadNullableBool(widget, "IsActiveWindow") ?? ReadNullableBool(widget, "IsFocused");
var verified = open.HasValue && (open == false || (widget is not null && active.HasValue && raised.HasValue));
all.Add(new { DockId = title, Title = title, Open = open, Active = active, Raised = raised, Verified = verified, Type = info.GetType().FullName });
}
if (!string.IsNullOrWhiteSpace(dockId) && all.Count == 0) return McpJson.Write(ToolOutcome<object>.Failure(new { Requested = dockId }, ToolStatus.Unavailable, "editor.dock_not_found", "No registered dock matched the requested dock ID."));
var page = all.Skip(boundedOffset).Take(boundedLimit).ToArray();
var pageVerified = page.All(item => (bool)item.GetType().GetProperty("Verified")!.GetValue(item)!);
return McpJson.Write(ToolOutcome<object>.Ready(new { Count = page.Length, Total = all.Count, Offset = boundedOffset, Limit = boundedLimit, HasMore = boundedOffset + page.Length < all.Count, Docks = page, PostconditionVerified = pageVerified, StateSource = "Editor.DockManager" }));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "editor.dock_state_failed", "Dock state discovery failed.", ex)); }
}
[McpTool("pocketknife_viewport_capture")]
public static string ViewportCapture(string captureMode = "editor_viewport", int width = 1280, int height = 720, bool includeUi = false, string? fileName = null)
{
try
{
var mode = (captureMode ?? "").Trim().ToLowerInvariant();
if (mode is not ("editor_viewport" or "scene_camera" or "play_first_person" or "play_third_person"))
return McpJson.Write(ToolOutcome<object>.Failure(new { CaptureMode = captureMode }, ToolStatus.Failed, "capture.invalid_mode", "Capture mode is not supported.", remediation: "Use editor_viewport, scene_camera, play_first_person, or play_third_person."));
width = Math.Clamp(width, 16, 4096);
height = Math.Clamp(height, 16, 4096);
if (mode is "play_first_person" or "play_third_person")
return McpJson.Write(ToolOutcome<object>.Failure(new { CaptureMode = mode, Width = width, Height = height }, ToolStatus.Unavailable, "capture.play_camera_unavailable", "The loaded editor exposes no typed first-person or third-person camera surface.", remediation: "Use official camera_screenshot with a verified camera ID."));
if (mode == "editor_viewport" && includeUi)
return McpJson.Write(ToolOutcome<object>.Failure(new { CaptureMode = mode }, ToolStatus.Unavailable, "capture.editor_ui_unavailable", "Editor viewport UI capture is not exposed by the verified editor camera surface."));
var playState = EditorRuntime.InvokeStatic("Editor.Mcp.PlayTools", "CurrentState");
var playing = ReadNullableBool(playState, "IsPlaying") ?? false;
object? bitmap = mode == "editor_viewport"
? EditorRuntime.InvokeStatic("Editor.Mcp.SceneTools", "EditorCameraScreenshot", width, height)
: EditorRuntime.InvokeStatic("Editor.Mcp.SceneTools", "CameraScreenshot", "", width, height, includeUi);
if (bitmap is null) return McpJson.Write(ToolOutcome<object>.Failure(new { CaptureMode = mode, Width = width, Height = height, IsPlaying = playing }, ToolStatus.Unavailable, "capture.surface_unavailable", "The verified editor screenshot surface returned no bitmap."));
var bytes = EncodePng(bitmap);
if (bytes is null || bytes.Length == 0) return McpJson.Write(ToolOutcome<object>.Failure(new { CaptureMode = mode, Width = width, Height = height }, ToolStatus.Failed, "capture.encode_failed", "The editor returned a bitmap that could not be encoded as PNG."));
var decoded = ReadPngDimensions(bytes);
var root = Project.Current?.GetRootPath();
if (string.IsNullOrWhiteSpace(root)) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Unavailable, "project.not_active", "No active project is available."));
var path = ResolveCapturePath(root, fileName, mode);
if (path is null) return McpJson.Write(ToolOutcome<object>.Failure(new { CaptureMode = mode }, ToolStatus.Failed, "capture.path_invalid", "Capture output must remain inside the active project."));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, bytes);
var verified = File.Exists(path) && new FileInfo(path).Length == bytes.Length && decoded.Width == width && decoded.Height == height;
var value = new
{
CaptureMode = mode,
Width = width,
Height = height,
DecodedWidth = decoded.Width,
DecodedHeight = decoded.Height,
Bytes = bytes.Length,
Path = path,
IsPlaying = playing,
IncludeUi = mode == "scene_camera" && includeUi,
PostconditionVerified = verified,
Image = new { type = "image", data = Convert.ToBase64String(bytes), mimeType = "image/png" }
};
return verified ? McpJson.Write(ToolOutcome<object>.Ready(value)) : McpJson.Write(ToolOutcome<object>.Failure(value, ToolStatus.Failed, "capture.verify_failed", "PNG output or decoded dimensions did not match the requested capture."));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "capture.failed", "Viewport capture failed.", ex)); }
}
[McpTool("pocketknife_package_search")]
public static string PackageSearch(string query, string? kind = null, int limit = 20, int offset = 0)
{
try
{
var normalized = (query ?? "").Trim();
if (normalized.Length == 0) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "package.query_required", "A package query is required."));
if (normalized.Length > 200) return McpJson.Write(ToolOutcome<object>.Failure(new { QueryLength = normalized.Length }, ToolStatus.Failed, "package.query_too_long", "Package query must be 200 characters or fewer."));
var normalizedKind = (kind ?? "").Trim();
if (normalizedKind.Length > 64 || (normalizedKind.Length > 0 && !normalizedKind.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-')))
return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "package.kind_invalid", "Package kind contains unsupported characters."));
var backendQuery = string.IsNullOrWhiteSpace(normalizedKind) ? normalized : $"{normalized} type:{normalizedKind}";
var boundedLimit = Math.Clamp(limit, 1, 100);
var boundedOffset = Math.Max(offset, 0);
var result = InvokeStaticAwait("Sandbox.Package", "FindAsync", 30000, backendQuery, boundedLimit, boundedOffset, CancellationToken.None);
if (result is null) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Unavailable, "package.search_unavailable", "Sandbox.Package.FindAsync is unavailable or did not complete."));
var packages = Enumerate(EditorRuntime.GetMember(result, "Packages") ?? EditorRuntime.GetMember(result, "Results")).ToArray();
var total = ReadNullableInt(result, "TotalCount") ?? ReadNullableInt(result, "Total") ?? packages.Length;
var items = packages.Select(PackagePayload).ToArray();
return McpJson.Write(ToolOutcome<object>.Ready(new { Query = normalized, Kind = string.IsNullOrWhiteSpace(normalizedKind) ? null : normalizedKind, Count = items.Length, Total = total, Offset = boundedOffset, Limit = boundedLimit, HasMore = boundedOffset + items.Length < total, Source = "sbox.cloud", Provenance = "Sandbox.Package.FindAsync", Packages = items }));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "package.search_failed", "Package search failed.", ex)); }
}
[McpTool("pocketknife_package_install")]
public static string PackageInstall(string ident, string? version = null, bool includeDependencies = true)
{
try
{
var requestIdent = ResolvePackageIdent(ident, version, out var identError);
if (requestIdent is null) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "package.ident_invalid", identError ?? "Package ident is invalid."));
var package = FetchPackage(requestIdent);
if (package is null) return McpJson.Write(ToolOutcome<object>.Failure(new { Ident = requestIdent }, ToolStatus.Failed, "package.not_found", "The requested package could not be fetched."));
var installed = InvokeStaticCompatible("Editor.AssetSystem", "IsCloudInstalled", requestIdent) as bool? ?? false;
object? installedAsset = null;
if (!installed)
{
var canInstall = InvokeStaticCompatible("Editor.AssetSystem", "CanCloudInstall", package) as bool?;
if (canInstall == false) return McpJson.Write(ToolOutcome<object>.Failure(new { Ident = requestIdent }, ToolStatus.Unavailable, "package.install_unavailable", "The editor asset system does not permit cloud installation for this package."));
var installTask = InvokeStaticCompatible("Editor.AssetSystem", "InstallAsync", requestIdent, false, null, CancellationToken.None);
if (installTask is null) return McpJson.Write(ToolOutcome<object>.Failure(new { Ident = requestIdent }, ToolStatus.Unavailable, "package.install_api_unavailable", "The verified editor package install API is unavailable."));
installedAsset = AwaitValue(installTask, 120000);
}
object? revision = null;
var deadline = DateTime.UtcNow.AddSeconds(120);
while (DateTime.UtcNow < deadline)
{
installed = InvokeStaticCompatible("Editor.AssetSystem", "IsCloudInstalled", requestIdent) as bool? ?? false;
revision = InvokeStaticCompatible("Editor.AssetSystem", "GetInstalledRevision", requestIdent);
if (installed && revision is not null) break;
Thread.Sleep(100);
}
var dependencies = DependencyIdList(package);
var dependencyStates = dependencies.Select(dependency => new { Ident = dependency, Installed = (InvokeStaticCompatible("Editor.AssetSystem", "IsCloudInstalled", dependency) as bool?) ?? false }).ToArray();
var dependenciesVerified = !includeDependencies || dependencyStates.All(item => item.Installed);
var revisionText = RevisionText(revision);
var verified = installed && revision is not null && dependenciesVerified;
var value = new { Ident = requestIdent, Installed = installed, InstalledAsset = installedAsset is null ? null : AssetPayload(installedAsset), InstalledRevision = revisionText, IncludeDependencies = includeDependencies, Dependencies = dependencyStates, DependenciesVerified = dependenciesVerified, PostconditionVerified = verified };
return verified ? McpJson.Write(ToolOutcome<object>.Ready(value)) : McpJson.Write(ToolOutcome<object>.Degraded(value, "package.install_unverified", "Package install completed or was dispatched, but installed revision/dependency readback is incomplete."));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "package.install_failed", "Package installation failed.", ex)); }
}
[McpTool("pocketknife_package_status")]
public static string PackageStatus(string ident, string? version = null)
{
try
{
var requestIdent = ResolvePackageIdent(ident, version, out var identError);
if (requestIdent is null) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "package.ident_invalid", identError ?? "Package ident is invalid."));
var package = FetchPackage(requestIdent);
var installed = InvokeStaticCompatible("Editor.AssetSystem", "IsCloudInstalled", requestIdent) as bool?;
var revision = InvokeStaticCompatible("Editor.AssetSystem", "GetInstalledRevision", requestIdent);
if (package is null || !installed.HasValue) return McpJson.Write(ToolOutcome<object>.Failure(new { Ident = requestIdent }, ToolStatus.Unavailable, "package.status_unavailable", "Package metadata or installed revision state is unavailable."));
return McpJson.Write(ToolOutcome<object>.Ready(new { Ident = requestIdent, Installed = installed.Value, InstalledRevision = RevisionText(revision), Package = PackagePayload(package), Source = "Editor.AssetSystem", Provenance = "Sandbox.Package.FetchAsync + Editor.AssetSystem.GetInstalledRevision" }));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "package.status_failed", "Package status lookup failed.", ex)); }
}
[McpTool("pocketknife_asset_search")]
public static string AssetSearch(string query, string? kind = null, string? pathPrefix = null, int limit = 20, int offset = 0)
{
try
{
var normalized = (query ?? "").Trim();
if (normalized.Length > 200) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "asset.query_too_long", "Asset query must be 200 characters or fewer."));
var normalizedKind = (kind ?? "").Trim().ToLowerInvariant();
var allowedKinds = new[] { "hdri", "model", "texture", "material", "sound", "sprite", "map" };
if (!string.IsNullOrWhiteSpace(normalizedKind) && !allowedKinds.Contains(normalizedKind, StringComparer.Ordinal)) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "asset.kind_invalid", "Asset kind is not supported."));
var prefix = ToolGuard.Normalize(pathPrefix ?? "");
if (prefix.Length > 200) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "asset.path_prefix_too_long", "Asset path prefix must be 200 characters or fewer."));
var entries = new List<AssetSearchEntry>();
var all = EditorRuntime.GetStaticProperty("Editor.AssetSystem", "All");
foreach (var asset in Enumerate(all))
{
var assetPath = ReadText(asset, "Path", "ResourcePath", "Name") ?? "";
var assetType = ReadText(asset, "TypeName", "Type", "AssetType") ?? "";
var assetTags = string.Join(" ", Enumerate(EditorRuntime.GetMember(asset, "Tags")).Select(value => value?.ToString()));
entries.Add(new AssetSearchEntry(assetPath, assetType, assetTags, AssetPayload(asset)));
}
var installedPackages = InvokeStaticCompatible("Editor.AssetSystem", "GetInstalledPackages");
foreach (var package in Enumerate(installedPackages))
{
try
{
var packageIdent = ReadText(package, "FullIdent", "Ident") ?? "";
foreach (var file in Enumerate(InvokeStaticCompatible("Editor.AssetSystem", "GetPackageFiles", package)))
{
var filePath = file.ToString() ?? "";
if (filePath.Length > 0) entries.Add(new AssetSearchEntry(filePath, Path.GetExtension(filePath), "", PackageFilePayload(packageIdent, filePath)));
}
}
catch { }
}
if (all is null && installedPackages is null) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Unavailable, "asset.index_unavailable", "Editor asset and installed-package indexes are unavailable."));
var terms = normalized.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var matches = entries.Where(entry => terms.All(term => string.Join(" ", entry.Path, entry.Type, entry.Tags).Contains(term, StringComparison.OrdinalIgnoreCase)))
.Where(entry => string.IsNullOrWhiteSpace(prefix) || ToolGuard.Normalize(entry.Path).StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Where(entry => KindMatches(normalizedKind, entry.Path, entry.Type)).Select(entry => entry.Payload).ToArray();
var boundedLimit = Math.Clamp(limit, 1, 100);
var boundedOffset = Math.Max(offset, 0);
var page = matches.Skip(boundedOffset).Take(boundedLimit).ToArray();
return McpJson.Write(ToolOutcome<object>.Ready(new { Query = normalized, Kind = string.IsNullOrWhiteSpace(normalizedKind) ? null : normalizedKind, PathPrefix = string.IsNullOrWhiteSpace(prefix) ? null : prefix, Count = page.Length, Total = matches.Length, Offset = boundedOffset, Limit = boundedLimit, HasMore = boundedOffset + page.Length < matches.Length, Source = "Editor.AssetSystem.All + Editor.AssetSystem.GetInstalledPackages", Provenance = "editor asset index plus installed cloud package files", Assets = page }));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "asset.search_failed", "Editor asset search failed.", ex)); }
}
[McpTool("pocketknife_asset_info")]
public static string AssetInfo(string pathOrIdent)
{
try
{
var requested = (pathOrIdent ?? "").Trim();
if (requested.Length == 0 || requested.Length > 200 || requested.Contains("://", StringComparison.Ordinal)) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "asset.ident_invalid", "Asset path or package ident is invalid."));
var asset = InvokeStaticCompatible("Editor.AssetSystem", "FindByPath", requested);
if (asset is not null) return McpJson.Write(ToolOutcome<object>.Ready(new { Requested = requested, Asset = AssetPayload(asset), Source = "Editor.AssetSystem.FindByPath", Provenance = "editor asset index" }));
var package = requested.Contains('.') && !requested.Contains('/') && !requested.Contains('\\') ? FetchPackage(requested) : null;
if (package is not null) return McpJson.Write(ToolOutcome<object>.Ready(new { Requested = requested, Package = PackagePayload(package), Source = "Sandbox.Package.FetchAsync", Provenance = "sbox.cloud package metadata" }));
return McpJson.Write(ToolOutcome<object>.Failure(new { Requested = requested }, ToolStatus.Unavailable, "asset.not_found", "No indexed asset or package matched the requested path or ident."));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "asset.info_failed", "Asset information lookup failed.", ex)); }
}
[McpTool("pocketknife_editor_surface_status")]
public static string EditorSurfaceStatus(string surface)
{
try
{
var descriptor = GetSurfaceDescriptor(surface);
if (descriptor is null) return McpJson.Write(ToolOutcome<object>.Failure(new { Surface = surface }, ToolStatus.Failed, "surface.invalid", "Editor surface is not allowlisted."));
var found = descriptor.RequiredTypes.Select(EditorRuntime.FindType).FirstOrDefault(type => type is not null);
var runtimeAvailable = found is not null || descriptor.Surface == "material" && EditorRuntime.FindType("Editor.EditorUtility") is not null;
var documentApiAvailable = descriptor.DocumentApiTypes.Any(type => EditorRuntime.FindType(type) is not null && descriptor.DocumentMethods.Any(method => HasMethod(EditorRuntime.FindType(type), method)));
var value = new { Surface = descriptor.Surface, RuntimeAvailable = runtimeAvailable, RuntimeType = found?.FullName, RequiredType = descriptor.RequiredTypes.FirstOrDefault(), RequiredComponent = descriptor.RequiredComponent, OfficialMatchingTools = descriptor.OfficialTools, DocumentApiAvailable = documentApiAvailable, MissingDependency = runtimeAvailable ? (documentApiAvailable ? null : descriptor.DocumentApiMissingCode) : descriptor.MissingCode };
if (!runtimeAvailable) return McpJson.Write(ToolOutcome<object>.Failure(value, ToolStatus.Unavailable, descriptor.MissingCode, descriptor.MissingMessage));
return McpJson.Write(ToolOutcome<object>.Ready(value));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "surface.status_failed", "Editor surface status lookup failed.", ex)); }
}
[McpTool("pocketknife_editor_document_open")]
public static string EditorDocumentOpen(string surface, string path)
{
try
{
var descriptor = GetSurfaceDescriptor(surface);
if (descriptor is null) return McpJson.Write(ToolOutcome<object>.Failure(new { Surface = surface }, ToolStatus.Failed, "document.surface_invalid", "Editor document surface is not allowlisted."));
var status = descriptor.RequiredTypes.Select(EditorRuntime.FindType).FirstOrDefault(type => type is not null);
if (status is null && descriptor.Surface != "material") return McpJson.Write(ToolOutcome<object>.Failure(new { Surface = descriptor.Surface }, ToolStatus.Unavailable, descriptor.MissingCode, descriptor.MissingMessage));
var root = Project.Current?.GetRootPath();
if (string.IsNullOrWhiteSpace(root)) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Unavailable, "project.not_active", "No active project is available."));
var normalizedPath = ToolGuard.ResolveAssetPath(path);
if (!IsUnder(normalizedPath, root) || !File.Exists(normalizedPath)) return McpJson.Write(ToolOutcome<object>.Failure(new { Surface = descriptor.Surface, Path = normalizedPath }, ToolStatus.Failed, "document.path_invalid", "Document path must exist inside the active project."));
EditorRuntime.InvokeStatic("Editor.EditorUtility", "OpenFile", normalizedPath);
var verified = false;
for (var i = 0; i < 10 && !verified; i++) { Thread.Sleep(100); verified = SurfaceDockReadback(descriptor); }
var value = new { Surface = descriptor.Surface, Path = normalizedPath, Requested = true, PostconditionVerified = verified };
return verified ? McpJson.Write(ToolOutcome<object>.Ready(value)) : McpJson.Write(ToolOutcome<object>.Degraded(value, "document.open_unverified", "OpenFile accepted the request, but active surface state readback did not confirm the requested document."));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "document.open_failed", "Editor document open failed.", ex)); }
}
[McpTool("pocketknife_navmesh_status")]
public static string NavMeshStatus(string? scenePath = null)
{
try
{
var snapshot = ResolveSceneSnapshot(scenePath);
if (snapshot is null) return McpJson.Write(ToolOutcome<object>.Failure(new { Requested = scenePath }, ToolStatus.Unavailable, "scene.session_not_found", "No open scene session matched the requested scene."));
var navMesh = GetNavMesh(snapshot);
if (navMesh is null) return McpJson.Write(ToolOutcome<object>.Failure(new { Scene = snapshot.Name, Path = snapshot.Path }, ToolStatus.Unavailable, "navmesh.not_present", "The selected scene does not expose Sandbox.Navigation.NavMesh."));
return McpJson.Write(ToolOutcome<object>.Ready(new { Scene = snapshot.Name, Path = snapshot.Path, IsEnabled = ReadNullableBool(navMesh, "IsEnabled"), IsGenerating = ReadNullableBool(navMesh, "IsGenerating"), IsDirty = ReadNullableBool(navMesh, "IsDirty"), Dependency = "Sandbox.Navigation.NavMesh", PostconditionVerified = true }));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "navmesh.status_failed", "NavMesh status lookup failed.", ex)); }
}
[McpTool("pocketknife_navmesh_bake")]
public static string NavMeshBake(string? scenePath = null)
{
try
{
var snapshot = ResolveSceneSnapshot(scenePath);
if (snapshot is null) return McpJson.Write(ToolOutcome<object>.Failure(new { Requested = scenePath }, ToolStatus.Unavailable, "scene.session_not_found", "No open scene session matched the requested scene."));
if (snapshot.Playing) return McpJson.Write(ToolOutcome<object>.Failure(new { Scene = snapshot.Name }, ToolStatus.Unavailable, "navmesh.playing", "NavMesh bake requires a non-playing scene session."));
var navMesh = GetNavMesh(snapshot);
if (navMesh is null) return McpJson.Write(ToolOutcome<object>.Failure(new { Scene = snapshot.Name }, ToolStatus.Unavailable, "navmesh.not_present", "The selected scene does not expose Sandbox.Navigation.NavMesh."));
var before = ReadNavMeshState(navMesh);
if (before.IsEnabled == false) return McpJson.Write(ToolOutcome<object>.Failure(new { Scene = snapshot.Name, Before = before }, ToolStatus.Unavailable, "navmesh.disabled", "The selected NavMesh is disabled; no bake was attempted."));
if (before.IsGenerating == false && before.IsDirty == false) return McpJson.Write(ToolOutcome<object>.Ready(new { Scene = snapshot.Name, Before = before, After = before, NoOp = true, PostconditionVerified = true }));
if (!HasMethod(navMesh.GetType(), "BakeNavMesh")) return McpJson.Write(ToolOutcome<object>.Failure(new { Scene = snapshot.Name, Before = before }, ToolStatus.Unavailable, "navmesh.bake_api_unavailable", "Sandbox.Navigation.NavMesh.BakeNavMesh is unavailable."));
EditorRuntime.InvokeInstance(navMesh, "BakeNavMesh");
var deadline = DateTime.UtcNow.AddSeconds(120);
var sawGeneration = before.IsGenerating == true;
NavMeshSnapshot after = before;
while (DateTime.UtcNow < deadline)
{
Thread.Sleep(100);
after = ReadNavMeshState(navMesh);
if (after.IsGenerating == true) sawGeneration = true;
if (after.IsGenerating == false && after.IsDirty == false && (sawGeneration || before.IsDirty == true)) break;
}
var verified = after.IsGenerating == false && after.IsDirty == false && (sawGeneration || before.IsDirty == true);
var value = new { Scene = snapshot.Name, Path = snapshot.Path, Before = before, After = after, PostconditionVerified = verified };
return verified ? McpJson.Write(ToolOutcome<object>.Ready(value)) : McpJson.Write(ToolOutcome<object>.Degraded(value, "navmesh.timeout", "NavMesh bake did not reach a clean completed state before timeout."));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "navmesh.bake_failed", "NavMesh bake failed.", ex)); }
}
[McpTool("pocketknife_navmesh_path")]
public static string NavMeshPath(Vector3 start, Vector3 end, int maxPoints = 256)
{
try
{
var boundedMax = Math.Clamp(maxPoints, 1, 256);
var snapshot = ResolveSceneSnapshot(null);
if (snapshot is null) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Unavailable, "scene.session_not_found", "No active scene session is available."));
var navMesh = GetNavMesh(snapshot);
if (navMesh is null) return McpJson.Write(ToolOutcome<object>.Failure(new { Scene = snapshot.Name }, ToolStatus.Unavailable, "navmesh.not_present", "The active scene does not expose Sandbox.Navigation.NavMesh."));
if (ReadNullableBool(navMesh, "IsEnabled") == false) return McpJson.Write(ToolOutcome<object>.Failure(new { Scene = snapshot.Name }, ToolStatus.Unavailable, "navmesh.disabled", "The active NavMesh is disabled; path calculation was not attempted."));
var path = EditorRuntime.InvokeInstance(navMesh, "GetSimplePath", start, end);
if (path is null) return McpJson.Write(ToolOutcome<object>.Failure(new { Scene = snapshot.Name }, ToolStatus.Unavailable, "navmesh.path_api_unavailable", "Sandbox.Navigation.NavMesh.GetSimplePath is unavailable."));
var points = Enumerate(path).ToArray();
var page = points.Take(boundedMax).Select(point => point?.ToString() ?? "").ToArray();
return McpJson.Write(ToolOutcome<object>.Ready(new { Scene = snapshot.Name, Start = start.ToString(), End = end.ToString(), Count = page.Length, Total = points.Length, MaxPoints = boundedMax, HasMore = points.Length > page.Length, Points = page, PostconditionVerified = true }));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "navmesh.path_failed", "NavMesh path query failed.", ex)); }
}
[McpTool("pocketknife_hotload_await")]
public static string HotloadAwait(string? packageIdent = null, int timeoutMs = 120000)
{
try
{
if (!string.IsNullOrWhiteSpace(packageIdent) && packageIdent.Trim().Length > 200) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "hotload.package_invalid", "Package ident must be 200 characters or fewer."));
var boundedTimeout = Math.Clamp(timeoutMs, 1000, 120000);
var manager = GetStaticObject("Sandbox.HotloadManager", "Current") ?? GetStaticObject("Sandbox.HotloadManager", "Instance");
if (manager is null) return McpJson.Write(ToolOutcome<object>.Failure(new { PackageIdent = packageIdent }, ToolStatus.Unavailable, "hotload.api_unavailable", "The loaded editor does not expose a current HotloadManager instance."));
var baseline = HotloadRevision(manager);
var started = DateTime.UtcNow;
var needsSwap = ReadNullableBool(manager, "NeedsSwap") ?? false;
while (needsSwap && DateTime.UtcNow - started < TimeSpan.FromMilliseconds(boundedTimeout)) { Thread.Sleep(100); needsSwap = ReadNullableBool(manager, "NeedsSwap") ?? true; }
var observed = HotloadRevision(manager);
var verified = !needsSwap;
var value = new { PackageIdent = string.IsNullOrWhiteSpace(packageIdent) ? null : packageIdent.Trim(), BaselineRevision = baseline, ObservedRevision = observed, ElapsedMs = (long)(DateTime.UtcNow - started).TotalMilliseconds, PostconditionVerified = verified };
return verified ? McpJson.Write(ToolOutcome<object>.Ready(value)) : McpJson.Write(ToolOutcome<object>.Degraded(value, "hotload.await_timeout", "Hotload remained pending until timeout."));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "hotload.await_failed", "Hotload await failed.", ex)); }
}
[McpTool("pocketknife_network_status")]
public static string NetworkStatus()
{
try
{
var gameType = EditorRuntime.FindType("Sandbox.Game");
var lobbyApi = FindFirstType("Sandbox.Lobby", "Sandbox.Multiplayer", "Sandbox.LobbyService", "Editor.LobbyService");
var providerConfigured = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("VOYAGEUR_PROVIDER_ENDPOINT"));
return McpJson.Write(ToolOutcome<object>.Ready(new
{
RuntimeAvailable = gameType is not null,
InGame = ReadStaticBool(gameType, "InGame"),
Playing = ReadStaticBool(gameType, "IsPlaying"),
Paused = ReadStaticBool(gameType, "IsPaused"),
TypedLobbyApiAvailable = lobbyApi is not null,
VoyageurProviderConfigured = providerConfigured,
Redacted = true
}));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "network.status_failed", "Network status lookup failed.", ex)); }
}
[McpTool("pocketknife_lobby_start")]
public static string LobbyStart(string? scenePath = null, int maxPlayers = 0)
{
return LobbyOperation("start", scenePath, maxPlayers);
}
[McpTool("pocketknife_lobby_stop")]
public static string LobbyStop()
{
return LobbyOperation("stop", null, 0);
}
[McpTool("pocketknife_server_start")]
public static string ServerStart(string provider = "voyageur", string? projectPath = null, string? scenePath = null)
{
if (!string.Equals(provider?.Trim(), "voyageur", StringComparison.OrdinalIgnoreCase)) return McpJson.Write(ToolOutcome<object>.Failure(new { Provider = provider }, ToolStatus.Failed, "server.provider_invalid", "Only the configured voyageur provider is supported."));
return McpJson.Write(ToolOutcome<object>.Failure(new { Provider = "voyageur", ProjectPath = projectPath, ScenePath = scenePath }, ToolStatus.Unavailable, "server.provider_unavailable", "No typed Voyageur editor provider is available; no process was launched."));
}
[McpTool("pocketknife_server_status")]
public static string ServerStatus(string operationId)
{
if (string.IsNullOrWhiteSpace(operationId) || operationId.Trim().Length > 200) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "server.operation_invalid", "A provider operation ID of at most 200 characters is required."));
return McpJson.Write(ToolOutcome<object>.Failure(new { OperationId = operationId.Trim() }, ToolStatus.Unavailable, "server.provider_unavailable", "No typed Voyageur editor provider is available."));
}
[McpTool("pocketknife_server_stop")]
public static string ServerStop(string operationId)
{
if (string.IsNullOrWhiteSpace(operationId) || operationId.Trim().Length > 200) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "server.operation_invalid", "A provider operation ID of at most 200 characters is required."));
return McpJson.Write(ToolOutcome<object>.Failure(new { OperationId = operationId.Trim() }, ToolStatus.Unavailable, "server.provider_unavailable", "No typed Voyageur editor provider is available; no process was stopped."));
}
private sealed class SceneSnapshot
{
public required object Session { get; init; }
public required string Id { get; init; }
public string? Path { get; init; }
public string Name { get; init; } = "";
public bool Active { get; init; }
public bool Dirty { get; init; }
public bool Mounted { get; init; }
public bool Playing { get; init; }
}
private sealed record AssetSearchEntry(string Path, string Type, string Tags, object Payload);
private sealed record NavMeshSnapshot(bool? IsEnabled, bool? IsGenerating, bool? IsDirty);
private sealed record SurfaceDescriptor(string Surface, string[] RequiredTypes, string? RequiredComponent, string[] OfficialTools, string MissingCode, string MissingMessage, string[] DocumentApiTypes, string[] DocumentMethods, string DocumentApiMissingCode, string[] DockTokens);
private static readonly SurfaceDescriptor[] SurfaceDescriptors =
{
new("material", new[] { "Editor.AssetSystem" }, null, new[] { "asset_info", "asset_write" }, "surface.material_unavailable", "The material editor asset surface is unavailable.", new[] { "Editor.AssetSystem" }, new[] { "FindByPath" }, "surface.material_document_api_unavailable", new[] { "material", "material editor" }),
new("modeldoc", new[] { "Editor.ModelEditor.ModelDoc", "Editor.ModelDocParser" }, null, Array.Empty<string>(), "surface.modeldoc_unavailable", "ModelDoc runtime surface is unavailable.", new[] { "Editor.ModelEditor.ModelDoc" }, Array.Empty<string>(), "surface.modeldoc_document_api_unavailable", new[] { "modeldoc", "model editor" }),
new("hammer", new[] { "Editor.MapEditor.HammerSceneEditorSession" }, null, Array.Empty<string>(), "surface.hammer_unavailable", "Hammer runtime surface is unavailable.", new[] { "Editor.MapEditor.HammerSceneEditorSession" }, Array.Empty<string>(), "surface.hammer_document_api_unavailable", new[] { "hammer" }),
new("shadergraph", new[] { "Editor.ShaderGraph.BaseNode" }, null, Array.Empty<string>(), "surface.shadergraph_unavailable", "ShaderGraph runtime surface is unavailable.", new[] { "Editor.ShaderGraph.GraphCompiler" }, Array.Empty<string>(), "surface.shadergraph_document_api_unavailable", new[] { "shadergraph", "shader graph" }),
new("sprite", new[] { "Editor.SpriteEditor.SpritesheetImporter" }, null, Array.Empty<string>(), "surface.sprite_unavailable", "Sprite runtime surface is unavailable.", new[] { "Editor.SpriteEditor.SpritesheetImporter" }, Array.Empty<string>(), "surface.sprite_document_api_unavailable", new[] { "sprite" }),
new("hotspot", new[] { "Editor.HotspotEditor", "Editor.Hotspot" }, null, Array.Empty<string>(), "surface.hotspot_unavailable", "Hotspot runtime surface is unavailable.", Array.Empty<string>(), Array.Empty<string>(), "surface.hotspot_document_api_unavailable", new[] { "hotspot" }),
new("terrain", new[] { "Sandbox.TerrainComponent", "Editor.Terrain" }, "TerrainComponent", Array.Empty<string>(), "surface.terrain_unavailable", "Terrain runtime component/editor surface is unavailable.", Array.Empty<string>(), Array.Empty<string>(), "surface.terrain_document_api_unavailable", new[] { "terrain" }),
new("navmesh", new[] { "Sandbox.Navigation.NavMesh" }, "Sandbox.Navigation.NavMesh", Array.Empty<string>(), "surface.navmesh_unavailable", "NavMesh runtime surface is unavailable.", new[] { "Sandbox.Navigation.NavMesh" }, new[] { "BakeNavMesh", "GetSimplePath" }, "surface.navmesh_document_api_unavailable", new[] { "navmesh", "navigation" })
};
private static SurfaceDescriptor? GetSurfaceDescriptor(string? surface) => SurfaceDescriptors.FirstOrDefault(item => item.Surface.Equals((surface ?? "").Trim().ToLowerInvariant(), StringComparison.Ordinal));
private static object? GetDockManager()
{
var main = GetStaticObject("Editor.EditorMainWindow", "Current");
return EditorRuntime.GetMember(main, "DockManager");
}
private static IEnumerable<SceneSnapshot> GetSceneSnapshots()
{
var all = EditorRuntime.GetStaticProperty("Editor.SceneEditorSession", "All");
var active = EditorRuntime.GetStaticProperty("Editor.SceneEditorSession", "Active");
var index = 0;
foreach (var session in Enumerate(all))
{
var scene = EditorRuntime.GetMember(session, "Scene");
var path = ReadText(scene, "ResourcePath", "FilePath", "Path", "SourcePath");
path ??= ReadText(EditorRuntime.GetMember(scene, "Source"), "ResourcePath", "FilePath", "Path", "SourcePath");
var name = ReadText(scene, "Name", "Title") ?? ReadText(session, "Name") ?? $"Scene {index + 1}";
var id = !string.IsNullOrWhiteSpace(path) ? "scene:" + ToolGuard.Normalize(path) : "untitled:" + index;
yield return new SceneSnapshot
{
Session = session,
Id = id,
Path = string.IsNullOrWhiteSpace(path) ? null : ToolGuard.Normalize(path),
Name = name,
Active = ReferenceEquals(session, active),
Dirty = ReadNullableBool(session, "HasUnsavedChanges") ?? false,
Mounted = ReadNullableBool(session, "IsMounted") ?? false,
Playing = ReadNullableBool(session, "IsPlaying") ?? false
};
index++;
}
}
private static SceneSnapshot? ResolveSceneSnapshot(string? requested)
{
var snapshots = GetSceneSnapshots().ToArray();
if (snapshots.Length == 0) return null;
if (string.IsNullOrWhiteSpace(requested)) return snapshots.FirstOrDefault(item => item.Active) ?? snapshots[0];
var value = requested.Trim();
return snapshots.FirstOrDefault(item => item.Id.Equals(value, StringComparison.OrdinalIgnoreCase) || item.Name.Equals(value, StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrWhiteSpace(item.Path) && item.Path.Equals(ToolGuard.Normalize(value), StringComparison.OrdinalIgnoreCase)));
}
private static object? GetNavMesh(SceneSnapshot snapshot)
{
var scene = EditorRuntime.GetMember(snapshot.Session, "Scene");
var navMesh = EditorRuntime.GetMember(scene, "NavMesh");
return navMesh?.GetType().FullName?.Contains("Sandbox.Navigation.NavMesh", StringComparison.Ordinal) == true ? navMesh : null;
}
private static NavMeshSnapshot ReadNavMeshState(object navMesh) => new(ReadNullableBool(navMesh, "IsEnabled"), ReadNullableBool(navMesh, "IsGenerating"), ReadNullableBool(navMesh, "IsDirty"));
private static object? FetchPackage(string ident)
{
return InvokeStaticAwait("Sandbox.Package", "FetchAsync", 30000, ident, false, true) ?? InvokeStaticAwait("Sandbox.Package", "FetchAsync", 30000, ident, false);
}
private static string? ResolvePackageIdent(string ident, string? version, out string? error)
{
error = null;
var normalized = (ident ?? "").Trim();
if (normalized.Length == 0 || normalized.Length > 200 || normalized.Contains("://", StringComparison.Ordinal) || normalized.Contains('/') || normalized.Contains('\\')) { error = "Package ident is invalid."; return null; }
if (string.IsNullOrWhiteSpace(version)) return normalized;
if (!int.TryParse(version.Trim(), out var revision) || revision < 0) { error = "Package version must be a non-negative numeric revision."; return null; }
var separator = normalized.IndexOf('.');
if (separator <= 0 || separator == normalized.Length - 1) { error = "Versioned package ident must contain org and package segments."; return null; }
var formatted = EditorRuntime.InvokeStatic("Sandbox.Package", "FormatIdent", normalized[..separator], normalized[(separator + 1)..], (int?)revision, false)?.ToString();
if (string.IsNullOrWhiteSpace(formatted)) { error = "The loaded package API could not format a versioned ident."; return null; }
return formatted;
}
private static object PackagePayload(object package) => new
{
Ident = ReadText(package, "FullIdent", "Ident") ?? "",
Title = ReadText(package, "Title") ?? "",
Author = ReadText(EditorRuntime.GetMember(package, "Org"), "Title", "Ident") ?? ReadText(package, "Org") ?? "",
Type = ReadText(package, "TypeName", "PackageType", "Type") ?? "",
Version = RevisionText(EditorRuntime.GetMember(package, "Revision")),
Source = "sbox.cloud",
Provenance = "Sandbox.Package"
};
private static object AssetPayload(object asset)
{
var path = ReadText(asset, "Path", "ResourcePath", "Name") ?? "";
var package = EditorRuntime.GetMember(asset, "Package") ?? EditorRuntime.GetMember(asset, "CloudPackage");
var packageIdent = ReadText(package, "FullIdent", "Ident");
var source = package is not null || ReadNullableBool(asset, "IsCloud") == true ? "cloud" : "project";
return new
{
Path = path,
Type = ReadText(asset, "TypeName", "Type", "AssetType") ?? "",
Package = packageIdent,
Source = source,
Provenance = packageIdent is null ? "Editor.AssetSystem.All" : "Editor.AssetSystem.All + Sandbox.Package",
Compiled = ReadNullableBool(asset, "IsCompiled"),
CompileFailed = ReadNullableBool(asset, "IsCompileFailed"),
UpToDate = ReadNullableBool(asset, "IsCompiledAndUpToDate"),
AvailableEditorSurface = SurfaceForPath(path)
};
}
private static object PackageFilePayload(string packageIdent, string path) => new
{
Path = path,
Type = Path.GetExtension(path),
Package = packageIdent,
Source = "cloud",
Provenance = "Editor.AssetSystem.GetPackageFiles"
};
private static string? SurfaceForPath(string path)
{
var extension = Path.GetExtension(path).ToLowerInvariant();
return extension switch
{
".vmat" => "material",
".vmdl" => "modeldoc",
".vmap" => "hammer",
".sprite" => "sprite",
".hotspot" => "hotspot",
".scene" => "scene",
_ => null
};
}
private static string? RevisionText(object? revision) => revision is null ? null : ReadText(revision, "VersionId", "AssetVersionId", "Version", "Revision", "Id") ?? revision.ToString();
private static string[] DependencyIdList(object package) => Enumerate(EditorRuntime.GetMember(package, "PackageReferences")).Select(value => ReadText(value, "FullIdent", "Ident") ?? value?.ToString()).Where(value => !string.IsNullOrWhiteSpace(value)).Cast<string>().Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
private static string? ResolveCapturePath(string root, string? fileName, string mode)
{
var captureRoot = Path.GetFullPath(Path.Combine(root, ".sbox", "captures"));
var requested = string.IsNullOrWhiteSpace(fileName) ? $"{mode}.png" : fileName.Trim();
if (requested.Length > 200) return null;
var path = Path.IsPathRooted(requested) ? Path.GetFullPath(requested) : Path.GetFullPath(Path.Combine(captureRoot, requested));
return IsUnder(path, root) ? path : null;
}
private static byte[]? EncodePng(object bitmap)
{
var method = bitmap.GetType().GetMethod("ToPng", BindingFlags.Public | BindingFlags.Instance, binder: null, Type.EmptyTypes, modifiers: null);
return method?.Invoke(bitmap, null) as byte[];
}
private static (int Width, int Height) ReadPngDimensions(byte[] bytes)
{
if (bytes.Length < 24 || bytes[0] != 137 || bytes[1] != 80 || bytes[2] != 78 || bytes[3] != 71) return (0, 0);
var width = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19];
var height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23];
return (width, height);
}
private static bool KindMatches(string kind, string path, string type)
{
if (string.IsNullOrWhiteSpace(kind)) return true;
var extension = Path.GetExtension(path).TrimStart('.').ToLowerInvariant();
var value = string.Join(" ", path, type).ToLowerInvariant();
return kind switch
{
"hdri" => extension is "hdr" or "exr" || value.Contains("hdri"),
"model" => extension is "vmdl" or "fbx" or "obj" || value.Contains("model"),
"texture" => extension is "vtex" or "png" or "tga" or "jpg" or "jpeg" || value.Contains("texture"),
"material" => extension is "vmat" || value.Contains("material"),
"sound" => extension is "sound" or "wav" or "mp3" or "ogg" || value.Contains("sound"),
"sprite" => extension is "sprite" or "vtex" || value.Contains("sprite"),
"map" => extension is "vmap" or "scene" || value.Contains("map"),
_ => false
};
}
private static bool SurfaceDockReadback(SurfaceDescriptor descriptor)
{
var manager = GetDockManager();
if (manager is null) return false;
var types = EditorRuntime.GetMember(manager, "DockTypes") as IEnumerable;
if (types is null) return false;
foreach (var info in types)
{
var title = EditorRuntime.GetMember(info, "Title")?.ToString() ?? "";
if (!descriptor.DockTokens.Any(token => title.Contains(token, StringComparison.OrdinalIgnoreCase))) continue;
var open = ReadNullableBool(EditorRuntime.InvokeInstance(manager, "IsDockOpen", title));
var widget = EditorRuntime.InvokeInstance(manager, "FindDockWidget", title);
var active = widget is not null && (ReadNullableBool(widget, "IsActiveWindow") == true || ReadNullableBool(widget, "IsFocused") == true);
if (open == true && active) return true;
}
return false;
}
private static string LobbyOperation(string operation, string? scenePath, int maxPlayers)
{
if (maxPlayers < 0 || maxPlayers > 256) return McpJson.Write(ToolOutcome<object>.Failure(new { MaxPlayers = maxPlayers }, ToolStatus.Failed, "lobby.max_players_invalid", "MaxPlayers must be between 0 and 256."));
var api = FindFirstType("Sandbox.Lobby", "Sandbox.Multiplayer", "Sandbox.LobbyService", "Editor.LobbyService");
return McpJson.Write(ToolOutcome<object>.Failure(new { Operation = operation, ScenePath = scenePath, MaxPlayers = maxPlayers, TypedApi = api?.FullName }, ToolStatus.Unavailable, "lobby.api_unavailable", "The current s&box runtime exposes no typed lobby lifecycle API; no lobby action was dispatched."));
}
private static Type? FindFirstType(params string[] names) => names.Select(EditorRuntime.FindType).FirstOrDefault(type => type is not null);
private static bool? ReadStaticBool(Type? type, string name)
{
if (type is null) return null;
try { return type.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)?.GetValue(null) as bool?; } catch { return null; }
}
private static object? GetStaticObject(string typeName, string memberName) => EditorRuntime.GetStaticProperty(typeName, memberName) ?? EditorRuntime.GetStaticField(typeName, memberName);
private static string? ReadText(object? target, params string[] names)
{
foreach (var name in names)
{
var value = EditorRuntime.GetMember(target, name);
if (value is null) continue;
var text = value.ToString();
if (!string.IsNullOrWhiteSpace(text)) return text;
}
return null;
}
private static bool? ReadNullableBool(object? target, string name)
{
var value = EditorRuntime.GetMember(target, name);
return value is bool boolean ? boolean : null;
}
private static bool? ReadNullableBool(object? value) => value is bool boolean ? boolean : null;
private static int? ReadNullableInt(object? target, string name)
{
var value = EditorRuntime.GetMember(target, name);
try { return value is null ? null : Convert.ToInt32(value); } catch { return null; }
}
private static IEnumerable<object> Enumerate(object? value)
{
if (value is not IEnumerable enumerable) yield break;
foreach (var item in enumerable) if (item is not null) yield return item;
}
private static bool HasMethod(Type? type, string name) => type?.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance).Any(method => method.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) == true;
private static object? InvokeStaticCompatible(string typeName, string methodName, params object?[] arguments)
{
var type = EditorRuntime.FindType(typeName);
var method = FindCompatibleMethod(type, methodName, arguments, staticOnly: true);
return method?.Invoke(null, arguments);
}
private static MethodInfo? FindCompatibleMethod(Type? type, string methodName, object?[] arguments, bool staticOnly)
{
if (type is null) return null;
var flags = BindingFlags.Public | BindingFlags.NonPublic | (staticOnly ? BindingFlags.Static : BindingFlags.Instance);
return type.GetMethods(flags).FirstOrDefault(method => method.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase) && method.GetParameters().Length == arguments.Length && method.GetParameters().Zip(arguments, ParameterAccepts).All(result => result));
}
private static bool ParameterAccepts(ParameterInfo parameter, object? value)
{
if (value is null) return !parameter.ParameterType.IsValueType || Nullable.GetUnderlyingType(parameter.ParameterType) is not null;
if (parameter.ParameterType.IsInstanceOfType(value)) return true;
var target = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType;
return target.IsEnum ? value is string || value.GetType().IsEnum : value is IConvertible && typeof(IConvertible).IsAssignableFrom(target);
}
private static object? InvokeStaticAwait(string typeName, string methodName, int timeoutMs, params object?[] arguments)
{
return Task.Run(() => AwaitValue(InvokeStaticCompatible(typeName, methodName, arguments), timeoutMs)).GetAwaiter().GetResult();
}
private static object? AwaitValue(object? value, int timeoutMs)
{
if (value is not Task task)
{
var asTask = value?.GetType().GetMethod("AsTask", BindingFlags.Public | BindingFlags.Instance, binder: null, Type.EmptyTypes, modifiers: null)?.Invoke(value, null);
if (asTask is Task valueTask) task = valueTask; else return value;
}
if (!task.Wait(timeoutMs)) return null;
return task.GetType().GetProperty("Result", BindingFlags.Public | BindingFlags.Instance)?.GetValue(task);
}
private static string HotloadRevision(object? target)
{
var type = target?.GetType();
var parts = new List<string> { type?.FullName ?? "null", ReadText(target, "Name", "NeedsSwap") ?? "" };
foreach (var property in new[] { "NeedsSwap", "IsBuilding", "Success", "Errors", "Warnings" }) parts.Add($"{property}={EditorRuntime.GetMember(target, property)}");
return string.Join("|", parts);
}
private static bool IsUnder(string path, string root)
{
var fullPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return fullPath.Equals(fullRoot, StringComparison.OrdinalIgnoreCase) || fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}
}
}