using System; using System.Collections.Generic; using System.IO; using System.Linq; using Yarn; using Yarn.Compiler; namespace YarnCheck { /// /// Headless compile-and-play harness for the project's Yarn scripts. /// /// Compiles every .yarn file under a directory with the same compiler Unity uses /// (the DLLs shipped in Packages/dev.yarnspinner.unity), and optionally runs a /// node through the real virtual machine with a scripted list of option picks, /// printing the line/command/node trace and the final variable state. /// /// The point is to answer "does this branch ever actually run" without opening /// Unity — flag-ordering bugs and broken detour chains show up here in seconds. /// static class Program { const int LanguageVersion = 3; static int Main(string[] args) { if (args.Length == 0 || args[0] is "-h" or "--help") { Console.WriteLine( "usage: dotnet run -- [start-node] [option-index ...]\n" + "\n" + " directory scanned recursively for *.yarn\n" + " [start-node] node to run; omit to compile only (exit 1 on error)\n" + " [option-index] choices to feed the option handler, in order\n" + "\n" + " Picks are consumed as the run needs them. If any are left over when\n" + " the dialogue ends, the node is run a second time with the remainder —\n" + " that models the player walking away and coming back, which is how the\n" + " scene is actually played.\n" + "\n" + "examples:\n" + " dotnet run -- ../../NightclubArcadia/Assets/Dialogue\n" + " dotnet run -- ../../NightclubArcadia/Assets/Dialogue Chair_Interaction 0 0 3\n"); return args.Length == 0 ? 1 : 0; } var dialogueDir = args[0]; if (!Directory.Exists(dialogueDir)) { Console.Error.WriteLine($"No such directory: {dialogueDir}"); return 1; } var files = Directory.GetFiles(dialogueDir, "*.yarn", SearchOption.AllDirectories) .OrderBy(f => f, StringComparer.Ordinal) .ToList(); if (files.Count == 0) { Console.Error.WriteLine($"No .yarn files under {dialogueDir}"); return 1; } var job = CompilationJob.CreateFromFiles(files); job.LanguageVersion = LanguageVersion; job.Library = ProjectLibrary(); var result = Compiler.Compile(job); var errors = result.Diagnostics .Where(d => d.Severity == Diagnostic.DiagnosticSeverity.Error) .ToList(); foreach (var d in errors.OrderBy(d => d.FileName).ThenBy(d => d.Range.Start.Line)) { Console.Error.WriteLine( $"[error] {Path.GetFileName(d.FileName)}:{d.Range.Start.Line + 1} {d.Message}"); } if (errors.Count > 0) { Console.Error.WriteLine($"\n{errors.Count} error(s) in {files.Count} file(s)."); return 1; } var program = result.Program; if (program == null) { Console.Error.WriteLine("Compilation produced no program."); return 1; } Console.WriteLine($"compiled ok — {files.Count} file(s), {program.Nodes.Count} node(s)"); if (args.Length == 1) { return 0; } var startNode = args[1]; if (!program.Nodes.ContainsKey(startNode)) { Console.Error.WriteLine($"No such node: {startNode}"); return 1; } var picks = new Queue(); foreach (var raw in args.Skip(2)) { if (!int.TryParse(raw, out var pick)) { Console.Error.WriteLine($"Not an option index: {raw}"); return 1; } picks.Enqueue(pick); } Play(result, program, startNode, picks); return 0; } /// /// The project's own Yarn functions. The compiler type-checks calls against /// these, and the VM calls them for real, so the stubs mirror the shipping /// implementations closely enough to follow the same branches. /// static Library ProjectLibrary() { var library = new Library(); AddProjectFunctions(library); return library; } static void AddProjectFunctions(Library library) { // SkillFunctions.TopSkill — first argument wins ties, as in the game. library.RegisterFunction("top_skill", (Func)((a, aName, b, bName, c, cName) => a >= b && a >= c ? aName : b >= a && b >= c ? bName : cName)); // AliasNameGenerator.alias_name — the real one is hash-seeded; the trace // only needs it to be deterministic and obviously a placeholder. library.RegisterFunction("alias_name", (Func)(name => $"")); library.RegisterFunction("skill_rank", (Func)(_ => 0f)); } static void Play(CompilationResult result, Yarn.Program program, string startNode, Queue picks) { var storage = new MemoryVariableStore(); var dialogue = new Dialogue(storage); AddProjectFunctions(dialogue.Library); dialogue.SetProgram(program); storage.Program = program; storage.SmartVariableEvaluator = dialogue; // Seed declared defaults the way the Unity runtime does from the Yarn project. // Smart variables (IsInlineExpansion) are computed, never stored. var declared = result.Declarations .Where(d => d.DefaultValue != null && !d.IsInlineExpansion) .ToList(); foreach (var decl in declared) { switch (decl.DefaultValue) { case bool b: storage.SetValue(decl.Name, b); break; case string s: storage.SetValue(decl.Name, s); break; case float f: storage.SetValue(decl.Name, f); break; case double d: storage.SetValue(decl.Name, (float)d); break; case int i: storage.SetValue(decl.Name, i); break; } } var stringTable = result.StringTable; string Text(string lineID) => stringTable != null && stringTable.TryGetValue(lineID, out var entry) ? entry.text ?? lineID : lineID; // <>, <>, <> and friends are Unity-side // commands; here they are printed and stepped over. That means checks never // succeed ($check_result stays false), so a check-gated branch shows its // failure side — worth remembering when reading a trace. dialogue.LineHandler = line => Console.WriteLine(" " + Text(line.ID)); dialogue.CommandHandler = command => { Console.WriteLine($" <<{command.Text}>>"); dialogue.Continue(); }; dialogue.NodeStartHandler = node => Console.WriteLine($" [enter {node}]"); dialogue.NodeCompleteHandler = node => Console.WriteLine($" [exit {node}]"); dialogue.DialogueCompleteHandler = () => Console.WriteLine(" [dialogue complete]"); dialogue.OptionsHandler = options => { for (var i = 0; i < options.Options.Length; i++) { Console.WriteLine($" [{i}] {Text(options.Options[i].Line.ID)}"); } if (picks.Count == 0) { Console.WriteLine(" (out of picks — stopping)"); dialogue.Stop(); return; } var pick = picks.Dequeue(); Console.WriteLine($" -> [{pick}]"); dialogue.SetSelectedOption(pick); }; var visit = 0; do { visit++; Console.WriteLine($"\n=== visit {visit}: {startNode} ==="); dialogue.SetNode(startNode); // SetNode arms the VM; IsActive only turns true once it is stepping. var steps = 0; do { dialogue.Continue(); if (++steps <= 10_000) continue; Console.WriteLine(" [aborted after 10000 steps — runaway loop?]"); dialogue.Stop(); break; } while (dialogue.IsActive); } while (picks.Count > 0 && visit < 10); Console.WriteLine("\n=== variables ==="); foreach (var decl in declared.OrderBy(d => d.Name, StringComparer.Ordinal)) { Console.WriteLine($" {decl.Name} = {Read(storage, decl)}"); } } /// /// Reads a variable back using the type it was declared with — asking a /// MemoryVariableStore for the wrong type coerces silently (a number reads /// back as "False"), which is exactly how you misread a trace. /// static string Read(MemoryVariableStore storage, Declaration decl) => decl.DefaultValue switch { bool => storage.TryGetValue(decl.Name, out var b) ? b.ToString() : "", string => storage.TryGetValue(decl.Name, out var s) ? s : "", _ => storage.TryGetValue(decl.Name, out var f) ? f.ToString() : "", }; } }