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/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/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 dcbc4a2..4385b8b 100644 --- a/internal/bridge/destination_test.go +++ b/internal/bridge/destination_test.go @@ -60,6 +60,109 @@ 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 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/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 { 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") }