From c887ab32fa70bd25d4d437bbe7e092a825de38f0 Mon Sep 17 00:00:00 2001 From: Dennis Brentjes Date: Sat, 10 Oct 2026 16:45:14 +0200 Subject: [PATCH] fsp-server: report capability separately from duty cycle The rate line was bytes/elapsed, which is not the capability of the path: the game reads in bursts, so any window containing idle time understates what the link can do. Dennis spotted this -- 593 KB/s observed while the per-round-trip arithmetic says ~700 -- and the fix belongs in the tool, not in the reader's head. Now reports, every two seconds: average bytes / elapsed, including idle (what the game happened to want) CAPABLE bytes / time ACTUALLY serving (what the path can do) duty fraction of the window streaming round trip median gap, split into total and this server's service time, plus the implied ceiling (reply_size / RTT) at that round trip Consecutive requests closer together than 10 ms count as streaming (the round trip is ~2 ms); longer gaps are the game not asking and are excluded from the capability figure. A synthetic burst model confirms the split: 1440 B every 2 ms in bursts with 200 ms idle reports 352 KB/s average but 703 KB/s capable at 50% duty, which is the distinction that matters when asking whether the adapter is the limit. Co-Authored-By: Claude Opus 5 --- scripts/fsp-server.py | 48 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/scripts/fsp-server.py b/scripts/fsp-server.py index 6ffd370..56d708b 100755 --- a/scripts/fsp-server.py +++ b/scripts/fsp-server.py @@ -311,6 +311,17 @@ def main(): uploads = {} # peer -> staged bytes, committed by CC_INSTALL service = [] # per-request service time, to split the round trip + # ⚠️ A plain bytes/elapsed average is NOT the capability of the path: the + # game reads in bursts, so any window that contains idle time understates + # what the link can do. Tracking the gap between consecutive requests + # separates the two -- gaps under IDLE_GAP are the round trip (we are + # streaming), longer ones are the game not asking. + IDLE_GAP = 0.010 + active_time = 0.0 # seconds actually spent streaming + active_bytes = 0 + idle_time = 0.0 + intervals = [] # inter-request gaps while active + t_prev = None served = 0 t_first = None t_report = time.time() @@ -510,6 +521,15 @@ def main(): reads += 1 now = time.time() service.append(now - t_req) + if t_prev is not None: + gap = now - t_prev + if gap <= IDLE_GAP: + active_time += gap + active_bytes += len(data) + intervals.append(gap) + else: + idle_time += gap + t_prev = now if t_first is None: t_first = now print(f"[{peer[0]}] GET_FILE {path!r} streaming started") @@ -520,16 +540,26 @@ def main(): el = max(now - t_first, 1e-9) svc = sorted(service) med = svc[len(svc) // 2] * 1000 if svc else 0.0 - rtt = (el / reads) * 1000 if reads else 0.0 - print(f" streaming {served/el/1024:7.0f} KB/s " - f"{served/2**20:8.1f} MiB in {el:6.1f}s " - f"{reads/el:5.0f} reads/s {len(data)} B/reply") - # One request in flight, so the observed interval IS the round - # trip. Splitting it shows whether WE are the slow part. - print(f" round trip {rtt:5.2f} ms, of which this server " - f"{med:5.2f} ms (median) -> " - f"{'SERVER-bound' if med > rtt/2 else 'console-bound'}") + iv = sorted(intervals) + rtt = iv[len(iv) // 2] * 1000 if iv else 0.0 + duty = 100 * active_time / el + cap = (active_bytes / active_time / 1024) if active_time else 0 + print(f" average {served/el/1024:7.0f} KB/s over {el:6.1f}s " + f"({served/2**20:7.1f} MiB, {len(data)} B/reply)") + # The capability figure: bytes served divided by the time + # actually spent serving, with idle gaps excluded. This is + # what the path can do; the average above is that times the + # duty cycle, i.e. how much the game happened to ask for. + print(f" CAPABLE {cap:7.0f} KB/s while streaming " + f"duty {duty:5.1f}% idle {idle_time:5.1f}s") + # One request in flight, so the median gap IS the round trip. + print(f" round trip {rtt:5.2f} ms median, of which this " + f"server {med:5.2f} ms -> " + f"{'SERVER-bound' if med > rtt / 2 else 'console-bound'}" + f"; ceiling at this RTT = " + f"{(len(data)/(rtt/1000))/1024 if rtt else 0:.0f} KB/s") service.clear() + intervals.clear() t_report = now continue