From 0710afdbfb178c2ecd7bd0052157d5dc901a6931 Mon Sep 17 00:00:00 2001 From: David Justice Date: Wed, 4 Nov 2026 20:14:01 -0500 Subject: [PATCH 1/2] Accept GSPro OpenAPI session notifications Keep normal 202 and 203 notifications on the existing target session without treating them as shot receipts or bridge readiness. Preserve strict envelopes and exact payload forwarding, with synthetic session regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/technical/PROTOCOL.md | 9 +++--- internal/bridge/destination_test.go | 45 ++++++++++++++++++++++++++++ internal/destination/profile.go | 7 +++-- internal/destination/profile_test.go | 22 ++++++++++++-- 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/docs/technical/PROTOCOL.md b/docs/technical/PROTOCOL.md index 5740a0b..2a2e7ef 100644 --- a/docs/technical/PROTOCOL.md +++ b/docs/technical/PROTOCOL.md @@ -46,10 +46,11 @@ numeric `TotalSpin` if that member is supplied, otherwise both numeric zeros, conversion, or a success ACK. GSPro shot bytes are also sent unchanged plus LF. Its response profile admits -200, 201, and terminal failures 500 through 599. Other codes, including 202 -and 203, invalidate the destination instead of creating a ready state. -Acceptance of these fields, framing, and responses by an installed GSPro -build remains unqualified. +200, player information 201, notifications 202 and 203, and terminal failures +500 through 599. Notifications are relayed unchanged without settling a shot +receipt, closing the connection, or setting the bridge's readiness state. +Other codes invalidate the destination. Acceptance of shot fields, framing, +and receipts by an installed GSPro build remains unqualified. ## Split shots and duplicate suppression diff --git a/internal/bridge/destination_test.go b/internal/bridge/destination_test.go index dcbc4a2..52bdcd6 100644 --- a/internal/bridge/destination_test.go +++ b/internal/bridge/destination_test.go @@ -60,6 +60,51 @@ func TestInitialDestinationUsesOnlySelectedProfile(t *testing.T) { } } +func TestGSProNotificationsPreserveSessionWithoutAcceptingShots(t *testing.T) { + h := configuredHarness(t, true, nil, nil, func(config *Config) { + config.Destination = destination.GSPro + }) + target := h.target() + notifications := []string{ + `{"Code":202,"Message":"synthetic ready","Player":null,"Extra":-0.00}`, + `{"Code":203,"Message":"synthetic round ended","Player":null}`, + } + relayNotifications := func() { + t.Helper() + target.send(t, []byte(strings.Join(notifications, ""))) + for _, want := range notifications { + if got := string(h.labs.frame(t)); got != want { + t.Fatal("notification bytes changed", got) + } + } + } + relayNotifications() + assertOneTargetConnection(t, h, 0) + for index, id := range []string{"1", "2"} { + previous := uint64(index) + want := gameShot(id) + h.labs.send(t, want) + if !bytes.Equal(target.frame(t), want) { + t.Fatal("notification changed shot bytes or eligibility") + } + h.wait(func(s Snapshot) bool { return s.Counts.Sent == previous+1 }) + relayNotifications() + state := h.state(false) + if state.Counts.TargetAccept != previous || state.Counts.SourceACK != previous || + state.Ready != "unknown" || state.TargetGeneration != 1 || h.fake.dials.Load() != 1 { + t.Fatal("notification settled a receipt, declared ready or retired the session", state) + } + h.labs.silent(t, 10*time.Millisecond) + target.send(t, []byte(acceptance)) + if got := string(h.labs.frame(t)); got != acceptance { + t.Fatal("actual receipt changed") + } + assertOneTargetConnection(t, h, previous+1) + relayNotifications() + assertOneTargetConnection(t, h, previous+1) + } +} + func TestGameSwitchSettlesOldReceiptRetainsSourceAndNeverFansOut(t *testing.T) { addresses := make(chan string, 8) h := configuredHarness(t, true, nil, nil, func(config *Config) { diff --git a/internal/destination/profile.go b/internal/destination/profile.go index 2f87070..9e50b53 100644 --- a/internal/destination/profile.go +++ b/internal/destination/profile.go @@ -119,8 +119,9 @@ func number(raw []byte) bool { // Response returns a response code permitted by id's profile or // [protocol.ErrShape]. InfiniteTees uses [protocol.Response]; GSPro permits -// 200, 201, and 500 through 599 after envelope validation. Standby and invalid -// IDs reject responses. A 200 code alone is not a game-ready signal. +// 200 through 203 and 500 through 599 after envelope validation. Notifications +// 201 through 203 neither accept a shot nor establish game readiness. Standby +// and invalid IDs reject responses. func (id ID) Response(frame []byte) (int, error) { if id.Default() == InfiniteTees { return protocol.Response(frame) @@ -129,7 +130,7 @@ func (id ID) Response(frame []byte) (int, error) { return 0, protocol.ErrShape } code, err := protocol.ResponseCode(frame) - if err != nil || (code != 200 && code != 201 && (code < 500 || code > 599)) { + if err != nil || ((code < 200 || code > 203) && (code < 500 || code > 599)) { return 0, protocol.ErrShape } return code, nil diff --git a/internal/destination/profile_test.go b/internal/destination/profile_test.go index 842ff5c..ab8b092 100644 --- a/internal/destination/profile_test.go +++ b/internal/destination/profile_test.go @@ -74,8 +74,8 @@ func TestResponseProfilesDoNotInventReadinessOrBroadenIT(t *testing.T) { code string it, gs bool }{ - {"200", true, true}, {"201", true, true}, {"202", true, false}, - {"203", true, false}, {"500", true, true}, {"501", true, true}, + {"200", true, true}, {"201", true, true}, {"202", true, true}, + {"203", true, true}, {"204", false, false}, {"500", true, true}, {"501", true, true}, {"503", false, true}, {"599", false, true}, {"600", false, false}, {`"200"`, false, false}, {"200.0", false, false}, } { @@ -93,6 +93,24 @@ func TestResponseProfilesDoNotInventReadinessOrBroadenIT(t *testing.T) { } } +func TestGSProNotificationsStillRequireStrictResponseEnvelopes(t *testing.T) { + for _, code := range []string{"202", "203"} { + for _, bad := range []string{ + `{"Code":` + code + `}`, + `{"Code":` + code + `,"Message":null}`, + `{"Code":` + code + `,"Message":"synthetic","Player":[]}`, + `{"Code":` + code + `,"Code":` + code + `,"Message":"synthetic"}`, + `{"Code":"` + code + `","Message":"synthetic"}`, + `{"Code":` + code + `.0,"Message":"synthetic"}`, + `{"Code":` + code + `,"Message":"synthetic","Extra":{"x":1,"x":2}}`, + } { + if _, err := GSPro.Response([]byte(bad)); err == nil { + t.Fatal("notification bypassed strict response validation", bad) + } + } + } +} + func TestPreferenceIsBoundedExplicitAndReplacedAtomically(t *testing.T) { p := &Preferences{Path: filepath.Join(t.TempDir(), "settings", "destination.json")} if id, err := p.Load(); err != nil || id != InfiniteTees { From 681049ac9b6af80caba338dbac6afba563e7eb7b Mon Sep 17 00:00:00 2001 From: David Justice Date: Thu, 5 Nov 2026 08:17:06 -0500 Subject: [PATCH 2/2] Record bounded GSPro notification observations Add fixed 202/203 observation events without response values, plus explicit base-versus-head notification, privacy and receipt-timeout regression coverage for efficacy assessment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/technical/DIAGNOSTICS.md | 7 ++++ internal/bridge/bridge.go | 8 ++++ internal/bridge/destination_test.go | 58 +++++++++++++++++++++++++++++ internal/diagnostics/log.go | 1 + internal/diagnostics/log_test.go | 7 +++- 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/docs/technical/DIAGNOSTICS.md b/docs/technical/DIAGNOSTICS.md index d3062ab..aaf3f65 100644 --- a/docs/technical/DIAGNOSTICS.md +++ b/docs/technical/DIAGNOSTICS.md @@ -66,6 +66,13 @@ omitted. Only explicit IPv4 loopback endpoint/port text survives. Windows facts are numeric major/minor/build values from the native version API, without a machine name or hardware inventory. +Validated GSPro 202 and 203 notifications emit fixed +`gspro_notification_202_observed_not_receipt` and +`gspro_notification_203_observed_not_receipt` events. These record only the +allowlisted code classification, without response text, player values or shot +data. They do not prove shot acceptance, readiness or visible flight. A generic +`target_response_profile_invalid` still does not identify the rejected code. + Installer output uses selected operation/boolean/numeric/source-revision fields from `setup-result.log`; it excludes the verbose MSI log and debug/authentication text, including hexadecimal UI records. No MSI API is called. A result recorded diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go index a2e5fbc..e4601c9 100644 --- a/internal/bridge/bridge.go +++ b/internal/bridge/bridge.go @@ -684,6 +684,14 @@ func (e *engine) handle(ev event) { e.wait.receipt = ev.frame e.acceptReceipt() } else { + if e.dst.profile == destination.GSPro { + switch code { + case 202: + e.log("gspro_notification_202_observed_not_receipt", 0) + case 203: + e.log("gspro_notification_203_observed_not_receipt", 0) + } + } if e.switching { e.recordReplyDiscard(code, "destination_switch_pending") } else { diff --git a/internal/bridge/destination_test.go b/internal/bridge/destination_test.go index 52bdcd6..4385b8b 100644 --- a/internal/bridge/destination_test.go +++ b/internal/bridge/destination_test.go @@ -105,6 +105,64 @@ func TestGSProNotificationsPreserveSessionWithoutAcceptingShots(t *testing.T) { } } +func TestNotificationClassificationRetainsPendingReceiptAndExactRelay(t *testing.T) { + for _, profile := range []destination.ID{destination.GSPro, destination.InfiniteTees} { + for _, code := range []int{202, 203} { + t.Run(fmt.Sprintf("%s/%d", profile, code), func(t *testing.T) { + e, _ := stateEngine(t) + e.dst = &target{gen: 7, profile: profile, cancel: func() {}} + e.wait = &pending{token: 11, alias: 1, written: true} + e.state.Ready = "unknown" + frame := []byte(fmt.Sprintf(`{"Code":%d,"Message":"PRIVATE-MESSAGE","Player":{"Handed":"PRIVATE-PLAYER"},"Unknown":-0.00}`, code)) + e.handle(event{kind: "target_frame", epoch: 3, gen: 7, token: 11, frame: frame}) + if e.dst == nil || e.wait == nil || e.wait.token != 11 || e.wait.receipt != nil || + e.state.Counts.TargetAccept != 0 || e.state.Counts.SourceACK != 0 || + e.state.Counts.Uncertain != 0 || e.state.Ready != "unknown" { + t.Fatal("notification retired session, settled receipt or declared readiness", e.state) + } + if e.src.queuedCount != 1 || !bytes.Equal((<-e.src.writes).frame, frame) { + t.Fatal("notification relay changed bytes") + } + if profile == destination.GSPro { + want := fmt.Sprintf("gspro_notification_%d_observed_not_receipt", code) + if len(e.state.Recent) != 1 || e.state.Recent[0].Reason != want || e.state.Recent[0].Alias != 0 { + t.Fatal("notification classification missing or contains private values", e.state.Recent) + } + } else if len(e.state.Recent) != 0 { + t.Fatal("GSPro notification diagnostics changed Infinite Tees") + } + }) + } + } +} + +func TestGSProNotificationCannotReplaceReceiptOrReplayTimedOutShot(t *testing.T) { + h := configuredHarness(t, true, nil, nil, func(config *Config) { + config.Destination = destination.GSPro + }) + target := h.target() + h.labs.send(t, gameShot("1")) + target.frame(t) + h.wait(func(s Snapshot) bool { return s.Counts.Sent == 1 }) + notification := []byte(`{"Code":202,"Message":"synthetic ready","Player":null}`) + target.send(t, notification) + if !bytes.Equal(h.labs.frame(t), notification) { + t.Fatal("notification relay changed") + } + h.wait(func(s Snapshot) bool { return s.Counts.Uncertain == 1 }) + recovered := h.target() + recovered.silent(t, 10*time.Millisecond) + h.labs.silent(t, 10*time.Millisecond) + h.labs.send(t, gameShot("1")) + h.wait(func(s Snapshot) bool { return s.Counts.Duplicate == 1 }) + recovered.silent(t, 10*time.Millisecond) + state := h.state(false) + if state.Counts.Sent != 1 || state.Counts.TargetAccept != 0 || state.Counts.SourceACK != 0 || + state.Ready != "unknown" || state.Source != "attached" { + t.Fatal("notification replaced receipt or timed-out shot replayed", state) + } +} + func TestGameSwitchSettlesOldReceiptRetainsSourceAndNeverFansOut(t *testing.T) { addresses := make(chan string, 8) h := configuredHarness(t, true, nil, nil, func(config *Config) { diff --git a/internal/diagnostics/log.go b/internal/diagnostics/log.go index a04fc4f..a722763 100644 --- a/internal/diagnostics/log.go +++ b/internal/diagnostics/log.go @@ -42,6 +42,7 @@ source_attached previous_source_epoch_actual_response_write_completed previous_s departed_source_socket_cleanup_failed admitted_upstream_write_completed_after_target_retirement source_200_complete_local_write_not_peer_consumption target_connected_ready_unknown_new_shots_only target_unavailable target_write_partial_or_uncertain target_response_profile_invalid +gspro_notification_202_observed_not_receipt gspro_notification_203_observed_not_receipt unsolicited_or_duplicate_200_discarded target_reported_failure_no_success_ACK target_receipt_deadline_delivery_uncertain delivery_uncertain_never_replay held_shot_target_generation_lost source_listener_failed platform_fault_waiting_for_source_departure identity_unavailable_pending_source_departure diff --git a/internal/diagnostics/log_test.go b/internal/diagnostics/log_test.go index 1a333bf..a707b80 100644 --- a/internal/diagnostics/log_test.go +++ b/internal/diagnostics/log_test.go @@ -132,6 +132,7 @@ func TestMultiGameEventsRemainActionableWithoutBroadeningRedaction(t *testing.T) "destination_change_already_pending", "destination_change_blocked_quit", "destination_change_blocked_source_fault", "invalid_destination", "upstream_response_discarded_destination_switch_pending", "destination_change_pending", "destination_change_failed", "destination_preference_save_failed", + "gspro_notification_202_observed_not_receipt", "gspro_notification_203_observed_not_receipt", } for _, code := range codes { if err := logger.WriteEvent(bridge.Event{At: time.Now().UTC(), Reason: code}); err != nil { @@ -141,6 +142,9 @@ func TestMultiGameEventsRemainActionableWithoutBroadeningRedaction(t *testing.T) if err := logger.WriteEvent(bridge.Event{Reason: "destination_change_PRIVATE_DEVICE", Alias: 987654321}); err != nil { t.Fatal(err) } + if err := logger.WriteEvent(bridge.Event{Reason: "gspro_notification_202_PRIVATE_PLAYER", Alias: 987654321}); err != nil { + t.Fatal(err) + } if err := closeLogger(t, logger); err != nil { t.Fatal(err) } @@ -153,7 +157,8 @@ func TestMultiGameEventsRemainActionableWithoutBroadeningRedaction(t *testing.T) t.Fatal("reviewed multi-game event was hidden as unknown", code) } } - if bytes.Contains(data, []byte("PRIVATE_DEVICE")) || bytes.Contains(data, []byte("987654321")) || + if bytes.Contains(data, []byte("PRIVATE_DEVICE")) || bytes.Contains(data, []byte("PRIVATE_PLAYER")) || + bytes.Contains(data, []byte("987654321")) || !bytes.Contains(data, []byte("unrecognized_event_redacted_")) { t.Fatal("destination event prefix bypassed privacy redaction") }