diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-07-10 13:10:36 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-07-10 13:10:36 -0700 |
| commit | db8cc16a49729811445844f82f61ca15e977b24f (patch) | |
| tree | a564b9e29884ac1f8916bb6aeadae890225ea4bb | |
| parent | 490422f24d2a060642d7753b685fccdb8a72a661 (diff) | |
Encode Hotline time seconds from the 1904 Mac epoch
The seconds field of the 8-byte Hotline time was encoded as seconds since
the start of the current year. Clients that decode this field as a raw
classic Mac OS timestamp (e.g. Pitbull Pro) therefore always rendered the
year as 1904, since the value was under one year past the 1904 epoch.
Encode seconds from 1904-01-01 to match the original Hotline server, and
decode symmetrically. Fixes #166.
| -rw-r--r-- | hotline/time.go | 27 | ||||
| -rw-r--r-- | hotline/time_test.go | 49 |
2 files changed, 66 insertions, 10 deletions
diff --git a/hotline/time.go b/hotline/time.go index 671e036..b6d2d9c 100644 --- a/hotline/time.go +++ b/hotline/time.go @@ -8,17 +8,26 @@ import ( type Time [8]byte +// macEpoch is the classic Mac OS time epoch: seconds are counted from +// midnight, January 1, 1904 (local time). The 4-byte seconds field of the +// Hotline time format overflows a uint32 on 2040-02-06; the original protocol +// shares this limitation. +var macEpoch = time.Date(1904, time.January, 1, 0, 0, 0, 0, time.Local) + // NewTime converts a time.Time to the 8 byte Hotline time format: -// Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes) +// Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes). +// +// The seconds field holds seconds since the classic Mac OS epoch +// (1904-01-01), matching the original Hotline server. Some clients (e.g. +// Pitbull Pro) ignore the year field and decode the seconds field as a raw +// Mac timestamp; encoding seconds-since-start-of-year instead made those +// clients always display the year 1904 (see issue #166). func NewTime(t time.Time) (b Time) { yearBytes := make([]byte, 2) secondBytes := make([]byte, 4) - // Get a time.Time for January 1st 00:00 from t so we can calculate the difference in seconds from t - startOfYear := time.Date(t.Year(), time.January, 1, 0, 0, 0, 0, time.Local) - binary.BigEndian.PutUint16(yearBytes, uint16(t.Year())) - binary.BigEndian.PutUint32(secondBytes, uint32(t.Sub(startOfYear).Seconds())) + binary.BigEndian.PutUint32(secondBytes, uint32(t.Sub(macEpoch).Seconds())) return [8]byte(slices.Concat( yearBytes, @@ -28,14 +37,12 @@ func NewTime(t time.Time) (b Time) { } // Time converts the Hotline Time format to a Go time.Time. -// The Hotline format stores: Year (2 bytes) + milliseconds (2 bytes, unused) + seconds since Jan 1 (4 bytes) +// The Hotline format stores: Year (2 bytes) + milliseconds (2 bytes, unused) + +// seconds since the Mac OS epoch (4 bytes). func (t Time) Time() time.Time { - year := binary.BigEndian.Uint16(t[0:2]) seconds := binary.BigEndian.Uint32(t[4:8]) - // Create start of year, then add seconds - startOfYear := time.Date(int(year), time.January, 1, 0, 0, 0, 0, time.Local) - return startOfYear.Add(time.Duration(seconds) * time.Second) + return macEpoch.Add(time.Duration(seconds) * time.Second) } // Format returns the time formatted according to the layout string. diff --git a/hotline/time_test.go b/hotline/time_test.go new file mode 100644 index 0000000..b52f3ef --- /dev/null +++ b/hotline/time_test.go @@ -0,0 +1,49 @@ +package hotline + +import ( + "encoding/binary" + "testing" + "time" +) + +func TestNewTime_RoundTrip(t *testing.T) { + // Truncate to seconds since the wire format has no sub-second precision. + want := time.Date(2025, time.October, 27, 18, 11, 34, 0, time.Local) + + got := NewTime(want).Time() + + if !got.Equal(want) { + t.Errorf("round trip = %v, want %v", got, want) + } +} + +func TestNewTime_YearField(t *testing.T) { + tm := time.Date(2025, time.July, 10, 12, 0, 0, 0, time.Local) + + b := NewTime(tm) + + if year := binary.BigEndian.Uint16(b[0:2]); year != 2025 { + t.Errorf("year field = %d, want 2025", year) + } +} + +// TestNewTime_SecondsSinceMacEpoch is the regression test for issue #166. +// Clients like Pitbull Pro ignore the year field and decode the 4-byte seconds +// field as a raw classic Mac OS timestamp (seconds since 1904-01-01). Encoding +// seconds-since-start-of-year instead made the value tiny, so those clients +// always rendered the year as 1904. Verify the seconds field decodes to the +// correct year when read as a Mac-epoch timestamp. +func TestNewTime_SecondsSinceMacEpoch(t *testing.T) { + tm := time.Date(2025, time.July, 10, 12, 0, 0, 0, time.Local) + + b := NewTime(tm) + seconds := binary.BigEndian.Uint32(b[4:8]) + + decoded := macEpoch.Add(time.Duration(seconds) * time.Second) + if decoded.Year() != 2025 { + t.Errorf("Mac-epoch decode of seconds field = year %d, want 2025 (issue #166)", decoded.Year()) + } + if !decoded.Equal(tm) { + t.Errorf("Mac-epoch decode = %v, want %v", decoded, tm) + } +} |