Editor tooling helpers for the s&box editor, implementing many MCP (management/control panel) assistant tools for scene sessions, docks, viewport captures, package and asset search/install/status, editor surfaces, navmesh operations and network status. It probes editor/runtime types via EditorRuntime reflection wrappers, reads/writes files under the project, encodes PNGs from editor bitmaps, and marshals results into MCP JSON responses.
#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().ToList();
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));
object? session = target?.Session;
string sessionId = target?.Id ?? ("scene:" + ToolGuard.Normalize(requested));
string? sessionPath = target?.Path ?? ToolGuard.Normalize(requested);
if (session is null)
{
session = EditorRuntime.InvokeStatic("Editor.SceneEditorSession", "CreateFromPath", ToolGuard.Normalize(requested));
}
if (session 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, and could not be loaded from path."));
EditorRuntime.InvokeInstance(session, "MakeActive", true);
EditorRuntime.InvokeInstance(session, "BringToFront");
var deadline = DateTime.UtcNow.AddSeconds(5);
object? active = null;
while (DateTime.UtcNow < deadline)
{
active = EditorRuntime.GetStaticProperty("Editor.SceneEditorSession", "Active");
if (ReferenceEquals(active, session)) break;
Thread.Sleep(50);
}
var verified = ReferenceEquals(active, session);
var value = new { Requested = requested, SessionId = sessionId, Path = sessionPath, 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 = null, string? version = null)
{
try
{
var effectiveIdent = (ident ?? "").Trim();
if (string.IsNullOrWhiteSpace(effectiveIdent) || effectiveIdent.Equals("current", StringComparison.OrdinalIgnoreCase) || effectiveIdent.Equals("status", StringComparison.OrdinalIgnoreCase))
{
effectiveIdent = $"{Project.Current?.Config.Org}.{Project.Current?.Config.Ident}";
}
var requestIdent = ResolvePackageIdent(effectiveIdent, 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.not_found", $"No cloud package matched the requested ident '{requestIdent}'."));
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);
var asset = InvokeStaticCompatible("Editor.AssetSystem", "FindByPath", normalizedPath);
if (asset is not null)
{
EditorRuntime.InvokeInstance(asset, "OpenInEditor", (object?)null);
}
else if (descriptor.Surface == "scene")
{
EditorRuntime.InvokeStatic("Editor.SceneEditorSession", "CreateFromPath", 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.playmode_active", "NavMesh bake requires that Play Mode is inactive."));
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_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)); }
}
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 SurfaceDescriptor[] SurfaceDescriptors => new SurfaceDescriptor[]
{
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("scene", new[] { "Editor.SceneEditorSession" }, null, new[] { "scene_activate", "scene_sessions" }, "surface.scene_unavailable", "Scene runtime surface is unavailable.", new[] { "Editor.SceneEditorSession" }, new[] { "CreateFromPath" }, "surface.scene_document_api_unavailable", new[] { "scene" }),
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 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 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);
}
}
}