Initial commit

This commit is contained in:
2026-06-05 20:53:53 -05:00
commit f9a59e9a66
99 changed files with 15897 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<AssemblyName>asw-sidecar</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.*" />
<PackageReference Include="System.Text.Json" Version="8.*" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
namespace ArtificersScrollwork.Sidecar.Gumps;
/// <summary>
/// Extracts Add* calls from Gump constructors and produces a GumpDrawList.
/// TODO (Phase 4): Full implementation.
/// </summary>
public class GumpExtractor
{
// Phase 4 implementation placeholder.
// Will walk the constructor body of a Gump class,
// map Add* method calls to DrawCall records,
// and detect dynamic regions (loop/conditional bodies).
}

View File

@@ -0,0 +1,22 @@
namespace ArtificersScrollwork.Sidecar.Models;
public record ClassInfo(
string Name,
string Namespace,
string FilePath,
string? BaseClass,
List<string> Interfaces,
List<PropertyInfo> Properties,
List<MethodInfo> Methods,
List<string> Attributes,
bool IsGump,
bool IsMobile,
bool IsItem
);
public record PropertyInfo(
string Name,
string Type,
bool HasGetter,
bool HasSetter
);

View File

@@ -0,0 +1,37 @@
namespace ArtificersScrollwork.Sidecar.Models;
/// <summary>Discriminated union of all Gump draw call types.</summary>
public abstract record DrawCall(string Type);
public record BackgroundDrawCall(int X, int Y, int W, int H, int GumpId)
: DrawCall("background");
public record ImageDrawCall(int X, int Y, int GumpId, int? Hue = null)
: DrawCall("image");
public record LabelDrawCall(int X, int Y, int Hue, string Text)
: DrawCall("label");
public record ButtonDrawCall(int X, int Y, int NormalId, int PressedId, int ButtonId)
: DrawCall("button");
public record HtmlDrawCall(int X, int Y, int W, int H, string Text, bool HasBackground, bool HasScrollbar)
: DrawCall("html");
public record ItemDrawCall(int X, int Y, int ItemId, int? Hue = null)
: DrawCall("item");
public record AlphaRegionDrawCall(int X, int Y, int W, int H)
: DrawCall("alpha_region");
public record TiledImageDrawCall(int X, int Y, int W, int H, int GumpId)
: DrawCall("tiled_image");
public record CheckboxDrawCall(int X, int Y, int InactiveId, int ActiveId, bool Checked, int SwitchId)
: DrawCall("checkbox");
public record RadioDrawCall(int X, int Y, int InactiveId, int ActiveId, bool Checked, int ReturnValue)
: DrawCall("radio");
public record TextEntryDrawCall(int X, int Y, int W, int H, int Hue, int EntryId, string InitialText)
: DrawCall("text_entry");

View File

@@ -0,0 +1,11 @@
namespace ArtificersScrollwork.Sidecar.Models;
public record FlowNode(
string Id,
string Type, // method_call | condition | gump_send | return | property_access
string Label,
List<FlowNode> Children,
string? FakeInputKey = null,
string? ResolvedGump = null,
int? AssetRef = null
);

View File

@@ -0,0 +1,16 @@
namespace ArtificersScrollwork.Sidecar.Models;
public record MethodInfo(
string Name,
string ReturnType,
List<ParameterInfo> Parameters,
bool IsOverride,
bool IsVirtual,
bool CallsGump,
string? GumpClass
);
public record ParameterInfo(
string Name,
string Type
);

View File

@@ -0,0 +1,17 @@
namespace ArtificersScrollwork.Sidecar.Models;
public record ScriptIndex(
int ClassCount,
int FileCount,
List<ClassSummary> Classes
);
public record ClassSummary(
string Name,
string Namespace,
string FilePath,
string? BaseClass,
bool IsGump,
bool IsMobile,
bool IsItem
);

View File

@@ -0,0 +1,365 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using ArtificersScrollwork.Sidecar.Models;
namespace ArtificersScrollwork.Sidecar.Parsing;
/// <summary>
/// Traces method call chains across ServUO scripts using Roslyn and produces a FlowNode tree.
/// Entry point: TraceMethod(className, methodName).
/// Max recursion depth: 8 levels.
/// </summary>
public class CallChainTracer
{
private const int MaxDepth = 8;
private readonly CSharpCompilation _compilation;
private readonly Dictionary<string, (ClassDeclarationSyntax Syntax, SemanticModel Model)> _classMap;
public CallChainTracer(CSharpCompilation compilation)
{
_compilation = compilation;
_classMap = BuildClassMap();
}
// ── Public entry point ────────────────────────────────────────────────────
public FlowNode TraceMethod(string className, string methodName)
{
if (!_classMap.TryGetValue(className, out var entry))
throw new KeyNotFoundException($"Class '{className}' not found");
var methodSyntax = entry.Syntax.Members
.OfType<MethodDeclarationSyntax>()
.FirstOrDefault(m => m.Identifier.Text == methodName)
?? throw new KeyNotFoundException($"Method '{methodName}' not found in '{className}'");
var rootNode = new FlowNode(
Id: NewId(),
Type: "method_call",
Label: $"{className}.{methodName}",
Children: TraceMethodBody(methodSyntax, entry.Model, depth: 0),
FakeInputKey: null,
ResolvedGump: null,
AssetRef: null
);
return rootNode;
}
// ── Internal traversal ────────────────────────────────────────────────────
private List<FlowNode> TraceMethodBody(
MethodDeclarationSyntax method,
SemanticModel model,
int depth)
{
if (depth >= MaxDepth) return [];
if (method.Body is null && method.ExpressionBody is null) return [];
SyntaxNode body = (SyntaxNode?)method.Body ?? method.ExpressionBody!;
return TraceStatements(body.ChildNodes(), model, depth);
}
private List<FlowNode> TraceStatements(
IEnumerable<SyntaxNode> nodes,
SemanticModel model,
int depth)
{
var result = new List<FlowNode>();
foreach (var node in nodes)
{
var children = TraceNode(node, model, depth);
result.AddRange(children);
}
return result;
}
private List<FlowNode> TraceNode(SyntaxNode node, SemanticModel model, int depth)
{
return node switch
{
IfStatementSyntax ifStmt => [TraceIf(ifStmt, model, depth)],
SwitchStatementSyntax sw => [TraceSwitch(sw, model, depth)],
ReturnStatementSyntax ret => [TraceReturn(ret, model, depth)],
ExpressionStatementSyntax expr => TraceExpressionStatement(expr, model, depth),
LocalDeclarationStatementSyntax local => TraceLocal(local, model, depth),
ForEachStatementSyntax forEach => [TraceForEach(forEach, model, depth)],
ForStatementSyntax forStmt => [TraceFor(forStmt, model, depth)],
BlockSyntax block => TraceStatements(block.Statements, model, depth),
_ => [],
};
}
// ── Statement handlers ────────────────────────────────────────────────────
private FlowNode TraceIf(IfStatementSyntax ifStmt, SemanticModel model, int depth)
{
var conditionText = ifStmt.Condition.ToString().Trim();
var fakeKey = ExtractFakeInputKey(ifStmt.Condition);
var trueChildren = TraceNode(ifStmt.Statement, model, depth + 1);
var falseChildren = ifStmt.Else is not null
? TraceNode(ifStmt.Else.Statement, model, depth + 1)
: [];
var branches = new List<FlowNode>();
if (trueChildren.Count > 0)
branches.Add(new FlowNode(NewId(), "branch_true", "true", trueChildren));
if (falseChildren.Count > 0)
branches.Add(new FlowNode(NewId(), "branch_false", "false", falseChildren));
return new FlowNode(
Id: NewId(),
Type: "condition",
Label: $"if ({Truncate(conditionText, 60)})",
Children: branches,
FakeInputKey: fakeKey,
ResolvedGump: null,
AssetRef: null
);
}
private FlowNode TraceSwitch(SwitchStatementSyntax sw, SemanticModel model, int depth)
{
var conditionText = sw.Expression.ToString().Trim();
var branches = new List<FlowNode>();
foreach (var section in sw.Sections)
{
var label = string.Join(", ", section.Labels.Select(l => l.ToString().Trim()));
var sectionChildren = TraceStatements(section.Statements, model, depth + 1);
if (sectionChildren.Count > 0)
branches.Add(new FlowNode(NewId(), "branch_case", label, sectionChildren));
}
return new FlowNode(
Id: NewId(),
Type: "condition",
Label: $"switch ({Truncate(conditionText, 60)})",
Children: branches,
FakeInputKey: ExtractFakeInputKey(sw.Expression),
ResolvedGump: null,
AssetRef: null
);
}
private FlowNode TraceReturn(ReturnStatementSyntax ret, SemanticModel model, int depth)
{
var label = ret.Expression is not null
? $"return {Truncate(ret.Expression.ToString(), 50)}"
: "return";
return new FlowNode(NewId(), "return", label, []);
}
private List<FlowNode> TraceExpressionStatement(
ExpressionStatementSyntax expr,
SemanticModel model,
int depth)
{
return TraceExpression(expr.Expression, model, depth);
}
private List<FlowNode> TraceLocal(
LocalDeclarationStatementSyntax local,
SemanticModel model,
int depth)
{
// Only interesting if the initializer contains a method call
var nodes = new List<FlowNode>();
foreach (var variable in local.Declaration.Variables)
{
if (variable.Initializer?.Value is InvocationExpressionSyntax invoc)
nodes.AddRange(TraceInvocation(invoc, model, depth, label: variable.Identifier.Text + " = …"));
}
return nodes;
}
private FlowNode TraceForEach(ForEachStatementSyntax forEach, SemanticModel model, int depth)
{
var label = $"foreach ({forEach.Type} {forEach.Identifier} in {Truncate(forEach.Expression.ToString(), 40)})";
var children = TraceNode(forEach.Statement, model, depth + 1);
return new FlowNode(NewId(), "condition", label, children, FakeInputKey: null);
}
private FlowNode TraceFor(ForStatementSyntax forStmt, SemanticModel model, int depth)
{
var label = $"for (…; {Truncate(forStmt.Condition?.ToString() ?? "", 40)}; …)";
var children = TraceNode(forStmt.Statement, model, depth + 1);
return new FlowNode(NewId(), "condition", label, children, FakeInputKey: null);
}
// ── Expression handlers ───────────────────────────────────────────────────
private List<FlowNode> TraceExpression(ExpressionSyntax expr, SemanticModel model, int depth)
{
return expr switch
{
InvocationExpressionSyntax invoc => TraceInvocation(invoc, model, depth),
AssignmentExpressionSyntax assign => TraceAssignment(assign, model, depth),
ConditionalExpressionSyntax ternary => TraceExpressionList([ternary.WhenTrue, ternary.WhenFalse], model, depth),
AwaitExpressionSyntax awaitExpr => TraceExpression(awaitExpr.Expression, model, depth),
_ => [],
};
}
private List<FlowNode> TraceExpressionList(IEnumerable<ExpressionSyntax> exprs, SemanticModel model, int depth)
{
return exprs.SelectMany(e => TraceExpression(e, model, depth)).ToList();
}
private List<FlowNode> TraceAssignment(AssignmentExpressionSyntax assign, SemanticModel model, int depth)
{
return TraceExpression(assign.Right, model, depth);
}
private List<FlowNode> TraceInvocation(
InvocationExpressionSyntax invoc,
SemanticModel model,
int depth,
string? label = null)
{
var invocStr = invoc.ToString();
// ── SendGump check ────────────────────────────────────────────────────
if (IsSendGump(invocStr))
{
var gumpClass = ExtractGumpClass(invoc);
return [new FlowNode(
Id: NewId(),
Type: "gump_send",
Label: label ?? invocStr.Truncate(80),
Children: [],
FakeInputKey: null,
ResolvedGump: gumpClass,
AssetRef: null
)];
}
// ── Property / member access (not a real call chain node) ────────────
if (IsPropertyAccess(invoc))
{
return [new FlowNode(NewId(), "property_access", label ?? Truncate(invocStr, 80), [])];
}
// ── Try to resolve and recurse into the called method ─────────────────
if (depth < MaxDepth)
{
var calledMethod = TryResolveMethod(invoc, model);
if (calledMethod is not null)
{
var resolvedLabel = label ?? BuildCallLabel(invoc);
var children = TraceMethodBody(calledMethod.Syntax, calledMethod.Model, depth + 1);
if (children.Count > 0)
{
return [new FlowNode(NewId(), "method_call", resolvedLabel, children)];
}
}
}
// ── Leaf call node ────────────────────────────────────────────────────
var callLabel = label ?? BuildCallLabel(invoc);
return [new FlowNode(NewId(), "method_call", callLabel, [])];
}
// ── Helpers ───────────────────────────────────────────────────────────────
private Dictionary<string, (ClassDeclarationSyntax, SemanticModel)> BuildClassMap()
{
var map = new Dictionary<string, (ClassDeclarationSyntax, SemanticModel)>(
StringComparer.OrdinalIgnoreCase);
foreach (var tree in _compilation.SyntaxTrees)
{
var model = _compilation.GetSemanticModel(tree);
foreach (var cls in tree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>())
{
var symbol = model.GetDeclaredSymbol(cls);
if (symbol is not null && !map.ContainsKey(symbol.Name))
map[symbol.Name] = (cls, model);
}
}
return map;
}
private record ResolvedMethod(MethodDeclarationSyntax Syntax, SemanticModel Model);
private ResolvedMethod? TryResolveMethod(InvocationExpressionSyntax invoc, SemanticModel model)
{
try
{
var symbolInfo = model.GetSymbolInfo(invoc);
var symbol = symbolInfo.Symbol as IMethodSymbol;
if (symbol is null) return null;
var containingClass = symbol.ContainingType?.Name;
if (containingClass is null) return null;
if (!_classMap.TryGetValue(containingClass, out var entry)) return null;
var methodSyntax = entry.Syntax.Members
.OfType<MethodDeclarationSyntax>()
.FirstOrDefault(m => m.Identifier.Text == symbol.Name);
return methodSyntax is not null ? new ResolvedMethod(methodSyntax, entry.Model) : null;
}
catch
{
return null;
}
}
private static bool IsSendGump(string invocStr) =>
invocStr.Contains("SendGump", StringComparison.OrdinalIgnoreCase);
private static bool IsPropertyAccess(InvocationExpressionSyntax invoc)
{
var memberAccess = invoc.Expression as MemberAccessExpressionSyntax;
return memberAccess?.Name.Identifier.Text is "ToString" or "GetType";
}
private static string? ExtractGumpClass(InvocationExpressionSyntax invoc)
{
var newExpr = invoc.DescendantNodes().OfType<ObjectCreationExpressionSyntax>().FirstOrDefault();
return newExpr?.Type.ToString();
}
private static string? ExtractFakeInputKey(ExpressionSyntax expr)
{
// If the condition references a simple identifier or member access, use it as the key
return expr switch
{
IdentifierNameSyntax id => id.Identifier.Text,
MemberAccessExpressionSyntax ma => ma.Name.Identifier.Text,
BinaryExpressionSyntax bin => ExtractFakeInputKey(bin.Left),
PrefixUnaryExpressionSyntax pre => ExtractFakeInputKey(pre.Operand),
_ => null,
};
}
private static string BuildCallLabel(InvocationExpressionSyntax invoc)
{
var name = invoc.Expression switch
{
MemberAccessExpressionSyntax ma => $"{ma.Expression}.{ma.Name}",
IdentifierNameSyntax id => id.Identifier.Text,
_ => invoc.Expression.ToString(),
};
return Truncate(name, 80);
}
private static string Truncate(string s, int max) =>
s.Length <= max ? s : s[..max] + "…";
private static int _counter;
private static string NewId() => $"n{System.Threading.Interlocked.Increment(ref _counter)}";
}
internal static class StringExtensions
{
public static string Truncate(this string s, int max) =>
s.Length <= max ? s : s[..max] + "…";
}

View File

@@ -0,0 +1,151 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using ArtificersScrollwork.Sidecar.Models;
namespace ArtificersScrollwork.Sidecar.Parsing;
/// <summary>
/// Walks a syntax tree and extracts ClassInfo for every class declaration.
/// </summary>
public class ClassWalker : CSharpSyntaxWalker
{
private readonly SemanticModel _model;
private readonly string _filePath;
public List<ClassInfo> Classes { get; } = new();
public ClassWalker(SemanticModel model, string filePath)
{
_model = model;
_filePath = filePath;
}
public override void VisitClassDeclaration(ClassDeclarationSyntax node)
{
var symbol = _model.GetDeclaredSymbol(node);
if (symbol is null)
{
base.VisitClassDeclaration(node);
return;
}
var baseClass = symbol.BaseType?.Name;
var interfaces = symbol.Interfaces.Select(i => i.Name).ToList();
var ns = symbol.ContainingNamespace?.ToDisplayString() ?? "";
var attrs = symbol.GetAttributes().Select(a => a.AttributeClass?.Name ?? "").ToList();
// Heuristics for class category
bool isGump = IsGump(symbol);
bool isMobile = IsMobile(symbol);
bool isItem = IsItem(symbol);
// Methods
var methods = node.Members
.OfType<MethodDeclarationSyntax>()
.Select(m => ParseMethod(m, symbol))
.ToList();
// Properties
var properties = node.Members
.OfType<PropertyDeclarationSyntax>()
.Select(p => new Models.PropertyInfo(
p.Identifier.Text,
p.Type.ToString(),
p.AccessorList?.Accessors.Any(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)) ?? false,
p.AccessorList?.Accessors.Any(a => a.IsKind(SyntaxKind.SetAccessorDeclaration)) ?? false
))
.ToList();
Classes.Add(new ClassInfo(
symbol.Name,
ns,
_filePath,
baseClass,
interfaces,
properties,
methods,
attrs,
isGump,
isMobile,
isItem
));
base.VisitClassDeclaration(node);
}
private Models.MethodInfo ParseMethod(MethodDeclarationSyntax m, INamedTypeSymbol classSymbol)
{
var isOverride = m.Modifiers.Any(mod => mod.IsKind(SyntaxKind.OverrideKeyword));
var isVirtual = m.Modifiers.Any(mod => mod.IsKind(SyntaxKind.VirtualKeyword));
var parameters = m.ParameterList.Parameters
.Select(p => new ParameterInfo(p.Identifier.Text, p.Type?.ToString() ?? ""))
.ToList();
// Check if this method sends a Gump
bool callsGump = false;
string? gumpClass = null;
if (m.Body is not null)
{
var sendGumps = m.Body.DescendantNodes()
.OfType<InvocationExpressionSyntax>()
.Where(inv => inv.ToString().Contains("SendGump"))
.FirstOrDefault();
if (sendGumps is not null)
{
callsGump = true;
// Try to extract the Gump class name from `new XxxGump(...)`
var newExpr = sendGumps.DescendantNodes()
.OfType<ObjectCreationExpressionSyntax>()
.FirstOrDefault();
gumpClass = newExpr?.Type.ToString();
}
}
return new Models.MethodInfo(
m.Identifier.Text,
m.ReturnType.ToString(),
parameters,
isOverride,
isVirtual,
callsGump,
gumpClass
);
}
private static bool IsGump(INamedTypeSymbol symbol)
{
var current = symbol.BaseType;
while (current is not null)
{
if (current.Name is "Gump" or "GumpPlus") return true;
current = current.BaseType;
}
return false;
}
private static bool IsMobile(INamedTypeSymbol symbol)
{
var current = symbol.BaseType;
while (current is not null)
{
if (current.Name is "Mobile" or "BaseCreature" or "BaseVendor") return true;
current = current.BaseType;
}
return false;
}
private static bool IsItem(INamedTypeSymbol symbol)
{
var current = symbol.BaseType;
while (current is not null)
{
if (current.Name is "Item" or "BaseWeapon" or "BaseArmor" or "BaseClothing") return true;
current = current.BaseType;
}
return false;
}
}

View File

@@ -0,0 +1,86 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using ArtificersScrollwork.Sidecar.Models;
namespace ArtificersScrollwork.Sidecar.Parsing;
/// <summary>
/// Walks a ServUO Scripts directory, parses all .cs files with Roslyn,
/// and builds a searchable in-memory index of classes and methods.
/// </summary>
public class ScriptIndexer
{
private readonly Dictionary<string, ClassInfo> _classes =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>The Roslyn compilation built during indexing. Used by CallChainTracer.</summary>
public CSharpCompilation? Compilation { get; private set; }
public ScriptIndex IndexDirectory(string path)
{
var files = Directory
.EnumerateFiles(path, "*.cs", SearchOption.AllDirectories)
.ToList();
var syntaxTrees = files
.Select(f =>
{
try { return CSharpSyntaxTree.ParseText(File.ReadAllText(f), path: f); }
catch { return null; }
})
.Where(t => t is not null)
.Select(t => t!)
.ToList();
Compilation = CSharpCompilation.Create(
"ServUOAnalysis",
syntaxTrees,
references: null,
options: new CSharpCompilationOptions(
OutputKind.DynamicallyLinkedLibrary,
reportSuppressedDiagnostics: false
)
);
_classes.Clear();
foreach (var tree in syntaxTrees)
{
SemanticModel model;
try { model = Compilation.GetSemanticModel(tree); }
catch { continue; }
var walker = new ClassWalker(model, tree.FilePath);
try { walker.Visit(tree.GetRoot()); }
catch { continue; }
foreach (var cls in walker.Classes)
_classes[cls.Name] = cls;
}
var summaries = _classes.Values
.Select(c => new ClassSummary(
c.Name, c.Namespace, c.FilePath, c.BaseClass,
c.IsGump, c.IsMobile, c.IsItem))
.OrderBy(c => c.Namespace)
.ThenBy(c => c.Name)
.ToList();
return new ScriptIndex(_classes.Count, files.Count, summaries);
}
public ClassInfo? GetClass(string name) =>
_classes.TryGetValue(name, out var cls) ? cls : null;
public List<ClassSummary> Search(string query) =>
_classes.Values
.Where(c =>
c.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
c.Namespace.Contains(query, StringComparison.OrdinalIgnoreCase) ||
c.Methods.Any(m => m.Name.Contains(query, StringComparison.OrdinalIgnoreCase)))
.Select(c => new ClassSummary(
c.Name, c.Namespace, c.FilePath, c.BaseClass,
c.IsGump, c.IsMobile, c.IsItem))
.Take(100)
.ToList();
}

140
sidecar/Program.cs Normal file
View File

@@ -0,0 +1,140 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ArtificersScrollwork.Sidecar.Parsing;
using ArtificersScrollwork.Sidecar.Gumps;
/// <summary>
/// Entry point for the ASW C# sidecar.
/// Reads newline-delimited JSON requests from stdin, writes responses to stdout.
///
/// Request: { "id": "uuid", "command": "cmd_name", "args": { ... } }
/// Response: { "id": "uuid", "ok": true, "data": { ... } }
/// | { "id": "uuid", "ok": false, "error": "message" }
/// </summary>
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
};
ScriptIndexer? indexer = null;
CallChainTracer? tracer = null;
while (true)
{
var line = Console.ReadLine();
if (line is null) break;
JsonObject? request;
try { request = JsonSerializer.Deserialize<JsonObject>(line, options); }
catch { continue; }
if (request is null) continue;
var id = request["id"]?.GetValue<string>() ?? "";
var command = request["command"]?.GetValue<string>() ?? "";
var reqArgs = request["args"] as JsonObject ?? new JsonObject();
try
{
JsonNode? data = command switch
{
"index_scripts" => HandleIndexScripts(reqArgs),
"get_class" => HandleGetClass(reqArgs),
"trace_method" => HandleTraceMethod(reqArgs),
"extract_gump" => HandleExtractGump(reqArgs),
"search" => HandleSearch(reqArgs),
_ => throw new InvalidOperationException($"Unknown command: {command}"),
};
WriteResponse(id, true, data, null, options);
}
catch (Exception ex)
{
WriteResponse(id, false, null, ex.Message, options);
}
}
// ── Command handlers ──────────────────────────────────────────────────────────
JsonNode HandleIndexScripts(JsonObject args)
{
var path = args["path"]?.GetValue<string>()
?? throw new ArgumentException("Missing 'path' argument");
indexer = new ScriptIndexer();
var index = indexer.IndexDirectory(path);
// Build a compilation for the tracer
tracer = new CallChainTracer(indexer.Compilation
?? throw new InvalidOperationException("Compilation not available after indexing"));
return JsonSerializer.SerializeToNode(index, options)!;
}
JsonNode HandleGetClass(JsonObject args)
{
EnsureIndexer();
var className = args["class"]?.GetValue<string>()
?? throw new ArgumentException("Missing 'class' argument");
var info = indexer!.GetClass(className)
?? throw new KeyNotFoundException($"Class '{className}' not found in index");
return JsonSerializer.SerializeToNode(info, options)!;
}
JsonNode HandleTraceMethod(JsonObject args)
{
EnsureIndexer();
if (tracer is null)
throw new InvalidOperationException("Tracer not initialized — send index_scripts first");
var className = args["class"]?.GetValue<string>()
?? throw new ArgumentException("Missing 'class' argument");
var methodName = args["method"]?.GetValue<string>()
?? throw new ArgumentException("Missing 'method' argument");
var flowNode = tracer.TraceMethod(className, methodName);
return JsonSerializer.SerializeToNode(flowNode, options)!;
}
JsonNode HandleExtractGump(JsonObject args)
{
EnsureIndexer();
throw new NotImplementedException("extract_gump not yet implemented (Phase 4)");
}
JsonNode HandleSearch(JsonObject args)
{
EnsureIndexer();
var query = args["query"]?.GetValue<string>()
?? throw new ArgumentException("Missing 'query' argument");
var results = indexer!.Search(query);
return JsonSerializer.SerializeToNode(results, options)!;
}
void EnsureIndexer()
{
if (indexer is null)
throw new InvalidOperationException("Scripts not indexed — send index_scripts first");
}
static void WriteResponse(string id, bool ok, JsonNode? data, string? error, JsonSerializerOptions opts)
{
var response = new JsonObject
{
["id"] = id,
["ok"] = ok,
};
if (ok && data is not null)
response["data"] = data;
else if (!ok)
response["error"] = error ?? "Unknown error";
Console.WriteLine(JsonSerializer.Serialize(response, opts));
Console.Out.Flush();
}