feat(clients): host/network split in every stats HUD (stats phase 2, client side)
Consumes the 0xCF host-timing plane (449a67c) on all four GUI clients: each
keeps a bounded pending ring of receipt samples keyed by pts, matches the
host's per-AU capture→sent reports against it, and the HUD equation becomes
= host 3.1 + network 6.7 + decode 2.1 + display 2.3
falling back to the combined `= host+network …` term whenever no timing
matched the window (old host / datagram loss) — same total, one split
fewer, never a misleading zero. Apple additionally gains the split as the
only equation line under the stage-1 fallback presenter (receipt is
presenter-independent), a `nextHostTiming` wrapper with its own plane lock,
and a unit-tested `HostNetworkSplitter`; Android extends the JNI stats
array 16→18 doubles (0–15 unchanged); Windows/Linux thread the split
through `Stats` into the HUD and the headless/debug logs.
Docs updated: design/stats-unification.md Phase 2 → implemented (wire
format, fallback semantics), and the docs-site matrix's Sunshine "Host
processing latency" row is now a direct match (ours includes the paced
send; avg vs p50).
Verified here: linux client clippy -D warnings green on the live tree,
windows stub check + hand-verified diff, android cargo-ndk arm64 check
green, apple loopback test extended (needs the rebuilt xcframework + swift
test on the mac). On-glass: pending on all platforms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
// Unit tests for HostNetworkSplitter (the host/network split of the unified stats model's
|
||||
// host+network stage — design/stats-unification.md Phase 2): pts matching, the per-frame
|
||||
// tiling arithmetic (network = combined − host, floored at 0), drain/reset semantics, the
|
||||
// bounded pending ring, and the absurd-receipt clamp. All samples use explicit instants, so
|
||||
// the expectations are exact.
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class HostNetworkSplitterTests: XCTestCase {
|
||||
/// An arbitrary host-capture pts (ns) far from zero, like a real CLOCK_REALTIME stamp.
|
||||
private let basePts: UInt64 = 1_000_000_000_000
|
||||
|
||||
private func receipt(_ s: HostNetworkSplitter, pts: UInt64, combinedMs: Int64,
|
||||
offsetNs: Int64 = 0) {
|
||||
s.recordReceipt(
|
||||
ptsNs: pts, receivedNs: Int64(pts) + combinedMs * 1_000_000 - offsetNs,
|
||||
offsetNs: offsetNs)
|
||||
}
|
||||
|
||||
func testEmptyDrainIsNil() {
|
||||
XCTAssertNil(HostNetworkSplitter().drain())
|
||||
}
|
||||
|
||||
func testMatchSplitsCombinedIntoHostAndNetwork() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: 8) // capture→received 8 ms
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // host says 3 ms of it was its own
|
||||
guard let split = s.drain() else { return XCTFail("expected a matched sample") }
|
||||
XCTAssertEqual(split.count, 1)
|
||||
XCTAssertEqual(split.hostP50Ms, 3.0)
|
||||
XCTAssertEqual(split.networkP50Ms, 5.0, "the two terms tile the combined interval")
|
||||
XCTAssertNil(s.drain(), "drain resets the window")
|
||||
}
|
||||
|
||||
func testSkewOffsetAppliesToTheCombinedInterval() {
|
||||
let s = HostNetworkSplitter()
|
||||
// Client clock 2 ms behind the host: the raw difference alone would read 6 ms.
|
||||
receipt(s, pts: basePts, combinedMs: 8, offsetNs: 2_000_000)
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
|
||||
XCTAssertEqual(s.drain()?.networkP50Ms, 5.0)
|
||||
}
|
||||
|
||||
func testUnmatchedTimingIsSkipped() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: 8)
|
||||
// A timing for an AU we never received (FEC-dropped) must not fabricate a sample.
|
||||
s.noteHostTiming(ptsNs: basePts + 1, hostUs: 3_000)
|
||||
XCTAssertNil(s.drain())
|
||||
}
|
||||
|
||||
func testReceiptSurvivesADrainUntilItsTimingArrives() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: 8)
|
||||
XCTAssertNil(s.drain(), "no timing matched yet")
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // arrives one tick late — still matches
|
||||
XCTAssertEqual(s.drain()?.hostP50Ms, 3.0)
|
||||
}
|
||||
|
||||
func testEachReceiptMatchesOnce() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: 8)
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // duplicate 0xCF — no second sample
|
||||
XCTAssertEqual(s.drain()?.count, 1)
|
||||
}
|
||||
|
||||
func testNetworkFlooredAtZero() {
|
||||
let s = HostNetworkSplitter()
|
||||
// A slightly-off skew offset can make host_us exceed the combined interval.
|
||||
receipt(s, pts: basePts, combinedMs: 2)
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
|
||||
guard let split = s.drain() else { return XCTFail("expected a sample") }
|
||||
XCTAssertEqual(split.hostP50Ms, 3.0)
|
||||
XCTAssertEqual(split.networkP50Ms, 0.0)
|
||||
}
|
||||
|
||||
func testPendingRingDropsOldest() {
|
||||
let s = HostNetworkSplitter()
|
||||
for i in 0..<300 { // cap is 256 — the first receipts fall out
|
||||
receipt(s, pts: basePts + UInt64(i), combinedMs: 8)
|
||||
}
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // evicted — no match
|
||||
XCTAssertNil(s.drain())
|
||||
s.noteHostTiming(ptsNs: basePts + 299, hostUs: 3_000) // newest — still pending
|
||||
XCTAssertEqual(s.drain()?.count, 1)
|
||||
}
|
||||
|
||||
func testAbsurdReceiptsAreDropped() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: -1) // received before capture — clock step
|
||||
receipt(s, pts: basePts + 1, combinedMs: 20_000) // > 10 s — garbage pts/offset
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 1_000)
|
||||
s.noteHostTiming(ptsNs: basePts + 1, hostUs: 1_000)
|
||||
XCTAssertNil(s.drain())
|
||||
}
|
||||
|
||||
func testResetForgetsPendingReceipts() {
|
||||
let s = HostNetworkSplitter()
|
||||
receipt(s, pts: basePts, combinedMs: 8)
|
||||
s.reset()
|
||||
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
|
||||
XCTAssertNil(s.drain(), "a fresh session must not match a previous session's receipts")
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,18 @@ final class LoopbackIntegrationTests: XCTestCase {
|
||||
XCTAssertEqual(conn.resolvedBitrateKbps, 50_000)
|
||||
|
||||
// Pull 25 synthetic frames and byte-verify the documented pattern:
|
||||
// u32 LE frame index, then data[i] = (idx as u8) &+ (i as u8).
|
||||
// u32 LE frame index, then data[i] = (idx as u8) &+ (i as u8). Alongside, drain the
|
||||
// per-AU host-timing plane (0xCF) the way the app's stats tick does — the connector
|
||||
// ORs VIDEO_CAP_HOST_TIMING in unconditionally and the synthetic host stamps one
|
||||
// report per AU, so the pts correlation must hold end to end through the xcframework.
|
||||
var got = 0
|
||||
var lastIndex: UInt32 = 0
|
||||
var receivedPts = Set<UInt64>()
|
||||
var timings: [PunktfunkConnection.HostTiming] = []
|
||||
let deadline = Date().addingTimeInterval(30)
|
||||
while got < 25 {
|
||||
XCTAssertLessThan(Date(), deadline, "timed out after \(got) frames")
|
||||
while let t = try conn.nextHostTiming(timeoutMs: 0) { timings.append(t) }
|
||||
guard let au = try conn.nextAU(timeoutMs: 2000) else { continue }
|
||||
let idx = au.data.prefix(4).reversed().reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
for (i, byte) in au.data.enumerated().dropFirst(4) {
|
||||
@@ -41,10 +47,22 @@ final class LoopbackIntegrationTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
XCTAssertGreaterThan(au.ptsNs, 0)
|
||||
receivedPts.insert(au.ptsNs)
|
||||
lastIndex = idx
|
||||
got += 1
|
||||
}
|
||||
XCTAssertGreaterThanOrEqual(lastIndex, 24)
|
||||
// Belt-and-braces: the last frame's timing lands just after its AU — give it a bounded
|
||||
// grace drain (the stream keeps running, so this must not loop on fresh timings).
|
||||
var grace = 0
|
||||
while grace < 64, !timings.contains(where: { receivedPts.contains($0.ptsNs) }),
|
||||
let t = try conn.nextHostTiming(timeoutMs: 100) {
|
||||
timings.append(t)
|
||||
grace += 1
|
||||
}
|
||||
XCTAssertTrue(
|
||||
timings.contains { receivedPts.contains($0.ptsNs) },
|
||||
"no 0xCF host timing matched a received AU's pts (got \(timings.count) timings)")
|
||||
|
||||
// Input goes the other way (enqueue-only; the host logs the count on close) —
|
||||
// including the touch kinds, gamepad events, the rich-input plane (DualSense
|
||||
|
||||
Reference in New Issue
Block a user