Resynchronize a log with the other stations, and rescore it

A station that was away — a laptop that lost the network, one that was
switched off — comes back missing whatever the others logged while it was
gone. N1MM fills that in by walking the log an hour at a time, and this is
that walk, on the same messages:

| Message | What it says |
|---|---|
| `CHECKSUM` | here is what I hold for your contacts in this hour, and the contest and callsign I am logging |
| `DELETEQS` | drop your copies of my contacts in that hour, I am sending them again |
| `RESYNCQSO` | one of those contacts |
| `CONFIRMED` | here is what the hour hashes to at my end — which also asks for the next hour |

Each station is the authority on the contacts it made, so an hour that
disagrees is replaced rather than merged. The answer to one block asks for
the next, and the walk ends when the block starts after the end of the
period. The Resynchronize tab counts the hours that agreed, the hours that
were sent again and the hours that still disagreed, which are N1MM's
columns.

**`LogChecksum`** is N1MM's: a CRC32 over the `DXLOG` columns of the block,
ordered by time and then callsign, leaving out what each station works out
for itself — points, the three multiplier flags, the country prefix, the
continent, the WPX prefix and `IsOriginal`. Two stations may disagree about
those and still hold the same log. A checksum that disagrees for any other
reason costs one block of traffic, which is what a resync is for.

**The link still holds no log.** It asks one through `ContactLog` for the
contacts of a computer in a block of time, and `SessionContactLog` answers
on the window thread, because that is where the log is touched.

**Every contact logged here now carries the computer that made it.** N1MM
stamps `NetBiosName` and `NetworkedCompNr` on every row and this program
did not, so its own contacts belonged to no station and a resync could not
ask for them.

**Rescore** writes the points and multiplier flags of a period back to the
database. The log in memory scores itself whenever it changes; the stored
rows do not, and N1MM reads them rather than working them out. A contact
that arrives from another station after the one that took a multiplier
leaves those columns behind. A station can also ask the others to rescore
when its resync finishes, which is the flag on the end of `CHECKSUM`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
2026-09-03 21:52:09 +00:00
co-authored by Claude Opus 5
parent 68b29d722a
commit 2a21c772ad
18 changed files with 1061 additions and 15 deletions
+111
View File
@@ -0,0 +1,111 @@
using System.Globalization;
using System.Text;
using Nonemm.Core;
namespace Nonemm.Network;
/// The checksum of one block of contacts. Two stations compare these to find
/// out whether they hold the same log without sending the log.
///
/// It is N1MM's: a CRC32 over the `DXLOG` columns of every contact in the
/// block, in the order the columns are read out of the database, oldest first
/// and by callsign within a timestamp. The columns this program works out from
/// the rules — points, the three multiplier flags, the country prefix, the
/// continent, the WPX prefix and `IsOriginal` — are left out, because the two
/// stations are allowed to disagree about them. `ContestNR` counts only when it
/// is -1.
///
/// The checksum is over what a station stored, so a field that one program
/// writes as empty and the other as a space is a difference. N1MM writes a
/// single space into `GridSquare`, `MiscText`, `ContactType` and
/// `RoverLocation` when it has nothing for them, so an empty value in those
/// four is hashed as a space.
///
/// A checksum that disagrees is not a fault: the block is sent again, which is
/// what a resync is for. It only costs traffic.
public static class LogChecksum
{
public static int Of(IEnumerable<Qso> contacts)
{
StringBuilder text = new();
IEnumerable<Qso> ordered = contacts
.OrderBy(q => q.TimestampUtc)
.ThenBy(q => q.Call.Text, StringComparer.Ordinal);
foreach (Qso qso in ordered)
{
Append(text, qso);
}
return Crc32.Of(text.ToString());
}
/// The contacts of one station inside one block of time. Both ends of a
/// resync pick the same rows this way.
public static IReadOnlyList<Qso> Block(
IEnumerable<Qso> contacts,
string computerName,
DateTime fromUtc,
TimeSpan length) =>
contacts
.Where(q =>
string.Equals(q.StationName, computerName, StringComparison.OrdinalIgnoreCase)
&& q.TimestampUtc >= fromUtc
&& q.TimestampUtc < fromUtc + length)
.OrderBy(q => q.TimestampUtc)
.ThenBy(q => q.Call.Text, StringComparer.Ordinal)
.ToList();
private static void Append(StringBuilder text, Qso qso)
{
text.Append(qso.TimestampUtc.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
text.Append(qso.Call.Text);
Number(text, qso.Frequency.Kilohertz);
Number(text, qso.QsxFrequency.Kilohertz);
text.Append(qso.Mode.Name);
text.Append(qso.ContestName);
text.Append(qso.SentReport);
text.Append(qso.ReceivedReport);
text.Append(qso.StationPrefix);
text.Append(qso.Qth);
text.Append(qso.Name);
text.Append(qso.Comment);
Number(text, qso.ReceivedNumber);
text.Append(qso.Section);
text.Append(qso.Precedence);
Number(text, qso.Check);
Number(text, qso.Zone);
Number(text, qso.SentNumber);
text.Append(qso.Power);
Number(text, qso.Band?.MegahertzLabel ?? 0);
text.Append(qso.Exchange1);
Number(text, qso.RadioNumber);
text.Append(qso.Operator);
text.Append(Filled(qso.GridSquare));
// the contest number counts only when it says the contact belongs to no
// contest
if (qso.ContestNumber == -1)
{
text.Append("-1");
}
text.Append(Filled(qso.MiscText));
text.Append(Filled(qso.ContactType));
Number(text, qso.RunPosition);
text.Append(Filled(qso.RoverLocation));
Flag(text, qso.IsRadioInterfaced);
Number(text, qso.NetworkedComputerNumber);
text.Append(qso.StationName);
Flag(text, qso.IsRunQso);
text.Append(qso.Id);
Flag(text, qso.IsClaimed);
}
/// A column N1MM stores as a single space when it has nothing for it.
private static string Filled(string value) => value.Length == 0 ? " " : value;
private static void Number(StringBuilder text, double value) =>
text.Append(value.ToString(CultureInfo.InvariantCulture));
private static void Number(StringBuilder text, int value) =>
text.Append(value.ToString(CultureInfo.InvariantCulture));
private static void Flag(StringBuilder text, bool value) => text.Append(value ? '1' : '0');
}