Debugger support for the scenario studio

Actions carry the scenario line they were parsed from (SourceLine);
ScenarioParser.SplitLine exposes the parser's line split. ScenarioEvent
remembers its source mod/file and can start at a label. DialogRuntime asks an
optional IDialogDebugger before every action and can be held; DialogBox
resumes a held dialog and aborts one without completing it.
This commit is contained in:
cthangover committed 2026-10-18 14:21:47 +02:00
1 parent 8982ad7092
commit aca89c631f
9 files changed
+221 -45

No files matched your search

+4 -2
View File
@@ -17,8 +17,8 @@ namespace Cthangover.Mods.Dialog
return new ScenarioEvent { ScenarioText = scenarioText }; return new ScenarioEvent { ScenarioText = scenarioText };
} }
/// <summary>Builds an event from a scene event definition's scenario fields.</summary> /// <summary>Builds an event from a scene event definition's scenario fields, remembering the source file.</summary>
public IExecutableEvent CreateFromDefinition(string scenarioText, string scenarioId, string condition, bool? lightUseTime, bool isOneRun) public IExecutableEvent CreateFromDefinition(string scenarioText, string scenarioId, string condition, bool? lightUseTime, bool isOneRun, string modId, string filePath)
{ {
return new ScenarioEvent return new ScenarioEvent
{ {
@@ -27,6 +27,8 @@ namespace Cthangover.Mods.Dialog
Condition = condition, Condition = condition,
LightUseTime = lightUseTime, LightUseTime = lightUseTime,
IsOneRun = isOneRun, IsOneRun = isOneRun,
SourceModId = modId,
SourcePath = filePath,
}; };
} }
} }
+62 -40
View File
@@ -36,9 +36,10 @@ namespace Cthangover.Mods.Dialog.Scenarios
var lines = text.Split('\n'); var lines = text.Split('\n');
ActionSelect pendingSelect = null; ActionSelect pendingSelect = null;
foreach (var rawLine in lines) for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++)
{ {
var line = rawLine.Trim(); var lineNumber = lineIndex + 1;
var line = lines[lineIndex].Trim();
if (line.Length == 0 || line.StartsWith('#')) if (line.Length == 0 || line.StartsWith('#'))
{ {
FinalizePendingSelect(dlg, ref pendingSelect); FinalizePendingSelect(dlg, ref pendingSelect);
@@ -48,51 +49,19 @@ namespace Cthangover.Mods.Dialog.Scenarios
if (line.StartsWith(':')) if (line.StartsWith(':'))
{ {
FinalizePendingSelect(dlg, ref pendingSelect); FinalizePendingSelect(dlg, ref pendingSelect);
dlg.Point(line.Substring(1)); dlg.Point(line.Substring(1)).SourceLine = lineNumber;
continue; continue;
} }
var tokens = Tokenize(line);
if (tokens.Count == 0) continue;
var cmd = tokens[0].ToLowerInvariant();
tokens.RemoveAt(0);
var positional = new List<string>(); var positional = new List<string>();
var named = new Dictionary<string, string>(); var named = new Dictionary<string, string>();
var arrowTarget = ""; var cmd = SplitLine(line, positional, named, out var arrowTarget);
if (cmd == null) continue;
for (var i = 0; i < tokens.Count; i++)
{
var t = tokens[i];
if (t == "->")
{
if (i + 1 < tokens.Count)
{
arrowTarget = tokens[i + 1].TrimStart(':');
i++;
}
continue;
}
var eqIdx = t.IndexOf('=');
if (eqIdx > 0)
{
var name = t.Substring(0, eqIdx);
if (IsValidIdent(name))
{
named[name.ToLowerInvariant()] = t.Substring(eqIdx + 1);
continue;
}
}
positional.Add(t);
}
if (cmd == "select") if (cmd == "select")
{ {
FinalizePendingSelect(dlg, ref pendingSelect); FinalizePendingSelect(dlg, ref pendingSelect);
pendingSelect = new ActionSelect(); pendingSelect = new ActionSelect { SourceLine = lineNumber };
var promptText = positional.Count > 0 ? positional[0] : null; var promptText = positional.Count > 0 ? positional[0] : null;
if (named.TryGetValue("key", out var key) && locale != null) if (named.TryGetValue("key", out var key) && locale != null)
@@ -141,14 +110,67 @@ namespace Cthangover.Mods.Dialog.Scenarios
FinalizePendingSelect(dlg, ref pendingSelect); FinalizePendingSelect(dlg, ref pendingSelect);
var strategy = ScenarioCommandStrategyFactory.Get(cmd); var strategy = ScenarioCommandStrategyFactory.Get(cmd);
if (strategy != null) if (strategy == null)
strategy.Execute(dlg, positional, named, arrowTarget, locale); continue;
var firstNew = dlg.Queue.Count;
strategy.Execute(dlg, positional, named, arrowTarget, locale);
for (var i = firstNew; i < dlg.Queue.Count; i++)
{
if (dlg.Queue[i] != null && dlg.Queue[i].SourceLine == 0)
dlg.Queue[i].SourceLine = lineNumber;
}
} }
FinalizePendingSelect(dlg, ref pendingSelect); FinalizePendingSelect(dlg, ref pendingSelect);
return dlg; return dlg;
} }
/// <summary>
/// Splits one trimmed, non-empty DSL line the way <see cref="Parse"/> does:
/// returns the lowercase command word (null for an empty line) and fills
/// the positional tokens, the <c>name=value</c> pairs (names lowercased)
/// and the <c>-&gt; :label</c> target. Tools that read scenarios without
/// running them (the scenario studio) use it to agree with the parser.
/// </summary>
public static string SplitLine(string line, List<string> positional, Dictionary<string, string> named, out string arrowTarget)
{
arrowTarget = "";
var tokens = Tokenize(line);
if (tokens.Count == 0)
return null;
var cmd = tokens[0].ToLowerInvariant();
for (var i = 1; i < tokens.Count; i++)
{
var t = tokens[i];
if (t == "->")
{
if (i + 1 < tokens.Count)
{
arrowTarget = tokens[i + 1].TrimStart(':');
i++;
}
continue;
}
var eqIdx = t.IndexOf('=');
if (eqIdx > 0)
{
var name = t.Substring(0, eqIdx);
if (IsValidIdent(name))
{
named[name.ToLowerInvariant()] = t.Substring(eqIdx + 1);
continue;
}
}
positional.Add(t);
}
return cmd;
}
/// <summary> /// <summary>
/// Extracts YAML-like front-matter metadata from script text. Lines before /// Extracts YAML-like front-matter metadata from script text. Lines before
/// a <c>---</c> separator or the first non-key:value line are parsed as /// a <c>---</c> separator or the first non-key:value line are parsed as
+2
View File
@@ -44,6 +44,8 @@ namespace Cthangover.Mods.Dialog.Action
public float StartTime { get; private set; } public float StartTime { get; private set; }
/// <summary>Action IDs whose <see cref="Destruct"/> will be triggered when this action destructs. Used for cross-action cleanup dependencies.</summary> /// <summary>Action IDs whose <see cref="Destruct"/> will be triggered when this action destructs. Used for cross-action cleanup dependencies.</summary>
public ISet<string> DelayedDestruct { get; } = new HashSet<string>(); public ISet<string> DelayedDestruct { get; } = new HashSet<string>();
/// <summary>1-based scenario line the action was parsed from; 0 when built in code.</summary>
public int SourceLine { get; set; }
/// <summary> /// <summary>
/// Scene node lookup seam for subclasses. Replaces direct /// Scene node lookup seam for subclasses. Replaces direct
+6
View File
@@ -39,6 +39,12 @@ namespace Cthangover.Mods.Dialog.Action
float WaitTime { get; } float WaitTime { get; }
/// <summary>Timestamp of the last <see cref="Run"/> call, in seconds since epoch.</summary> /// <summary>Timestamp of the last <see cref="Run"/> call, in seconds since epoch.</summary>
float StartTime { get; } float StartTime { get; }
/// <summary>
/// 1-based line of the scenario text this action was parsed from, 0 for
/// actions built in code. Set by <see cref="Scenarios.ScenarioParser"/>;
/// debugging tools map the running action back to the script with it.
/// </summary>
int SourceLine { get; set; }
#endregion #endregion
+36 -2
View File
@@ -209,7 +209,7 @@ namespace Cthangover.Mods.Dialog
/// </summary> /// </summary>
public void NextAction() public void NextAction()
{ {
if (Runtime.IsWaitAnswer) if (Runtime.IsHeld || Runtime.IsWaitAnswer)
return; return;
if (Runtime.IsEnd) if (Runtime.IsEnd)
{ {
@@ -242,7 +242,7 @@ namespace Cthangover.Mods.Dialog
/// <summary>Advances the dialog on <c>ui_accept</c> or on a click outside the body, unless a choice is pending or the queue has ended.</summary> /// <summary>Advances the dialog on <c>ui_accept</c> or on a click outside the body, unless a choice is pending or the queue has ended.</summary>
protected override void OnInput(InputEvent @event) protected override void OnInput(InputEvent @event)
{ {
if (Runtime.IsEnd || Runtime.IsWaitAnswer) if (Runtime.IsHeld || Runtime.IsEnd || Runtime.IsWaitAnswer)
return; return;
if (@event.IsActionPressed("ui_accept")) if (@event.IsActionPressed("ui_accept"))
@@ -269,6 +269,40 @@ namespace Cthangover.Mods.Dialog
} }
} }
/// <summary>
/// Continues a dialog the debugger held (<see cref="DialogRuntime.IsHeld"/>):
/// runs from the held action on, exactly as if it had never stopped.
/// Does nothing when the runtime is not held.
/// </summary>
public void Resume()
{
if (!Runtime.IsHeld)
return;
Runtime.IsHeld = false;
Runtime.Run();
ScheduleWaitTimer();
}
/// <summary>
/// Stops the running dialog without finishing it (<see cref="DialogRuntime.Abort"/>)
/// and frees the box for another queue: closes the answer box, hides
/// and releases the locker. The scenario does not count as completed.
/// </summary>
public void Abort()
{
ClearWaitTimer();
if (answerBox != null)
{
answerBox.Hide();
answerBox = null;
}
Runtime.Abort();
Hide();
Locker = null;
}
/// <summary>Whether a dialog is currently running (Locker is not null).</summary> /// <summary>Whether a dialog is currently running (Locker is not null).</summary>
public bool IsRunning => Locker != null; public bool IsRunning => Locker != null;
+51
View File
@@ -57,6 +57,26 @@ namespace Cthangover.Mods.Dialog
/// <summary>True when the dialog queue is exhausted (index past end or queue is empty).</summary> /// <summary>True when the dialog queue is exhausted (index past end or queue is empty).</summary>
public bool IsEnd => Lists.IsEmpty(dialogQueue) || index >= dialogQueue.Count; public bool IsEnd => Lists.IsEmpty(dialogQueue) || index >= dialogQueue.Count;
/// <summary>
/// Process-wide debugging hook asked before every action (see
/// <see cref="IDialogDebugger"/>); null in normal play, which costs one
/// null check per action.
/// </summary>
public static IDialogDebugger Debugger { get; set; }
/// <summary>
/// True while the debugger holds the runtime before <see cref="CurrentAction"/>:
/// that action has not run yet, so advancing must wait for
/// <see cref="DialogBox.Resume"/> instead of skipping it.
/// </summary>
public bool IsHeld { get; set; }
/// <summary>Queue index of <see cref="CurrentAction"/>.</summary>
public int Index => index;
/// <summary>Current <c>${name}</c> variables of the dialog, for inspection.</summary>
public IReadOnlyDictionary<string, string> Variables => (IReadOnlyDictionary<string, string>)variables;
/// <summary>Retrieves a stored variable value by name. Returns null if not set.</summary> /// <summary>Retrieves a stored variable value by name. Returns null if not set.</summary>
public string GetVariable(string name) public string GetVariable(string name)
{ {
@@ -122,6 +142,7 @@ namespace Cthangover.Mods.Dialog
{ {
variables = new Dictionary<string, string>(); variables = new Dictionary<string, string>();
DialogBox = dialogBox; DialogBox = dialogBox;
IsHeld = false;
this.endDialogQueue = endQueue == null ? null : endQueue.ToList(); this.endDialogQueue = endQueue == null ? null : endQueue.ToList();
if (Lists.IsNotEmpty(this.dialogQueue)) if (Lists.IsNotEmpty(this.dialogQueue))
@@ -188,6 +209,12 @@ namespace Cthangover.Mods.Dialog
GameLogger.Log("DIALOG", $"Run: idx={index}/{dialogQueue.Count-1} type={action.GetType().Name} wait={action.WaitType}", LogLevel.Debug); GameLogger.Log("DIALOG", $"Run: idx={index}/{dialogQueue.Count-1} type={action.GetType().Name} wait={action.WaitType}", LogLevel.Debug);
if (Debugger != null && Debugger.ShouldHold(this, action))
{
IsHeld = true;
return;
}
action.Run(this); action.Run(this);
if (iteration++ > 100000) if (iteration++ > 100000)
@@ -301,6 +328,30 @@ namespace Cthangover.Mods.Dialog
} }
} }
/// <summary>
/// Drops the running queue without finishing it: destructs constructed
/// actions and forgets the queue, but — unlike <see cref="End"/> — runs no
/// end actions, does not mark a one-run scenario completed and publishes
/// no <see cref="DialogEndedEvent"/>. Used to restart a scenario that is
/// being edited; the caller hides the box (<see cref="DialogBox.Abort"/>).
/// </summary>
public void Abort()
{
if (Lists.IsNotEmpty(dialogQueue))
{
foreach (var action in dialogQueue)
if (action != null && action.IsConstructed)
action.Destruct();
}
dialogQueue = null;
endDialogQueue = null;
index = 0;
isLastGoTo = false;
IsHeld = false;
GameLogger.Log("DIALOG", "Abort: queue dropped without ending");
}
/// <summary> /// <summary>
/// Records a finished one-run scenario in the per-game completion /// Records a finished one-run scenario in the per-game completion
/// history (<see cref="RuntimeData.CompletedScenarioIds"/>) so the /// history (<see cref="RuntimeData.CompletedScenarioIds"/>) so the
+23
View File
@@ -0,0 +1,23 @@
using Cthangover.Mods.Dialog.Action;
namespace Cthangover.Mods.Dialog
{
/// <summary>
/// Hook a debugging tool installs in <see cref="DialogRuntime.Debugger"/> to
/// stop a dialog before an action runs (pause, step, breakpoints). The
/// runtime asks before every action of every dialog — including standalone
/// runtimes without a <see cref="DialogBox"/> (hover commands) — so the
/// implementation must answer quickly and hold only dialogs it can resume
/// (<see cref="DialogBox.Resume"/>).
/// </summary>
public interface IDialogDebugger
{
/// <summary>
/// Returns true to hold <paramref name="runtime"/> before
/// <paramref name="action"/>: the runtime marks itself
/// <see cref="DialogRuntime.IsHeld"/> and returns without running it;
/// clicks are ignored until <see cref="DialogBox.Resume"/>.
/// </summary>
bool ShouldHold(DialogRuntime runtime, IActionCommand action);
}
}
+4 -1
View File
@@ -159,7 +159,7 @@ namespace Cthangover.Mods.Dialog.Executable
var dlg = new DialogQueue(); var dlg = new DialogQueue();
CreateDialog(dlg); CreateDialog(dlg);
dialogBox.SetDialogQueueAndRun(dlg, null, 0, this); dialogBox.SetDialogQueueAndRun(dlg, null, StartIndex(dlg), this);
IsFirstRun = false; IsFirstRun = false;
return true; return true;
} }
@@ -167,6 +167,9 @@ namespace Cthangover.Mods.Dialog.Executable
protected virtual void CreateDialog(DialogQueue dlg) protected virtual void CreateDialog(DialogQueue dlg)
{ {
} }
/// <summary>Queue index the dialog starts at; 0 unless a subclass starts mid-script.</summary>
protected virtual int StartIndex(DialogQueue dlg) => 0;
} }
} }
+33
View File
@@ -81,6 +81,23 @@ namespace Cthangover.Mods.Dialog.Executable
/// </summary> /// </summary>
public override string ID => ScenarioId ?? base.ID; public override string ID => ScenarioId ?? base.ID;
/// <summary>
/// Mod (or content overlay) the scenario text was read from; null for
/// generated text (test play, inline scenarios). With <see cref="SourcePath"/>
/// it lets debugging tools open and edit the file behind a running event.
/// </summary>
public string SourceModId { get; set; }
/// <summary>Mod-relative path of the scenario file; see <see cref="SourceModId"/>.</summary>
public string SourcePath { get; set; }
/// <summary>
/// Label (<c>:name</c> in the script, without the colon) to start at
/// instead of the first line. Used to restart a scenario from where an
/// edited part begins; an unknown label falls back to the start.
/// </summary>
public string StartLabel { get; set; }
protected override void CreateDialog(DialogQueue dlg) protected override void CreateDialog(DialogQueue dlg)
{ {
if (LightUseTime.HasValue) if (LightUseTime.HasValue)
@@ -111,6 +128,22 @@ namespace Cthangover.Mods.Dialog.Executable
dlg.RuntimeObjectList.Add(obj); dlg.RuntimeObjectList.Add(obj);
} }
/// <summary>Index of the <see cref="StartLabel"/> point in the built queue, or 0.</summary>
protected override int StartIndex(DialogQueue dlg)
{
if (string.IsNullOrEmpty(StartLabel))
return 0;
for (var i = 0; i < dlg.Queue.Count; i++)
{
if (dlg.Queue[i]?.ID == StartLabel)
return i;
}
GameLogger.Log("SCENE", $"ScenarioEvent '{ID}': start label '{StartLabel}' not found, starting from the top", LogLevel.Warning);
return 0;
}
private long _cachedVersion = long.MinValue; private long _cachedVersion = long.MinValue;
private bool _cachedResult; private bool _cachedResult;