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