diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 7f54ea3..9d5a92e 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -86,6 +86,16 @@ subsystem oracles. Test scenes are **synthesized** via `Age.Engine/Sys4/ScriptAs | `play [--boot] [--state ] [--save-state ] [0xADDR=VAL…]` | ★ Cross-scene **state runner**: run a scene sequence carrying persistent globals. `--boot` first runs the 9 `*INIT` data scripts (real skill/item/unit/map/stage state). `--state`/`--save-state` load/persist a JSON snapshot. | `GameSession`; **executes call-script**. | | `sweep [--boot] [0xADDR=VAL…]` | Corpus-scale run. **With call-script execution on: 284/297 exit, 13 STEP-LIMIT** (input/state-gated ADV scenes spin headless once subroutine global-writes drive their loops — state divergence, not a bug; 0 depth-cap/unresolved). **With seeds = a story-state explorer**: reports which scenes' dialogue changes ±seed (e.g. form flag `0xa57=1` → 34/297 scenes). | | +**Faithful headless vs plow (`HaltAtWaitForInput`)** — headless has no player, so op `0x72 wait-for-input` +either **halts** ("the scene is waiting; with no input, stop here") or is ignored (**plow** — walk every page). +Plow is a *fiction*: it runs past every prompt into code no real playthrough reaches — e.g. a plowed +`SC0000` fell through 166 prompts into the name-entry poll loop and spun `sleep 1` **493k×** to STEP-LIMIT. +So: **`run`/`play` HALT at the first `wait-for-input` by default** (faithful; `SC0000` stops at ~402 steps, +0 sleeps — matching the real run's path to the first prompt), with **`--plow`** to opt into full-page +coverage. **`sweep` PLOWS by default** (it *is* the dialogue-coverage oracle: 284 exit / 13 STEP-LIMIT), +with **`--halt-at-wait`** to opt into faithful mode (then all 297 scenes halt cleanly at their first prompt +— 0 STEP-LIMIT). Interactive Godot is unaffected (it really blocks on input; flag stays false there). + **`--trace [--trace-file ] [--trace-steps]`** (on `run`/`play`/`sweep`): stream the engine's own diagnostic events over the `Age.Engine.Diagnostics.ITraceSink` seam — scene/subroutine frame enter+exit (indented by call depth), call-script dispatch with resolved name, and the final halt+step count — to diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index 4c58a5f..18da9da 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -22,7 +22,10 @@ if (args[0] == "run") var script = Sys4Loader.Load(args[1], table); var runHost = new CaptureHost(); using var trace = TraceSetup.Build(args, table); - var vm = new VirtualMachine(script, table, runHost, null, provider, trace.Sink); + // Faithful by default: halt at wait-for-input (no player => stop, don't plow past prompts). --plow opts + // into the old walk-every-page behavior (dialogue coverage). See VmOptions.HaltAtWaitForInput. + var vm = new VirtualMachine(script, table, runHost, new VmOptions(HaltAtWaitForInput: !args.Contains("--plow")), + provider, trace.Sink); vm.Run(); trace.Report(); Console.WriteLine($"{Path.GetFileName(args[1])}: {vm.Steps} steps, {vm.Emitted.Count} show-text, {vm.CallScriptDispatches} call-scripts (halt: {vm.HaltReason})"); @@ -128,10 +131,11 @@ if (args[0] == "play") } long totalLines = 0; using var trace = TraceSetup.Build(args, table); // one sink across the sequence (histogram aggregates) + var playOpts = new VmOptions(HaltAtWaitForInput: !args.Contains("--plow")); // faithful by default foreach (var name in scenes) { var script = Sys4Loader.Load(scripts[name.ToUpperInvariant()], table); - var r = session.RunScene(script, table, new CaptureHost(), null, provider, trace.Sink); + var r = session.RunScene(script, table, new CaptureHost(), playOpts, provider, trace.Sink); totalLines += r.Emitted.Count; Console.WriteLine($" {name,-14} {r.Emitted.Count,4} lines, {r.Steps,7} steps (halt: {r.Halt})"); } @@ -150,6 +154,9 @@ if (args[0] == "sweep") var scripts = Paths.Scripts(); var names = scripts.Keys.Where(n => sceneRe.IsMatch(n)).OrderBy(n => n, StringComparer.Ordinal).ToList(); bool boot = args.Contains("--boot"); + // Sweep DEFAULTS to plow (walk every page) — it's the dialogue-coverage oracle. --halt-at-wait opts into + // the faithful "stop at the first prompt" semantics (VmOptions.HaltAtWaitForInput). + var sweepOpts = new VmOptions(HaltAtWaitForInput: args.Contains("--halt-at-wait")); string? baseline = null; if (boot) { @@ -175,7 +182,7 @@ if (args[0] == "sweep") { var session = Fresh(); if (seeded) foreach (var (k, v) in seeds) session.Seed(k, v); - return session.RunScene(Sys4Loader.Load(scripts[name], table), table, new CaptureHost(), null, provider).Emitted.Count; + return session.RunScene(Sys4Loader.Load(scripts[name], table), table, new CaptureHost(), sweepOpts, provider).Emitted.Count; } if (seeds.Count > 0) @@ -198,7 +205,7 @@ if (args[0] == "sweep") foreach (var name in names) { var session = Fresh(); - var r = session.RunScene(Sys4Loader.Load(scripts[name], table), table, new CaptureHost(), null, provider, trace.Sink); + var r = session.RunScene(Sys4Loader.Load(scripts[name], table), table, new CaptureHost(), sweepOpts, provider, trace.Sink); var halt = r.Halt ?? "null"; haltDist[halt] = haltDist.GetValueOrDefault(halt) + 1; totalLines += r.Emitted.Count; diff --git a/engine/Age.Engine.Tests/HaltAtWaitTests.cs b/engine/Age.Engine.Tests/HaltAtWaitTests.cs new file mode 100644 index 0000000..48eb152 --- /dev/null +++ b/engine/Age.Engine.Tests/HaltAtWaitTests.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class HaltAtWaitTests +{ + private static OpcodeTable Table() => OpcodeTableJson.Load(Paths.OpcodesJson); + + // Two-page scene: show A ; wait-for-input ; show B ; exit. + private static Age.Engine.Model.Script TwoPage(OpcodeTable t) => ScriptAssembler.Assemble(t, "TWOPAGE", + new List<(int, Operand[])> + { + (0x6e, new[] { new Operand(2, 0), new Operand(0, 0) }), // show-text A + (0x72, new[] { new Operand(0, 0) }), // wait-for-input + (0x6e, new[] { new Operand(2, 1), new Operand(0, 0) }), // show-text B + (0x2, Array.Empty()), // exit + }, new[] { "A", "B" }); + + [Fact] + public void Default_PlowsPastWait_EmitsBothPages() + { + var t = Table(); + var host = new RecordingHost(); + var vm = new VirtualMachine(TwoPage(t), t, host); // default VmOptions => HaltAtWaitForInput=false + vm.Run(); + Assert.Equal(2, host.Lines.Count); // both pages emitted (plow) + Assert.Equal("exit", vm.HaltReason); + } + + [Fact] + public void HaltAtWait_StopsAtFirstWait_EmitsOnlyFirstPage() + { + var t = Table(); + var host = new RecordingHost(); + var vm = new VirtualMachine(TwoPage(t), t, host, new VmOptions(HaltAtWaitForInput: true)); + vm.Run(); + Assert.Single(host.Lines); // only page A ran; halted at the wait + Assert.Equal("wait-for-input", vm.HaltReason); + } +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index e0aa0d9..fd213a3 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -207,7 +207,10 @@ public sealed class VirtualMachine _host.ShowText(off, text); } return pc + 1; - case "wait-for-input": _host.WaitForInput(); return pc + 1; + case "wait-for-input": + // Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions). + if (_o.HaltAtWaitForInput) { HaltReason ??= "wait-for-input"; return HALT; } + _host.WaitForInput(); return pc + 1; case "sleep": // 0xc8 (duration) — pause the host duration ms; headless hosts no-op (parity). Frame pacing. _host.Sleep(Read(a[0])); return pc + 1; case "end-text-line": case "set-font": diff --git a/engine/Age.Engine/Vm/VmOptions.cs b/engine/Age.Engine/Vm/VmOptions.cs index 2d858ee..7ce9fc1 100644 --- a/engine/Age.Engine/Vm/VmOptions.cs +++ b/engine/Age.Engine/Vm/VmOptions.cs @@ -1,2 +1,10 @@ namespace Age.Engine.Vm; -public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000, int CallDepthCap = 64); + +/// When true, the VM halts (reason "wait-for-input") at op 0x72 instead +/// of calling the host and continuing. This is the FAITHFUL headless semantics: with no player to click, +/// "the scene is waiting for input" means stop here — not pretend the click already happened and plow on +/// through every prompt into code no real playthrough reaches (which is what made a headless SC0000 spin +/// 493k× in the name-entry poll loop). Leave false for interactive frontends that really block on input +/// (Godot) and for the dialogue-coverage sweep that deliberately walks every page. +public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000, int CallDepthCap = 64, + bool HaltAtWaitForInput = false);