| name | 120hz-input-loop |
| description | Guardrail for the dedicated 120Hz off-MainActor input thread and the onInputReady wiring between GameControllerManager and StreamingService.startStreamingV2(). Load before editing HighFrequencyInputController.swift, GameControllerManager.swift, StreamingService.swift, VirtualSteeringWheelService.swift, or any controller-input code. Severity medium — violating it regresses input latency or leaks the streaming session. |
| user-invocable | false |
120Hz Input Loop Wiring
Status: enforced | Severity: medium — input lag regression
Applies to: VisionRemotePS5/Controllers/HighFrequencyInputController.swift, VisionRemotePS5/Controllers/GameControllerManager.swift, VisionRemotePS5/Services/StreamingService.swift (the startStreamingV2() wiring point), VisionRemotePS5/Services/VirtualSteeringWheelService.swift
Evidence: TODO.md:21-27 (Phase 1.1 — Decouple Input Polling from Video Loop)
Reality
PS5 Remote Play accepts controller updates at high frequency. The project runs a dedicated 120Hz input thread (HighFrequencyInputController.swift) at QoS: .userInteractive, threadPriority: 1.0, off-MainActor. The thread fires an onInputReady callback at 120Hz; the callback funnels into ChiakiFullSession.setControllerState().
Critical wiring (TODO.md §1.1, fixed in v10.1):
GameControllerManager runs the timer at 120Hz.
StreamingService.startStreamingV2() connects onInputReady → ChiakiFullSession.setControllerState(...) with weak self capture.
- The legacy per-event methods (
pressButton(...), etc.) are kept for the on-screen virtual UI but the 120Hz timer is what actually pushes state to the PS5 in real-time.
Hallucination vector
An LLM will:
- Migrate
HighFrequencyInputController to @MainActor "for safety" — destroys deterministic 120Hz pacing.
- Replace the dedicated
Thread with DispatchQueue or Task.detached(priority: .userInitiated) — neither offers .userInteractive QoS + 120Hz wakeups reliably.
- Remove the legacy
pressButton(...) etc. methods as "duplicated by the 120Hz path" — those are still used by the on-screen overlay UI.
- Disconnect the
onInputReady callback in a refactor of startStreamingV2() — the original v10.0 bug.
- Capture
self strongly inside the closure assigned to onInputReady — leaks the streaming session for the lifetime of the input controller.
- Tie the input rate to the display refresh rate (e.g., reuse the
vision-pro-display-rate 90Hz constant) — these are independent. Input is fixed 120Hz regardless of display.
Hard rules
You MUST NEVER:
- Add
@MainActor to HighFrequencyInputController or any of its members.
- Replace the dedicated
Thread (with QoS: .userInteractive, threadPriority: 1.0) with any DispatchQueue or Task construct.
- Lower the input timer interval below
1.0/120.0 seconds (i.e., do not run faster than 120Hz — PS5 ignores excess updates and you waste CPU).
- Disconnect or remove the
onInputReady = { [weak self] in ... self.session.setControllerState(...) } wiring in StreamingService.startStreamingV2().
- Capture
self strongly inside any closure assigned to onInputReady, the timer block, or any haptic callback. Always [weak self].
- Delete the legacy
pressButton(...), releaseButton(...), etc. methods on GameControllerManager. They are called by the on-screen virtual controller overlay.
- Re-couple the input cadence to the display refresh rate, video callback, or audio render loop.
- Print to stdout from inside the 120Hz timer block (use
DebugLog with rate-limit, like the buffer-exhaustion debouncer pattern from the buffer-pool-recovery skill).
You MUST:
- Preserve the wiring contract:
GameControllerManager.onInputReady ↔ ChiakiFullSession.setControllerState(...).
- All shared mutable state read by the 120Hz block must be either
os_unfair_lock-guarded or @MainActor-isolated with explicit MainActor.run boundary (note: do not introduce the latter; existing code uses lock-guarded snapshots — match it).
- For new input sources (e.g., a third-party wheel), funnel through the same 120Hz timer; do NOT spin up a second high-rate thread.
Before/After
@MainActor
final class HighFrequencyInputController {
func start() {
Task { @MainActor in
while !Task.isCancelled {
onInputReady?()
try? await Task.sleep(nanoseconds: 8_333_333)
}
}
}
}
final class HighFrequencyInputController {
private var inputThread: Thread?
var onInputReady: (() -> Void)?
func start() {
let thread = Thread { [weak self] in
Thread.current.qualityOfService = .userInteractive
Thread.current.threadPriority = 1.0
while !Thread.current.isCancelled {
self?.onInputReady?()
Thread.sleep(forTimeInterval: 1.0 / 120.0)
}
}
inputThread = thread
thread.start()
}
}
gameControllerManager.onInputReady = { [weak self] in
guard let self, let session = self.chiakiSession else { return }
session.setControllerState(self.gameControllerManager.snapshotState())
}