Add the digital interface, running MMTTY under Wine

MMTTY and 2Tone have no socket or pipe interface: N1MM hosts XMMT.ocx and
exchanges window messages with the engine. A Linux process cannot load that
control, so bridge/nonemm-mmtty-bridge.exe hosts it under Wine and passes
lines over its standard input and output. docs/digital-bridge.md states the
protocol and what the control needs.

The window is N1MM's: receive pane with coloured callsigns, grab list, call
stacking, twenty-four macro buttons and the engine controls. One left click
copies what is under it — a callsign to the callsign box, anything else to the
exchange box the contest keeps for that kind of value.

Config > Digital registers XMMT.ocx in the Wine prefix on its own, making the
prefix first if it is not there. The engine, the bridge and the control start
at the copies shipped beside the program.

The bridge reads the control's own events. OnTranslateMessage carries only the
messages the control has no event for, so nothing was ever decoded through it.

hamlib's data mode names read as digital modes now, and a mode typed into the
callsign box changes mode the way a frequency changes band, so a station with
no radio can reach RTTY at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-31 21:35:45 +00:00
co-authored by Claude Opus 5
parent 76b57cee1d
commit c8de74e24a
70 changed files with 4560 additions and 27 deletions
+115
View File
@@ -0,0 +1,115 @@
using Nonemm.Contests;
namespace Nonemm.Session;
/// What a word clicked in the receive pane is worth. N1MM drops a clicked word
/// into whichever exchange box has the cursor. That box still comes first here
/// when it takes the word, but a word the cursor's box does not take goes to
/// the box of the contest's exchange that holds that kind of value, so a serial
/// number and a state land where they belong.
public static class DigitalElement
{
private const string Precedences = "QABUMS";
/// Strips what RTTY carries around a value: the report, a colon, and the Z
/// on a four-figure time. N1MM cuts the same three before it takes an
/// element from a click.
public static string Trim(string word)
{
string text = DigitalReceiver.Scrub(word).Replace(":", "");
if (text.Length > 3 && text.Contains("599"))
{
text = text.Replace("599", "");
}
if (text.Length == 5 && text[^1] == 'Z' && text[..4].All(char.IsAsciiDigit))
{
text = text[..4];
}
return text;
}
/// True when the box holds this kind of value. A report box is never a
/// target: nobody clicks 599 in, and the program fills it in itself.
public static bool Fits(ExchangeField field, string word) => field.Kind switch
{
ExchangeFieldKind.Report => false,
ExchangeFieldKind.Number => IsNumber(word, 1, 6),
ExchangeFieldKind.CqZone => IsNumber(word, 1, 2) && InRange(word, 1, 40),
ExchangeFieldKind.ItuZone => IsNumber(word, 1, 2) && InRange(word, 1, 90),
ExchangeFieldKind.Check => IsNumber(word, 2, 2),
ExchangeFieldKind.Power => IsPower(word),
ExchangeFieldKind.Precedence => word.Length == 1 && Precedences.Contains(word[0]),
ExchangeFieldKind.Grid => IsGrid(word),
ExchangeFieldKind.Text => word.Length > 0,
_ => ExchangeValues.For(field.Kind).Contains(word, StringComparer.OrdinalIgnoreCase),
};
/// The exchange box the word belongs in, counted the way `EntryFields`
/// counts: 0 is the callsign box, 1 and up are the exchange boxes. Null
/// when the contest has no box that takes it.
public static int? FieldFor(EntryFields fields, string word)
{
if (word.Length == 0)
{
return null;
}
// the box the cursor is in wins when it takes the word: that is where
// N1MM puts it, and the operator has already said which box it is
if (fields.Focus > 0 && Fits(fields.Exchange[fields.Focus - 1], word))
{
return fields.Focus;
}
// then the box that only holds this kind of value, then a number box,
// then plain text, so a check does not land in the serial number box
// and a section does not land in the name box
return Pick(fields, word, ListedOrShaped)
?? Pick(fields, word, kind => kind == ExchangeFieldKind.Number)
?? Pick(fields, word, kind => kind == ExchangeFieldKind.Text);
}
private static bool ListedOrShaped(ExchangeFieldKind kind) =>
kind is not (ExchangeFieldKind.Number or ExchangeFieldKind.Text or ExchangeFieldKind.Report);
private static int? Pick(EntryFields fields, string word, Func<ExchangeFieldKind, bool> wanted)
{
int? filled = null;
for (int at = 0; at < fields.Exchange.Count; at++)
{
ExchangeField field = fields.Exchange[at];
if (!wanted(field.Kind) || !Fits(field, word))
{
continue;
}
if (fields[at + 1].Trim().Length == 0)
{
return at + 1;
}
// every box that takes it is full, so the first one is written over
filled ??= at + 1;
}
return filled;
}
private static bool IsNumber(string word, int shortest, int longest) =>
word.Length >= shortest && word.Length <= longest && word.All(char.IsAsciiDigit);
private static bool InRange(string word, int lowest, int highest) =>
int.TryParse(word, out int value) && value >= lowest && value <= highest;
/// A power is the figures, with or without the W or KW after them.
private static bool IsPower(string word)
{
string figures = word.EndsWith("KW", StringComparison.Ordinal)
? word[..^2]
: word.EndsWith('W') ? word[..^1] : word;
return IsNumber(figures, 1, 4);
}
/// Four or six characters: two letters, two figures, and a letter pair on a
/// six-character locator.
private static bool IsGrid(string word) =>
word.Length is 4 or 6
&& char.IsAsciiLetter(word[0]) && char.IsAsciiLetter(word[1])
&& char.IsAsciiDigit(word[2]) && char.IsAsciiDigit(word[3])
&& (word.Length == 4 || (char.IsAsciiLetter(word[4]) && char.IsAsciiLetter(word[5])));
}