fix(decodes): a decode's mode is a marker, not a mode name

Every station on an already-worked band was flagged NEW MODE.

A WSJT-X Decode does not carry the mode's name. It carries the
one-character marker from the decode line - "~" for FT8, "+" for FT4 - and
that character was passed straight through as though it were a mode. The
status resolver then compared "~" against the modes worked for the entity,
matched nothing, and concluded the mode had never been worked. Same cause
put "~ -07" in the comment of every decode spot pushed to the FlexRadio
panadapter, which nobody had traced back.

Resolved through a marker table, with the mode from the sender's last
Status as the fallback - Status is the message that carries the real name.
So an unlisted or future marker degrades to correct rather than to
nonsense, and a sender that puts the name in the field directly is believed
as-is. With neither available the mode is left empty, which makes the
resolver answer "worked": the safe side, since a wrong mode invents a
new-mode flag exactly as the marker did.
This commit is contained in:
2026-08-18 11:40:13 +02:00
parent 453e0df27b
commit 47992b5f03
3 changed files with 119 additions and 1 deletions
@@ -0,0 +1,59 @@
package udp
import "testing"
// A Decode does not carry the mode's NAME. It carries the one-character marker
// from the decode line — "~" for FT8, "+" for FT4 — and that character used to
// be passed on as if it were a mode. Everything downstream compared it against
// the modes in the log, matched nothing, and reported every station on an
// already-worked band as a NEW MODE.
func TestDecodeModeNameResolvesTheMarker(t *testing.T) {
for raw, want := range map[string]string{
"~": "FT8",
"+": "FT4",
"#": "JT65",
"@": "JT9",
} {
if got := DecodeModeName(raw, "FT8"); got != want {
t.Errorf("DecodeModeName(%q) = %q, want %q", raw, got, want)
}
}
}
// A sender that puts the real name in the field is believed as-is — several do,
// and the marker table must not get in their way.
func TestDecodeModeNameKeepsARealName(t *testing.T) {
for _, raw := range []string{"FT8", "ft4", "JS8", "Q65"} {
if got := DecodeModeName(raw, ""); got == "" || got != upper(raw) {
t.Errorf("DecodeModeName(%q) = %q, want the name itself", raw, got)
}
}
}
// The safety net: an unknown marker falls back to the mode from the sender's
// last Status, which always carries the real name. This is what keeps a future
// or unlisted marker degrading to correct rather than to nonsense.
func TestDecodeModeNameFallsBackToStatus(t *testing.T) {
if got := DecodeModeName("%", "FT4"); got != "FT4" {
t.Errorf("unknown marker resolved to %q, want the Status mode FT4", got)
}
if got := DecodeModeName("", "FT8"); got != "FT8" {
t.Errorf("empty mode resolved to %q, want the Status mode FT8", got)
}
// Nothing known at all is empty rather than a guess: an empty mode makes the
// status resolver answer "worked", which is the safe side — a wrong mode
// would invent a new-mode flag exactly as the marker did.
if got := DecodeModeName("%", ""); got != "" {
t.Errorf("with no Status mode the result was %q, want empty", got)
}
}
func upper(s string) string {
out := []rune(s)
for i, r := range out {
if r >= 'a' && r <= 'z' {
out[i] = r - 32
}
}
return string(out)
}
+13 -1
View File
@@ -183,6 +183,9 @@ type Server struct {
// lastFrom is the address each program's packets arrive from — where a Reply
// has to be sent. See SendReply.
lastFrom map[string]*net.UDPAddr
// lastMode is the mode NAME from each program's last Status, used to resolve
// a Decode's one-character mode marker.
lastMode map[string]string
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
// badPkts counts datagrams this listener could not parse, so the diagnostic
@@ -436,6 +439,14 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
}
s.trPeriod[w.ProgramID] = w.TRPeriod
}
// The mode NAME, which only Status carries: a Decode gives the
// one-character marker instead. See DecodeModeName.
if w.Mode != "" {
if s.lastMode == nil {
s.lastMode = map[string]string{}
}
s.lastMode[w.ProgramID] = w.Mode
}
s.mu.Unlock()
}
if !w.IsDecode && (w.TxMessage != "" || w.DECall != "") {
@@ -452,6 +463,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
s.mu.Lock()
dial := s.dialHz[w.ProgramID]
tr := s.trPeriod[w.ProgramID]
statusMode := s.lastMode[w.ProgramID]
s.mu.Unlock()
if dial <= 0 {
// No Status from THIS instance yet. Guessing with another
@@ -464,7 +476,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.DecodeFreqHz = dial + w.DeltaFreqHz
ev.DecodeSNR = w.SNR
ev.DecodeCQ = w.IsCQ
ev.Mode = w.Mode
ev.Mode = DecodeModeName(w.Mode, statusMode)
ev.DecodeMsg = w.DecodeMsg
ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight)
ev.DecodeTRPeriod = tr
+47
View File
@@ -453,3 +453,50 @@ func readQString(r *bytes.Reader) (string, error) {
}
return string(buf), nil
}
// decodeModeChar maps the single character WSJT-X puts in a Decode's mode field
// to the mode it stands for.
//
// A Decode does NOT carry the mode's name. It carries the one-character marker
// that appears in the decode line and in ALL.TXT — "~" for FT8, "+" for FT4 —
// and that character was being passed straight through as if it were a mode.
// Everything downstream then compared "~" against the modes in the log, matched
// nothing, and called every station on an already-worked band a new MODE.
//
// The table covers what is common; anything missing falls back to the mode from
// the sender's last Status, which carries the real name — so an unlisted or
// future marker degrades to correct rather than to nonsense.
var decodeModeChar = map[string]string{
"~": "FT8",
"+": "FT4",
"#": "JT65",
"@": "JT9",
"&": "MSK144",
":": "Q65",
"`": "FST4",
}
// DecodeModeName resolves a Decode's mode field to a real mode name. statusMode
// is the mode from the same program's last Status, used when the field is a
// marker we do not know, or empty.
func DecodeModeName(raw, statusMode string) string {
raw = strings.TrimSpace(raw)
if m, ok := decodeModeChar[raw]; ok {
return m
}
// A mode name is at least two alphanumeric characters ("FT8", "JS8", "Q65").
// Anything shorter, or carrying punctuation, is a marker rather than a name.
if len(raw) >= 2 {
named := true
for _, r := range raw {
if !(r >= 'A' && r <= 'Z') && !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') {
named = false
break
}
}
if named {
return strings.ToUpper(raw)
}
}
return strings.ToUpper(strings.TrimSpace(statusMode))
}