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(),
};
}