diff --git a/CHANGELIST.md b/CHANGELIST.md
index 4819a1c1d..af2c6906b 100644
--- a/CHANGELIST.md
+++ b/CHANGELIST.md
@@ -1,3 +1,7 @@
+### WIP (xxxx-xx-xx)
+
+- Add live tool output console for the Linux GUI (gmipf)
+
### 3.8.2 (2026-07-01)
- Output used configuration path for commandline programs
diff --git a/MPF.Avalonia/Helpers/OutputPump.cs b/MPF.Avalonia/Helpers/OutputPump.cs
new file mode 100644
index 000000000..2f9c067d1
--- /dev/null
+++ b/MPF.Avalonia/Helpers/OutputPump.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Channels;
+using System.Threading.Tasks;
+
+namespace MPF.Avalonia.Helpers
+{
+ ///
+ /// Decouples the process-output producer from the (slow) UI consumer with a
+ /// bounded channel, so a firehose of output can never block the reader thread
+ /// or grow memory without bound -- the two failure modes behind the old lag.
+ ///
+ /// - Producer side () is called from the stdout/stderr read
+ /// loops. It never blocks: when the consumer falls behind and the buffer is
+ /// full, the OLDEST queued chunk is dropped ().
+ /// The raw stream is still written to the log file in full elsewhere, so
+ /// dropping only affects the live on-screen tail, never the saved log.
+ /// - Consumer side drains everything currently available in ONE batch
+ /// (), so the UI does a single dispatch per
+ /// flush tick instead of one per line. That is what keeps a high output
+ /// rate from saturating the UI thread.
+ ///
+ public sealed class OutputPump
+ {
+ private readonly Channel _channel;
+
+ public OutputPump(int capacity = 8192)
+ {
+ _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity)
+ {
+ FullMode = BoundedChannelFullMode.DropOldest,
+ SingleReader = true,
+ SingleWriter = false,
+ });
+ }
+
+ ///
+ /// Producer: enqueue a raw output chunk. Never blocks, never throws on full.
+ ///
+ public void Post(string chunk)
+ {
+ // With DropOldest, TryWrite always succeeds for a bounded channel and
+ // silently evicts the oldest item when full -- exactly the backpressure
+ // we want for a live tail. After completion TryWrite returns false, which
+ // we ignore (output arriving after the window closed is simply dropped).
+ _channel.Writer.TryWrite(chunk);
+ }
+
+ ///
+ /// Signal that no more output will arrive.
+ ///
+ public void Complete() => _channel.Writer.TryComplete();
+
+ ///
+ /// Drain every chunk currently queued into one list, without waiting.
+ /// Caller feeds the batch to a and pushes a
+ /// single UI update. caps a single drain so one
+ /// pathological burst cannot monopolize a flush tick.
+ ///
+ public List DrainAvailable(int max = 4096)
+ {
+ var batch = new List();
+ while (batch.Count < max && _channel.Reader.TryRead(out string? chunk))
+ {
+ batch.Add(chunk);
+ }
+
+ return batch;
+ }
+
+ ///
+ /// Await at least one chunk being available (or completion). For the flush loop.
+ ///
+ public ValueTask WaitToReadAsync(CancellationToken ct = default)
+ => _channel.Reader.WaitToReadAsync(ct);
+
+ ///
+ /// Reference flush loop: wait, drain a batch, feed the buffer, raise one
+ /// onBatch callback (with the number of chunks consumed in the batch), then
+ /// throttle. In the GUI this callback marshals to the UI thread once per
+ /// batch. Throttling is what bounds the dispatch rate.
+ ///
+ public async Task RunAsync(TerminalBuffer buffer, Action onBatch, int flushIntervalMs = 75, CancellationToken ct = default)
+ {
+ try
+ {
+ while (await WaitToReadAsync(ct).ConfigureAwait(false))
+ {
+ List batch = DrainAvailable();
+ if (batch.Count > 0)
+ {
+ foreach (string chunk in batch)
+ {
+ buffer.Feed(chunk);
+ }
+
+ onBatch(batch.Count);
+ }
+
+ if (flushIntervalMs > 0)
+ await Task.Delay(flushIntervalMs, ct).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // expected on shutdown
+ }
+
+ // Final drain after completion so the tail is not lost.
+ List tail = DrainAvailable();
+ foreach (string chunk in tail)
+ {
+ buffer.Feed(chunk);
+ }
+
+ buffer.Flush();
+ onBatch(tail.Count);
+ }
+ }
+}
diff --git a/MPF.Avalonia/Helpers/TerminalBuffer.cs b/MPF.Avalonia/Helpers/TerminalBuffer.cs
new file mode 100644
index 000000000..615b07d8c
--- /dev/null
+++ b/MPF.Avalonia/Helpers/TerminalBuffer.cs
@@ -0,0 +1,237 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace MPF.Avalonia.Helpers
+{
+ ///
+ /// Minimal terminal line-discipline for redirected child-process output.
+ ///
+ /// The point: read RAW characters from stdout (never ReadLine) and
+ /// interpret carriage returns the way a real terminal does. Dump tools
+ /// (redumper, DiscImageCreator, Aaru) update their progress with a bare
+ /// \r (no \n) so the same physical line is redrawn in place.
+ /// ReadLine/OutputDataReceived treat \r as a line break,
+ /// which explodes one updating line into thousands of separate lines -- the
+ /// spam + lag the maintainer saw when output was redirected years ago.
+ ///
+ /// Here, \r moves the write cursor to column 0 of the live line
+ /// (overwrite, not new line); \n commits the live line. Progress
+ /// updates therefore mutate a single live line and never grow the log.
+ ///
+ /// No UI dependency: pure and unit-testable. The UI binds the committed lines
+ /// to a virtualized list and the live line to one trailing row.
+ ///
+ public sealed class TerminalBuffer
+ {
+ private readonly StringBuilder _current = new();
+
+ ///
+ /// Write column within
+ ///
+ private int _cursor;
+
+ ///
+ /// Mid ANSI escape sequence (survives chunk boundaries)
+ ///
+ private bool _inEscape;
+
+ private readonly StringBuilder _escape = new();
+
+ ///
+ /// Lines committed so far (each was terminated by \n).
+ ///
+ public List CommittedLines { get; } = new();
+
+ ///
+ /// The live line being assembled after the last \n.
+ ///
+ public string CurrentLine => _current.ToString();
+
+ ///
+ /// Raised once per committed line.
+ ///
+ public event Action? LineCommitted;
+
+ ///
+ /// Raised when the live line changes (printable char, \r overwrite, erase).
+ ///
+ public event Action? CurrentLineChanged;
+
+ ///
+ /// Feed a raw chunk of output. Chunk boundaries are arbitrary: a \r\n
+ /// or an escape sequence may be split across two calls and is handled.
+ ///
+ public void Feed(string? chunk)
+ {
+ if (string.IsNullOrEmpty(chunk))
+ return;
+
+ bool liveChanged = false;
+ for (int i = 0; i < chunk.Length; i++)
+ {
+ char c = chunk[i];
+
+ if (_inEscape)
+ {
+ liveChanged |= FeedEscape(c);
+ continue;
+ }
+
+ switch (c)
+ {
+ case '\x1b': // ESC -- start of an ANSI sequence
+ _inEscape = true;
+ _escape.Clear();
+ break;
+
+ case '\n':
+ CommitLine();
+ break;
+
+ case '\r':
+ _cursor = 0; // carriage return: back to column 0, keep the chars
+ break;
+
+ case '\t':
+ do { WriteChar(' '); } while (_cursor % 8 != 0);
+ liveChanged = true;
+ break;
+
+ case '\b':
+ if (_cursor > 0)
+ _cursor--;
+
+ break;
+
+ default:
+ if (!char.IsControl(c))
+ {
+ WriteChar(c);
+ liveChanged = true;
+ }
+ // other control chars (bell, etc.) are dropped
+ break;
+ }
+ }
+
+ if (liveChanged)
+ CurrentLineChanged?.Invoke(CurrentLine);
+ }
+
+ ///
+ /// Force-commit whatever is in the live line (e.g. on process exit).
+ ///
+ public void Flush()
+ {
+ if (_current.Length > 0)
+ CommitLine();
+ }
+
+ private void WriteChar(char c)
+ {
+ if (_cursor < _current.Length)
+ _current[_cursor] = c;
+ else
+ _current.Append(c);
+
+ _cursor++;
+ }
+
+ private void CommitLine()
+ {
+ string line = _current.ToString();
+ CommittedLines.Add(line);
+ LineCommitted?.Invoke(line);
+ _current.Clear();
+ _cursor = 0;
+ }
+
+ ///
+ /// Consume one character of an in-progress escape sequence. We only model
+ /// the few CSI sequences progress writers actually use; everything else
+ /// (colors, cursor moves) is recognized and stripped so it never shows as
+ /// literal garbage. Returns whether the live line changed.
+ ///
+ private bool FeedEscape(char c)
+ {
+ // First char after ESC decides the family.
+ if (_escape.Length == 0)
+ {
+ if (c == '[')
+ {
+ _escape.Append(c); // CSI -- keep collecting until a final byte
+ return false;
+ }
+
+ // Not a CSI (e.g. ESC( charset, ESC] OSC). We don't model these;
+ // drop ESC + this byte and resume. Good enough for tool output.
+ _inEscape = false;
+ return false;
+ }
+
+ // Inside CSI: parameters/intermediates until a final byte 0x40-0x7E.
+ if (c >= '@' && c <= '~')
+ {
+ bool changed = ApplyCsi(_escape.ToString(1, _escape.Length - 1), c);
+ _inEscape = false;
+ _escape.Clear();
+ return changed;
+ }
+
+ _escape.Append(c);
+ return false;
+ }
+
+ ///
+ /// Apply a CSI sequence given its parameter string and final byte.
+ ///
+ private bool ApplyCsi(string parameters, char final)
+ {
+ switch (final)
+ {
+ case 'K': // EL -- erase in line
+ EraseInLine(ParseInt(parameters, 0));
+ return true;
+
+ case 'G': // CHA -- cursor to absolute column (1-based)
+ _cursor = Math.Max(0, ParseInt(parameters, 1) - 1);
+ return false;
+
+ case 'D': // CUB -- cursor back N
+ _cursor = Math.Max(0, _cursor - Math.Max(1, ParseInt(parameters, 1)));
+ return false;
+
+ default:
+ // SGR colors ('m'), cursor up/down, etc. -- recognized and ignored.
+ return false;
+ }
+ }
+
+ private void EraseInLine(int mode)
+ {
+ switch (mode)
+ {
+ case 0: // cursor to end of line
+ if (_cursor < _current.Length)
+ _current.Length = _cursor;
+
+ break;
+ case 1: // start of line to cursor (blank them, keep length)
+ for (int i = 0; i < _cursor && i < _current.Length; i++)
+ {
+ _current[i] = ' ';
+ }
+
+ break;
+ case 2: // entire line
+ _current.Clear();
+ _cursor = 0;
+ break;
+ }
+ }
+
+ private static int ParseInt(string s, int fallback)
+ => int.TryParse(s, out int v) ? v : fallback;
+ }
+}
diff --git a/MPF.Avalonia/Resources/Strings.de.xaml b/MPF.Avalonia/Resources/Strings.de.xaml
index 0aef46289..cf1537b3d 100644
--- a/MPF.Avalonia/Resources/Strings.de.xaml
+++ b/MPF.Avalonia/Resources/Strings.de.xaml
@@ -10,6 +10,8 @@
Abbrechen
Minimieren
Schließen
+ Kopieren
+ Alles auswählen
Einstellungen
Status
WARNUNG:
@@ -49,6 +51,10 @@
Löschen
Speichern
+
+ Programmausgabe
+ Dieses Fenster schließen, wenn das Programm beendet ist
+
Optionen
Willkommen bei MPF, erkunden Sie die Optionen
diff --git a/MPF.Avalonia/Resources/Strings.xaml b/MPF.Avalonia/Resources/Strings.xaml
index 28ee3739f..d64273785 100644
--- a/MPF.Avalonia/Resources/Strings.xaml
+++ b/MPF.Avalonia/Resources/Strings.xaml
@@ -10,6 +10,8 @@
Cancel
Minimize
Close
+ Copy
+ Select All
Settings
Status
WARNING:
@@ -50,6 +52,10 @@
Clear Log
Save Log
+
+ Program Output
+ Close this window when the program exits
+
Options
Welcome to MPF, Explore the Options
diff --git a/MPF.Avalonia/Services/ToolOutputConsole.cs b/MPF.Avalonia/Services/ToolOutputConsole.cs
new file mode 100644
index 000000000..5e1a7d790
--- /dev/null
+++ b/MPF.Avalonia/Services/ToolOutputConsole.cs
@@ -0,0 +1,128 @@
+using System;
+using global::Avalonia;
+using global::Avalonia.Controls;
+using global::Avalonia.Platform;
+using MPF.Avalonia.Windows;
+using MPF.Frontend;
+using MPF.Frontend.Tools;
+
+namespace MPF.Avalonia.Services
+{
+ ///
+ /// Bridges the frontend's seam to an Avalonia
+ /// .
+ ///
+ ///
+ /// Only created on platforms that need an in-app console for the dumping tool (Linux,
+ /// where the tool has no console window of its own). The window is shown modeless and
+ /// owned, so the MPF main window keeps working and its Stop button stays reachable.
+ ///
+ public sealed class ToolOutputConsole : IToolOutputConsole
+ {
+ ///
+ /// Gap between the main window and the tool-output window, in device-independent pixels
+ ///
+ private const double PlacementGap = 8;
+
+ private readonly Window _owner;
+ private readonly Options _options;
+ private ToolOutputWindow? _window;
+
+ public ToolOutputConsole(Window owner, Options options)
+ {
+ _owner = owner;
+ _options = options;
+ }
+
+ ///
+ public void Open()
+ {
+ // Discard any leftover console from a previous run (kept open because the user
+ // turned off auto-close) so each dump starts with a clean console.
+ _window?.Close();
+ _window = null;
+
+ var window = new ToolOutputWindow(_options.GUI.ToolConsoleAutoClose);
+ window.AutoCloseChanged += OnAutoCloseChanged;
+ window.Closed += (_, _) =>
+ {
+ if (ReferenceEquals(_window, window))
+ _window = null;
+ };
+ _window = window;
+
+ TryPositionBesideOwner(window);
+ window.Show(_owner);
+ }
+
+ ///
+ public void Append(string chunk) => _window?.Append(chunk);
+
+ ///
+ public void NotifyToolExited() => _window?.NotifyToolExited();
+
+ ///
+ /// Persist the user's auto-close preference whenever the checkbox is toggled
+ ///
+ private void OnAutoCloseChanged(bool autoClose)
+ {
+ _options.GUI.ToolConsoleAutoClose = autoClose;
+ OptionsLoader.SaveToConfig(_options);
+ }
+
+ ///
+ /// Place the console beside the main window on whichever side has room, falling back
+ /// to overlaying the main window; always clamped fully inside the monitor work area.
+ ///
+ private void TryPositionBesideOwner(ToolOutputWindow window)
+ {
+ try
+ {
+ Screen? screen = _owner.Screens.ScreenFromWindow(_owner) ?? _owner.Screens.Primary;
+ if (screen is null)
+ {
+ window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
+ return;
+ }
+
+ double scale = screen.Scaling;
+ PixelRect area = screen.WorkingArea;
+
+ // Owner frame and the new window, converted to physical pixels.
+ Size ownerSize = _owner.FrameSize ?? _owner.Bounds.Size;
+ PixelPoint ownerPos = _owner.Position;
+ int ownerW = (int)Math.Round(ownerSize.Width * scale);
+ int winW = (int)Math.Round(window.Width * scale);
+ int winH = (int)Math.Round(window.Height * scale);
+ int gap = (int)Math.Round(PlacementGap * scale);
+
+ int rightRoom = (area.X + area.Width) - (ownerPos.X + ownerW);
+ int leftRoom = ownerPos.X - area.X;
+
+ int x;
+ if (rightRoom >= winW + gap)
+ x = ownerPos.X + ownerW + gap; // beside, to the right
+ else if (leftRoom >= winW + gap)
+ x = ownerPos.X - gap - winW; // beside, to the left
+ else
+ x = ownerPos.X + (ownerW - winW) / 2; // overlay, centered on the owner
+
+ int y = ownerPos.Y; // align tops
+
+ // Clamp the whole window inside the work area so it never lands off-screen.
+ int maxX = (area.X + area.Width) - winW;
+ int maxY = (area.Y + area.Height) - winH;
+ x = Math.Max(area.X, Math.Min(x, maxX));
+ y = Math.Max(area.Y, Math.Min(y, maxY));
+
+ window.WindowStartupLocation = WindowStartupLocation.Manual;
+ window.Position = new PixelPoint(x, y);
+ }
+ catch
+ {
+ // Any platform quirk in the geometry math -> just center on the owner.
+ window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
+ }
+ }
+ }
+}
diff --git a/MPF.Avalonia/Windows/MainWindow.axaml.cs b/MPF.Avalonia/Windows/MainWindow.axaml.cs
index 687ea119e..677e45c53 100644
--- a/MPF.Avalonia/Windows/MainWindow.axaml.cs
+++ b/MPF.Avalonia/Windows/MainWindow.axaml.cs
@@ -116,10 +116,18 @@ private void OnOpened(object? sender, EventArgs e)
if (MainViewModel.Options.GUI.ShowDebugViewMenuItem)
DebugViewMenuItem!.IsVisible = true;
+ // On Linux the dumping tool has no console window of its own, so stream its
+ // live output into a separate MPF window. Windows/macOS keep the tool's own
+ // console (leave this null), preserving the original behavior.
+ IToolOutputConsole? toolConsole = OperatingSystem.IsLinux()
+ ? new ToolOutputConsole(this, MainViewModel.Options)
+ : null;
+
MainViewModel.Init(
LogOutput!.EnqueueLog,
DisplayUserMessage,
- ShowMediaInformationWindow);
+ ShowMediaInformationWindow,
+ toolConsole);
// Pass translation strings to MainViewModel
var translationStrings = new Dictionary
diff --git a/MPF.Avalonia/Windows/ToolOutputWindow.axaml b/MPF.Avalonia/Windows/ToolOutputWindow.axaml
new file mode 100644
index 000000000..da7d87c86
--- /dev/null
+++ b/MPF.Avalonia/Windows/ToolOutputWindow.axaml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MPF.Avalonia/Windows/ToolOutputWindow.axaml.cs b/MPF.Avalonia/Windows/ToolOutputWindow.axaml.cs
new file mode 100644
index 000000000..52b7c7122
--- /dev/null
+++ b/MPF.Avalonia/Windows/ToolOutputWindow.axaml.cs
@@ -0,0 +1,292 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using global::Avalonia.Controls;
+using global::Avalonia.Controls.Primitives;
+using global::Avalonia.Input;
+using global::Avalonia.Input.Platform;
+using global::Avalonia.Interactivity;
+using global::Avalonia.Layout;
+using global::Avalonia.Threading;
+using global::Avalonia.VisualTree;
+using MPF.Avalonia.Helpers;
+
+namespace MPF.Avalonia.Windows
+{
+ ///
+ /// Separate window that streams a dumping tool's live standard output and error.
+ ///
+ ///
+ /// Shown modeless (via Show(owner)) so the MPF main window never freezes and
+ /// its Stop button stays reachable. Raw output chunks arrive on
+ /// from background reader threads; a bounded batches them and
+ /// a applies terminal line-discipline so a tool's bare
+ /// \r progress redraws collapse onto a single live line instead of flooding the
+ /// scrollback. Committed lines feed a virtualized list with a CMD-style fixed cap.
+ ///
+ public partial class ToolOutputWindow : Window
+ {
+ ///
+ /// CMD-style fixed scrollback: oldest lines are trimmed past this cap
+ ///
+ private const int MaxLines = 10_000;
+
+ ///
+ /// One batched UI dispatch per ~60 fps frame
+ ///
+ private const int FlushIntervalMs = 16;
+
+ private readonly ObservableCollection _lines = new();
+ private readonly TerminalBuffer _buffer = new();
+ private readonly OutputPump _pump = new(capacity: 8192);
+ private readonly CancellationTokenSource _cts = new();
+ private readonly Task _pumpTask;
+
+ ///
+ /// Committed lines already pushed to the UI
+ ///
+ private int _committedIndex;
+
+ ///
+ /// Follow the tail; toggled only by user input
+ ///
+ private bool _autoScroll = true;
+
+ ///
+ /// Coalesces deferred ScrollToEnd posts
+ ///
+ private bool _scrollQueued;
+
+ ///
+ /// Vertical scrollbar Scroll event subscribed
+ ///
+ private bool _scrollBarHooked;
+
+ ///
+ /// ListBox's inner scroll viewer (found lazily)
+ ///
+ private ScrollViewer? _scroller;
+
+ ///
+ /// Raised when the user toggles the auto-close checkbox, so the host can persist it
+ ///
+ public event Action? AutoCloseChanged;
+
+ public ToolOutputWindow() : this(autoClose: true) { }
+
+ public ToolOutputWindow(bool autoClose)
+ {
+ InitializeComponent();
+
+ LinesList.ItemsSource = _lines;
+
+ CopyMenuItem.Click += (_, _) => CopySelectionToClipboard();
+ SelectAllMenuItem.Click += (_, _) => LinesList.SelectAll();
+ // Window-level so Ctrl+C / Ctrl+A work without giving the (deliberately
+ // non-focusable) log rows keyboard focus.
+ KeyDown += OnKeyDown;
+
+ AutoCloseCheck.IsChecked = autoClose;
+ AutoCloseCheck.IsCheckedChanged += (_, _) => AutoCloseChanged?.Invoke(AutoCloseCheck.IsChecked == true);
+
+ // The pump runs for the lifetime of the window, draining whatever Append posts.
+ _pumpTask = _pump.RunAsync(_buffer, OnBatch, FlushIntervalMs, _cts.Token);
+ }
+
+ ///
+ /// Whether the window should close itself when the tool exits
+ ///
+ public bool AutoClose
+ {
+ get => AutoCloseCheck.IsChecked == true;
+ set => AutoCloseCheck.IsChecked = value;
+ }
+
+ ///
+ /// Push a raw chunk of tool output. Thread-safe: called from reader threads.
+ ///
+ public void Append(string chunk) => _pump.Post(chunk);
+
+ ///
+ /// The tool process exited: flush the remaining output, then close if the user
+ /// opted in. Safe to call on the UI thread.
+ ///
+ public async void NotifyToolExited()
+ {
+ // Complete the pump so its final drain flushes the tail to the UI, then wait
+ // for that drain to finish before deciding whether to close.
+ _pump.Complete();
+ try
+ {
+ await _pumpTask;
+ }
+ catch
+ {
+ // shutdown races are fine
+ }
+
+ if (AutoCloseCheck.IsChecked == true)
+ Close();
+ }
+
+ ///
+ /// Ctrl+C / context-menu Copy: copy the selected log lines as plain text.
+ /// Ctrl+A selects every committed line.
+ ///
+ private void OnKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.C)
+ {
+ CopySelectionToClipboard();
+ e.Handled = true;
+ }
+ else if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.A)
+ {
+ LinesList.SelectAll();
+ e.Handled = true;
+ }
+ }
+
+ ///
+ /// Copies the selected rows to the clipboard, newline-joined and in top-to-bottom
+ /// (source) order regardless of the order they were clicked.
+ ///
+ private async void CopySelectionToClipboard()
+ {
+ IReadOnlyList selected = LinesList.Selection?.SelectedIndexes ?? Array.Empty();
+ if (selected.Count == 0)
+ return;
+
+ var sb = new StringBuilder(selected.Count * 48);
+ foreach (int idx in selected.OrderBy(i => i))
+ {
+ if (idx >= 0 && idx < _lines.Count)
+ sb.Append(_lines[idx]);
+ sb.Append('\n');
+ }
+ if (sb.Length > 0 && sb[^1] == '\n')
+ sb.Length -= 1; // drop the trailing newline
+
+ IClipboard? clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is not null)
+ await clipboard.SetTextAsync(sb.ToString());
+ }
+
+ ///
+ /// Runs on the consumer thread after a batch was fed to the buffer. Snapshot the
+ /// new committed lines + the current live line, then do exactly ONE dispatch to
+ /// the UI thread for the whole batch.
+ ///
+ private void OnBatch(int consumed)
+ {
+ List? newLines = null;
+ List committed = _buffer.CommittedLines;
+ if (_committedIndex < committed.Count)
+ {
+ newLines = new List(committed.Count - _committedIndex);
+ for (; _committedIndex < committed.Count; _committedIndex++)
+ {
+ newLines.Add(committed[_committedIndex]);
+ }
+ }
+
+ string live = _buffer.CurrentLine;
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ EnsureScroller();
+
+ if (newLines is not null)
+ {
+ foreach (string line in newLines)
+ {
+ _lines.Add(line);
+ }
+ }
+
+ // CMD-style fixed scrollback: always drop the oldest lines past the cap.
+ while (_lines.Count > MaxLines)
+ {
+ _lines.RemoveAt(0);
+ }
+
+ LiveLine.Text = live;
+
+ if (_autoScroll && _scroller is not null && !_scrollQueued)
+ {
+ _scrollQueued = true;
+ // Defer past the layout pass so the extent is fresh and we land on the bottom.
+ Dispatcher.UIThread.Post(() =>
+ {
+ _scrollQueued = false;
+ if (_autoScroll)
+ _scroller!.ScrollToEnd();
+ }, DispatcherPriority.Background);
+ }
+ });
+ }
+
+ ///
+ /// Lazily grab the ListBox's inner ScrollViewer (only exists after the control is
+ /// templated) and subscribe to its scroll changes for tail-follow tracking.
+ ///
+ private void EnsureScroller()
+ {
+ if (_scroller is null)
+ {
+ _scroller = LinesList.FindDescendantOfType();
+ if (_scroller is not null)
+ LinesList.PointerWheelChanged += OnWheel;
+ }
+
+ // The vertical ScrollBar realizes slightly later than the ScrollViewer.
+ if (_scroller is not null && !_scrollBarHooked)
+ {
+ ScrollBar? vbar = _scroller.GetVisualDescendants()
+ .OfType()
+ .FirstOrDefault(sb => sb.Orientation == Orientation.Vertical);
+ if (vbar is not null)
+ {
+ vbar.Scroll += OnUserScroll;
+ _scrollBarHooked = true;
+ }
+ }
+ }
+
+ ///
+ /// Tail-follow is toggled ONLY by genuine user input: the scrollbar's Scroll event
+ /// (thumb drag / track / page) and the mouse wheel. Neither fires for the
+ /// programmatic ScrollToEnd or for the ring-buffer trim -- so following never drops
+ /// on its own under fast output. Position then decides direction: at/near the
+ /// bottom = follow, else not.
+ ///
+ private void OnUserScroll(object? sender, ScrollEventArgs e)
+ => SyncFollowToPosition();
+
+ private void OnWheel(object? sender, PointerWheelEventArgs e)
+ // The wheel scroll is applied after this event fires; check once it has.
+ => Dispatcher.UIThread.Post(SyncFollowToPosition, DispatcherPriority.Background);
+
+ private void SyncFollowToPosition()
+ {
+ if (_scroller is null)
+ return;
+
+ double distanceFromBottom = _scroller.Extent.Height - _scroller.Viewport.Height - _scroller.Offset.Y;
+ _autoScroll = distanceFromBottom <= 4;
+ }
+
+ protected override void OnClosed(EventArgs e)
+ {
+ // Best-effort shutdown; if the tool is still running its output is simply
+ // dropped from here on (Post on a completed channel is a no-op).
+ _cts.Cancel();
+ _pump.Complete();
+ base.OnClosed(e);
+ }
+ }
+}
diff --git a/MPF.ExecutionContexts/BaseExecutionContext.cs b/MPF.ExecutionContexts/BaseExecutionContext.cs
index 872a765d5..d3adaec99 100644
--- a/MPF.ExecutionContexts/BaseExecutionContext.cs
+++ b/MPF.ExecutionContexts/BaseExecutionContext.cs
@@ -2,7 +2,9 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
+using System.IO;
using System.Text.RegularExpressions;
+using System.Threading;
using SabreTools.RedumpLib.Data;
namespace MPF.ExecutionContexts
@@ -45,6 +47,18 @@ public bool? this[string key]
///
private Process? process;
+ ///
+ /// Optional sink for the tool's raw standard output and error
+ ///
+ ///
+ /// When set, the program is launched with its output redirected and streamed
+ /// here in raw chunks (carriage returns preserved), instead of letting the tool
+ /// draw its own console window. Used by a GUI frontend to display live tool
+ /// output on platforms without an external console. Leave null for the legacy
+ /// behavior, where the tool owns its console window (UseShellExecute).
+ ///
+ public Action? OutputReceived { get; set; }
+
#endregion
#region Virtual Dumping Information
@@ -195,15 +209,20 @@ protected abstract void SetDefaultParameters(string? drivePath,
///
public void ExecuteInternalProgram()
{
+ // When no output sink is attached, keep the original behavior exactly: the
+ // tool runs in its own console window (UseShellExecute) with no redirection.
+ // When a sink is attached, redirect output and stream it to the caller.
+ bool redirect = OutputReceived is not null;
+
// Create the start info
var startInfo = new ProcessStartInfo()
{
FileName = ExecutablePath!,
Arguments = GenerateParameters() ?? "",
- CreateNoWindow = false,
- UseShellExecute = true,
- RedirectStandardOutput = false,
- RedirectStandardError = false,
+ CreateNoWindow = redirect,
+ UseShellExecute = !redirect,
+ RedirectStandardOutput = redirect,
+ RedirectStandardError = redirect,
};
// Create the new process
@@ -211,10 +230,63 @@ public void ExecuteInternalProgram()
// Start the process
process.Start();
- process.WaitForExit();
+
+ if (redirect)
+ {
+ // Pump stdout and stderr on dedicated threads using RAW reads (never
+ // ReadLine): dump tools redraw progress with a bare '\r', and ReadLine
+ // would treat every '\r' as a new line -- the exact spam/lag that made
+ // redirected output unusable before. The consumer interprets '\r'.
+ Thread outThread = StartStreamPump(process.StandardOutput);
+ Thread errThread = StartStreamPump(process.StandardError);
+
+ process.WaitForExit();
+ outThread.Join();
+ errThread.Join();
+ }
+ else
+ {
+ process.WaitForExit();
+ }
+
process.Close();
}
+ ///
+ /// Start a background thread that copies a redirected stream to
+ /// in raw chunks
+ ///
+ ///
+ /// Blocking reads on a dedicated thread keep this compatible across every target
+ /// framework (no async or channels needed here). Multiple pumps may run at once;
+ /// the sink is expected to be safe to call from several threads.
+ ///
+ private Thread StartStreamPump(StreamReader reader)
+ {
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ var buffer = new char[4096];
+ int read;
+ while ((read = reader.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ OutputReceived?.Invoke(new string(buffer, 0, read));
+ }
+ }
+ catch
+ {
+ // The stream is closed when the process exits or is killed; nothing to do.
+ }
+ })
+ {
+ IsBackground = true,
+ Name = "MPF tool output pump",
+ };
+ thread.Start();
+ return thread;
+ }
+
///
/// Cancel an in-progress dumping process
///
diff --git a/MPF.Frontend/DumpEnvironment.cs b/MPF.Frontend/DumpEnvironment.cs
index a6a1879a4..6dda32b91 100644
--- a/MPF.Frontend/DumpEnvironment.cs
+++ b/MPF.Frontend/DumpEnvironment.cs
@@ -97,6 +97,17 @@ public int? Speed
///
public EventHandler? ReportStatus;
+ ///
+ /// Optional sink for the dumping tool's raw output
+ ///
+ ///
+ /// When set, the tool runs with its standard output and error redirected and
+ /// streamed here. Used by a GUI frontend to show live tool output in a separate
+ /// window on platforms without an external console. Leave null for the legacy
+ /// behavior, where the tool owns its own console window.
+ ///
+ public Action? ToolOutputReceived { get; set; }
+
#endregion
///
@@ -489,6 +500,10 @@ public async Task Run(PhysicalMediaType? mediaType, IProgress { _executionContext.ExecuteInternalProgram(); return true; });
#else
diff --git a/MPF.Frontend/IToolOutputConsole.cs b/MPF.Frontend/IToolOutputConsole.cs
new file mode 100644
index 000000000..83523c875
--- /dev/null
+++ b/MPF.Frontend/IToolOutputConsole.cs
@@ -0,0 +1,39 @@
+namespace MPF.Frontend
+{
+ ///
+ /// Abstraction for a live tool-output console shown by a GUI frontend while a
+ /// dumping program runs.
+ ///
+ ///
+ /// Implemented by a frontend that can display the tool's redirected output in its
+ /// own surface (for example, a separate window on Linux, where the dumping tool has
+ /// no console window of its own). Frontends that rely on the tool drawing its own
+ /// console window (Windows) leave this null, preserving the original behavior.
+ ///
+ public interface IToolOutputConsole
+ {
+ ///
+ /// Show the console. Called on the UI thread just before the tool starts.
+ ///
+ void Open();
+
+ ///
+ /// Receive a raw chunk of the tool's standard output or error.
+ ///
+ /// Raw output chunk, with carriage returns preserved
+ ///
+ /// Thread-safe: called from background reader threads, so implementations must
+ /// not touch UI state directly here.
+ ///
+ void Append(string chunk);
+
+ ///
+ /// Signal that the tool process has exited. Called on the UI thread.
+ ///
+ ///
+ /// The implementation closes the console or leaves it open per the user's
+ /// preference.
+ ///
+ void NotifyToolExited();
+ }
+}
diff --git a/MPF.Frontend/Options.cs b/MPF.Frontend/Options.cs
index f4c8ef5f3..f4aa34010 100644
--- a/MPF.Frontend/Options.cs
+++ b/MPF.Frontend/Options.cs
@@ -88,6 +88,7 @@ public Options(Options? source)
GUI.CopyUpdateUrlToClipboard = source.GUI.CopyUpdateUrlToClipboard;
GUI.OpenLogWindowAtStartup = source.GUI.OpenLogWindowAtStartup;
+ GUI.ToolConsoleAutoClose = source.GUI.ToolConsoleAutoClose;
GUI.DefaultInterfaceLanguage = source.GUI.DefaultInterfaceLanguage;
GUI.ShowDebugViewMenuItem = source.GUI.ShowDebugViewMenuItem;
@@ -444,6 +445,12 @@ public class GuiSettings
/// Version 1 and greater
public bool OpenLogWindowAtStartup { get; set; } = true;
+ ///
+ /// Automatically close the separate tool-output window when the dumping program exits
+ ///
+ /// Version 1 and greater; currently used by the Linux GUI tool-output window
+ public bool ToolConsoleAutoClose { get; set; } = true;
+
#endregion
#region Interface
diff --git a/MPF.Frontend/Tools/OptionsLoader.cs b/MPF.Frontend/Tools/OptionsLoader.cs
index 1918b9aff..82ffe72a9 100644
--- a/MPF.Frontend/Tools/OptionsLoader.cs
+++ b/MPF.Frontend/Tools/OptionsLoader.cs
@@ -367,6 +367,7 @@ private static Options ConvertFromDictionary(Dictionary? source
options.GUI.CopyUpdateUrlToClipboard = GetBooleanSetting(source, "CopyUpdateUrlToClipboard", true);
options.GUI.OpenLogWindowAtStartup = GetBooleanSetting(source, "OpenLogWindowAtStartup", true);
+ options.GUI.ToolConsoleAutoClose = GetBooleanSetting(source, "ToolConsoleAutoClose", true);
valueString = GetStringSetting(source, "DefaultInterfaceLanguage", InterfaceLanguage.AutoDetect.ShortName());
options.GUI.DefaultInterfaceLanguage = valueString.ToInterfaceLanguage();
@@ -541,6 +542,7 @@ private static Options ConvertFromDictionary(Dictionary? source
{ "VerboseLogging", options.VerboseLogging.ToString() },
{ "OpenLogWindowAtStartup", options.GUI.OpenLogWindowAtStartup.ToString() },
+ { "ToolConsoleAutoClose", options.GUI.ToolConsoleAutoClose.ToString() },
{ "RetrieveMatchInformation", options.Processing.Login.RetrieveMatchInformation.ToString() },
{ "RedumpOrgUsername", options.Processing.Login.RedumpOrgUsername },
diff --git a/MPF.Frontend/ViewModels/MainViewModel.cs b/MPF.Frontend/ViewModels/MainViewModel.cs
index 18d9e1a20..a93e79db2 100644
--- a/MPF.Frontend/ViewModels/MainViewModel.cs
+++ b/MPF.Frontend/ViewModels/MainViewModel.cs
@@ -71,6 +71,11 @@ public Options Options
///
private ProcessUserInfoDelegate? _processUserInfo;
+ ///
+ /// Optional live tool-output console supplied by the frontend (null if unused)
+ ///
+ private IToolOutputConsole? _toolConsole;
+
#endregion
#region Properties
@@ -597,12 +602,14 @@ public MainViewModel()
public void Init(
Action loggerAction,
Func displayUserMessage,
- ProcessUserInfoDelegate processUserInfo)
+ ProcessUserInfoDelegate processUserInfo,
+ IToolOutputConsole? toolConsole = null)
{
// Set the callbacks
_logger = loggerAction;
_displayUserMessage = displayUserMessage;
_processUserInfo = processUserInfo;
+ _toolConsole = toolConsole;
// Finish initializing the rest of the values
InitializeUIValues(removeEventHandlers: false, rebuildPrograms: true, rescanDrives: true);
@@ -2259,9 +2266,20 @@ public async void StartDumping()
protectionProgress.ProgressChanged += ProgressUpdated;
_environment.ReportStatus += ProgressUpdated;
+ // Stream live tool output to the separate console window, if the frontend
+ // provides one (Linux GUI). Harmless no-op for frontends that do not.
+ if (_toolConsole is not null)
+ {
+ _toolConsole.Open();
+ _environment.ToolOutputReceived = _toolConsole.Append;
+ }
+
// Run the program with the parameters
ResultEventArgs result = await _environment.Run(CurrentPhysicalMediaType, resultProgress);
+ // The tool has exited; let the console close itself per the user's preference
+ _toolConsole?.NotifyToolExited();
+
// If we didn't execute a dumping command we cannot get submission output
if (!_environment.IsDumpingCommand())
{