Initial commit
This commit is contained in:
16
sidecar/ArtificersScrollwork.Sidecar.csproj
Normal file
16
sidecar/ArtificersScrollwork.Sidecar.csproj
Normal 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>
|
||||
13
sidecar/Gumps/GumpExtractor.cs
Normal file
13
sidecar/Gumps/GumpExtractor.cs
Normal 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).
|
||||
}
|
||||
22
sidecar/Models/ClassInfo.cs
Normal file
22
sidecar/Models/ClassInfo.cs
Normal 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
|
||||
);
|
||||
37
sidecar/Models/DrawCall.cs
Normal file
37
sidecar/Models/DrawCall.cs
Normal 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");
|
||||
11
sidecar/Models/FlowNode.cs
Normal file
11
sidecar/Models/FlowNode.cs
Normal 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
|
||||
);
|
||||
16
sidecar/Models/MethodInfo.cs
Normal file
16
sidecar/Models/MethodInfo.cs
Normal 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
|
||||
);
|
||||
17
sidecar/Models/ScriptIndex.cs
Normal file
17
sidecar/Models/ScriptIndex.cs
Normal 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
|
||||
);
|
||||
365
sidecar/Parsing/CallChainTracer.cs
Normal file
365
sidecar/Parsing/CallChainTracer.cs
Normal 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] + "…";
|
||||
}
|
||||
151
sidecar/Parsing/RoslynParser.cs
Normal file
151
sidecar/Parsing/RoslynParser.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
86
sidecar/Parsing/ScriptIndexer.cs
Normal file
86
sidecar/Parsing/ScriptIndexer.cs
Normal 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
140
sidecar/Program.cs
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user