Every client of GSPro Open Connect that could be found and read, what each one actually puts
on the wire and expects back, and the conformance cases that follow. The suite is the
acceptance test for libgspro and the checklist for any host transport (PinPoint's
QTcpServer adapter included): a case tagged [T] is about the bytes the host writes and
when, not about the library.
Tags are protocol.md §0's. A case cites the clients that motivate it, so
that when a client is dropped or a new one appears the suite can be re-derived rather than
re-argued. ⚠ No case here has yet been run against a real launch monitor; the matrix is
read from source, and design §11's package 7 is where it meets hardware.
The suite is built, runs, and is green. tests/ holds 115 socket-free cases across
nine binaries, plus the sans-I/O gate and a Python fixture cross-check that needs no C at
all — and the host-transport family (§3.8) on top of that, twice: nine cases against the
asyncio reference adapter and the same nine, with four more about the transport itself,
against the C one behind GS_BUILD_NET. It was written before the library, so that the specification was one that could be run
rather than one that could only be read; every case now passes against the core, in both
configurations:
cmake --preset dev && cmake --build --preset dev && ctest --preset dev
cmake --preset san && cmake --build --preset san && ctest --preset san # CT-D24, CT-X02conformance-runs.md records what each run actually produced — the
counts, the coverage, the soaks, and ⚠ what the run changed, because a run that changes
nothing is a run that was not looking.
⚠ Green here is not the same as correct. Every case is read from a client's source, not from a wire, so the suite says the library agrees with what those clients are written to send — design §11's package 7 is where that meets hardware.
Stars are GitHub's on 2026-09-09 and are a rough proxy for user base; a DIY launch monitor's stars measure builders, a bridge's measure owners of that device who play GSPro.
| Tag | Device | Language | Stars | Alive | Licence |
|---|---|---|---|---|---|
| [OF] | OpenFlight DIY radar LM (hardware) | Python | 1043 | 2026-09 | AGPL-3.0 |
| [PIT] | PiTrac DIY camera LM (hardware) | C++ | 411 | 2026-08 | GPL-2.0 |
| [TL] | Garmin R10 | Node/Electron | 138 | 2023-04 | none |
| [MLM] | Rapsodo MLM2PRO, Mevo+, Uneekor, SkyTrak+ (screen OCR) | Python | 96 | 2026-07 | GPL-3.0 |
| [R10] | Garmin R10 | C# | 92 | 2024-01 | MIT |
| [OSG] | (server role) PiTrac, Square | C#/Godot | 61 | 2026-08 | GPL-2.0 |
| [OCR] | Rapsodo MLM2PRO (screen OCR) | Python | 23 | 2023-08 | MIT |
| [OSP] | SkyTrak+ via OpenSkyPlus (installer shipped) | C# | 10 / 1 | 2024-11 | proprietary |
| [KJD] | none (library) | Java | 8 | 2023-12 | MIT |
| [TNB] | none (reference library) | C# | 5 | 2023-10 | MIT |
| [FB] | Garmin R10 | Go | 5 | 2025-11 | Apache-2.0 |
| [SLX] | (proxy) SLX Micro via SLX Connect | TypeScript | 2 | 2024-10, adopted upstream | MIT |
| [OB] | (server role) Uneekor EYEMINI LITE via VIEW | Node | 1 | 2026-07 | MIT |
| [GG] | Garmin R10 via E6 stream | Rust | 1 | 2026-06 | none |
| [GC2] | Foresight GC2 (serial) | Rust | 0 | 2024-08 | none |
| [SB] | Foresight GC2 (USB) — ⚠ not known to work | Python | 2 | 2022-08 | none |
Commercial connectors — Garmin's own R10/R50 GSPro mode, Rapsodo's, FlightScope's, Uneekor's, Square's, Bushnell's, SLX Connect — are closed and were not read. [SLX] is the nearest thing: its logic was adopted into SLX Connect, and it sat between that connector and GSPro long enough to document two GSPro behaviours nobody else did (§2).
| Tag | Delimiter | Layout | Numbers | Absent = | Ball keys sent | Club object | Contains* truth |
|---|---|---|---|---|---|---|---|
| [PIT] | none | indented | plain; TotalSpin 0.0 placeholder with real pair |
0.0 | all 7, no carry | all ten 0.0 | ball yes, club no |
| [TL] | none | compact | plain | omitted | 5 required only | 6 of 10 zero | honest |
| [MLM] | none | compact | plain | 0 | all 8, key Backspin, carry 0 |
4 zero, face-to-path in HFI | club always true |
| [R10] | none | compact | plain | 0.0 | all 8 | all ten, when present | honest |
| [OCR] | none | compact | plain | 0 | 7 (no carry); putt: axis 0, VLA 0 | Speed only |
club always true |
| [OSP] | none | indented | plain | null → omitted | measured only | Speed only |
honest |
| [KJD] | none | compact | plain | omitted | as built | as built | honest |
| [TNB] | none | indented | plain | null → omitted | as built | as built | honest |
| [FB] | \n |
compact | plain | omitempty for pair + carry |
up to 8 | all ten | honest; heartbeat every 5 s |
| [OB] bridge | none | compact | plain | 0 | all 8 | null + extra ClubName |
honest; heartbeat every 3 s |
| [GG] | \n |
compact | plain | omitted | as measured | measured members only, or absent | honest |
| [OF] | none | compact | plain, rounded | 0.0 | all 8 | Speed, Path, rest 0.0 |
club iff speed measured; heartbeat 5 s idle |
| [GC2] | none | compact | plain | 0.0 | all 8 (TotalSpin real and pair) |
all ten 0.0 | ball yes, club no |
| [SB] | none | compact | strings "147.5", "true"; key Apiversion |
— | 5 | none | strings |
| Tag | Reply parsing | Waits for 200 | Acts on 201 | Handles 202/203 | Reconnect |
|---|---|---|---|---|---|
| [PIT] | one read = one object; thread dies on failure | no | DR / PT only, else Driver | no → "unknown" | lazily, next shot |
| [TL] | one read = one object | no | logs only | no | 5 s |
| [MLM] | regex split | 2 s, then re-sends | PT → putting mode | no | manual |
| [R10] | }{ → },{ array |
no | Club → device | no | 5 s |
| [OCR] | regex split | 10 s | PT → putting mode | no | — |
| [OSP] | SupportMultipleContent |
no | Handed; arms on non-zero DistanceToTarget; putting by config |
yes, exact strings; arms only after 202 | 30 s forever |
| [KJD] | brace count | no | event | no | — |
| [TNB] | 256-byte read = one object | 10 s queue | event | enum only | — |
| [FB] | decoder, buffer reset on error | no | logs only | no | — |
| [OB] bridge | discards | no | — | — | 1.5 s |
| [GG] | — | no | — | — | — |
| [OF] | string-aware framer | no | Handed + Club → state | no (ignored) | 1 → 30 s backoff |
| [GC2] | logs raw | no | — | — | 5 s |
| [SLX] monitor side | one read = one object | — | forwards GSPro's 201 to SLX | — | — |
| Server | Frames | Replies to shot | Replies to heartbeat / status | 201 | Bad input |
|---|---|---|---|---|---|
| GSPro Connect (from client evidence) | — | 200 (misspelled text, [TNB]) | 200 [TNB] / apparently nothing [PIT] — U13 | unsolicited on club change [CL]; after shots, concatenated with the 200 [MLM] [SLX]; with DistanceToTarget [OSP]; 202/203 at match start, sometimes concatenated [OSP] |
unknown (U6); zero-speed shot resets club [SLX] |
| [OSG] | one read = one object | {"Code":200} |
501 "Missing or invalid shot data" | never | 501 / 413 |
| [OB] | string-aware, 64 KiB | 200 "Shot received successfully" | 200 "Heartbeat OK" / "OK" | on connect, and on setPlayer |
501 "Bad JSON: …" |
| [PIT] test server | one read = one object | 201 | 201 | every reply | — |
| [OF] mock | records bytes | scripted | scripted | scripted | — |
| libgspro (design §5) | string-aware, 16 KiB | 200 | 200 | on change; on connect by policy; 202/203 by session state | 501, resync |
Two facts about GSPro come only from [SLX] and are worth restating because a proxy that sat in the real path is the strongest evidence in this survey short of a capture:
- GSPro concatenates replies. "GSPro connector sometimes sends double messages with each flush."
- A shot with zero ball speed makes GSPro reset the club selection, and Swinglogic's own connector produced such shots often enough that a proxy was written to turn them into heartbeats — and the fix was then adopted into SLX Connect. Two consequences for a server: a zero-speed "shot" from a commercial connector is a thing that happens (CT-D12), and emulating GSPro's reaction to it would be emulating a bug.
Each case names the input, the required outcome, and the sources that make it necessary.
Prefix: F framing, D decode, K kind, R reply, P player and session, C
connection, W wire log, X robustness, T host transport. A case marked [T] is verified against
a host's socket adapter — with gsp_shoot.py, or with the C client half of
tests/gs_net_client.h — and not against the library alone. ⚠ A
T case is therefore written once per adapter rather than once, and
tests/test_coverage.py fails a T row that reaches only one of
them.
| Case | Input | Required | Because |
|---|---|---|---|
| CT-F01 | One compact object in one call | one message | [MLM] [TL] [R10] [OF] [OB] [GC2] |
| CT-F02 | Same object delivered one byte per call | one message, after the last byte | TCP |
| CT-F03 | Two objects in one call | two messages, two replies, in order | [OSP] [SLX] observed GSPro doing it; clients will |
| CT-F04 | Object + \n |
one message; the newline consumed, not flagged | [FB] [GG] |
| CT-F05 | Object + \r\n + object |
two messages | legal whitespace |
| CT-F06 | Indented object with newlines and tabs inside | one message | [TNB] [OSP] [PIT] |
| CT-F07 | Object whose DeviceID contains {, } and \" |
one message, string intact | a bare brace counter ([KJD] [SLX]) fails this; ours must not |
| CT-F08 | Split inside a string literal across two calls | one message | TCP |
| CT-F09 | Leading garbage xyz{...} |
GSP_EV_PROTOCOL_ERROR LEADING_GARBAGE, one 501, then the object decodes |
§5.3 |
| CT-F10 | Top-level [{...}] |
protocol error, 501, resync at next { |
§5.3 |
| CT-F11 | 16 KiB + 1 of {"a":"aaaa… |
protocol error TOO_LARGE, 501, buffer discarded, a following object decodes | [OSG] [OB] bound at 64 KiB; ours 16 |
| CT-F12 | Ten objects in one 8 KiB call | ten messages, ten replies | burst |
| Case | Input | Required | Because |
|---|---|---|---|
| CT-D01 | [GSP]'s full example verbatim | every field present, no flags except UNKNOWN_KEYS = 0 |
[GSP] |
| CT-D02 | [TL]'s five-key BallData |
present = required five; BALL_INCOMPLETE clear (TotalSpin+SpinAxis suffice) |
[TL] |
| CT-D03 | "Backspin" lower-case s |
back_spin present, KEY_CASE_MISMATCH set |
[MLM] |
| CT-D04 | "APIVersion" / "Apiversion" |
api_version "1", KEY_CASE_MISMATCH |
[R10] [SB] |
| CT-D05 | "ShotNumber": 13.0 |
shot_number 13, no NOT_INTEGER flag |
[OSG] |
| CT-D06 | "ShotNumber": 13.5 |
13, NOT_INTEGER |
§9.3 |
| CT-D07 | "ClubData": null |
club.present = 0, no crash, not CLUB_OBJECT_WITHOUT_FLAG |
[OB] |
| CT-D08 | Extra top-level "ClubName": "DRIVER" |
unknown_keys = 1, UNKNOWN_KEYS set, decoded normally |
[OB] |
| CT-D09 | BallData with "Speed":"147.5" and options "ContainsBallData":"true" |
values coerced, TYPE_COERCED set |
[SB] |
| CT-D10 | ClubData with only Speed |
club.present = SPEED only | [OSP] [OCR] |
| CT-D11 | ClubData with Speed and Path |
present = SPEED|PATH plus whatever else was 0.0 and sent | [OF] |
| CT-D12 | Shot with "Speed": 0 and ContainsBallData: true |
delivered as SHOT with speed present and zero; no flag — zero is a value; gsp_message_ball_zero_speed() true |
[SLX]: happens in the wild; the host decides |
| CT-D13 | TotalSpin: 0.0 with real BackSpin/SideSpin |
decoder: all three present, no flag. gsp_ball_data_derive(): total and axis recomputed, derived = TOTAL|AXIS |
[PIT] |
| CT-D14 | TotalSpin and pair both real and consistent |
derive: no change, GSP_OK |
[GC2] [OF] |
| CT-D15 | [GSP]'s example (3250 vs pair 2500/-800) | derive: no change, GSP_PENDING |
protocol §3.2 |
| CT-D16 | Pair only, BackSpin: 0, SideSpin: 500 |
derive: total 500, axis +90 | [GC2]'s edge |
| CT-D17 | "DeviceID" of 100 characters |
truncated to 63, STRING_TRUNCATED flag and warning |
§5.4 |
| CT-D18 | "DeviceID": "Foresight GC2 (ABC123)" |
stored; gsp_event_is_sensitive() true; redacted by format |
[GC2] |
| CT-D19 | "Units": "Meters" |
units METERS, text kept, no conversion | [OCR] [OF] |
| CT-D20 | "Units": "Metres" |
units UNKNOWN, UNKNOWN_UNITS, text kept |
§3.6 |
| CT-D21 | No Units |
UNKNOWN, text "", no flag ([GSP]: default yards is the host's inference) |
[GSP] |
| CT-D22 | Duplicate key "Speed" twice |
last wins | §9.1 |
| CT-D23 | Numbers in exponent form 1.5e2, negative zero, -0.0 |
150, 0 | strtod grammar |
| CT-D24 | Every truncation of every fixture | never reads past len; ASan clean |
design §5.4 |
| Case | Options | Required kind | Because |
|---|---|---|---|
| CT-K01 | ball true, club false | SHOT | [GSP] |
| CT-K02 | ball false, club true | SHOT (club-only) | [TNB] |
| CT-K03 | ball false, club false, heartbeat true | HEARTBEAT | [R10] [FB] [OB] [OF] |
| CT-K04 | ball false, club false, heartbeat false | STATUS | [TNB] [OSP] |
| CT-K05 | heartbeat true and ready true | HEARTBEAT; ready flag read; CLIENT_STATE event if changed |
[OSP] [R10] [PIT] |
| CT-K06 | no ShotDataOptions |
STATUS, MISSING_OPTIONS |
§9.7 |
| CT-K07 | ball true, no BallData |
SHOT, BALL_FLAG_WITHOUT_OBJECT, BALL_INCOMPLETE |
§3.4 |
| CT-K08 | ball false, BallData present with values |
STATUS, BALL_OBJECT_WITHOUT_FLAG, values decoded |
§3.4 |
| Case | Input | Required | Because |
|---|---|---|---|
| CT-R01 | Any of CT-K01…K08 | exactly one write, kind ACK, code 200, queued before on_bytes returns |
[MLM] 2 s, [OCR] 10 s |
| CT-R02 | CT-F03's two objects | two ACKs in order | [SLX] |
| CT-R03 | CT-F09/F10/F11 | exactly one write, kind FAILURE, 501, with the documented text | [OSG] [OB] |
| CT-R04 | Incomplete shot, reject_incomplete_shots off |
200 | default |
| CT-R05 | Incomplete shot, policy on | 501 "Incomplete ball data" | policy |
| CT-R06 | Every write the ring can produce, fed to gsp_response_decode() |
code ∈ {200, 201, 202, 203, 501}; never anything else | design §9.1 |
| CT-R07 | Write ring full | on_bytes returns GSP_ERR_QUEUE_FULL, bytes retained, resumes after drain |
design §3.4 |
| CT-R08 | ACK text policy set to the misspelled string | that string, verbatim | U4 |
| Case | Action | Required | Because |
|---|---|---|---|
| CT-P01 | set_player(RH, DR) with two connections |
one 201 per connection, PLAYER_INFO_SENT × 2, reason CHANGED |
design §5.6 |
| CT-P02 | set_player twice with equal values |
second sends nothing | design §5.6 |
| CT-P03 | set_player(RH, PT, distance 4.2) |
JSON has DistanceToTarget: 4.2; a client such as [MLM] would switch to putting |
[MLM] [OCR] [OSP] |
| CT-P04 | set_player(UNKNOWN, UNKNOWN, no distance) |
nothing sent; Player would be empty |
design §5.6 |
| CT-P05 | surface set to "Green" |
Surface member present |
[OSP] [FB] |
| CT-P06 | Connection opened after set_player, policy on |
201 queued immediately, reason ON_CONNECT | [OB]; U1 |
| CT-P07 | Same, policy off | nothing until a change | U1 |
| CT-P08 | Shot then set_player change, one drain |
ring order is ACK then 201 | [MLM]'s {200}{201} |
| CT-P09 | set_session_state(ACTIVE) |
202 "GSPro ready" to all; repeated call sends nothing |
[OSP] |
| CT-P10 | set_session_state(ENDED) |
203 "GSPro round ended", exact string |
[OSP] matches it |
| CT-P11 | Connect while ACTIVE, announce_ready_on_connect |
201 then 202 | [OSP] arms only after 202 |
| CT-P12 | 202 fed to gsp_response_decode() |
code 202, has_message, text exact | CT-R06 |
| Case | Action | Required | Because |
|---|---|---|---|
| CT-C01 | Open id 7 twice | second returns INVALID_STATE |
design §3.2 |
| CT-C02 | on_bytes for unknown id |
UNKNOWN_CONNECTION |
|
| CT-C03 | Open 5 with max 4 | fifth TOO_MANY_CONNECTIONS; no event for it |
design §5.1 |
| CT-C04 | First message names DeviceID | CLIENT_IDENTIFIED once; second message with another name → WARN_DEVICE_ID_CHANGED, id unchanged |
design §5.2 |
| CT-C05 | Close after 3 shots, 2 heartbeats | CONNECTION_CLOSED info: messages 5, shots 3, cause as given |
design §5.2 |
| CT-C06 | Reconnect with ShotNumber restarting at 1 |
second connection's first shot is not SHOT_NUMBER_REPEATED (per-connection) |
§4.3 |
| CT-C07 | Same shot number twice on one connection | SHOT_NUMBER_REPEATED |
[MLM] re-send |
| CT-C08 | Heartbeats with incrementing ShotNumber then a shot |
no repeated flag; counter is provenance | [FB] |
| CT-C09 | gsp_server_close() with 2 open |
two CONNECTION_CLOSED SERVER_CLOSED; every later call GSP_ERR_CLOSED; polls drain |
design §3.3 |
| CT-C10 | Idle alarm 10 s, silence 11 s | CLIENT_IDLE once; next message → CLIENT_ACTIVE |
design §5.7 |
| CT-C11 | No idle alarm | next_due_us always NEVER; tick a no-op |
§9.6 |
| Case | Input | Required | Because |
|---|---|---|---|
| CT-X01 | 1 MiB of { |
bounded memory, TOO_LARGE errors, connection survives | §9.3 |
| CT-X02 | Random bytes, 10 000 iterations under ASan/UBSan | no crash, no read past len |
design §5.4 |
| CT-X03 | 100 consecutive errors, threshold 50 | CLOSE_REQUESTED once, at the 50th |
design §5.3 |
| CT-X04 | Errors then a good message | consecutive count reset; no close request | design §5.3 |
| CT-X05 | Event ring of 4, 10 shots | 6 dropped, dropped_events() 6, WARN_EVENTS_DROPPED, all 10 ACKs written |
design §5.4 |
| CT-X06 | Purity | core object references no socket, thread, clock or file symbol | design §2 |
| CT-X07 | ABI | sizes, offsets, enumerators match the Python binding both ways | design §4.6 |
These ten rows are about a host rather than about the library: the library owns no socket
by construction, so nothing in the rest of the suite can see reply latency, whether a 200 and a
201 reached the wire as two writes, or what happens when a client vanishes mid-message. Each
row is therefore run against every host adapter — driven by gsp_shoot.py, or by the C
client half in tests/gs_net_client.h.
Nine of the ten run today, against two adapters:
| Adapter | Suite | Run it |
|---|---|---|
python/gspro/asyncio_transport.py |
../tests/test_python_transport.py |
ctest -R python_transport |
net/gs_net.c (gspro_net, C) |
../tests/test_net.c |
ctest -R test_net (needs GS_BUILD_NET) |
PinPoint Studio's QTcpServer |
lives in PinPoint | design §11 package 8 |
⚠ Running it twice is not redundancy. These rows are the promises a host can break on
its own — Nagle left on, two replies concatenated, bytes split before on_bytes, a timer never
re-armed after a poll — and the two adapters share no code below the socket: an asyncio event
loop and a select() loop are different platform code making the same promises. The C suite
drives both ends from one thread, so a failure is reproducible rather than a race.
⚠ Both are still one machine over the friendliest network there is. Loopback does not reorder, rarely drops, and its segmentation is not a LAN's. A green run means this host is correct here.
⚠ CT-T06 is not automated and is not pretended. It needs a client on a second machine, and
it is the one case that can tell a listener bound to 0.0.0.0 from one bound to loopback —
which is the difference between a launch monitor working and not (design §6.1). Run
tools/gsp_listen.py --host 0.0.0.0 (or gsplisten --host 0.0.0.0, which needs no Python at
the far end) and aim tools/gsp_shoot.py at it from another machine. tests/test_coverage.py
carries it as the single deferred id, with that reason, and both transport suites print the
instructions at the end of a run rather than letting a green result imply the row was covered.
| Case | Drive | Required | Because |
|---|---|---|---|
| CT-T01 | Connect, send CT-D01, read | one 200 within 500 ms | [MLM] 2 s with margin |
| CT-T02 | Send two shots back to back | two 200s, in order, each in its own segment where the platform allows | §9.8 |
| CT-T03 | Shot, then host changes club within 10 ms | 200 and 201 arrive as two writes; with write_spacing_us = 20 ms, at least 20 ms apart |
[PIT] dies on {200}{201} |
| CT-T04 | Send one byte per 10 ms | one 200 after the last | CT-F02 end to end |
| CT-T05 | Connect and send nothing for 5 min | connection still open | §9.6 |
| CT-T06 | Connect from a second machine | accepted (bind is not loopback) | design §6.1 |
| CT-T07 | Two simultaneous clients | both acknowledged independently | design §5.1 |
| CT-T08 | Client disconnects mid-message | CONNECTION_CLOSED, no event for the partial |
|
| CT-T09 | Bind while 921 is held by another process | host reports Error state with the reason; no crash |
design §6 |
| CT-T10 | Host reply size | every write ≤ GSP_WRITE_MAX, one write() call each |
design §5.5 |
⚠ CT-T10's "one write() call each" is asserted on the sending side, by counting syscalls.
It cannot be seen from the client: TCP may merge two writes into one segment and split one into
two, so a client counting reads is measuring the kernel. The Python run wraps the asyncio
writer; the C run compares gsp_net_stats.sends (syscalls) with .writes (requests) and
requires them equal, including for the {201}{202} pair a new connection is owed while a
session is active — the case where two requests really are queued at once.
⚠ CT-T05's window is five seconds by default, not five minutes. The row is right and the
suite runs a shortened form so that CI is not held for five minutes per platform; GSP_SOAK=1
runs the full duration, and the case prints which it did rather than leaving the reader to
assume. It also checks the structural reason silence is safe — with no idle alarm armed the
server schedules nothing at all, so there is no timer that could fire — which the short run
covers completely.
⚠ CT-T02's "own segment" is reported, not asserted. Nothing can make TCP coalescing
impossible (design §5.5); a case that demanded two segments would be testing the kernel's
scheduler. CT-T03 asserts the thing that can be made true: with write_spacing_us set, the
two replies are held apart in time.
The byte-level record of design §7, and the reason it exists: protocol.md §11 lists ten open
questions, and when U2, U4 or U7 is answered a byte-level capture of the session that
answered it re-decodes with the fix applied. A decoded log has already thrown away what the fix
would have read differently. The first capture against a real launch monitor is the fixture
that pins the decoder, which makes these rows preconditions for design §11's package 7.
⚠ The wire log is OFF unless config.wire_ring is set, and that is the default a case has
to state rather than assume: a library that recorded a household's traffic because somebody
turned on verbose logging would be a different kind of library (design §9.2).
| Case | Input | Required | Because |
|---|---|---|---|
| CT-W01 | wire_ring 0 (the default) |
poll_wire() returns 0 forever; no chunk is built and nothing is copied |
design §7 |
| CT-W02 | One shot, wire on | one CLIENT_TO_SERVER chunk holding the message's exact bytes, its connection, and the arrival time |
design §7 |
| CT-W03 | Two objects in one read | two chunks, one per object, in order — a chunk is a MESSAGE, not a read | §2 |
| CT-W04 | One object split across three reads | one chunk, whole, when the last byte arrives | design §3.2.1 |
| CT-W05 | An object longer than GSP_WIRE_CHUNK_MAX |
consecutive chunks, CONTINUES on every one but the last, concatenating to the original bytes |
design §7 |
| CT-W06 | Leading garbage, and an oversize object | the discarded bytes are recorded too | design §5.3 |
| CT-W07 | Any reply | one SERVER_TO_CLIENT chunk per write request, its exact bytes, recorded when the host polls it |
design §9.1 |
| CT-W08 | Connection opened and closed | META chunks carrying the event and, on close, the connection's final counters |
design §7 |
| CT-W09 | record_identifiers off (the default) |
the DeviceID value is overwritten in place, the same length so the JSON still parses and offsets hold, REDACTED set; the peer address is not in the META chunk |
design §9.2 |
| CT-W10 | record_identifiers on |
DeviceID and peer are recorded verbatim, REDACTED clear |
design §9.2 |
| CT-W11 | A ring smaller than the traffic | drop-oldest, dropped_wire() counts them, the next surviving chunk carries LOST, one WARN_WIRE_DROPPED per overflow run |
design §3.4 |
| CT-W12 | Any session | chunk sequence strictly increases, and a gap appears only where chunks were dropped |
design §7 |
⚠ A chunk is one message, one discarded run, or one reply — not one read(). Read
boundaries are the kernel's, not the client's (design §3.2.1), so recording them would preserve
an artefact and lose the thing that matters: DeviceID redaction (CT-W09) needs to know where
the value SITS, which is only knowable once an object has been framed.
⚠ What could not be decoded is recorded anyway (CT-W06). A capture taken to answer a
question about a client that this library failed on, which omitted the bytes it failed on,
would omit the whole subject. Redaction is applied to those bytes on a best-effort scan for the
DeviceID key, so a malformed object cannot leak what a well-formed one would not.
One file per row, hand-written from the source's serialisation code — not copied files. The
.json is the exact bytes a source emits; a .txt beside it says what the decode must yield.
| File | Emits | Case |
|---|---|---|
gsp_full.json |
[GSP] verbatim, indented, with the vendor's inconsistent spin | D01, D15 |
tl_minimal.json |
five ball keys, six club zeros | D02 |
mlm_backspin.json |
Backspin, carry 0, club always true, face-to-path in HFI |
D03 |
r10_apiversion.json |
APIVersion, all eight ball keys |
D04 |
r10_heartbeat.json |
ShotNumber 0, IsHeartBeat + ready + detected | K03, K05 |
osg_float_shot_number.json |
13.0, 15-decimal spins |
D05 |
tnb_indented.json |
Newtonsoft indented, nulls omitted | F06 |
osp_connect_heartbeat.json |
indented, IsHeartBeat true + ready | K05 |
osp_shot.json |
nulls omitted, club Speed only |
D10 |
ocr_putt.json |
axis 0, VLA 0, club Speed only |
D10 |
pit_shot.json |
indented, TotalSpin 0.0 placeholder, ten club zeros |
D13 |
pit_keepalive.json |
IsHeartBeat true, ready true | K03 |
of_shot.json |
compact rounded, club Speed+Path |
D11 |
of_heartbeat.json |
ShotNumber 0, detected false | K03 |
ob_shot.json |
ClubData: null, ClubName |
D07, D08 |
ob_heartbeat.json |
3 s cadence shape | K03 |
fb_shot_newline.json |
compact + \n, omitempty |
F04 |
fb_heartbeat_newline.json |
incrementing ShotNumber | C08 |
gg_shot_newline.json |
\n, partial club members |
F04, D11 |
gc2_shot.json |
serial in DeviceID, total and pair | D18, D14 |
sb_strings.json |
string-typed values, Apiversion |
D09 |
slx_zero_speed.json |
a "shot" with Speed 0 | D12 |
gsp_full_commented.json |
the vendor's example with its // comments, which is what somebody pastes |
X08 |
Provenance for each, and the byte-level details a re-generation would get wrong, are in
../tests/fixtures/README.md.
Written while building the suite, each covering a hazard the tables imply but do not name. They carry a letter suffix so the numbered rows keep their meaning.
| Case | What it pins | Because |
|---|---|---|
| CT-F10b | whitespace alone is pending, never garbage | [MLM]'s splitter allows spaces between objects |
| CT-D07b | a null BOOLEAN in ShotDataOptions is absence |
[R10] writes "IsHeartBeat": null |
| CT-D08b | an unknown key whose value is a nested object or array is skipped whole | a decoder that resumed inside it would read the nested members as top-level keys |
| CT-D21 | absent Units is UNKNOWN and not a finding |
[TL] sends no Units key at all |
| CT-D23b | missing required root fields are delivered and flagged, never refused | design §4.5 |
| CT-K03b | a heartbeat carrying full BallData/ClubData objects |
[PIT] and [OF] both do |
| CT-K05b | the server raises CLIENT_STATE from a heartbeat, and only on change |
[OSP] carries ready on a heartbeat; [FB] repeats it every 5 s |
| CT-K08b | ContainsClubData: true is not evidence the numbers are real |
[MLM] sets it on every shot |
| CT-X02b | the noise sweep through a server, which adds the framer's state | |
| CT-X08 | the vendor's own commented example is refused cleanly | it is not JSON, and it is the first thing anyone copies |
| CT-X09 | NULL and zero-length at every entry point | the floor beneath every other case |
| API-* | the vocabulary tables, the redaction sweep, the documented defaults | a club code that maps to its neighbour, or an event whose formatter forgot to redact, fails silently |
⚠ The redaction sweep (API_no_event_leaks_an_identifier_when_redacted) formats EVERY event
type with an identifier in every field that can hold one. design §9.2: a logging path that
only redacts the events a developer happened to hit is one that leaks during an incident.
Read as a list, because each one was true of at least one of the top four clients by user base:
- that a message ends at a newline, or that it does not ([FB] [GG] vs everyone else);
- that a read holds one message ([PIT] [TNB] [TL] believe it and are wrong the other way);
- that
TotalSpinbeing present means it is real ([PIT], the second-largest client); - that
ContainsClubDatameans the club numbers are real ([MLM]); - that a key's case matches the page ([MLM] [R10] [SB]);
- that an object is an object and not
null([OB]); - that heartbeats are absent, periodic, or carry a fixed
ShotNumber([FB] [OB] [OF] [R10]); - that the client will wait more than two seconds for a reply ([MLM]);
- that the client can parse two replies in one read ([PIT]);
- that a zero-speed shot is impossible ([SLX]);
- that the client cares about the
Messagetext ([OSG] proves nobody does, [OSP] proves one string matters).