Let MMTTY hold the text waiting to go out, behind a setting

MMTTY has a type-ahead buffer of its own: characters go into it, a backspace
takes back one it has not transmitted, and TxBufLen says how many are left. Used
that way it paces itself, so there is no gap between characters to tune and no
baud rate to keep in step with the engine.

EngineTypeAhead does that, and Config > Digital picks between it and the pump
that is there now. Off is still the default: three things it rests on have never
been seen with a real engine.

tools/Nonemm.EngineProbe asks the engine those three questions and writes the
answers to a file. It starts MMTTY through the bridge, pushes a message, polls
TxBufLen while it goes out, backspaces over text that has and has not been
transmitted, and logs what came back on the receive side and when.

The bridge learns one verb for it: `buffer` reads TxBufLen and answers with the
count, or -1 when the control will not say. A property the control does not know
is a log line rather than an error, since it stops nothing.

TypeAhead and EngineTypeAhead share the TransmitBuffer interface, which is what
the digital window now works through, so the window does not know which one it
has.

docs/unfinished.md states what each buffer assumes and how to run the probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
This commit is contained in:
2026-09-01 22:41:18 +00:00
parent da7f7fb3f5
commit 54b9181c06
25 changed files with 1311 additions and 68 deletions

View File

@@ -0,0 +1,269 @@
using System.Text;
using Nonemm.Digital;
namespace Nonemm.EngineProbe;
/// Four questions about MMTTY that cannot be answered without MMTTY running,
/// asked in order and written to the report:
///
/// 1. Does the control answer `TxBufLen`, and is a name it does not know
/// distinguishable from one it does?
/// 2. While a message is going out, does that number count down at the
/// character rate? If it does, it says exactly how much of the message is
/// still in the engine and can still be taken back.
/// 3. Does a backspace pushed in as a character delete a character the engine
/// has not transmitted yet, and what happens to backspaces over characters
/// that have already gone? Those have to be refused: text typed after them
/// would otherwise go out twice.
/// 4. What comes back on the receive side while transmitting, and when? With
/// the sound loopback off that is MMTTY echoing its transmit window; with it
/// on it is the demodulator hearing the transmission.
///
/// The answers decide whether the transmit buffer can be handed to MMTTY
/// instead of being paced from here. `docs/unfinished.md` states what each one
/// means.
public sealed class EngineProbe
{
/// Long enough for a character at 45.45 baud, short enough to see the count
/// move.
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
/// A message at 45.45 baud takes a few seconds; this is well past the end
/// of one.
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
private const string Message = "CQ TEST DE OM5M OM5M";
private const string Alphabet = "ABCDEFGHIJKLMNOP";
private const int Backspaces = 4;
private const char Backspace = '\b';
private const string Short = "MNOPQRST";
private const int TooManyBackspaces = 6;
private const string Afterwards = "XX";
private readonly MmttyEngine engine;
private readonly ProbeLog log;
private readonly StringBuilder received = new();
private readonly Lock gate = new();
private TaskCompletionSource<int>? asking;
public EngineProbe(MmttyEngine engine, ProbeLog log)
{
this.engine = engine;
this.log = log;
engine.Reported += (_, what) => log.Write($"bridge: {what}");
engine.TransmitChanged += (_, on) => log.Write(on ? "engine keyed" : "engine unkeyed");
engine.Buffered += WhenBuffered;
engine.Received += WhenReceived;
}
public async Task RunAsync(CancellationToken cancellation)
{
await NamesAsync(cancellation).ConfigureAwait(false);
await IdleAsync(cancellation).ConfigureAwait(false);
await MessageAsync(cancellation).ConfigureAwait(false);
await BackspaceAsync(cancellation).ConfigureAwait(false);
await TooLateAsync(cancellation).ConfigureAwait(false);
}
/// Question 1. `DISP_E_UNKNOWNNAME` is 0x80020006; a name the control knows
/// answers with a number instead.
private async Task NamesAsync(CancellationToken cancellation)
{
log.Step("1. does the control answer TxBufLen");
log.Write($"TxBufLen: {Answer(await AskAsync("TxBufLen", cancellation).ConfigureAwait(false))}");
log.Write($"NotAProperty: {Answer(await AskAsync("NotAProperty", cancellation).ConfigureAwait(false))}");
log.Write("a number for the first and no answer for the second is what makes the count usable");
}
/// The number with nothing to transmit, which is what the count has to
/// start and end at.
private async Task IdleAsync(CancellationToken cancellation)
{
log.Step("2. TxBufLen with nothing to send");
for (int look = 0; look < 3; look++)
{
log.Write($"TxBufLen: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}");
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
}
}
/// Questions 2 and 4. The whole message is pushed as fast as the bridge
/// takes it, so what the count does afterwards is the engine transmitting
/// rather than the probe feeding.
private async Task MessageAsync(CancellationToken cancellation)
{
log.Step($"3. \"{Message}\" pushed in one go, then TxBufLen every {PollInterval.TotalMilliseconds} ms");
TakeReceived();
await engine.SetPttAsync(true, cancellation).ConfigureAwait(false);
await TypeAsync(Message, cancellation).ConfigureAwait(false);
log.Write($"pushed {Message.Length} characters");
await DrainAsync(cancellation).ConfigureAwait(false);
log.Write($"received while transmitting: \"{TakeReceived()}\"");
await engine.SetPttAsync(false, cancellation).ConfigureAwait(false);
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
}
/// Question 3. The wait is there so the engine is partway through the
/// alphabet when the backspaces arrive: the ones over transmitted
/// characters are the ones MMTTY has to refuse.
private async Task BackspaceAsync(CancellationToken cancellation)
{
log.Step($"4. \"{Alphabet}\", then {Backspaces} backspaces once it is under way");
TakeReceived();
await engine.SetPttAsync(true, cancellation).ConfigureAwait(false);
await TypeAsync(Alphabet, cancellation).ConfigureAwait(false);
log.Write($"pushed {Alphabet.Length} characters");
await Task.Delay(TimeSpan.FromSeconds(1), cancellation).ConfigureAwait(false);
int before = await AskAsync("", cancellation).ConfigureAwait(false);
log.Write($"TxBufLen before the backspaces: {Answer(before)}");
await TypeAsync(new string(Backspace, Backspaces), cancellation).ConfigureAwait(false);
int after = await AskAsync("", cancellation).ConfigureAwait(false);
log.Write($"TxBufLen after the backspaces: {Answer(after)}");
log.Write($"a drop of {Backspaces} means the engine took them out of its buffer");
await DrainAsync(cancellation).ConfigureAwait(false);
string went = TakeReceived();
log.Write($"received while transmitting: \"{went}\"");
log.Write($"the last {Backspaces} letters of \"{Alphabet}\" are the ones that should be missing");
await engine.SetPttAsync(false, cancellation).ConfigureAwait(false);
}
/// The other half of question 3: more backspaces than there are characters
/// left to take back. If the extra ones are ignored, what goes out is the
/// transmitted part followed by the new text. If they do something else,
/// this is where it shows.
private async Task TooLateAsync(CancellationToken cancellation)
{
log.Step($"5. \"{Short}\", then {TooManyBackspaces} backspaces near the end of it");
TakeReceived();
await engine.SetPttAsync(true, cancellation).ConfigureAwait(false);
await TypeAsync(Short, cancellation).ConfigureAwait(false);
int left = await WaitForAsync(2, cancellation).ConfigureAwait(false);
log.Write($"TxBufLen when the backspaces go in: {Answer(left)}");
await TypeAsync(new string(Backspace, TooManyBackspaces), cancellation).ConfigureAwait(false);
log.Write($"TxBufLen after them: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}");
await TypeAsync(Afterwards, cancellation).ConfigureAwait(false);
await DrainAsync(cancellation).ConfigureAwait(false);
log.Write($"received while transmitting: \"{TakeReceived()}\"");
log.Write($"what went out should end in \"{Afterwards}\" once, not twice");
await engine.SetPttAsync(false, cancellation).ConfigureAwait(false);
}
/// Waits until the engine holds no more than `wanted` characters, so the
/// next thing the probe does lands at a known point in the message.
private async Task<int> WaitForAsync(int wanted, CancellationToken cancellation)
{
DateTime giveUp = DateTime.UtcNow + Patience;
while (DateTime.UtcNow < giveUp)
{
int left = await AskAsync("", cancellation).ConfigureAwait(false);
if (left < 0)
{
log.Write($"no answer to wait on, guessing at {Short.Length - wanted} characters gone");
await Task.Delay(TimeSpan.FromSeconds(1), cancellation).ConfigureAwait(false);
return left;
}
if (left <= wanted)
{
return left;
}
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
}
return -1;
}
/// Polls until the engine says it has nothing left, or until the patience
/// runs out. A control that does not answer has nothing to poll, so the
/// wait is the whole of a message instead.
private async Task DrainAsync(CancellationToken cancellation)
{
DateTime giveUp = DateTime.UtcNow + Patience;
int last = int.MinValue;
while (DateTime.UtcNow < giveUp)
{
int left = await AskAsync("", cancellation).ConfigureAwait(false);
if (left != last)
{
log.Write($"TxBufLen: {Answer(left)}");
last = left;
}
if (left == 0)
{
return;
}
if (left < 0)
{
log.Write("no answer to poll, waiting out the message instead");
await Task.Delay(TimeSpan.FromSeconds(10), cancellation).ConfigureAwait(false);
return;
}
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
}
log.Write($"gave up waiting after {Patience.TotalSeconds} s");
}
private async Task TypeAsync(string text, CancellationToken cancellation)
{
foreach (char character in text)
{
await engine.TypeAsync(character, cancellation).ConfigureAwait(false);
}
}
/// Sends one question and waits for the answer. The bridge answers every
/// question, with -1 when the control would not, so this cannot hang while
/// the bridge is alive.
private async Task<int> AskAsync(string property, CancellationToken cancellation)
{
TaskCompletionSource<int> answer = new(TaskCreationOptions.RunContinuationsAsynchronously);
lock (gate)
{
asking = answer;
}
await engine.AskBufferedAsync(property, cancellation).ConfigureAwait(false);
return await answer.Task.WaitAsync(Patience, cancellation).ConfigureAwait(false);
}
private static string Answer(int left) => left < 0 ? "no answer" : left.ToString();
private void WhenBuffered(object? sender, int left)
{
lock (gate)
{
asking?.TrySetResult(left);
}
}
private void WhenReceived(object? sender, string text)
{
foreach (char character in text)
{
log.Write($"rx {Printable(character)}");
}
lock (received)
{
received.Append(text);
}
}
private string TakeReceived()
{
lock (received)
{
string text = received.ToString();
received.Clear();
return text;
}
}
/// Control codes matter here: a backspace coming back is the engine saying
/// it removed a character.
private static string Printable(char character) => character switch
{
'\b' => "\\b (backspace)",
'\r' => "\\r",
'\n' => "\\n",
_ when char.IsControl(character) => $"\\x{(int)character:x2}",
_ => character.ToString(),
};
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Nonemm.EngineProbe</RootNamespace>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Nonemm.Digital\Nonemm.Digital.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,45 @@
namespace Nonemm.EngineProbe;
/// The probe's report: every line on the screen and in the file, stamped with
/// the milliseconds since the probe started. The timing is the point of the
/// report, so nothing is written without it.
public sealed class ProbeLog : IDisposable
{
private readonly StreamWriter file;
private readonly Lock gate = new();
private readonly long started = Environment.TickCount64;
public ProbeLog(string path)
{
Path = System.IO.Path.GetFullPath(path);
file = new StreamWriter(Path, append: false) { AutoFlush = true };
}
public string Path { get; }
public long Elapsed => Environment.TickCount64 - started;
public void Write(string text)
{
lock (gate)
{
string line = $"{Elapsed,7} ms {text}";
Console.WriteLine(line);
file.WriteLine(line);
}
}
/// A heading, so the file can be read a step at a time.
public void Step(string name)
{
lock (gate)
{
Console.WriteLine();
Console.WriteLine($"== {name}");
file.WriteLine();
file.WriteLine($"== {name}");
}
}
public void Dispose() => file.Dispose();
}

View File

@@ -0,0 +1,122 @@
using Nonemm.Digital;
namespace Nonemm.EngineProbe;
/// Starts MMTTY through the bridge, asks it the questions in `EngineProbe`, and
/// writes what it answered to a file. It has to run where MMTTY runs: a sound
/// card and a Wine prefix with XMMT.ocx registered.
public static class Program
{
private const string Usage = """
usage: engine probe --engine <path to MMTTY.EXE> [options]
--engine <path> the engine to start, as a Linux path
--bridge <path> the bridge program (default bridge/nonemm-mmtty-bridge.exe)
--prefix <path> WINEPREFIX to run in (default: Wine's own)
--ptt <port> serial port to key (default: none, so no radio is keyed)
--out <file> where to write the report (default engine-probe.log)
The probe transmits: MMTTY makes tones on the sound card for about half a
minute. It keys no serial port unless --ptt says so, but a rig listening to
that sound card through VOX will still go on the air.
""";
public static async Task<int> Main(string[] arguments)
{
Dictionary<string, string> options;
try
{
options = Read(arguments);
}
catch (ArgumentException problem)
{
Console.Error.WriteLine(problem.Message);
Console.Error.WriteLine();
Console.Error.WriteLine(Usage);
return 1;
}
using CancellationTokenSource stopping = new();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
stopping.Cancel();
};
using ProbeLog log = new(Option(options, "out", "engine-probe.log"));
MmttyEngine engine = new(
new WineBridgeChannel(
Option(options, "bridge", Path.Combine("bridge", "nonemm-mmtty-bridge.exe")),
options.GetValueOrDefault("prefix")),
new MmttyOptions
{
EnginePath = options["engine"],
PttPort = Option(options, "ptt", ""),
});
try
{
log.Write($"starting {options["engine"]}");
await engine.StartAsync(stopping.Token).ConfigureAwait(false);
log.Write($"MMTTY {engine.Version} is up");
await new EngineProbe(engine, log).RunAsync(stopping.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
log.Write("stopped");
}
catch (Exception problem)
{
log.Write($"failed: {problem.Message}");
Console.Error.WriteLine(problem);
return 1;
}
finally
{
await Shutdown(engine, log).ConfigureAwait(false);
Console.WriteLine();
Console.WriteLine($"the report is in {log.Path}");
}
return 0;
}
/// The engine is left keyed if the probe stopped partway through, so PTT is
/// dropped before the engine is, whatever happened.
private static async Task Shutdown(MmttyEngine engine, ProbeLog log)
{
try
{
await engine.SetPttAsync(false).ConfigureAwait(false);
await engine.StopAsync().ConfigureAwait(false);
}
catch (Exception problem)
{
log.Write($"the engine did not shut down cleanly: {problem.Message}");
}
engine.Dispose();
}
private static Dictionary<string, string> Read(string[] arguments)
{
Dictionary<string, string> options = [];
for (int i = 0; i < arguments.Length; i += 2)
{
if (!arguments[i].StartsWith("--", StringComparison.Ordinal))
{
throw new ArgumentException($"expected an option, found {arguments[i]}");
}
if (i + 1 >= arguments.Length)
{
throw new ArgumentException($"{arguments[i]} needs a value");
}
options[arguments[i][2..]] = arguments[i + 1];
}
if (!options.ContainsKey("engine"))
{
throw new ArgumentException("--engine is required");
}
return options;
}
private static string Option(Dictionary<string, string> options, string name, string fallback) =>
options.TryGetValue(name, out string? given) ? given : fallback;
}