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
+222 -46

No files matched your search

+2
View File
@@ -44,6 +44,8 @@ namespace Cthangover.Mods.Dialog.Action
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>
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>
/// Scene node lookup seam for subclasses. Replaces direct
+6
View File
@@ -39,6 +39,12 @@ namespace Cthangover.Mods.Dialog.Action
float WaitTime { get; }
/// <summary>Timestamp of the last <see cref="Run"/> call, in seconds since epoch.</summary>
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
+36 -2
View File
@@ -209,7 +209,7 @@ namespace Cthangover.Mods.Dialog
/// </summary>
public void NextAction()
{
if (Runtime.IsWaitAnswer)
if (Runtime.IsHeld || Runtime.IsWaitAnswer)
return;
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>
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
}
}
/// <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>
public bool IsRunning => Locker != null;
+52 -1
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>
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>
public string GetVariable(string name)
{
@@ -122,6 +142,7 @@ namespace Cthangover.Mods.Dialog
{
variables = new Dictionary<string, string>();
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
}
}
/// <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>
/// Records a finished one-run scenario in the per-game completion
/// 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();
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)
{
}
/// <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>
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)
{
if (LightUseTime.HasValue)
@@ -111,6 +128,22 @@ namespace Cthangover.Mods.Dialog.Executable
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 bool _cachedResult;